diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fadcf22d..a36c9fe10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fixed Review Agent failing on private GitHub repositories when fetching the PR diff. The parser now uses the REST API endpoint with the `diff` media type instead of the web `diff_url` which is not accessible to API tokens. [#1302](https://github.com/sourcebot-dev/sourcebot/pull/1302) + ## [5.0.2] - 2026-06-11 ### Changed diff --git a/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts index d06cdd889..878974454 100644 --- a/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts +++ b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts @@ -1,9 +1,9 @@ -import { expect, test, vi, describe } from 'vitest'; -import { Octokit } from 'octokit'; -import { githubPrParser } from './githubPrParser'; -import { GitHubPullRequest } from '../types'; +import { expect, test, vi, describe } from "vitest"; +import { Octokit } from "octokit"; +import { githubPrParser } from "./githubPrParser"; +import { GitHubPullRequest } from "../types"; -vi.mock('@sourcebot/shared', () => ({ +vi.mock("@sourcebot/shared", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), @@ -13,23 +13,25 @@ vi.mock('@sourcebot/shared', () => ({ })); // Minimal shape satisfying the fields accessed by githubPrParser -function makePullRequest(overrides: Partial<{ - number: number; - title: string; - body: string | null; - head_sha: string; - owner: string; - repo: string; - diff_url: string; -}> = {}): GitHubPullRequest { +function makePullRequest( + overrides: Partial<{ + number: number; + title: string; + body: string | null; + head_sha: string; + owner: string; + repo: string; + diff_url: string; + }> = {}, +): GitHubPullRequest { const opts = { number: 7, - title: 'My PR title', - body: 'My PR description', - head_sha: 'sha_abc123', - owner: 'my-org', - repo: 'my-repo', - diff_url: 'https://github.com/my-org/my-repo/pull/7.diff', + title: "My PR title", + body: "My PR description", + head_sha: "sha_abc123", + owner: "my-org", + repo: "my-repo", + diff_url: "https://github.com/my-org/my-repo/pull/7.diff", ...overrides, }; @@ -54,43 +56,55 @@ function makeMockOctokit(diffText: string) { } as unknown as Octokit; } -describe('githubPrParser', () => { - test('maps pull request metadata correctly', async () => { - const octokit = makeMockOctokit(''); +describe("githubPrParser", () => { + test("maps pull request metadata correctly", async () => { + const octokit = makeMockOctokit(""); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); - expect(result.title).toBe('My PR title'); - expect(result.description).toBe('My PR description'); + expect(result.title).toBe("My PR title"); + expect(result.description).toBe("My PR description"); expect(result.number).toBe(7); - expect(result.head_sha).toBe('sha_abc123'); - expect(result.owner).toBe('my-org'); - expect(result.repo).toBe('my-repo'); - expect(result.hostDomain).toBe('github.com'); + expect(result.head_sha).toBe("sha_abc123"); + expect(result.owner).toBe("my-org"); + expect(result.repo).toBe("my-repo"); + expect(result.hostDomain).toBe("github.com"); }); - test('uses empty string when body is null', async () => { - const octokit = makeMockOctokit(''); + test("uses empty string when body is null", async () => { + const octokit = makeMockOctokit(""); const pr = makePullRequest({ body: null }); const result = await githubPrParser(octokit, pr); - expect(result.description).toBe(''); + expect(result.description).toBe(""); }); - test('fetches diff using the pull request diff_url', async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: '' }); + test("fetches diff using the REST API endpoint instead of the web diff_url", async () => { + const mockRequest = vi.fn().mockResolvedValue({ data: "" }); const octokit = { request: mockRequest } as unknown as Octokit; - const pr = makePullRequest({ diff_url: 'https://github.com/my-org/my-repo/pull/7.diff' }); + const pr = makePullRequest({ + owner: "my-org", + repo: "my-repo", + number: 7, + }); await githubPrParser(octokit, pr); - expect(mockRequest).toHaveBeenCalledWith('https://github.com/my-org/my-repo/pull/7.diff'); + expect(mockRequest).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + { + owner: "my-org", + repo: "my-repo", + pull_number: 7, + mediaType: { format: "diff" }, + }, + ); }); - test('returns empty file_diffs for an empty diff', async () => { - const octokit = makeMockOctokit(''); + test("returns empty file_diffs for an empty diff", async () => { + const octokit = makeMockOctokit(""); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); @@ -98,96 +112,98 @@ describe('githubPrParser', () => { expect(result.file_diffs).toEqual([]); }); - test('parses a unified diff with added and context lines', async () => { + test("parses a unified diff with added and context lines", async () => { const unifiedDiff = [ - 'diff --git a/src/foo.ts b/src/foo.ts', - '--- a/src/foo.ts', - '+++ b/src/foo.ts', - '@@ -1,2 +1,3 @@', - ' context line', - '+added line', - ' another context', - ].join('\n'); + "diff --git a/src/foo.ts b/src/foo.ts", + "--- a/src/foo.ts", + "+++ b/src/foo.ts", + "@@ -1,2 +1,3 @@", + " context line", + "+added line", + " another context", + ].join("\n"); const octokit = makeMockOctokit(unifiedDiff); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); expect(result.file_diffs).toHaveLength(1); - expect(result.file_diffs[0].from).toBe('src/foo.ts'); - expect(result.file_diffs[0].to).toBe('src/foo.ts'); + expect(result.file_diffs[0].from).toBe("src/foo.ts"); + expect(result.file_diffs[0].to).toBe("src/foo.ts"); expect(result.file_diffs[0].diffs).toHaveLength(1); }); - test('newSnippet contains added lines and context', async () => { + test("newSnippet contains added lines and context", async () => { const unifiedDiff = [ - 'diff --git a/src/foo.ts b/src/foo.ts', - '--- a/src/foo.ts', - '+++ b/src/foo.ts', - '@@ -1,1 +1,2 @@', - ' context', - '+new line here', - ].join('\n'); + "diff --git a/src/foo.ts b/src/foo.ts", + "--- a/src/foo.ts", + "+++ b/src/foo.ts", + "@@ -1,1 +1,2 @@", + " context", + "+new line here", + ].join("\n"); const octokit = makeMockOctokit(unifiedDiff); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); const diff = result.file_diffs[0].diffs[0]; - expect(diff.newSnippet).toContain('+new line here'); - expect(diff.oldSnippet).not.toContain('+new line here'); + expect(diff.newSnippet).toContain("+new line here"); + expect(diff.oldSnippet).not.toContain("+new line here"); }); - test('oldSnippet contains deleted lines', async () => { + test("oldSnippet contains deleted lines", async () => { const unifiedDiff = [ - 'diff --git a/src/foo.ts b/src/foo.ts', - '--- a/src/foo.ts', - '+++ b/src/foo.ts', - '@@ -1,2 +1,1 @@', - ' context', - '-removed line', - ].join('\n'); + "diff --git a/src/foo.ts b/src/foo.ts", + "--- a/src/foo.ts", + "+++ b/src/foo.ts", + "@@ -1,2 +1,1 @@", + " context", + "-removed line", + ].join("\n"); const octokit = makeMockOctokit(unifiedDiff); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); const diff = result.file_diffs[0].diffs[0]; - expect(diff.oldSnippet).toContain('-removed line'); - expect(diff.newSnippet).not.toContain('-removed line'); + expect(diff.oldSnippet).toContain("-removed line"); + expect(diff.newSnippet).not.toContain("-removed line"); }); - test('parses multiple files from a diff', async () => { + test("parses multiple files from a diff", async () => { const unifiedDiff = [ - 'diff --git a/src/a.ts b/src/a.ts', - '--- a/src/a.ts', - '+++ b/src/a.ts', - '@@ -1,1 +1,2 @@', - ' ctx', - '+add', - 'diff --git a/src/b.ts b/src/b.ts', - '--- a/src/b.ts', - '+++ b/src/b.ts', - '@@ -1,2 +1,1 @@', - ' ctx', - '-remove', - ].join('\n'); + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1,1 +1,2 @@", + " ctx", + "+add", + "diff --git a/src/b.ts b/src/b.ts", + "--- a/src/b.ts", + "+++ b/src/b.ts", + "@@ -1,2 +1,1 @@", + " ctx", + "-remove", + ].join("\n"); const octokit = makeMockOctokit(unifiedDiff); const pr = makePullRequest(); const result = await githubPrParser(octokit, pr); expect(result.file_diffs).toHaveLength(2); - expect(result.file_diffs[0].to).toBe('src/a.ts'); - expect(result.file_diffs[1].to).toBe('src/b.ts'); + expect(result.file_diffs[0].to).toBe("src/a.ts"); + expect(result.file_diffs[1].to).toBe("src/b.ts"); }); - test('throws when the diff request fails', async () => { + test("throws when the diff request fails", async () => { const octokit = { - request: vi.fn().mockRejectedValue(new Error('Network error')), + request: vi.fn().mockRejectedValue(new Error("Network error")), } as unknown as Octokit; const pr = makePullRequest(); - await expect(githubPrParser(octokit, pr)).rejects.toThrow('Network error'); + await expect(githubPrParser(octokit, pr)).rejects.toThrow( + "Network error", + ); }); }); diff --git a/packages/web/src/features/agents/review-agent/nodes/githubPrParser.ts b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.ts index b633450ea..f928b1962 100644 --- a/packages/web/src/features/agents/review-agent/nodes/githubPrParser.ts +++ b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.ts @@ -1,57 +1,81 @@ -import { sourcebot_pr_payload, sourcebot_file_diff, sourcebot_diff } from "@/features/agents/review-agent/types"; +import { + sourcebot_pr_payload, + sourcebot_file_diff, + sourcebot_diff, +} from "@/features/agents/review-agent/types"; import parse from "parse-diff"; import { Octokit } from "octokit"; import { GitHubPullRequest } from "@/features/agents/review-agent/types"; import { createLogger } from "@sourcebot/shared"; -const logger = createLogger('github-pr-parser'); +const logger = createLogger("github-pr-parser"); -export const githubPrParser = async (octokit: Octokit, pullRequest: GitHubPullRequest): Promise => { +export const githubPrParser = async ( + octokit: Octokit, + pullRequest: GitHubPullRequest, +): Promise => { logger.debug("Executing github_pr_parser"); - let parsedDiff: parse.File[] = []; + let parsedDiff: parse.File[] = []; try { - const diff = await octokit.request(pullRequest.diff_url); - parsedDiff = parse(diff.data); + const diff = await octokit.request( + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + { + owner: pullRequest.base.repo.owner.login, + repo: pullRequest.base.repo.name, + pull_number: pullRequest.number, + mediaType: { + format: "diff", + }, + }, + ); + parsedDiff = parse(diff.data as unknown as string); } catch (error) { logger.error("Error fetching diff: ", error); throw error; } - const sourcebotFileDiffs: (sourcebot_file_diff | null)[] = parsedDiff.map((file) => { - if (!file.from || !file.to) { - logger.debug(`Skipping file due to missing from (${file.from}) or to (${file.to})`) - return null; - } + const sourcebotFileDiffs: (sourcebot_file_diff | null)[] = parsedDiff.map( + (file) => { + if (!file.from || !file.to) { + logger.debug( + `Skipping file due to missing from (${file.from}) or to (${file.to})`, + ); + return null; + } - const diffs: sourcebot_diff[] = file.chunks.map((chunk) => { - let oldSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`; - let newSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`; + const diffs: sourcebot_diff[] = file.chunks.map((chunk) => { + let oldSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`; + let newSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`; - for (const change of chunk.changes) { - if (change.type === "normal") { - oldSnippet += change.ln1 + ":" + change.content + "\n"; - newSnippet += change.ln2 + ":" + change.content + "\n"; - } else if (change.type === "add") { - newSnippet += change.ln + ":" + change.content + "\n"; - } else if (change.type === "del") { - oldSnippet += change.ln + ":" + change.content + "\n"; + for (const change of chunk.changes) { + if (change.type === "normal") { + oldSnippet += change.ln1 + ":" + change.content + "\n"; + newSnippet += change.ln2 + ":" + change.content + "\n"; + } else if (change.type === "add") { + newSnippet += change.ln + ":" + change.content + "\n"; + } else if (change.type === "del") { + oldSnippet += change.ln + ":" + change.content + "\n"; + } } - } - return { - oldSnippet: oldSnippet, - newSnippet: newSnippet, - } - }); + return { + oldSnippet: oldSnippet, + newSnippet: newSnippet, + }; + }); - return { - from: file.from, - to: file.to, - diffs: diffs, - } - }); - const filteredSourcebotFileDiffs: sourcebot_file_diff[] = sourcebotFileDiffs.filter((file) => file !== null) as sourcebot_file_diff[]; + return { + from: file.from, + to: file.to, + diffs: diffs, + }; + }, + ); + const filteredSourcebotFileDiffs: sourcebot_file_diff[] = + sourcebotFileDiffs.filter( + (file) => file !== null, + ) as sourcebot_file_diff[]; logger.debug("Completed github_pr_parser"); return { @@ -62,6 +86,6 @@ export const githubPrParser = async (octokit: Octokit, pullRequest: GitHubPullRe repo: pullRequest.base.repo.name, file_diffs: filteredSourcebotFileDiffs, number: pullRequest.number, - head_sha: pullRequest.head.sha - } -} \ No newline at end of file + head_sha: pullRequest.head.sha, + }; +};