Repository navigation
feat(pr-watch): event-driven GitHub PR watching through gh webhook forward - #6018
Conversation
…rward A new optional skill. A Claude Code mod spawns `pr_watch.py run`, which keeps one `gh webhook forward` per watched repo, filters pull_request / pull_request_review payloads by the watch config, and prints one PR_EVENT line per relevant event; the mod submits each line as a new turn. No polling, no Monitor 30-minute expiry. The runner records the forwarder hook ids it creates and deletes exactly those on restart and stop, never another forwarder's hook; it stops re-parented forwarders a killed runner left behind. State and the runner copy live under <workspace>/state/pr-watch/, outside the mod folder. The mod is opt-in (install-mod into a mods folder); no manifest field auto-loads it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the loader The skills typecheck globbed skills/**/*.ts and reached the mod, whose `claude-code` types exist only under `claude plugin test`; exclude that one folder, as the in-repo ag2space-inbox mod is kept out by living in packages/. pr_watch.py now resolves the workspace through workspace_default.resolve_workspace; the remaining __file__ walk locates the skill's mod sources and carries the pragma. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/pr-watch/scripts/pr_watch.pyLines 146-155 146
147 def manifest_config() -> dict:
148 try:
149 cfg = json.loads(MANIFEST.read_text()).get("config") or {}
! 150 except (OSError, ValueError, AttributeError):
! 151 return {}
152 return cfg if isinstance(cfg, dict) else {}
153
154
155 def setting_source(name: str, cli: Optional[str] = None, st: Optional[State] = None) -> Tuple[str, str]:Lines 202-210 202 return 0
203 print(json.dumps({k: dict(zip(("value", "from"), setting_source(k, st=st))) for k in SETTINGS}, indent=2))
204 return 0
205 if not key or value is None:
! 206 raise SystemExit("pr-watch: config set KEY VALUE")
207 setting_source(key)
208 st.ensure()
209 cfg = load_config(st)
210 settings = cfg.setdefault("settings", {})Lines 763-771 763 """
764
765
766 def _now() -> str:
! 767 return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
768
769
770 def _esc(value) -> str:
771 return html.escape("" if value is None else str(value), quote=True)Lines 774-783 774 def _link(url, label: str) -> str:
775 """An anchor for an http(s) url; anything else is the escaped label alone."""
776 try:
777 ok = isinstance(url, str) and urlparse(url).scheme in ("http", "https") and bool(urlparse(url).netloc)
! 778 except ValueError:
! 779 ok = False
780 return f'<a href="{_esc(url)}" rel="noopener noreferrer">{_esc(label)}</a>' if ok else _esc(label)
781
782
783 def tracker_order(rows: Iterable[dict]) -> List[dict]:Lines 823-832 823 r = subprocess.run([sys.executable, str(script)] + args, capture_output=True, text=True,
824 timeout=COMMONS_TIMEOUT_S)
825 except subprocess.TimeoutExpired:
826 raise PublishError(f"{_verb(args)} timed out after {COMMONS_TIMEOUT_S}s")
! 827 except OSError as exc:
! 828 raise PublishError(f"{_verb(args)}: {exc}")
829 if r.returncode != 0:
830 why = (r.stderr or r.stdout or "").strip().splitlines()
831 raise PublishError(f"{_verb(args)} exited {r.returncode}: {why[-1] if why else 'no output'}")
832 return r.stdoutLines 835-844 835 def _commons_json(script: Path, args: List[str]):
836 out = run_commons(script, args)
837 try:
838 return json.loads(out)
! 839 except ValueError:
! 840 raise PublishError(f"{_verb(args)} printed no JSON")
841
842
843 def publish_tracker(st: State, data: dict, room: str, title: str, script: Path) -> str:
844 """Write the whole tracker page into the room, creating it when it is missing."""Lines 846-854 846 kind = data.get("page_kind") if data.get("page_room") == room else None
847 if kind:
848 listed = _commons_json(script, ["--json", "pages", room])
849 if not isinstance(listed, list):
! 850 raise PublishError("pages printed no list")
851 if kind not in {p.get("kind") for p in listed if isinstance(p, dict)}:
852 kind = None
853 if not kind:
854 made = _commons_json(script, ["--json", "page-add", "--kind", "html", room, title])Lines 853-861 853 if not kind:
854 made = _commons_json(script, ["--json", "page-add", "--kind", "html", room, title])
855 kind = made.get("kind") if isinstance(made, dict) else None
856 if not isinstance(kind, str) or not PAGE_KIND_RE.match(kind):
! 857 raise PublishError(f"page-add returned no html page kind: {str(made)[:80]}")
858 data["page_kind"], data["page_room"] = kind, room
859 write_json(st.tracker, data)
860 got = _commons_json(script, ["--json", "--kind", kind, "read", room])
861 current = got.get("text") if isinstance(got, dict) else NoneLines 859-867 859 write_json(st.tracker, data)
860 got = _commons_json(script, ["--json", "--kind", kind, "read", room])
861 current = got.get("text") if isinstance(got, dict) else None
862 if not isinstance(current, str):
! 863 raise PublishError("read returned no page text")
864 if current != page:
865 run_commons(script, ["--kind", kind, "replace", "--", room, current, page])
866 return kindLines 875-883 875 raise SystemExit(f"pr-watch: not a GitHub login: {args.author!r}")
876 st.ensure()
877 data = read_json(st.tracker, {})
878 if not isinstance(data, dict):
! 879 raise SystemExit(f"pr-watch: {st.tracker} is not a JSON object")
880 rows = data.setdefault("rows", {})
881 key = f"{repo}#{number}"
882 row = rows.get(key) or {"repo": repo, "number": number, "link": f"https://github.com/{repo}/pull/{number}"}
883 for field in ("author", "state", "head", "verdict", "event", "link"): |
…res it The run-loop tests spawned the runner as a subprocess, which the coverage gate does not measure. Add in-process tests for forward_once (events, hook record and delete, 422 and unlistable-hooks back-off), cmd_run (SIGHUP reload, SIGTERM cleanup, missing extension, held lock) and the process, status and cleanup helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CI coverage run does not trace worker threads, so loop, _stderr, _record and the shutdown kill path went unmeasured. Call each directly with real assertions, and cover the remaining error branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…als of the owner's reviews A watch can set `reviewed` (`add --reviewed`): synchronize, reopened, ready_for_review and closed on any PR in the repo are forwarded when a `me` login has submitted a review there. The payload does not list reviewers, so the runner asks the reviews API once per PR (10 s timeout), caches a yes for its lifetime and a no for 10 minutes, and marks a PR at once when it sees the owner's own review submitted. An API failure forwards the event with `reviewed=unknown` rather than dropping it. classify() takes the lookup as an argument and stays pure. Author-rule lines now carry `reason=author`; an event matching both rules yields one line. A dismissed review by a `me` login is forwarded as `review_dismissed`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
$.prompt.submit only starts a turn once the session is idle. The mod now tracks whether a turn is running: idle, an event is submitted as before; mid-turn, it is held and attached as `context` to the main loop's next tool result, and whatever is still held at turn.complete is submitted as one prompt. The tool.call hook carries a .catch that denies only when it failed before running the tool. Pure helpers (messageFor, takeEvents, withContext) are exported and unit-tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…editing rules, costs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hook that throws is skipped, so clearing the flag after next(e) could leave it set and hold events until the next tool call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommons room Settings move into manifest.json `config` (PR_WATCH_PROMPT, PR_WATCH_TRACKER_ROOM, PR_WATCH_TRACKER_TITLE, PR_WATCH_COMMONS_SCRIPT), read CLI > env > manifest > config.json per skills/MANIFEST.md, with `config get|set` for the config.json layer. The prompt template now exists once: the runner resolves it and prints PR_WATCH_PROMPT_TEMPLATE <json> on start and on SIGHUP; the mod uses the last one it received and sends bare event lines before any arrives. New `track` records a PR's outcome in <state>/tracker.json and, when a tracker room is set, publishes a self-contained HTML page through the room-commons CLI (page-add once, then a whole-page replace). Publish failures keep the row, print one PR_WATCH_NOTE and exit 0. The runner never publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…review echo A repo that found a foreign forwarder hook slept the full 5-minute backoff even after `add`, `remove` or `config set` sent SIGHUP: the reload only started new repos. The runner now keeps a wake generation that the SIGHUP path (and shutdown) bumps; each loop waits on stop, its repo being dropped, or a wake, so a reload retries backed-off repos at once. The loop records the reason and retry time in <state>/backoff.json, and `status` prints "backing off: <reason>, retry at HH:MM". A pull_request_review submitted by one of the watch's `me` logins is the owner's own review echoing back; classify() now drops it, while ReviewedLookup.observe() still marks the PR reviewed from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mmons script Every column header of the tracker page is a button that sorts the rows by that column, a second click reversing it (aria-sort marks the direction). install-mod records the checkout it ran from, so the runner copy in the state dir resolves a relative PR_WATCH_COMMONS_SCRIPT there instead of under the state dir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approving at 44c11d7.
Read the skill end to end: pr_watch.py (config/settings, classify, ReviewedLookup, hook record/drop, the Runner loop and backoff, tracker render/publish, CLI), mod/hooks/register.ts, the mod tests, the manifest, SKILL.md and the CI/tsconfig changes.
What I checked and found sound:
- Nothing from GitHub can steer the session. Every value that reaches a
PR_EVENTline goes through_safe(charset-restricted, 80 chars); titles and bodies never leave the runner, andtest_untrusted_fields_cannot_break_the_linepins the forged-newline case. The prompt template comes only from the manifest or the owner's env/config. - Webhook deletion is bounded.
drop_recordeddeletes exactly the ids inhooks.json; a foreign forwarder hook is reported and backed off, never touched (test_a_hook_it_did_not_create_is_reported_and_never_deleted). Stale forwarder pids are killed only when the process command still sayswebhook … forward. - The mod follows the engine's rules:
$never leaves a hook, nonode:*imports, the gatingtool.callhook carries the.catchthat denies only whennextwas not called, subagent calls (e.agentId) and denied calls never carry events, andturn.completeclears the flag beforenext(e). - Opt-in by construction: the manifest declares no plugin/hooks, the repo copy's
paths.tsis empty, so nothing runs untilinstall-modplus the owner's own hot-reload step.
Verified locally in a scratch worktree at 44c11d7 (one commit past the head the description's evidence was taken at; it adds sortable tracker columns and the state copy's commons-script resolution, plus one test):
python3 tests/skills/pr-watch/pr_watch.test.py: 91 tests, OK.claude plugin validate skills/pr-watch/mod: passed;claude plugin test mod: 9 pass, 0 fail.bash scripts/review-checks.sh --diff: PASS;ruff checkon the script and test: clean.- CI at posting: 16 passing, 18 pending, failing: none.
One non-blocking note for a follow-up: Runner._record records any forwarder hook that appears within 30 s of starting gh webhook forward, and since before is empty by then, a hook another person creates on the same repo in that window would be recorded as ours and deleted on the next restart. Narrow unless two admins forward the same repo at the same moment, so fine to leave.
Agent-assisted review by Mars, Qingyun's Sutando seat, at the owner's request.
What changed, and why
A new optional skill,
pr-watch: event-driven GitHub PR watching for an agent. Watching PRs today means polling or aMonitorthat expires every 30 minutes. This skill uses GitHub'sgh webhook forward(thecli/gh-webhookextension) inside a Claude Code mod, so each relevant event reaches the session (a new turn when idle, or context on the next tool result mid-turn), with no polling and no expiry.skills/pr-watch/scripts/pr_watch.py: CLI (add,remove,list,status,install-mod,cleanup,config,track) and the runner (run) the mod spawns. The runner keeps onegh webhook forwardper watched repo, parses its stdout JSON stream, filters by the watch config, and prints onePR_EVENT …line per event. Standard library plusgh.skills/pr-watch/mod/: the mod. Onsession.startit spawns the runner, splits stdout into lines and drops repeats within 60 s. It respawns the runner after 30 s if it exits, except when the extension is missing or another runner holds the lock.$.prompt.submitonly starts a turn once the session is idle, so the mod tracksturn.start/turn.complete. Idle: the event is submitted as a prompt built from the template the runner sent (see Configuration). Turn running: the event is held and atool.callhook attaches it ascontextto the main loop's next tool result (never a subagent's, never a denied call); whatever is still held atturn.completeis submitted as one prompt. Thetool.callhook carries.catch(($, e, next) => next.called ? next(e) : { deny }). The mod never passes$to a helper and imports nonode:*module (either makes the engine refuse it); SKILL.md records both under "Editing the mod".pull_requestopened/synchronize/reopened/ready_for_review/closed(withmerged) on a watched author's PR (reason=author);review_requestedwhen the reviewer is one of the configured "me" logins, on any PR in the repo;pull_request_reviewsubmittedon a watched author's PR (reviewer + state), except a review by one of the "me" logins: that is the owner's own review echoing back, so it is dropped (it still marks the PR reviewed for the reviewed rule). Only logins, numbers, states and short shas reach the prompt. Titles and bodies never do.add --reviewed, needs a non-*--me):synchronize/reopened/ready_for_review/closedon any PR in the repo where a "me" login has submitted a review, whoever wrote it (reason=reviewed). The payload doesn't list reviewers, so the runner asksrepos/{repo}/pulls/{n}/reviewsonce (10 s timeout) and caches the answer per PR: a yes for the runner's lifetime, a no for 10 minutes; an owner review it sees submitted marks the PR at once. On an API failure the event is forwarded withreviewed=unknown, never dropped.classify()takes the lookup as an argument and stays pure. An event matching both rules yields onereason=authorline, with no lookup.pull_request_reviewdismissedwhere the dismissed review is by a "me" login, on any PR in the repo →review_dismissed reviewer=… author=… head=…. The forwarder already subscribes topull_request,pull_request_review(now pinned by a test).Configuration (no hardcoded values)
skills/pr-watch/manifest.jsonconfig, perskills/MANIFEST.md"Config-only manifests":PR_WATCH_PROMPT(the event prompt template),PR_WATCH_TRACKER_ROOM(default empty: no tracker),PR_WATCH_TRACKER_TITLE(defaultPR watch),PR_WATCH_COMMONS_SCRIPT(defaultskills/room-commons/scripts/room_commons.py, resolved against the repo root derived frompr_watch.py's location, never the cwd).setting(name, cli=None, st=None)with the documented precedence CLI > env > manifest ><state>/config.jsonsettings; an empty value at any layer counts as unset.pr_watch.py config get [KEY] | config set KEY VALUEwrites the config.json layer, signals the runner, and says so when env or a non-empty manifest default still wins.PR_WATCH_PROMPT_TEMPLATE <json string>on start and on every SIGHUP; the mod keeps the last one it received and dropped its own copy (and its$.fs.readof config.json). Before any template arrives, the mod sends the bare event lines.{track}in the template becomes a one-sentence "record the outcome withtrack" hint only when a tracker room is set.install-modalso copiesmanifest.jsonbeside the runner copy, so the copy reads the same defaults. No room ids, logins or repo names are in the skill's code, docs or tests (examples useacme/widgets,alice,!room:server).workspacesymlink,resolve_workspaceresolves to<clone>/workspace; SKILL.md says to pass--state-dirthere (or run the engine checkout's copy).Tracker: an HTML page in a configurable Commons room
pr_watch.py track --pr OWNER/REPO#N [--author A] [--state open|merged|closed] [--head SHA8] [--verdict approved|changes_requested|commented|none] [--event TEXT] [--link URL] [--room R] [--title T]upserts a row in<state>/tracker.jsonkeyedrepo#nwithupdated_at. With no room set it only records and says so in one line.render_tracker(rows, title, now)is pure: a self-contained HTML page with inline CSS for light and dark (prefers-color-scheme) and phone width (scrolling table), open PRs first then newest, columns PR (link) / author / state / head reviewed / verdict / last event / updated, and the note "Generated by pr-watch; edits here are overwritten." Every value is HTML-escaped; only http(s) URLs become links.sys.executableand a 60 s timeout. The CLI has no whole-page write for HTML pages, so the whole-page replace isreadthenreplace -- <room> <current text> <new page>:--json page-add --kind html <room> <title>, page kind (html-<id>) and room recorded intracker.json;--json pages <room>(is the page still listed?),--json --kind html-<id> read <room>,--kind html-<id> replace -- <room> <old> <new>;PR_WATCH_NOTE tracker publish failed: …and exits 0; the nexttrackrepublishes everything. A missing script path is noted and skipped. The runner never publishes and never needs room credentials; onlytrack, run by the agent, does.Limits handled in code:
HTTP 422 Hook already exists): the runner lists forwarder hooks (name: cli, urlwebhook-forwarder.github.com) before it starts. If one exists that it didn't create, it emits aPR_WATCH_NOTE, doesn't start, and retries every 5 minutes, or at once on the next SIGHUP (add,remove,config set): the reload bumps a wake generation that cuts every backed-off loop's wait short. The reason and retry time are kept inbackoff.json, andstatusprintsbacking off: foreign forwarder hook <id>, retry at HH:MM.state/pr-watch/hooks.json. It deletes exactly those ids on forwarder restart, on runner stop and at the next start, and never deletes another hook. A failed delete stays recorded; a 404 counts as gone.forwarders.json, and the next start stops any that are still running.install-modplaces, live under<workspace>/state/pr-watch/(workspace resolved viascripts/sutando-config.sh workspace).install-modrefuses a state dir inside the mod.$.process.spawnchild, so a reload or session end kills it and the nextsession.startrespawns it. A file lock keeps it to one runner per state dir.What to look at first
classify(),ReviewedLookup,drop_recorded()andRunner.forward_once()inpr_watch.py; thenmod/hooks/register.ts(theturn.start/turn.complete/tool.callhooks). For the config and tracker:setting_source(),prompt_template(),render_tracker(),publish_tracker()andcmd_track().Worst-case disruption to users, and mitigation
The skill is opt-in. The manifest declares no
claude_plugin,toolsorhooks, so nothing loads unless the owner runsinstall-modand turns on hot reload for the mods folder (or passes--plugin-dir). Without a config the mod stays idle. The repo copy'spaths.tsis empty, so even the in-repo mod stays idle. The destructive action, deleting webhooks, is limited to ids this skill recorded when it created them. Author-rule lines gained a trailingreason=author; a custom prompt template that matched the old line exactly would need a look. The mid-turn path adds text only to the main loop's tool results, and only while events are waiting. The one CI change adds aclaude plugin testline to the existing Mod harness job. Installs from an earlier head of this PR have a"prompt"key inconfig.json; it is no longer read (the template comes from the manifest, overridable byPR_WATCH_PROMPTin the env). The tracker is off until a room is set, and its failures never failtrack.What user behavior this proves / evidence
Feature, so there's no failing "before". Evidence at HEAD:
Coverage was not re-run locally for this update (no heavy local runs on the author's machine); PR CI's coverage gate measures it.
The Python suite covers:
labeled,dismissed) and a forged-newline field;ghon PATH;install-mod;reason=reviewed; none → dropped; lookup failure →reviewed=unknown; only the four actions; author+reviewed → onereason=authorline with no lookup), theReviewedLookupcache (one call per PR, the 10-minute no, the lifetime yes, failures not cached, an observed owner review marking the PR) and the dismissed rule (mine → forwarded; someone else's → not);--events=pull_request,pull_request_review, and thatforward_oncepasses the lookup to the filter;config set/get, and the "a higher layer still wins" message;PR_WATCH_PROMPT_TEMPLATEline: one JSON line on start and one per SIGHUP (in the realrunloop and in-process), the{track}hint only with a room, and an env override;updated_at; open-first then newest ordering; bad--pr/--state/--authorrefused with nothing written;render_trackerescaping (a login and an event containing<script>), ajavascript:link rendered as text, dark-mode CSS and viewport present; publishing against a fake room-commons script: the firsttrackrunspage-add,read,replaceand recordshtml-p1, the second runspages,read,replaceon the same page, a removed page and a new room each get a new page, a failingpage-addor a timeout keeps the row and exits 0 with onePR_WATCH_NOTE, the nexttrackrepublishes, a missing script is noted and skipped, and an unset room only records;statusprints it (andnot runningwithout a live runner), the record clears when the forwarder starts; a backed-off loop stays asleep without a wake and retries at once on one; shutdown wakes it; andcmd_runagainst a fakeghretries a backed-off repo within seconds of a SIGHUP once the foreign hook is gone;review_submittedis dropped whileobservestill marks the PR (with no reviews API call);runloop against a fakegh. That test checks that events become lines and that the hook the runner created is recorded and then deleted on SIGTERM. It also checks that a pre-existing forwarder hook is reported, never deleted, and never forwarded over.Positive controls: removing the per-repo filter in
drop_recordedfailstest_only_recorded_ids_are_deleted, and dropping the pre-existing-hook check failstest_a_hook_it_did_not_create_is_reported_and_never_deleted. Replacing_esc/_linkwith unescaped versions failstest_render_escapes_every_value_and_links_only_http, and makingRunner.wakea no-op failstest_cmd_run_retries_a_backed_off_repo_on_sighup.The mod's pure helpers (
takeLinesincluding the template line,parseTemplate,Dedupe,messageForwith a template and with none yet,takeEvents,withContext) are unit-tested underclaude plugin test; the hook wiring itself (turn.start/turn.complete/tool.callordering in a live session) is not exercised by a test here.This PR has no live round trip against a GitHub repo, and the tracker has not been published to a real room: the room-commons commands are exercised only against a fake script that mimics their argument and JSON shapes. The behavior it's built from (one forwarder per repo, a hook left behind after a kill, the stdout JSON stream) was observed on a working single-author reference setup, which this generalizes.
Feature/documentation consistency
skills/pr-watch/SKILL.mdis the skill's canonical doc: when to use it, setup, requirements, costs and limits. No existingdocs/page covers PR watching, sodocs/catalog.jsonis unchanged. N/A otherwise.🤖 Generated with Claude Code