Twinned from https://github.com/cfsh/ninefold/issues/651 by tools/beads/import_github.py's reconcile pass.
Filed unclaimed from session:session-audit (#635/#638). ⚠️ This is not specific to my PR — it blocks every session's merges from 2026-08-06T13:28Z onward.
``` $ xvfb-run -a ./tools/pr/merge_pr.py 638 refuse: PR #638 reviewDecision=(none) ```
…on a PR that @aedanpope has APPROVED, is MERGEABLE/CLEAN, and has three CONVERGED Tier-0 verdicts.
tools/pr/merge_pr.py:424 gates on GitHub's computed reviewDecision:
```python if decision != "APPROVED": raise MergeRefused(f"PR #{number} reviewDecision={decision or '(none)'}") ```
The repo's ruleset "pull requests required" (id 19101787) was updated at 2026-08-06T13:28:13Z and now carries:
```json {"required_approving_review_count": 0, "dismiss_stale_reviews_on_push": false, "require_last_push_approval": false, "required_review_thread_resolution": true} ```
With required_approving_review_count: 0, GitHub reports reviewDecision as null once no review request is pending. It is REVIEW_REQUIRED while a request is outstanding, and it returns to null — not APPROVED — when the request is satisfied. There is no state in which an approval yields APPROVED, so the gate can never open.
The approval itself is fine and is not dismissed:
``` $ gh api repos/cfsh/ninefold/pulls/638/reviews aedanpope [APPROVED] 2026-08-06T14:01:51Z sha=a15aef2 ```
| time | event |
|---|---|
| 12:55:59Z | #603 merged by merge_pr.py ✅ |
| 13:20:31Z | #615 merged ✅ |
| 13:26:15Z | #600 merged ✅ |
| 13:26:21Z | #626 merged — the last one that worked ✅ |
| 13:28:13Z | ruleset updated ⚠️ |
| 14:01:51Z | #638 approved by @aedanpope |
| 14:29Z | merge_pr.py 638 → refuse: reviewDecision=(none) ❌ |
Every successful merge today predates the ruleset change by minutes. Nothing has merged since.
⚠️ I first suspected my own post-approval pushes had staled the approval, and that is wrong — worth recording so nobody else spends time on it. A poll digest taken while HEAD was exactly the approved sha a15aef2 already showed an empty reviewDecision (OPEN MERGEABLE/CLEAN, versus OPEN REVIEW_REQUIRED … on the tick before the approval). The gate would have refused at the approved sha too. dismiss_stale_reviews_on_push is false and the review is still listed — pushing after an approval is not the trigger.
A. Config, immediate — @aedanpope only. Set required_approving_review_count: 1 on the ruleset. That restores reviewDecision: APPROVED and unblocks every session now. It also matches what the repo already assumes: one human reviewer whose approval is the go-signal, which CLAUDE.md states as the rule.
B. Code, durable. merge_pr.py should not depend on a derived field that a repo-settings change can silently empty. CLAUDE.md already teaches exactly this lesson one level up — *"reviewDecision alone misses most feedback"* — and the same reasoning applies to the merge gate: read repos/{repo}/pulls/{n}/reviews and require a non-dismissed APPROVED from aedanpope, which is the fact the rule is actually about. ⚠️ Whoever does this must keep it fail-closed: an unreadable reviews list must refuse, not merge, and a CHANGES_REQUESTED newer than the approval must still block.
⚠️ Do B even if A happens. A restores the current behaviour and leaves the merge path resting on a field the repo owner can empty from a settings page with no error anywhere — the failure is a *refusal*, which is the safe direction, but it is silent, repo-wide, and took a timeline reconstruction to attribute.
merge_pr.py has no override flag, and hand-rolled gh pr merge is forbidden by CLAUDE.md precisely because the gate is what runs presubmit --all, the feedback-ledger audit and probe_gate.py. I have not bypassed it and am holding #638 rather than merging around the check.
None.
No comments.