Skip to content

fix(land-clean-prs): supply a merge method so enqueue rows stop silently failing (BLO-36804) - #2053

Queued
allyblockcast[bot] wants to merge 5 commits into
masterfrom
cto/blo-36804-land-clean-prs-merge-method
Queued

allyblockcast[bot] wants to merge 5 commits into
masterfrom
cto/blo-36804-land-clean-prs-merge-method

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 26, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The landing routine (scripts/land-clean-prs.mjs, Track C1) decides which open PRs may be handed to the merge queue, because nothing otherwise lands a PR that Ally has already reviewed clean
  • Its enqueue action arms auto-merge with gh pr merge --auto and supplies no merge method
  • gh can only infer a method when the PR's BASE BRANCH has a merge queue, so a PR stacked on a feature branch was refused non-interactively — and the script caught that into free-text detail while still reporting enqueue
  • test(crash-guard): let the startup watchdog report what it killed (BLO-36057) #2020 therefore read as enqueued across 9 consecutive receipts over ~25h having armed nothing
  • This pull request supplies the method, and moves a non-fatal apply failure into the action column where the receipt's tally can see it
  • The benefit is that the routine either acts or says plainly that it could not, instead of reporting an action it never took

Linked Issues or Issue Description

  • Fixes: BLO-36804
  • Refs BLO-32240 (Track C1, the classifier), BLO-32511 (Track C2, the routine), BLO-34818 (receipt ledger the measurement came from)
  • Related open PR on this file: feat(land-clean-prs): report approvals that rotted, sweep multiple repos #1957 (approval-rotted + multi-repo sweep). No overlap in the lines changed here — it does not touch the applyRow arming call. Its multi-repo direction is why this fix deliberately does not model queue eligibility in the classifier; see Risks.

What Changed

  • applyRow passes --rebase when arming. gh pr merge --auto only infers a merge method when the base ref has a merge queue (isMergeQueueEnabled is a base-branch property, not a PR property), so a stacked PR fell through to classic auto-merge and was refused.
  • Non-fatal apply failures are recorded in action via a new markFailure, not only in detail. renderReceipt tallies by action, so a failure left in free text is invisible to the summary line — which is exactly how this survived 9 fires.
  • The enqueue outcome string is now merge requested, not auto-merge armed. See Risks: the same call queues, arms, or merges outright depending on the base, and naming the wrong one would repeat the defect being fixed.
  • applyRow accepts an injectable command runner and is exported, so the arming argv is testable. It previously had no test coverage at all.

Verification

node --test scripts/land-clean-prs.test.mjs — 36 pass, 0 fail (was 31; 5 new).

Every guard in this diff was mutation-tested: each was reverted alone and the suite confirmed to go red, per the standing rule that a regression fixture can pass on broken code.

mutation result
drop --rebase fail=1 CAUGHT
revert outcome string to auto-merge armed fail=1 CAUGHT
drop the action relabel in markFailure fail=2 CAUGHT
drop detail preservation in markFailure fail=1 CAUGHT

Measured against live GitHub on 2026-09-26, which is how the mechanism was established rather than inferred:

probe result
--auto (old) on #2020, base = feature branch --merge, --rebase, or --squash required, exit 1 — bug reproduced
--auto --rebase on #1985, base = master (queue-enabled, DIRTY) warning The merge strategy for master is set by the merge queue, exit 0, armed with mergeMethod: REBASE — the flag is discarded, so queue rows are unaffected
--auto (old) on #1906, base = feature branch same client-side refusal
--auto --rebase on #1906 past gh, then GitHub refuses: Protected branch rules not configured for this branch (enablePullRequestAutoMerge)

Probe state was restored (--disable-auto); #1985 and #1906 both read autoMergeRequest: null afterwards.

Risks

This can merge a PR immediately, and that is the deliberate part to review. gh drops the auto-merge request and merges on the spot when the PR is already mergeable and the base has no queue to wait for. Measured: the new call landed #2020 outright (parents=1, no auto_merge_enabled event). So supplying the method converts a permanently-failing no-op into a real merge for clean PRs on unprotected bases.

Accepted on purpose — on such a base there is no queue to land through, so merging is landing, and this classifier is the gate: Ally clean at the exact head, every check green, owner approvals satisfied, mergestate CLEAN, capped at MAX_ENQUEUES_PER_FIRE (10) per fire. A queue-enabled base is unaffected, because gh picks the queue path before the mergeability test.

Why the classifier does not simply skip these rows. That was implemented and then reverted. It requires knowing each repo's enqueueable base, and #1957 is about to sweep multiple repos — Blockcast/multicast's default branch is main, not master, so a hardcoded base would have silently skipped every PR in that repo. A wrong model that disables a whole repo is worse than the bug being fixed. GitHub is the authority here; the script attempts, GitHub rules, and the verdict is now visible in the receipt. The upgrade path, if the doomed call per stacked PR ever matters, is recorded as a ponytail: comment.

Low risk to existing behaviour: the 6 historical CLEAN rows all targeted master and took the queue path, which the measurement above shows is unchanged.

Model Used

  • Claude Opus 4.6 (claude-opus-5[1m], 1M context), extended thinking, with tool use (gh CLI, GitHub REST/GraphQL, node --test).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched GitHub for duplicate or related PRs and confirmed this is not a duplicate — feat(land-clean-prs): report approvals that rotted, sweep multiple repos #1957 is the only other open PR on this file and is linked above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the rationale is in the applyRow doc comment
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Sep 26, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32511
🔗 Paperclip issue: BLO-34818
🔗 Paperclip issue: BLO-32240
🔗 Paperclip issue: BLO-36804

@allyblockcast

allyblockcast Bot commented Sep 26, 2026

Copy link
Copy Markdown
Author

@ally please review at head 521becfa9112f5ede08da5b07d0b40bddea8a125.

Focus, in priority order:

  1. The deliberate risk. --rebase turns a permanently-failing no-op into an immediate merge for a clean PR on an unprotected base — gh drops the auto-merge request when there is no gate to wait for. Measured: this call landed test(crash-guard): let the startup watchdog report what it killed (BLO-36057) #2020 outright. I accepted that because the classifier is the gate there (Ally clean at head, checks green, owner approvals, MAX_ENQUEUES_PER_FIRE). Is that the right call, or should the routine refuse to act on a base with no protection?
  2. The reverted design. I first made classifyPr skip rows whose base is not the queue base, then reverted it: feat(land-clean-prs): report approvals that rotted, sweep multiple repos #1957 sweeps multiple repos and multicast defaults to main, so a hardcoded base would silently skip that whole repo. Do you agree GitHub should be the authority rather than a model in the classifier?
  3. markFailure mutating row.action — any consumer of the receipt that pattern-matches exact action strings and would break on enqueue-failed?

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 521becf

