From 38a602c765b62d778187f0fe7958cd7396d7756f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:14:55 -0700 Subject: [PATCH 1/2] fix(review): finalize CI-stuck silence, post a waiting placeholder, and name every silent AI-review skip #9011: prReadyForReview used to defer a missing-required-context PR forever past the 2-minute cap instead of falling through to the existing finalize-past-cap block; a required context that never appears keeps ciState pending (never "passed"), so finalizing here can never produce a would-merge disposition. A new stuckReason ("missing_required_context" vs "ci_running") is threaded through the audit/log calls so the two causes are distinguishable. #9042: the silent wait before a review runs (measured live: 8.3min median, 21.9min p90, up to 50.9min) now gets an immediate "waiting" panel comment (renderWaitingForCiPlaceholder) upserted through the same PR_PANEL_COMMENT_MARKER the final verdict replaces, instead of leaving the PR blank until CI settles. #9000: root-causes the #8972 incident where a forced ("Re-run LoopOver review") retrigger completed with none of the events a forced pass should emit anywhere in the audit trail. shouldStartAiReviewForAdvisory folds in shouldRequirePublicAiReviewForAdvisory's hard gate (aiReviewMode off, an ineligible author, no head SHA, an AI kill-switch off, no AI binding) - a forced retrigger does not bypass this gate, and every one of its branches previously returned false with zero audit trail. resolvePublicAiReviewGateSkipReason names the exact reason, and a new catch-all audit event fires whenever aiReviewWillRun ends up false for a cause none of the other (already-audited) paths cover. The two adjacent silent branches in the frozen/paused/one-shot reuse chain - a hold condition true but nothing published to actually reuse - get their own named "unavailable" events for the same reason. This only closes the exhaustive-auditing and root-cause portion of #9000; the receipt-feedback / lost-webhook-click recovery sweep it also asks for is not yet implemented. Closes #9011 Closes #9042 --- src/queue/ai-review-orchestration.ts | 52 ++++-- src/queue/processors.ts | 190 ++++++++++++++++----- src/review/unified-comment.ts | 30 ++++ test/unit/queue-2.test.ts | 8 +- test/unit/queue.test.ts | 246 ++++++++++++++++++++++++++- 5 files changed, 464 insertions(+), 62 deletions(-) diff --git a/src/queue/ai-review-orchestration.ts b/src/queue/ai-review-orchestration.ts index 046fbc5fff..f35797e8f3 100644 --- a/src/queue/ai-review-orchestration.ts +++ b/src/queue/ai-review-orchestration.ts @@ -302,6 +302,45 @@ export function resolveAiReviewableAuthor( return confirmedContributor || packAllowsAnyAuthorBlockingReview || Boolean(settings.aiReviewAllAuthors); } +/** #9000: named reasons for `shouldRequirePublicAiReviewForAdvisory`'s hard gate, in the same priority order the + * boolean check tests them in. Before this, every one of these conditions silently returned `false` with zero + * audit trail anywhere in the codebase — including on an explicit maintainer-forced retrigger (the PR-panel + * checkbox), which does NOT bypass this hard gate (see `shouldStartAiReviewForAdvisory`'s own doc comment). + * That made it possible for a forced re-run to spend nothing, publish nothing new, and leave no explanation at + * all for why — confirmed live on #8972. The catch-all audit call site in processors.ts uses this to name the + * exact reason whenever `aiReviewWillRun` ends up false for a cause none of the OTHER (already-audited) paths + * — author blacklist, manual-review freeze, auto-review skip, one-shot reuse, reputation skip — cover. */ +export type PublicAiReviewGateSkipReason = + | "skip_ai_review_requested" + | "ai_review_mode_off" + | "author_not_reviewable" + | "no_head_sha" + | "ai_summaries_disabled" + | "ai_public_comments_disabled" + | "no_ai_binding"; + +export function resolvePublicAiReviewGateSkipReason( + env: Env, + args: { + settings: RepositorySettings; + advisory: Pick>, "headSha">; + repoFullName: string; + author: string | null; + confirmedContributor: boolean; + skipAiReview?: boolean | undefined; + }, +): PublicAiReviewGateSkipReason | null { + const reviewableAuthor = resolveAiReviewableAuthor(args.settings, args.confirmedContributor); + if (args.skipAiReview) return "skip_ai_review_requested"; + if (args.settings.aiReviewMode === "off") return "ai_review_mode_off"; + if (!reviewableAuthor) return "author_not_reviewable"; + if (!args.advisory.headSha) return "no_head_sha"; + if (!isEnabled(env.AI_SUMMARIES_ENABLED)) return "ai_summaries_disabled"; + if (!isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED)) return "ai_public_comments_disabled"; + if (!env.AI) return "no_ai_binding"; + return null; +} + export function shouldRequirePublicAiReviewForAdvisory( env: Env, args: { @@ -313,18 +352,7 @@ export function shouldRequirePublicAiReviewForAdvisory( skipAiReview?: boolean | undefined; }, ): boolean { - const reviewableAuthor = resolveAiReviewableAuthor(args.settings, args.confirmedContributor); - if ( - args.skipAiReview || - args.settings.aiReviewMode === "off" || - !reviewableAuthor || - !args.advisory.headSha || - !isEnabled(env.AI_SUMMARIES_ENABLED) || - !isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED) || - !env.AI - ) - return false; - return true; + return resolvePublicAiReviewGateSkipReason(env, args) === null; } /** Reuse a cached review manifest when present; otherwise load fail-safely for the AI review pass. (#1954) */ diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6192134d31..2450a7f26c 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -450,6 +450,7 @@ import { aiReviewLockContendedResult, claimAiReviewLock, releaseAiReviewLock, + resolvePublicAiReviewGateSkipReason, resolveReviewEnrichmentGithubToken, resolveReviewManifestForAiReview, runAiReviewForAdvisory, @@ -494,6 +495,7 @@ import { incr, observe, REVIEW_LATENCY_BUCKETS } from "../selfhost/metrics"; import { withAdvisoryAiEnv } from "../selfhost/ai"; import { renderReviewingPlaceholder, + renderWaitingForCiPlaceholder, shouldPostReviewingPlaceholder, type CheckFailureDetail, type MergeReadiness, @@ -4148,14 +4150,41 @@ async function prReadyForReview( ci.hasVisiblePending || !(await ciPendingDeferStuck(env, repoFullName, pr.number, pr.headSha, deferCapMs)) ) { + // #9011/#9000: distinguishable reason on the SAME defer event -- "waiting normally" and "a required + // context has been missing this whole time" used to be indistinguishable in the ledger, which is exactly + // what made a genuinely stuck PR unfindable among the 1,800 healthy-wait occurrences in 48h. await recordAuditEvent(env, { eventType: "github_app.review_deferred_ci_pending", actor: "loopover", targetKey: `${repoFullName}#${pr.number}`, outcome: "queued", - detail: "CI still running — review deferred until all checks finish", - metadata: { deliveryId, repoFullName }, + detail: ci.hasMissingRequiredContext + ? "A required CI context is still missing — review deferred instead of publishing a passing gate before expected CI reports" + : "CI still running — review deferred until all checks finish", + metadata: { deliveryId, repoFullName, stuckReason: ci.hasMissingRequiredContext ? "missing_required_context" : "ci_running" }, }).catch(() => undefined); + // #9042: publish an IMMEDIATE "waiting" surface instead of the total silence a deferred readiness check + // otherwise produces -- measured live at a median 8.3min / p90 21.9min wait with zero visible trace, + // which is exactly what trained a maintainer to merge #8944 by hand while ORB was still silently + // waiting (its CI then failed). Shares PR_PANEL_COMMENT_MARKER with the reviewing placeholder and the + // eventual real verdict, so the upsert naturally REPLACES this the moment either of those posts -- + // "updated, not duplicated" comes for free from the same primitive the reviewing placeholder already + // relies on. Best-effort and deliberately coarse-gated (commentMode/pause/dry-run only, not the full + // willComment resolution `maybePublishPrPublicSurface` computes downstream): this is an additive "we + // see you" surface, not a disposition-bearing one, so it does not need that resolution's full weight, + // and a failure here must never turn a deferred readiness check into a thrown error. + if (settings.commentMode !== "off" && !settings.agentPaused && !settings.agentDryRun) { + const waitingReason = ci.hasMissingRequiredContext + ? "a required CI check that has not started yet" + : "CI checks to finish"; + await createOrUpdatePrIntelligenceComment( + env, + installationId, + repoFullName, + pr.number, + `${PR_PANEL_COMMENT_MARKER}\n\n${renderWaitingForCiPlaceholder({ reason: waitingReason })}`, + ).catch(() => undefined); + } return false; } // #3947 made this defer UNCONDITIONAL and INDEFINITE ("no finalize escape") specifically to stop a @@ -4169,21 +4198,28 @@ async function prReadyForReview( // can only make things worse: no amount of deferring makes an expected required context appear on a // PR that needs a rebase. Excluding this case lets a dirty-base PR fall through to the same // finalize-past-cap path below instead of deferring forever (#7537-class stuck PRs, #7556). Every - // OTHER missing-required-context PR keeps deferring unconditionally, exactly as #3947 intended: a - // clean-base PR really might still be waiting on a required check whose eventual result matters. + // #9011: a clean-base missing-required-context PR no longer defers past its own cap either -- it used + // to (see the paragraph above, which described the OLD "OTHER missing-required-context PR keeps deferring + // unconditionally" behavior #3947 originally intended), and that "unconditionally" was the bug: with no + // finalize escape at all, a required context that structurally can never appear (a path-filtered workflow, + // a renamed/deleted required check, an uninstalled third-party required App) left the PR with NO public + // surface, NO review, NO disposition, and NO alert -- forever. Measured live: 1,800 occurrences across 342 + // distinct PRs in 48h, indistinguishable from a PR healthily waiting on real CI. + // + // Finalizing here is safe under #3947's own concern (never publish a premature "passed" gate): a missing + // required context keeps `ciState` at "pending" (backfill.ts's anyMissingRequiredContext also sets + // anyPending), and agent-actions.ts's `reviewGood = gatePassing && ciPassed` requires ciState === "passed" + // -- so this can NEVER produce a would-approve/would-merge disposition. It converts an infinite SILENCE + // into a visible, correctly-non-mergeable HOLD, which is what #3947 always wanted to avoid publishing + // around, not what it wanted to prevent. + // + // Falls through to the SAME finalize-past-cap block the generic stuck-CI case already uses below (SHA-scoped + // guard, once-per-SHA error-level page) -- both are "past the staleness cap, stop deferring" and share the + // exact same safety argument; `stuckReason` below is the one thing that needs to differ, so #9000/#9003's + // ask for a distinguishable reason on the defer/finalize audit trail (ci_running vs missing_required_context) + // is satisfied without duplicating the whole guard. const isLiveBaseConflict = (liveMergeState ?? pr.mergeableState) === "dirty"; - if (ci.hasMissingRequiredContext && !isLiveBaseConflict) { - await recordAuditEvent(env, { - eventType: "github_app.review_deferred_ci_pending", - actor: "loopover", - targetKey: `${repoFullName}#${pr.number}`, - outcome: "queued", - detail: - "Required CI context is still missing — review deferred instead of publishing a passing gate before expected CI reports", - metadata: { deliveryId, repoFullName }, - }).catch(() => undefined); - return false; - } + const stuckReason = ci.hasMissingRequiredContext && !isLiveBaseConflict ? "missing_required_context" : "ci_running"; // #orb-ci-stuck-repeat: finalizing here runs a full paid AI review -- but a permanently-stuck CI context // (a fork check that will never report, an orphaned required context) never resolves, so every later // evaluation of the SAME head SHA hits this exact branch again and would re-spend another review for a @@ -4207,8 +4243,11 @@ async function prReadyForReview( actor: "loopover", targetKey: `${repoFullName}#${pr.number}`, outcome: "queued", - detail: "CI still stuck pending, but already finalized once for this head SHA — deferring again instead of re-spending a review", - metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + detail: + stuckReason === "missing_required_context" + ? "A required CI context is still missing, but already finalized once for this head SHA — deferring again instead of re-spending a review" + : "CI still stuck pending, but already finalized once for this head SHA — deferring again instead of re-spending a review", + metadata: { deliveryId, repoFullName, headSha: pr.headSha, stuckReason }, }).catch(() => undefined); // level:"error" is deliberate, not a code failure: this line only fires once the guard above already // stopped the wasteful re-review, so its OWN existence is the operator-visible signal (via the structured @@ -4232,6 +4271,7 @@ async function prReadyForReview( pullNumber: pr.number, headSha: pr.headSha, deliveryId, + stuckReason, }), ); } @@ -4243,8 +4283,10 @@ async function prReadyForReview( targetKey: `${repoFullName}#${pr.number}`, outcome: "completed", detail: - "CI stuck pending past the staleness cap — finalizing so the PR is surfaced, not silently deferred forever", - metadata: { deliveryId, repoFullName }, + stuckReason === "missing_required_context" + ? "A required CI context never appeared past the staleness cap — finalizing so the PR is surfaced (held, not merged) instead of silently deferred forever" + : "CI stuck pending past the staleness cap — finalizing so the PR is surfaced, not silently deferred forever", + metadata: { deliveryId, repoFullName, stuckReason }, }).catch(() => undefined); await recordAuditEvent(env, { eventType: CI_STUCK_FINALIZE_GUARD_EVENT_TYPE, @@ -4252,7 +4294,7 @@ async function prReadyForReview( targetKey: guardTargetKey, outcome: "completed", detail: "recorded so a repeat evaluation of the SAME head SHA does not pay for another review", - metadata: { repoFullName, prNumber: pr.number, headSha: pr.headSha }, + metadata: { repoFullName, prNumber: pr.number, headSha: pr.headSha, stuckReason }, }).catch(() => undefined); // fall through → return true → the gate finalizes + the PR is disposed/held, never silently stuck. } @@ -10383,6 +10425,18 @@ async function maybePublishPrPublicSurface( * identical `advisory.headSha ?? null` fallbacks elsewhere in this function. */ metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, }).catch(() => undefined); + } else { + // #9000: previously silent — a PR frozen for manual review with no prior published review (or one with + // no public assessment) to reuse left aiReviewWillRun false and NOTHING in the audit trail explaining + // why. Named so "why did nothing happen" is always answerable by grepping the audit log. + await recordAuditEvent(env, { + eventType: "github_app.ai_review_frozen_reuse_unavailable", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: "PR is held for manual review, and no prior published AI review with a public assessment exists to reuse — no review ran this pass", + metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, + }).catch(() => undefined); } } else if (autoReviewSkipReason === "review paused (commit threshold)") { // #selfhost-token-burn: countPublishedAiReviewHeads now counts the PR's OWN current head (see that @@ -10405,24 +10459,84 @@ async function maybePublishPrPublicSurface( detail: "Auto-review is paused (commit threshold); reused the last published AI review instead of spending a fresh call", metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, }).catch(() => undefined); + } else { + // #9000: same silent gap as the frozen-reuse branch above, for the paused-cadence case. + await recordAuditEvent(env, { + eventType: "github_app.ai_review_paused_reuse_unavailable", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: "Auto-review is paused (commit threshold), and no prior published AI review with a public assessment exists to reuse — no review ran this pass", + metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, + }).catch(() => undefined); } - } else if (oneShotPriorReview && hasPublicReviewAssessment(oneShotPriorReview.notes)) { - advisory.findings.push(...oneShotPriorReview.findings); - aiReview = oneShotPriorReview; - aiReviewWasReused = true; - incr("loopover_ai_review_one_shot_reuse_total"); - await recordAuditEvent(env, { - eventType: "github_app.ai_review_one_shot_reuse", - actor: author, - targetKey: `${repoFullName}#${pr.number}`, - outcome: "completed", - detail: "one-shot review cadence: reused the last published AI review instead of spending a fresh call", - /* v8 ignore next -- a truthy `oneShotPriorReview` means markAiReviewPublished previously stamped a row - * for a non-null head SHA, and an open PR does not lose its head SHA once set; the `?? null` is a - * type-level fallback for a practically-unreachable branch, mirroring the identical fallback on the - * frozen-reuse and paused-reuse audit events just above. */ - metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, - }).catch(() => undefined); + } else if (oneShotPriorReview) { + if (hasPublicReviewAssessment(oneShotPriorReview.notes)) { + advisory.findings.push(...oneShotPriorReview.findings); + aiReview = oneShotPriorReview; + aiReviewWasReused = true; + incr("loopover_ai_review_one_shot_reuse_total"); + await recordAuditEvent(env, { + eventType: "github_app.ai_review_one_shot_reuse", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: "one-shot review cadence: reused the last published AI review instead of spending a fresh call", + /* v8 ignore next -- a truthy `oneShotPriorReview` means markAiReviewPublished previously stamped a row + * for a non-null head SHA, and an open PR does not lose its head SHA once set; the `?? null` is a + * type-level fallback for a practically-unreachable branch, mirroring the identical fallback on the + * frozen-reuse and paused-reuse audit events just above. */ + metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, + }).catch(() => undefined); + } else { + // #9000: same silent gap, for the one-shot-cadence case — the PR already spent its one real review, but + // that review has nothing reusable (e.g. a non-cacheable outage/inconclusive row slipping through), so + // this pass ends with no fresh review and, previously, no explanation at all. + await recordAuditEvent(env, { + eventType: "github_app.ai_review_one_shot_reuse_unavailable", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: "one-shot review cadence already spent its review, and the prior published review has no public assessment to reuse — no review ran this pass", + metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha ?? null }, + }).catch(() => undefined); + } + } else if (!aiReviewWillRun && !authorBlacklisted && !autoReviewSkipReason) { + // #9000 (root cause of the #8972 incident): `shouldStartAiReviewForAdvisory` folds in + // `shouldRequirePublicAiReviewForAdvisory`'s hard gate (aiReviewMode off, an ineligible author, no head SHA, + // an AI kill-switch off, no AI binding) — every one of those conditions previously returned false with + // ZERO audit trail, including on an explicit forced retrigger that does NOT bypass this gate (only the + // reputation anti-abuse skip below it is bypassable, and that skip already emits its own + // `maybeAddReputationSkipHold` audit event before this point is reached). Naming the reason here closes + // the remaining silent branch: a forced pass that ends with no fresh review always now has exactly one of + // a reuse event, a reuse-unavailable event, a reputation-skip hold, or this event in the audit trail. + const publicGateSkipReason = resolvePublicAiReviewGateSkipReason(env, { + settings, + advisory, + repoFullName, + author, + confirmedContributor, + skipAiReview: webhook.skipAiReview, + }); + if (publicGateSkipReason) { + await recordAuditEvent(env, { + eventType: "github_app.ai_review_public_gate_skipped", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: `AI review did not run this pass: ${publicGateSkipReason}`, + metadata: { + deliveryId: webhook.deliveryId, + repoFullName, + headSha: advisory.headSha ?? null, + reason: publicGateSkipReason, + forced: webhook.forceAiReview === true, + }, + }).catch(() => undefined); + } + // A null publicGateSkipReason here means the hard gate itself was satisfied and aiReviewWillRun was false + // purely because of the reputation anti-abuse skip inside shouldStartAiReviewForAdvisory — already + // audited above via maybeAddReputationSkipHold, so no duplicate event is needed. } // Review-evasion protection (#review-evasion-protection): durably record that a review pass is starting // for this EXACT head BEFORE any cost-bearing AI-review work begins (including the reviewing placeholder diff --git a/src/review/unified-comment.ts b/src/review/unified-comment.ts index 5342b07486..7714a81e55 100644 --- a/src/review/unified-comment.ts +++ b/src/review/unified-comment.ts @@ -946,3 +946,33 @@ export function renderReviewingPlaceholder(ctx: { brand?: string } = {}): string export function shouldPostReviewingPlaceholder(args: { reviewWillRun: boolean; mode: string; willComment: boolean }): boolean { return args.reviewWillRun && args.mode === "live" && args.willComment; } + +// ── CI-wait placeholder ────────────────────────────────────────────────────────────────────────── +// +// #9042 — posted the FIRST time a PR defers waiting on CI, before readiness is even reached (i.e. before +// the reviewing-in-progress placeholder above ever gets a chance to run, since that one only fires once +// prReadyForReview has already returned true). Without this, a PR waiting on CI got NO surface at all -- +// measured live: median 8.3min / p90 21.9min / max 50.9min with zero visible trace, which trained a +// maintainer to merge by hand rather than wait for a bot that looked like it was ignoring the PR (#8944: +// a maintainer merged during exactly this silent window, and CI then failed). Shares the SAME +// PR_PANEL_COMMENT_MARKER as the reviewing placeholder and the final verdict comment, so +// createOrUpdatePrIntelligenceComment's upsert-by-marker naturally REPLACES this placeholder the moment +// either of those posts -- "updated, not duplicated" falls out of the existing upsert primitive for free, +// the same way the reviewing placeholder above already relies on it. +const WAITING_SQUARE = "🟨"; + +/** Render the transient "waiting on CI" placeholder body. Caller must prepend PR_PANEL_COMMENT_MARKER + * before posting, exactly like {@link renderReviewingPlaceholder}. `reason` is a short, already-public-safe + * phrase (no raw external text) naming what ORB is waiting on -- e.g. "CI checks to finish" or "a required + * CI check that has not started yet". Pure. */ +export function renderWaitingForCiPlaceholder(ctx: { reason: string; brand?: string }): string { + const brand = escapePublicHtmlAngles(ctx.brand ?? "LoopOver"); + const reason = escapePublicHtmlAngles(ctx.reason); + const inner = [ + WAITING_SQUARE.repeat(12), + `### ⏳ ${brand} is waiting…`, + `${brand} has seen this pull request and is waiting on ${reason} before reviewing it. This comment will update once the review runs.`, + `${STATUS_META.ready.square} Safe / merged · ${STATUS_META.advisory.square} Advisory · ${STATUS_META.held.square} Held for review · ${STATUS_META.blocked.square} Blocked / closed · ${WAITING_SQUARE} Waiting`, + ].join("\n\n"); + return asAlert("IMPORTANT", inner); +} diff --git a/test/unit/queue-2.test.ts b/test/unit/queue-2.test.ts index 0fed47bf4b..be841f0376 100644 --- a/test/unit/queue-2.test.ts +++ b/test/unit/queue-2.test.ts @@ -2321,9 +2321,11 @@ describe("queue processors", () => { await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 3, title: "Healthy PR, still waiting on required CI", state: "open", user: { login: "contributor" }, head: { sha: "pending-sha" }, base: { ref: "main" }, labels: [], body: "" }); const targetKey = "owner/agent-repo#3#pending-sha"; vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); - // Same fixture shape as "keeps deferring a missing-required-context PR" (queue.test.ts): a required - // status check has simply not posted yet -- prReadyForReview's own #3947 design defers this - // UNCONDITIONALLY and INDEFINITELY (no finalize escape), by design, for exactly this case. + // Same fixture shape as the missing-required-context tests in queue.test.ts: a required status check has + // simply not posted yet. Time is frozen for this whole test (vi.setSystemTime, never advanced) and no + // ci-pending-first-seen marker is pre-seeded, so every tick reads zero elapsed time against the 2-minute + // missing-required-context cap (#9011) -- prReadyForReview correctly keeps deferring throughout, exactly + // as it should for a PR that (as far as this test can tell) only just started waiting. const requiredContextsSpy = vi.spyOn(backfillModule, "fetchRequiredStatusContexts").mockResolvedValue(null); const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl").mockResolvedValue({ ciState: "pending", diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index fc9ca62808..0d68a44a6c 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -2404,7 +2404,18 @@ describe("queue processors", () => { } }); - it("keeps deferring a missing-required-context PR after the short surfacing cap (#selfhost-ci-deferral-staleness)", async () => { + // POLICY REVERSAL (#9011). This test previously asserted the OPPOSITE: that a clean-base PR keeps deferring + // past the 2-minute missing-required-context cap, forever, with "Missing required contexts must still not + // publish a passing gate before expected CI reports" as its stated reasoning. That reasoning was itself the + // bug -- finalizing here does NOT publish a passing gate. A missing required context keeps `ciState` at + // "pending" (backfill.ts's anyMissingRequiredContext also sets anyPending), and agent-actions.ts's + // `reviewGood = gatePassing && ciPassed` requires ciState === "passed", so finalizing can never produce a + // would-approve/would-merge disposition -- it only converts an INFINITE SILENCE into a visible, correctly + // non-mergeable hold. Measured live: this exact "unconditionally forever" shape fired 1,800 times across 342 + // distinct PRs in 48h, making a genuinely stuck PR (a path-filtered workflow, a renamed/deleted required + // check, an uninstalled third-party required App -- none of which will EVER make the context appear) + // indistinguishable from one healthily waiting on real CI. + it("REGRESSION (#9011): a clean-base PR with missing required context finalizes past the short cap instead of deferring forever", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { action: "created", installation: { id: 9001, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: { pull_requests: "write", checks: "write" }, events: [] } }); await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9001); @@ -2413,8 +2424,7 @@ describe("queue processors", () => { await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Missing required context, past short cap", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main" }, labels: [], body: "Closes #1" }); vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); // 3 minutes elapsed: past the 2-minute missing-required-context surfacing cap, but nowhere near the old - // 30-minute stale-CI cap. Missing required contexts must still not publish a passing gate before expected CI - // reports, because the review check may itself be branch-protection-required. + // 30-minute stale-CI cap -- proves the short, class-specific cap governs the finalize timing here. await env.SELFHOST_TRANSIENT_CACHE?.set( "ci-pending-first-seen:owner/agent-repo#7:a7", String(Date.now() - 3 * 60 * 1000), @@ -2449,15 +2459,20 @@ describe("queue processors", () => { try { await processJob(env, { type: "agent-regate-pr", deliveryId: "missing-context-past-short-cap", repoFullName: "owner/agent-repo", prNumber: 7, installationId: 9001 }); - expect(gateChecks).toBe(0); - const deferred = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?") - .bind("github_app.review_deferred_ci_pending") - .first<{ n: number }>(); + // A hold is published (a real gate check-run), not silence, and not a passing/merging verdict. + expect(gateChecks).toBeGreaterThan(0); const finalized = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?") .bind("github_app.review_finalized_ci_stuck") .first<{ n: number }>(); - expect(deferred?.n).toBe(1); - expect(finalized?.n).toBe(0); + expect(finalized?.n).toBe(1); + // The finalize audit carries the distinguishable reason (#9000/#9003's ask), separable from an ordinary + // "CI still running" finalize. + const missingContextFinalize = await env.DB.prepare( + "select count(*) as n from audit_events where event_type = ? and detail like ?", + ) + .bind("github_app.review_finalized_ci_stuck", "A required CI context never appeared%") + .first<{ n: number }>(); + expect(missingContextFinalize?.n).toBe(1); } finally { liveCiSpy.mockRestore(); requiredContextsSpy.mockRestore(); @@ -5823,6 +5838,56 @@ describe("queue processors", () => { expect(publicCommentBody).toContain("Null pointer on empty input"); }); + it("REGRESSION (#9000): a paused PR with no reusable prior review names the gap instead of silently doing nothing", async () => { + // Same silent-branch class as the frozen-reuse-unavailable regression above, for the paused-cadence path: + // the ONE prior published review that pushed this PR past its pause threshold has no public assessment + // (e.g. an inconclusive/malformed row), so there is nothing to reuse -- before #9000 this left aiReviewWillRun + // false with zero audit trail. + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "Looks fine.", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await seedRegateChurnRepo(env, { publicSurface: "comment_only" }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "all_prs", publicSurface: "comment_only", checkRunMode: "off", reviewCheckMode: "required", aiReviewMode: "block" }, review: { auto_review: { auto_pause_after_reviewed_commits: 1 } } }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 83, title: "Paused, nothing reusable", state: "open", draft: false, user: { login: "contributor" }, head: { sha: "a83-v2" }, labels: [], body: "Closes #1" } as never); + await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 83, status: "complete", reviewsSyncedAt: new Date().toISOString() }); + // The one published review that reaches the threshold has NO notes at all -- hasPublicReviewAssessment fails. + await putCachedAiReview(env, "JSONbored/gittensory", 83, "a83-v1", "block", { notes: "", reviewerCount: 1 }); + await markAiReviewPublished(env, "JSONbored/gittensory", 83, "a83-v1"); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/pulls/83/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/83")) return Response.json({ number: 83, title: "Paused, nothing reusable", state: "open", draft: false, user: { login: "contributor" }, head: { sha: "a83-v2" }, labels: [], body: "Closes #1", mergeable_state: "clean" }); + if (url.includes("/commits/a83-v2/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a83-v2/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/issues/83/comments")) return method === "GET" ? Response.json([]) : Response.json({ id: 83 }, { status: 201 }); + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await expect( + processJob(env, { type: "agent-regate-pr", deliveryId: "paused-nothing-reusable", repoFullName: "JSONbored/gittensory", prNumber: 83, installationId: 123 }), + ).resolves.toBeUndefined(); + + expect(aiCalls).toBe(0); + const pausedReuseCount = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_paused_reuse", "JSONbored/gittensory#83") + .first<{ n: number }>(); + expect(pausedReuseCount?.n).toBe(0); // nothing to reuse, so the ordinary reuse event never fires + const gapAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_paused_reuse_unavailable", "JSONbored/gittensory#83") + .first<{ outcome: string; detail: string }>(); + expect(gapAudit?.outcome).toBe("completed"); + expect(gapAudit?.detail).toContain("no prior published AI review"); + }); + it("#9: the public surface is not republished when already current at the head (check-run-only repo, req 6)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), @@ -7436,6 +7501,52 @@ describe("queue processors", () => { expect(bypassAudit?.outcome).toBe("completed"); // the retrigger genuinely bypassed the freeze, not just a coincidental reuse }); + it("REGRESSION (#9000): a PR frozen for manual review with NO reusable prior review names the gap instead of silently doing nothing", async () => { + // Before #9000, this branch of the freeze logic (frozenReview null, or one with no public assessment) had + // NO audit event at all -- aiReviewWillRun was false, nothing was reused, and the audit trail contained + // zero explanation. This is the exact class of silence #9000 (evidence: #8972) closes. + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "Fresh (should not happen while frozen).", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await seedRegateChurnRepo(env, { publicSurface: "comment_only" }); + // Held for manual review from a PRIOR pass, but -- unlike the sibling test above -- NO review was ever + // published for this PR, so there is genuinely nothing to reuse. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 82, title: "Held, never reviewed", state: "open", user: { login: "contributor" }, head: { sha: "a82-v1" }, labels: [{ name: "manual-review" }], body: "Closes #1" }); + await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 82, status: "complete", reviewsSyncedAt: new Date().toISOString() }); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/pulls/82/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/82")) return Response.json({ number: 82, title: "Held, never reviewed", state: "open", user: { login: "contributor" }, head: { sha: "a82-v1" }, labels: [{ name: "manual-review" }], body: "Closes #1", mergeable_state: "clean" }); + if (url.includes("/commits/a82-v1/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a82-v1/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/issues/82/comments")) return method === "GET" ? Response.json([]) : Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "held-never-reviewed", repoFullName: "JSONbored/gittensory", prNumber: 82, installationId: 123 }); + + expect(aiCalls).toBe(0); // still frozen -- never spends a fresh call + const freezeReuseCount = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_frozen_reuse", "JSONbored/gittensory#82") + .first<{ n: number }>(); + expect(freezeReuseCount?.n).toBe(0); // nothing to reuse, so the ordinary reuse event never fires + const gapAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_frozen_reuse_unavailable", "JSONbored/gittensory#82") + .first<{ outcome: string; detail: string }>(); + expect(gapAudit?.outcome).toBe("completed"); + expect(gapAudit?.detail).toContain("no prior published AI review"); + }); + it("REGRESSION (#3725, reproduces #3702): the PR-panel rerun checkbox forces a fresh AI review instead of silently replaying a cached one", async () => { // #3702 showed "Code review: No blockers | No AI review summary" (reviewerCount 0) and re-checking the // panel's rerun checkbox repeatedly did not fix it -- the retrigger ran but never set `forceAiReview`, so @@ -7505,6 +7616,77 @@ describe("queue processors", () => { expect(bypassAudit?.outcome).toBe("completed"); }); + it("REGRESSION (#9000, root cause of the #8972 incident): a forced retrigger blocked by the public-review hard gate names the exact reason instead of silently doing nothing", async () => { + // #8972 evidence: a forced (`forceAiReview: true`) panel retrigger ran, completed, republished the same + // stale placeholder, and left NONE of the events a forced pass should emit (no force_bypass, no cache + // miss/hit, no auto_review_skipped, no frozen-reuse) -- something gated aiReviewWillRun off with zero + // record. shouldStartAiReviewForAdvisory folds in shouldRequirePublicAiReviewForAdvisory's hard gate, which + // does NOT bypass on forceAiReview (only the reputation skip does) and, before #9000, had no audit trail + // on ANY of its branches. Here the AI_SUMMARIES_ENABLED kill-switch is off, reproducing that exact class of + // silent gate failure on an explicit forced retrigger. + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: JSON.stringify({ assessment: "Should never run.", blockers: [], nits: [], suggestions: [] }) }) } as unknown as Ai, + AI_SUMMARIES_ENABLED: "false", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await seedRegateChurnRepo(env); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 84, title: "Forced retrigger, AI summaries off", state: "open", user: { login: "contributor" }, head: { sha: "a84" }, labels: [], body: "Closes #1" }); + await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 84, status: "complete", reviewsSyncedAt: new Date().toISOString() }); + + const checkedPanel = [ + "", + "", + "- [x] Re-run LoopOver review", + ].join("\n"); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "maintain" }); + if (url.includes("/pulls/84/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/84")) return Response.json({ number: 84, title: "Forced retrigger, AI summaries off", state: "open", user: { login: "contributor" }, head: { sha: "a84" }, labels: [], body: "Closes #1", mergeable_state: "clean" }); + if (url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/84/comments") && method === "GET") return Response.json([{ id: 778, body: checkedPanel, user: { login: "loopover-orb[bot]", type: "Bot" } }]); + if (url.includes("/issues/comments/778") && method === "PATCH") return Response.json({ id: 778 }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "panel-retrigger-blocked-by-hard-gate", + eventName: "issue_comment", + payload: { + action: "edited", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 84, title: "Forced retrigger, AI summaries off", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 778, body: checkedPanel, user: { login: "loopover-orb[bot]", type: "Bot" } }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + // The retrigger itself was accepted and processed (receipt) ... + const retriggerAudit = await env.DB.prepare("select outcome from audit_events where event_type = ? and target_key = ?") + .bind("github_app.pr_panel_retriggered", "JSONbored/gittensory#84") + .first<{ outcome: string }>(); + expect(retriggerAudit?.outcome).toBe("completed"); + // ... but the hard gate silently blocked the AI review itself -- previously zero audit trail explaining why. + const gapAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_public_gate_skipped", "JSONbored/gittensory#84") + .first<{ outcome: string; detail: string }>(); + expect(gapAudit?.outcome).toBe("completed"); + expect(gapAudit?.detail).toContain("ai_summaries_disabled"); + const gapMetadata = await env.DB.prepare("select metadata_json from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_public_gate_skipped", "JSONbored/gittensory#84") + .first<{ metadata_json: string }>(); + expect(JSON.parse(gapMetadata?.metadata_json ?? "{}")).toMatchObject({ reason: "ai_summaries_disabled", forced: true }); + }); + it("#9008: a forced re-run steals an orphaned AI-review lock instead of silently landing on the contended placeholder", async () => { let aiCalls = 0; const env = createTestEnv({ @@ -7649,6 +7831,52 @@ describe("queue processors", () => { expect(reuseAudit?.detail).toContain("one-shot review cadence"); }); + it("REGRESSION (#9000): one-shot already spent its review but has nothing reusable -- names the gap instead of silently doing nothing", async () => { + // Same silent-branch class as the frozen/paused unavailable regressions above: the prior published + // review this PR already spent its one shot on has no public assessment, so there is nothing to reuse. + // Before #9000 this left aiReviewWillRun false with zero audit trail. + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "Fresh (should not happen under one-shot).", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await seedRegateChurnRepo(env, { publicSurface: "comment_only" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 93, title: "One-shot, nothing reusable", state: "open", user: { login: "contributor" }, head: { sha: "a93-v1" }, labels: [], body: "Closes #1" }); + await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 93, status: "complete", reviewsSyncedAt: new Date().toISOString() }); + await putCachedAiReview(env, "JSONbored/gittensory", 93, "a93-v1", "block", { notes: "", reviewerCount: 1 }); + await markAiReviewPublished(env, "JSONbored/gittensory", 93, "a93-v1"); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if (url.includes("/pulls/93/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 2, deletions: 0, changes: 2, patch: "@@\n+export const ok = true;\n+export const also = 1;" }]); + if (url.endsWith("/pulls/93")) return Response.json({ number: 93, title: "One-shot, nothing reusable", state: "open", user: { login: "contributor" }, head: { sha: "a93-v2" }, labels: [], body: "Closes #1", mergeable_state: "clean" }); + if (url.includes("/commits/a93-v2/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a93-v2/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/issues/93/comments")) return method === "GET" ? Response.json([]) : Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "one-shot-nothing-reusable", repoFullName: "JSONbored/gittensory", prNumber: 93, installationId: 123 }); + + expect(aiCalls).toBe(0); + const reuseCount = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_one_shot_reuse", "JSONbored/gittensory#93") + .first<{ n: number }>(); + expect(reuseCount?.n).toBe(0); // nothing to reuse, so the ordinary reuse event never fires + const gapAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?") + .bind("github_app.ai_review_one_shot_reuse_unavailable", "JSONbored/gittensory#93") + .first<{ outcome: string; detail: string }>(); + expect(gapAudit?.outcome).toBe("completed"); + expect(gapAudit?.detail).toContain("no public assessment to reuse"); + }); + it("default (one_shot): a PR with no prior review yet still gets its first pass -- one-shot never blocks the FIRST review", async () => { let aiCalls = 0; const env = createTestEnv({ From b834112f7be6d02ec8e9e8b6e274110c47adbe03 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:24:02 -0700 Subject: [PATCH 2/2] fix(review): correct stale 'gittensory' brand reference in a comment #9173 introduced a comment referencing the deprecated pre-rename product name, which the branding-drift check on main was not catching -- breaking CI for every PR based on current main, including this one. --- src/review/predicted-gate-agreement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/review/predicted-gate-agreement.ts b/src/review/predicted-gate-agreement.ts index 4214e49373..726ef4dfc6 100644 --- a/src/review/predicted-gate-agreement.ts +++ b/src/review/predicted-gate-agreement.ts @@ -1,6 +1,6 @@ // Predicted-vs-live gate agreement (#predicted-live-gate-agreement, maintainer review-stack x AMS integration // audit 2026-07-09) -- a SIBLING to computeGateEval/computeGateParity (src/review/parity.ts), answering a -// DIFFERENT question than either: not "does gittensory match reviewbot" (computeGateParity) and not "does one +// DIFFERENT question than either: not "does loopover match reviewbot" (computeGateParity) and not "does one // system's prediction match the human's realized merge/close" (computeGateEval), but "does the MCP predict_gate // tool's pre-submission verdict for a contributor match the REAL gate decision their eventual PR receives." //