diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 24492d633d..9d2f61b703 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -335,6 +335,8 @@ import { buildReviewEnrichment, isEnrichmentEnabled, isReesGithubTokenForwardingEnabled, + resolveEnrichmentLinkedIssue, + resolveEnrichmentLinkedIssueNumbers, } from "../review/enrichment-wire"; import { captureReviewFailure } from "../selfhost/sentry"; import { evaluateWithSurfaceLane } from "../review/content-lane-wire"; @@ -3646,6 +3648,7 @@ export async function runAiReviewForAdvisory( title: string; body?: string | null | undefined; baseSha?: string | null | undefined; + linkedIssues?: number[] | undefined; }; author: string | null; confirmedContributor: boolean; @@ -3827,6 +3830,14 @@ export async function runAiReviewForAdvisory( title: args.pr.title, body: args.pr.body ?? undefined, author: args.author, + linkedIssue: await resolveEnrichmentLinkedIssue( + env, + args.repoFullName, + resolveEnrichmentLinkedIssueNumbers( + args.pr.linkedIssues, + args.pr.body, + ), + ), githubToken: isReesGithubTokenForwardingEnabled(env) ? await resolveReviewEnrichmentGithubToken( env, diff --git a/src/review/enrichment-wire.ts b/src/review/enrichment-wire.ts index d3442d02d5..e970979ef3 100644 --- a/src/review/enrichment-wire.ts +++ b/src/review/enrichment-wire.ts @@ -6,6 +6,7 @@ // Single env switch: GITTENSORY_REVIEW_ENRICHMENT (+ REES_URL must be set, so the hosted Worker — which sets neither // — is unaffected). Default OFF → gathers nothing, prompt byte-identical. FULLY FAIL-SAFE: any timeout / non-200 / // network / parse error, or an empty brief, returns undefined and the review proceeds on diff + grounding + RAG. +import { extractLinkedIssueNumbers, getIssue } from "../db/repositories"; import { sanitizePublicComment } from "../queue-intelligence"; import { neutralizePromptInjection } from "./prompt-injection"; import type { PullRequestFileRecord } from "../types"; @@ -156,6 +157,33 @@ interface EnrichmentInput { diff: string; } +/** Prefer explicit linkedIssues; fall back to Fixes #N parsing from the PR body. */ +export function resolveEnrichmentLinkedIssueNumbers( + linkedIssues: number[] | undefined, + body: string | null | undefined, +): number[] { + const explicit = (linkedIssues ?? []).filter((candidate) => Number.isInteger(candidate) && candidate > 0); + if (explicit.length > 0) return explicit; + return extractLinkedIssueNumbers(body ?? ""); +} + +/** Resolve the PR's primary linked issue into the compact REES envelope (#1478). */ +export async function resolveEnrichmentLinkedIssue( + env: Env, + repoFullName: string, + linkedIssues: number[], +): Promise { + const number = linkedIssues.find((candidate) => Number.isInteger(candidate) && candidate > 0); + if (!number) return undefined; + const issue = await getIssue(env, repoFullName, number).catch(() => null); + if (!issue) return { number }; + return { + number: issue.number, + ...(issue.title ? { title: issue.title } : {}), + ...(issue.body ? { body: issue.body } : {}), + }; +} + /** Optional comma-list of REES analyzers. Unset/"all" omits the field so REES runs its full registry. * An explicit typo-only list fails closed by sending [] rather than expanding to every analyzer. */ export function resolveReesAnalyzers(env: Env): string[] | undefined { diff --git a/test/unit/enrichment-wire.test.ts b/test/unit/enrichment-wire.test.ts index 0c6e6546ad..8f75fbfd14 100644 --- a/test/unit/enrichment-wire.test.ts +++ b/test/unit/enrichment-wire.test.ts @@ -7,7 +7,11 @@ import { resolveReesAnalyzerBudgetMs, resolveReesProfile, resolveReesTransportTimeoutMs, + resolveEnrichmentLinkedIssue, + resolveEnrichmentLinkedIssueNumbers, } from "../../src/review/enrichment-wire"; +import { createTestEnv } from "../helpers/d1"; +import { upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; const env = (o: Record) => o as unknown as Env; const input = { @@ -130,6 +134,27 @@ describe("buildReviewEnrichment", () => { ]); }); + it("includes linkedIssue in the REES POST when provided", async () => { + const calls: RequestInit[] = []; + globalThis.fetch = vi.fn(async (_url: unknown, init: RequestInit) => { + calls.push(init); + return { + ok: true, + json: async () => ({ promptSection: "brief" }), + } as Response; + }) as unknown as typeof fetch; + await buildReviewEnrichment(env({ REES_URL: "https://r" }), { + ...input, + linkedIssue: { number: 42, title: "Fix cache", body: "Details here." }, + }); + const body = JSON.parse(calls[0]!.body as string); + expect(body.linkedIssue).toEqual({ + number: 42, + title: "Fix cache", + body: "Details here.", + }); + }); + it("sends an analyzer budget below the transport timeout and accepts partial degraded briefs", async () => { let body: { budget?: { timeoutMs?: number; maxBriefChars?: number } } | undefined; globalThis.fetch = vi.fn(async (_url: unknown, init: RequestInit) => { @@ -545,6 +570,84 @@ describe("resolveReesAnalyzers", () => { }); }); +describe("resolveEnrichmentLinkedIssueNumbers", () => { + it("prefers explicit linkedIssues over body parsing", () => { + expect(resolveEnrichmentLinkedIssueNumbers([7], "Fixes #42")).toEqual([7]); + }); + + it("parses Fixes #N from the PR body when linkedIssues is empty", () => { + expect(resolveEnrichmentLinkedIssueNumbers([], "Fixes #42\nCloses #99")).toEqual([42, 99]); + expect(resolveEnrichmentLinkedIssueNumbers(undefined, "Resolves #3")).toEqual([3]); + }); + + it("returns an empty list when neither source yields issue numbers", () => { + expect(resolveEnrichmentLinkedIssueNumbers([], "no issue refs")).toEqual([]); + expect(resolveEnrichmentLinkedIssueNumbers(undefined, undefined)).toEqual([]); + }); +}); + +describe("resolveEnrichmentLinkedIssue", () => { + it("returns undefined when no linked issue numbers are provided", async () => { + const env = createTestEnv({}); + expect(await resolveEnrichmentLinkedIssue(env, "o/r", [])).toBeUndefined(); + expect(await resolveEnrichmentLinkedIssue(env, "o/r", [0, -1])).toBeUndefined(); + }); + + it("returns the compact envelope from the local issue cache", async () => { + const env = createTestEnv({}); + await upsertRepositoryFromGitHub( + env, + { name: "r", full_name: "o/r", private: false, owner: { login: "o" } }, + 1, + ); + await upsertIssueFromGitHub(env, "o/r", { + number: 42, + title: "Fix cache race", + body: "Repro steps inside.", + state: "open", + user: { login: "reporter" }, + labels: [], + html_url: "https://github.com/o/r/issues/42", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + expect(await resolveEnrichmentLinkedIssue(env, "o/r", [42])).toEqual({ + number: 42, + title: "Fix cache race", + body: "Repro steps inside.", + }); + }); + + it("falls back to number-only when the issue is not cached locally", async () => { + const env = createTestEnv({}); + expect(await resolveEnrichmentLinkedIssue(env, "o/r", [99])).toEqual({ number: 99 }); + }); + + it("uses the first positive linked issue number", async () => { + const env = createTestEnv({}); + await upsertRepositoryFromGitHub( + env, + { name: "r", full_name: "o/r", private: false, owner: { login: "o" } }, + 1, + ); + await upsertIssueFromGitHub(env, "o/r", { + number: 7, + title: "Primary", + body: "", + state: "open", + user: { login: "reporter" }, + labels: [], + html_url: "https://github.com/o/r/issues/7", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + expect(await resolveEnrichmentLinkedIssue(env, "o/r", [0, 7, 8])).toEqual({ + number: 7, + title: "Primary", + }); + }); +}); + describe("resolveReesProfile", () => { it("returns undefined for unset profiles", () => { expect(resolveReesProfile(env({}))).toBeUndefined(); diff --git a/test/unit/enrichment-wiring.test.ts b/test/unit/enrichment-wiring.test.ts index 28aeb81d4a..36a23f53f3 100644 --- a/test/unit/enrichment-wiring.test.ts +++ b/test/unit/enrichment-wiring.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { runAiReviewForAdvisory } from "../../src/queue/processors"; -import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepositoryFromGitHub, upsertIssueFromGitHub } from "../../src/db/repositories"; import type { Advisory, RepositorySettings } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; +import * as enrichmentWire from "../../src/review/enrichment-wire"; const notesJson = JSON.stringify({ assessment: "Looks fine.", @@ -85,6 +86,17 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE REES_ANALYZERS: "secret,actionPin,redos", }); await seedRepoFile(env, "acme/widgets"); + await upsertIssueFromGitHub(env, "acme/widgets", { + number: 42, + title: "Linked bug", + body: "Issue context for history analyzer.", + state: "open", + user: { login: "reporter" }, + labels: [], + html_url: "https://github.com/acme/widgets/issues/42", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); const reesRequest: { url?: string; auth?: string | null; @@ -94,6 +106,7 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE author?: string; body?: string; githubToken?: string; + linkedIssue?: { number: number; title?: string; body?: string }; }; } = {}; const fetchSpy = vi @@ -108,6 +121,7 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE author?: string; body?: string; githubToken?: string; + linkedIssue?: { number: number; title?: string; body?: string }; }; return new Response( JSON.stringify({ @@ -128,6 +142,7 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE title: "Add a feature", body: "Implements the thing.", baseSha: "base7", + linkedIssues: [42], }, author: "alice", confirmedContributor: true, @@ -145,6 +160,11 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE expect(reesRequest.body?.author).toBe("alice"); expect(reesRequest.body?.body).toBe("Implements the thing."); expect(reesRequest.body?.githubToken).toBe("public-read-token"); + expect(reesRequest.body?.linkedIssue).toEqual({ + number: 42, + title: "Linked bug", + body: "Issue context for history analyzer.", + }); // The brief's content flows into the user prompt, but the system prompt carries our FIXED // enrichment suffix — the REES-supplied systemSuffix is untrusted and is never spliced in. expect(seenUser[0] ?? "").toContain("## EXTERNAL REVIEW BRIEF"); @@ -155,7 +175,7 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE } }); - it("FLAG-OFF (default): the REES is never called", async () => { + it("FLAG-OFF (default): the REES is never called and linked issues are not resolved", async () => { const run = vi.fn(async () => ({ response: notesJson })); const env = createTestEnv({ AI: { run } as unknown as Ai, @@ -164,6 +184,10 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE AI_DAILY_NEURON_BUDGET: "100000", }); await seedRepoFile(env, "acme/off"); + const linkedIssueSpy = vi.spyOn( + enrichmentWire, + "resolveEnrichmentLinkedIssue", + ); let reesCalled = false; const fetchSpy = vi .spyOn(globalThis, "fetch") @@ -175,13 +199,15 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE await runAiReviewForAdvisory(env, { settings: { aiReviewMode: "advisory" } as RepositorySettings, repoFullName: "acme/off", - pr: { number: 7, title: "t", body: "b" }, + pr: { number: 7, title: "t", body: "Fixes #42", linkedIssues: [42] }, author: "alice", confirmedContributor: true, advisory: adv("acme/off"), }); expect(reesCalled).toBe(false); + expect(linkedIssueSpy).not.toHaveBeenCalled(); } finally { + linkedIssueSpy.mockRestore(); fetchSpy.mockRestore(); } }); @@ -232,4 +258,67 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE fetchSpy.mockRestore(); } }); + + it("derives linkedIssue from Fixes #N in the PR body when linkedIssues is empty", async () => { + const run = vi.fn(async () => ({ response: notesJson })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + Object.assign(env, { + GITTENSORY_REVIEW_ENRICHMENT: "true", + REES_URL: "https://rees.example", + REES_SHARED_SECRET: "sek", + }); + await seedRepoFile(env, "acme/widgets"); + await upsertIssueFromGitHub(env, "acme/widgets", { + number: 55, + title: "Body-linked bug", + body: "Parsed from PR description.", + state: "open", + user: { login: "reporter" }, + labels: [], + html_url: "https://github.com/acme/widgets/issues/55", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + let reesBody: { linkedIssue?: { number: number; title?: string; body?: string } } | undefined; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (url, init) => { + if (String(url).includes("/v1/enrich")) { + reesBody = JSON.parse(String(init?.body ?? "{}")) as { + linkedIssue?: { number: number; title?: string; body?: string }; + }; + return new Response(JSON.stringify({ promptSection: "brief" }), { + status: 200, + }); + } + return new Response("nope", { status: 404 }); + }); + try { + await runAiReviewForAdvisory(env, { + settings: { aiReviewMode: "advisory" } as RepositorySettings, + repoFullName: "acme/widgets", + pr: { + number: 7, + title: "Fix the bug", + body: "Fixes #55", + linkedIssues: [], + }, + author: "alice", + confirmedContributor: true, + advisory: adv("acme/widgets"), + }); + expect(reesBody?.linkedIssue).toEqual({ + number: 55, + title: "Body-linked bug", + body: "Parsed from PR description.", + }); + } finally { + fetchSpy.mockRestore(); + } + }); });