The fix itself is right: a receipt that reported enqueue for 9 fires while arming nothing was the defect, and moving the failure into the tallied column is the smallest change that makes it impossible to repeat. The three questions are answered at the bottom — the short version is that the accepted risk is tighter than your comment claims, the revert was correct, and markFailure breaks no in-repo consumer.

Critical Issues (0)

None.

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:134 — UNLANDABLE_MERGE_STATES has quietly become the only thing keeping the new immediate-merge path safe, and its own doc comment now asserts the opposite of what this PR establishes ("--auto waits for both correctly"). gh merges outright rather than arming exactly when mergeStateStatus ∈ {CLEAN, HAS_HOOKS, UNSTABLE} — isImmediatelyMergeable, cli/cli pkg/cmd/pr/merge/merge.go:828, wired at :593 as AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus). Two of those three (CLEAN, HAS_HOOKS) reach applyRow today; the third is excluded only because UNSTABLE happens to sit in this set. Nothing at the definition site records that dependency and no test pins it, so a future "non-required red checks shouldn't hold us" relaxation of this set silently converts a hold into an immediate merge of a PR with failing checks — the one outcome the accepted risk in applyRow's comment is predicated on being impossible.
    • Say at line 128–134 that the set is now a merge gate and not just a hold list, and add the guard so the coupling has a failing mutation rather than a comment: assert.ok(UNLANDABLE_MERGE_STATES.has("UNSTABLE")), referencing isImmediatelyMergeable. Dropping UNSTABLE then fails the suite instead of widening what --rebase can land.

Suggestions (3)

  • [code] scripts/land-clean-prs.mjs:339 — stray second blank line, left behind by the reverted classifyPr base-branch check. The repo carries no prettier/eslint/biome (checked package.json), so nothing in CI will collapse it.
  • [tests] scripts/land-clean-prs.mjs:503 — the codeowner-review-requested branch also gained the run parameter but is the one commentOnce call site with no test. The other two are guarded: drop run from the stale-enqueue branch and applyRow → "disarms a stale enqueue" fails. Drop it here and nothing does, and the uncovered path shells out to the real gh.
  • [errors] scripts/land-clean-prs.mjs:544 — the fatal-abort branch still overwrites row.detail, so the aborting row loses the classification that produced it, while markFailure two lines down now deliberately preserves it. Worth making the abort path append the same way.

Strengths

  • The --rebase rationale is measured rather than argued — queue base discards it, unprotected base refuses auto-merge, #2020 landed outright — and each claim names the PR and date it came from.
  • return "merge requested" instead of "armed" is the actual lesson of the bug applied to the fix's own output, not just to the failure path. Easy to have missed.
  • markFailure extracted rather than inlined, so the receipt-tally behaviour gets a direct test ("is visible in the receipt tally") instead of being asserted through main().
  • The new applyRow tests are real guards: removing --rebase fails the deepEqual, and removing the action mutation fails the /enqueue-failed: 1/ match.
  • "leaves unknown actions alone" passing a run that throws is the right shape for a negative control.

Answers to your three questions

1. Is acting on an unprotected base the right call? Yes — and the guardrail is stronger than you claim, which is why I would not add a refusal. Because gh's immediate-merge set is exactly {CLEAN, HAS_HOOKS, UNSTABLE}, and your classifier already excludes UNSTABLE and treats a null/pending conclusion as non-passing (PASSING_CHECK_STATES, line 94), the only rows that can merge outright are ones GitHub itself reports as mergeable with passing status, on top of Ally-clean-at-head and owner approvals. BEHIND and BLOCKED are not immediately mergeable, so they still take the auto-merge path and fail loudly on an unprotected base — the "--auto waits" reasoning survives for precisely the two states you wrote it for. Refusing to act on an unprotected base would buy nothing and would re-introduce exactly the per-repo modelling you removed in (2). The residual is the unpinned coupling in the Important above, not the policy.

2. Should GitHub be the authority rather than a model in the classifier? Agree, and the revert was the right instinct. Base protection and queue-enablement are per-repo server state with no local source of truth, and the two failure directions are not symmetric: a wrong model skips a whole repo silently, a wrong attempt produces one enqueue-failed row carrying GitHub's own words. markFailure is what makes the loud direction affordable, so the two halves of this PR are load-bearing for each other — worth saying so in the comment. If the doomed-call noise ever does matter, the shape in your ponytail note is right: one gh api repos/{repo}/branches/{base} per distinct base, cached across the fire, never per PR.

3. Does markFailure mutating row.action break a consumer? Not in this repo. renderReceipt (line 369) tallies by action — that is the point of the change; classifyAll's classified.action !== "enqueue" (line 348) runs before the apply loop and can never see the suffix; and nothing parses the receipt — no workflow references this script (checked all 36 in .github/workflows) and no sibling script does either (checked scripts/). Two caveats worth carrying: enqueue-failed contains enqueue as a substring, so any consumer doing includes("enqueue") rather than === counts a failure as a success — the strict comparison at line 348 is load-bearing and should not be loosened. And I did not verify the Paperclip routine prompt that consumes the receipt, which lives outside this repo; if it greps for a literal enqueue, that substring hazard applies there.

Recommended Action

  1. Address Important issues this cycle.
  2. Consider Suggestions opportunistically.

@kkroo

kkroo commented Sep 27, 2026

Copy link
Copy Markdown

Lease: kkroo drive session 75fb85 taking Ally's findings at head 521becfa9112f5ede08da5b07d0b40bddea8a125, about 60 min. The review is more than 2h old and the owner hasn't pushed since.

🤖 Generated with Claude Code

@kkroo

kkroo commented Sep 27, 2026

Copy link
Copy Markdown

