diff --git a/src/queue/processors.ts b/src/queue/processors.ts index cc6f7951b5..eebbe77bd2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -444,6 +444,7 @@ import { import { loadLinkedIssueHardRules, resolveLinkedIssueHardRule, + resolveLinkedIssueHasOpenReference, } from "../review/linked-issue-hard-rules"; import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config"; import { resolveUnlinkedIssueMatchHold } from "../review/unlinked-issue-guardrail"; @@ -1628,20 +1629,22 @@ async function sweepRepoRegate( const others = openPullRequests.filter( (other) => other.number !== pr.number, ); - // Thread linked-issue authors so the re-gate sweep applies the self-authored-linked-issue block too — without - // this a self-authored PR re-gated by the sweep escapes a block the main webhook path applies. (#self-authored-parity) - const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins( + // Thread linked-issue authors + the open-reference check so the re-gate sweep applies the same + // self-authored-linked-issue block AND stale-issue-link countermeasure the main webhook path applies — + // without this a self-authored or stale-link-gaming PR re-gated by the sweep escapes both. (#self-authored-parity, #unlinked-issue-guardrail-followup) + const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext( env, sweepInstallationId, repoFullName, pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: others, requireLinkedIssue, duplicateWinnerEnabled, linkedIssueAuthorLogins, + confirmedNoOpenLinkedIssue, }); const gate = evaluateGateCheck( advisory, @@ -2991,16 +2994,10 @@ async function reReviewStoredPullRequest( )) ) return; - const [cachedOtherOpenPullRequests, linkedIssueAuthorLogins] = + const [cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] = await Promise.all([ listOtherOpenPullRequests(env, repoFullName, prNumber), - resolveLinkedIssueAuthorLogins( - env, - installationId, - repoFullName, - pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", - ), + resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings), ]); // #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the advisory // (and the disposition below) elect the cluster winner, so the real lowest-OPEN PR is never demoted+auto-closed. @@ -3015,6 +3012,7 @@ async function reReviewStoredPullRequest( otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); await persistAdvisory(env, advisory); @@ -5378,19 +5376,14 @@ async function processGitHubWebhook( }); return; } - // Resolve settings first so the self-authored live-fetch fallback only fires when its gate is in block mode. + // Resolve settings first so the self-authored + open-reference live-fetch fallbacks only fire when their + // respective gates are in block mode. const settings = await resolveRepositorySettings(env, repoFullName); - const [repo, cachedOtherOpenPullRequests, linkedIssueAuthorLogins] = + const [repo, cachedOtherOpenPullRequests, { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue }] = await Promise.all([ getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), - resolveLinkedIssueAuthorLogins( - env, - installationId, - repoFullName, - pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", - ), + resolveLinkedIssueAdvisoryContext(env, installationId, repoFullName, pr.linkedIssues, settings), ]); // #dup-winner / audit #15: drop any cached-open duplicate sibling already closed on GitHub before the // advisory (and the disposition) elect the cluster winner, so the real lowest-OPEN PR is never auto-closed. @@ -5405,6 +5398,7 @@ async function processGitHubWebhook( otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); await persistAdvisory(env, advisory); @@ -5914,6 +5908,27 @@ export async function resolveLinkedIssueAuthorLogins( ); } +// Shared per-call-site resolver for buildPullRequestAdvisory's linked-issue-derived context +// (#unlinked-issue-guardrail-followup). Every gate-evaluating call site (the main webhook path, the cron +// sweep, the heavy re-review pass, and authorized PR actions) already threads `linkedIssueAuthorLogins` the +// same way; bundling the new open-reference check into the SAME resolver keeps all of them in parity rather +// than risking only some remembering to add it. The live open-reference fetch is skipped entirely (resolves +// `true` with no network call) unless `linkedIssueGateMode` is actually "block" -- the only mode where +// whether a citation is open can change the gate's outcome. +export async function resolveLinkedIssueAdvisoryContext( + env: Env, + installationId: number | null | undefined, + repoFullName: string, + linkedIssues: number[], + settings: Pick, +): Promise<{ linkedIssueAuthorLogins: (string | null)[]; confirmedNoOpenLinkedIssue: boolean }> { + const [linkedIssueAuthorLogins, hasOpenReference] = await Promise.all([ + resolveLinkedIssueAuthorLogins(env, installationId, repoFullName, linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"), + settings.linkedIssueGateMode === "block" ? resolveLinkedIssueHasOpenReference({ env, repoFullName, linkedIssues, installationId }) : Promise.resolve(true), + ]); + return { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue: !hasOpenReference }; +} + export function shouldCollectSlopEvidence( settings: Pick, ): boolean { @@ -9908,19 +9923,21 @@ export async function buildAuthorizedPrActionAdvisory( getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), ]); - // Mirror the main webhook path: thread linked-issue authors so an authorized PR action (gate-override / panel - // retrigger) honors the self-authored-linked-issue block too. installationId comes from the repo record. (#self-authored-parity) - const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins( + // Mirror the main webhook path: thread linked-issue authors + the open-reference check so an authorized PR + // action (gate-override / panel retrigger) honors the same self-authored-linked-issue block AND stale- + // issue-link countermeasure. installationId comes from the repo record. (#self-authored-parity, #unlinked-issue-guardrail-followup) + const { linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue } = await resolveLinkedIssueAdvisoryContext( env, repo?.installationId ?? null, repoFullName, pr.linkedIssues, - settings.selfAuthoredLinkedIssueGateMode === "block", + settings, ); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, }); return { repo, advisory }; diff --git a/src/review/linked-issue-hard-rules.ts b/src/review/linked-issue-hard-rules.ts index ee63a8c08e..5750f859e8 100644 --- a/src/review/linked-issue-hard-rules.ts +++ b/src/review/linked-issue-hard-rules.ts @@ -1,5 +1,6 @@ -import { fetchLinkedIssueFacts } from "../github/backfill"; +import { fetchLinkedIssueFacts, type LinkedIssueFactsFetch } from "../github/backfill"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; +import { createInstallationToken } from "../github/app"; import { extractLinkedIssueNumbersWithOverflow } from "../db/repositories"; import { resolveRepositorySettings } from "../settings/repository-settings"; import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "./linked-issue-hard-rules-config"; @@ -184,3 +185,49 @@ export async function resolveLinkedIssueHardRule(args: { } return evaluateLinkedIssueHardRules({ issues: issueFacts, config: args.config, repoOwner: args.repoOwner, prAuthorLogin: args.prAuthorLogin }); } + +// ── Stale/fabricated-link countermeasure for the "must link an issue" HARD gate (#unlinked-issue-guardrail- +// followup) ────────────────────────────────────────────────────────────────────────────────────────────── +// +// `pr.linkedIssues` (extractLinkedIssueNumbersWithOverflow) is a pure body-text regex match — it never checks +// whether the cited issue is actually OPEN. So a repo running `linkedIssueGateMode: "block"` (requires a +// linked issue to merge) can be satisfied by a contributor citing an already-CLOSED or fabricated issue +// number, which defeats the whole point of requiring a link. This pair of functions gives the gate a +// verified, fail-open "is at least one citation a real, currently open issue" signal to use INSTEAD of bare +// presence, without changing what `pr.linkedIssues` itself means anywhere else it's used (duplicate-winner +// overlap, label propagation, scoring, etc. all keep reading raw presence). + +/** + * PURE evaluator. `true` means "treat the presence check as satisfied" — either a linked issue is CONFIRMED + * open, or at least one fetch was ambiguous (`fetch_error`) and we can't rule out a real open issue behind + * it. `false` — the only case this whole mechanism exists to catch — means EVERY fetched result conclusively + * resolved to NOT an open issue (found-but-closed, or a confirmed 404), with zero ambiguity. An empty input + * (nothing was fetched, e.g. the caller didn't need to check) fails open to `true` — the caller is + * responsible for handling "no linked issues at all" separately (that's the existing bare-presence check). + */ +export function hasVerifiableOpenLinkedIssueReference(fetchResults: LinkedIssueFactsFetch[]): boolean { + if (fetchResults.length === 0) return true; + if (fetchResults.some((result) => result.status === "found" && result.facts.state === "open")) return true; + return fetchResults.some((result) => result.status === "fetch_error"); +} + +/** + * Orchestrate the live per-issue fetch for {@link hasVerifiableOpenLinkedIssueReference}. Mints its own + * installation token (falling back to the public token, exactly like fetchLinkedIssueFacts's own + * hasProvenAccess discipline degrades a public-token 404 to `fetch_error` rather than a confirmed miss) so + * callers only need an `installationId`, mirroring `resolveLinkedIssueAuthorLogins`'s lazy-token pattern. + * Fail-safe: a token-mint failure still proceeds on the public token rather than skipping the check. + */ +export async function resolveLinkedIssueHasOpenReference(args: { + env: Env; + repoFullName: string; + linkedIssues: number[]; + installationId?: number | null | undefined; +}): Promise { + if (args.linkedIssues.length === 0) return true; + const ciToken = args.installationId ? await createInstallationToken(args.env, args.installationId).catch(() => undefined) : undefined; + const token = ciToken ?? args.env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubRateLimitAdmissionKeyForToken(args.env, token, args.installationId); + const fetchResults = await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey))); + return hasVerifiableOpenLinkedIssueReference(fetchResults); +} diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 5511105051..0ec00332ac 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -190,6 +190,15 @@ export function buildPullRequestAdvisory( * surface a `self_authored_linked_issue` finding when the PR author also opened the linked issue. Absent * or empty ⇒ the finding is never raised (fail-open: unknown issue authorship stays advisory-only). */ linkedIssueAuthorLogins?: (string | null | undefined)[]; + /** Same-account issue-avoidance countermeasure (#unlinked-issue-guardrail-followup): `pr.linkedIssues` is + * populated by a pure body-text regex that never checks whether the cited issue is actually OPEN, so a + * contributor can satisfy `linkedIssueGateMode: "block"` by citing an already-CLOSED (or fabricated) + * issue number. When the caller has live-verified that NONE of this PR's linked issue numbers resolve to + * a confirmed-open issue, it sets this true and `missing_linked_issue` fires exactly as if nothing were + * linked at all. Absent/false ⇒ byte-identical to today (presence alone still satisfies the requirement) + * — this is fail-open by construction: the caller only ever sets it true after a live check confirms + * every reference is dead, never on ambiguity. */ + confirmedNoOpenLinkedIssue?: boolean; } = {}, ): Advisory { const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; @@ -215,7 +224,7 @@ export function buildPullRequestAdvisory( action: "Re-deliver the webhook or wait for the next sync.", }); } else { - addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? []); + addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? [], Boolean(context.confirmedNoOpenLinkedIssue)); } return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); } @@ -675,6 +684,7 @@ function addPullRequestFindings( requireLinkedIssue: boolean, duplicateWinnerEnabled: boolean, linkedIssueAuthorLogins: (string | null | undefined)[], + confirmedNoOpenLinkedIssue: boolean, ): void { if (pr.state !== "open") { findings.push({ @@ -684,12 +694,15 @@ function addPullRequestFindings( detail: `The pull request state is ${pr.state}.`, }); } - if (pr.linkedIssues.length === 0 && requireLinkedIssue) { + const noLinkedIssueCited = pr.linkedIssues.length === 0; + if ((noLinkedIssueCited || confirmedNoOpenLinkedIssue) && requireLinkedIssue) { findings.push({ code: "missing_linked_issue", severity: "warning", title: "No linked issue detected", - detail: "No closing reference or linked issue number was found in the PR metadata/body.", + detail: noLinkedIssueCited + ? "No closing reference or linked issue number was found in the PR metadata/body." + : "The PR cites an issue number, but it could not be verified as a currently open issue.", action: "If this PR is intended to solve an issue, link it explicitly in the PR body.", }); } else { diff --git a/test/unit/linked-issue-hard-rules.test.ts b/test/unit/linked-issue-hard-rules.test.ts index d9aa65a80a..e234132f86 100644 --- a/test/unit/linked-issue-hard-rules.test.ts +++ b/test/unit/linked-issue-hard-rules.test.ts @@ -4,11 +4,14 @@ import * as backfillModule from "../../src/github/backfill"; import { DEFAULT_LINKED_ISSUE_HARD_RULES, evaluateLinkedIssueHardRules, + hasVerifiableOpenLinkedIssueReference, loadLinkedIssueHardRules, resolveLinkedIssueHardRule, + resolveLinkedIssueHasOpenReference, type LinkedIssueFacts, type LinkedIssueHardRulesConfig, } from "../../src/review/linked-issue-hard-rules"; +import type { LinkedIssueFactsFetch } from "../../src/github/backfill"; import { normalizeLinkedIssueHardRulesConfig } from "../../src/review/linked-issue-hard-rules-config"; import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; import { setLocalManifestReader } from "../../src/signals/focus-manifest-loader"; @@ -497,3 +500,95 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () = expect(resolveEffectiveSettings(db, parseFocusManifest(null)).linkedIssueGateMode).toBe("advisory"); }); }); + +describe("hasVerifiableOpenLinkedIssueReference (#unlinked-issue-guardrail-followup — pure evaluator)", () => { + const found = (state: string): LinkedIssueFactsFetch => ({ status: "found", facts: { number: 1, state, labels: [], assignees: [], authorLogin: null } }); + const notFound: LinkedIssueFactsFetch = { status: "not_found" }; + const fetchError: LinkedIssueFactsFetch = { status: "fetch_error" }; + + it("fails open (true) on an empty input — the caller handles the zero-citation case separately", () => { + expect(hasVerifiableOpenLinkedIssueReference([])).toBe(true); + }); + + it("is true when at least one linked issue is confirmed open", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("open")])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), found("open")])).toBe(true); + }); + + it("is false when every linked issue conclusively resolves to NOT open (closed or confirmed-missing), with zero ambiguity", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("closed")])).toBe(false); + expect(hasVerifiableOpenLinkedIssueReference([notFound])).toBe(false); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), notFound])).toBe(false); + }); + + it("fails open (true) whenever ANY result is ambiguous (fetch_error), even if none are confirmed open", () => { + expect(hasVerifiableOpenLinkedIssueReference([fetchError])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([found("closed"), fetchError])).toBe(true); + expect(hasVerifiableOpenLinkedIssueReference([notFound, fetchError])).toBe(true); + }); + + it("a confirmed-open result takes priority over an ambiguous one present in the same set", () => { + expect(hasVerifiableOpenLinkedIssueReference([found("open"), fetchError])).toBe(true); + }); +}); + +describe("resolveLinkedIssueHasOpenReference (#unlinked-issue-guardrail-followup — live orchestration)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("returns true and fetches nothing when there are no linked issues", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [] }); + expect(result).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns true when the linked issue is confirmed open", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "open", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(true); + }); + + it("returns false when the linked issue is confirmed CLOSED — the exact stale-link gaming case", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "closed", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(false); + }); + + it("fails open (true) when the fetch errors transiently rather than confirming the issue is dead", async () => { + vi.stubGlobal("fetch", async () => new Response("server error", { status: 500 })); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7] }); + expect(result).toBe(true); + }); + + it("still resolves correctly (via the public-token fallback) when no installationId is supplied at all", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/issues/") ? Response.json({ number: 7, state: "closed", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7], installationId: null }); + expect(result).toBe(false); + }); + + it("falls back to the public token (and still resolves) when installationId is set but token minting fails", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => + input.toString().includes("/app/installations/") ? new Response("forbidden", { status: 403 }) : input.toString().includes("/issues/") ? Response.json({ number: 7, state: "open", labels: [], assignees: [] }) : new Response("missing", { status: 404 }), + ); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [7], installationId: 123 }); + expect(result).toBe(true); + }); + + it("checks multiple linked issues and is true when only one of several is open", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/issues/1")) return Response.json({ number: 1, state: "closed", labels: [], assignees: [] }); + if (url.endsWith("/issues/2")) return Response.json({ number: 2, state: "open", labels: [], assignees: [] }); + return new Response("missing", { status: 404 }); + }); + const result = await resolveLinkedIssueHasOpenReference({ env: createTestEnv({}), repoFullName: "owner/repo", linkedIssues: [1, 2] }); + expect(result).toBe(true); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 130042949d..8292c49462 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6772,6 +6772,117 @@ describe("queue processors", () => { }); }); + it("blocks under linkedIssueGateMode:block when the PR only cites an already-CLOSED issue (#unlinked-issue-guardrail-followup — the stale-link gaming case)", async () => { + // Before the fix, pr.linkedIssues.length > 0 alone satisfied this gate regardless of the cited issue's real + // state — a contributor could cite an already-closed (or fabricated) issue number to fake compliance. + 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, + }); + // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code) — mirrors the + // existing "publishes an opt-in gate..." test above, which needs the same manifest override for the raw + // DB setting to take effect as a live hard block. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/5") && !url.includes("/comments")) return Response.json({ number: 5, state: "closed", labels: [], assignees: [] }); + if (url.includes("/commits/gate124/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 901 }, { status: 201 }); + if (url.includes("/check-runs/901") && (init?.method ?? "GET") === "PATCH") return Response.json({ id: 901, html_url: "https://github.com/checks/901" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-stale-link", + 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: 43, title: "Fake compliance", state: "open", user: { login: "contributor" }, head: { sha: "gate124" }, labels: [], body: "Closes #5" }, + }, + }); + + const summary = await env.DB.prepare("select conclusion from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 43, "gate124") + .first<{ conclusion: string }>(); + expect(summary?.conclusion).toBe("failure"); + }); + + it("does NOT block under linkedIssueGateMode:block when the cited issue is genuinely OPEN", 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, + }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/5") && !url.includes("/comments")) return Response.json({ number: 5, state: "open", labels: [], assignees: [] }); + if (url.includes("/commits/gate125/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 902 }, { status: 201 }); + if (url.includes("/check-runs/902") && (init?.method ?? "GET") === "PATCH") { + const body = JSON.parse(String(init?.body ?? "{}")) as { conclusion?: string; output?: { title?: string } }; + expect(body.output?.title).not.toBe("Gittensory Orb Review Agent: No linked issue detected"); + return Response.json({ id: 902, html_url: "https://github.com/checks/902" }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-open-link", + 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: 44, title: "Real link", state: "open", user: { login: "contributor" }, head: { sha: "gate125" }, labels: [], body: "Closes #5" }, + }, + }); + + const summary = await env.DB.prepare("select conclusion from check_summaries where repo_full_name = ? and pull_number = ? and head_sha = ?") + .bind("JSONbored/gittensory", 44, "gate125") + .first<{ conclusion: string }>(); + expect(summary?.conclusion).not.toBe("failure"); + }); + it("accepts PR-body validation evidence for configured manifest test expectations", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index b52e48071b..da9765e6a0 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -73,6 +73,68 @@ describe("advisory rules", () => { expect(advisory.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); }); + it("does NOT flag a cited-but-unverified linked issue as missing (byte-identical to today when the caller hasn't confirmed it's dead)", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 13, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [7], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + expect(advisory.findings.map((finding) => finding.code)).not.toContain("missing_linked_issue"); + }); + + it("flags a linked issue as missing when the caller has confirmed none of the citations resolve to an open issue (#unlinked-issue-guardrail-followup)", () => { + // pr.linkedIssues is populated by a pure body-text regex that never checks the cited issue's real state -- + // a contributor can otherwise satisfy `requireLinkedIssue`/`linkedIssueGateMode: block` by citing an + // already-CLOSED (or fabricated) issue number. confirmedNoOpenLinkedIssue is the caller's live-verified + // signal that every citation is conclusively dead. + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 14, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [7], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true, confirmedNoOpenLinkedIssue: true }); + + expect(advisory.conclusion).toBe("neutral"); + const finding = advisory.findings.find((f) => f.code === "missing_linked_issue"); + expect(finding).toBeDefined(); + expect(finding?.detail).toContain("could not be verified as a currently open issue"); + }); + + it("confirmedNoOpenLinkedIssue is a no-op when the PR links nothing at all (the existing zero-citation path still drives the finding, with its own detail text)", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 15, + title: "Fix a bug", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true, confirmedNoOpenLinkedIssue: true }); + + const finding = advisory.findings.find((f) => f.code === "missing_linked_issue"); + expect(finding?.detail).toBe("No closing reference or linked issue number was found in the PR metadata/body."); + }); + it("marks unknown repositories as action required", () => { const advisory = buildRepositoryAdvisory(null, "owner/repo"); expect(advisory.conclusion).toBe("action_required");