diff --git a/src/github/app.ts b/src/github/app.ts index 6e2b0773c6..956a60d5cd 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -1,7 +1,7 @@ import { Octokit } from "@octokit/core"; import type { Advisory, GitHubWebhookPayload } from "../types"; import { signRs256Jwt } from "../utils/crypto"; -import { evaluateGateCheck, formatCheckRunOutput, formatGateCheckOutput, type GateCheckConclusion, type GateCheckPolicy } from "../rules/advisory"; +import { evaluateGateCheck, formatCheckRunOutput, formatGateCheckOutput, type CheckRunAnnotationContext, type CheckRunOutput, type GateCheckConclusion, type GateCheckPolicy } from "../rules/advisory"; type CheckRunResponse = { id: number; @@ -100,11 +100,12 @@ export async function createOrUpdateCheckRun( repoFullName: string, advisory: Advisory, detailLevel: "minimal" | "standard" | "deep" = "minimal", + annotationContext?: CheckRunAnnotationContext, ): Promise { return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { name: GITTENSORY_CONTEXT_CHECK_NAME, conclusion: advisory.conclusion, - output: formatCheckRunOutput(advisory, detailLevel), + output: formatCheckRunOutput(advisory, detailLevel, annotationContext), }); } @@ -171,7 +172,7 @@ async function createOrUpdateNamedCheckRun( name: string; status?: GitHubCheckStatus | undefined; conclusion?: GitHubCheckConclusion | undefined; - output: { title: string; summary: string; text: string }; + output: CheckRunOutput; checkRunId?: number | undefined; }, ): Promise { diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0c6423ae0c..fad77795c7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -24,6 +24,7 @@ import { listOtherOpenPullRequests, listOpenPullRequests, listPullRequests, + listPullRequestFiles, listRecentMergedPullRequests, listRepoLabels, listRepoPullRequestFiles, @@ -982,7 +983,12 @@ async function maybePublishPrPublicSurface( if (decision.willCheckRun && advisory.headSha) { try { - const checkRunResult = await createOrUpdateCheckRun(env, installationId, repoFullName, advisory, settings.checkRunDetailLevel); + const checkRunFiles = await listPullRequestFiles(env, repoFullName, pr.number); + const checkRunResult = await createOrUpdateCheckRun(env, installationId, repoFullName, advisory, settings.checkRunDetailLevel, { + files: checkRunFiles, + collisions, + pullNumber: pr.number, + }); if (checkRunResult?.kind === "permission_missing") { failedOutputs.push({ output: "check_run", error: checkRunResult.warning }); await recordAuditEvent(env, { diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 90fc0e0449..396bb2bc7b 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -349,6 +349,58 @@ describe("GitHub check runs", () => { expect(capturedBody.output?.text).toContain("does not post late first comments"); }); + it("publishes Context check annotations on changed files while Gate stays text-only", async () => { + const privateKey = await generatePrivateKeyPem(); + let contextBody: { name?: string; output?: { annotations?: Array<{ path: string; title: string }> } } = {}; + let gateBody: { name?: string; output?: { annotations?: Array<{ path: string; title: string }> } } = {}; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + const body = JSON.parse(String(init?.body)) as { + name?: string; + output?: { annotations?: Array<{ path: string; title: string }> }; + }; + if (body.name === "Gittensory Context") contextBody = body; + if (body.name === "Gittensory Gate") gateBody = body; + return Response.json({ id: body.name === "Gittensory Gate" ? 90 : 77 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + const advisory: Advisory = { + id: "advisory-annot", + targetType: "pull_request", + targetKey: "JSONbored/gittensory#9", + repoFullName: "JSONbored/gittensory", + pullNumber: 9, + headSha: "bbb999", + conclusion: "neutral", + severity: "warning", + title: "Gittensory advisory available", + summary: "1 advisory finding generated.", + findings: [], + generatedAt: "2026-05-22T00:00:00.000Z", + }; + + await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory, "standard", { + pullNumber: 9, + files: [{ repoFullName: "JSONbored/gittensory", pullNumber: 9, path: "src/api/routes.ts", additions: 4, deletions: 0, changes: 4, payload: {} }], + collisions: { + repoFullName: "JSONbored/gittensory", + generatedAt: "2026-06-10T00:00:00.000Z", + summary: { clusterCount: 0, highRiskCount: 0, itemsReviewed: 0 }, + clusters: [], + }, + }); + await createOrUpdateGateCheckRun(env, 123, "JSONbored/gittensory", advisory); + + expect(contextBody.output?.annotations?.[0]).toMatchObject({ path: "src/api/routes.ts", title: "Missing test evidence" }); + expect(gateBody.output?.annotations).toBeUndefined(); + }); + it("publishes check run with standard detail level and includes public-safe finding text", async () => { const privateKey = await generatePrivateKeyPem(); let capturedBody: { output?: { text?: string } } = {};