Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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,
};
}

Expand Down
35 changes: 24 additions & 11 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
backfillRepositorySegment,
enqueueRepositoryOpenDataBackfill,
fetchAndStorePullRequestFilesForReview,
fetchLinkedIssueFacts,
fetchLiveCiAggregate,
fetchLivePullRequestMergeState,
fetchLivePullRequestReviewDecision,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1752,14 +1754,25 @@ export function shouldCollectLinkedIssueEvidence(settings: Pick<RepositorySettin
return settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off" || mergeReadinessGateEnabled(settings);
}

// Fetch the author login for each linked issue number from the local DB. Returns a parallel array of
// logins (null when the issue is not in the DB or has no recorded author). Errors are swallowed per-issue
// so a DB hiccup on one issue never prevents the advisory from running — the detection is fail-open
// (an unknown author login never triggers the self_authored_linked_issue finding).
export async function resolveLinkedIssueAuthorLogins(env: Env, repoFullName: string, linkedIssues: number[]): Promise<(string | null)[]> {
// 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<RepositorySettings, "slopGateMode" | "mergeReadinessGateMode">): boolean {
Expand Down
77 changes: 72 additions & 5 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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([]);
});

Expand All @@ -383,21 +388,83 @@ 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]);
});

it("swallows per-issue DB errors and returns null for the erroring issue", async () => {
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();
}
});
});
Loading