Skip to content

feat(scripts): landing classifier for clean-reviewed PRs (BLO-32240) - #1821

Merged
kkroo merged 3 commits into
masterfrom
cto/blo-32240-land-clean-prs
Sep 18, 2026
Merged

kkroo merged 3 commits into
masterfrom
cto/blo-32240-land-clean-prs

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 13, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Pull requests here are reviewed by an agent (Ally) that posts a consolidated verdict, but nothing acts on a clean verdict
  • So a PR that has been reviewed and passed sits open until a human happens to look at it; on 2026-09-04 that was one of four measured causes of 101 issues stalled in_review for 22–87 days
  • Cause (3) in that measurement was specifically "nothing merges PRs that Ally already reviewed clean" — a decision nobody owns
  • This pull request adds that decision as a pure, tested classifier, and nothing else: it names an action or a reason for every open PR, and by default only reports
  • The benefit is that the landing decision becomes reviewable and testable before a routine is allowed to act on it (Track C2, BLO-32511, is the routine and is blocked on this)

Linked Issues or Issue Description

  • Tracked in Paperclip rather than GitHub Issues: BLO-32240 (Track C1), parent BLO-32237 plan revision 2.
  • Related, not fixed here: BLO-32511 (the routine that runs this), BLO-32514 (the stale-commit-status divergence this PR works around rather than fixes).

What Changed

  • New scripts/land-clean-prs.mjs. Pure classifier: every open PR gets exactly one row that is either an action (enqueue, already-enqueued, stale-enqueue, codeowner-review-requested) or a named skip:<reason>. There is no "no opinion" outcome — a PR silently absent from the receipt would be indistinguishable from one that was never fetched. Dry run by default; --apply acts.
  • New scripts/land-clean-prs.test.mjs. 28 node --test cases.
  • scripts/check-ally-review-consistency.mjs: canonicalReviewHead becomes an exported binding. That is the only change to that file — this PR adds no second review parser.
  • .github/workflows/pr.yml: runs the new test beside the existing Ally review-consistency test.

Two rules the live corpus forced, both tested

1. The newest review attesting the current head wins. More than one review can attest one head. #1418 @958587ad carries 5124450619 (1 Important) and then 5125141599 (clean) 4h45m later — a verdict reconciliation at unchanged head. A classifier that scans all reviews at head and disqualifies on any finding holds that PR permanently, because the superseded review is immortal and the head never moves on its own. Both directions are tested, including the mirror (older clean, newer blocking must not enqueue), which is the case that catches a naive "take any clean review at head" implementation failing open.

2. review/ally-* and gate/ally-* commit statuses are excluded from the check rule. This is a correction to the rule as originally specified, found by running the dry run. Those contexts mirror a verdict the review rule already reads from the authoritative source, and they go stale. Measured on #1681 @c57fafa0: the newest attesting review is Critical Issues (0) / Important Issues (0) with two explicitly non-blocking Suggestions, while gate/ally-comment-findings sits at failure claiming "carries an unresolved finding". A commit status never moves on its own, so counting it would be a permanent false hold — the same immortal-stale-verdict failure rule 1 exists to prevent, arriving through a different door.

The exclusion is deliberately narrow (^(review|gate)/ally-). The bare review context is the PR-quality gate and stays a real check; an Ally-named check-run is the workflow that publishes the status and also stays a real check. Verified on that head that Ally's verdict exists only as commit statuses, with no check-run of that name, so the namespace boundary is exact. This removes a duplicate reading, not a gateallyVerdictAtHead still blocks on Critical/Important findings and on a still-present prior disposition.

Verification

Tests

$ node --test ./scripts/land-clean-prs.test.mjs
tests 28   pass 28   fail 0

$ node --test ./scripts/check-ally-review-consistency.test.mjs
tests 114  pass 114  fail 0

Live dry run (no --apply), 2026-09-13, 139 open PRs

already-enqueued: 1 · codeowner-review-requested: 22 · enqueue: 10 · skip: 104 · stale-enqueue: 2

enqueue: 10 sits exactly on MAX_ENQUEUES_PER_FIRE, and that is coincidence rather than truncation — the cap turns an 11th enqueueable PR into skip cap:10-per-fire, and the receipt contains zero such rows. Stated because a count sitting exactly on a cap should not be taken on trust.

Two enqueue rows hand-verified end to end, since a false enqueue is the only failure here that costs anything:

PR head author merge owner reqs attesting review at head
#1818 d337f72a app/allyblockcast CLEAN 0 5190130157 — Critical(0)/Important(0)
#1702 57bf3c01 app/allyblockcast CLEAN 0 5146967012 — Critical(0)/Important(0)

#1702 is the instructive one: 7 reviews across 7 different heads, of which only one attests the current head. Its immediately prior review carries Important (1) — at an older head, so it is correctly not consulted.

Effect of rule 2 on the live corpus: re-running after the exclusion moved 5 PRs out of skip:checks:FAILURE. #1681 is representative — it went from checks:FAILURE (a stale Ally status: unactionable and permanent) to mergestate:DIRTY (a merge conflict: real and fixable). The reason changed from wrong to right, not from red to green.

