prhub 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prhub/__init__.py +2 -0
- prhub/__main__.py +5 -0
- prhub/cli.py +426 -0
- prhub/commits.py +207 -0
- prhub/conditions.py +86 -0
- prhub/daemon.py +213 -0
- prhub/github.py +164 -0
- prhub/refs.py +94 -0
- prhub/store.py +166 -0
- prhub-0.1.0.dist-info/METADATA +250 -0
- prhub-0.1.0.dist-info/RECORD +14 -0
- prhub-0.1.0.dist-info/WHEEL +4 -0
- prhub-0.1.0.dist-info/entry_points.txt +2 -0
- prhub-0.1.0.dist-info/licenses/LICENSE +21 -0
prhub/__init__.py
ADDED
prhub/__main__.py
ADDED
prhub/cli.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""prhub CLI. Everything except `daemon` reads local files only and never calls GitHub."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from . import __version__, commits, store
|
|
14
|
+
from .conditions import CONDITIONS, evaluate, is_done, one_line
|
|
15
|
+
from .refs import RefResolver
|
|
16
|
+
|
|
17
|
+
EXIT_MET, EXIT_TIMEOUT, EXIT_IMPOSSIBLE, EXIT_NO_DAEMON = 0, 1, 3, 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def caller() -> str:
|
|
21
|
+
"""Who is waiting, recorded with each watch: PRHUB_CALLER, else a terminal-multiplexer pane id, else "cli"."""
|
|
22
|
+
for var in ("PRHUB_CALLER", "HERDR_PANE_ID", "TMUX_PANE"):
|
|
23
|
+
if os.environ.get(var):
|
|
24
|
+
return os.environ[var]
|
|
25
|
+
return "cli"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolver(cfg: dict) -> RefResolver:
|
|
29
|
+
from .refs import index_clones
|
|
30
|
+
cache = store.home() / "repos.json"
|
|
31
|
+
repos = store.read_json(cache)
|
|
32
|
+
if not repos or time.time() - cache.stat().st_mtime > 6 * 3600:
|
|
33
|
+
repos = index_clones(cfg["clone_roots"])
|
|
34
|
+
store.write_json(cache, repos)
|
|
35
|
+
return RefResolver(cfg.get("aliases") or {}, repos)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _record(ref):
|
|
39
|
+
rec = store.read_pr(ref)
|
|
40
|
+
return rec, (rec or {}).get("summary")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _commit_record(ref, fresh: bool):
|
|
44
|
+
"""The commit's record, asking the daemon for a poll (<= 90 s) when there is none yet or `fresh`."""
|
|
45
|
+
store.add_commit_watch(ref, by=caller())
|
|
46
|
+
rec = store.read_commit(ref)
|
|
47
|
+
if rec is None or fresh:
|
|
48
|
+
store.nudge()
|
|
49
|
+
start = time.time()
|
|
50
|
+
while time.time() < start + 90:
|
|
51
|
+
rec = store.read_commit(ref)
|
|
52
|
+
if rec and rec["fetched_at"] >= start - 1:
|
|
53
|
+
break
|
|
54
|
+
time.sleep(2)
|
|
55
|
+
return rec
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_state_commit(a, cfg) -> int:
|
|
59
|
+
ref = commits.resolve(a.pr, resolver(cfg))
|
|
60
|
+
rec = _commit_record(ref, a.fresh)
|
|
61
|
+
if rec is None:
|
|
62
|
+
print(f"{ref}: no data yet (is `prhub daemon` running? see `prhub status`)", file=sys.stderr)
|
|
63
|
+
return EXIT_NO_DAEMON
|
|
64
|
+
if rec.get("missing"):
|
|
65
|
+
print(f"{ref}: GitHub has no such commit (typo, or not pushed yet)", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
s = rec["summary"]
|
|
68
|
+
if a.json:
|
|
69
|
+
state, failed = commits.rollup(s, a.workflow)
|
|
70
|
+
print(json.dumps({"ref": rec["ref"], "fetched_at": rec["fetched_at"], "checks": state, "failing": failed, **s}))
|
|
71
|
+
else:
|
|
72
|
+
print(commits.one_line(rec["ref"], s, a.workflow) + f" ({int(time.time() - rec['fetched_at'])}s old)")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_wait_commit(a, cfg) -> int:
|
|
77
|
+
ref = commits.resolve(a.pr, resolver(cfg))
|
|
78
|
+
conds = [c.strip() for c in a.until.split(",")]
|
|
79
|
+
for c in conds:
|
|
80
|
+
if c not in commits.CONDITIONS:
|
|
81
|
+
print(f"unknown condition '{c}' for a commit (use: {', '.join(commits.CONDITIONS)})", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
if not store.daemon_healthy():
|
|
84
|
+
print("prhub daemon is not polling (see `prhub status`); start it with `prhub install-launchd` (macOS) or `prhub install-systemd` (Linux)",
|
|
85
|
+
file=sys.stderr)
|
|
86
|
+
return EXIT_NO_DAEMON
|
|
87
|
+
store.add_commit_watch(ref, by=caller())
|
|
88
|
+
store.nudge()
|
|
89
|
+
start = time.time()
|
|
90
|
+
baseline = None
|
|
91
|
+
last_line = None
|
|
92
|
+
next_touch = start + 1800
|
|
93
|
+
while time.time() < start + a.timeout:
|
|
94
|
+
rec = store.read_commit(ref)
|
|
95
|
+
if rec and rec["fetched_at"] >= start - 1:
|
|
96
|
+
if rec.get("missing"):
|
|
97
|
+
print(f"{ref}: GitHub has no such commit (typo, or not pushed yet)", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
s = rec["summary"]
|
|
100
|
+
baseline = baseline or s
|
|
101
|
+
line = commits.one_line(rec["ref"], s, a.workflow)
|
|
102
|
+
for c in conds:
|
|
103
|
+
met, dead = commits.evaluate(c, s, baseline, a.env, a.workflow)
|
|
104
|
+
if met:
|
|
105
|
+
print(f"{c}: {line}")
|
|
106
|
+
return EXIT_MET
|
|
107
|
+
if dead:
|
|
108
|
+
print(f"cannot reach {c} ({dead}): {line}")
|
|
109
|
+
return EXIT_IMPOSSIBLE
|
|
110
|
+
last_line = line
|
|
111
|
+
if time.time() > next_touch: # a long wait keeps its commit watch from expiring
|
|
112
|
+
store.add_commit_watch(ref)
|
|
113
|
+
next_touch = time.time() + 1800
|
|
114
|
+
if not store.daemon_healthy(max_age=max(300, 3 * store.daemon_state().get("interval", 60))):
|
|
115
|
+
print("prhub daemon stopped polling while waiting", file=sys.stderr)
|
|
116
|
+
return EXIT_NO_DAEMON
|
|
117
|
+
time.sleep(a.poll)
|
|
118
|
+
print("timeout: " + (last_line or f"{ref}: no fresh data"))
|
|
119
|
+
return EXIT_TIMEOUT
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_state(a, cfg) -> int:
|
|
123
|
+
if commits.is_commit_text(a.pr):
|
|
124
|
+
return cmd_state_commit(a, cfg)
|
|
125
|
+
ref = resolver(cfg).resolve(a.pr)
|
|
126
|
+
store.add_watch(ref)
|
|
127
|
+
rec, s = _record(ref)
|
|
128
|
+
if rec is None or a.fresh:
|
|
129
|
+
store.nudge()
|
|
130
|
+
deadline = time.time() + 90
|
|
131
|
+
start = time.time()
|
|
132
|
+
while time.time() < deadline:
|
|
133
|
+
rec, s = _record(ref)
|
|
134
|
+
if rec and rec["fetched_at"] >= start - 1:
|
|
135
|
+
break
|
|
136
|
+
time.sleep(2)
|
|
137
|
+
if rec is None:
|
|
138
|
+
print(f"{ref}: no data yet (is `prhub daemon` running? see `prhub status`)", file=sys.stderr)
|
|
139
|
+
return EXIT_NO_DAEMON
|
|
140
|
+
if a.json:
|
|
141
|
+
print(json.dumps({"ref": rec["ref"], "fetched_at": rec["fetched_at"], **s, "failing": rec.get("failing", [])}))
|
|
142
|
+
else:
|
|
143
|
+
age = int(time.time() - rec["fetched_at"])
|
|
144
|
+
print(one_line(rec["ref"], s, rec.get("failing")) + f" ({age}s old)")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_wait(a, cfg) -> int:
|
|
149
|
+
if commits.is_commit_text(a.pr):
|
|
150
|
+
return cmd_wait_commit(a, cfg)
|
|
151
|
+
ref = resolver(cfg).resolve(a.pr)
|
|
152
|
+
conds = [c.strip() for c in a.until.split(",")]
|
|
153
|
+
for c in conds:
|
|
154
|
+
if c not in CONDITIONS:
|
|
155
|
+
print(f"unknown condition '{c}' (use: {', '.join(CONDITIONS)})", file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
if not store.daemon_healthy():
|
|
158
|
+
print("prhub daemon is not polling (see `prhub status`); start it with `prhub install-launchd` (macOS) or `prhub install-systemd` (Linux)",
|
|
159
|
+
file=sys.stderr)
|
|
160
|
+
return EXIT_NO_DAEMON
|
|
161
|
+
store.add_watch(ref, by=caller())
|
|
162
|
+
store.nudge()
|
|
163
|
+
start = time.time()
|
|
164
|
+
deadline = start + a.timeout
|
|
165
|
+
merge_states = cfg["done"]["merge_states"]
|
|
166
|
+
baseline = None
|
|
167
|
+
last_line = None
|
|
168
|
+
while time.time() < deadline:
|
|
169
|
+
rec, s = _record(ref)
|
|
170
|
+
if rec and rec["fetched_at"] >= start - 1: # only judge data fetched after we started waiting
|
|
171
|
+
if baseline is None:
|
|
172
|
+
baseline = s
|
|
173
|
+
for c in conds:
|
|
174
|
+
met, impossible = evaluate(c, s, baseline, merge_states)
|
|
175
|
+
if met:
|
|
176
|
+
print(f"{c}: " + one_line(rec["ref"], s, rec.get("failing")))
|
|
177
|
+
return EXIT_MET
|
|
178
|
+
if impossible:
|
|
179
|
+
print(f"cannot reach {c}: " + one_line(rec["ref"], s, rec.get("failing")))
|
|
180
|
+
return EXIT_IMPOSSIBLE
|
|
181
|
+
last_line = one_line(rec["ref"], s, rec.get("failing"))
|
|
182
|
+
if not store.daemon_healthy(max_age=max(300, 3 * store.daemon_state().get("interval", 60))):
|
|
183
|
+
print("prhub daemon stopped polling while waiting", file=sys.stderr)
|
|
184
|
+
return EXIT_NO_DAEMON
|
|
185
|
+
time.sleep(a.poll)
|
|
186
|
+
print("timeout: " + (last_line or f"{ref}: no fresh data"))
|
|
187
|
+
return EXIT_TIMEOUT
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_watch(a, cfg) -> int:
|
|
191
|
+
r = resolver(cfg)
|
|
192
|
+
for p in a.pr:
|
|
193
|
+
ref = r.resolve(p)
|
|
194
|
+
store.add_watch(ref)
|
|
195
|
+
print(f"watching {ref}")
|
|
196
|
+
store.nudge()
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_unwatch(a, cfg) -> int:
|
|
201
|
+
r = resolver(cfg)
|
|
202
|
+
w = store.watches()
|
|
203
|
+
for p in a.pr:
|
|
204
|
+
w.pop(str(r.resolve(p)), None)
|
|
205
|
+
store.save_watches(w)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_list(a, cfg) -> int:
|
|
210
|
+
merge_states = cfg["done"]["merge_states"]
|
|
211
|
+
rows = []
|
|
212
|
+
for rec in store.all_prs():
|
|
213
|
+
s = rec.get("summary") or {}
|
|
214
|
+
if a.ready and not is_done(s, merge_states):
|
|
215
|
+
continue
|
|
216
|
+
if not a.all and s.get("state") != "OPEN":
|
|
217
|
+
continue
|
|
218
|
+
rows.append(rec)
|
|
219
|
+
if a.json:
|
|
220
|
+
print(json.dumps([{"ref": r["ref"], **r["summary"], "failing": r.get("failing", [])} for r in rows]))
|
|
221
|
+
else:
|
|
222
|
+
for r in rows:
|
|
223
|
+
print(one_line(r["ref"], r["summary"], r.get("failing")) + f" {r['summary'].get('title', '')[:60]}")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_status(a, cfg) -> int:
|
|
228
|
+
d = store.daemon_state()
|
|
229
|
+
healthy = store.daemon_healthy()
|
|
230
|
+
print(f"prhub {__version__} home {store.home()}")
|
|
231
|
+
if not d:
|
|
232
|
+
print("daemon: never ran")
|
|
233
|
+
return EXIT_NO_DAEMON
|
|
234
|
+
age = int(time.time() - d.get("last_poll", 0))
|
|
235
|
+
rate = d.get("rate") or {}
|
|
236
|
+
if until := store.rate_limited_until(d):
|
|
237
|
+
print(f"daemon: PAUSED by the GitHub rate limit until {time.strftime('%H:%M', time.localtime(until))} "
|
|
238
|
+
f"(pid {d.get('pid')}, alive). `prhub wait` keeps waiting and resumes by itself. Do not poll GitHub "
|
|
239
|
+
f"by hand meanwhile: your calls hit the same exhausted limit.")
|
|
240
|
+
print(f"daemon: {'healthy' if healthy else 'NOT polling'} pid {d.get('pid')} last poll {age}s ago "
|
|
241
|
+
f"interval {d.get('interval')}s tracked {d.get('tracked')} "
|
|
242
|
+
f"github budget {rate.get('remaining', '?')} left (reset {rate.get('resetAt', '?')})")
|
|
243
|
+
if d.get("last_error"):
|
|
244
|
+
print(f"last error: {d['last_error']}")
|
|
245
|
+
print(f"watches: {len(store.watches())} PR files: {len(store.all_prs())} "
|
|
246
|
+
f"commits watched: {len(store.commit_watches())}")
|
|
247
|
+
return 0 if healthy else EXIT_NO_DAEMON
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_daemon(a, cfg) -> int:
|
|
251
|
+
from .daemon import Daemon
|
|
252
|
+
Daemon().run(once=a.once)
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --- Claude Code PreToolUse hook: send agents to `prhub wait` instead of their own GitHub polling loops ---
|
|
257
|
+
|
|
258
|
+
# A command word starts the line or follows ; & | ( { or a shell keyword, so quoted mentions
|
|
259
|
+
# (a grep pattern, a heredoc of notes) do not count. An `rtk` / `rtk proxy` wrapper is seen through.
|
|
260
|
+
CMD = r"(?:^|[;&|({]|\b(?:then|do|while|until|if|xargs|time)\b|!)\s*(?:rtk\s+(?:proxy\s+)?)?"
|
|
261
|
+
# A loop only polls if it sleeps: `sleep` must be a command, so `time.sleep(…)` in a python -c, a `for`
|
|
262
|
+
# inside a script string or a file called watch.json do not count.
|
|
263
|
+
LOOP = re.compile(r"(?:^|[;&|({]|\bdo\b)\s*(while|until|for)\b[\s\S]*" + CMD + r"sleep\b|"
|
|
264
|
+
+ CMD + r"watch\s+-n\b", re.M)
|
|
265
|
+
GH_READ = re.compile(CMD + r"gh\s+(pr\s+(checks|view|status|list)|run\s+(view|list)|api)\b", re.M)
|
|
266
|
+
GH_WATCH = re.compile(CMD + r"(gh\s+(pr\s+checks\b[^\n|;&]*--watch|run\s+watch)\b|"
|
|
267
|
+
r"watch\b[^\n|;&]*\bgh\s+(pr|run|api)\b)", re.M)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def hook_verdict(command: str) -> str | None:
|
|
271
|
+
"""A reason to refuse `command`, or None to let it run."""
|
|
272
|
+
if "PRHUB_ALLOW=1" in command:
|
|
273
|
+
return None
|
|
274
|
+
if GH_WATCH.search(command) or (LOOP.search(command) and GH_READ.search(command)):
|
|
275
|
+
return ("Don't poll GitHub in a loop: every agent shares one rate limit. prhub already watches PRs "
|
|
276
|
+
"for you, at no API cost per agent:\n"
|
|
277
|
+
" prhub wait <pr> --until done|green|checks|merged|review|change [--timeout 3600]\n"
|
|
278
|
+
" prhub wait <owner/repo@sha> --until checks|green|deployed [--env production] [--workflow NAME]\n"
|
|
279
|
+
" prhub state <pr> (current state, instant)\n"
|
|
280
|
+
" prhub list --ready (your PRs ready to merge)\n"
|
|
281
|
+
"<pr> can be a URL, owner/repo#N, a nickname like 'backend #2814', or a number in this repo. "
|
|
282
|
+
"Run it in the background (or ScheduleWakeup) if you need to keep working. "
|
|
283
|
+
"One-off reads like `gh pr view` without a loop are fine.")
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_hook(a, cfg) -> int:
|
|
288
|
+
try:
|
|
289
|
+
data = json.load(sys.stdin)
|
|
290
|
+
except ValueError:
|
|
291
|
+
return 0
|
|
292
|
+
if data.get("tool_name") != "Bash" or not store.daemon_healthy(): # fail open if prhub isn't running
|
|
293
|
+
return 0
|
|
294
|
+
reason = hook_verdict((data.get("tool_input") or {}).get("command") or "")
|
|
295
|
+
if reason:
|
|
296
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
297
|
+
"permissionDecision": "deny",
|
|
298
|
+
"permissionDecisionReason": reason}}))
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
PLIST = """<?xml version="1.0" encoding="UTF-8"?>
|
|
303
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
304
|
+
<plist version="1.0"><dict>
|
|
305
|
+
<key>Label</key><string>dev.prhub.daemon</string>
|
|
306
|
+
<key>ProgramArguments</key><array><string>{exe}</string><string>daemon</string></array>
|
|
307
|
+
<key>RunAtLoad</key><true/>
|
|
308
|
+
<key>KeepAlive</key><true/>
|
|
309
|
+
<key>ProcessType</key><string>Background</string>
|
|
310
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>{path}</string></dict>
|
|
311
|
+
<key>StandardOutPath</key><string>{log}</string>
|
|
312
|
+
<key>StandardErrorPath</key><string>{log}</string>
|
|
313
|
+
</dict></plist>
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
UNIT = """[Unit]
|
|
317
|
+
Description=prhub: one GitHub poller for many coding agents
|
|
318
|
+
After=network-online.target
|
|
319
|
+
|
|
320
|
+
[Service]
|
|
321
|
+
ExecStart={exe} daemon
|
|
322
|
+
Restart=always
|
|
323
|
+
RestartSec=30
|
|
324
|
+
Environment=PATH={path}
|
|
325
|
+
Nice=10
|
|
326
|
+
|
|
327
|
+
[Install]
|
|
328
|
+
WantedBy=default.target
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _service_path(exe: str) -> str:
|
|
333
|
+
"""PATH for the service: wherever `gh` and prhub live, then the usual system directories."""
|
|
334
|
+
dirs = [os.path.dirname(shutil.which("gh") or ""), os.path.dirname(exe),
|
|
335
|
+
"/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
|
336
|
+
return ":".join(dict.fromkeys(d for d in dirs if d))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _exe() -> str:
|
|
340
|
+
return shutil.which("prhub") or os.path.abspath(sys.argv[0])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_install_launchd(a, cfg) -> int:
|
|
344
|
+
exe = _exe()
|
|
345
|
+
plist = os.path.expanduser("~/Library/LaunchAgents/dev.prhub.daemon.plist")
|
|
346
|
+
log = str(store.home() / "daemon.log")
|
|
347
|
+
os.makedirs(os.path.dirname(plist), exist_ok=True)
|
|
348
|
+
with open(plist, "w") as f:
|
|
349
|
+
f.write(PLIST.format(exe=exe, path=_service_path(exe), log=log))
|
|
350
|
+
uid = os.getuid()
|
|
351
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}/dev.prhub.daemon"], capture_output=True)
|
|
352
|
+
r = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", plist], capture_output=True, text=True)
|
|
353
|
+
print(f"installed {plist} (log {log})" if r.returncode == 0 else f"launchctl: {r.stderr.strip()}")
|
|
354
|
+
return r.returncode
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_install_systemd(a, cfg) -> int:
|
|
358
|
+
exe = _exe()
|
|
359
|
+
unit = os.path.expanduser("~/.config/systemd/user/prhub.service")
|
|
360
|
+
os.makedirs(os.path.dirname(unit), exist_ok=True)
|
|
361
|
+
with open(unit, "w") as f:
|
|
362
|
+
f.write(UNIT.format(exe=exe, path=_service_path(exe)))
|
|
363
|
+
for cmd in (["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", "prhub.service"]):
|
|
364
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
365
|
+
if r.returncode:
|
|
366
|
+
print(f"{' '.join(cmd)}: {r.stderr.strip()}", file=sys.stderr)
|
|
367
|
+
return r.returncode
|
|
368
|
+
print(f"installed {unit}; logs: journalctl --user -u prhub. To keep it running while you are logged out: "
|
|
369
|
+
"loginctl enable-linger $USER")
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
374
|
+
p = argparse.ArgumentParser(prog="prhub", description="One GitHub poller for many coding agents.")
|
|
375
|
+
p.add_argument("--version", action="version", version=f"prhub {__version__}")
|
|
376
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
377
|
+
target = "a PR (URL, owner/repo#N, 'nick #N', N) or a commit (owner/repo@sha, 'nick@sha', @sha, commit URL)"
|
|
378
|
+
|
|
379
|
+
s = sub.add_parser("state", help="current state of a PR or commit (local, instant)")
|
|
380
|
+
s.add_argument("pr", help=target)
|
|
381
|
+
s.add_argument("--json", action="store_true")
|
|
382
|
+
s.add_argument("--fresh", action="store_true", help="ask the daemon for a poll first (<= 90 s)")
|
|
383
|
+
s.add_argument("--workflow", help="commits: only runs/statuses whose name contains this")
|
|
384
|
+
|
|
385
|
+
w = sub.add_parser("wait", help="block until a PR or commit reaches a condition")
|
|
386
|
+
w.add_argument("pr", help=target)
|
|
387
|
+
w.add_argument("--until", required=True, help="PR: " + ", ".join(CONDITIONS) + "; commit: "
|
|
388
|
+
+ ", ".join(commits.CONDITIONS) + " (comma = any)")
|
|
389
|
+
w.add_argument("--env", default="production", help="commits, deployed: environment name, or 'any'")
|
|
390
|
+
w.add_argument("--workflow", help="commits, checks/green: only runs/statuses whose name contains this")
|
|
391
|
+
w.add_argument("--timeout", type=int, default=3600, help="seconds (default 3600)")
|
|
392
|
+
w.add_argument("--poll", type=float, default=3.0, help="seconds between local reads (default 3)")
|
|
393
|
+
|
|
394
|
+
sw = sub.add_parser("watch", help="track PRs the discovery search does not find")
|
|
395
|
+
sw.add_argument("pr", nargs="+")
|
|
396
|
+
su = sub.add_parser("unwatch", help="stop tracking PRs")
|
|
397
|
+
su.add_argument("pr", nargs="+")
|
|
398
|
+
|
|
399
|
+
sl = sub.add_parser("list", help="tracked PRs")
|
|
400
|
+
sl.add_argument("--ready", action="store_true", help="only PRs that meet `done`")
|
|
401
|
+
sl.add_argument("--all", action="store_true", help="include merged/closed")
|
|
402
|
+
sl.add_argument("--json", action="store_true")
|
|
403
|
+
|
|
404
|
+
sub.add_parser("status", help="daemon health and GitHub budget")
|
|
405
|
+
sd = sub.add_parser("daemon", help="run the poller (usually under launchd or systemd)")
|
|
406
|
+
sd.add_argument("--once", action="store_true", help="poll once and exit")
|
|
407
|
+
sh = sub.add_parser("hook", help="agent hook that refuses GitHub polling loops")
|
|
408
|
+
sh.add_argument("agent", choices=["claude"])
|
|
409
|
+
sub.add_parser("install-launchd", help="run the daemon at login (macOS)")
|
|
410
|
+
sub.add_parser("install-systemd", help="run the daemon as a systemd user service (Linux)")
|
|
411
|
+
return p
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
HANDLERS = {"state": cmd_state, "wait": cmd_wait, "watch": cmd_watch, "unwatch": cmd_unwatch, "list": cmd_list,
|
|
415
|
+
"status": cmd_status, "daemon": cmd_daemon, "hook": cmd_hook,
|
|
416
|
+
"install-launchd": cmd_install_launchd, "install-systemd": cmd_install_systemd}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main(argv: list[str] | None = None) -> int:
|
|
420
|
+
a = build_parser().parse_args(argv)
|
|
421
|
+
cfg = store.load_config()
|
|
422
|
+
try:
|
|
423
|
+
return HANDLERS[a.cmd](a, cfg)
|
|
424
|
+
except ValueError as e:
|
|
425
|
+
print(f"prhub: {e}", file=sys.stderr)
|
|
426
|
+
return 2
|
prhub/commits.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Watch one commit's CI and deployments, in any repo: `owner/repo@sha`.
|
|
2
|
+
|
|
3
|
+
Generic on purpose. Workflow names, default branches (main, develop…) and deploy styles differ per repo, so:
|
|
4
|
+
- CI is every check suite that actually ran something on the commit, minus cron runs (a scheduled workflow attaches
|
|
5
|
+
to whatever the branch head is, so it says nothing about this commit) and minus apps that never start a run
|
|
6
|
+
(cursor, classroom, cloudflare… leave a suite QUEUED forever with zero runs).
|
|
7
|
+
- "deployed to <env>" comes from GitHub Deployments, which both deploy-on-merge and nightly-release repos record,
|
|
8
|
+
plus commit statuses named like `deployed/<env>`. A deploy of a LATER commit counts when that commit contains this
|
|
9
|
+
one (the daemon asks GitHub's compare API once per new deployment), because a cancelled or batched deploy means
|
|
10
|
+
your own commit may never be deployed by itself.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from .refs import RefResolver, remote_of
|
|
20
|
+
|
|
21
|
+
URL_RE = re.compile(r"github\.com/([\w.-]+)/([\w.-]+)/commit/([0-9a-fA-F]{7,40})")
|
|
22
|
+
FULL_RE = re.compile(r"^([\w.-]+)/([\w.-]+)@([0-9a-fA-F]{7,40})$")
|
|
23
|
+
NICK_RE = re.compile(r"^([\w .-]+?)\s*@\s*([0-9a-fA-F]{7,40})$")
|
|
24
|
+
HERE_RE = re.compile(r"^@([0-9a-fA-F]{7,40})$")
|
|
25
|
+
|
|
26
|
+
OK = {"SUCCESS", "SKIPPED", "NEUTRAL"}
|
|
27
|
+
CONDITIONS = ("checks", "green", "deployed", "change")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, order=True)
|
|
31
|
+
class CommitRef:
|
|
32
|
+
owner: str
|
|
33
|
+
repo: str
|
|
34
|
+
sha: str # as given (may be abbreviated); records also carry the full oid
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return f"{self.owner}/{self.repo}@{self.sha}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def slug(self) -> str:
|
|
41
|
+
return f"{self.owner}__{self.repo}__{self.sha}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_commit_text(text: str) -> bool:
|
|
45
|
+
t = text.strip()
|
|
46
|
+
return bool(URL_RE.search(t) or FULL_RE.match(t) or NICK_RE.match(t) or HERE_RE.match(t))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve(text: str, resolver: RefResolver | None = None, cwd: str | None = None) -> CommitRef:
|
|
50
|
+
t = text.strip()
|
|
51
|
+
if m := URL_RE.search(t):
|
|
52
|
+
return CommitRef(m.group(1), m.group(2), m.group(3).lower())
|
|
53
|
+
if m := FULL_RE.match(t):
|
|
54
|
+
return CommitRef(m.group(1), m.group(2), m.group(3).lower())
|
|
55
|
+
if m := HERE_RE.match(t):
|
|
56
|
+
r = remote_of(cwd or os.getcwd())
|
|
57
|
+
if not r:
|
|
58
|
+
raise ValueError(f"'{t}' names a commit in this checkout, but the current directory is not a GitHub clone")
|
|
59
|
+
return CommitRef(r[0], r[1], m.group(1).lower())
|
|
60
|
+
if m := NICK_RE.match(t):
|
|
61
|
+
repo = (resolver or RefResolver())._repo_for(m.group(1))
|
|
62
|
+
if repo:
|
|
63
|
+
owner, name = repo.split("/", 1)
|
|
64
|
+
return CommitRef(owner, name, m.group(2).lower())
|
|
65
|
+
raise ValueError(f"unknown repo nickname '{m.group(1)}': add it to [aliases] in the prhub config")
|
|
66
|
+
raise ValueError(f"cannot read '{t}' as a commit (try owner/repo@sha or a commit URL)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
COMMIT_FIELDS = """
|
|
70
|
+
object(expression: %s) { ... on Commit { oid committedDate
|
|
71
|
+
status { contexts { context state targetUrl } }
|
|
72
|
+
checkSuites(first: 50) { nodes { app { slug } status conclusion checkRuns(first: 0) { totalCount }
|
|
73
|
+
workflowRun { event url workflow { name } } } } } }
|
|
74
|
+
deployments(last: 30) { nodes { commitOid environment createdAt latestStatus { state } } }
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def alias_query(refs: list[CommitRef]) -> str:
|
|
79
|
+
parts = [f"c{i}: repository(owner: {json.dumps(r.owner)}, name: {json.dumps(r.repo)}) {{"
|
|
80
|
+
+ COMMIT_FIELDS % json.dumps(r.sha) + "}" for i, r in enumerate(refs)]
|
|
81
|
+
return "query { rateLimit { cost remaining resetAt }\n" + "\n".join(parts) + "\n}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def summarize(repo_node: dict) -> dict | None:
|
|
85
|
+
"""Flatten one aliased repository node. None when the commit does not exist (typo, or not pushed yet)."""
|
|
86
|
+
obj = (repo_node or {}).get("object") or {}
|
|
87
|
+
if not obj.get("oid"):
|
|
88
|
+
return None
|
|
89
|
+
oid = obj["oid"]
|
|
90
|
+
runs = []
|
|
91
|
+
for s in ((obj.get("checkSuites") or {}).get("nodes") or []):
|
|
92
|
+
if not s or not ((s.get("checkRuns") or {}).get("totalCount") or 0):
|
|
93
|
+
continue # an app that never started a run on this commit
|
|
94
|
+
wr = s.get("workflowRun") or {}
|
|
95
|
+
if wr.get("event") == "schedule":
|
|
96
|
+
continue # cron runs land on the branch head; they are not about this commit
|
|
97
|
+
runs.append({"name": ((wr.get("workflow") or {}).get("name")) or (s.get("app") or {}).get("slug") or "?",
|
|
98
|
+
"event": wr.get("event"), "status": s.get("status"), "conclusion": s.get("conclusion"),
|
|
99
|
+
"url": wr.get("url")})
|
|
100
|
+
statuses = [{"name": c.get("context"), "state": c.get("state"), "url": c.get("targetUrl")}
|
|
101
|
+
for c in (((obj.get("status") or {}).get("contexts")) or []) if c]
|
|
102
|
+
envs: dict[str, dict] = {}
|
|
103
|
+
for d in ((repo_node.get("deployments") or {}).get("nodes") or []): # oldest first
|
|
104
|
+
if not d:
|
|
105
|
+
continue
|
|
106
|
+
st = (d.get("latestStatus") or {}).get("state") or "PENDING"
|
|
107
|
+
e = envs.setdefault(d.get("environment") or "?", {"this": None, "last_success": None})
|
|
108
|
+
if d.get("commitOid") == oid:
|
|
109
|
+
e["this"] = st
|
|
110
|
+
if st == "SUCCESS":
|
|
111
|
+
e["last_success"] = {"sha": d.get("commitOid"), "at": d.get("createdAt")}
|
|
112
|
+
return {"oid": oid, "committed": obj.get("committedDate"), "runs": runs, "statuses": statuses, "envs": envs}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def needs_containment(s: dict) -> list[tuple[str, str]]:
|
|
116
|
+
"""(env, deployed sha) pairs where a successful deploy of ANOTHER commit happened after this one was made,
|
|
117
|
+
so whether it contains this commit has to be asked."""
|
|
118
|
+
out = []
|
|
119
|
+
for env, e in (s.get("envs") or {}).items():
|
|
120
|
+
ls = e.get("last_success")
|
|
121
|
+
if ls and ls["sha"] != s["oid"] and (ls.get("at") or "") >= (s.get("committed") or ""):
|
|
122
|
+
out.append((env, ls["sha"]))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def rollup(s: dict, workflow: str | None = None) -> tuple[str | None, list[str]]:
|
|
127
|
+
"""(PENDING | SUCCESS | FAILURE | None when nothing ran yet, names of failed runs/statuses). `workflow` keeps only
|
|
128
|
+
runs and statuses whose name contains it (case-insensitive)."""
|
|
129
|
+
w = (workflow or "").lower()
|
|
130
|
+
runs = [r for r in s["runs"] if w in (r["name"] or "").lower()]
|
|
131
|
+
statuses = [x for x in s["statuses"] if w in (x["name"] or "").lower()]
|
|
132
|
+
if not runs and not statuses:
|
|
133
|
+
return None, []
|
|
134
|
+
# A run CANCELLED because a newer run of the same workflow replaced it (re-run, concurrency group) is not a
|
|
135
|
+
# failure when a sibling of that name passed; with no passing sibling it still is.
|
|
136
|
+
passed = {r["name"] for r in runs if r["status"] == "COMPLETED" and (r["conclusion"] or "") in OK}
|
|
137
|
+
failed = ([r["name"] for r in runs if r["status"] == "COMPLETED" and (r["conclusion"] or "") not in OK
|
|
138
|
+
and not (r["conclusion"] == "CANCELLED" and r["name"] in passed)]
|
|
139
|
+
+ [x["name"] for x in statuses if x["state"] in ("FAILURE", "ERROR")])
|
|
140
|
+
pending = (any(r["status"] != "COMPLETED" for r in runs)
|
|
141
|
+
or any(x["state"] in ("PENDING", "EXPECTED") for x in statuses))
|
|
142
|
+
return ("PENDING" if pending else "FAILURE" if failed else "SUCCESS"), failed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def deploy_state(s: dict, env: str) -> tuple[bool, str | None]:
|
|
146
|
+
"""(live in `env`, failure text or None). `env` "any" accepts every environment."""
|
|
147
|
+
names = [n for n in (s.get("envs") or {}) if env == "any" or n.lower() == env.lower()]
|
|
148
|
+
failure = None
|
|
149
|
+
for n in names:
|
|
150
|
+
e = s["envs"][n]
|
|
151
|
+
if e.get("this") == "SUCCESS" or e.get("live"):
|
|
152
|
+
return True, None
|
|
153
|
+
if e.get("this") in ("FAILURE", "ERROR"):
|
|
154
|
+
failure = f"the {n} deployment of this commit {e['this']}"
|
|
155
|
+
for x in s.get("statuses") or []: # e.g. a `deployed/production` commit status
|
|
156
|
+
name = (x["name"] or "").lower()
|
|
157
|
+
if "deploy" in name and (env == "any" or env.lower() in name):
|
|
158
|
+
if x["state"] == "SUCCESS":
|
|
159
|
+
return True, None
|
|
160
|
+
if x["state"] in ("FAILURE", "ERROR"):
|
|
161
|
+
failure = f"status {x['name']}={x['state']}"
|
|
162
|
+
return False, failure
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fingerprint(s: dict) -> tuple:
|
|
166
|
+
return (tuple((r["name"], r["status"], r["conclusion"]) for r in s["runs"]),
|
|
167
|
+
tuple((x["name"], x["state"]) for x in s["statuses"]),
|
|
168
|
+
tuple(sorted((k, v.get("this"), bool(v.get("live")), (v.get("last_success") or {}).get("sha"))
|
|
169
|
+
for k, v in (s.get("envs") or {}).items())))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def evaluate(cond: str, s: dict, baseline: dict | None, env: str = "production",
|
|
173
|
+
workflow: str | None = None) -> tuple[bool, str | None]:
|
|
174
|
+
"""(met, reason it can no longer be met or None)."""
|
|
175
|
+
if cond == "change":
|
|
176
|
+
return baseline is not None and fingerprint(s) != fingerprint(baseline), None
|
|
177
|
+
state, failed = rollup(s, workflow)
|
|
178
|
+
if cond == "checks":
|
|
179
|
+
return state in ("SUCCESS", "FAILURE"), None
|
|
180
|
+
if cond == "green":
|
|
181
|
+
return state == "SUCCESS", (f"failed: {', '.join(failed)}" if state == "FAILURE" else None)
|
|
182
|
+
if cond == "deployed":
|
|
183
|
+
live, failure = deploy_state(s, env)
|
|
184
|
+
return live, (None if live else failure)
|
|
185
|
+
raise ValueError(f"unknown condition '{cond}' for a commit (use: {', '.join(CONDITIONS)})")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def one_line(ref: str, s: dict, workflow: str | None = None) -> str:
|
|
189
|
+
state, failed = rollup(s, workflow)
|
|
190
|
+
bits = [f"{ref} ({s['oid'][:10]})", f"checks={state or 'none yet'}"]
|
|
191
|
+
shown = [r for r in s["runs"] if (workflow or "").lower() in (r["name"] or "").lower()]
|
|
192
|
+
if shown:
|
|
193
|
+
bits.append("runs: " + ", ".join(f"{r['name']}={r['conclusion'] or r['status']}" for r in shown[:8])
|
|
194
|
+
+ (f" (+{len(shown) - 8})" if len(shown) > 8 else ""))
|
|
195
|
+
if failed:
|
|
196
|
+
bits.append("failing: " + ", ".join(failed[:6]))
|
|
197
|
+
deps = []
|
|
198
|
+
for env, e in sorted((s.get("envs") or {}).items()):
|
|
199
|
+
if e.get("this") == "SUCCESS" or e.get("live"):
|
|
200
|
+
via = "" if e.get("this") == "SUCCESS" else f" via {(e.get('last_success') or {}).get('sha', '')[:8]}"
|
|
201
|
+
deps.append(f"{env}=live{via}")
|
|
202
|
+
elif e.get("this"):
|
|
203
|
+
deps.append(f"{env}={e['this']}")
|
|
204
|
+
elif e.get("live") is False:
|
|
205
|
+
deps.append(f"{env}=not yet")
|
|
206
|
+
bits.append("deploys: " + (", ".join(deps) if deps else "none recorded for this commit"))
|
|
207
|
+
return " ".join(bits)
|