diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e860703d48..87f325f2dd 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3334,6 +3334,7 @@ export async function getCachedAiReview( pullNumber: number, headSha: string | null | undefined, mode: string, + expectedInputFingerprint?: string | undefined, ): Promise<{ notes: string; reviewerCount: number; findings: AdvisoryFinding[]; metadata?: Record | undefined } | null> { if (!headSha) return null; const row = await env.DB @@ -3342,6 +3343,11 @@ export async function getCachedAiReview( .first<{ notes: string; reviewerCount: number; mode: string; findingsJson: string | null; metadataJson: string | null }>(); if (!row || row.mode !== mode) return null; const metadata = parseJson>(row.metadataJson, {}); + if ( + expectedInputFingerprint !== undefined && + metadata.inputFingerprint !== expectedInputFingerprint + ) + return null; return { notes: row.notes, reviewerCount: row.reviewerCount, diff --git a/src/env.d.ts b/src/env.d.ts index 83409d6f9c..7053788fe7 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -210,6 +210,7 @@ declare global { REES_SHARED_SECRET?: string; REES_TIMEOUT_MS?: string; REES_ANALYZERS?: string; + REES_PROFILE?: string; REES_FORWARD_GITHUB_TOKEN?: string; /** Convergence flag: the deterministic content/registry SURFACE LANE drives the gate for registry-submission * PRs (metagraphed surfaces[]/providers/candidates). Truthy ON *AND* the repo in GITTENSORY_REVIEW_REPOS — diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5f9e7fa2cd..622a66f8e2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -19,6 +19,7 @@ import { getIssue, listContributorPullRequests, listContributorRepoStats, + getRepoSyncSegment, listIssues, listIssueSignalSample, listLatestSignalSnapshotsByTarget, @@ -56,6 +57,7 @@ import { replaceCollisionEdges, upsertRepoQueueTrendSnapshot, upsertAgentCommandAnswer, + upsertCheckSummary, upsertOfficialMinerDetection, rollupProductUsageDaily, upsertBurdenForecast, @@ -206,6 +208,7 @@ import { } from "../settings/agent-execution"; import { SWEEP_FANOUT_DEDUP_MS, + SWEEP_MAX_PRS, isRegateSweepDraining, selectRegateCandidates, } from "../settings/agent-sweep"; @@ -347,6 +350,7 @@ import { emptyReviewRagTelemetry, isRagEnabled, } from "../review/rag-wire"; +import { aiReviewCacheInputFingerprint } from "../review/ai-review-cache-input"; import { buildReviewEnrichment, isEnrichmentEnabled, @@ -422,6 +426,7 @@ import { errorMessage, nowIso } from "../utils/json"; const OFFICIAL_MINER_DETECTION_TTL_MS = 5 * 60 * 1000; const OFFICIAL_MINER_DETECTION_UNAVAILABLE_TTL_MS = 60 * 1000; const PER_PR_REGATE_BACKPRESSURE_TYPES = ["agent-regate-pr"] as const; +const SWEEP_OPEN_PULL_REQUEST_SYNC_MAX_AGE_MS = 10 * 60 * 1000; const PR_PUBLIC_SURFACE_ACTIONS = new Set([ "opened", "reopened", @@ -1065,6 +1070,99 @@ async function currentRegateBacklog(env: Env): Promise { return queueSnapshotBacklog(snapshot, PER_PR_REGATE_BACKPRESSURE_TYPES); } +function sweepOpenPullRequestSyncCredentialAvailable( + env: Env, + repo: NonNullable>>, +): boolean { + if (env.GITHUB_PUBLIC_TOKEN) return true; + if (env.ORB_ENROLLMENT_SECRET) return true; + return Boolean( + repo.installationId && + env.GITHUB_APP_PRIVATE_KEY?.includes("BEGIN"), + ); +} + +function openPullRequestSyncStale( + segment: Awaited>, + nowMs: number, +): boolean { + if (!segment) return true; + if ( + segment.status === "running" || + segment.status === "refreshing" || + segment.status === "waiting_rate_limit" + ) + return false; + if (segment.status !== "complete" && segment.status !== "not_modified") + return true; + const completedMs = Date.parse(segment.completedAt ?? ""); + return ( + !Number.isFinite(completedMs) || + nowMs - completedMs > SWEEP_OPEN_PULL_REQUEST_SYNC_MAX_AGE_MS + ); +} + +async function refreshOpenPullRequestsForScheduledSweep( + env: Env, + repo: Awaited>, + requestedBy: "schedule" | "api" | "test", +): Promise { + if (requestedBy !== "schedule") return; + if (!repo || !sweepOpenPullRequestSyncCredentialAvailable(env, repo)) return; + const segment = await getRepoSyncSegment( + env, + repo.fullName, + "open_pull_requests", + ).catch(() => null); + if (!openPullRequestSyncStale(segment, Date.now())) return; + await backfillRepositorySegment(env, { + repoFullName: repo.fullName, + segment: "open_pull_requests", + requestedBy, + mode: "light", + force: true, + }).catch((error) => { + console.warn( + JSON.stringify({ + level: "warn", + event: "sweep_open_pr_sync_failed", + repoFullName: repo.fullName, + error: errorMessage(error), + }), + ); + }); +} + +async function surfaceRepairPriorityPullNumbers( + env: Env, + repoFullName: string, + pulls: readonly PullRequestRecord[], + gateCheckEnabled: boolean, +): Promise { + const priorityPullNumbers = new Set(); + for (const pr of pulls) { + if (pr.headSha && pr.lastPublishedSurfaceSha !== pr.headSha) + priorityPullNumbers.add(pr.number); + } + if (!gateCheckEnabled) return [...priorityPullNumbers]; + await Promise.all( + pulls.map(async (pr) => { + if (!pr.headSha) return; + const checks = await listCheckSummaries(env, repoFullName, pr.number).catch( + () => [], + ); + const currentGateCheck = checks.find( + (check) => + check.name === GITTENSORY_GATE_CHECK_NAME && + check.headSha === pr.headSha && + check.status === "completed", + ); + if (!currentGateCheck) priorityPullNumbers.add(pr.number); + }), + ); + return [...priorityPullNumbers]; +} + // Convergence (RAG / codebase index, flag GITTENSORY_REVIEW_RAG). The dispatch for the `rag-index-repo` job. // Caller already gated on isRagEnabled(env). // - No repoFullName → cron fan-out: enqueue one FULL re-index job per registered + cutover-allowlisted repo. @@ -1224,8 +1322,23 @@ async function sweepRepoRegate( }); return; } + const repo = await getRepository(env, repoFullName); + await refreshOpenPullRequestsForScheduledSweep( + env, + repo, + requestedBy, + ); + const openPullRequests = await listOpenPullRequests(env, repoFullName); + const priorityPullNumbers = await surfaceRepairPriorityPullNumbers( + env, + repoFullName, + openPullRequests, + settings.gateCheckMode === "enabled", + ); const regateBacklog = requestedBy === "schedule" ? await currentRegateBacklog(env) : 0; - if (regateBacklog > 0) { + // Normal stale maintenance yields behind existing per-PR repairs. Missing current Gate checks are outage repair: + // do not let one repo's draining sweep strand required statuses in another repo. + if (regateBacklog > 0 && priorityPullNumbers.length === 0) { await recordAuditEvent(env, { eventType: "agent.sweep.regate", actor: "gittensory", @@ -1237,13 +1350,23 @@ async function sweepRepoRegate( }); return; } - const [repo, openPullRequests] = await Promise.all([ - getRepository(env, repoFullName), - listOpenPullRequests(env, repoFullName), - ]); + // With an active backlog (regateBacklog > 0), a priority repair PR earns an EXCEPTION to the "yield to + // backlog" rule above, not a license for the whole sweep to also drag along a full SWEEP_MAX_PRS batch of + // ordinary stale PRs -- selectRegateCandidates sorts priority PRs first, so capping max to exactly + // priorityPullNumbers.length restricts the candidate set to repairs only. No backlog pressure ⇒ a normal, + // full-size sweep as before. + const repairCandidateLimit = + priorityPullNumbers.length > 0 + ? regateBacklog > 0 + ? priorityPullNumbers.length + : Math.max(SWEEP_MAX_PRS, priorityPullNumbers.length) + : null; const candidates = selectRegateCandidates({ pulls: openPullRequests, now: nowIso(), + priorityPullNumbers, + priorityBypassesFreshness: priorityPullNumbers.length > 0, + ...(repairCandidateLimit !== null ? { max: repairCandidateLimit } : {}), }); // No stale PRs this tick — stay quiet rather than writing an empty heartbeat to the audit feed. if (candidates.length === 0) return; @@ -3697,12 +3820,46 @@ async function processGitHubWebhook( } } -type PublicSurfaceOutput = "comment" | "label" | "check_run"; +type PublicSurfaceOutput = "comment" | "label" | "check_run" | "gate_check_run"; type PublicSurfaceOutputFailure = { output: PublicSurfaceOutput; error: string; }; +async function recordPublishedGateCheckSummary( + env: Env, + args: { + repoFullName: string; + pullNumber: number; + headSha: string | null | undefined; + checkRunId: number; + conclusion: string | null | undefined; + detailsUrl?: string | undefined; + deliveryId: string; + }, +): Promise { + /* v8 ignore next -- createOrUpdateNamedCheckRun returns null without a head SHA, so published results have one. */ + if (!args.headSha) return; + const completedAt = nowIso(); + await upsertCheckSummary(env, { + id: String(args.checkRunId), + repoFullName: args.repoFullName, + pullNumber: args.pullNumber, + headSha: args.headSha, + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + /* v8 ignore next -- Gate publication always supplies a conclusion; this keeps the DB value defensive. */ + conclusion: args.conclusion ?? null, + startedAt: null, + completedAt, + ...(args.detailsUrl ? { detailsUrl: args.detailsUrl } : {}), + payload: { + deliveryId: args.deliveryId, + source: "gittensory_gate_check", + }, + }); +} + function mergeReadinessGateEnabled( settings: Pick, ): boolean { @@ -4930,6 +5087,8 @@ async function maybePublishPrPublicSurface( let inlineCommentsEnabledForReview = false; let aiReviewExpected = false; let gateFinalized = false; + const publishedOutputs: PublicSurfaceOutput[] = []; + const failedOutputs: PublicSurfaceOutputFailure[] = []; const reviewedHeadSha = reviewedPullRequestHeadSha(pr.headSha, advisory.headSha); const freshnessForReviewOutput = (phase: string): Promise => reviewTargetFreshness(env, { @@ -4976,6 +5135,123 @@ async function maybePublishPrPublicSurface( }); return reviewFiles; }; + const finishPublicSurfacePublication = async (): Promise< + ReturnType | undefined + > => { + const gateSurfaceIncomplete = gateEnabled && !gateFinalized; + if (publishedOutputs.length === 0) { + if (failedOutputs.length > 0) { + await recordAuditEvent(env, { + eventType: "github_app.pr_public_surface_failed", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: failedOutputs.map((failure) => failure.output).join(","), + metadata: { + deliveryId: webhook.deliveryId, + repoFullName, + failedOutputs, + gateCheckRequired: gateEnabled, + gateCheckFinalized: gateFinalized, + }, + }); + // The advisory ran but NOTHING reached the PR (revoked token / perms removed / GitHub 5xx). For an + // advisory-only bot this is the worst failure — escalate to Sentry at error level, not just the audit ledger. + captureReviewFailure(new Error("PR public-surface publish failed — review produced output but nothing was posted to the PR"), { + kind: "publish", + installationId, + owner: repoFullName.split("/")[0], + repo: repoFullName, + pr: pr.number, + head_sha: advisory.headSha, + failedOutputs: failedOutputs.map((failure) => failure.output), + }); + } + if (gateSurfaceIncomplete) { + await recordAuditEvent(env, { + eventType: "github_app.pr_public_surface_incomplete", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: "required gate check did not finalize", + metadata: { + deliveryId: webhook.deliveryId, + repoFullName, + gateCheckMode: settings.gateCheckMode, + publishedOutputs, + failedOutputs, + }, + }).catch(() => undefined); + } + return gateEvaluation; + } + if (gateSurfaceIncomplete) { + // This branch is reachable with publishedOutputs non-empty (e.g. gate-only: ["gate_check_run"]), which + // can happen via the early `!prelimHasPublicOutput` return below -- at that point `decision` is still + // `prelim` (never reassigned by decidePublicSurface's official-miner-aware pass). That is safe here: + // `willLabel` is a non-optional boolean on every PublicSurfaceDecision variant (never undefined), and + // prelimHasPublicOutput being false means "label" was not in prelim.actions, which decidePublicSurface + // never sets independently of willLabel -- so decision.willLabel is always false on this path anyway. + await recordAuditEvent(env, { + eventType: "github_app.pr_public_surface_incomplete", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: "required gate check did not finalize", + metadata: { + deliveryId: webhook.deliveryId, + repoFullName, + publicSurface: settings.publicSurface, + label: decision.willLabel ? settings.gittensorLabel : null, + checkRunMode: settings.checkRunMode, + gateCheckMode: settings.gateCheckMode, + publicAudienceMode: settings.publicAudienceMode, + publishedOutputs, + failedOutputs, + }, + }).catch(() => undefined); + return gateEvaluation; + } + await recordAuditEvent(env, { + eventType: "github_app.pr_public_surface_published", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + metadata: { + deliveryId: webhook.deliveryId, + publicSurface: settings.publicSurface, + label: decision.willLabel ? settings.gittensorLabel : null, + checkRunMode: settings.checkRunMode, + gateCheckMode: settings.gateCheckMode, + publicAudienceMode: settings.publicAudienceMode, + publishedOutputs, + failedOutputs, + gateCheckFinalized: gateFinalized, + }, + }); + await recordGithubProductUsage(env, "pr_public_surface_published", { + actor: author, + repoFullName, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + metadata: { + publicSurface: settings.publicSurface, + labelApplied: decision.willLabel, + checkRunMode: settings.checkRunMode, + gateCheckMode: settings.gateCheckMode, + publicAudienceMode: settings.publicAudienceMode, + publishedOutputs, + failedOutputs, + gateCheckFinalized: gateFinalized, + }, + }); + // Stamp the head SHA only after every required public surface for this repo completed. For gate-enabled repos, + // a comment/label without a finalized Orb gate check is incomplete and must stay repair-visible to the sweep. + await markPullRequestSurfacePublished(env, repoFullName, pr.number, advisory.headSha).catch((error) => { + console.error(JSON.stringify({ level: "warn", event: "surface_published_mark_failed", repoFullName, pullNumber: pr.number, error: errorMessage(error) })); + }); + return gateEvaluation; + }; try { const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([ listIssues(env, repoFullName), @@ -5232,57 +5508,78 @@ async function maybePublishPrPublicSurface( agent: "dual-ai", }, async () => { - // #1 self-host AI-review cache: the LLM output for a PR changes only when the code (head SHA) or the review - // mode changes, so reuse a prior review for this exact (repo, pr, head SHA, mode) — a re-delivered webhook or - // the block-mode ~2-min re-gate sweep (which re-runs the AI for every open PR) need not re-spend the call. On - // self-host there is no AI gateway, so this is the only AI cache. The deterministic gate below still runs. + // `.gittensory.yml` review.profile + review.path_instructions + review.exclude_paths (#review-profile / + // #review-path-instructions / #review-exclude-paths): resolve from the manifest (cached from settings + // resolution, so a cheap cache hit — no extra fetch) and thread them into the AI review. Profile shapes + // nitpickiness; path-instructions add per-path guidance; exclude-paths drop files from review. Absent ⇒ + // byte-identical prompt. Fail-safe to defaults on any read error (resolveReviewPromptOverrides). + const { + profile: reviewProfile, + inlineComments: reviewInlineComments, + pathInstructions: reviewPathInstructions, + instructions: manifestReviewInstructions, + excludePaths: reviewExcludePaths, + } = resolveReviewPromptOverrides( + /* v8 ignore next -- fail-open manifest-read rejection is exercised in runAiReviewForAdvisory; this wrapper preserves the same fallback. */ + await loadRepoFocusManifest(env, repoFullName).catch(() => null), + ); + inlineCommentsEnabledForReview = shouldRequestInlineFindings( + env, + repoFullName, + reviewInlineComments, + ); + // Per-repo review CONTEXT (#review-skills): fold the container-private review/AGENTS.md (or legacy + // review/CLAUDE.md) guide + the matching review/skills/*.md modules into the SAME review-instructions slot, + // so reviews follow each repo's conventions. + // Glob-gated for cost (only skills matching the changed files are injected); absent config dir ⇒ empty ⇒ + // byte-identical prompt. getReviewFiles() is memoized, so this reuses the loaded diff. + const reviewFilesForAi = await getReviewFiles(); + const changedReviewPaths = reviewFilesForAi.map((file) => file.path); + const reviewInstructions = + [ + manifestReviewInstructions, + composeRepoReviewContext( + await loadRepoReviewContext(repoFullName), + changedReviewPaths, + ), + ] + .map((part) => part?.trim()) + .filter(Boolean) + .join("\n\n") || null; + const reviewInputFingerprint = await aiReviewCacheInputFingerprint({ + changedPaths: changedReviewPaths, + env, + mode: settings.aiReviewMode, + pr: { + baseSha: webhook.baseSha, + title: pr.title, + }, + review: { + effectiveInlineComments: inlineCommentsEnabledForReview, + excludePaths: reviewExcludePaths, + inlineComments: reviewInlineComments, + instructions: reviewInstructions, + pathInstructions: reviewPathInstructions, + profile: reviewProfile, + }, + settings, + }); + // #1 self-host AI-review cache: reuse a prior review for this exact (repo, pr, head SHA, mode) ONLY when + // the prompt/config inputs that affect the model output still match. Private repo instructions, RAG + // suppressions, feature flags, inline-comment mode, and BYOK model choices all change review output even + // when the head SHA is unchanged, so they are folded into the stored input fingerprint. const cachedReview = await getCachedAiReview( env, repoFullName, pr.number, advisory.headSha, settings.aiReviewMode, + reviewInputFingerprint, ).catch(() => null); if (cachedReview && hasPublicReviewAssessment(cachedReview.notes)) { advisory.findings.push(...cachedReview.findings); aiReview = cachedReview; } else { - // `.gittensory.yml` review.profile + review.path_instructions + review.exclude_paths (#review-profile / - // #review-path-instructions / #review-exclude-paths): resolve from the manifest (cached from settings - // resolution, so a cheap cache hit — no extra fetch) and thread them into the AI review. Profile shapes - // nitpickiness; path-instructions add per-path guidance; exclude-paths drop files from review. Absent ⇒ - // byte-identical prompt. Fail-safe to defaults on any read error (resolveReviewPromptOverrides). - const { - profile: reviewProfile, - inlineComments: reviewInlineComments, - pathInstructions: reviewPathInstructions, - instructions: manifestReviewInstructions, - excludePaths: reviewExcludePaths, - } = resolveReviewPromptOverrides( - /* v8 ignore next -- fail-open manifest-read rejection is exercised in runAiReviewForAdvisory; this wrapper preserves the same fallback. */ - await loadRepoFocusManifest(env, repoFullName).catch(() => null), - ); - inlineCommentsEnabledForReview = shouldRequestInlineFindings( - env, - repoFullName, - reviewInlineComments, - ); - // Per-repo review CONTEXT (#review-skills): fold the container-private review/AGENTS.md (or legacy - // review/CLAUDE.md) guide + the matching review/skills/*.md modules into the SAME review-instructions slot, - // so reviews follow each repo's conventions. - // Glob-gated for cost (only skills matching the changed files are injected); absent config dir ⇒ empty ⇒ - // byte-identical prompt. getReviewFiles() is memoized, so the second call reuses the loaded diff. - const reviewInstructions = - [ - manifestReviewInstructions, - composeRepoReviewContext( - await loadRepoReviewContext(repoFullName), - (await getReviewFiles()).map((file) => file.path), - ), - ] - .map((part) => part?.trim()) - .filter(Boolean) - .join("\n\n") || null; aiReview = await runAiReviewForAdvisory(env, { settings, advisory, @@ -5291,7 +5588,7 @@ async function maybePublishPrPublicSurface( pr: { ...pr, baseSha: webhook.baseSha ?? null }, author, confirmedContributor, - files: await getReviewFiles(), + files: reviewFilesForAi, reviewProfile, reviewPathInstructions, reviewInstructions, @@ -5305,7 +5602,14 @@ async function maybePublishPrPublicSurface( pr.number, advisory.headSha, settings.aiReviewMode, - aiReview, + { + ...aiReview, + metadata: { + /* v8 ignore next -- runAiReviewForAdvisory (the sole path reaching here) always sets metadata on its "ok" returns; the nullish fallback is a type-level (optional field) safeguard, not a reachable runtime path. */ + ...(aiReview.metadata ?? {}), + inputFingerprint: reviewInputFingerprint, + }, + }, ).catch(() => undefined); } }, @@ -5509,7 +5813,30 @@ async function maybePublishPrPublicSurface( mode, ), ); - if (gateCheckResult?.kind === "published") gateFinalized = true; + if (gateCheckResult?.kind === "published") { + gateFinalized = true; + publishedOutputs.push("gate_check_run"); + await recordPublishedGateCheckSummary(env, { + repoFullName, + pullNumber: pr.number, + headSha: advisory.headSha, + checkRunId: gateCheckResult.id, + /* v8 ignore next -- gate-enabled publication always has a gate evaluation. */ + conclusion: gateEvaluation?.conclusion ?? null, + detailsUrl: gateCheckResult.html_url, + deliveryId: webhook.deliveryId, + }).catch((error) => { + console.error( + JSON.stringify({ + level: "warn", + event: "gate_check_summary_upsert_failed", + repoFullName, + pullNumber: pr.number, + error: errorMessage(error), + }), + ); + }); + } if (gateCheckResult?.kind === "permission_missing") { await auditGateCheckPermissionMissing( env, @@ -5524,7 +5851,7 @@ async function maybePublishPrPublicSurface( // set), proving the App could write checks for this head at least once. Finalize the pending check to // neutral (mirrors the catch); if access was truly revoked this PATCH also fails and is swallowed. if (pendingGateCheckRunId !== undefined && !gateFinalized) { - await createOrUpdateErroredGateCheckRun( + const fallbackGateCheckResult = await createOrUpdateErroredGateCheckRun( env, installationId, repoFullName, @@ -5532,7 +5859,29 @@ async function maybePublishPrPublicSurface( { checkRunId: pendingGateCheckRunId }, mode, ).catch(() => undefined); - gateFinalized = true; + if (fallbackGateCheckResult?.kind === "published") { + gateFinalized = true; + publishedOutputs.push("gate_check_run"); + await recordPublishedGateCheckSummary(env, { + repoFullName, + pullNumber: pr.number, + headSha: advisory.headSha, + checkRunId: fallbackGateCheckResult.id, + conclusion: "neutral", + detailsUrl: fallbackGateCheckResult.html_url, + deliveryId: webhook.deliveryId, + }).catch((error) => { + console.error( + JSON.stringify({ + level: "warn", + event: "gate_check_summary_upsert_failed", + repoFullName, + pullNumber: pr.number, + error: errorMessage(error), + }), + ); + }); + } } } } catch (checkError) { @@ -5543,7 +5892,7 @@ async function maybePublishPrPublicSurface( // grew long with failing-check names) were silently never reviewed or closed. Finalize the pending // check to a neutral terminal state so it doesn't hang, log, and CONTINUE — do not re-throw. if (pendingGateCheckRunId !== undefined && !gateFinalized) { - await createOrUpdateErroredGateCheckRun( + const fallbackGateCheckResult = await createOrUpdateErroredGateCheckRun( env, installationId, repoFullName, @@ -5551,7 +5900,29 @@ async function maybePublishPrPublicSurface( { checkRunId: pendingGateCheckRunId }, mode, ).catch(() => undefined); - gateFinalized = true; + if (fallbackGateCheckResult?.kind === "published") { + gateFinalized = true; + publishedOutputs.push("gate_check_run"); + await recordPublishedGateCheckSummary(env, { + repoFullName, + pullNumber: pr.number, + headSha: advisory.headSha, + checkRunId: fallbackGateCheckResult.id, + conclusion: "neutral", + detailsUrl: fallbackGateCheckResult.html_url, + deliveryId: webhook.deliveryId, + }).catch((error) => { + console.error( + JSON.stringify({ + level: "warn", + event: "gate_check_summary_upsert_failed", + repoFullName, + pullNumber: pr.number, + error: errorMessage(error), + }), + ); + }); + } } await recordAuditEvent(env, { eventType: "github_app.gate_check_failed_nonfatal", @@ -5591,8 +5962,9 @@ async function maybePublishPrPublicSurface( throw error; } - if (!prelimHasPublicOutput) return gateEvaluation; - if (publicSurfaceSkipped || !official || !author) return gateEvaluation; + if (!prelimHasPublicOutput) return finishPublicSurfacePublication(); + if (publicSurfaceSkipped || !official || !author) + return finishPublicSurfacePublication(); const [github] = await Promise.all([ fetchPublicContributorProfile(author, env), @@ -5631,9 +6003,6 @@ async function maybePublishPrPublicSurface( repoStats, official.status === "confirmed" ? official.snapshot : null, ); - const publishedOutputs: PublicSurfaceOutput[] = []; - const failedOutputs: PublicSurfaceOutputFailure[] = []; - if (decision.willCheckRun && advisory.headSha) { try { // FIX B: the check-run annotations/details need the real diff too — reuse the shared resolver (one resolve @@ -6073,74 +6442,7 @@ async function maybePublishPrPublicSurface( } } } - if (publishedOutputs.length === 0) { - if (failedOutputs.length > 0) { - await recordAuditEvent(env, { - eventType: "github_app.pr_public_surface_failed", - actor: author, - targetKey: `${repoFullName}#${pr.number}`, - outcome: "error", - detail: failedOutputs.map((failure) => failure.output).join(","), - metadata: { - deliveryId: webhook.deliveryId, - repoFullName, - failedOutputs, - }, - }); - // The advisory ran but NOTHING reached the PR (revoked token / perms removed / GitHub 5xx). For an - // advisory-only bot this is the worst failure — escalate to Sentry at error level, not just the audit ledger. - captureReviewFailure(new Error("PR public-surface publish failed — review produced output but nothing was posted to the PR"), { - kind: "publish", - installationId, - owner: repoFullName.split("/")[0], - repo: repoFullName, - pr: pr.number, - head_sha: advisory.headSha, - failedOutputs: failedOutputs.map((failure) => failure.output), - }); - } - return gateEvaluation; - } - await recordAuditEvent(env, { - eventType: "github_app.pr_public_surface_published", - actor: author, - targetKey: `${repoFullName}#${pr.number}`, - outcome: "completed", - metadata: { - deliveryId: webhook.deliveryId, - publicSurface: settings.publicSurface, - label: decision.willLabel ? settings.gittensorLabel : null, - checkRunMode: settings.checkRunMode, - gateCheckMode: settings.gateCheckMode, - publicAudienceMode: settings.publicAudienceMode, - publishedOutputs, - failedOutputs, - }, - }); - await recordGithubProductUsage(env, "pr_public_surface_published", { - actor: author, - repoFullName, - targetKey: `${repoFullName}#${pr.number}`, - outcome: "completed", - metadata: { - publicSurface: settings.publicSurface, - labelApplied: decision.willLabel, - checkRunMode: settings.checkRunMode, - gateCheckMode: settings.gateCheckMode, - publicAudienceMode: settings.publicAudienceMode, - publishedOutputs, - failedOutputs, - }, - }); - // Stamp the head SHA we just published at for reporting and stale-surface diagnostics. This is not a hard - // re-review skip: GitHub comments/checks can be stale or incomplete even when this marker matches the current - // head. Reached only when at least one surface output actually published (the zero-output early-return above - // covers the suppressed/dry-run case). The helper no-ops on a null head, and its WHERE pins head_sha so a head - // that advanced mid-pass won't stamp. - await markPullRequestSurfacePublished(env, repoFullName, pr.number, advisory.headSha).catch((error) => { - console.error(JSON.stringify({ level: "warn", event: "surface_published_mark_failed", repoFullName, pullNumber: pr.number, error: errorMessage(error) })); - }); - return gateEvaluation; + return finishPublicSurfacePublication(); } async function recordPublicSurfaceOutputFailure( diff --git a/src/review/ai-review-cache-input.ts b/src/review/ai-review-cache-input.ts new file mode 100644 index 0000000000..177b074084 --- /dev/null +++ b/src/review/ai-review-cache-input.ts @@ -0,0 +1,103 @@ +import { sha256Hex } from "../utils/crypto"; +import type { + ReviewPathInstruction, + ReviewProfile, +} from "../signals/focus-manifest"; +import type { RepositorySettings } from "../types"; + +type StableJsonValue = + | null + | boolean + | number + | string + | StableJsonValue[] + | { [key: string]: StableJsonValue }; + +function stableJsonValue(value: unknown): StableJsonValue { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") + return value; + if (Array.isArray(value)) return value.map(stableJsonValue); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, stableJsonValue(entryValue)]), + ); + } + return null; +} + +export async function aiReviewInputFingerprint(input: unknown): Promise { + return `ai-review-input:v1:${await sha256Hex(JSON.stringify(stableJsonValue(input)))}`; +} + +export async function aiReviewCacheInputFingerprint(args: { + changedPaths: string[]; + env: Partial< + Pick< + Env, + | "GITTENSORY_REVIEW_ENRICHMENT" + | "GITTENSORY_REVIEW_GROUNDING" + | "GITTENSORY_REVIEW_INLINE_COMMENTS" + | "GITTENSORY_REVIEW_RAG" + | "GITTENSORY_REVIEW_REPUTATION" + | "GITTENSORY_REVIEW_REPOS" + | "REES_ANALYZERS" + | "REES_FORWARD_GITHUB_TOKEN" + | "REES_PROFILE" + | "REES_TIMEOUT_MS" + | "REES_URL" + > + >; + mode: string; + pr: { baseSha?: string | null | undefined; title: string }; + review: { + effectiveInlineComments: boolean; + excludePaths: string[]; + inlineComments: boolean; + instructions: string | null; + pathInstructions: ReviewPathInstruction[]; + profile: ReviewProfile | null; + }; + settings: Pick< + RepositorySettings, + | "aiReviewAllAuthors" + | "aiReviewByok" + | "aiReviewCloseConfidence" + | "aiReviewModel" + | "aiReviewProvider" + | "gatePack" + >; +}): Promise { + return aiReviewInputFingerprint({ + changedPaths: args.changedPaths, + env: { + enrichment: args.env.GITTENSORY_REVIEW_ENRICHMENT ?? null, + grounding: args.env.GITTENSORY_REVIEW_GROUNDING ?? null, + inlineComments: args.env.GITTENSORY_REVIEW_INLINE_COMMENTS ?? null, + rag: args.env.GITTENSORY_REVIEW_RAG ?? null, + reesAnalyzers: args.env.REES_ANALYZERS ?? null, + reesGithubTokenForwarding: args.env.REES_FORWARD_GITHUB_TOKEN ?? null, + reesProfile: args.env.REES_PROFILE ?? null, + reesTimeoutMs: args.env.REES_TIMEOUT_MS ?? null, + reesUrl: args.env.REES_URL ?? null, + reputation: args.env.GITTENSORY_REVIEW_REPUTATION ?? null, + reviewRepos: args.env.GITTENSORY_REVIEW_REPOS ?? null, + }, + mode: args.mode, + pr: { + baseSha: args.pr.baseSha ?? null, + title: args.pr.title, + }, + review: args.review, + settings: { + aiReviewAllAuthors: args.settings.aiReviewAllAuthors, + aiReviewByok: args.settings.aiReviewByok, + aiReviewCloseConfidence: args.settings.aiReviewCloseConfidence ?? null, + aiReviewModel: args.settings.aiReviewModel ?? null, + aiReviewProvider: args.settings.aiReviewProvider ?? null, + gatePack: args.settings.gatePack, + }, + }); +} diff --git a/src/settings/agent-sweep.ts b/src/settings/agent-sweep.ts index 0aef6efb78..75bb59aa47 100644 --- a/src/settings/agent-sweep.ts +++ b/src/settings/agent-sweep.ts @@ -46,6 +46,8 @@ export const SWEEP_FANOUT_DEDUP_MS = 90 * 1000; export function selectRegateCandidates(input: { pulls: PullRequestRecord[]; now: string; + priorityPullNumbers?: readonly number[] | ReadonlySet | undefined; + priorityBypassesFreshness?: boolean; freshnessWindowMs?: number; max?: number; }): PullRequestRecord[] { @@ -68,13 +70,24 @@ export function selectRegateCandidates(input: { const created = pr.createdAt ? Date.parse(pr.createdAt) : Number.NaN; return Number.isFinite(created) ? created : 0; }; + const priorityPullNumbers = + input.priorityPullNumbers instanceof Set + ? input.priorityPullNumbers + : new Set(input.priorityPullNumbers ?? []); + const repairPriority = (pr: PullRequestRecord): number => + priorityPullNumbers.has(pr.number) ? 0 : 1; return input.pulls .filter((pr) => pr.state === "open" && !pr.isDraft) .filter((pr) => { + if ( + input.priorityBypassesFreshness && + priorityPullNumbers.has(pr.number) + ) + return true; if (!Number.isFinite(freshCutoff)) return true; return webhookFreshness(pr) <= freshCutoff; }) - .sort((a, b) => regateProgress(a) - regateProgress(b) || a.number - b.number) + .sort((a, b) => repairPriority(a) - repairPriority(b) || regateProgress(a) - regateProgress(b) || a.number - b.number) .slice(0, Math.max(0, max)); } diff --git a/test/unit/agent-sweep.test.ts b/test/unit/agent-sweep.test.ts index 7fed6f40ca..493a7a490d 100644 --- a/test/unit/agent-sweep.test.ts +++ b/test/unit/agent-sweep.test.ts @@ -92,6 +92,36 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => { const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 }); expect(picked.map((p) => p.number)).toEqual([2, 3]); // stalest re-gate (600m), then 300m; 120m dropped by cap }); + + it("REGRESSION (repair priority): missing public surfaces are selected before ordinary stale PRs", () => { + const pulls = [ + pr({ number: 1, lastRegatedAt: minutesAgo(900) }), + pr({ number: 2, lastRegatedAt: minutesAgo(10) }), + pr({ number: 3, lastRegatedAt: minutesAgo(800) }), + ]; + const picked = selectRegateCandidates({ + pulls, + now: NOW, + max: 2, + priorityPullNumbers: new Set([2]), + }); + expect(picked.map((p) => p.number)).toEqual([2, 1]); + }); + + it("REGRESSION (repair priority): priority repairs can bypass webhook freshness when the current Gate check is missing", () => { + const pulls = [ + pr({ number: 1, updatedAt: minutesAgo(1), lastRegatedAt: minutesAgo(900) }), + pr({ number: 2, updatedAt: minutesAgo(120), lastRegatedAt: minutesAgo(800) }), + ]; + const picked = selectRegateCandidates({ + pulls, + now: NOW, + max: 2, + priorityPullNumbers: new Set([1]), + priorityBypassesFreshness: true, + }); + expect(picked.map((p) => p.number)).toEqual([1, 2]); + }); }); it("excludes drafts and non-open PRs", () => { diff --git a/test/unit/ai-review-cache.test.ts b/test/unit/ai-review-cache.test.ts index 3d6f27b394..4595fdb54f 100644 --- a/test/unit/ai-review-cache.test.ts +++ b/test/unit/ai-review-cache.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { getCachedAiReview, putCachedAiReview } from "../../src/db/repositories"; +import { + aiReviewCacheInputFingerprint, + aiReviewInputFingerprint, +} from "../../src/review/ai-review-cache-input"; import { createTestEnv } from "../helpers/d1"; describe("AI review cache (#1)", () => { @@ -86,4 +90,170 @@ describe("AI review cache (#1)", () => { metadata: { rag: { enabled: true, injected: false, retrievedPaths: [] } }, }); }); + + it("misses old cache rows when callers require an input fingerprint", async () => { + const env = createTestEnv(); + await putCachedAiReview(env, "o/r", 10, "sha1", "block", { + notes: "old review", + reviewerCount: 1, + }); + + expect(await getCachedAiReview(env, "o/r", 10, "sha1", "block", "ai-review-input:v1:new")).toBeNull(); + expect(await getCachedAiReview(env, "o/r", 10, "sha1", "block")).toEqual({ + notes: "old review", + reviewerCount: 1, + findings: [], + }); + }); + + it("reuses fingerprinted cache rows only when the review input fingerprint matches", async () => { + const env = createTestEnv(); + const matching = await aiReviewInputFingerprint({ + instructions: "Use the current repository review guide.", + nested: { b: true, a: ["src/changed.ts"] }, + ignored: undefined, + }); + const sameDifferentKeyOrder = await aiReviewInputFingerprint({ + ignored: undefined, + nested: { a: ["src/changed.ts"], b: true }, + instructions: "Use the current repository review guide.", + }); + const changed = await aiReviewInputFingerprint({ + instructions: "Use an older repository review guide.", + nested: { a: ["src/changed.ts"], b: true }, + }); + expect(sameDifferentKeyOrder).toBe(matching); + expect(changed).not.toBe(matching); + + await putCachedAiReview(env, "o/r", 11, "sha1", "block", { + notes: "fresh review", + reviewerCount: 2, + metadata: { inputFingerprint: matching }, + }); + + expect(await getCachedAiReview(env, "o/r", 11, "sha1", "block", changed)).toBeNull(); + expect(await getCachedAiReview(env, "o/r", 11, "sha1", "block", matching)).toEqual({ + notes: "fresh review", + reviewerCount: 2, + findings: [], + metadata: { inputFingerprint: matching }, + }); + }); + + it("fingerprints scalar review-input values deterministically", async () => { + const values = await Promise.all([ + aiReviewInputFingerprint(null), + aiReviewInputFingerprint(true), + aiReviewInputFingerprint(7), + aiReviewInputFingerprint("rules"), + aiReviewInputFingerprint(undefined), + ]); + expect(values[4]).toBe(values[0]); + expect(new Set(values).size).toBe(4); + await expect(aiReviewInputFingerprint("rules")).resolves.toBe(values[3]); + }); + + it("normalizes review cache fingerprint inputs from prompt, settings, and runtime config", async () => { + const base = { + changedPaths: ["src/changed.ts"], + env: {}, + mode: "block", + pr: { title: "Tighten review cache invalidation" }, + review: { + effectiveInlineComments: false, + excludePaths: [], + inlineComments: false, + instructions: "Use the current repository review guide.", + pathInstructions: [], + profile: null, + }, + settings: { + aiReviewAllAuthors: true, + aiReviewByok: false, + aiReviewCloseConfidence: undefined, + aiReviewModel: undefined, + aiReviewProvider: undefined, + gatePack: "oss-anti-slop" as const, + }, + }; + + const baseline = await aiReviewCacheInputFingerprint(base); + await expect( + aiReviewCacheInputFingerprint({ + ...base, + pr: { ...base.pr, baseSha: null }, + settings: { + ...base.settings, + aiReviewCloseConfidence: null, + aiReviewModel: null, + aiReviewProvider: null, + }, + }), + ).resolves.toBe(baseline); + await expect( + aiReviewCacheInputFingerprint({ + ...base, + review: { + ...base.review, + instructions: "Use an older repository review guide.", + }, + }), + ).resolves.not.toBe(baseline); + await expect( + aiReviewCacheInputFingerprint({ + ...base, + env: { + GITTENSORY_REVIEW_RAG: "true", + REES_URL: "https://rees.example", + REES_ANALYZERS: "secret,redos", + REES_PROFILE: "deep", + REES_TIMEOUT_MS: "12000", + REES_FORWARD_GITHUB_TOKEN: "false", + }, + }), + ).resolves.not.toBe(baseline); + }); + + it("changes the fingerprint when the configured REES endpoint URL itself changes", async () => { + const base = { + changedPaths: ["src/changed.ts"], + env: {}, + mode: "block", + pr: { title: "Tighten review cache invalidation" }, + review: { + effectiveInlineComments: false, + excludePaths: [], + inlineComments: false, + instructions: "Use the current repository review guide.", + pathInstructions: [], + profile: null, + }, + settings: { + aiReviewAllAuthors: true, + aiReviewByok: false, + aiReviewCloseConfidence: undefined, + aiReviewModel: undefined, + aiReviewProvider: undefined, + gatePack: "oss-anti-slop" as const, + }, + }; + + // Two DIFFERENT, both-truthy REES_URL values must not collide: reusing an AI review that ran + // against a different analyzer endpoint could reuse stale output produced by a different service. + const withEndpointA = await aiReviewCacheInputFingerprint({ + ...base, + env: { REES_URL: "https://rees-a.example" }, + }); + const withEndpointB = await aiReviewCacheInputFingerprint({ + ...base, + env: { REES_URL: "https://rees-b.example" }, + }); + const withEndpointARepeated = await aiReviewCacheInputFingerprint({ + ...base, + env: { REES_URL: "https://rees-a.example" }, + }); + + expect(withEndpointA).not.toBe(withEndpointB); + expect(withEndpointA).toBe(withEndpointARepeated); + }); }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 14f2466dfa..f9f2ec0632 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -53,6 +53,7 @@ import { fetchPullRequestFreshness, } from "../../src/github/pr-freshness"; import { createTestEnv } from "../helpers/d1"; +import { aiReviewCacheInputFingerprint } from "../../src/review/ai-review-cache-input"; vi.mock("../../src/github/pr-freshness", async (importOriginal) => { const actual = await importOriginal(); @@ -1478,12 +1479,12 @@ describe("queue processors", () => { await processJob(env, { type: "agent-regate-pr", deliveryId: "rebase-rereview", repoFullName: "owner/agent-repo", prNumber: 7, installationId: 9001 }); - // The PR was resynced to b8 and re-reviewed at the new head (check-runs fetched at b8). The marker is NOT in the - // GitHub-sync SET clause, so the resync upsert preserved it (still a7) until a fresh publish advances it. + // The PR was resynced to b8 and re-reviewed at the new head (check-runs fetched at b8). The GitHub-sync SET + // clause still preserves the old marker; the successful gate publication is what advances it to the new head. expect(checkRunsFetchedAtNewHead).toBe(true); const stored = await getPullRequest(env, "owner/agent-repo", 7); expect(stored?.headSha).toBe("b8"); - expect(stored?.lastPublishedSurfaceSha).toBe("a7"); // marker survived the resync (omitted from the sync SET clause) + expect(stored?.lastPublishedSurfaceSha).toBe("b8"); }); it("#4 over-publish dedup: a failing surface-published stamp is swallowed (fail-open) — the publish still completes", async () => { @@ -1541,10 +1542,33 @@ describe("queue processors", () => { await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Stale PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "Closes #1" }); await upsertPullRequestFile(env, { repoFullName: "owner/agent-repo", pullNumber: 7, path: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, payload: { patch: "@@\n+export const ok = value.length;" } }); // Pre-seed the AI review for this exact head SHA + mode → the sweep's block-mode review must reuse it, not re-run. + const inputFingerprint = await aiReviewCacheInputFingerprint({ + changedPaths: ["src/a.ts"], + env, + mode: "block", + pr: { title: "Stale PR", baseSha: null }, + review: { + effectiveInlineComments: false, + excludePaths: [], + inlineComments: false, + instructions: null, + pathInstructions: [], + profile: null, + }, + settings: { + aiReviewAllAuthors: false, + aiReviewByok: false, + aiReviewCloseConfidence: undefined, + aiReviewModel: undefined, + aiReviewProvider: undefined, + gatePack: "oss-anti-slop", + }, + }); await putCachedAiReview(env, "owner/agent-repo", 7, "a7", "block", { notes: "cached review", reviewerCount: 2, findings: [{ code: "ai_consensus_defect", severity: "critical", title: "Cached defect", detail: "Cached critical defect." }], + metadata: { inputFingerprint }, }); vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -2177,6 +2201,404 @@ describe("queue processors", () => { expect(typeof after?.last_regated_at).toBe("string"); // stamped via a D1 write at dispatch — convergence does not need a GitHub write }); + it("agent re-gate sweep prioritizes PRs missing the current Gate check even when their surface marker is current", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertInstallation(env, { action: "created", installation: { id: 9400, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9400); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + for (const number of [1, 2, 3, 4]) { + const headSha = `a${number}`; + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number, title: `PR${number}`, state: "open", user: { login: "c" }, head: { sha: headSha }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", number, headSha); + if (number !== 2) { + await upsertCheckSummary(env, { + id: `gate-${number}`, + repoFullName: "owner/agent-repo", + pullNumber: number, + headSha, + name: "Gittensory Orb Review Agent", + status: "completed", + conclusion: "success", + payload: {}, + }); + } + } + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 5, title: "Draft without a head", state: "open", draft: true, user: { login: "c" }, labels: [], body: "" } as never); + await env.DB.prepare( + `update pull_requests + set last_regated_at = case number + when 1 then '2026-05-27T01:00:00.000Z' + when 2 then '2026-05-28T01:50:00.000Z' + when 3 then '2026-05-27T02:00:00.000Z' + when 4 then '2026-05-27T03:00:00.000Z' + end + where repo_full_name = ?`, + ) + .bind("owner/agent-repo") + .run(); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const fanned = sent.filter((job) => job.type === "agent-regate-pr"); + expect(fanned.map((job) => (job as Extract).prNumber)).toEqual([2, 1, 3]); + }); + + it("REGRESSION: scheduled sweeps repair every missing current Gate check without waiting behind another repo backlog", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + snapshot() { + return { + totals: { pending: 0, processing: 1, dead: 0, due: 0 }, + byType: [ + { + type: "agent-regate-pr", + status: "processing", + count: 1, + due: 0, + }, + ], + }; + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9402, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9402); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + for (const number of [1, 2, 3, 4, 5]) { + const headSha = `repair-${number}`; + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number, title: `Repair ${number}`, state: "open", user: { login: "c" }, head: { sha: headSha }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", number, headSha); + } + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + const fanned = sent.filter((job): job is Extract => job.type === "agent-regate-pr"); + expect(fanned.map((job) => job.prNumber)).toEqual([1, 2, 3, 4, 5]); + const audit = await env.DB.prepare("select metadata_json from audit_events where event_type = ? and outcome = ?") + .bind("agent.sweep.regate", "completed") + .first<{ metadata_json: string }>(); + expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ + repoFullName: "owner/agent-repo", + examined: 5, + }); + }); + + it("REGRESSION: an active per-PR regate backlog restricts the sweep to priority repairs, not a full stale-PR batch too", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + // A nonzero per-PR regate backlog (agent-regate-pr pending/processing > 0) -- the same signal the + // "waiting behind another repo backlog" deferral above reacts to. + snapshot() { + return { + totals: { pending: 1, processing: 0, dead: 0, due: 1 }, + byType: [{ type: "agent-regate-pr", status: "pending", count: 1, due: 1 }], + }; + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9403, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9403); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + // PR 1: missing its current Gate check -- the one priority repair. + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 1, title: "Repair 1", state: "open", user: { login: "c" }, head: { sha: "repair-1" }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", 1, "repair-1"); + // PRs 2-5: ordinary, already-current, stale-by-time PRs -- a normal (no-backlog) sweep would pick these up + // too, but while the backlog is draining they must sit out so the sweep only carries the priority repair. + for (const number of [2, 3, 4, 5]) { + const headSha = `stale-${number}`; + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number, title: `Stale ${number}`, state: "open", user: { login: "c" }, head: { sha: headSha }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", number, headSha); + await upsertCheckSummary(env, { + id: `gate-current-${number}`, + repoFullName: "owner/agent-repo", + pullNumber: number, + headSha, + name: "Gittensory Orb Review Agent", + status: "completed", + conclusion: "success", + payload: {}, + }); + } + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + const fanned = sent.filter((job): job is Extract => job.type === "agent-regate-pr"); + expect(fanned.map((job) => job.prNumber)).toEqual([1]); // only the priority repair, not PRs 2-5 + }); + + it("agent re-gate sweep fail-opens when current Gate check reads fail during repair priority selection", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertInstallation(env, { action: "created", installation: { id: 9401, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9401); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Repair me", state: "open", user: { login: "c" }, head: { sha: "a7" }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", 7, "a7"); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/from\s+["`]?check_summaries["`]?/i.test(sql)) throw new Error("check summary read failed"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const fanned = sent.filter((job) => job.type === "agent-regate-pr") as Extract[]; + expect(fanned.map((job) => job.prNumber)).toEqual([7]); + }); + + it("scheduled sweeps skip open-PR refresh when an allowlisted repo has not been registered locally yet", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + GITTENSORY_REVIEW_REPOS: "owner/missing-repo", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + const segmentSpy = vi.spyOn(repositoriesModule, "getRepoSyncSegment"); + const backfillSpy = vi.spyOn(backfillModule, "backfillRepositorySegment"); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/missing-repo" }); + + expect(segmentSpy).not.toHaveBeenCalled(); + expect(backfillSpy).not.toHaveBeenCalled(); + expect(sent).toEqual([]); + segmentSpy.mockRestore(); + backfillSpy.mockRestore(); + }); + + it("scheduled sweeps can refresh stale open-PR rows with an Orb enrollment credential", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + ORB_ENROLLMENT_SECRET: "orb-secret", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9406, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9406); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertRepoSyncSegment(env, completeSegment("owner/agent-repo", "open_pull_requests")); + const backfillSpy = vi.spyOn(backfillModule, "backfillRepositorySegment").mockResolvedValueOnce({ + ok: true, + repoFullName: "owner/agent-repo", + segment: "open_pull_requests", + status: "complete", + fetchedCount: 0, + warnings: [], + }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect(backfillSpy).toHaveBeenCalledWith(env, expect.objectContaining({ segment: "open_pull_requests", force: true })); + expect(sent.filter((job) => job.type === "agent-regate-pr")).toEqual([]); + backfillSpy.mockRestore(); + }); + + it("REGRESSION: scheduled sweeps refresh stale open-PR rows so missed webhooks cannot hide PRs from repair", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9402, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9402); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertRepoSyncSegment(env, completeSegment("owner/agent-repo", "open_pull_requests")); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + return Response.json({ + data: { + rateLimit: { remaining: 4999, resetAt: "2026-05-28T03:00:00.000Z" }, + repository: { + issues: { totalCount: 0 }, + openPullRequests: { totalCount: 1 }, + mergedPullRequests: { totalCount: 0 }, + closedPullRequests: { totalCount: 0 }, + labels: { totalCount: 0 }, + }, + }, + }); + } + if (url.includes("/pulls?state=open")) { + return Response.json([ + { + number: 11, + title: "Webhook missed this PR", + state: "open", + user: { login: "contributor" }, + head: { sha: "h11" }, + labels: [], + body: "Fixes #1", + created_at: "2026-05-27T00:00:00.000Z", + updated_at: "2026-05-27T00:00:00.000Z", + }, + ]); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect((await getPullRequest(env, "owner/agent-repo", 11))?.headSha).toBe("h11"); + const fanned = sent.filter((job) => job.type === "agent-regate-pr") as Extract[]; + expect(fanned.map((job) => job.prNumber)).toEqual([11]); + expect(sent.some((job) => job.type === "backfill-pr-details" && job.repoFullName === "owner/agent-repo")).toBe(true); + }); + + it("scheduled sweeps do not duplicate an active open-PR refresh", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9403, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9403); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertRepoSyncSegment(env, { + ...completeSegment("owner/agent-repo", "open_pull_requests"), + status: "running", + }); + const fetchSpy = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json([])); + vi.stubGlobal("fetch", fetchSpy); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect( + fetchSpy.mock.calls + .map((call) => String((call as [RequestInfo | URL, RequestInit?])[0])) + .filter((url) => url === "https://api.github.com/graphql" || url.includes("/pulls?state=open")), + ).toEqual([]); + expect(sent.filter((job) => job.type === "agent-regate-pr")).toEqual([]); + }); + + it("scheduled sweeps fail open when open-PR sync state reads and refreshes fail", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9404, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9404); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + const segmentSpy = vi.spyOn(repositoriesModule, "getRepoSyncSegment").mockRejectedValueOnce(new Error("segment read failed")); + const backfillSpy = vi.spyOn(backfillModule, "backfillRepositorySegment").mockRejectedValueOnce(new Error("open PR refresh failed")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect(backfillSpy).toHaveBeenCalledWith(env, expect.objectContaining({ segment: "open_pull_requests", mode: "light", force: true })); + expect(warn.mock.calls.some((call) => String(call[0]).includes("sweep_open_pr_sync_failed"))).toBe(true); + expect(sent.filter((job) => job.type === "agent-regate-pr")).toEqual([]); + segmentSpy.mockRestore(); + backfillSpy.mockRestore(); + warn.mockRestore(); + }); + + it("scheduled sweeps refresh incomplete open-PR sync segments", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9405, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9405); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertRepoSyncSegment(env, { + ...completeSegment("owner/agent-repo", "open_pull_requests"), + status: "partial", + }); + const backfillSpy = vi.spyOn(backfillModule, "backfillRepositorySegment").mockResolvedValueOnce({ + ok: true, + repoFullName: "owner/agent-repo", + segment: "open_pull_requests", + status: "complete", + fetchedCount: 0, + warnings: [], + }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect(backfillSpy).toHaveBeenCalledWith(env, expect.objectContaining({ segment: "open_pull_requests" })); + expect(sent.filter((job) => job.type === "agent-regate-pr")).toEqual([]); + backfillSpy.mockRestore(); + }); + + it("scheduled sweeps refresh completed open-PR sync rows whose completion time is missing", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(m: import("../../src/types").JobMessage) { + sent.push(m); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9407, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9407); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + const segmentSpy = vi.spyOn(repositoriesModule, "getRepoSyncSegment").mockResolvedValueOnce({ + ...completeSegment("owner/agent-repo", "open_pull_requests"), + completedAt: undefined, + } as never); + const backfillSpy = vi.spyOn(backfillModule, "backfillRepositorySegment").mockResolvedValueOnce({ + ok: true, + repoFullName: "owner/agent-repo", + segment: "open_pull_requests", + status: "complete", + fetchedCount: 0, + warnings: [], + }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); + + expect(backfillSpy).toHaveBeenCalledWith(env, expect.objectContaining({ segment: "open_pull_requests", force: true })); + expect(sent.filter((job) => job.type === "agent-regate-pr")).toEqual([]); + segmentSpy.mockRestore(); + backfillSpy.mockRestore(); + }); + it("REGRESSION (#audit-sweep-dispatch-stamp): ONE sweep stamps ALL candidates AT DISPATCH, so the next fan-out skips the repo as draining — no overlapping sweeps", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); @@ -2259,6 +2681,17 @@ describe("queue processors", () => { await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" } }); // Seeded "now" → within the freshness window → not a candidate; no clock advance. await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Fresh PR", state: "open", user: { login: "c" }, head: { sha: "a7" }, labels: [], body: "x" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", 7, "a7"); + await upsertCheckSummary(env, { + id: "gate-fresh-7", + repoFullName: "owner/agent-repo", + pullNumber: 7, + headSha: "a7", + name: "Gittensory Orb Review Agent", + status: "completed", + conclusion: "success", + payload: {}, + }); await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); @@ -2395,6 +2828,17 @@ describe("queue processors", () => { await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9201); await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" } }); await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "PR7", state: "open", user: { login: "c" }, head: { sha: "a7" }, labels: [], body: "" }); + await repositoriesModule.markPullRequestSurfacePublished(env, "owner/agent-repo", 7, "a7"); + await upsertCheckSummary(env, { + id: "gate-backlog-7", + repoFullName: "owner/agent-repo", + pullNumber: 7, + headSha: "a7", + name: "Gittensory Orb Review Agent", + status: "completed", + conclusion: "success", + payload: {}, + }); vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); const getRepo = vi.spyOn(repositoriesModule, "getRepository"); const listOpen = vi.spyOn(repositoriesModule, "listOpenPullRequests"); @@ -2402,8 +2846,8 @@ describe("queue processors", () => { await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: "owner/agent-repo" }); expect(sent.filter((m) => m.type === "agent-regate-pr")).toEqual([]); - expect(getRepo).not.toHaveBeenCalled(); - expect(listOpen).not.toHaveBeenCalled(); + expect(getRepo).toHaveBeenCalledWith(env, "owner/agent-repo"); + expect(listOpen).toHaveBeenCalledWith(env, "owner/agent-repo"); const audit = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ outcome: string; metadata_json: string }>(); expect(audit?.outcome).toBe("queued"); expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ deferred: true, regateBacklog: 1 }); @@ -2682,31 +3126,229 @@ describe("queue processors", () => { installation: { id: 900, account: { login: "empty-org", id: 99, type: "Organization" } }, }, }); - const eventsAfterEmpty = await listProductUsageEvents(env, { limit: 50 }); - expect(eventsAfterEmpty.filter((e) => e.eventName === "github_installation_created")).toHaveLength(0); + const eventsAfterEmpty = await listProductUsageEvents(env, { limit: 50 }); + expect(eventsAfterEmpty.filter((e) => e.eventName === "github_installation_created")).toHaveLength(0); + + // Case 2: repository fallback (no repositories array) — must produce exactly one event with consistent metadata + await processJob(env, { + type: "github-webhook", + deliveryId: "install-single-repo-fallback", + eventName: "installation", + payload: { + action: "created", + installation: { id: 901, account: { login: "single-org", id: 100, type: "Organization" } }, + repository: { name: "my-repo", full_name: "single-org/my-repo", private: false, owner: { login: "single-org" } }, + }, + }); + const eventsAfterSingle = await listProductUsageEvents(env, { limit: 50 }); + const createdEvents = eventsAfterSingle.filter((e) => e.eventName === "github_installation_created"); + expect(createdEvents).toHaveLength(1); + expect(createdEvents[0]).toMatchObject({ + eventName: "github_installation_created", + repoFullName: "/my-repo", + metadata: expect.objectContaining({ action: "created", repoCount: 1, truncatedRepos: 0 }), + }); + }); + + it("publishes an opt-in gate without comment output, blocking a non-confirmed author normally (#gate-nonconfirmed)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + }); + const calls = { minerList: 0, gateChecks: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") { + calls.minerList += 1; + return Response.json([]); + } + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string; status?: string; conclusion?: string; output?: { title?: string } }; + expect(body).toMatchObject({ name: "Gittensory Orb Review Agent", status: "in_progress", output: { title: "Gittensory Orb Review Agent is evaluating" } }); + expect(body.conclusion).toBeUndefined(); + calls.gateChecks += 1; + return Response.json({ id: 900 }, { status: 201 }); + } + if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") { + const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string; status?: string; conclusion?: string; output?: { title?: string } }; + // Non-confirmed author + linked-issue block + no issue → gated normally → failure (#gate-nonconfirmed). + expect(body).toMatchObject({ name: "Gittensory Orb Review Agent", status: "completed", conclusion: "failure", output: { title: "Gittensory Orb Review Agent: No linked issue detected" } }); + calls.gateChecks += 1; + return Response.json({ id: 900, html_url: "https://github.com/checks/900" }); + } + return new Response("not found", { status: 404 }); + }); + + // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code). + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-only", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 42, title: "Gate without issue", state: "open", user: { login: "contributor" }, head: { sha: "gate123" }, labels: [], body: "No issue link." }, + }, + }); + + expect(calls).toEqual({ minerList: 1, gateChecks: 2 }); + const stored = await getPullRequest(env, "JSONbored/gittensory", 42); + expect(stored?.lastPublishedSurfaceSha).toBe("gate123"); + const published = await env.DB.prepare("select metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_published") + .first<{ metadata_json: string }>(); + expect(published?.metadata_json).toContain('"publishedOutputs":["gate_check_run"]'); + const summary = await env.DB.prepare("select name, status, conclusion from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 42, "gate123") + .first<{ name: string; status: string; conclusion: string }>(); + expect(summary).toMatchObject({ + name: "Gittensory Orb Review Agent", + status: "completed", + conclusion: "failure", + }); + }); + + it("stamps a gate-only surface even when local Gate check-summary persistence fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const realPrepare = env.DB.prepare.bind(env.DB); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + env.DB.prepare = ((sql: string) => { + if (/insert\s+into\s+["`]?check_summaries["`]?/i.test(sql)) throw new Error("summary write failed"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate-summary-fails/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 975 }, { status: 201 }); + if (url.includes("/check-runs/975") && method === "PATCH") return Response.json({ id: 975, html_url: "https://github.com/checks/975" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-summary-fails", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 85, title: "Gate summary fails", state: "open", user: { login: "contributor" }, head: { sha: "gate-summary-fails" }, labels: [], body: "No issue link." }, + }, + }); + + const stored = await getPullRequest(env, "JSONbored/gittensory", 85); + expect(stored?.lastPublishedSurfaceSha).toBe("gate-summary-fails"); + expect(errors.mock.calls.some((call) => String(call[0]).includes("gate_check_summary_upsert_failed"))).toBe(true); + errors.mockRestore(); + }); + + it("finalizes a permission-missing gate check through the neutral fallback before stamping the surface", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const realPrepare = env.DB.prepare.bind(env.DB); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + env.DB.prepare = ((sql: string) => { + if (/insert\s+into\s+["`]?check_summaries["`]?/i.test(sql)) throw new Error("summary write failed"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + let patches = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate-permission-fallback/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 976 }, { status: 201 }); + if (url.includes("/check-runs/976") && method === "PATCH") { + patches += 1; + if (patches === 1) + return new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 403 }); + return Response.json({ id: 976, html_url: "https://github.com/checks/976" }); + } + return new Response("not found", { status: 404 }); + }); - // Case 2: repository fallback (no repositories array) — must produce exactly one event with consistent metadata await processJob(env, { type: "github-webhook", - deliveryId: "install-single-repo-fallback", - eventName: "installation", + deliveryId: "gate-permission-fallback", + eventName: "pull_request", payload: { - action: "created", - installation: { id: 901, account: { login: "single-org", id: 100, type: "Organization" } }, - repository: { name: "my-repo", full_name: "single-org/my-repo", private: false, owner: { login: "single-org" } }, + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 86, title: "Gate permission fallback", state: "open", user: { login: "contributor" }, head: { sha: "gate-permission-fallback" }, labels: [], body: "No issue link." }, }, }); - const eventsAfterSingle = await listProductUsageEvents(env, { limit: 50 }); - const createdEvents = eventsAfterSingle.filter((e) => e.eventName === "github_installation_created"); - expect(createdEvents).toHaveLength(1); - expect(createdEvents[0]).toMatchObject({ - eventName: "github_installation_created", - repoFullName: "/my-repo", - metadata: expect.objectContaining({ action: "created", repoCount: 1, truncatedRepos: 0 }), - }); + + expect(patches).toBe(2); + const stored = await getPullRequest(env, "JSONbored/gittensory", 86); + expect(stored?.lastPublishedSurfaceSha).toBe("gate-permission-fallback"); + expect(errors.mock.calls.some((call) => String(call[0]).includes("gate_check_permission_missing"))).toBe(true); + expect(errors.mock.calls.some((call) => String(call[0]).includes("gate_check_summary_upsert_failed"))).toBe(true); + errors.mockRestore(); }); - it("publishes an opt-in gate without comment output, blocking a non-confirmed author normally (#gate-nonconfirmed)", async () => { + it("does not stamp a permission-missing gate check when the neutral fallback cannot publish", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( env, @@ -2724,50 +3366,49 @@ describe("queue processors", () => { autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", - linkedIssueGateMode: "block", - requireLinkedIssue: true, + linkedIssueGateMode: "off", + aiReviewMode: "off", }); - const calls = { minerList: 0, gateChecks: 0 }; + let patches = 0; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); - if (url === "https://api.gittensor.io/miners") { - calls.minerList += 1; - return Response.json([]); - } + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/gate123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") { - const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string; status?: string; conclusion?: string; output?: { title?: string } }; - expect(body).toMatchObject({ name: "Gittensory Orb Review Agent", status: "in_progress", output: { title: "Gittensory Orb Review Agent is evaluating" } }); - expect(body.conclusion).toBeUndefined(); - calls.gateChecks += 1; - return Response.json({ id: 900 }, { status: 201 }); - } - if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") { - const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string; status?: string; conclusion?: string; output?: { title?: string } }; - // Non-confirmed author + linked-issue block + no issue → gated normally → failure (#gate-nonconfirmed). - expect(body).toMatchObject({ name: "Gittensory Orb Review Agent", status: "completed", conclusion: "failure", output: { title: "Gittensory Orb Review Agent: No linked issue detected" } }); - calls.gateChecks += 1; - return Response.json({ id: 900 }); + if (url.includes("/commits/gate-permission-fallback-fails/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 977 }, { status: 201 }); + if (url.includes("/check-runs/977") && method === "PATCH") { + patches += 1; + if (patches === 1) + return new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 403 }); + return new Response("fallback update failed", { status: 500 }); } return new Response("not found", { status: 404 }); }); - // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code). - await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); await processJob(env, { type: "github-webhook", - deliveryId: "gate-only", + deliveryId: "gate-permission-fallback-fails", eventName: "pull_request", payload: { action: "opened", installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, - pull_request: { number: 42, title: "Gate without issue", state: "open", user: { login: "contributor" }, head: { sha: "gate123" }, labels: [], body: "No issue link." }, + pull_request: { number: 87, title: "Gate permission fallback fails", state: "open", user: { login: "contributor" }, head: { sha: "gate-permission-fallback-fails" }, labels: [], body: "No issue link." }, }, }); - expect(calls).toEqual({ minerList: 1, gateChecks: 2 }); + expect(patches).toBe(2); + const stored = await getPullRequest(env, "JSONbored/gittensory", 87); + expect(stored?.lastPublishedSurfaceSha ?? null).toBeNull(); + const incomplete = await env.DB.prepare("select metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_incomplete") + .first<{ metadata_json: string }>(); + expect(incomplete?.metadata_json).toContain('"publishedOutputs":[]'); + const published = await env.DB.prepare("select event_type from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_published") + .all(); + expect(published.results).toEqual([]); }); it("suppresses public review output when the live PR head changed before publish", async () => { @@ -4645,7 +5286,11 @@ describe("queue processors", () => { const usage = await env.DB.prepare("select feature, status from ai_usage_events where feature = ?").bind("ai_review_pr").first<{ feature: string; status: string }>(); expect(usage).toMatchObject({ feature: "ai_review_pr", status: "ok" }); expect(cacheReadSpy).toHaveBeenCalled(); + expect(cacheReadSpy.mock.calls[0]?.[5]).toMatch(/^ai-review-input:v1:/); expect(cacheWriteSpy).toHaveBeenCalled(); + expect(cacheWriteSpy.mock.calls[0]?.[5]).toMatchObject({ + metadata: { inputFingerprint: expect.stringMatching(/^ai-review-input:v1:/) }, + }); cacheReadSpy.mockRestore(); cacheWriteSpy.mockRestore(); }); @@ -4687,6 +5332,12 @@ describe("queue processors", () => { } return new Response("not found", { status: 404 }); }); + const realPrepare = env.DB.prepare.bind(env.DB); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + env.DB.prepare = ((sql: string) => { + if (/insert\s+into\s+["`]?check_summaries["`]?/i.test(sql)) throw new Error("summary write failed"); + return realPrepare(sql); + }) as typeof env.DB.prepare; await processJob(env, { type: "github-webhook", @@ -4712,6 +5363,266 @@ describe("queue processors", () => { .bind("github_app.gate_check_failed_nonfatal", "JSONbored/gittensory#80") .first<{ outcome: string }>(); expect(audit?.outcome).toBe("error"); + expect(errors.mock.calls.some((call) => String(call[0]).includes("gate_check_summary_upsert_failed"))).toBe(true); + errors.mockRestore(); + }); + + it("does not stamp a current public surface when a required Gate check never finalizes", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + let commentPosts = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor", public_repos: 1 }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate-missing/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 972 }, { status: 201 }); + if (url.includes("/check-runs/972") && method === "PATCH") return new Response("check update failed", { status: 500 }); + if (url.includes("/issues/82/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/82/comments") && method === "POST") { + commentPosts += 1; + return Response.json({ id: 8200, html_url: "https://github.com/comment/8200" }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-missing-but-comment-posted", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 82, title: "Comment cannot mask missing gate", state: "open", user: { login: "contributor" }, head: { sha: "gate-missing" }, labels: [], body: "No issue link." }, + }, + }); + + expect(commentPosts).toBeGreaterThan(0); + const stored = await getPullRequest(env, "JSONbored/gittensory", 82); + expect(stored?.lastPublishedSurfaceSha ?? null).toBeNull(); + const incomplete = await env.DB.prepare("select detail, metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_incomplete") + .first<{ detail: string; metadata_json: string }>(); + expect(incomplete?.detail).toBe("required gate check did not finalize"); + expect(incomplete?.metadata_json).toContain('"publishedOutputs":["comment"]'); + const published = await env.DB.prepare("select event_type from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_published") + .all(); + expect(published.results).toEqual([]); + const summary = await env.DB.prepare("select id from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 82, "gate-missing") + .first<{ id: string }>(); + expect(summary ?? null).toBeNull(); + }); + + it("records the intended label in incomplete-surface audits when a label publishes but Gate never finalizes", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + let labelPosts = 0; + 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: "installation-token" }); + if (url.includes("/commits/gate-missing-label/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 978 }, { status: 201 }); + if (url.includes("/check-runs/978") && method === "PATCH") return new Response("check update failed", { status: 500 }); + if (url.includes("/issues/88/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/88/labels") && method === "POST") { + labelPosts += 1; + return Response.json([{ name: "gittensor" }]); + } + if (url.includes("/labels") && method === "POST") return Response.json({ name: "gittensor" }, { status: 201 }); + if (url.includes("/labels/") && method === "DELETE") return new Response(null, { status: 204 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-missing-label-published", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 88, title: "Label cannot mask missing gate", state: "open", user: { login: "contributor" }, head: { sha: "gate-missing-label" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(labelPosts).toBeGreaterThan(0); + const stored = await getPullRequest(env, "JSONbored/gittensory", 88); + expect(stored?.lastPublishedSurfaceSha ?? null).toBeNull(); + const incomplete = await env.DB.prepare("select metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_incomplete") + .first<{ metadata_json: string }>(); + const metadata = JSON.parse(incomplete?.metadata_json ?? "{}"); + expect(metadata).toMatchObject({ + label: "gittensor", + publishedOutputs: ["label"], + }); + }); + + it("does not stamp a gate-only surface when the incomplete-surface audit write fails", async () => { + const originalRecordAuditEvent = repositoriesModule.recordAuditEvent; + let incompleteAuditWrites = 0; + const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (auditEnv, event) => { + if (event.eventType === "github_app.pr_public_surface_incomplete") { + incompleteAuditWrites += 1; + throw new Error("audit failed"); + } + await originalRecordAuditEvent(auditEnv, event); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate-zero-missing/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 973 }, { status: 201 }); + if (url.includes("/check-runs/973") && method === "PATCH") return new Response("check update failed", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-missing-zero-output", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 83, title: "Gate only missing", state: "open", user: { login: "contributor" }, head: { sha: "gate-zero-missing" }, labels: [], body: "No issue link." }, + }, + }); + + expect(incompleteAuditWrites).toBe(1); + const stored = await getPullRequest(env, "JSONbored/gittensory", 83); + expect(stored?.lastPublishedSurfaceSha ?? null).toBeNull(); + auditSpy.mockRestore(); + }); + + it("does not stamp a comment surface when the incomplete-surface audit write fails", async () => { + const originalRecordAuditEvent = repositoriesModule.recordAuditEvent; + let incompleteAuditWrites = 0; + const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (auditEnv, event) => { + if (event.eventType === "github_app.pr_public_surface_incomplete") { + incompleteAuditWrites += 1; + throw new Error("audit failed"); + } + await originalRecordAuditEvent(auditEnv, event); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor", public_repos: 1 }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate-comment-missing/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 974 }, { status: 201 }); + if (url.includes("/check-runs/974") && method === "PATCH") return new Response("check update failed", { status: 500 }); + if (url.includes("/issues/84/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/84/comments") && method === "POST") return Response.json({ id: 8400, html_url: "https://github.com/comment/8400" }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-missing-comment-audit-fails", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 84, title: "Comment missing gate", state: "open", user: { login: "contributor" }, head: { sha: "gate-comment-missing" }, labels: [], body: "No issue link." }, + }, + }); + + expect(incompleteAuditWrites).toBe(1); + const stored = await getPullRequest(env, "JSONbored/gittensory", 84); + expect(stored?.lastPublishedSurfaceSha ?? null).toBeNull(); + auditSpy.mockRestore(); }); it("propagates a rate-limited Gate completion so the queue retries and the pending Gate stays reviewing", async () => {