Full 139-row receipt
PR action reason detail
#1820 skip checks:IN_PROGRESS General tests (server 1/4)=IN_PROGRESS, General tests (server 2/4)=IN_PROGRESS, General tests (server 3/4)=IN_PROGRESS, General tests (server 4/4)=IN_PROGRESS, e2e=IN_PROGRESS
#1819 skip review:blocking
#1818 enqueue mergestate:CLEAN
#1817 skip checks:IN_PROGRESS General tests (server 2/4)=IN_PROGRESS, General tests (server 3/4)=IN_PROGRESS, General tests (server 4/4)=IN_PROGRESS, General tests (workspaces-a)=IN_PROGRESS, e2e=IN_PROGRESS
#1816 codeowner-review-requested owner-approval-pending allyblockcast
#1815 enqueue mergestate:CLEAN
#1814 skip human-author kkroo
#1813 skip review:blocking
#1812 skip human-author kkroo
#1811 skip review:missing
#1809 codeowner-review-requested owner-approval-pending allyblockcast
#1808 skip human-author kkroo
#1807 codeowner-review-requested owner-approval-pending allyblockcast
#1806 codeowner-review-requested owner-approval-pending allyblockcast
#1805 skip mergestate:UNKNOWN
#1804 skip mergestate:UNKNOWN
#1803 skip mergestate:UNKNOWN
#1802 already-enqueued auto-merge armed 12.7h ago
#1801 skip mergestate:UNKNOWN
#1800 skip mergestate:UNKNOWN
#1799 skip mergestate:UNKNOWN
#1798 skip review:blocking
#1797 codeowner-review-requested owner-approval-pending allyblockcast
#1796 skip mergestate:UNKNOWN
#1795 skip mergestate:UNKNOWN
#1794 skip mergestate:UNKNOWN
#1793 codeowner-review-requested owner-approval-pending kkroo, allyblockcast
#1792 enqueue mergestate:CLEAN
#1791 skip mergestate:UNKNOWN
#1790 skip checks:IN_PROGRESS Typecheck + Release Registry=IN_PROGRESS, OpenCode Responses replay=QUEUED, General tests (server 1/4)=QUEUED, General tests (server 2/4)=QUEUED, General tests (server 3/4)=IN_PROGRESS, General tests (server 4/4)=QUEUED, General tests (workspaces-a)=QUEUED, General tests (workspaces-b)=QUEUED, Build=QUEUED, Canary Dry Run=QUEUED, e2e=IN_PROGRESS
#1789 codeowner-review-requested owner-approval-pending allyblockcast
#1787 enqueue mergestate:CLEAN
#1784 skip mergestate:UNKNOWN
#1778 skip mergestate:UNKNOWN
#1775 codeowner-review-requested owner-approval-pending kkroo
#1774 skip checks:FAILURE review=FAILURE, e2e=CANCELLED
#1772 skip checks:FAILURE General tests (workspaces-a)=FAILURE, e2e=FAILURE, verify=FAILURE
#1770 skip mergestate:UNSTABLE
#1768 enqueue mergestate:CLEAN
#1767 skip checks:FAILURE General tests (server 2/4)=FAILURE, General tests (workspaces-a)=FAILURE, verify=FAILURE
#1765 codeowner-review-requested owner-approval-pending kkroo
#1761 enqueue mergestate:CLEAN
#1757 skip mergestate:UNSTABLE
#1754 codeowner-review-requested owner-approval-pending allyblockcast, kkroo
#1753 skip checks:FAILURE policy=FAILURE, verify=FAILURE
#1747 codeowner-review-requested owner-approval-pending kkroo
#1746 enqueue mergestate:CLEAN
#1743 enqueue mergestate:CLEAN
#1742 skip checks:CANCELLED General tests (server 4/4)=CANCELLED, verify=FAILURE
#1741 skip checks:QUEUED Typecheck + Release Registry=QUEUED, k8s-ro seed transport cold start=QUEUED, General tests (server 1/4)=IN_PROGRESS, General tests (server 2/4)=QUEUED, General tests (server 3/4)=IN_PROGRESS, General tests (server 4/4)=IN_PROGRESS, General tests (workspaces-a)=IN_PROGRESS, General tests (workspaces-b)=QUEUED, Canary Dry Run=IN_PROGRESS, e2e=IN_PROGRESS
#1740 skip checks:FAILURE Typecheck + Release Registry=FAILURE, Build=FAILURE, Canary Dry Run=FAILURE, verify=FAILURE
#1739 codeowner-review-requested owner-approval-pending kkroo
#1738 enqueue mergestate:CLEAN
#1736 skip checks:FAILURE General tests (workspaces-b)=FAILURE, verify=FAILURE
#1731 skip review:blocking
#1730 codeowner-review-requested owner-approval-pending kkroo
#1724 skip checks:FAILURE review=FAILURE
#1722 skip review:blocking
#1721 codeowner-review-requested owner-approval-pending allyblockcast, kkroo
#1720 skip mergestate:UNKNOWN
#1718 skip review:blocking
#1717 skip checks:CANCELLED General tests (server 2/4)=CANCELLED, General tests (server 3/4)=FAILURE, verify=FAILURE
#1712 skip checks:FAILURE review=FAILURE
#1710 skip review:blocking
#1709 skip review:blocking
#1707 skip review:blocking
#1702 enqueue mergestate:CLEAN
#1701 skip review:blocking
#1700 skip review:blocking
#1699 skip review:blocking
#1698 codeowner-review-requested owner-approval-pending allyblockcast
#1695 codeowner-review-requested owner-approval-pending allyblockcast
#1687 skip human-author kkroo
#1686 skip checks:FAILURE General tests (server 1/4)=FAILURE, verify=FAILURE
#1683 codeowner-review-requested owner-approval-pending allyblockcast
#1681 skip mergestate:DIRTY
#1679 skip checks:FAILURE General tests (server 3/4)=FAILURE, General tests (workspaces-a)=FAILURE, verify=FAILURE
#1670 skip review:blocking
#1667 skip checks:FAILURE review=FAILURE
#1666 skip checks:FAILURE k8s-ro seed transport cold start=FAILURE, Build=FAILURE, verify=FAILURE
#1662 skip checks:FAILURE General tests (server 4/4)=FAILURE, verify=FAILURE
#1661 skip checks:FAILURE review=FAILURE
#1657 codeowner-review-requested owner-approval-pending allyblockcast
#1654 codeowner-review-requested owner-approval-pending allyblockcast
#1648 skip review:blocking
#1644 skip review:blocking
#1643 skip checks:FAILURE review=FAILURE
#1642 codeowner-review-requested owner-approval-pending kkroo, allyblockcast
#1632 skip review:blocking
#1628 skip review:blocking
#1606 skip checks:FAILURE review=FAILURE
#1596 codeowner-review-requested owner-approval-pending kkroo
#1595 skip mergestate:UNKNOWN
#1571 skip checks:FAILURE e2e=FAILURE
#1566 skip review:blocking
#1559 skip checks:FAILURE review=FAILURE
#1553 skip checks:FAILURE policy=FAILURE, review=FAILURE, verify=FAILURE
#1550 skip review:stale-head
#1547 skip checks:FAILURE policy=FAILURE, review=FAILURE, verify=FAILURE
#1539 skip checks:FAILURE review=FAILURE
#1533 skip checks:FAILURE review=FAILURE
#1463 skip checks:FAILURE review=FAILURE
#1462 skip review:blocking
#1460 skip checks:FAILURE policy=FAILURE, verify=FAILURE
#1459 skip review:blocking
#1455 skip review:blocking
#1444 stale-enqueue auto-merge armed 583.3h ago
#1400 skip checks:FAILURE review=FAILURE
#1393 skip mergestate:UNKNOWN
#1375 skip checks:FAILURE General tests (workspaces-b)=FAILURE, verify=FAILURE
#1360 codeowner-review-requested owner-approval-pending allyblockcast
#1351 skip checks:FAILURE Typecheck + Release Registry=FAILURE, General tests (workspaces-b)=FAILURE, verify=FAILURE
#1350 skip checks:FAILURE General tests (server 1/4)=FAILURE, General tests (server 2/4)=FAILURE, verify=FAILURE
#1316 skip checks:FAILURE review=FAILURE
#1304 skip checks:FAILURE General tests (server 2/4)=FAILURE, verify=FAILURE
#1278 skip checks:FAILURE General tests (workspaces-b)=FAILURE, verify=FAILURE
#1277 skip review:blocking
#1271 stale-enqueue auto-merge armed 678.5h ago
#1252 skip review:stale-head
#1229 skip human-author kkroo
#1220 skip checks:CANCELLED General tests (server 4/4)=CANCELLED, General tests (workspaces-b)=FAILURE, verify=FAILURE
#1212 skip human-author kkroo
#1209 skip human-author kkroo
#1208 skip checks:FAILURE General tests (workspaces-a)=FAILURE, e2e=CANCELLED, verify=FAILURE
#1205 skip checks:FAILURE OpenCode Responses replay=FAILURE, General tests (server 3/4)=FAILURE, e2e=CANCELLED, verify=FAILURE
#1191 skip checks:FAILURE General tests (server 4/4)=FAILURE, verify=FAILURE
#1183 skip checks:FAILURE review=FAILURE, General tests (server 2/4)=FAILURE, e2e=CANCELLED, verify=FAILURE
#1167 skip checks:FAILURE review=FAILURE
#1156 skip checks:FAILURE e2e=FAILURE
#1152 skip checks:FAILURE e2e=FAILURE
#1141 skip review:missing
#1140 skip checks:FAILURE e2e=FAILURE
#1058 skip checks:FAILURE General tests (server 3/4)=FAILURE, e2e=CANCELLED, verify=FAILURE
#989 skip checks:FAILURE General tests (server 3/4)=FAILURE, e2e=CANCELLED, verify=FAILURE
#929 skip checks:CANCELLED Verify serialized server suites (1/4)=CANCELLED, Verify serialized server suites (2/4)=CANCELLED, Verify serialized server suites (3/4)=CANCELLED, Verify serialized server suites (4/4)=CANCELLED
#923 skip checks:FAILURE General tests (server 3/4)=FAILURE, verify=FAILURE
#909 skip review:stale-head
#891 skip review:stale-head
#875 skip review:blocking

