diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 24492d633d..070266feb0 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -335,6 +335,7 @@ import { buildReviewEnrichment, isEnrichmentEnabled, isReesGithubTokenForwardingEnabled, + resolveEnrichmentLinkedIssue, } from "../review/enrichment-wire"; import { captureReviewFailure } from "../selfhost/sentry"; import { evaluateWithSurfaceLane } from "../review/content-lane-wire"; @@ -3646,6 +3647,7 @@ export async function runAiReviewForAdvisory( title: string; body?: string | null | undefined; baseSha?: string | null | undefined; + linkedIssues?: number[] | undefined; }; author: string | null; confirmedContributor: boolean; @@ -3817,6 +3819,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, { @@ -3827,6 +3834,7 @@ export async function runAiReviewForAdvisory( 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 938235dbbc..2b967659a0 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"; @@ -155,6 +156,23 @@ interface EnrichmentInput { diff: string; } +/** 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 33c85db7c0..39f77d61e0 100644 --- a/test/unit/enrichment-wire.test.ts +++ b/test/unit/enrichment-wire.test.ts @@ -7,7 +7,10 @@ import { resolveReesAnalyzerBudgetMs, resolveReesProfile, resolveReesTransportTimeoutMs, + resolveEnrichmentLinkedIssue, } 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 +133,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) => { @@ -538,6 +562,68 @@ describe("resolveReesAnalyzers", () => { }); }); +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..f08d1642e8 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: "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 +105,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 +120,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 +141,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 +159,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");