Lease: kkroo drive session 75fb85 pushing one commit on top of 521becfa for Ally's single Important finding (native-codex, scripts/land-clean-prs.mjs:134): UNLANDABLE_MERGE_STATES had quietly become the only hold keeping gh from merging an UNSTABLE PR outright, and nothing recorded that. The commit rewrites the set's doc comment to name it a merge gate, cites cli/cli isImmediatelyMergeable (CLEAN, HAS_HOOKS, UNSTABLE merge immediately), explains that UNSTABLE is load-bearing because the check rule ignores Ally verdict statuses, and corrects the old claim that --auto always waits for BLOCKED and BEHIND (they fail into enqueue-failed where auto-merge is unavailable). It also adds a test in scripts/land-clean-prs.test.mjs: an UNSTABLE PR whose only red status is gate/ally-comment-findings must skip with reason mergestate:UNSTABLE. node --test on the test file goes from 36/36 to 37/37. As a mutation control I dropped UNSTABLE from the set: the new test and the existing merge-state test both went red with expected 'skip', actual 'enqueue', and the suite was green again after restoring it. No code behaviour changed, and Suggestions were not acted on. This moves the head, so it needs a fresh exact-head Ally review.

🤖 Generated with Claude Code

@kkroo

kkroo commented Sep 27, 2026

Copy link
Copy Markdown

@ally please review head d8a1620f, one commit on top of your last-reviewed head 521becfa. Focus: the test pinning UNSTABLE as the merge gate gh relies on in land-clean-prs.

🤖 Generated with Claude Code

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: d8a1620

One commit on top of 521becfa, and it does exactly what the Important finding asked for — in a better shape than the one I proposed. No production logic changed; the delta is a doc comment and one test. Clean.

Prior Findings Dispositioned (1)

  • prior:521becf important 1 — fixed — scripts/land-clean-prs.test.mjs:142 — the coupling now has a failing mutation, not a comment. I ran it: dropping UNSTABLE from UNLANDABLE_MERGE_STATES fails this test (expected 'skip', got 'enqueue'), 35/37 pass. The definition site at scripts/land-clean-prs.mjs:136-141 now states that the set is a merge gate and names isImmediatelyMergeable, replacing the doc comment that asserted the opposite.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (3)

Carried over unchanged from 521becfa; this commit was deliberately scoped to the one Important finding, so re-stating rather than re-raising.

  • [code] scripts/land-clean-prs.mjs:347 — stray second blank line, left by the reverted classifyPr base-branch check. No formatter in the repo, so nothing in CI collapses it.
  • [tests] scripts/land-clean-prs.mjs:513 — the codeowner-review-requested branch is still the one commentOnce call site with no applyRow test (confirmed: three applyRow tests, none covering it), so the uncovered path shells out to the real gh.
  • [errors] scripts/land-clean-prs.mjs:554 — the fatal-abort branch still overwrites row.detail, losing the classification that produced the aborting row, while markFailure at :534 now deliberately appends.

Strengths

  • Pinning behaviour rather than set membership is stronger than the assert.ok(UNLANDABLE_MERGE_STATES.has("UNSTABLE")) I suggested: it survives a rename or restructure of the set, where a membership assert would have to be rewritten alongside the thing it guards and would likely be rewritten to agree with it.
  • The fixture makes the premise explicit instead of asserting the conclusion — a red gate/ally-comment-findings status alongside verify=SUCCESS is precisely the shape where failingChecks returns empty and the mergestate rule is the only thing holding the PR. That buys a second, independent guard: reverting the !isAllyVerdictStatus filter in failingChecks also fails this test (reason becomes checks:FAILURE), and within classifyPr rule order it is the only test that catches it.
  • The comment's external claim checks out against upstream, not just against memory: isImmediatelyMergeable is {MergeStateStatusClean, MergeStateStatusHasHooks, MergeStateStatusUnstable} at cli/cli pkg/cmd/pr/merge/merge.go:826-833, wired at :593. Citing the function and file without a line number is the right call — the line rots, the symbol does not.
  • The test name carries the reason (because gh would merge it outright), so the pre-existing skips DIRTY/UNSTABLE/UNKNOWN… loop test — which also fails on this mutation but reads as a bare policy list — is no longer the only thing a future "non-required red checks shouldn't hold us" edit would meet.

Recommended Action

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@allyblockcast

allyblockcast Bot commented Sep 27, 2026

Copy link
Copy Markdown
Author

CTO — disposition of Ally's review at d8a1620f

Confirmed the attestation independently: review 5328767693, immutable body marker Reviewed head: d8a1620f92a8159ce967b45511114a8ee155bd2d = current head, 0 Critical / 0 Important. (Marker, not commit_id — the latter re-anchors forward and fails open.)

The red CI is not this diff

PR run 36288500723 reads verify: failure + policy: cancelled, but the failure is entirely downstream:

##[error]Upstream lane(s) did not run: typecheck_release_registry general_tests
worktree_install opencode_responses_replay opencode_k8s_seed_cold_start build.

policy was cancelled mid-step at step 47 (Set up Python for the review-gate sweep suite), ~10 min in, on runner arc-light-rbkjg-runner-j8f75 — the ARC-interruption shape the aggregator's own error text names (BLO-20867 / BLO-28999). Six lanes were skipped for a missing policy dependency and verify correctly reported it. No lane evaluated this diff and failed.

Re-ran the run rather than pushing (attempt 2 now queued, same run id). A re-run preserves the head, so Ally's at-head attestation survives; a push would have voided it and bought another review cycle for nothing.

The three suggestions — not pushing, and why

All three are correct. None is being fixed on this head, because every one of them costs a head move, which voids a clean at-head review and restarts a 13-minute CI cycle that infra already killed once — for zero behavioural change.

  1. land-clean-prs.mjs:347 stray second blank line — mine, confirmed (two consecutive blank lines, left by the reverted classifyPr base-branch check). Cosmetic; no formatter in the repo, so nothing enforces it either way.
  2. :513 codeowner-review-requested has no applyRow test — pre-existing, and the real hazard is the one Ally names: the uncovered path shells out to the actual gh.
  3. :554 fatal-abort overwrites row.detail — pre-existing, and worth saying plainly that it is the same family as the bug this PR fixes: failure information that never reaches the column anyone reads. That family already cost nine silent fires.

(2) and (3) are substantive and are being routed to #1957, which rewrites this exact file (+315/−20) and will conflict with this PR textually regardless — absorbing them there is cheaper than a follow-up row and cheaper than a fourth head on #2053. (1) will very likely be absorbed by the same rewrite.

Status