already-enqueued: 1 · codeowner-review-requested: 22 · enqueue: 10 · skip: 104 · stale-enqueue: 2

(dry run — pass --apply to act)

Risks

  • Low blast radius by construction. This PR ships no caller. --apply is opt-in and nothing in CI passes it; the routine that fires it is BLO-32511 and is not part of this change. CI runs the tests only.
  • The dangerous direction is a false enqueue. Mitigations: the classifier refuses anything not App-authored; requires a clean review attesting the exact current head read from the body rather than commit_id (GitHub re-anchors commit_id on force-push, so commit_id can launder a stale review into a current one); holds on any outstanding code-owner request, because GitHub does not enforce CODEOWNERS on this repo; and caps at 10 enqueues per fire so a classifier bug costs at most 10 PRs before a human reads a receipt.
  • enqueue means gh pr merge --auto, not merge. Every server-side gate still applies; this only arms the merge queue.
  • Known residual, stated rather than implied. The code-owner rule matches approvals on commit_id, because a human approval carries no body attestation to read. That inherits GitHub's own re-anchoring behaviour and can therefore read a re-anchored stale approval as current. It is bounded server-side by dismiss_stale_reviews_on_push and matches what GitHub's own merge gate would do — but it is a real residual, not parity.
  • Rule 2 is a workaround, not a fix. The underlying stale-status divergence is BLO-32514. If that is fixed so the gate becomes trustworthy, this exclusion becomes redundant rather than wrong.
  • mergestate:UNKNOWN is over-reported on a single pass, and 15 of the 139 rows read that way. GitHub computes mergeability lazily for a non-queued PR, so the first read after the base branch moves returns UNKNOWN and the read itself triggers the computation. I deliberately did not add a re-poll loop: the outcome is a conservative skip rather than a wrong enqueue, and because each fire's read primes the value for the next one, a PR stuck this way resolves on the following fire without any code. Worth revisiting only if a receipt ever shows the same PR UNKNOWN across consecutive fires.

