diff --git a/src/lib/__tests__/brinApi.test.ts b/src/lib/__tests__/brinApi.test.ts new file mode 100644 index 0000000..cc820b6 --- /dev/null +++ b/src/lib/__tests__/brinApi.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { scanContributor, scanPr } from "../brinApi.js"; + +vi.mock("../logger.js", () => ({ + childLogger: () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +describe("scanPr", () => { + beforeEach(() => { + process.env.BRIN_API_BASE = "https://brin.example"; + vi.unstubAllGlobals(); + }); + + it("forwards a GitHub token for private PR access", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ score: 90, verdict: "safe" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await scanPr("acme", "private-repo", 123, { + tolerance: "aggressive", + githubToken: "ghs_installation_token", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://brin.example/pr/acme/private-repo/123?details=true&mode=full&tolerance=aggressive", + expect.objectContaining({ + headers: { "x-github-token": "ghs_installation_token" }, + }), + ); + }); +}); + +describe("scanContributor", () => { + beforeEach(() => { + process.env.BRIN_API_BASE = "https://brin.example"; + vi.unstubAllGlobals(); + }); + + it("forwards a GitHub token for private contributor access", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ score: 90, verdict: "safe" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await scanContributor("octocat", { + githubToken: "ghs_installation_token", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://brin.example/contributor/octocat?details=true&mode=full", + expect.objectContaining({ + headers: { "x-github-token": "ghs_installation_token" }, + }), + ); + }); +}); diff --git a/src/lib/__tests__/policy.test.ts b/src/lib/__tests__/policy.test.ts index e4f04f8..307c0e2 100644 --- a/src/lib/__tests__/policy.test.ts +++ b/src/lib/__tests__/policy.test.ts @@ -62,6 +62,24 @@ describe("evaluatePrScan", () => { }); }); + it("returns inconclusive when Brin reports an internal scan error", () => { + const result: PrScanResult = { + score: 0, + verdict: "dangerous", + threats: [ + { + type: "scan_error", + detail: "failed to fetch PR: GitHub API returned 404", + }, + ], + }; + + expect(evaluatePrScan(result, config)).toEqual({ + status: "inconclusive", + shouldFail: false, + }); + }); + it("respects custom blockBelowScore threshold", () => { const custom: RepoConfig = { ...config, diff --git a/src/lib/brinApi.ts b/src/lib/brinApi.ts index eeb7aae..e202d4a 100644 --- a/src/lib/brinApi.ts +++ b/src/lib/brinApi.ts @@ -6,13 +6,20 @@ export async function scanPr( owner: string, repo: string, prNumber: number, - tolerance = "conservative", + options: { tolerance?: string; githubToken?: string } = {}, ): Promise { + const tolerance = options.tolerance ?? "conservative"; const url = `${env.brinApiBase}/pr/${owner}/${repo}/${prNumber}?details=true&mode=full&tolerance=${tolerance}`; const log = childLogger({ service: "brin-api", endpoint: "pr", owner, repo, prNumber }); + const headers = options.githubToken + ? { "x-github-token": options.githubToken } + : undefined; try { - const res = await fetch(url, { signal: AbortSignal.timeout(300_000) }); + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(300_000), + }); if (!res.ok) { log.warn({ status: res.status }, "Brin PR API returned non-OK status"); return {}; @@ -26,12 +33,21 @@ export async function scanPr( } } -export async function scanContributor(login: string): Promise { +export async function scanContributor( + login: string, + options: { githubToken?: string } = {}, +): Promise { const url = `${env.brinApiBase}/contributor/${login}?details=true&mode=full`; const log = childLogger({ service: "brin-api", endpoint: "contributor", login }); + const headers = options.githubToken + ? { "x-github-token": options.githubToken } + : undefined; try { - const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(30_000), + }); if (!res.ok) { log.warn({ status: res.status }, "Brin contributor API returned non-OK status"); return {}; diff --git a/src/lib/policy.ts b/src/lib/policy.ts index d89ee1c..dc2c8ab 100644 --- a/src/lib/policy.ts +++ b/src/lib/policy.ts @@ -1,9 +1,16 @@ import type { PrScanResult, ContributorResult, RepoConfig, PrStatus } from "./types.js"; +export function hasPrScanError(result: PrScanResult): boolean { + return result.threats?.some((threat) => threat.type === "scan_error") ?? false; +} + export function evaluatePrScan( result: PrScanResult, config: RepoConfig, ): { status: PrStatus; shouldFail: boolean } { + if (hasPrScanError(result)) { + return { status: "inconclusive", shouldFail: false }; + } if (result.score == null) { return { status: "inconclusive", shouldFail: false }; } diff --git a/src/services/__tests__/labels.test.ts b/src/services/__tests__/labels.test.ts index 4631c02..80f69ea 100644 --- a/src/services/__tests__/labels.test.ts +++ b/src/services/__tests__/labels.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { ensureLabels, setLabel } from "../labels.js"; +import { clearLabels, ensureLabels, setLabel } from "../labels.js"; function mockOctokit(overrides: Record = {}) { return { @@ -122,3 +122,29 @@ describe("setLabel", () => { }); }); }); + +describe("clearLabels", () => { + it("removes brin labels and preserves unrelated labels", async () => { + const octokit = mockOctokit({ + listLabelsOnIssue: vi.fn().mockResolvedValue({ + data: [ + { name: "bug" }, + { name: "pr:flagged" }, + { name: "enhancement" }, + ], + }), + }); + + await clearLabels(octokit, "owner", "repo", 42, [ + "pr:verified", + "pr:flagged", + ]); + + expect(octokit.rest.issues.setLabels).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 42, + labels: ["bug", "enhancement"], + }); + }); +}); diff --git a/src/services/contributorTrust.ts b/src/services/contributorTrust.ts index 0ddecbb..f882b2a 100644 --- a/src/services/contributorTrust.ts +++ b/src/services/contributorTrust.ts @@ -9,6 +9,7 @@ import { deleteMarkerComment, renderContributorTrustComment, } from "./comments.js"; +import { getGitHubToken } from "./githubToken.js"; import { ensureLabels, setLabel } from "./labels.js"; import { childLogger } from "../lib/logger.js"; @@ -53,7 +54,8 @@ export async function runContributorTrust( CHECK_NAMES.CONTRIBUTOR_TRUST, ); - const result = await scanContributor(authorLogin); + const githubToken = await getGitHubToken(octokit); + const result = await scanContributor(authorLogin, { githubToken }); const { isSafe } = evaluateContributor(result, config); log.info( diff --git a/src/services/githubToken.ts b/src/services/githubToken.ts new file mode 100644 index 0000000..d392ca9 --- /dev/null +++ b/src/services/githubToken.ts @@ -0,0 +1,9 @@ +import type { Octokit } from "octokit"; + +export async function getGitHubToken(octokit: Octokit): Promise { + const auth = await octokit.auth({ type: "installation" }); + if (auth && typeof auth === "object" && "token" in auth && typeof auth.token === "string") { + return auth.token; + } + return undefined; +} diff --git a/src/services/labels.ts b/src/services/labels.ts index ab6a46f..ff961e5 100644 --- a/src/services/labels.ts +++ b/src/services/labels.ts @@ -64,3 +64,28 @@ export async function setLabel( labels: [...preserved, nextLabel], }); } + +export async function clearLabels( + octokit: Octokit, + owner: string, + repo: string, + issueNumber: number, + brinLabelNames: readonly string[], +): Promise { + const { data: current } = await octokit.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: issueNumber, + }); + + const preserved = current + .map((l) => l.name) + .filter((name) => !brinLabelNames.includes(name)); + + await octokit.rest.issues.setLabels({ + owner, + repo, + issue_number: issueNumber, + labels: preserved, + }); +} diff --git a/src/services/prScan.ts b/src/services/prScan.ts index 7e7216e..1ced0b2 100644 --- a/src/services/prScan.ts +++ b/src/services/prScan.ts @@ -9,7 +9,8 @@ import { deleteMarkerComment, renderPrScanComment, } from "./comments.js"; -import { ensureLabels, setLabel } from "./labels.js"; +import { getGitHubToken } from "./githubToken.js"; +import { clearLabels, ensureLabels, setLabel } from "./labels.js"; import { childLogger } from "../lib/logger.js"; const PR_LABELS = [LABEL_DEFS.PR_VERIFIED, LABEL_DEFS.PR_FLAGGED]; @@ -41,7 +42,11 @@ export async function runPrScan( CHECK_NAMES.PR_SCAN, ); - const result = await scanPr(owner, repo, prNumber, config.prScan.tolerance); + const githubToken = await getGitHubToken(octokit); + const result = await scanPr(owner, repo, prNumber, { + tolerance: config.prScan.tolerance, + githubToken, + }); const { status, shouldFail } = evaluatePrScan(result, config); log.info( @@ -91,6 +96,8 @@ export async function runPrScan( ? "A deep scan is in progress. Results will update automatically." : "Unable to determine scan results at this time.", }); + await clearLabels(octokit, owner, repo, prNumber, PR_LABEL_NAMES); + await deleteMarkerComment(octokit, owner, repo, prNumber, MARKERS.PR_SCAN); break; } }