Add a Stop hook catching a PR opened without a reviewer request - #1042
Conversation
Closes #1041. I opened PRs #1038 and #1040, wrote "Still owed: reviews requested on #1038 and #1040" into a status report, and moved on. Neither had a reviewer. The prose rule to always request one already existed, so the rule is what failed and the fix has to be a mechanism. What makes it invisible rather than merely careless: opening a PR auto-triggers the repo OWN review workflow but does not summon Copilot, so the PR shows review-shaped activity in its checks while nothing has read the diff. And with claude-review failing repo-wide on context size (#897), Copilot is often the only working reviewer, so an unrequested PR gets no review at all. Decidable from the transcript, so it is a hook per algorithmatize-checks: a PR was created or readied this session AND no reviewer request came after it Modelled on hooks/no-stale-pr-status.py -- same Stop-hook shape, scan, fail-open, and once-per-message sentinel. The draft carve-out is load-bearing. A draft deliberately defers review since it does not trigger the review bot (pr-on-claim), so a guard firing on drafts would be wrong on the commonest correct workflow here, get switched off, and take the real case with it. Ordering is covered both ways: drafting then readying re-arms the guard, readying then converting back to draft silences it. 14 tests, mutation-checked. One was vacuous on the first pass: every create-a-draft fixture exits early at `last_open < 0`, so the draft exemption branch was never reached and the tests passed with it deleted. Added the convert-back-to-draft case, which is the only one that reaches it. NOT ACTIVATED. Per README line 273 authoring a hook needs no permission but registering it in ~/.claude/settings.json waits for this to merge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Not ready to approve
The hook conflates events across PRs and contains several false-positive and false-negative matcher paths.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds a Stop hook intended to prevent ready pull requests from ending a session without a reviewer request.
Changes:
- Adds transcript-based PR and reviewer-request detection.
- Exempts draft pull requests.
- Adds hook registration metadata and 14 test cases.
File summaries
| File | Description |
|---|---|
hooks/no-unreviewed-pr.py |
Implements the Stop hook. |
hooks/test-no-unreviewed-pr.py |
Tests request, draft, and ordering behavior. |
hooks/hooks.json |
Registers the hook’s event metadata. |
Review details
Suppressed comments (5)
hooks/no-unreviewed-pr.py:112
- These comparisons are global rather than PR-specific. If PR A and PR B are opened and only A receives a later review request,
last_request > last_opencan allow the reply even though B remains unreviewed; likewise, drafting an unrelated PR can clear the obligation for a ready PR. Track outstanding review obligations by PR identity (or at minimum by unmatched open/request events) and require every ready PR to be discharged.
if last_request > last_open:
return 0
# The most recent action was drafting, which legitimately defers review.
if last_draft > last_open:
return 0
hooks/no-unreviewed-pr.py:52
- The CLI operation that converts a ready PR back to draft is
gh pr ready --undo, but this pattern does not recognize it. Consequently the documented draft-gating workflow still blocks at Stop; add the actual CLI form and a regression case using it.
RX_DRAFT = re.compile(r"\"?draft\"?\s*[:=]\s*true|--draft\b", re.I)
hooks/no-unreviewed-pr.py:115
- The sentinel key contains only the reply text, while the file lives in the process-wide temporary directory. A later session that happens to use the same common recap (for example, “Opened the PR.”) will find the old sentinel and silently skip the guard. Include a session-unique value such as
transcript_pathin the key.
key = hashlib.sha256(text.encode()).hexdigest()[:16]
sentinel = os.path.join(tempfile.gettempdir(), f".claude-unreviewed-pr-{key}")
hooks/no-unreviewed-pr.py:82
- Scanning every tool's serialized input for command substrings creates false opens: an
rg/search call forcreate_pull_request, anecho 'gh pr create ...', or a failed create attempt all setlast_open. This Stop hook will then block an ordinary reply even though no PR exists. Match harness tools by exact tool name and correlate CLI invocations with successful tool results rather than treating arbitrary input text as proof of creation.
blob = (b.get("name") or "") + " " + json.dumps(
b.get("input") or {})
# Draft is checked first: `gh pr create --draft` matches
# both patterns, and it is the draft that decides.
if RX_DRAFT.search(blob):
hooks/no-unreviewed-pr.py:135
- This user-facing block reason repeats the unsupported claim that
claude-reviewfails repo-wide due to context size. #1029 explicitly records successful runs on other branches and an unconfirmed cause, so the hook would present speculation as fact. Use a conditional, durable explanation instead.
"diff. In this repo `claude-review` currently fails repo-wide on "
"context size (ai-config#897), so Copilot is often the only "
"working reviewer and the PR gets no review at all.\n\n"
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Purely additive conflict in hooks/hooks.json: this branch appended no-unreviewed-pr.py while #1045 appended no-unfiled-finding.py and no-mistake-without-a-hook.py. Kept all three. Verified every registered script actually exists on disk, since a registration pointing at a missing file is the failure this particular merge could silently produce. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Not ready to approve
The hook can miss unreviewed PRs and misclassify valid draft or reviewer operations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (10)
hooks/no-unreviewed-pr.py:85
- The scanner applies these patterns to the serialized input of every tool, not just an actual Bash command or the matching GitHub tool. An Edit/Write call whose file content mentions
gh pr createwill therefore create a fake obligation, while content mentioningrequested_reviewerscan falsely discharge one. This is the exact documentation/heredoc false-positive shape warned about in README.md:265-271; gate CLI matches on the Bash command field and MCP matches on the exact tool name plus structured arguments.
blob = (b.get("name") or "") + " " + json.dumps(
b.get("input") or {})
# Draft is checked first: `gh pr create --draft` matches
# both patterns, and it is the draft that decides.
if RX_DRAFT.search(blob):
last_draft = i
elif RX_OPEN.search(blob):
last_open = i
hooks/no-unreviewed-pr.py:87
- A request attempt is treated as successful without inspecting its tool result. For example, the documented 422 response when the PR author is the requested reviewer still advances
last_request, so the hook permits the session to stop with no reviewer attached. Correlate each request with a successful tool result or a subsequent verification result before clearing the obligation.
if RX_REQUEST.search(blob):
last_request = i
hooks/no-unreviewed-pr.py:111
- These scalar timestamps lose obligations when a session handles multiple PRs. Opening A, opening B, then requesting review only for B makes
last_request > last_openand silently forgets A; similarly, opening ready A and then opening draft B makeslast_draft > last_opensilence A. Track obligations by PR identity and clear only the matching PR, then block while any non-draft PR remains unreviewed.
# A request after the open is exactly what discharges the obligation.
if last_request > last_open:
return 0
# The most recent action was drafting, which legitimately defers review.
if last_draft > last_open:
hooks/test-no-unreviewed-pr.py:96
- This fixture does not convert the ready PR back to draft:
DRAFT_TOOLis anothercreate_pull_requestcall, so it opens a second draft PR. The test therefore blesses the global-state bug where a later draft silences an earlier ready PR. Useupdate_pull_requestwithdraft: truefor the same PR, and add a separate two-PR case proving that a new draft does not clear the first PR's obligation.
([CREATE_CLI, DRAFT_TOOL, say("Held as a draft behind #1029.")], False,
hooks/no-unreviewed-pr.py:141
- Both commands are invalid when copied with these placeholders because Bash treats an unquoted
<as redirection, including inside the API path. Quote every placeholder-bearing argument so the recovery instructions can actually run.
" gh api repos/<owner>/<repo>/pulls/<N>/requested_reviewers \\\n"
" -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]' "
"\\\n --jq '.requested_reviewers[].login'\n\n"
"Then verify it landed:\n\n"
" gh pr view <N> --json reviewRequests "
hooks/no-unreviewed-pr.py:13
- The cited #897 does not establish this scope or cause: it calls prompt overflow only the leading hypothesis, says the literal error is unconfirmed, and reports 15 successful runs on six other branches. State the observed failures without presenting a repo-wide context-size diagnosis as fact.
That matters more in this repo than it would elsewhere, because
`claude-review` currently fails repo-wide on context size (ai-config#897).
When it is down, Copilot is the ONLY working reviewer, so a PR opened without
an explicit request gets no review at all.
hooks/no-unreviewed-pr.py:135
- This repeats a diagnosis that #897 explicitly leaves unconfirmed: context size is only the leading hypothesis, and the cited record includes successful runs on other branches. Keep the warning grounded in the observed review failures rather than asserting a repo-wide cause.
"diff. In this repo `claude-review` currently fails repo-wide on "
"context size (ai-config#897), so Copilot is often the only "
"working reviewer and the PR gets no review at all.\n\n"
hooks/hooks.json:102
- The manifest also hardens the unconfirmed #897 hypothesis into fact. #897 reports branch-specific failures alongside successful runs elsewhere and does not confirm context size as the cause, so this rationale should describe the observed failures without claiming a repo-wide diagnosis.
"why": "CLAUDE.md 'Open a PR for every pushed feature branch' + skills/ardi 'always request another review' -- opening a PR auto-triggers the repo's own review workflow but does NOT summon Copilot, and that auto-triggered half is what disguises the missing one. With claude-review failing repo-wide on context size (ai-config#897), an unrequested PR gets no review at all."
hooks/no-unreviewed-pr.py:44
- The request surfaces are inverted here: supported forms such as
gh pr create --reviewer ...and the repository's documentedupdate_pull_requestwith areviewersargument are not recognized, whilegh pr review --requestis not a valid GitHub CLI option. This causes false blocks after real requests and lets a failed nonexistent command look like a request. Detect the supported create/edit/MCP forms structurally and remove the nonexistent form.
RX_REQUEST = re.compile(
r"requested_reviewers|request_copilot_review|"
r"gh\s+pr\s+edit[^\n]*--add-reviewer|"
r"gh\s+pr\s+review[^\n]*--request",
hooks/no-unreviewed-pr.py:52
- The standard CLI operation for converting a ready PR back to draft is
gh pr ready --undo. It does not match this draft detector, so it is instead treated as an open action byRX_OPENand the advertised draft-gating carve-out behaves in exactly the wrong direction for CLI users. Classify this form as a draft action and add a regression case.
This issue also appears in the following locations of the same file:
- line 78
- line 86
- line 107
- line 137
RX_DRAFT = re.compile(r"\"?draft\"?\s*[:=]\s*true|--draft\b", re.I)
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Review 4839187904 flagged one inline comment plus nine suppressed. All ten were correct. 1-2. CLI text-matching read ANY tool's serialized input, not just Bash commands or the matching GitHub tool -- an Edit/Write call whose content mentioned `gh pr create` created a fake obligation, and content mentioning `requested_reviewers` could falsely discharge one. This is the exact heredoc/documentation false positive README.md:265-271 warns about, and it is self-demonstrating: these very hook files contain both strings in prose. Gated text-matching to shell tools only; structured GitHub tools are now matched on tool NAME with arguments read as fields. 3. Request attempts were trusted without inspecting their result. A documented 422 (PR author is the requested reviewer) still advanced the state, so the hook would let a session stop with no reviewer attached. Now correlates each request with its tool_result and only discharges on success. 4. Scalar timestamps lost obligations across multiple PRs: opening A then B and requesting only B silently forgot A. Restructured around a PR-identity map (keyed by number, parsed from API paths, CLI verbs, or structured fields) so each PR's obligation is tracked and cleared independently. 5. The test fixture for "convert back to draft" opened a SECOND draft PR instead of converting the first, so it blessed the very global-state bug the finding describes. Replaced with update_pull_request(draft=True) against the same PR number, plus a genuine two-PR case. 6. Recovery commands had unquoted placeholders; an unquoted `<` is a shell redirect. Quoted every placeholder-bearing argument, and added a test asserting it. 7-8. The docstring and the hooks.json rationale both hardened #897's "leading hypothesis, explicitly unconfirmed, 15 successful runs on six other branches" into a flat repo-wide diagnosis. Restated as the observed effect (claude-review has failed on every attempted review this session) without asserting the unconfirmed cause. 9. `gh pr review --request` is not a valid gh flag -- verified against `gh pr review --help`, which has only --approve/--comment/ --request-changes. Matching it let a FAILED command look like a successful request. Removed; kept only the verified request forms (`requested_reviewers`, `request_copilot_review`, `gh pr edit --add-reviewer`, `gh pr create --reviewer`). 10. `gh pr ready --undo` (verified against `gh pr ready --help`) converts a ready PR back to draft and was unmatched, so RX_OPEN's own `gh pr ready` alternative classified it as an OPEN action -- the advertised draft-gating carve-out running backwards. Matched as a draft action. Two things worth recording from fixing #10. First attempt added a negative-lookahead exclusion to RX_OPEN; the mutation that removed it did NOT fail, because RX_DRAFT is checked first via if/elif and its own `--undo` pattern already wins regardless -- so the exclusion was dead code, removed rather than kept. Second, the test asserting this was itself insufficient on the first pass (it checked "did not block", which several other states also satisfy); rewritten to re-derive the actual open_prs state directly, and confirmed by mutation that flipping elif to an independent if is caught. Tests 14 -> 20, all mutation-checked, including the four CLI-surface corrections verified against `gh --help` output rather than recollection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Not ready to approve
PR identity, request-result correlation, read-only API matching, and sentinel scope can produce both missed and spurious blocks.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (4)
hooks/no-unreviewed-pr.py:53
- A read-only
gh api repos/o/r/pulls/1038/requested_reviewersstill matches the first alternative. Because that GET succeeds, the nexttool_resultremoves the outstanding obligation even though no reviewer was requested. Match this endpoint only when the command is mutating (for example, POST/a reviewer field), and add the negative GET case that the prior review called for.
RX_REQUEST = re.compile(
r"requested_reviewers|request_copilot_review|"
r"gh\s+pr\s+edit[^|;&]*--add-reviewer|"
r"gh\s+pr\s+create[^|;&]*(?:--reviewer|-r\s)",
re.I,
hooks/no-unreviewed-pr.py:149
- Real
gh pr createcalls do not contain the new PR number; the number arrives in the command result (asrequest-pr-review/SKILL.md:33-34notes). This therefore records"*", while the subsequent reviewer request is keyed by the returned number, so a successfully reviewed PR still remains outstanding and the Stop hook blocks. The structured create tool has the same shape. Correlate the create result and replace the provisional key with the returned PR identity; the tests currently hide this by appending# pulls/1038to every create command.
if name in OPEN_TOOLS:
if not inp.get("draft"):
open_prs[pr_key(blob)] = True
continue
hooks/no-unreviewed-pr.py:138
- Every
tool_resultis applied to every pending reviewer request, without checking itstool_use_id. If a reviewer request is issued alongside another tool call, the unrelated result can arrive first; an unrelated success then clears the request, or an unrelated failure prevents a successful request from clearing it. Track pending requests by tool-use ID and consume only the matching result.
if kind == "tool_result":
# Only a SUCCESSFUL request discharges. A 422 still
# produces a tool_use, so trusting the attempt alone lets
# the session stop with no reviewer attached.
body = json.dumps(b.get("content") or "")
for _, key in pending:
if not RX_FAILED.search(body):
open_prs.pop(key, None)
if key == "*":
open_prs.clear()
pending = []
hooks/no-unreviewed-pr.py:200
- This sentinel is global to the machine's temporary directory because its key omits the transcript/session path. If a later session ends with the same text and PR label, the sentinel from the earlier session silently suppresses the guard. Include the transcript path in the hash, as
remind-ums-after-error.pydoes for per-session sentinel scope.
key = hashlib.sha256((text + which).encode()).hexdigest()[:16]
sentinel = os.path.join(tempfile.gettempdir(), f".claude-unreviewed-pr-{key}")
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…er than silently discharging it Review at 1e6d41c found the round-10 fix was only half-complete. It closed the update_pull_request half (identity known from the input pull_number), but a create+reviewers obligation (`gh pr create --reviewer`, `create_pull_request(reviewers=[...])`) has ob["num"] == None at append time -- a not-yet-created PR has no number -- so the early drop `if failed and rnum is None and ob["num"] is None: continue` fires whenever the reviewer step fails with a body that does not echo the number (an ordinary `{"status":422,...}`), BEFORE the `self` keep-logic runs. That silently discharges a genuinely-created, unreviewed PR -- the dangerous class every round has blocked on. Reproduced against scan() for both the structured tool and the shell form. The failure is ambiguous by nature: a numberless body cannot tell "the create failed, no PR" from "the PR was created and only the reviewer step failed". Fix: exempt `self` obligations from the early drop (`and not ob["self"]`), so they fall through to the `self` check and stay outstanding. The cost is a rare unclearable (num-None) obligation when the create truly failed -- a safe over-warn per shared/principles/fail-fast.md, chosen over a silent discharge, consistent with the fail-safe direction this file states throughout. Tests: +2 (structured and shell create+reviewers whose reviewer step fails with NO number in the body must stay tracked). 65 pass; removing the `not ob["self"]` guard fails exactly those two. Controls unchanged: a numberED failure still tracks, and a plain create (no reviewers) that fails numberless still drops (no PR was created). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015icfsSybE9vMSt8cUrXB6X
Code reviewRe-reviewed Round 10's finding is fixed. The known-identity numberless-failure drop condition now reads I also traced through the new draft-transition commits (scoping However, I found a new, reproducible silent-discharge bug -- filed as an inline comment on I traced this via careful static reading of the exact code paths (execution/testing tools were unavailable in this review sandbox), with precise line references and a worked example in the inline comment -- I'd encourage verifying it against VerdictNeeds more work. |
|
Claude finished review — View run Posted successfully. Summary of this review round: Findings posted:
Verified fixed: Round 10's finding (the known-identity numberless-failure drop condition) is correctly resolved in the current head, and the shipped 65-test suite passes. Verdict: Needs more work (posted to the PR, along with the inline finding). One caveat worth flagging to you directly: this sandbox had no working |
…s it update_pull_request(draft=False, reviewers=[...]) appended a self obligation (kept/discharged by the broad RX_FAILED) AND registered pending[tid] (discharged by the narrower RX_REQ_FAILED). The pending path ran after the obligations loop and _clear()'d the very obligation the broad check kept, so a reviewer-add failing with plain-language text and no 4xx shape (is_error unset) silently discharged a genuinely-unreviewed PR. Reserve the pending registration for a reviewers-only edit with no draft:false transition; the self-obligation path is sufficient and fail-safe for the combined case. +3 tests, mutation-checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015icfsSybE9vMSt8cUrXB6X
Code reviewRe-reviewed CLAUDE.md / lab-manual compliance: clean. Two independent passes checked hook-registration conventions ( Bugs — two new, confirmed, reproducible issues, both posted inline:
A third candidate ( Both confirmed bugs sit in code that predates this round's own commits (not introduced by the latest push) but were not flagged in any prior review round, and both are reproducible with ordinary, non-contrived shell usage rather than adversarial input. VerdictNeeds more work. |
|
Claude finished review — View run Review complete and posted to PR #1042. SummaryCLAUDE.md / lab-manual compliance: Clean — two independent passes found no violations (hook registration conventions, banned-punctuation rules, fail-fast documentation, one-function-per-file, algorithmatize-checks). Confirmed bugs (posted inline, both independently reproduced by execution against
A third candidate ( Verdict: Needs more work. |
…reviewed-pr Round 7 review found three parsing-layer bugs, all reproduced against scan(): 1. _simple_commands separator set was wrong both ways. A newline was dropped as shlex whitespace (not a separator), merging two commands so a failed request newline-joined with a succeeding command read as last=True and SILENTLY discharged (dangerous). And < / > were treated as separators, so a sole successful request with a trailing redirect (> /dev/null, 2>&1) split into two commands, never registered as last, and never discharged (permanent nag). Fix: _SHELL_OPS = control operators only (drop < >); convert unquoted newlines to ; separators (a quoted newline becomes a literal ; inside one token). 2. A bare (no number - the ordinary current-branch form) yielded a num=None obligation that no code path could ever clear, wedging the session. Fix: RX_RES_HASH recognizes gh's success line so result_ident backfills the number and the normal discharge path applies. 3. RX_FAILED/RX_REQ_FAILED's alternative was dead against the escaped-quote body json.dumps produces for string content; a sole 4xx failure with is_error unset could discharge. Fix: tolerate \?" (mirrors RX_RES_NUM). +7 tests, each mutation-checked (reverting any one fix fails exactly its cases); 75 pass, repo hook suite 9/9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015icfsSybE9vMSt8cUrXB6X
|
Claude finished review — View run Code review summary — #1042 (head
|
…e command Round 8 review found a dangerous silent-discharge in the OPEN identity path, reproduced against scan(): cmd_ident() searched the WHOLE raw command string for a PR number, so a decoy verb chained ahead of the open (`gh pr view 42 && gh pr ready`) mislabeled the obligation with the decoy's number. The bare `gh pr ready` genuinely readied #1038, but the obligation was tagged #42, so a later unrelated `requested_reviewers` POST for #42 discharged the real, unreviewed #1038 -- obligations == [], guard reports clean. Fix mirrors the request/draft paths: a structural open_ident() locates the `gh pr create`/`gh pr ready` simple command via _simple_commands() and takes identity from THAT command's own argv (None for create, whose number is in the result; the explicit number for `gh pr ready <N>`; None for a bare ready, then backfilled from its own owner/repo#N result). The whole-string cmd_ident and its RX_CMD_VERB/RX_CMD_REPO regexes, plus _scrub_payload/RX_PAYLOAD (used only by cmd_ident), are now dead and removed. The draft and request paths drop their `or num`/`or repo` cmd_ident fallbacks too -- those were the same decoy surface with no legitimate use (structural identity already covers every real case). +3 tests (the dangerous decoy stays tracked; a request for the real PR still discharges; an explicit `gh pr ready <N>` after a decoy takes its own number), mutation-checked -- matching decoy verbs as opens fails exactly those three. 78 pass, repo hook suite 9/9.
|
Round 8 finding ( Reproduced against Fix (your suggested direction — structural, mirroring Since Verification: the dangerous decoy stays tracked; a request for the actual readied PR (#1038) still discharges (proving identity resolved to #1038); and You're right this was a reachable dangerous-direction path none of rounds 1–7 exercised (the batched/id-correlation tests all used separate Generated by Claude Code |
Code reviewRe-reviewed Round 10's finding is fixed. The prior review's finding -- However, I found a new, real silent-discharge bug in the I verified two concrete, hand-traced reproductions, independently confirmed by two separate review passes: (1) a I also re-confirmed the rest of the file is solid: VerdictNeeds more work. |
|
Claude finished review — View run Code reviewRe-reviewed Round 10's finding is fixed. The prior review's finding -- However, I found a new, real silent-discharge bug in the I verified two concrete, hand-traced reproductions, independently confirmed by two separate review passes: (1) a I also re-confirmed the rest of the file is solid: VerdictNeeds more work. |
… path
Round 9 review found the fourth discharge path missing the fail-safe guard the
other three already have. A `self` obligation (an open whose same action also
requested a reviewer) discharged on `if ob["self"] and not failed` -- the coarse
whole-body flag, with no "last simple command" guard and no same-PR scoping.
Since the shell-path `self` is set from request_ident scanning the WHOLE command,
the matched request can be a NON-LAST command (its own failure masked by a
trailing success) or target a DIFFERENT PR than the one opened. Both reproduced
against scan():
* `gh pr create && gh pr edit --add-reviewer X; gh pr view` -- the add-reviewer
fails with GraphQL text matching none of RX_FAILED's alternatives, the
trailing view succeeds (is_error=False), so failed=False and the PR was
silently discharged with its reviewer-add having failed.
* `gh pr edit 999 --add-reviewer someone && gh pr create` -- the unrelated #999
request satisfied requested=True for the new #1040 obligation, so #1040
self-discharged on ordinary success though no reviewer was requested for it.
Fix mirrors the pending[tid] discharge: the obligation now carries the matched
request's ordering (slast) and target (srnum/srrepo), and the self discharge
fires only when the request was the last/atomic simple command AND names this PR
(srnum None -- the create's own reviewer or a current-branch edit -- or equal to
the resolved number). A structured create/edit is atomic (slast=True) and targets
its own PR, so it is unaffected; the round-6 plain-language failure still stays
tracked via the coarse `failed`.
+4 tests (non-last request; unrelated-PR request; the same-PR-guard-in-isolation
last-request-different-PR case; a same-PR last request that still discharges),
each guard mutation-checked independently. 82 pass, repo hook suite 9/9.
Code reviewRe-reviewed hooks/no-unreviewed-pr.py, hooks/test-no-unreviewed-pr.py, hooks/hooks.json, and README.md at head 220d2fe. The prior review's finding (self-discharge missing the last-command/same-PR guard) is fixed. Since git fetch/gh api were not available in this session, I reconstructed the full hooks/no-unreviewed-pr.py from
I also traced the new same_pr-in-isolation test ( I did not find a new regression in the cases I traced (chained ready+request targeting a different PR; a sole/unchained VerdictReady for merge. |
|
Claude finished review — View run Review posted successfully. SummaryReviewed PR #1042 at head Since
I also traced the additional edge cases covered by the round's new tests (same-PR-but-last, and the discharging control case) and confirmed the fix's invariant holds without introducing a new regression in the cases I checked (structured-tool paths, Verdict: Ready for merge. |
Review of #1104 found the round-number attributions in the fail-fast.md partial-guard case record ("nine-round review", "round 8 the open path", "round 9 the self path") do not match #1042's actual review history: the open_ident fix was that PR's round 13, the review ran past round 13, and the same file's existing #1042 discharge case record already says "~12 review rounds / rounds 8-10". The two numbering schemes (this session's count vs the PR's own comment numbering) are irreconcilable and add no value to the lesson. Describe the pattern by PATH instead (shell-command parser, then the open path, then the self discharge), dropping the round numbers in both the fail-fast.md addition and the memories/tools.md shlex citation. The lesson (guard all parallel sibling paths in one change) does not depend on the numbering.
Closes #1041.
What happened
I opened #1038 and #1040, wrote "Still owed: reviews requested on #1038 and #1040" into a status report, and moved on. Neither had a reviewer.
The prose rule to always request one already existed --- so the rule is what failed, and a stronger rule would fail the same way. This is the mechanism.
Why it is invisible, not just careless
Opening a PR auto-triggers the repo's own review workflow but does not summon Copilot. So review genuinely is in motion for that half, and the PR shows review-shaped activity in its checks while nothing has read the diff. The half that runs is what disguises the half that doesn't.
Compounding it:
claude-reviewcurrently fails repo-wide on context size (#897), so Copilot is often the only working reviewer. Measured --- #1038 and #1040 sat with zero reviews for ~10 minutes until the user asked, andclaude-reviewhad failed on every attempt across #1029, so neither would have been reviewed by anything.The check
Exactly decidable from the transcript, which is what makes it a hook rather than a rule to remember:
Modelled on
hooks/no-stale-pr-status.py: same Stop-hook shape, transcript scan, fail-open, once-per-message sentinel.The draft carve-out is load-bearing
A draft PR deliberately defers review --- a draft doesn't trigger the review bot (
pr-on-claim). A guard that fired on drafts would be wrong on the commonest correct workflow in this repo, get switched off, and take the real case with it.Ordering is covered both ways: drafting then readying re-arms the guard; readying then converting back to draft (the draft-gating in
CLAUDE.md) silences it --- but only when the conversion actually succeeds (a faileddraft:true/--undokeeps the PR tracked).Round 3: a rewrite of the identity + correlation model
Round-3 review (Copilot +
claude) reproduced four correctness bugs in the round-1 scalar/number-keyed model.scan()was rewritten around a per-obligation list keyed by PR identity resolved from tool_use results, fixing all four:gh pr createcommand carries no PR number --- the number only appears in its result --- so the old model keyed the open"*"and a later numbered request never cleared it. The open is now keyed from its own result (URL ornumberfield), correlated by tool_use id.tool_resultcorrelation. Results are now matched to their originatingtool_useby id, so an unrelated 4xx result batched with a real request can no longer misattribute failure to it.gh api .../requested_reviewers(a GET) no longer counts as a request; only a mutating POST or a--reviewer/--add-reviewer/request_copilot_reviewform does. (The short-rreviewer flag is now matched case-sensitively so it does not collide with-R, the repo flag.)The test fixtures were rewritten to mirror real transcripts --- every tool_use carries an
id, every result references it, and the create fixtures keep the PR number out of the command and in the result, the exact shape the old model got wrong.Rounds 4-6: quoted/embedded commands
This repo's own docs and this hook's own block-reason text are full of literal
gh pr create/requested_reviewers -X POSTexamples, so agh pr comment --body "...", a heredoc, a herestring, or a bareechoquoting one could forge an obligation or --- worse --- silently discharge a real, unreviewed one. Two matching surfaces, defended differently because they key on structurally different things:_scrub_all), sincegh pr create/readyis always a leading command word, never legitimately quoted.request_ident): the command is split into simple commands on shell operators, each tokenized withshlex, and the request tokens (requested_reviewers+-X POST,--add-reviewer,--reviewer) count only as the argv of an actualgh api/gh pr edit/gh pr createinvocation --- never the value of a string argument toecho/gh pr comment/a heredoc/herestring. Identity comes from the request command itself.\-continuations are joined first so the hook's own multi-line recovery command parses as one request. Parsing fails toward not-a-request, so it never silently discharges.Rounds 7-11: attributing a combined result blob
A single Bash
tool_result(or a combined structured call) is one opaque blob covering possibly-several actions, so a failure anywhere in it cannot be cleanly attributed to a specific one, and any state change made on a tool_use before its result is known can be wrong if that result fails. Rounds 7-11 (plus a follow-up sweep) converged on this --- every one a dangerous-direction (silent-discharge) or safe-direction (over-nag) bug reproduced againstscan():is_erroris the whole call's exit status, so a successful request followed by a failing command still nagged. Round 10 (dangerous): the round-9 fix droppedis_errorfor thegh apiform, so a sole request failing with a non-4xx error (network/5xx/auth) silently discharged.update_pull_request, whose PR number is known from the input --- so adraft=False+reviewers=[...]edit whose reviewer-add failed with a bare{"status":422}silently discharged a genuinely-ready PR.gh pr ready --undo/update_pull_request(draft=true)) cleared the obligation at tool_use time --- so a conversion that failed left the PR ready but forgotten. Now deferred to the transition's own non-failed result.Rounds 8-10's discharge patches were replaced by one fail-safe invariant. A discharge asserts the PR got a reviewer, so it fires only on positive evidence the request itself succeeded. The one reliable success signal is
is_error, authoritative only when the request is the last simple command (its exit status is the whole call's) or the call is a single atomic structured tool:is_errorbelongs to a later command) so it never discharges --- a deliberate over-warn, since over-warning is annoying but silently losing protection defeats the hook.The two state-change-on-tool_use paths were made result-gated the same way: the round-11 resolution drop is scoped to when the PR's identity is unknown from every source (
failed and rnum is None and ob["num"] is None), and every draft transition clears its PR only on a non-failed result.Verification
55 tests, mutation-checked --- reverting result-keying, id-correlation, the POST-only request check, the owner/repo identity, the draft ordering, the draft exemption, the transcript-scoped sentinel, the round-6 structural parse, the round-7 create-attribution fix, the round-11 identity-scoped resolution drop, the result-gated draft clear (both sites), or any of the three terms of the round-10 discharge invariant (the last-command guard, the ambiguity term, or
err) each fails exactly its own case.Not activated
Per
README.mdline 273: authoring a hook needs no permission, registering it in~/.claude/settings.jsonwaits for this to merge. I have not runinstall-hooks.py --fix.