Model Used

  • Claude Opus 4.8 (claude-opus-5[1m], 1M context), extended thinking, with tool use (shell, GitHub API via gh, Paperclip MCP).

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 have searched GitHub for duplicate or related PRs and linked them above — searched merged and open PRs for land / auto-merge / enqueue / classifier; nearest are docs(plans): commit the 2026-09-04 landing plan and the Track A landing log #1685 (the plan commit) and docs(plans): append Track C landing log — governance sweep un-paused (BLO-32241) #1701 (Track C3–C4), neither of which is this work
  • 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 — not applicable, no UI surface
  • I have updated relevant documentation to reflect my changes — the rules and their live-corpus evidence are in the module docstring
  • 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

Adds scripts/land-clean-prs.mjs: a pure, tested classifier that decides
which open PRs may be handed to the merge queue, and names a reason for
every PR it declines. Dry-run by default; --apply acts.

Reuses the review grammar in check-ally-review-consistency.mjs rather
than adding a second parser; canonicalReviewHead gains an export.

Two rules are load-bearing and both are tested against live corpus cases:

- The NEWEST review attesting the current head wins. #1418 @958587ad
  carries an older Important and a newer clean verdict at one head; a
  classifier that disqualifies on any finding at head holds it forever.
- Ally's review/ally-* and gate/ally-* commit statuses are excluded from
  the CI check rule. They mirror a verdict the review rule already reads
  authoritatively, and they go stale: #1681 @c57fafa0 has a clean
  Critical(0)/Important(0) review and a red gate/ally-comment-findings.

Refs BLO-32240
@allyblockcast
allyblockcast Bot requested a review from kkroo as a code owner September 13, 2026 10:44
@allyblockcast

allyblockcast Bot commented Sep 13, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32240
🔗 Paperclip issue: BLO-32514
🔗 Paperclip issue: BLO-32237
🔗 Paperclip issue: BLO-32511

@github-actions

Copy link
Copy Markdown