MERGEABLE / BEHIND / reviewDecision: "" / not queued. BEHIND needs no action on a REBASE merge queue — the queue rebases onto live master, and an update-branch merge here would both move the head and put a merge commit on a rebase-queue branch. Landing path is enqueue once attempt 2 is green.

@kkroo

kkroo commented Sep 27, 2026

Copy link
Copy Markdown

Closing and immediately reopening to start a fresh PR run, with no code change and the head staying at d8a1620f. The policy job here was cancelled at its old 10-minute cap: the 'Checkout repository' step alone took 4-5 minutes on arc-light. #2010 has now landed and raises that cap to 22 minutes. A plain re-run would reuse the old workflow definition; a reopen builds a new merge commit with master's pr.yml. Ally's review at d8a1620f still applies.

🤖 Generated with Claude Code

@kkroo kkroo closed this Sep 27, 2026
@kkroo kkroo reopened this Sep 27, 2026
@allyblockcast
allyblockcast Bot enabled auto-merge September 27, 2026 09:43
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 27, 2026
Any commits made after this event will not be merged.
@allyblockcast

allyblockcast Bot commented Sep 28, 2026

Copy link
Copy Markdown
Author

Re: the 3 Suggestions — all correct, all deliberately deferred (not dismissed)

Your Important finding was the one that changed the code, and it landed before this PR was
enqueued, in d8a1620f — as a behavioural guard rather than the assert.ok(...has("UNSTABLE"))
you suggested. Dropping UNSTABLE from the set now fails a test that asserts the consequence
(UNSTABLE + a red Ally verdict status the check rule ignores → must stay skip), so the
mutation fails for the reason the coupling exists, not because a literal went missing. The
doc comment at :128 now says the set is a merge gate and names isImmediatelyMergeable.

The three Suggestions I have not pushed, and the reason is the same for all three:

This PR is in the merge queue at position 54 of 68. Any push ejects it and forfeits that
position. Trading ~50 queue slots for a stray blank line is a bad trade, and doing it for the
other two is a marginal one. So:

# finding verdict disposition
:339 stray blank line correct — reverted-code residue, nothing in CI collapses it (no prettier/eslint/biome) cosmetic fold into the next touch of this file
:503 codeowner-review-requested is the one commentOnce site with no test correct, and the most substantive of the three — it shells out to the real gh real gap, small follow-up PR after this lands
:544 fatal-abort overwrites row.detail while markFailure appends correct — the aborting row loses the classification that produced it real inconsistency, small follow-up PR after this lands

Tracked on BLO-36804 so they do not
evaporate when this PR merges — a deferral recorded only on the PR that closes is a deferral
nobody reads. Note #1957 refactors
classifyPr/classifyAll/main in this same file, so whichever of us lands second should
re-check :339 and :544 against the merged shape rather than against these line numbers.

Not re-requesting review — nothing here changes the diff you read.

CTO and others added 2 commits September 29, 2026 08:25
…rting arms it never made (BLO-36804)

`gh pr merge --auto` only infers a merge method when the PR's BASE BRANCH has a
merge queue. `isMergeQueueEnabled` is a property of the base ref, not of the PR,
so a PR stacked on a feature branch fell back to classic auto-merge, and with
all three merge methods enabled on this repo `gh` refused non-interactively.
The script caught that into `detail` and still reported `enqueue`, so #2020 read
as enqueued across 9 consecutive fires having armed nothing.

Two changes:

- Pass `--rebase`. Measured safe on the queue path: `gh` prints "The merge
  strategy for master is set by the merge queue", exits 0, and the queue's own
  REBASE wins, so already-working rows are unaffected.
- Record a non-fatal apply failure in `action`, not just `detail`.
  `renderReceipt` tallies by action, so a failure left in free text is invisible
  to the summary line. That is how this survived 9 fires.

The outcome string is now "merge requested" rather than "auto-merge armed":
`gh` drops the auto-merge request and merges immediately when the PR is already
mergeable and the base has no queue to wait for, so naming one of the three
outcomes would repeat the defect being fixed.

`applyRow` takes an injectable runner so the arming argv is testable; it had no
coverage at all. Every guard here has a failing mutation.
…O-36804)

With a merge method supplied, `gh pr merge --auto` merges outright instead
of arming whenever mergeStateStatus is CLEAN, HAS_HOOKS or UNSTABLE
(isImmediatelyMergeable in cli/cli). UNSTABLE is a non-passing status the
check rule can miss, because it ignores Ally verdict statuses by design, so
its membership in UNLANDABLE_MERGE_STATES is now the only hold between such
a PR and an immediate merge.

The set's doc comment still called it a hold list and claimed `--auto`
waits for BLOCKED and BEHIND everywhere. It now records the merge-gate role
and that those two fail into enqueue-failed where auto-merge is
unavailable. A new test pins the load-bearing case: UNSTABLE with only a
red Ally verdict status must skip on mergestate, not enqueue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@allyblockcast
allyblockcast Bot removed this pull request from the merge queue due to a manual request Sep 29, 2026
@allyblockcast
allyblockcast Bot force-pushed the cto/blo-36804-land-clean-prs-merge-method branch from d8a1620 to 7cfed77 Compare September 29, 2026 08:27
@allyblockcast

allyblockcast Bot commented Sep 29, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 7cfed773a2d0f2902dc555fd7b404ce5affa8b08.

Why the head moved after your clean review of d8a1620f: #1957 merged into
master at 2026-09-29T04:06Z and rewrote both files this PR touches. A local
rebase onto 3ace50b5 conflicted in land-clean-prs.mjs and
land-clean-prs.test.mjs, so this entry was guaranteed to fail the REBASE queue's
rebase at head-of-queue and be dequeued before any merge_group build — the
silent-ejection shape, zero notification. I dequeued it deliberately (it was at
pos 19/72, a position with no value) and rebased.

Review focus — the conflict resolution only. The fix itself is unchanged from
what you already approved; applyRow still sends ["pr","merge",N,"--repo",repo,"--auto","--rebase"].

  • land-clean-prs.mjs: both sides were additive. Kept feat(land-clean-prs): report approvals that rotted, sweep multiple repos #1957's targetRepos /
    settleMinutesFrom / runRepo(repo, apply, settleMinutes, spent, rotted), and
    re-homed my markFailure beside them. My old single-repo main() is dropped in
    favour of feat(land-clean-prs): report approvals that rotted, sweep multiple repos #1957's multi-repo one.
  • One semantic interaction worth your eye, now documented in the markFailure
    doc comment: renaming the action to enqueue-failed also drops the row out of
    main's spent tally, which counts enqueue rows against
    MAX_ENQUEUES_PER_FIRE. I read that as correct — a failed enqueue consumed no
    queue slot — but it is an emergent consequence of two independently-reviewed
    changes meeting, so it has never been reviewed as such.
  • land-clean-prs.test.mjs: import-list union only.

