diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 24492d633d..966e030d8d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -75,6 +75,7 @@ import { fetchAndStorePullRequestFilesForReview, fetchLinkedIssueFacts, fetchLiveCiAggregate, + type LiveCiAggregate, fetchLivePullRequest, fetchLivePullRequestHeadSha, fetchLivePullRequestMergeState, @@ -409,6 +410,182 @@ const PR_PUBLIC_SURFACE_ACTIONS = new Set([ const PR_GATE_CLOSED_ACTIONS = new Set(["closed"]); const ISSUE_PLAN_COOLDOWN_MS = 10 * 60 * 1000; +interface LiveGithubFacts { + requiredContexts: Map | null>>; + ciAggregates: Map>; + mergeStates: Map>; +} + +function createLiveGithubFacts(): LiveGithubFacts { + return { + requiredContexts: new Map(), + ciAggregates: new Map(), + mergeStates: new Map(), + }; +} + +function liveFactKey(...parts: Array): string { + return JSON.stringify(parts.map((part) => [typeof part, part])); +} + +function liveFactTokenPart(token: string | undefined): string { + if (!token) return "token:none"; + let hash = 0x811c9dc5; + for (let index = 0; index < token.length; index += 1) { + hash ^= token.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `token:${token.length}:${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function primeLiveMergeState( + facts: LiveGithubFacts, + repoFullName: string, + prNumber: number, + token: string | undefined, + mergeState: unknown, +): void { + if (typeof mergeState !== "string") return; + facts.mergeStates.set( + liveFactKey(repoFullName, prNumber, liveFactTokenPart(token)), + Promise.resolve(mergeState), + ); +} + +function cachedRequiredStatusContexts( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + baseRef: string | null | undefined, + token: string | undefined, +): Promise | null> { + const key = liveFactKey(repoFullName, baseRef, liveFactTokenPart(token)); + const cached = facts.requiredContexts.get(key); + if (cached) return cached; + const next = evictLiveFactOnReject( + facts.requiredContexts, + key, + fetchRequiredStatusContexts(env, repoFullName, baseRef, token), + ); + facts.requiredContexts.set(key, next); + return next; +} + +function evictLiveFactOnReject( + cache: Map>, + key: string, + promise: Promise, +): Promise { + return promise.catch((error) => { + cache.delete(key); + throw error; + }); +} + +function fetchLiveCiAggregateWithRequiredContexts( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + headSha: string | null | undefined, + baseRef: string | null | undefined, + token: string | undefined, +): Promise { + // CI refresh callers need fresh check/status state; branch protection contexts move slowly enough to stay request-cached. + return cachedRequiredStatusContexts(env, repoFullName, facts, baseRef, token) + .catch(() => null) + .then((requiredContexts) => + fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts), + ); +} + +function cachedLiveCiAggregate( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + headSha: string | null | undefined, + baseRef: string | null | undefined, + token: string | undefined, +): Promise { + const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token)); + const cached = facts.ciAggregates.get(key); + if (cached) return cached; + const next = evictLiveFactOnReject( + facts.ciAggregates, + key, + fetchLiveCiAggregateWithRequiredContexts( + env, + repoFullName, + facts, + headSha, + baseRef, + token, + ), + ); + facts.ciAggregates.set(key, next); + return next; +} + +function refreshLiveCiAggregate( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + headSha: string | null | undefined, + baseRef: string | null | undefined, + token: string | undefined, +): Promise { + const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token)); + const next = evictLiveFactOnReject( + facts.ciAggregates, + key, + fetchLiveCiAggregateWithRequiredContexts( + env, + repoFullName, + facts, + headSha, + baseRef, + token, + ), + ); + facts.ciAggregates.set(key, next); + return next; +} + +function cachedLiveMergeState( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + prNumber: number, + token: string | undefined, +): Promise { + const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token)); + const cached = facts.mergeStates.get(key); + if (cached) return cached; + const next = evictLiveFactOnReject( + facts.mergeStates, + key, + fetchLivePullRequestMergeState(env, repoFullName, prNumber, token), + ); + facts.mergeStates.set(key, next); + return next; +} + +function refreshLiveMergeState( + env: Env, + repoFullName: string, + facts: LiveGithubFacts, + prNumber: number, + token: string | undefined, +): Promise { + const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token)); + const next = evictLiveFactOnReject( + facts.mergeStates, + key, + fetchLivePullRequestMergeState(env, repoFullName, prNumber, token), + ); + facts.mergeStates.set(key, next); + return next; +} + /** * Run (or dry-run) the data-retention prune across the configured log/snapshot tables and audit the * outcome. The per-table windows live in RETENTION_POLICY; only append-only/superseded tables are pruned. @@ -1244,6 +1421,7 @@ async function maybeRunAgentMaintenance( otherOpenPullRequests: PullRequestRecord[]; deliveryId: string; gate: ReturnType | undefined; + liveFacts: LiveGithubFacts; }, ): Promise { const { @@ -1282,6 +1460,8 @@ async function maybeRunAgentMaintenance( const ciToken = await createInstallationToken(env, installationId).catch( () => undefined, ); + const token = ciToken ?? env.GITHUB_PUBLIC_TOKEN; + const baseRef = pr.baseRef ?? args.repo?.defaultBranch; const [ changedFiles, hardGuardrailGlobs, @@ -1297,37 +1477,29 @@ async function maybeRunAgentMaintenance( loadHardGuardrailGlobs(env, repoFullName), // RC2: branch-protection REQUIRED status contexts, so only a required red check gates the PR (a red // codecov/* is surfaced but never blocks merge/approve or forces request_changes). null ⇒ fold all red. - fetchRequiredStatusContexts( - env, - repoFullName, - pr.baseRef ?? args.repo?.defaultBranch, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - ), - // Live mergeable_state — the stored one lags GitHub's async recompute after the bot's own approve, which - // otherwise leaves a green+approved PR stuck OPEN at mergeState=CLEAN (never auto-merged). - fetchLivePullRequestMergeState( + cachedRequiredStatusContexts( env, repoFullName, - pr.number, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, + args.liveFacts, + baseRef, + token, ), + // Live mergeable_state after the gate's own publish/review/check mutations. Readiness may have seen the PR as + // blocked before the bot approval/check landed, so this boundary must refresh instead of replaying the cache. + refreshLiveMergeState(env, repoFullName, args.liveFacts, pr.number, token), // RC1: live reviewDecision so the approve/request-changes dedup is accurate. The STORED reviewDecision is // only written by the open-PR backfill and goes stale → the planner re-posted a review every cycle (the // re-review loop with 14-23 stacked reviews). With the live value, an already-approved/changes-requested PR // is not re-reviewed for the same state. - fetchLivePullRequestReviewDecision( - env, - repoFullName, - pr.number, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - ), + fetchLivePullRequestReviewDecision(env, repoFullName, pr.number, token), ]); - const ciAggregate = await fetchLiveCiAggregate( + const ciAggregate = await refreshLiveCiAggregate( env, repoFullName, + args.liveFacts, pr.headSha, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - requiredContexts, + baseRef, + token, ); const changedPaths = changedPathsForGuardrail(changedFiles); const repoOwner = repoFullName.includes("/") @@ -1496,6 +1668,7 @@ async function reReviewStoredPullRequest( ]); let pr = await getPullRequest(env, repoFullName, prNumber); if (!pr || pr.state !== "open") return; + const liveFacts = createLiveGithubFacts(); // #sweep-resync: RESYNC the stored PR to its LIVE head before reviewing. The self-host relay can drop the // `synchronize` webhook (relay down), so a push/rebase never refreshes the stored head SHA + cached files; the // sweep would then review a STALE diff and the AI fail-closes it as INCOHERENT_DIFF, stranding the PR in "held". @@ -1511,6 +1684,7 @@ async function reReviewStoredPullRequest( prNumber, resyncToken, ); + primeLiveMergeState(liveFacts, repoFullName, prNumber, resyncToken, live?.mergeable_state); if (live?.head?.sha && live.head.sha !== pr.headSha) { await upsertPullRequestFromGitHub(env, repoFullName, live).catch( () => undefined, @@ -1524,8 +1698,8 @@ async function reReviewStoredPullRequest( // Operator review flow: rebase-if-behind → wait for ALL CI to finish → only THEN review. Defers (returns) when // a rebase fired a synchronize, or CI is still running — the synchronize / CI-completion webhook re-triggers // once the head is current and CI has settled (the sweep backstops a missed event). REST-budget dedup - // (#audit-rate-headroom): thread the already-fetched live PR's `mergeable_state` so prReadyForReview reuses it - // instead of issuing a second `GET /pulls/{n}` for the behind-base check. + // (#audit-rate-headroom): seed the request-local facts from the resync payload, then share them with the + // readiness check, public surface, and auto-maintain planner. if ( !(await prReadyForReview( env, @@ -1534,7 +1708,7 @@ async function reReviewStoredPullRequest( pr, settings, deliveryId, - { liveMergeState: live?.mergeable_state ?? undefined }, + liveFacts, )) ) return; @@ -1585,6 +1759,7 @@ async function reReviewStoredPullRequest( { deliveryId, baseSha: live?.base?.sha ?? null, + liveFacts, ...(previewPollAttempt !== undefined ? { previewPollAttempt } : {}), ...(options.skipAiReview ? { skipAiReview: true } : {}), }, @@ -1612,6 +1787,7 @@ async function reReviewStoredPullRequest( otherOpenPullRequests, deliveryId, gate, + liveFacts, }).catch((error) => { console.error( JSON.stringify({ @@ -1643,12 +1819,9 @@ async function prReadyForReview( pr: PullRequestRecord, settings: RepositorySettings, deliveryId: string, - // REST-budget dedup (#audit-rate-headroom): the re-gate sweep already fetched the FULL live PR once (the resync - // `GET /pulls/{n}`), whose `mergeable_state` is exactly what the behind-base check needs. When the caller passes - // it, REUSE it instead of issuing a second `GET /pulls/{n}` here. `undefined` ⇒ no payload (the webhook path, - // which has no pre-fetched live PR) ⇒ fall back to the live fetch. The shared installation REST bucket is one - // hourly budget across all repos, so removing the duplicate GET halves the per-regate `GET /pulls/{n}` cost. - options: { liveMergeState?: string | undefined } = {}, + // REST-budget dedup (#audit-rate-headroom): callers thread a request-local live-facts bag through readiness, + // public rendering, and auto-maintain so one review/regate job only pays for each mutable GitHub read once. + liveFacts: LiveGithubFacts, ): Promise { // Only gate an OPEN, non-draft, agent-configured PR. A closed PR (the live path also runs on `closed` to // finalize / record reputation) must NOT be rebased or CI-waited — proceed so finalization runs. @@ -1664,14 +1837,10 @@ async function prReadyForReview( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; if (!token) return true; - // 1) rebase if BEHIND base — the synchronize on the new head re-triggers this flow on the merged result. Reuse a - // caller-supplied mergeable_state (the sweep's resync payload) to skip a redundant `GET /pulls/{n}`; fall back to - // the live fetch only when no payload was threaded (the webhook path). fetchLivePullRequestMergeState fails open - // internally (swallows its own fetch errors → undefined), so the fallback needs no extra catch — mirroring the - // resync's fetchLivePullRequest call above. - const liveMergeState = - options.liveMergeState ?? - (await fetchLivePullRequestMergeState(env, repoFullName, pr.number, token)); + // 1) rebase if BEHIND base — the synchronize on the new head re-triggers this flow on the merged result. The + // request-local facts may already be seeded from the sweep's resync payload, and the fallback live merge-state + // fetch fails open internally (swallows its own fetch errors → undefined). + const liveMergeState = await cachedLiveMergeState(env, repoFullName, liveFacts, pr.number, token); if (liveMergeState === "behind") { const autonomyLevel = resolveAutonomy(settings.autonomy, "update_branch"); const installation = await getInstallation(env, installationId); @@ -1704,19 +1873,7 @@ async function prReadyForReview( } // 2) wait for CI to finish before running the Gittensory review. Required contexts still define which failures // block/close, but hasPending tracks any visible non-bot CI that is not settled yet. - const requiredContexts = await fetchRequiredStatusContexts( - env, - repoFullName, - pr.baseRef, - token, - ).catch(() => null); - const ci = await fetchLiveCiAggregate( - env, - repoFullName, - pr.headSha, - token, - requiredContexts, - ).catch(() => undefined); + const ci = await cachedLiveCiAggregate(env, repoFullName, liveFacts, pr.headSha, pr.baseRef, token).catch(() => undefined); if (ci?.hasPending) { // Staleness cap: genuinely-running CI settles in minutes. A required check that stays pending far longer // (an orphaned / never-completing check — e.g. a fork check that never reports back) would otherwise make us @@ -3067,6 +3224,7 @@ async function processGitHubWebhook( let gate: | Awaited> | undefined; + const liveFacts = createLiveGithubFacts(); if ( await prReadyForReview( env, @@ -3075,6 +3233,7 @@ async function processGitHubWebhook( pr, settings, deliveryId, + liveFacts, ) ) { gate = await maybePublishPrPublicSurface( @@ -3090,6 +3249,7 @@ async function processGitHubWebhook( authorType: payload.pull_request.user?.type, action: payload.action, baseSha: payload.pull_request.base?.sha ?? null, + liveFacts, }, ).catch((error) => { if (isGitHubRateLimitedError(error) || isRetryableJobError(error)) throw error; @@ -3117,6 +3277,7 @@ async function processGitHubWebhook( otherOpenPullRequests, deliveryId, gate, + liveFacts, }).catch((error) => { /* v8 ignore next -- best-effort: auto-maintain failures are logged, never surfaced to the gate. */ console.error( @@ -4295,6 +4456,7 @@ async function maybePublishPrPublicSurface( baseSha?: string | null | undefined; previewPollAttempt?: number | undefined; skipAiReview?: boolean | undefined; + liveFacts: LiveGithubFacts; }, ): Promise | undefined> { const author = pr.authorLogin ?? null; @@ -5228,31 +5390,15 @@ async function maybePublishPrPublicSurface( const ciToken = await createInstallationToken(env, installationId).catch( () => undefined, ); + const token = ciToken ?? env.GITHUB_PUBLIC_TOKEN; + const baseRef = pr.baseRef ?? repo?.defaultBranch; // Required contexts still detect missing/pending required CI, but every visible completed red check/status is // adverse and blocks the PR. - const requiredContexts = await fetchRequiredStatusContexts( - env, - repoFullName, - pr.baseRef ?? repo?.defaultBranch, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - ); - const liveCi = await fetchLiveCiAggregate( - env, - repoFullName, - pr.headSha, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - requiredContexts, - ); + const liveCi = await refreshLiveCiAggregate(env, repoFullName, webhook.liveFacts, pr.headSha, baseRef, token); // Live merge-state too — the SAME source the disposition uses (planAgentMaintenanceActions reads liveMergeState). - // The stored pr.mergeableState lags GitHub's async recompute, so a base-conflicting PR could read `clean` here - // ("✅ safe to merge") while the disposition reads the live `dirty` and auto-CLOSES it — the exact #4220 - // contradiction. (#review-audit / #ready-needs-mergeable) - const liveMergeState = await fetchLivePullRequestMergeState( - env, - repoFullName, - pr.number, - ciToken ?? env.GITHUB_PUBLIC_TOKEN, - ).catch(() => undefined); + // The stored pr.mergeableState lags GitHub's async recompute, and the gate's own check/review publication can + // also advance mergeability after readiness ran, so refresh at this post-publish boundary. + const liveMergeState = await refreshLiveMergeState(env, repoFullName, webhook.liveFacts, pr.number, token).catch(() => undefined); const mergeStateLabel = liveMergeState ?? pr.mergeableState; // fail-safe to the stored value const ciState: MergeReadiness["ciState"] = liveCi.ciState === "passed" @@ -6221,6 +6367,7 @@ async function maybeProcessPrPanelRetrigger( ) { await refreshPullRequestDetails(env, repoFullName, pr.number); } + const liveFacts = createLiveGithubFacts(); if ( !(await prReadyForReview( env, @@ -6229,6 +6376,7 @@ async function maybeProcessPrPanelRetrigger( pr, settings, deliveryId, + liveFacts, )) ) { await recordAuditEvent(env, { @@ -6252,6 +6400,7 @@ async function maybeProcessPrPanelRetrigger( { deliveryId, action: "manual_retrigger", + liveFacts, }, ); await recordGithubProductUsage(env, "pr_panel_retriggered", { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index d9bdf5799b..3e8fa13f56 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { PR_PANEL_COMMENT_MARKER } from "../../src/github/comments"; +import * as backfillModule from "../../src/github/backfill"; import * as repositoriesModule from "../../src/db/repositories"; import * as sentryModule from "../../src/selfhost/sentry"; import { @@ -801,40 +802,182 @@ describe("queue processors", () => { resyncUpsertSpy.mockRestore(); }); - // REST-budget dedup (#audit-rate-headroom): the sweep resync already fetched the full live PR (its mergeable_state - // covers the behind-base check), so prReadyForReview must REUSE it instead of issuing its own `GET /pulls/{n}`. The - // only bare `GET /pulls/7` reads in the per-PR re-review are now the resync (1) + auto-maintain's merge-state (1) — - // never a third from prReadyForReview. (This pins the redundancy removal: before the dedup there were three.) - it("#audit-rate-headroom: the per-PR re-review reuses the resync payload's merge state — prReadyForReview issues NO extra GET /pulls/{n}", async () => { + // REST-budget dedup (#audit-rate-headroom): one per-PR re-review threads request-local live GitHub facts through + // readiness and auto-maintain, while post-gate planning refreshes facts that can change after the bot publishes + // review/check state. Mergeability can advance to clean; CI can flip red and must still suppress merge. + it("#audit-rate-headroom: the per-PR re-review refreshes merge state and CI after the gate publication boundary", 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" }, events: [] } }); await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9001); - await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto", update_branch: "auto" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); - await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "Closes #1" }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto", update_branch: "auto" }, autoMaintain: { requireApprovals: 0, mergeMethod: "squash" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main" }, labels: [], body: "Closes #1" }); let barePullGets = 0; + let branchProtectionGets = 0; + let liveCheckRunsGets = 0; + let statusGets = 0; + let mergeAttempts = 0; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = (init?.method ?? "GET").toUpperCase(); if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - // The full PR payload carries mergeable_state CLEAN (not behind) + the live head. Count only the bare - // `GET /pulls/7` (no sub-resource, GET only) — the redundant prReadyForReview fetch would be a third here. + // Count only the bare `GET /pulls/7` (no sub-resource, GET only). The resync payload starts blocked, then the + // post-gate maintenance read observes the bot's newly published review/check state as clean. if (/\/pulls\/7(?:\?|$)/.test(url) && method === "GET") { barePullGets += 1; - return Response.json({ number: 7, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, mergeable_state: "clean", labels: [], body: "Closes #1" }); + return Response.json({ + number: 7, + title: "Clean PR", + state: "open", + user: { login: "contributor" }, + head: { sha: "a7" }, + mergeable_state: barePullGets === 1 ? "blocked" : "clean", + labels: [], + body: "Closes #1", + }); } if (url.includes("/pulls/7/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); - if (url.includes("/commits/a7/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/commits/a7/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/commits/a7/check-runs") && url.includes("per_page=100")) { + liveCheckRunsGets += 1; + return Response.json({ total_count: 0, check_runs: [] }); + } + if (url.includes("/commits/a7/status")) { + statusGets += 1; + return Response.json( + statusGets === 1 + ? { state: "success", statuses: [] } + : { + state: "failure", + statuses: [ + { + context: "codecov/patch", + state: "failure", + description: "patch coverage below target", + target_url: "https://ci.example.test/codecov", + }, + ], + }, + ); + } + if (url.includes("/pulls/7/merge") && method === "PUT") { + mergeAttempts += 1; + return Response.json({ merged: true, sha: "merged-a7" }); + } 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: [] } } }); + if (url.includes("/branches/")) { + branchProtectionGets += 1; + return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + } return Response.json({}); }); vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); await processJob(env, { type: "agent-regate-pr", deliveryId: "dedup-pulls-get", repoFullName: "owner/agent-repo", prNumber: 7, installationId: 9001 }); - // Resync (1) + auto-maintain merge-state (1) = 2; the deduped prReadyForReview adds NONE (was 3 before the fix). + // Readiness reuses the resync payload, but auto-maintain refreshes merge-state and CI after gate publication. expect(barePullGets).toBe(2); + expect(branchProtectionGets).toBe(1); + expect(liveCheckRunsGets).toBe(2); + expect(statusGets).toBe(2); + expect(mergeAttempts).toBe(0); + }); + + it("#audit-rate-headroom: auto-maintain falls back to the public token when a post-gate mint fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITHUB_PUBLIC_TOKEN: "public-token" }); + 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); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto", approve: "auto", update_branch: "auto" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main" }, labels: [], body: "Closes #1" }); + let gateFinalized = false; + let failedMaintenanceMint = false; + let publicFallbackUsed = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + const headersText = init?.headers instanceof Headers ? JSON.stringify([...init.headers.entries()]) : JSON.stringify(init?.headers ?? {}); + if (url.includes("/access_tokens")) { + if (gateFinalized && !failedMaintenanceMint) { + failedMaintenanceMint = true; + return new Response("mint failed", { status: 500 }); + } + return Response.json({ token: "installation-token", expires_at: "2026-05-28T00:04:00.000Z" }); + } + if (/\/pulls\/7(?:\?|$)/.test(url) && method === "GET") return Response.json({ number: 7, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main", sha: "base" }, mergeable_state: "clean", labels: [], body: "Closes #1" }); + if (url.includes("/pulls/7/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.includes("/commits/a7/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a7/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/commits/a7/check-suites")) return Response.json({ check_suites: [] }); + if (url.includes("/branches/")) { + if (gateFinalized && headersText.includes("public-token")) publicFallbackUsed = true; + return Response.json({ contexts: [] }); + } + if (url === "https://api.github.com/graphql") return Response.json({ data: { repository: { pullRequest: { reviewDecision: "APPROVED" } } } }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/check-runs") && method === "GET") return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string }; + if (body.status !== "in_progress" || body.conclusion) { + gateFinalized = true; + clearInstallationTokenCacheForTest(); + } + return Response.json({ id: 901 }, { status: 201 }); + } + if (url.includes("/check-runs/901") && method === "PATCH") { + gateFinalized = true; + clearInstallationTokenCacheForTest(); + return Response.json({ id: 901 }); + } + return Response.json({}); + }); + + await processJob(env, { type: "agent-regate-pr", deliveryId: "dedup-public-token-fallback", repoFullName: "owner/agent-repo", prNumber: 7, installationId: 9001 }); + + expect(failedMaintenanceMint).toBe(true); + expect(publicFallbackUsed).toBe(true); + }); + + it("#audit-rate-headroom: required-context lookup failures still fetch pending CI before review", 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); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto", update_branch: "auto" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Pending CI", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main" }, labels: [], body: "Closes #1" }); + const requiredContextsSpy = vi + .spyOn(backfillModule, "fetchRequiredStatusContexts") + .mockRejectedValue(new Error("branch protection unavailable")); + let checkRunsFetched = false; + let gateChecks = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (/\/pulls\/7(?:\?|$)/.test(url) && method === "GET") return Response.json({ number: 7, title: "Pending CI", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, mergeable_state: "clean", labels: [], body: "Closes #1" }); + if (url.includes("/pulls/7/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.includes("/commits/a7/check-runs")) { + checkRunsFetched = true; + return Response.json({ total_count: 1, check_runs: [{ name: "CI build", status: "in_progress", conclusion: null, app: { slug: "github-actions" } }] }); + } + if (url.includes("/commits/a7/status")) return Response.json({ state: "pending", statuses: [] }); + if (url.includes("/check-runs") && method === "POST") { + gateChecks += 1; + return Response.json({ id: 901 }, { status: 201 }); + } + return Response.json({}); + }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + try { + await processJob(env, { type: "agent-regate-pr", deliveryId: "ci-required-contexts-fail", repoFullName: "owner/agent-repo", prNumber: 7, installationId: 9001 }); + + expect(requiredContextsSpy).toHaveBeenCalled(); + expect(checkRunsFetched).toBe(true); + 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 }>(); + expect(deferred?.n).toBe(1); + } finally { + requiredContextsSpy.mockRestore(); + } }); it("#sweep-resync: a failing resync upsert is swallowed (fail-open) — the sweep never throws", async () => { @@ -4815,7 +4958,7 @@ describe("queue processors", () => { // comment. Mirrors the legacy panel-posting setup (confirmed miner + comment_and_label) but flips the flag // and enables the gate so `maybePublishPrPublicSurface` takes the flag-ON branch. it("renders the unified PR-review comment when the flag is on and the gate evaluates", async () => { - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITHUB_PUBLIC_TOKEN: "public-token", GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); await persistRegistrySnapshot( env, normalizeRegistryPayload( @@ -4836,9 +4979,21 @@ describe("queue processors", () => { gateCheckMode: "enabled", backfillEnabled: true, privateTrustEnabled: true, + autonomy: { update_branch: "auto" }, }); let postedBody = ""; const calls = { comments: 0, gateChecks: 0 }; + let gateFinalized = false; + let failedPostGateMint = false; + const liveCiSpy = vi + .spyOn(backfillModule, "fetchLiveCiAggregate") + .mockRejectedValueOnce(new Error("transient CI read failed")) + .mockResolvedValue({ + ciState: "passed", + hasPending: false, + failingDetails: [], + nonRequiredFailingDetails: [], + }); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; @@ -4884,7 +5039,13 @@ describe("queue processors", () => { if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 }); if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) { + if (gateFinalized && !failedPostGateMint) { + failedPostGateMint = true; + return new Response("mint failed", { status: 500 }); + } + return Response.json({ token: "installation-token", expires_at: "2026-05-28T00:04:00.000Z" }); + } // PR files — the unified branch (re)fetches them to count changed files for the readiness chip. if (url.includes("/pulls/3/files")) return Response.json([{ filename: "src/cache.ts", additions: 5, deletions: 1, status: "modified" }]); // #review-audit: the LIVE merge-state the comment now reads — the base just advanced with a conflict, so the @@ -4895,10 +5056,17 @@ describe("queue processors", () => { if (url.includes("/check-runs") && method === "GET") return Response.json({ total_count: 0, check_runs: [] }); if (url.includes("/check-runs") && method === "POST") { calls.gateChecks += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string }; + if (body.status !== "in_progress" || body.conclusion) { + gateFinalized = true; + clearInstallationTokenCacheForTest(); + } return Response.json({ id: 901 }, { status: 201 }); } if (url.includes("/check-runs/901") && method === "PATCH") { calls.gateChecks += 1; + gateFinalized = true; + clearInstallationTokenCacheForTest(); return Response.json({ id: 901 }); } if (url.includes("/issues/3/comments") && method === "GET") return Response.json([]); @@ -4910,44 +5078,53 @@ describe("queue processors", () => { return new Response("not found", { status: 404 }); }); - await processJob(env, { - type: "github-webhook", - deliveryId: "pr-unified-comment", - eventName: "pull_request", - payload: { - action: "synchronize", - installation: { - id: 123, - account: { login: "JSONbored", id: 1, type: "User" }, - repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, - events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], - }, - repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, - pull_request: { - number: 3, - title: "Fix webhook duplicate delivery again", - state: "open", - user: { login: "oktofeesh1" }, - head: { sha: "unified123" }, - labels: [{ name: "bug" }], - body: "Fixes #1\n\nValidation: npm test", + try { + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-unified-comment", + eventName: "pull_request", + payload: { + action: "synchronize", + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 3, + title: "Fix webhook duplicate delivery again", + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: "unified123" }, + labels: [{ name: "bug" }], + body: "Fixes #1\n\nValidation: npm test", + }, }, - }, - }); + }); - expect(calls.comments).toBe(2); - // Still leads with the panel marker → the upsert updates the SAME sticky comment in place (no duplicate). - expect(postedBody).toContain(""); - // The UNIFIED shape, which the legacy body never emits: a full-comment GitHub alert wrapper… - expect(postedBody).toMatch(/> \[!(TIP|NOTE|WARNING|CAUTION)\]/); - // …and the renderer's synthesized "Code review" signal row (bold first table label). - expect(postedBody).toContain("**Code review**"); - // Public-safe by construction — no internal trust/economics fields leak through the unified renderer. - expect(postedBody).not.toMatch(/wallet|hotkey|reward|trust score/i); - // #review-audit (#4220): the comment reads the LIVE `dirty` merge-state (not the stale stored one), so it must - // NOT headline "safe to merge" while the disposition would auto-close the base-conflicting PR. - expect(postedBody).not.toMatch(/safe to merge/i); + const installationTokenCiReads = liveCiSpy.mock.calls.filter( + ([, , , token]) => token === "installation-token", + ); + expect(installationTokenCiReads).toHaveLength(2); + expect(calls.comments).toBe(2); + expect(failedPostGateMint).toBe(true); + // Still leads with the panel marker → the upsert updates the SAME sticky comment in place (no duplicate). + expect(postedBody).toContain(""); + // The UNIFIED shape, which the legacy body never emits: a full-comment GitHub alert wrapper… + expect(postedBody).toMatch(/> \[!(TIP|NOTE|WARNING|CAUTION)\]/); + // …and the renderer's synthesized "Code review" signal row (bold first table label). + expect(postedBody).toContain("**Code review**"); + // Public-safe by construction — no internal trust/economics fields leak through the unified renderer. + expect(postedBody).not.toMatch(/wallet|hotkey|reward|trust score/i); + // #review-audit (#4220): the comment reads the LIVE `dirty` merge-state (not the stale stored one), so it must + // NOT headline "safe to merge" while the disposition would auto-close the base-conflicting PR. + expect(postedBody).not.toMatch(/safe to merge/i); + } finally { + liveCiSpy.mockRestore(); + } }); // FIX B + FIX D3 at the processor call site: a unified comment for a PR whose CI has a FAILED check, with the