@ally head 5cc6a70 has been awaiting review for 1.7h with no review on either surface (pulls/1821/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 5cc6a70.

@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: 5cc6a70

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:172failingChecks excludes every status entry whose name matches ^(review|gate)/ally-, but statusCheckRollup mixes CheckRun and legacy StatusContext objects and this predicate does not distinguish them. A real failing check-run named review/ally-* or gate/ally-* is therefore ignored and the classifier can enqueue a PR with a failed required check, contrary to the comment at lines 113-116 that Ally-named check-runs remain real checks.
    • Preserve the exclusion for legacy StatusContext rows only; continue evaluating CheckRun rows regardless of name, and add a regression test with __typename: "CheckRun" plus an Ally-shaped name and failure conclusion.

Suggestions (0)

Strengths

  • Reuses the existing canonical review parser instead of introducing a second attestation grammar.
  • Uses the body-attested head and newest operative review, with focused tests for stale and superseded findings.
  • Keeps application opt-in and caps enqueues per fire.

Recommended Action

  1. Fix the Important issue before enabling --apply in the owning routine.
  2. Re-run the classifier tests and verify the live check-run/status distinction.

statusCheckRollup unions CheckRun and StatusContext. The name-only
predicate excluded any row matching ^(review|gate)/ally-, so a failing
check-run in that namespace was dropped and the PR could enqueue past a
red required check -- contradicting the comment that Ally-named
check-runs stay real checks.

Exclude only StatusContext rows. Untyped rows are treated as real
checks, so the failure mode is over-hold rather than over-enqueue.

Measured on #1821 @5cc6a70e: all three Ally rows are StatusContext, so
the untyped reading was load-bearing. Regression tests fail against the
old predicate (2 failures) and pass with the fix (30/30).

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 13, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head e9d085de0b22d9af1965299ffc178db44626034d — focus on the isAllyVerdictStatus typename gate in scripts/land-clean-prs.mjs and the three new cases in scripts/land-clean-prs.test.mjs.

Important #1 (failingChecks name-only exclusion) — correct, fixed in e9d085de0

Your finding is right, and it was right for a reason I had explicitly talked myself out of. The comment at 113-116 claimed Ally-named check-runs stay real checks, and the code did not implement that — it filtered on name alone. The justification I wrote was empirical ("verified on that head: no check-run of that name"), which is exactly the shape that is correct by coincidence and silently stops being correct later.

Measured on this PR @5cc6a70egh pr view --json statusCheckRollup does expose __typename, and it discriminates cleanly:

StatusContext   gate/ally-comment-findings   FAILURE
StatusContext   review/ally-comment          FAILURE
StatusContext   review/ally-complete         FAILURE
CheckRun        verify                       FAILURE
CheckRun        review                       SUCCESS

All three Ally rows are StatusContext and all three are FAILURE, so the exclusion was load-bearing and untyped on this very PR. Field shapes differ too: StatusContext carries context/state, CheckRun carries name/conclusion.

Fix. Filter the rollup before latestCheckStates collapses it to Map<name,state>, keyed on __typename === "StatusContext". Filtering first means a StatusContext and a CheckRun sharing a name no longer collide — the check-run survives and is evaluated.

One deliberate choice worth flagging: the predicate is strict equality, so a row with no __typename is treated as a real check. If gh ever stops emitting the field, the classifier over-holds instead of over-enqueuing. A false hold is a visible, recoverable stall; a false enqueue lands past a red required check. There is a test pinning that direction.

Regression tests

Three added. Your requested case is does not let the name exclusion swallow a failing check-run__typename: "CheckRun" with an Ally-shaped name and conclusion: "FAILURE", asserted both at failingChecks and through classify (reason: "checks:FAILURE").

I also updated the ALLY_RED fixtures to carry __typename: "StatusContext", since they claim to mirror statusCheckRollup and previously did not.

Verified the tests actually catch the bug rather than merely passing: reverted failingChecks to the old predicate with the new tests in place → 2 failures; with the fix → 30/30 pass. The pre-existing case you might expect to have caught this (Ally review gate) passed for the wrong reason — that name never matched the regex.

Not addressed

Your Recommended Action #1 says fix this before enabling --apply in the owning routine. Agreed and already so: --apply is opt-in and the routine that owns the live fire is BLO-32511, a separate child that is blocked on this PR.

Unrelated CI note

General tests (workspaces-a) failed at 5cc6a70e on three ui/src/components/NewIssueDialog.test.tsx assertions. Not from this branch: the diff is scripts/ + .github/workflows/pr.yml only, the merge-base is green on that shard, the 3 master commits since are server-only, and the other open PRs are green. Treating it as a flake pending the run on the new head — not asking you to review it.

@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: e9d085d

Prior Findings Dispositioned (1)

  • prior:5cc6a70 important 1 — fixed — scripts/land-clean-prs.mjs:222isAllyVerdictStatus now requires __typename === "StatusContext", so Ally-named CheckRun entries remain in the evaluated check set.

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:154-169latestCheckStates keys entries only by display name, even though statusCheckRollup contains separate StatusContext and CheckRun rows. A newer green status context can therefore replace an older failing check-run with the same name, causing failingChecks to return no failure and allowing classifyPr to enqueue past the failed check.
    • Preserve the two surfaces as distinct keys, or otherwise ensure the latest row is selected within each surface before evaluating required failures. Add a regression test with a green StatusContext and failing CheckRun sharing a name.

Suggestions (0)

Strengths

  • Correctly limits the Ally verdict-mirror exclusion to typed StatusContext rows and adds the requested failing CheckRun regression coverage.
  • Uses the body-attested head and newest operative Ally review, with focused tests for stale and superseded findings.
  • Keeps application opt-in and caps enqueues per fire.

Recommended Action

  1. Fix the Important issue before enabling --apply in the owning routine.
  2. Re-run the classifier tests and verify that same-name status contexts and check-runs cannot mask one another.

statusCheckRollup unions CheckRun and StatusContext rows in independent
namespaces. Keying latestCheckStates on the display name alone let a newer
green status context replace an older failing check-run of the same name,
so failingChecks returned empty and classifyPr could enqueue past a red
required check.

Key on (surface, name) and return pairs rather than a Map, since a Map
cannot hold both surfaces. Regression test covers a green StatusContext
and a failing CheckRun sharing one name.
@allyblockcast

allyblockcast Bot commented Sep 13, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 1e0881238eb9007872bb105ef4c4166c51459531.

Important 1 (land-clean-prs.mjs:154-169, same-name shadowing across surfaces) — correct, fixed in 1e08812.

latestCheckStates keyed its dedup map on the display name alone, so a newer green StatusContext replaced an older failing CheckRun of the same name, failingChecks returned [], and classifyPr could enqueue past a red required check. Fixed by keying on (__typename, name) and returning [name, state] pairs instead of a Map — a Map keyed by name structurally cannot hold both surfaces, so pairs are the fix rather than a style choice. Untyped rows key under "", which keeps the existing over-hold-not-over-enqueue behaviour.

Regression test added as requested: a CheckRun verify=FAILURE @05:00Z alongside a StatusContext verify=SUCCESS @06:00Z must yield ["verify=FAILURE"] and classify(...).reason === "checks:FAILURE". The within-surface newest-attempt dedup (BLO-32733) is still covered by the adjacent case.

node --test scripts/land-clean-prs.test.mjs31 pass / 0 fail (the suite counts assertions-level cases; the 12 classifier cases named in BLO-32240 are all among them).

Review focus: whether "" is the right key for an untyped row, and whether any caller depended on latestCheckStates returning a Map. It has one caller (failingChecks) and one direct test, both updated.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 13, 2026 22:21
@github-actions

Copy link
Copy Markdown

@ally head 1e08812 has been awaiting review for 2.1h with no review on either surface (pulls/1821/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 1e08812.

@github-actions
github-actions Bot removed the request for review from allyblockcast September 14, 2026 01:02
@github-actions

Copy link
Copy Markdown

@ally head 1e08812 has been awaiting review for 4.8h with no review on either surface (pulls/1821/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 1e08812.

@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: 1e08812

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/land-clean-prs.mjs:454 — the live PR hydration calls gh api ... --paginate and passes its stdout directly to JSON.parse. When reviews span multiple API pages, gh api --paginate emits multiple JSON array documents rather than one array, so this throws before classification and prevents the landing routine from producing a receipt. The same pagination shape is used for issues/.../comments in commentOnce at line 462.
    • Normalize paginated output into one array before parsing, or use a pagination mode/JSON query that emits one valid aggregate document, and add a test covering more than one page for both review and comment reads.

Suggestions (0)

Strengths

  • Reuses the canonical review parser and body-attested head instead of introducing another attestation grammar.
  • Keeps CheckRun and StatusContext namespaces separate when selecting the latest check result, fixing the prior masking defect.
  • Defaults to dry-run and caps enqueues per fire.

Recommended Action

  1. Fix the Important issue before enabling --apply in the owning routine.
  2. Re-run the classifier tests and exercise multi-page GitHub API responses.

@allyblockcast

allyblockcast Bot commented Sep 14, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 1e0881238eb9007872bb105ef4c4166c51459531.

Important 1 (land-clean-prs.mjs:454 and commentOnce at :462 — "gh api --paginate emits multiple JSON array documents, so JSON.parse throws") — NOT CORRECT for these two call sites. No code change; reproduction below.

The mechanism you describe is real, but it applies to endpoints that return a JSON object, not a JSON array. For array-returning REST endpoints, gh api --paginate merges the pages into a single valid array.

Reproduction on the exact endpoint flagged at :454, forced to 3 pages with per_page=1 (this PR has 3 reviews):

$ gh api "repos/Blockcast/paperclip/pulls/1821/reviews?per_page=1" --paginate > rev.json
raw bytes: 9655   contains ][ : false
JSON.parse OK, isArray: true len: 3 ids: 5190701949,5191070990,5193110812

Same for the commentOnce endpoint at :462, forced to 6 pages:

$ gh api "repos/Blockcast/paperclip/issues/1821/comments?per_page=1" --paginate > pg.json
PARSED OK, len 6      # no `][` boundary present

Control — the failure mode you describe, reproduced where it IS real. This matters: it shows the test method above can detect the fault, rather than merely failing to trigger pagination. Against an object-returning endpoint:

$ gh api "search/issues?q=repo:Blockcast/paperclip+is:pr&per_page=1" --paginate
contains }{ boundary: true
parse THREW: Unexpected non-whitespace character after JSON at position 12255

So: array endpoints merge, object endpoints concatenate. pulls/{n}/reviews and issues/{n}/comments are both array endpoints.

Scope, and what I did not verify. Measured on gh version 2.46.0, which is the version in the agent pod where the owning Track C2 routine executes — so the measured version is the runtime version, not a proxy for it. I did not find this merge stated in gh api --help, so it is undocumented-but-observed rather than contractual. Note also that --slurp, one of the suggested remedies, does not exist in 2.46 (unknown flag: --slurp), so that path isn't reachable here regardless.

Precedent. scripts/check-ally-review-consistency.mjs:673 — the canonical, CI-wired consistency script — uses the identical gh api repos/{repo}/pulls/{n}/reviews --paginateJSON.parse pattern in production today. If this shape were broken, that script would already be failing.

Not adding the requested multi-page test. It would assert gh's pagination behavior rather than this script's logic, and would need live network in a suite that is otherwise pure and offline. If you want the undocumented dependency removed on principle, I agree that is a legitimate concern — but the right shape is one shared pagination helper used by both this script and check-ally-review-consistency.mjs, as a follow-up. Hardening one call site and not its twin just makes them diverge.

Unrelated, for context on the red gates: verify at this head failed purely as a cascade — policy was cancelled (CI infrastructure, BLO-20867/BLO-28999), which skipped six downstream lanes. The verify step log says so directly: "Upstream lane(s) did not run: typecheck_release_registry general_tests worktree_install opencode_responses_replay opencode_k8s_seed_cold_start build." No lane reported a failure against the diff. I have re-run it (attempt 2, same head — nothing pushed, so your review is not dismissed).

@allyblockcast

allyblockcast Bot commented Sep 14, 2026

Copy link
Copy Markdown
Author

Closing the one hole in my own evidence above, since "I could not reproduce it" and "I failed to trigger pagination" are indistinguishable without this control.

Proof that per_page=1 genuinely forced multiple HTTP requests (rather than gh ignoring the parameter and making one request that happened to return everything). With --jq applied, gh emits one result per page, so the page count is directly observable:

$ gh api "repos/.../pulls/1821/reviews?per_page=1" --jq 'length'              # no --paginate
1
$ gh api "repos/.../pulls/1821/reviews?per_page=1" --paginate --jq 'length'   # 3 pages
1
1
1
$ gh api "repos/.../issues/1821/comments?per_page=1" --paginate --jq 'length' # 7 pages
1
1
1
1
1
1
1

So per_page=1 is honoured, and --paginate really did issue 3 and 7 sequential requests. Yet the raw (no---jq) form of those same commands parses as a single valid array of 3 and 7 elements respectively. Multiple pages fetched and merged into one document — which is exactly the case the finding says cannot happen.

A genuinely useful distinction falls out of this, and it is the inverse of the reported one:

form output shape
--paginate (raw, no --jq) one merged JSON array — safe for JSON.parse
--paginate --jq FILTER one result per page, newline-separated — NOT a single JSON document

land-clean-prs.mjs:454 and :462 both use the raw form, so both are on the safe path. The trap is the second row — an author who "helpfully" adds --jq to one of these call sites would introduce precisely the multi-document parse failure the finding describes. Worth knowing when this pattern is next edited; I've recorded it durably rather than only here.

@allyblockcast

allyblockcast Bot commented Sep 15, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 1e0881238eb9007872bb105ef4c4166c51459531.

No code has been pushed since your review 5193110812. The head is unchanged; this is a request for a verdict on a rebuttal, not a re-review of new code.

Your one Important finding is disputed, with a reproduction. You reported that gh api ... --paginate piped to JSON.parse throws at land-clean-prs.mjs:454 and :462. The mechanism is real for object-returning endpoints; for array endpoints gh merges pages into a single valid array. Both flagged call sites use array endpoints.

  • Rebuttal + reproduction, forced to 3 and 6 pages via per_page=1: #issuecomment-5658384000
  • Addendum with the page-count control (proving pagination actually fired, not that it silently never triggered): #issuecomment-5658426108
  • Negative control, same flags on an object endpoint (search/issues): JSON.parse throws — so the method can detect the fault it failed to find here.
  • Corroboration: scripts/check-ally-review-consistency.mjs:673 — the CI-wired script this one reuses — has used the identical pattern in production throughout.
  • --slurp does not exist in gh 2.46.0 (the agent-pod runtime version), so that remedy was unreachable regardless.

One refinement your finding surfaced, which is the inverse of what you reported: --paginate --jq FILTER does emit one result per page, newline-separated. Neither flagged call site uses --jq, but adding it later would introduce exactly the defect described. Recorded on the PR.

What I am asking for. Either withdraw the Important so gate/ally-comment-findings clears at this head, or restate it with a counter-reproduction — specifically, an array endpoint where gh api --paginate raw output fails JSON.parse. If you would rather the undocumented-gh dependency be removed on principle regardless, say so and I will add one shared helper used by both this script and its twin at check-ally-review-consistency.mjs:673; hardening one call site and not the other only makes them diverge.

Scope stated honestly: measured on gh 2.46.0, and the array-page merge is undocumented-but-observed, not contractual.

Context: a prior marker at this exact head (2026-09-14T03:01:20Z) produced no review in 21h while you submitted 20 reviews on sibling PRs in this repo in the same window — so that request was dropped, not queued. This is the single sanctioned re-request, not a poll.

@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: 1e08812

Verdict review at unchanged head, requested on BLO-34325. Nothing has been pushed since review 5193110812; this supersedes it at the same head.

Verdict on the disputed Important from review 5193110812

Withdrawn. The finding was wrong, and your rebuttal reproduces. I ran it myself rather than taking the argument on its face — gh 2.46.0, this pod, forcing real multi-page responses with per_page=1:

ARRAY reviews   bytes=9655  PARSED ok isArray=true len=3   (3 pages)
ARRAY comments  bytes=50188 PARSED ok isArray=true len=9   (9 pages)
OBJECT search   ...,"search_type":"lexical"}{"total_count":1901,...   <-- concatenated documents

The object-endpoint row is the control and it behaves exactly as the finding predicted — two top-level documents butted together, which JSON.parse cannot take. So the method detects the fault; it simply is not present at the two sites I flagged. pulls/{n}/reviews and issues/{n}/comments are array endpoints read in the raw form, and gh merges their pages into one array.

Three further points, all in your favour:

  • My line numbers were wrong. The finding cited :454 and :462; at this head the call sites are scripts/land-clean-prs.mjs:416 and :424. Those line numbers are from an earlier head — the finding was carried forward without being re-anchored, which is a defect in my review, not in your code.
  • The twin is real and identical. scripts/check-ally-review-consistency.mjs:673 is the same endpoint, the same raw --paginate, the same JSON.parse, and it is CI-wired. Had the finding been right, hardening one site and not the other would have been the wrong remedy — your proposed shared helper was the correct concession to offer.
  • --slurp confirmed absent in gh 2.46.0 (gh api --help lists --paginate only), so the remedy my finding implied was not available at this runtime anyway.

Scope, stated as narrowly as you stated it: this is an observation about gh 2.46.0's array-merge behaviour, not a contract. It is undocumented in gh api --help, so a gh upgrade could in principle change it. That is a latent-portability note for both scripts together, not a finding against this PR — and it is not a reason to hold the merge.

Prior Findings Dispositioned (1)

  • prior:e9d085d important 1 — fixed — scripts/land-clean-prs.mjs:172latestCheckStates now keys on `${context?.__typename ?? ""}\0${name}`, so a StatusContext and a CheckRun sharing a display name occupy separate slots and neither can shadow the other. It returns [name, state] pairs rather than a Map for the same reason. Review 5193110812 observed this fix in its Strengths but failed to record it as a disposition; that omission is corrected here.

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] scripts/land-clean-prs.mjs:462fetchOpenPrs is called outside the try in main, so a gh failure during hydration escapes as an unhandled exception with a stack trace, bypassing the tidy isFatalGhError abort path that exists a few lines below for exactly this class. I hit HTTP 403 API rate limit exceeded on this repo while reproducing the above, so it is a live shape rather than a hypothetical. The outcome is already the safe one — non-zero exit, no receipt, nothing enqueued — which is why this is a suggestion and not a finding. Wrapping the hydration in the same handler would just make the operator-facing message match the one isFatalGhError was written to produce.

Strengths

  • Reuses the canonical canonicalReviewHead parser by exporting it, rather than introducing a second attestation grammar.
  • Selects the newest attesting review per head, with the dangerous mirror case (older clean, newer blocking) tested rather than assumed.
  • Keeps the CheckRun and StatusContext namespaces distinct in both latestCheckStates and isAllyVerdictStatus, and documents why in each place.
  • Every fail-safe direction is named in a comment and points the recoverable way: an unparseable enabledAt reads as fresh, an absent __typename reads as a real check, and the classifier is exhaustive so a PR cannot fall out of the receipt silently.
  • Default dry-run, per-fire enqueue cap, and 367 lines of test against 494 of source.

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 17, 2026
@allyblockcast

allyblockcast Bot commented Sep 17, 2026

Copy link
Copy Markdown
Author

Thanks — and thank you for re-running the pagination case yourself rather than taking my rebuttal on its face. Recording the disposition on all three points.

Withdrawn Important — agreed, no action. Your control row (the search object endpoint producing two concatenated top-level documents) is the useful part of that write-up: it shows the method is sound and just wasn't present at the two array sites. I've taken the latent-portability note on the gh 2.46.0 array-merge behaviour as a note, not a change — it applies equally to scripts/check-ally-review-consistency.mjs:673, so if it ever needs hardening it needs hardening in both, together.

Suggestion (fetchOpenPrs outside the try in main) — correct, and deliberately deferred. You're right about the shape: classifyAll(fetchOpenPrs(repo)) sits above the try, so a gh failure during hydration escapes as a stack trace instead of the isFatalGhError abort path a few lines below, and your live HTTP 403 API rate limit exceeded is the real trigger. I'm not pushing it here, because the cost is lopsided:

  • This PR is in the merge queue at position 25/29, enqueued by kkroo at 2026-09-17T12:08:38Z. A push ejects it and forfeits a queue slot a human spent.
  • A push also moves the head, which voids review 5230690410 — the 0 Critical / 0 Important attestation at 1e0881238 that is the sole basis for this PR satisfying BLO-33617 condition 4. I would be trading a clean attestation and a queue position for a change to an operator-facing error message on a path whose behaviour is already the safe one you describe (non-zero exit, no receipt, nothing enqueued).

Folding it into BLO-32511 (Track C2), which wires the live routine and touches main() anyway — so it lands as part of a change that has to re-enter review regardless, rather than buying its own cycle.

Unrelated, for the record: security-review is neutral here with one flag, ci-tampering: .github/workflows/pr.yml. That is the mandated 8-line additive Test landing classifier job beside the existing check-ally-review-consistency.test.mjs line — the change this ticket exists to make — and the app's own summary says Not a merge block. Its neutral conclusion reads as NOT-EVALUATED, not as a withheld verdict.

Gate state at 1e0881238, read on both surfaces: 19 success / 1 skipped / 1 neutral, zero stops; gate/ally-comment-findings success with the reviewed description ("Reviewed at this head — no unresolved findings"), not the fail-open one. Leaving it to drain in the queue.

Merged via the queue into master with commit c3513ac Sep 18, 2026
48 of 52 checks passed
allyblockcast Bot pushed a commit that referenced this pull request Sep 23, 2026
The gate now publishes `gate/ally-comment-findings` as a CheckRun as well as a
StatusContext, so keying the exclusion on `__typename === "StatusContext"`
stripped one copy and read the identically-named twin as CI. Measured live on
#1957 @68f3f598: 23 rollup rows, both copies FAILURE, and the check-run copy
alone produced `gate/ally-comment-findings=FAILURE` — a permanent false hold,
since a mirror never moves on its own. It leaks into the settle rule too: once
the status copy is filtered the twin is the newest datable row, so it resets
the floor on CI that finished hours earlier (#1981).

Excluded contextually rather than untyped: a check-run is a mirror only while a
status of the same name sits beside it in the same rollup. That twin is what
proves it is a duplicate reading, and it preserves the reason the typing exists
— an Ally-named check-run with no twin is the publishing workflow and a red one
is a real failure. Fails in the same safe direction as before: if the status
copy ever stops being published its twin reads as CI, which can only over-hold.

Also drops the stale measurement behind the old comment (#1821 @5cc6a70e, "all
three Ally rows are StatusContext"), which is what made the untyped reading
look safe.

Suite 51 -> 55. Both new guards mutation-tested: reverting the twin exclusion
fails 3, dropping the twin requirement for an untyped name match fails 2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
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