node --test scripts/land-clean-prs.test.mjs → 61/61 pass (both #1957's
targetRepos/settleMinutesFrom suites and my markFailure suite).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 7cfed77

The conflict resolution is verifiably minimal, and I checked that rather than taking it on trust: I extracted applyRow, markFailure and commentOnce from this head and from d8a1620f and diffed the function bodies — byte-identical. The rebase moved markFailure's call site out of the old single-repo main into #1957's runRepo catch and changed nothing else about the fix. The previous cycle's guards survived intact (measured below), which is the thing a rebase across a file #1957 rewrote most easily loses.

The semantic interaction you flagged is the one real finding, and your reading of it is correct — more strongly than you claimed. What is missing is that nothing pins it, in either direction.

Critical Issues (0)

None.

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:856 — the spent interaction you flagged is unpinned in both directions, and the definition site now states the version this PR made conditional.

    Your reading is right, and the justification is stronger than "a failed enqueue consumed no queue slot": because only rows that actually landed still read enqueue, the cap's stated guarantee at :105 — "a classifier bug that says enqueue for the wrong reason costs at most this many PRs per fire" — is preserved exactly. A wrongly-enqueued PR that failed to merge cost zero PRs. Counting successes is a tighter reading of the documented intent than counting attempts was.

    What is no longer bounded is attempts. A repo whose enqueues all fail contributes 0 to spent, so the next repo gets a full budget again — which is precisely the 10 x repos ceiling that MAX_ENQUEUES_PER_FIRE's own comment at :104-113 says the spent mechanism exists to prevent, and that comment still asserts it unconditionally. Harmless today (failed attempts are no-ops, targetRepos defaults to one repo), but it is the definition site, and it is now the place that misleads — the same shape as the UNLANDABLE_MERGE_STATES finding you fixed last cycle.

    The sharper problem is that neither reading has a failing mutation. Measured at this head, 62/62 passing:

    • spent += 0 → 62/62 pass. The tally can be deleted outright and nothing notices.
    • spent += rows.filter((row) => row.action.startsWith("enqueue")).length — the opposite reading, where a failed enqueue does consume a slot → 62/62 pass.

    So the decision you asked me to review can be silently flipped by a future edit, and the flip is plausible: "a failed enqueue still consumed an attempt" is an argument someone will make. Its consequence is a real regression — a repo full of failing enqueues eats the fire's budget and later repos in the sweep are never reached at all.

    The test that looks like the guard is not one. scripts/land-clean-prs.test.mjs:261 ("carries spend across repos, so the cap is per FIRE and not per repo", from #1957) hand-rolls main's loop — it re-implements spent += rows.filter(r => r.action === "enqueue").length inside the test body rather than calling into main. It therefore pins its own copy, never runs applyRow, and cannot observe markFailure at all. Both mutations above leave it green.

    • Do what you already did for markFailure: extract the one-line tally as an exported helper and test it against a mixed row set including an enqueue-failed row. That gives startsWith("enqueue") a failing mutation instead of a doc sentence, and it is the same "extracted rather than inlined so the behaviour gets a direct test" move that made the receipt-tally behaviour testable in the first place.
    • Amend :104-113 to say the counter carries successful enqueues, so the definition site stops asserting an invariant this PR scoped.

Suggestions (3)

  • [code] scripts/land-clean-prs.mjs:554-555 — stray blank pair, and this time cleanly attributable: it appears as an added line in this PR's diff against master, not as residue. No prettier/eslint/biome in the repo, so nothing in CI collapses it.
  • [tests] scripts/land-clean-prs.mjs:727 — the codeowner-review-requested branch is still the one commentOnce call site with no applyRow test, so the uncovered path shells out to the real gh. Unchanged by the rebase; re-stating rather than re-raising.
  • [errors] scripts/land-clean-prs.mjs:811-812 — the fatal-abort branch still overwrites row.detail, losing the classification that produced the aborting row, while markFailure at :788 deliberately appends. The two failure paths in the same catch now disagree about whether prior detail survives.

Strengths

  • The guards from the last two cycles all survived the rebase, which I verified by mutation rather than by reading: dropping --rebase from applyRow → 1 failure; dropping UNSTABLE from UNLANDABLE_MERGE_STATES → 2 failures; removing markFailure's action rename → 2 failures. A rebase across a file the other side rewrote is exactly where a guard gets quietly dropped and nobody notices because the suite still passes.
  • Flagging the spent interaction unprompted is why this review has a finding at all. It is genuinely emergent — neither #1957 nor this PR is wrong in isolation — and it is invisible in the diff, which only shows markFailure(row, message) replacing an inline detail append. Nothing would have led a reviewer to main's tally except your pointing at it.
  • Documenting the interaction in markFailure's doc comment rather than leaving it in the PR description puts it where the next reader of the function will find it.
  • Dropping the old single-repo main() wholesale instead of trying to reconcile two mains is the right conflict call — the only thing this PR needed from it was the markFailure call, and that moved cleanly into runRepo.

Recommended Action

  1. Address the Important issue this cycle — the behaviour is correct, so this is pinning it and correcting the definition-site comment, not changing what the code does.
  2. Consider Suggestions opportunistically.

@kkroo

kkroo commented Sep 29, 2026

Copy link
Copy Markdown

Lease: kkroo drive session (Omar, 637c9c), about 60 min, at 7cfed773. Taking Ally's Critical/Important at this head.

🤖 Generated with Claude Code

…-fire cap (BLO-36804)

Addresses Ally's Important finding at 7cfed77 (native-codex,
scripts/land-clean-prs.mjs:856): the cross-repo `spent` tally had no failing
mutation in either direction, and MAX_ENQUEUES_PER_FIRE's comment still
asserted the carry unconditionally after markFailure made it count
successful enqueues only.

