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 ADDED
@@ -0,0 +1,2 @@
1
+ """prhub: one GitHub poller for many coding agents."""
2
+ __version__ = "0.1.0"
prhub/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)