Repository navigation
chore: stop final-review stranding or looping PRs; claim take-backs (#183) - #189
Conversation
…183) final-review applied `approved` before merging, ignored `first pass reviewed` PRs with a newer commit, and re-commented on a failed gate every tick. - final-review merges first and labels `approved` only after the merge succeeds. On a merge failure (unless the PR merged anyway) it posts one comment, sets `needs review`, and stops. - The gate is split into nothing-to-do (closed, draft, label gone), soft (pending CI, mergeability unknown: no comment, no label change), and hard (red CI, conflicts, an active request-changes review: one comment, then `changes requested`). A missing first-pass body is hard and goes to `needs review`. - find-final-review-candidate.sh paginates its events read, skips drafts, and defers PRs whose head check runs haven't completed (pending-checks.sh). - find-review-candidate.sh re-reviews a `first pass reviewed` PR whose head is strictly newer than the label. - New claim-review-candidate.sh, called from review-issue's prompt_cmd, re-applies `review in progress` on a take-back before the agent starts, so the take-back cap no longer depends on the agent running step 1.5. First picks are still labeled by the agent, so a Claude outage leaves them eligible. - The cap counts attempts only within a 6-hour window, so a PR capped during an outage re-arms on its own. - set-review-label.sh: --reapply replaces the `review in progress` special case. The target label is added before the others are removed, and the POST is retried once.
garretpremo
left a comment
There was a problem hiding this comment.
🤖 Automated review by claude — generated by the
review-issueskill. Treat as advisory; a human still owns the merge decision.
Every acceptance criterion from #183's triage, plus the maintainer's take-back addition, is met. CI is green at 6baf425, and the settled decisions (merge then label, the merge-failure path, the stale first pass reviewed rule, the preflight's pending and draft filters, the gate tiers, the take-back-only claim with its 6-hour window, POST before DELETE) are implemented as described. I also checked locally:
- The four affected test files pass (55 tests).
- Both yamls parse under claude-cron's
JobSchema. - Six spot-check mutations each fail at least one test: claim without
--reapply, claim on every pick, no POST retry, no attempt window, final side-ge, no draft filter.
Non-blocking observations
- Interactive take-over regression.
- Step 1.5 no longer refreshes
review in progress, becauseset-review-label.shwithout--reapplyleaves a present label alone. - So an interactive
/review-issueon a stranded PR is no longer protected: once the dead run's label is 30 minutes old, the cron takes the PR back, and two reviews run at once. - Under #187, step 1.5's re-apply both restarted the timer and reached the cap.
- This PR is a live example: a killed cron run labeled it at 21:05Z, and this review is an interactive stand-in.
find-review-candidate.sh:32-35still says the threshold protects interactive sessions.- Fix: in step 1.5, have interactive mode pass
--reapplywhen the PR already showsreview in progress.
- Step 1.5 no longer refreshes
- The final-review gate and the preflight disagree about checks.
- The gate reads
gh pr checks, which prints "no checks reported" and exits 1 when the head has zero check runs. That happens after a follow-up commit that touches only paths outside CI:ci.ymlande2e.ymlare path-filtered, and the auto-labeler only runs onopened. - The preflight treats that head as eligible, but the gate table doesn't say it passes. An agent that reads it as a failure now sends the PR to
changes requested, where before it only posted a comment. gh pr checksalso counts commit statuses as pending, andpending-checks.shdoesn't read those. A stuck status would end in a soft STOP every tick. This is latent: the repo posts no statuses.- Mirroring the cron-mode note in review-issue step 4, or using
pending-checks.shfor the pending check, would align the two.
- The gate reads
mergeStateStatusBLOCKEDis neither hard nor soft.- The gate passes, and the merge returns 405. The PR goes to
needs review, gets a full re-review, returns tofirst pass reviewed, and gets another 405. Each cycle costs a review run and three comments. - This is latent: the "dev and next" ruleset has enforcement disabled today.
- Adding
BLOCKEDto the hard row closes it without changing the merge-failure decision.
- The gate passes, and the merge returns 405. The PR goes to
- Nothing caps a re-review of a stale
first pass reviewedPR.- Suppose the agent skips step 1.5, or stops early because it sees a passing automated review. Then the verdict call
set-review-label.sh <pr> "first pass reviewed"changes nothing, the label time stays put, and the PR is re-selected every tick. A completed run ends up looping. changes requestedalready has the same mechanism.- Two ways to cover both labels: add a sentence to step 1.5 ("a cron pick may still show
first pass reviewedbecause a commit landed after the first pass; review the new head"), or pass--reapplyon the verdict calls.
- Suppose the agent skips step 1.5, or stops early because it sees a passing automated review. Then the verdict call
- Pre-existing, possible follow-up.
- final-review captures
HEAD_SHAafter the preflight's staleness check, and step 1 doesn't compare the head with the label again. - So a push in that gap whose head has no check runs (paths outside CI only) would pass the gate and be merged unreviewed.
- final-review captures
Nitpicks
final-review.yaml:14-16says every path ends in a state that "won't be re-selected unchanged", then lists the soft failure, which is re-selected unchanged by design.- The
pending-checks.shheader still names only the review cron and the review-issue skill as its consumers. The final-review preflight now uses it too. - Two test gaps:
- no final-side test that a
changes requestedPR isn't selected (only the label filter covers that); - no
set-review-label.shtest that a failed DELETE leaves both labels, which is the other half of the POST-first guarantee.
- no final-side test that a
claim-review-candidate.sh:27-29decides it's a take-back by re-reading the labels. That pair became possible once a swap's DELETE can fail, so a first pick showing bothneeds reviewand a leftoverreview in progressgets--reapply'd. It's harmless (it clears the extra label), but it contradicts the header's "a first pick is left to the agent".
garretpremo
left a comment
There was a problem hiding this comment.
🤖 Final review by claude — generated by the
final-reviewskill. CI is green, no blockers, soak window elapsed. Marking approved and merging.
Approved.
Outstanding observations from first-pass review
- Interactive take-over regression.
- Step 1.5 no longer refreshes
review in progress, becauseset-review-label.shwithout--reapplyleaves a present label alone. - So an interactive
/review-issueon a stranded PR is no longer protected: once the dead run's label is 30 minutes old, the cron takes the PR back, and two reviews run at once. - Under #187, step 1.5's re-apply both restarted the timer and reached the cap.
- This PR is a live example: a killed cron run labeled it at 21:05Z, and this review is an interactive stand-in.
find-review-candidate.sh:32-35still says the threshold protects interactive sessions.- Fix: in step 1.5, have interactive mode pass
--reapplywhen the PR already showsreview in progress.
- Step 1.5 no longer refreshes
- The final-review gate and the preflight disagree about checks.
- The gate reads
gh pr checks, which prints "no checks reported" and exits 1 when the head has zero check runs. That happens after a follow-up commit that touches only paths outside CI:ci.ymlande2e.ymlare path-filtered, and the auto-labeler only runs onopened. - The preflight treats that head as eligible, but the gate table doesn't say it passes. An agent that reads it as a failure now sends the PR to
changes requested, where before it only posted a comment. gh pr checksalso counts commit statuses as pending, andpending-checks.shdoesn't read those. A stuck status would end in a soft STOP every tick. This is latent: the repo posts no statuses.- Mirroring the cron-mode note in review-issue step 4, or using
pending-checks.shfor the pending check, would align the two.
- The gate reads
mergeStateStatusBLOCKEDis neither hard nor soft.- The gate passes, and the merge returns 405. The PR goes to
needs review, gets a full re-review, returns tofirst pass reviewed, and gets another 405. Each cycle costs a review run and three comments. - This is latent: the "dev and next" ruleset has enforcement disabled today.
- Adding
BLOCKEDto the hard row closes it without changing the merge-failure decision.
- The gate passes, and the merge returns 405. The PR goes to
- Nothing caps a re-review of a stale
first pass reviewedPR.- Suppose the agent skips step 1.5, or stops early because it sees a passing automated review. Then the verdict call
set-review-label.sh <pr> "first pass reviewed"changes nothing, the label time stays put, and the PR is re-selected every tick. A completed run ends up looping. changes requestedalready has the same mechanism.- Two ways to cover both labels: add a sentence to step 1.5 ("a cron pick may still show
first pass reviewedbecause a commit landed after the first pass; review the new head"), or pass--reapplyon the verdict calls.
- Suppose the agent skips step 1.5, or stops early because it sees a passing automated review. Then the verdict call
- Pre-existing, possible follow-up.
- final-review captures
HEAD_SHAafter the preflight's staleness check, and step 1 doesn't compare the head with the label again. - So a push in that gap whose head has no check runs (paths outside CI only) would pass the gate and be merged unreviewed.
- final-review captures
Closes #183
Summary
The
final-reviewcron could leave a PR stuck, or keep re-processing it every tick:approved:approvedwas applied before the merge, so a failed merge (409 because the head moved, 405 because it isn't mergeable, an API error) or a run killed in between left the PR open with a label neither cron selects.first pass reviewed: a commit pushed after that label made final-review skip the PR as stale, and the review cron never looked at that label either.This PR implements the #183 triage decisions, building on #178's
pending-checks.shand paginated label-event helpers. It also makes #178's take-back cap independent of the review agent, which the maintainer added to the scope.What changes
final-review: merge first, then label (finding 1)
SKILL.mdandfinal-review.yamlswap the order. It is nowmerge-pr.sh <pr> "$HEAD_SHA", thenset-review-label.sh <pr> "approved", thenlabel-merged-issues.sh. The approval comment still goes out before the merge.gh pr view --json state. If the merge landed anyway (for example, a client-side timeout), it carries on with the label steps. Otherwise:final-<pr>-merge-fail.md, quoting the errormerge-pr.shprinted.scripts/pr-comment.sh.set-review-label.sh <pr> "needs review".first pass reviewed, so the next tick re-checks the gates (or, if the head moved, the review cron re-reviews the PR). A run killed after the merge leaves a closed PR, so the missingapprovedlabel strands nothing.SKILL.mdstep 6, the red flags, the skill description, the yaml description and header, and themerge-pr.shheader.Re-review a stale
first pass reviewed(finding 2)find-review-candidate.sh, thechanges requestedbranch now coversfirst pass reviewedas well: the PR is eligible when its head commit is strictly newer than the latest label. That's the same comparisonfind-final-review-candidate.shuses, so eachfirst pass reviewedPR belongs to exactly one of the two crons.review in progress#178's pending-checks gate.approvedis still never selected.Stop the gate-failure loop (finding 3)
find-final-review-candidate.shdefers any PR whose head has a check run that isn't completed. It uses review-issue'spending-checks.sh(the same check-runs query), prints a stderr note, and moves on to the next PR. Red CI stays eligible, so the gate can report it.find-final-review-candidate.shalso skips draft PRs. GitHub refuses to merge a draft (405), and with the new merge-failure path, selecting one would bounce it through a full re-review every cycle.SKILL.mdand the yaml, using gh's realmergeablevalues (MERGEABLE/CONFLICTING/UNKNOWN). When both hard and soft conditions hold, hard wins. The missing-body check (step 2) runs only after the gate passes.OPEN, is a draft, orfirst pass reviewedis goneCONFLICTING/DIRTY; an active request-changes reviewchanges requested, then STOPmergeableisUNKNOWNneeds review, then STOPAfter a hard failure, a
changes requestedPR stays out of both crons until a newer commit lands. The comment also says how to re-arm the PR once a CI re-run turns a flaky failure green: runset-review-label.sh <pr> "first pass reviewed", which restarts the soak. For an active request-changes review, it also says the review has to be dismissed first.Paginate the final-review events read
find-final-review-candidate.shread only the first 30 issue events. It now usesgh api --paginate ".../events?per_page=100", emitting one line per event (--jqruns once per page) and takingsort | tail -n 1.find-review-candidate.shhas done this since #178.Take-back cap independent of the review agent (maintainer addition)
review in progress#178 caps take-backs by countinglabeled: review in progressevents, but it relied on the review agent re-runningset-review-label.shin step 1.5. A take-back run whose agent skipped that step and then died left no event, so the PR was taken back again on every tick.claim-review-candidate.sh:find-review-candidate.sh, thenset-review-label.sh --reapply <pr> "review in progress"(label listing to stderr), then prints the PR number.review-issue.yaml'sprompt_cmdnow calls it underset -euo pipefail.prompt_cmdexits non-zero, and claude-cron doesn't start the run (executor/prompt.ts).set-review-label.sh: review-issue cron: a run that dies mid-review strands the PR inreview in progress#178's special case (always re-applyreview in progress) becomes an explicit--reapplyflag. Without it, a label that's already present is left alone, so the agent's own step-1.5 call adds no second event.review in progress, i.e. a take-back. A first pick is still labeled by the agent in step 1.5. If claude fails fast (usage limit, expired auth, a broken CLI), the PR staysneeds reviewand recovers by itself once claude is back; it isn't claimed, taken back and capped. An outage can cap at most a PR whose earlier run had actually started.labeledevent, whatever the agent does. Step 1.5's text now explains a PR that already showsreview in progress.needs review, and 6 hours ago (attempt_window=21600, next to the threshold, documented in the script header and the yaml comment). A PR capped during an outage re-arms by itself, and a review that dies every time costs at most two runs per 6 hours.set-review-label.shnow adds the target before removing the other review-state labels, so a failure never leaves a PR with none. It also retries the POST once, because under--reapplythe label has just been removed.Tests
gh: now honorspr list --labelandisDraft, and has afailLabelPostscounter.strandedReviewmoved to the shared fixtures module, and there's a newrunArgvWithFakeGh.tests/find-review-candidate.test.ts:first pass reviewedis never selected" becomes "never selected without a newer commit".first pass reviewedis selected; a commit in the same second as the label is left to final-review; a stalefirst pass reviewedis deferred while checks are pending.tests/find-final-review-candidate.test.ts: 13 tests.tests/claim-review-candidate.test.ts: 9 tests.prompt_cmd, run throughbash -c: it claims a take-back and prompts with only/review-issue <n>; with nothing eligible it fails without a prompt.tests/set-review-label.test.ts: updated for--reapply, plus two new tests: a failed POST is retried once, and a swap whose POSTs fail keeps the old label.Acceptance criteria
final-reviewappliesapprovedonly aftermerge-pr.shsucceeds.needs reviewwith exactly one comment naming the failure, and the run doesn't retry.SKILL.mdstep 6, its red flags and the matchingfinal-review.yamltext describe the actual recovery path.find-review-candidate.shselects afirst pass reviewedPR whose head commit is newer than its latestfirst pass reviewedlabel, subject to review-issue cron: a run that dies mid-review strands the PR inreview in progress#178's pending-checks deferral. It doesn't select one whose head is older than the label.approvedis still never selected.find-final-review-candidate.shskips a PR whose head has any check run that isn't completed, and still returns a later eligible PR. A PR with zero check runs, or with every check run completed (including failures), stays eligible.changes requested, or toneeds reviewwhen there's no first-pass body. A soft failure produces no comment and no label change. After a hardchanges requestedfailure, neither candidate script selects the PR until a newer commit lands. (After the no-first-pass case, the review cron picks the PR up right away, which is intended.)labeledevent past the first page of issue events. This is tested with a fakeghthat returns only the first page unless--paginateis passed.tests/find-review-candidate.test.tsis extended, andtests/find-final-review-candidate.test.tsis added.labeled: review in progressevent, whether or not the agent runs step 1.5. A first pick whose run fails before the agent starts leaves the PR eligible as it was.chore:commit prefix.Test plan
bun test: 1221 pass / 0 failbun run lint: 0 errors (the 117 warnings already exist ondev; none come from the touched files)--severity=warningover CI's file set: cleanreview-issue.yamlandfinal-review.yamlparse under claude-cron'sJobSchema.prompt_cmdfailure path and the final-review same-second case.--paginatedropped fromfind-final-review-candidate.shfirst pass reviewedbranch dropped>=instead of>--reapplyset-review-label.shalways re-applying>=on the final-review sideset -euo pipefailremoved fromprompt_cmdThe final-review skill and yaml changes are agent instructions with no automated test. Like #178, this takes effect only once the release reaches
mainand the shared checkout claude-cron runs from is pulled.Known limitations
review in progress#178): an agent that skips step 1.5 on a first run and then dies leaves the PRneeds review, so it's picked again on every tick. The claim covers only take-backs, by design: claiming every pick would close this gap, but then a Claude outage would leave every eligible PR capped for up to 6 hours. The maintainer chose take-backs only.--reapply:set-review-label.sh --reapplyis a DELETE followed by a POST, retried once. If both POSTs fail, the PR is left with no review-state label. The claim then exits non-zero, so the run doesn't start, but the PR needs a manual label.prompt_cmdbudget: claude-cron givesprompt_cmda hardcoded 30 seconds. That now covers the finder, one labels GET, and the label calls on a take-back. It's well within budget at current PR volume, and the yaml comment notes the limit.commit_idof the latest automated review would close that gap.state == "CHANGES_REQUESTED" and .dismissed_at == null) counts superseded change-request reviews.