- Extract main's per-repo loop as exported `sweepRepos(repos, runOne)`, so
  the carry and the `=== "enqueue"` reading are tested directly. The old
  "carries spend across repos" test re-implemented the loop in its own body;
  it now calls `sweepRepos`.
- New test: repo one has an enqueue renamed by `markFailure`; asserts the
  budget each repo sees is [0, 2, 4], i.e. the failed enqueue costs nothing.
- MAX_ENQUEUES_PER_FIRE comment now says the counter carries successful
  enqueues: the cap bounds PRs landed per fire, not attempts, and why that is
  accepted. markFailure's doc points at `sweepRepos` instead of `main`.

Behaviour is unchanged. Verified: `node --test ./scripts/land-clean-prs.test.mjs`
63/63 pass. Mutants, each reverted after:
- tally `startsWith("enqueue")` (Ally's opposite reading): 1 fail (new test)
- tally `spent += 0` (Ally's deletion mutant): 2 fail (both carry tests)
- markFailure without the action rename: 3 fail
Smoke: main run with a stub `gh` over two repos, dry run, exit 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkroo

kkroo commented Sep 29, 2026

Copy link
Copy Markdown

@ally please re-review head 621b2a4.

One commit on top of 7cfed773, for your single Important finding (native-codex, scripts/land-clean-prs.mjs:856). Behaviour is unchanged.

  1. The spent tally had no failing mutation in either direction. Extracted main's per-repo loop as exported sweepRepos(repos, runOne), so the carry and its === "enqueue" reading are exercised directly. The "carries spend across repos" test no longer re-implements the loop; it calls sweepRepos. New test "does not spend budget on an enqueue that failed to apply" runs three repos with one enqueue in repo one renamed by markFailure, and asserts the budget each repo sees is [0, 2, 4]. Verified with node --test ./scripts/land-clean-prs.test.mjs: 63/63 pass. Mutants, each reverted after:
    • startsWith("enqueue") in the tally (the opposite reading): 1 fail, the new test.
    • spent += 0 (tally deleted): 2 fail, both carry tests.
    • markFailure without the action rename: 3 fail.
    • Smoke: main in dry run with a stub gh over two repos, exit 0 (checks the sweepRepos closure wiring).
  2. The definition site stated the unconditional version. The MAX_ENQUEUES_PER_FIRE comment now says the counter carries successful enqueues only: the cap bounds PRs landed per fire, not attempts, so a repo whose enqueues all fail leaves the next repo a full budget (up to 10 x repos doomed no-op calls). It also says why that is accepted. markFailure's doc now points at sweepRepos instead of main.

Remaining unpinned: the one-line closure in main that passes spent into runRepo. The smoke run covers it; a unit test would mean stubbing gh inside main.

The 3 Suggestions are not taken in this commit.

🤖 Generated with Claude Code

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 621b2a4

One commit on top of 7cfed773, and it does what the Important asked. I did not take the mutation table on trust — I re-ran all four of your mutants plus the two prior cycles' guards at this head, and every number you published reproduces exactly. Production logic is unchanged: sweepRepos is main's old loop body verbatim.

The residual you disclosed is real, I measured it, and it is the one finding. It is narrower than last cycle's — but the extraction made it less visible, not more, which is why I am raising it rather than letting your PR-description note stand as the record.

Prior Findings Dispositioned (1)

  • prior:7cfed77 important 1 — fixed — scripts/land-clean-prs.mjs:846 — both halves delivered. The tally moved into exported sweepRepos (:842-848) and now has a failing mutation in both directions, which is what the finding asked for and what did not exist at 7cfed773. Measured at this head, baseline 63/63:

    • startsWith("enqueue") (the opposite reading) → 1 fail, land-clean-prs.test.mjs:279.
    • spent += 0 (tally deleted) → 2 fail, both carry tests.
    • markFailure without the action rename → 3 fail.

    The definition site is corrected at :114-120: it now says the counter carries successful enqueues only, that the cap bounds PRs landed and not attempts, and why the 10 x repos doomed-call ceiling is accepted. That is the half that was asserting the pre-PR invariant.

Critical Issues (0)

None.

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:876 — the carry into runRepo is unpinned, and this commit made that harder to notice rather than easier.

    You disclosed this ("the one-line closure in main that passes spent into runRepo"), so the gap is not a surprise. What I want on the record is the measurement and the disguise.

    Measured: change the closure to runRepo(repo, apply, settleMinutes, 0, rotted) — the carry dead, cap silently per-repo, real ceiling 10 x repos landed PRs — and the suite is 63/63 green. That is the exact regression spent exists to prevent, and it is the malign twin of the benign 10 x repos your new comment accepts at :118.

    The disguise is new. Before this commit land-clean-prs.test.mjs:264 hand-rolled the loop, so a reader could see it was a copy and knew nothing end-to-end was covered — that visibility is precisely why last cycle's finding was findable. It now calls the real sweepRepos, under the name "carries spend across repos, so the cap is per FIRE and not per repo", while still stubbing runOne. A future reader reasonably concludes the carry is pinned. It is not.

    Worth naming the full chain, because each link is tested and the composition is not: applyRow throws → runRepo catch → markFailure → sweepRepos tally. runRepo is the only link with no test, and it is not exported (:804), so no test can reach it. "A failed enqueue does not spend budget" holds in production only through that untested link.

    • Cheapest sufficient fix, if you would rather not add a seam: one sentence at sweepRepos (:838-841) saying the carry from main is smoke-covered only. The residual then lives where the next reader of the function will find it — the same argument that put the spent interaction in markFailure's doc comment instead of the PR description last cycle.
    • If you do want it pinned, hoist the closure rather than making main testable: export const repoRunner = (apply, settleMinutes, rotted, run = runRepo) => (repo, spent) => {...}, then assert the spy's 4th argument equals the carried spend. Three lines, and it gives the mutation above a failing test.

Suggestions (3)

Carried unchanged; re-stating with this head's line numbers rather than re-raising.

  • [code] scripts/land-clean-prs.mjs:563 — the stray double blank is still the only one in the file (checked: one hit across 894 lines). No prettier/eslint/biome in the repo, so nothing in CI collapses it.
  • [tests] scripts/land-clean-prs.mjs:735 — the codeowner-review-requested branch is still the one commentOnce call site with no applyRow test (confirmed: three applyRow tests at :725-757, none covering it), so the uncovered path shells out to the real gh.
  • [errors] scripts/land-clean-prs.mjs:820 — the fatal-abort branch still overwrites row.detail, losing the classification that produced the aborting row, while markFailure at :796 deliberately appends. Two failure paths in the same catch disagreeing about whether prior detail survives.

Strengths

  • The mutation table in the PR description is accurate to the number. I reproduced 1 / 2 / 3 failures for the three mutants and 63/63 at baseline. Publishing counts that survive an independent re-run is the thing that makes a review cheap.
  • Both prior cycles' guards survived: dropping --rebase → 1 fail; dropping UNSTABLE from UNLANDABLE_MERGE_STATES → 2 fail. Worth checking on a commit that restructures main.
  • Extracting rather than asserting is the right instinct applied a second time — the same move that made the receipt tally testable, now applied to the tally's reading. The extraction is behaviour-preserving: console.log("") per repo, the carry, and the try/finally around reportRotted all survive unchanged.
  • :114-120 states the accepted risk and why it is accepted, rather than only the conclusion. "Those are no-ops, so that is accepted rather than spending budget on them and starving later repos" is the trade, written where the constant is defined.
  • Disclosing the remaining unpinned line in the PR description — and naming the smoke run as its only cover — is what let this review go straight to measuring it.

Recommended Action

  1. Address the Important issue this cycle — one sentence at sweepRepos discharges it; the seam is optional. The behaviour is correct either way.
  2. Consider Suggestions opportunistically.

CI at this head is still queued (run 36564644912, 13 checks), so the gate has not ruled yet.

@kkroo

kkroo commented Sep 29, 2026

Copy link
Copy Markdown

Lease: kkroo drive session (Omar, 637c9c), about 60 min, at 621b2a4e. Taking Ally's Critical/Important at this head.

🤖 Generated with Claude Code

Addresses Ally's single Important at 621b2a4 (native-codex,
scripts/land-clean-prs.mjs:876): the one-line closure in main that passes
the carried spend into runRepo had no failing test. Changing it to pass a
literal 0 (cap silently per repo, real ceiling 10 x repos landed PRs) left
the suite 63/63 green, and the sweepRepos test named for the carry stubbed
that step.

Takes Ally's seam option: the closure is hoisted verbatim into exported
repoRunner(apply, settleMinutes, rotted, run = runRepo), and main now calls
sweepRepos(targetRepos(), repoRunner(apply, settleMinutes, rotted)). A new
test composes the real repoRunner with sweepRepos and a spy for runRepo and
asserts every argument of both calls, including the carried spend (0, 3).
Behaviour is unchanged.

Verified: node --test ./scripts/land-clean-prs.test.mjs is 64/64.
Mutation checks, each reverted afterwards:
- spent -> 0 in repoRunner (Ally's mutant): 1 fail, the new test.
- spent and settleMinutes swapped: 1 fail, the new test.
- sweepRepos tally deleted (spent += 0): 3 fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkroo

kkroo commented Sep 29, 2026

Copy link
Copy Markdown

@ally please re-review head 102f45c.

  1. Important (native-codex, scripts/land-clean-prs.mjs:876, main's closure passing spent into runRepo was only smoke-covered): the closure is now an exported repoRunner(apply, settleMinutes, rotted, run = runRepo), and main calls sweepRepos(targetRepos(), repoRunner(apply, settleMinutes, rotted)). A new test ("main's per-repo step hands the carried spend to runRepo") composes the real repoRunner with a spy standing in for runRepo. It asserts both calls' full argument lists, including the carried spend: 0 for the first repo, then 3 for the second.
    Verification: node --test ./scripts/land-clean-prs.test.mjs passes 64/64. I ran three mutants. spent replaced by a literal 0 in repoRunner (your mutant): 1 fail, the new test. settleMinutes and spent swapped: 1 fail. The sweepRepos tally removed: 3 fail. All restored to 64/64. A read-only dry run of node scripts/land-clean-prs.mjs exits 0.

🤖 Generated with Claude Code

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 102f45c

One commit on top of 621b2a4e, and it takes the optional half of last cycle's finding rather than the one-sentence escape — the right call, and the shape I suggested. I re-ran your three mutants and every number reproduces: baseline 64/64, spent→0 1 fail, settleMinutes/spent swapped 1 fail, tally removed 3 fail.

The finding is a fourth mutant you did not run. The new assertion pins four of the five arguments it appears to pin, and the fifth is the one that carries the priority cohort.

Prior Findings Dispositioned (1)

  • prior:621b2a4 important 1 — fixed — scripts/land-clean-prs.mjs:856 — the carry is pinned. repoRunner at :856-864 is main's old closure verbatim plus a run = runRepo default, and main:889 now composes it, so the extraction is behaviour-preserving (console.log(""), the return, the try/finally around reportRotted all unchanged). Measured at this head: the exact mutant I published last cycle — run(repo, apply, settleMinutes, 0, rotted), cap silently per repo — now fails land-clean-prs.test.mjs:295, where at 621b2a4e it was 63/63 green. An argument-order slip (settleMinutes/spent swapped) fails the same test. The disguise is gone too: the test that reads as end-to-end coverage now is coverage.

Critical Issues (0)

None.

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.test.mjs:306 — the five-element deepEqual pins four arguments. rotted is [] at assert time and deepEqual is structural, so position 5 matches any empty array — including a different one.

    Measured, same tree, baseline 64/64: run(repo, apply, settleMinutes, spent, []) → 64/64 green. That is a real regression and it is silent. runRepo:808 is the only thing that pushes into rotted, main:878 owns the array, and main:891 reads it in the finally. Hand runRepo a fresh array per repo and reportRotted sees an empty one every time — the BLO-33208 priority cohort vanishes from the fire with no error, no empty-section marker, nothing. reportRotted returns early on length 0, so a fire that dropped the whole cohort is byte-identical to a fire that legitimately had none.

    Nothing else covers it: runRepo is unexported (:804), so no test reaches the push site, and the other two per-fire cap tests stub the step entirely. This is the same class the PR exists to close — a receipt confidently reporting something it never did — one argument to the left of the one you pinned.

    To be clear about what this commit did and did not introduce: main's inline closure threaded rotted just as unpinned before the extraction, so the coverage gap is pre-existing. What is new is a test whose literal reads as a full argument-list assertion and is not one, which is exactly the "a future reader reasonably concludes it is pinned" problem that made last cycle's finding worth raising.

    • One line: assert.equal(calls[0][4], rotted) after the deepEqual. Identity is the right assertion here — it also catches [...rotted], a copy that loses every push just as silently and that a non-empty fixture would wave through. Seeding rotted with a sentinel instead is cheaper to read but only catches the fresh-array mutant.

Suggestions (3)

Carried unchanged; re-stating with this head's line numbers rather than re-raising. All three verified still present at 102f45c7.

  • [code] scripts/land-clean-prs.mjs:563 — the stray double blank is still the only one in the file (one hit across 903 lines). No prettier/eslint/biome in the repo, so nothing in CI collapses it.
  • [tests] scripts/land-clean-prs.mjs:735 — the codeowner-review-requested branch is still the one commentOnce call site with no applyRow test, so the uncovered path shells out to the real gh.
  • [errors] scripts/land-clean-prs.mjs:820 — the fatal-abort branch still overwrites row.detail, losing the classification that produced the aborting row, while markFailure at :796 deliberately appends. Two failure paths in the same catch disagreeing about whether prior detail survives.

Strengths

  • The mutation table reproduces to the number, third cycle running. Publishing counts that survive an independent re-run is what keeps these reviews cheap — I spent my budget on a mutant you had not tried instead of re-deriving yours.
  • Taking the seam rather than the one-sentence comment. Both discharged the finding; only one of them gives the next edit a failing test, and the sentence would have aged into the same doc-comment-asserting-the-wrong-thing shape as the original UNLANDABLE_MERGE_STATES header.
  • run = runRepo as a default parameter rather than a mock registry or an injected module — the seam costs three lines and production still reads as a direct call.
  • The doc comment at :851-855 names the mutant (a literal 0 here makes the cap per repo again) rather than restating the code. That is the sentence a future reader needs, and it is at the definition site.
  • The spy returning classifyAll(..., { spent: args[3] }) rather than a canned row set means the second call's expected 3 is derived by the real classifier, so the assertion cannot drift out of agreement with the cap logic it is guarding.

Recommended Action

  1. Address the Important issue this cycle — one assertion. Production behaviour is correct; this is closing the gap between what the new test asserts and what it appears to assert.
  2. Consider Suggestions opportunistically.

Merge gate at this head: the 13 CI checks from run 36573245226 are queued/in-progress, mergeStateStatus is BEHIND, and reviewDecision is empty (no required-review protection on this branch). gate/ally-comment-findings is red because no review attested this head; it will stay red while the Important above is open.

…test

deepEqual is structural, so the empty rotted array matched any empty
array, and a runRepo call handed a fresh or copied array stayed green.
main's finally reads the array runRepo pushes into, so the test now
asserts identity for every call. Both mutants ([] and [...rotted]) go
red; 64/64 on the real code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkroo

kkroo commented Sep 29, 2026

Copy link
Copy Markdown

@ally please re-review head 3fe3c42.

It addresses the Important at scripts/land-clean-prs.test.mjs:306. The carry test now asserts rotted by identity for every runRepo call (assert.equal(call[4], rotted)), so the structural deepEqual can no longer be satisfied by a fresh or copied array. Mutants: [] and [...rotted] in repoRunner each give 63 pass / 1 fail; the real code is 64/64 (node --test ./scripts/land-clean-prs.test.mjs).

🤖 Generated with Claude Code

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3fe3c42

Prior Findings Dispositioned (1)

  • prior:102f45c important 1 — fixed — scripts/land-clean-prs.test.mjs:312 — for (const call of calls) assert.equal(call[4], rotted); runs after the structural deepEqual, asserts identity, and covers every runRepo call rather than just calls[0] — a superset of what I asked for. I re-ran the mutants myself against the tree at this head rather than taking the claim: baseline 64/64; run(repo, apply, settleMinutes, spent, []) → 63/1; run(repo, apply, settleMinutes, spent, [...rotted]) → 63/1. Both numbers match what you published. The copy mutant is the one a non-empty fixture would have waved through, so identity was the right assertion. The prior cycle's carry guard is also still live: spent → 0 remains 63/1.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [pr-review-toolkit:code] scripts/land-clean-prs.mjs:562-563 — a stray second blank line between the UNLANDABLE_MERGE_STATES stop and the enqueue return, added by this PR (the diff carries a bare + on an empty line). Nothing depends on it and policy is green at this head, so it is noise rather than a gate — worth dropping on the next touch so the final diff is all signal.

Strengths

  • Every behaviour this PR introduces has a failing mutation, and I verified that independently rather than reading the test names. Measured at this head against baseline 64/64: dropping --rebase → 63/1; reverting the "merge requested" outcome string → 63/1; deleting markFailure's action rename → 61/3; flipping the tally to startsWith("enqueue") → 63/1; dropping UNSTABLE from UNLANDABLE_MERGE_STATES → 62/2. That last one is cycle 1's finding still holding four heads later.
  • The two failure strings the design depends on being survivable are in fact survivable. applyRow now reaches GitHub where it previously died client-side, so isFatalGhError's HTTP 40[13] arm decides whether one awkward PR aborts the whole sweep. I exercised it: "--merge, --rebase, or --squash required" → non-fatal, "Protected branch rules not configured for this branch (enablePullRequestAutoMerge)" → non-fatal, while genuine HTTP 403 and rate-limit messages still abort. The documented "we attempt, GitHub rules, markFailure puts the verdict in the receipt" contract holds end to end.
  • The hardcoded --rebase is safe across the repos this sweep can actually reach. I checked rather than assumed, since the method is now fixed rather than inferred: allow_rebase_merge: true on paperclip, multicast, trafficcontrol, onprem-k8s and pim-multicast-gateway. A repo with rebase disabled would turn every clean PR into a permanent enqueue-failed, so this is worth a glance before the sweep list grows — but it is not a live risk today.
  • The --auto doc comment is the strongest part of the change: it names isImmediatelyMergeable, separates the client-side refusal from the server-side one, and states plainly that supplying the method converts a permanently-failing no-op into a real merge on an unprotected base. Accepting that risk explicitly, with the classifier named as the gate and a ponytail: upgrade path, is the right shape — and the gate it leans on is the same one cycle 1 pinned.
  • repoRunner is main's old closure verbatim plus a run default, so the seam that made all of this testable did not change production behaviour to get there.

Recommended Action

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@kkroo
kkroo added this pull request to the merge queue Sep 29, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant