diff --git a/src/github/backfill.ts b/src/github/backfill.ts index e2d7ac8fc5..648f1d7ec4 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -2144,7 +2144,7 @@ export async function fetchLivePullRequestReviewDecision(env: Env, repoFullName: } /** The deterministic linked-issue facts the hard-rule evaluator needs (labels / assignees / open-state). */ -export type LinkedIssueFactsResult = { number: number; labels: string[]; assignees: string[]; state: string }; +export type LinkedIssueFactsResult = { number: number; labels: string[]; assignees: string[]; state: string; authorLogin: string | null }; /** * FETCH the facts for one linked issue via the REST issues endpoint. FAIL-OPEN: any fetch/parse error returns @@ -2159,6 +2159,7 @@ export async function fetchLinkedIssueFacts(env: Env, repoFullName: string, issu state?: string | null; labels?: Array<{ name?: string | null } | string | null> | null; assignees?: Array<{ login?: string | null } | null> | null; + user?: { login?: string | null } | null; }>(env, repoFullName, `/issues/${issueNumber}`, token).catch(() => undefined); if (!result) return undefined; const data = result.data; @@ -2172,6 +2173,7 @@ export async function fetchLinkedIssueFacts(env: Env, repoFullName: string, issu labels, assignees, state: String(data.state ?? "open").toLowerCase(), + authorLogin: data.user?.login ?? null, }; } diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 4c9285a188..37d389bfe7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -67,6 +67,7 @@ import { backfillRepositorySegment, enqueueRepositoryOpenDataBackfill, fetchAndStorePullRequestFilesForReview, + fetchLinkedIssueFacts, fetchLiveCiAggregate, fetchLivePullRequestMergeState, fetchLivePullRequestReviewDecision, @@ -812,7 +813,7 @@ async function reReviewStoredPullRequest( if (!(await prReadyForReview(env, installationId, repoFullName, pr, settings, deliveryId))) return; const [otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([ listOtherOpenPullRequests(env, repoFullName, prNumber), - resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues), + resolveLinkedIssueAuthorLogins(env, installationId, repoFullName, pr.linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"), ]); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, @@ -1569,11 +1570,12 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str if (payload.action === "reopened" && installationId && (await maybeRecloseDisallowedReopen(env, deliveryId, installationId, repoFullName, pr, payload).catch(() => false))) { return; } - const [repo, settings, otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([ + // Resolve settings first so the self-authored live-fetch fallback only fires when its gate is in block mode. + const settings = await resolveRepositorySettings(env, repoFullName); + const [repo, otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([ getRepository(env, repoFullName), - resolveRepositorySettings(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), - resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues), + resolveLinkedIssueAuthorLogins(env, installationId, repoFullName, pr.linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"), ]); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, @@ -1752,14 +1754,25 @@ export function shouldCollectLinkedIssueEvidence(settings: Pick { +// Resolve the author login for each linked issue number. Prefers the local DB cache; on a cache MISS (issue not +// cached, or no recorded author), falls back to a LIVE GitHub fetch so a stale/missing cache can't silently void +// the self_authored_linked_issue anti-farming detection (#audit-3.11). The live token is minted lazily — only +// when at least one issue misses the cache — so the common (fully-cached) path adds no fetch. Each lookup is +// fail-safe: a per-issue error yields null (the detection stays fail-open only on a genuine inability to resolve). +export async function resolveLinkedIssueAuthorLogins(env: Env, installationId: number | null | undefined, repoFullName: string, linkedIssues: number[], liveFallback = false): Promise<(string | null)[]> { if (linkedIssues.length === 0) return []; - const results = await Promise.all(linkedIssues.map((n) => getIssue(env, repoFullName, n).then((i) => i?.authorLogin ?? null).catch(() => null))); - return results; + const cached = await Promise.all(linkedIssues.map((n) => getIssue(env, repoFullName, n).then((i) => i?.authorLogin ?? null).catch(() => null))); + // The live-fetch fallback only fires when the self-authored gate can actually BLOCK (caller passes + // liveFallback) — so quiet/advisory paths add no API calls, and we pay the fetch only where a cache miss + // could otherwise void a hard block. + if (!liveFallback || !installationId || cached.every((login) => login != null)) return cached; + const token = await createInstallationToken(env, installationId).catch(() => undefined); + if (!token) return cached; + return Promise.all( + cached.map((login, index) => + login != null ? Promise.resolve(login) : fetchLinkedIssueFacts(env, repoFullName, linkedIssues[index]!, token).then((facts) => facts?.authorLogin ?? null).catch(() => null), + ), + ); } export function shouldCollectSlopEvidence(settings: Pick): boolean { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 6f36fd6acc..b531d47034 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { gateCheckPolicy, resolveLinkedIssueAuthorLogins, shouldCollectLinkedIssueEvidence, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "../../src/queue/processors"; import { createTestEnv } from "../helpers/d1"; import { upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; @@ -371,9 +373,12 @@ describe("focus-manifest policy gate (#555)", () => { }); describe("resolveLinkedIssueAuthorLogins", () => { + // Clear the global installation-token cache so each live-fetch test mints deterministically (no cross-test reuse). + beforeEach(() => clearInstallationTokenCacheForTest()); + it("returns [] immediately for an empty linkedIssues array (no DB work)", async () => { const env = createTestEnv(); - const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", []); + const result = await resolveLinkedIssueAuthorLogins(env, null, "owner/repo", []); expect(result).toEqual([]); }); @@ -383,13 +388,13 @@ describe("resolveLinkedIssueAuthorLogins", () => { await upsertIssueFromGitHub(env, "owner/repo", { number: 10, title: "Bug report", body: "", state: "open", user: { login: "alice" }, labels: [], html_url: "https://github.com/owner/repo/issues/10", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); await upsertIssueFromGitHub(env, "owner/repo", { number: 11, title: "Feature", body: "", state: "open", user: { login: "bob" }, labels: [], html_url: "https://github.com/owner/repo/issues/11", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); - const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", [10, 11]); + const result = await resolveLinkedIssueAuthorLogins(env, null, "owner/repo", [10, 11]); expect(result).toEqual(["alice", "bob"]); }); it("returns null for an issue not in the DB (fail-open: unknown author does not trigger the finding)", async () => { const env = createTestEnv(); - const result = await resolveLinkedIssueAuthorLogins(env, "owner/repo", [99]); + const result = await resolveLinkedIssueAuthorLogins(env, null, "owner/repo", [99]); expect(result).toEqual([null]); }); @@ -397,7 +402,69 @@ describe("resolveLinkedIssueAuthorLogins", () => { const env = createTestEnv(); // Pass a broken DB binding to force a DB error. const brokenEnv = { ...env, DB: null } as unknown as typeof env; - const result = await resolveLinkedIssueAuthorLogins(brokenEnv, "owner/repo", [1]); + const result = await resolveLinkedIssueAuthorLogins(brokenEnv, null, "owner/repo", [1]); + expect(result).toEqual([null]); + }); + + it("falls back to a LIVE fetch for the author when the issue is not cached (#audit-3.11)", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: "pkcs1", format: "pem" }).toString(), GITHUB_APP_SLUG: "gittensory" }); + // Issue #50 is NOT in the local cache; a fresh GitHub fetch must still resolve its author so the + // self-authored detection isn't silently voided by a cache miss. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/50")) return Response.json({ number: 50, state: "open", user: { login: "self-farmer" }, labels: [], assignees: [] }); + return new Response("not found", { status: 404 }); + }); + try { + const result = await resolveLinkedIssueAuthorLogins(env, 123, "owner/repo", [50], true); + expect(result).toEqual(["self-farmer"]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("returns the cached results unchanged when the live token cannot be minted", async () => { + clearInstallationTokenCacheForTest(); // ensure the bad-key mint actually runs (no cached token from a prior test) + // createTestEnv's GITHUB_APP_PRIVATE_KEY is not a real RSA key → the JWT/token mint throws → fail-safe. + const env = createTestEnv(); + const result = await resolveLinkedIssueAuthorLogins(env, 424242, "owner/repo", [50], true); expect(result).toEqual([null]); }); + + it("yields null for a cache-missed issue whose live fetch returns no facts (fail-safe)", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: "pkcs1", format: "pem" }).toString(), GITHUB_APP_SLUG: "gittensory" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + return new Response("not found", { status: 404 }); // the issue fetch 404s → no facts → null + }); + try { + const result = await resolveLinkedIssueAuthorLogins(env, 555, "owner/repo", [50], true); + expect(result).toEqual([null]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("live-fetches only the cache-missed issues, keeping the cached authors (mixed list)", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: "pkcs1", format: "pem" }).toString(), GITHUB_APP_SLUG: "gittensory" }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 1); + await upsertIssueFromGitHub(env, "owner/repo", { number: 10, title: "Cached", body: "", state: "open", user: { login: "alice" }, labels: [], html_url: "https://github.com/owner/repo/issues/10", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/issues/11")) return Response.json({ number: 11, state: "open", user: { login: "bob" }, labels: [], assignees: [] }); + return new Response("not found", { status: 404 }); + }); + try { + const result = await resolveLinkedIssueAuthorLogins(env, 123, "owner/repo", [10, 11], true); + expect(result).toEqual(["alice", "bob"]); // #10 from cache (no fetch), #11 resolved live + } finally { + vi.unstubAllGlobals(); + } + }); });