Merge gate: refuse autonomous merge on a current-head requested change (#304)#311
Conversation
…#304) Closes the last acceptance criterion of #304 — "a blocking review actually blocks" — on the path our PRs actually take. GitHub only enforces CHANGES_REQUESTED as a merge blocker when a branch-protection `pull_request` rule exists, and `mainprotect` carries none: a requested change is visible and the merge queue merges straight over it. Rather than change a GitHub-side ruleset (blast-radius-wide, governs every human merge, not revertible in a PR), the teeth go in our own gate — reviewable, testable, revertible. `check --mode autonomous` now also refuses when an independent reviewer has an outstanding CHANGES_REQUESTED on the CURRENT head. Two rules keep the gate CLEARABLE, which matters as much as making it strict: - SHA-pinned. A requested change against an older commit says nothing about this one. Counting it would leave a PR blocked forever after it was fixed and re-reviewed — and an unclearable gate is one workers learn to route around, which is the exact failure #304 exists to end. - Latest verdict per reviewer wins, so a reviewer who requests changes and then approves the same head has cleared it. COMMENTED and DISMISSED carry no verdict and neither block nor clear. The PR author's own reviews never count: author == reviewer is the collapse this whole issue is about. If the author cannot be identified, the gate fails CLOSED. The PR is DERIVED from the head SHA rather than passed as a flag. An optional --pr would let a caller silently skip the check — a bypass, in a gate that exists because a gate you can route around is not a gate. A required --pr would break every existing autonomous caller. Deriving it needs no caller change and leaves no hole; an unresolvable PR fails closed. Verified against the live API on #308 as well as in unit tests: a current-head CHANGES_REQUESTED from a non-author blocks, while the same review on a stale SHA and the author's own review both do not. Refs #304 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
Independent review (codex) confirmed the stale-SHA logic is correct, and found
five ways the gate could wave a merge through. Every one is the same shape as the
bug it exists to fix: an ambiguity treated as "fine".
HIGH — PR resolution could pick the WRONG pull request. `commits/{sha}/pulls`
returns every PR associated with a commit, merged and closed ones included, so
taking the first exact-head match could select a stale PR and mask a
CHANGES_REQUESTED on the one actually entering the merge queue — a direct bypass.
Now: only OPEN PRs whose head is this SHA, and exactly one, or fail closed.
- A non-array reviews response (null, an error body, a truncated read) meant "no
reviews" and certified the merge. It means "we do not know". Fails closed; the
old test had locked the unsafe behavior in.
- An unknown PR author passed when the review list was empty. Without the author
we cannot tell a self-review from an independent one — author == reviewer is the
exact collapse this prevents — so it fails closed with zero reviews too.
- A current-head verdict whose author could not be read silently VANISHED rather
than blocking.
- Ordering used submitted_at, so a missing or malformed timestamp parsed to 0 and
a LATER requested change could lose to an EARLIER approval, silently unblocking
the merge. Now uses the API's documented response order with the review id as a
deterministic tie-breaker.
- `--paginate` without `--slurp` emits one JSON document per page, so the parse
threw past 100 reviews: fail-closed, but UNCLEARABLE — and an unclearable gate
gets routed around, which is the failure mode we are trying to end.
Re-verified live: #308 still parks on human-required (not on reviews), human mode
is unchanged, and a SHA with no open PR fails closed.
Refs #304
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
The independent reviewer caught that my previous commit's message claimed three fixes the committed code did not contain. It was right: a patch to fetchHeadPullRequestReviews silently failed to apply (prettier had reflowed the target), so only the call site changed — the file destructured `ambiguous` from a function that never returned it, still took the first matching PR of any state, and still paginated without --slurp. My own live check passed anyway, because #308 happens to have exactly one open PR. That is precisely the hole an independent reviewer reading the committed diff closes, and precisely why "it worked when I ran it" is not verification. Now applied, and the caller's logic is extracted into pure, tested seams — selectHeadPullRequest() and flattenReviewPages() — because the bug lived in the one part of this gate that had no tests while the evaluator's own tests were green: - Only OPEN pulls whose head is exactly this SHA count, and exactly one must remain; ambiguity fails closed instead of guessing which PR is real. - --slurp + flatten, so a PR with more than 100 reviews stays evaluable. - A non-array reviews body reaches the evaluator UNCHANGED (not `?? []`) so it fails closed on an unread response rather than certifying the merge. Refs #304 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
final-review — verdict: clean · PARKED for human mergeHead: Reviewer's verdict: "READY — no CRITICAL or HIGH issue remains that makes merging unsafe. The committed code was verified directly at the current PR head." ⛔ Parked deliberately, though policy would allow an autonomous mergeThis PR touches no sensitive path ( What the review caught — and why it matters here specificallyCycle 2 found that my commit message claimed three fixes the committed code did not contain. It was right: a patch to My own live verification passed anyway — because #308 happens to have exactly one open PR. That is exactly the hole an independent reviewer reading the committed diff closes, and exactly why "it worked when I ran it" is not verification. It is also a rather pointed demonstration of the lesson in this PR's own subject: the gate must consume an unambiguous, SHA-pinned signal, never an inferred one. Five fail-open holes closed (all found by review)Each one is the same shape as the bug the gate exists to fix — an ambiguity treated as "fine":
The caller's logic is now extracted into pure tested seams ( Both directions tested, per the constraintA gate that only fails closed is proven strict, not proven correct. Verified in unit tests and against the live GitHub API on #308's real review data:
|
#311 (commit e6b1ade) taught the autonomous merge gate to fetch a PR's GitHub review states and refuse the merge on a current-head CHANGES_REQUESTED. Per the operator-confirmed #313 harmony plan, the autonomous check returns to exactly: a clean, SHA-current final-review status plus no current-head merge-park signal. AO's review authority does not live in GitHub review states. Removed evaluateBlockingReviews + VERDICT_REVIEW_STATES + selectHeadPullRequest + flattenReviewPages from final-review-status-core.mjs, and fetchHeadPullRequestReviews + the autonomous blocking-reviews veto from final-review-status.mjs. evaluateAutonomousMergeStatuses (final-review-clean + no merge-park) is unchanged; human mode is untouched; the review-passed status mirror, merge-park semantics, and the ao-native reviews API all remain. Tests pin the restored contract: a CLI integration test asserts autonomous check passes on a clean final-review with a single commit-statuses gh call and never reads /reviews or /pulls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1BedAdnAVFqVQTBoVyyp5
Refs #304 — closes its last acceptance criterion ("a blocking review actually blocks") on the path our PRs actually take. Stacked conceptually on #308 but independent of it: this touches only
ops/, so it can land in either order.Why here and not in branch protection
mainprotectcarries nopull_requestrule, so GitHub does not enforceCHANGES_REQUESTEDas a merge blocker at all — a requested change is visible and the merge queue merges straight over it. That is the gap #308 could not close on its own.Fixing it by adding a branch-protection rule would be a GitHub-side config change: blast-radius-wide, it governs every human merge too, and it is not reviewable or revertible in a PR. Our PRs land through this gate, not the GitHub UI — so the teeth belong here, where the rule is reviewable, testable, and revertible.
The rule
check --mode autonomousnow also refuses when an independent reviewer has an outstandingCHANGES_REQUESTEDon the current head.Two rules keep the gate clearable, which matters as much as making it strict:
COMMENTEDandDISMISSEDcarry no verdict: they neither block nor clear.The PR author's own reviews never count —
author == revieweris the collapse this whole issue is about. If the author cannot be identified, the gate fails closed.The PR is derived from the SHA, not passed as a flag
Deliberate. An optional
--prwould let a caller silently skip the new check — a bypass, in a gate that exists precisely because a gate you can route around is not a gate. A required--prwould break every existing autonomous caller. Deriving it from the head SHA needs no caller change and leaves no hole; an unresolvable PR fails closed.Testing
Unit: 9 new tests covering both directions, because a gate that only fails closed is proven strict, not proven correct — blocks on a current-head requested change; does not block on a stale-SHA one, the author's own, a later approval of the same head, or
COMMENTED/DISMISSED.Also verified against the live GitHub API on #308's real review data: a current-head
CHANGES_REQUESTEDfrom a non-author blocks, while the same review on a stale SHA and the author's own both do not. Full ops suite green (364),format:checkgreen.Note
This is a merge-gate change, so it only ever makes the gate stricter. It still requires an independent review and a human merge like anything else — it grants no new authority.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RP5jQC1fNo5ak4oyogD3ay
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.