diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 509b2c7174..5e69940963 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -330,6 +330,7 @@ import { buildReviewEnrichment, isEnrichmentEnabled, isReesGithubTokenForwardingEnabled, + resolveEnrichmentLinkedIssue, } from "../review/enrichment-wire"; import { captureReviewFailure } from "../selfhost/sentry"; import { evaluateWithSurfaceLane } from "../review/content-lane-wire"; @@ -3641,6 +3642,7 @@ export async function runAiReviewForAdvisory( title: string; body?: string | null | undefined; baseSha?: string | null | undefined; + linkedIssues?: number[] | undefined; }; author: string | null; confirmedContributor: boolean; @@ -3809,6 +3811,11 @@ export async function runAiReviewForAdvisory( // its public-safe brief splices into the prompt next to grounding + RAG. Flag-OFF (default) → no call, no branch, // byte-identical prompt. Fully fail-safe (any timeout/error/empty → undefined → review proceeds). const enrichmentDiff = buildAiReviewDiff(files); + const enrichmentLinkedIssue = await resolveEnrichmentLinkedIssue( + env, + args.repoFullName, + args.pr.linkedIssues ?? [], + ); const enrichment = isEnrichmentEnabled(env) && convergedRepoAllowed ? await buildReviewEnrichment(env, { @@ -3817,7 +3824,9 @@ export async function runAiReviewForAdvisory( headSha: args.advisory.headSha, baseSha: args.pr.baseSha ?? null, title: args.pr.title, + body: args.pr.body ?? undefined, author: args.author, + linkedIssue: enrichmentLinkedIssue, githubToken: isReesGithubTokenForwardingEnabled(env) ? await resolveReviewEnrichmentGithubToken( env, diff --git a/src/review/enrichment-wire.ts b/src/review/enrichment-wire.ts index e0bb183e9c..6448145617 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 { getIssue } from "../db/repositories"; import { sanitizePublicComment } from "../queue-intelligence"; import { neutralizePromptInjection } from "./prompt-injection"; import type { PullRequestFileRecord } from "../types"; @@ -135,18 +136,44 @@ function headShaPrefix(headSha: string | null | undefined): string | undefined { return text ? text.slice(0, 12) : undefined; } +export interface EnrichmentLinkedIssue { + number: number; + title?: string; + body?: string; +} + interface EnrichmentInput { repoFullName: string; prNumber: number; headSha: string | null; baseSha?: string | null; title?: string | undefined; + body?: string | undefined; author?: string | null | undefined; + linkedIssue?: EnrichmentLinkedIssue | undefined; githubToken?: string | undefined; files: PullRequestFileRecord[]; diff: string; } +/** Resolve the PR's primary linked issue into the compact REES envelope (#1478). Fail-safe: returns + * `{ number }` when the issue row is missing so the history analyzer can still key on the number. */ +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 { @@ -232,7 +259,9 @@ export async function buildReviewEnrichment( headSha: input.headSha, baseSha: input.baseSha ?? null, title: input.title, + ...(input.body ? { body: input.body } : {}), author: input.author ?? undefined, + ...(input.linkedIssue ? { linkedIssue: input.linkedIssue } : {}), ...(input.githubToken ? { githubToken: input.githubToken } : {}), files: input.files.map((file) => ({ path: file.path, diff --git a/test/unit/enrichment-wire.test.ts b/test/unit/enrichment-wire.test.ts index 0e5ea5c198..e67eb51792 100644 --- a/test/unit/enrichment-wire.test.ts +++ b/test/unit/enrichment-wire.test.ts @@ -3,11 +3,14 @@ import { isEnrichmentEnabled, buildReviewEnrichment, isReesGithubTokenForwardingEnabled, + resolveEnrichmentLinkedIssue, resolveReesAnalyzers, resolveReesAnalyzerBudgetMs, resolveReesProfile, resolveReesTransportTimeoutMs, } from "../../src/review/enrichment-wire"; +import { upsertIssueFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; const env = (o: Record) => o as unknown as Env; const input = { @@ -83,7 +86,9 @@ describe("buildReviewEnrichment", () => { { ...input, baseSha: "baseabc", + body: "Fixes #12", author: "alice", + linkedIssue: { number: 12, title: "Add caching", body: "Cache registry sync." }, githubToken: "gh-read-token", }, ); @@ -106,6 +111,12 @@ describe("buildReviewEnrichment", () => { const body = JSON.parse(calls[0]!.init.body as string); expect(body.repoFullName).toBe("o/r"); expect(body.baseSha).toBe("baseabc"); + expect(body.body).toBe("Fixes #12"); + expect(body.linkedIssue).toEqual({ + number: 12, + title: "Add caching", + body: "Cache registry sync.", + }); expect(body.author).toBe("alice"); expect(body.githubToken).toBe("gh-read-token"); expect(body.analyzers).toBeUndefined(); @@ -561,6 +572,47 @@ describe("resolveReesProfile", () => { }); }); +describe("resolveEnrichmentLinkedIssue", () => { + it("returns undefined when no linked issues are present", async () => { + const env = createTestEnv(); + await expect( + resolveEnrichmentLinkedIssue(env, "o/r", []), + ).resolves.toBeUndefined(); + await expect( + resolveEnrichmentLinkedIssue(env, "o/r", [0, -1]), + ).resolves.toBeUndefined(); + }); + + it("returns number-only envelope when the issue row is missing", async () => { + const env = createTestEnv(); + await expect( + resolveEnrichmentLinkedIssue(env, "o/r", [42]), + ).resolves.toEqual({ number: 42 }); + }); + + it("returns title/body from the cached issue row", async () => { + const env = createTestEnv(); + await upsertIssueFromGitHub(env, "o/r", { + number: 12, + title: "Add caching", + body: "Cache registry sync.", + state: "open", + user: { login: "alice" }, + labels: [], + html_url: "https://github.com/o/r/issues/12", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + await expect( + resolveEnrichmentLinkedIssue(env, "o/r", [12]), + ).resolves.toEqual({ + number: 12, + title: "Add caching", + body: "Cache registry sync.", + }); + }); +}); + describe("REES timeout budget helpers", () => { it("keeps analyzer execution below the HTTP transport timeout", () => { expect(resolveReesTransportTimeoutMs(undefined)).toBe(8000); diff --git a/test/unit/enrichment-wiring.test.ts b/test/unit/enrichment-wiring.test.ts index fc6d020a48..2611608d52 100644 --- a/test/unit/enrichment-wiring.test.ts +++ b/test/unit/enrichment-wiring.test.ts @@ -1,6 +1,6 @@ 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"; @@ -85,6 +85,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: "Add feature", + body: "Implement the requested feature.", + 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; @@ -92,6 +103,8 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE analyzers?: string[]; baseSha?: string | null; author?: string; + body?: string; + linkedIssue?: { number: number; title?: string; body?: string }; githubToken?: string; }; } = {}; @@ -105,6 +118,8 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE analyzers?: string[]; baseSha?: string | null; author?: string; + body?: string; + linkedIssue?: { number: number; title?: string; body?: string }; githubToken?: string; }; return new Response( @@ -124,8 +139,9 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE pr: { number: 7, title: "Add a feature", - body: "Implements the thing.", + body: "Implements the thing. Fixes #42", baseSha: "base7", + linkedIssues: [42], }, author: "alice", confirmedContributor: true, @@ -141,6 +157,12 @@ describe("review-enrichment wired into the processors review (flag GITTENSORY_RE ]); expect(reesRequest.body?.baseSha).toBe("base7"); expect(reesRequest.body?.author).toBe("alice"); + expect(reesRequest.body?.body).toBe("Implements the thing. Fixes #42"); + expect(reesRequest.body?.linkedIssue).toEqual({ + number: 42, + title: "Add feature", + body: "Implement the requested feature.", + }); expect(reesRequest.body?.githubToken).toBe("public-read-token"); // 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.