feat(land-clean-prs): report approvals that rotted, sweep multiple repos - #1957
allyblockcast[bot] wants to merge 9 commits into
Conversation
Folds the standing DIRTY-after-APPROVAL detector into the script that already sweeps PRs, per the CTO ruling on BLO-33208. The standalone version (trafficcontrol#1815) is closed: it required a repo secret no agent can provision, so its twice-daily schedule would have been permanently red and its alarm state indistinguishable from its broken state (BLO-33268). An approval is the scarcest signal on a PR — measured fleet throughput is ~2.4 human review actions/day — and it is spent silently when master moves underneath the branch. The conflicting commit lands on the BASE, so no `synchronize`, no `pull_request` event and no check re-run fires, and `mergeStateStatus` is not a check so it cannot be a `gateSignals` monitor either. Nothing wakes the assignee. That cohort has been hand-cleaned four times (BLO-29984, BLO-31321, BLO-32205, BLO-33208) and regrown in the same repos every time. Three deltas over what already shipped: - `approvalLanes` splits APPROVED reviewers human vs bot, keyed on the REST `user.type` and never on a `[bot]` login suffix. Only a human approval spent a scarce resource; conflating them overstated BLO-33208's own cohort by ~2.7x. A conflicted PR holding one is reported as its own `approval-rotted` action so the receipt tally carries the count rather than burying it among every other skip. - `checkSettlement` refuses an all-green rollup that is empty (nothing attested this head, which renders identically to everything passing) or whose newest row is under 15m old (green only because the reds have not registered yet). Rows with no parseable timestamp count as settled — holding them would be permanent, and `--auto` re-gates anyway. - `targetRepos` accepts a comma-separated `LAND_CLEAN_PRS_REPO`. A single-repo sweep is how the other two repos stayed unobserved between cleanups. DIRTY now decides ahead of the check rules, which is load-bearing rather than cosmetic: GitHub cannot evaluate a `paths:` filter on a PR whose merge commit will not compute, so path-filtered workflows are silently never dispatched and the surviving checks mean nothing (BLO-32606, measured on onprem-k8s#3269 — 4 runs dirty, 22 for the identical tree once mergeable). Reported, never acted on. A deliberate sequencing hold is indistinguishable from a strand on every API surface; trafficcontrol#1726 was exactly that, and a rebase would have broken a shared proto field-number space. Refs: BLO-33208 Co-Authored-By: Paperclip <noreply@paperclip.ing>
Found by driving the sweep against trafficcontrol rather than by reading it. `#1726` classified `enqueue` while `draft: true`, with a body that opens "Draft, and blocked by design. Do not merge before magma#1936" — the two repos carry a byte-identical `cdni_log.proto` under one package, so they share a field-number space and landing this half alone is the BLO-29906 breakage. The classifier never read `isDraft`; the field was not even requested in `PR_LIST_FIELDS`. GitHub would have refused the auto-merge, so nothing would have broken today, but that is luck rather than a rule, and it evaporates the moment someone marks a PR ready while its sequencing constraint still holds. A wasted enqueue also spends the per-fire cap and fails identically on every subsequent fire. Draft is the author's own "not this one" — the same category as SKIP_LABELS — and it is the only machine-readable form a deliberate hold reliably takes, so it is decided in the listing rules alongside them and costs no extra API call. Latent until now only because the routine swept one repo; the multi-repo change in the previous commit is what makes it reachable. Refs: BLO-33208 Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please review at head 659be01 — folding the BLO-33208 approval-rot detector into Review focus, in priority order:
Every guard is mutation-tested (each reverted individually, suite confirmed red); the table is in the PR body. |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 659be01
Critical Issues (0)
Important Issues (2)
-
[code]
scripts/land-clean-prs.mjs:623—MAX_ENQUEUES_PER_FIREsilently became a per-repo cap, not a per-fire one.runRepocallsclassifyAllonce per repo, andclassifyAllresetsenqueued = 0on every call, so the blast radius is now10 × repos. The constant's own doc at:107still asserts the old contract — "A classifier bug that says 'enqueue' for the wrong reason costs at most this many PRs per fire" — and this PR changes three classification rules in the same diff (draft skip, DIRTY-before-checks, settle floor), which is precisely when that cap is load-bearing. Measured against this head: 12 clean fixtures × 3 repos →repoA=10 repoB=10 repoC=10, 30 auto-merges armed in one fire.- Thread a remaining budget through
runRepo(maxEnqueues: cap - spent, accumulating across repos), or — if per-repo really is the intent — rename the constant and update:105-108so the stated blast radius matches the code. Right now the comment and the behaviour disagree, and the comment is the one people will trust.
- Thread a remaining budget through
-
[errors]
scripts/land-clean-prs.mjs:651—Number(process.env.LAND_CLEAN_PRS_SETTLE_MINUTES || CHECK_SETTLE_MINUTES)returnsNaNfor any non-numeric value, andageMinutes < NaNisfalse, socheckSettlementreturns{ settled: true }for every rollup. The guard this PR adds vanishes silently, in the fail-open direction. The most likely bad input is15m— invited by the doc comment's own "15 minutes" phrasing and by the detail string it prints (floor 15m). Measured:classifyPr(fresh, { settleMinutes: Number("banana") })→enqueueon a rollup whose newest check reported 1.0 minutes ago; the same fixture atsettleMinutes: 15correctly returnschecks:settling.const parsed = Number(process.env.LAND_CLEAN_PRS_SETTLE_MINUTES); const settleMinutes = Number.isFinite(parsed) ? parsed : CHECK_SETTLE_MINUTES;— this is the same ruleclassifyFromListingalready applies to an unparseableautoMergeRequest.enabledAt, which explicitly refuses to let a bad timestamp read as infinitely old. The new knob is the one path that disarms the new guard, so it deserves the existing convention.
Suggestions (4)
- [code]
scripts/land-clean-prs.mjs:385—*/export function classifyFromListing(...)— the JSDoc terminator and the declaration are joined on one line. It parses, but it is an edit artifact and nothing inpr.ymlformatsscripts/, so it will persist. - [tests]
scripts/land-clean-prs.test.mjs:364— same shape:describe("check settling floor (…)", () => { const at = …puts the helper on thedescribeline. - [errors]
scripts/land-clean-prs.mjs:637— on a fatalgherror in repo 2 of N,process.exit(1)fires before theapproval-rottedroll-up at:663, so the priority-cohort report earned from the repos that already completed is discarded. Printingrottedbefore the exit (or accumulating it in afinally) keeps the expensive half of the fire's output. - [code]
scripts/land-clean-prs.mjs:475—checks:noneas a hard stop is the right fail-closed default, but multi-repo is what makes it newly reachable: a swept repo that publishes no checks at all will now classify every PRchecks:noneforever, with nothing in the receipt distinguishing "nothing has attested yet" from "this repo never attests". A one-line note in the receipt, or a control against a few already-merged commits in that repo, would keep the structural case legible.
Strengths
approvalLaneskeys on the RESTuser.typerather than a[bot]login suffix, and the fetcher at:563genuinely carries that field (REST reviews, notgh pr view --json reviews) — so the rationale in the doc comment is backed by the data path, not assumed. Thelooks-like-a[bot]/type: "User"fixture pins exactly that.- The DIRTY-before-checks reordering is justified with a measured case (onprem-k8s#3269, 4 runs dirty vs 22 mergeable) rather than asserted, and the test names the invariant it protects rather than the assertion it makes.
applyRowreturnsnullforapproval-rotted, so the "reported, never acted on" contract is enforced by the code and not only by the comment — which matters given the trafficcontrol#1726 sequencing-hold case the PR describes.- Tests are wired into CI at
.github/workflows/pr.yml:499; 43/43 pass when run against this head.
Recommended Action
- Address the Important issues this cycle — the enqueue-cap change in particular, since it widens blast radius in the same diff that changes three classification rules.
- Consider the Suggestions opportunistically.
…bad settle value disarming the floor Both from Ally's review at 659be01. MAX_ENQUEUES_PER_FIRE had silently become per-REPO: runRepo calls classifyAll once per swept repo and classifyAll reset its counter on every call, so the real ceiling was cap x repos. The blast radius scaled with the same multi-repo knob that makes a classifier bug reach further, and this PR changes three classification rules at once, which is exactly when the cap is load-bearing. classifyAll now takes `spent` and main accumulates it across repos; the cap reason still names the true cap, not the remainder. settleMinutesFrom replaces the old unguarded coercion. `Number("15m")` is NaN and `ageMinutes < NaN` is false, so any unparseable value reported every rollup as settled — the new guard disarming itself in the fail-OPEN direction, silently. "15m" is the likely bad input: the doc comment says "15 minutes" and the detail string prints back `floor 15m`. Blank is treated as unset rather than 0 for the same reason. Same rule classifyFromListing already applies to an unparseable autoMergeRequest.enabledAt. Also from the suggestions: print the approval-rotted roll-up before the fatal-gh exit, so the priority cohort earned by repos that already completed is not discarded; and fix two edit artifacts that joined a JSDoc terminator and a describe helper onto their declaration lines. Tests 43 -> 47. Both new guards mutation-tested one at a time: dropping the `spent` seed fails 1, neutering the NaN guard fails 2. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Thinking Path
Linked Issues or Issue Description
Blockcast/trafficcontrol#1815, closed in favour of this.What Changed
approvalLanes(pr)splits APPROVED reviewers into human and bot lanes, keyed on the RESTuser.typeand never on a[bot]login suffix. Only a human approval spent a scarce resource; conflating the two overstated BLO-33208's own cohort by ~2.7x.approval-rottedaction, so the receipt tally carries the count instead of burying it among every otherskip. Reported, never acted on.checkSettlement(rollup)refuses an all-green rollup that is either empty (nothing attested this head, which renders identically to everything passing) or whose newest row is under 15 minutes old (green only because the reds have not registered yet). Rows carrying no parseable timestamp count as settled — holding them would be permanent, and--autore-gates server-side.targetRepos()accepts a comma-separatedLAND_CLEAN_PRS_REPO. Sweeping one repo is how the other two stayed unobserved between cleanups.paths:filter on a PR whose merge commit will not compute, so path-filtered workflows are silently never dispatched and the surviving checks mean nothing — measured ononprem-k8s#3269: 4 workflow runs while dirty, 22 for the identical tree once mergeable.skip/draft). Separate commit, and found by running the sweep rather than reading it — see Verification.Verification
Twelve assertions ported from the closed detector's suite onto this classifier. Every guard was mutation-tested — reverted individually, one per run, confirming the suite goes red; a guard with no failing mutation is a comment, not a test:
approvalLaneskeys on[bot]suffix instead oftypetargetReposstops splitting on,approval-rottedcollapses back intoskipAlso driven live, dry-run, against
Blockcast/multicast,Blockcast/trafficcontrol— the multi-repo loop, the per-repo receipt and the tally all behave.approval-rottedis currently 0 across both, consistent with the cohort BLO-33208 already measured down from 8.That live run is what found the draft bug.
trafficcontrol#1726classifiedenqueuewhiledraft: true, with a body opening "Draft, and blocked by design. Do not merge before magma#1936" — the two repos carry a byte-identicalcdni_log.protounder one package and share a field-number space, so landing that half alone is the BLO-29906 breakage. The classifier never readisDraft; the field was not even requested. GitHub would have refused the auto-merge, so nothing breaks today — but that is luck, not a rule, and it was latent only because the routine swept one repo. The multi-repo change here is what makes it reachable, which is why the fix ships alongside it.Risks
mergestate:DIRTYrather thanchecks:…. That is the intent — on a conflicted PR the check reading is not trustworthy — but anything parsing the receipt'sreasonstrings would see the shift. Nothing does today.LAND_CLEAN_PRS_SETTLE_MINUTES.checks:none. Over-hold, never over-enqueue.#1726surfaced.approval-rotteddeliberately has noapplyRowbranch.Model Used
claude-opus-5[1m]), 1M context, extended thinking, tool use via Claude Code / Paperclipclaude_k8sadapter.Checklist
trafficcontrol#1815, the superseded standalone detector, and#1793before it — both closed)Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template