From 143ba90354289593b55cf4f4d7c2618ee2208db6 Mon Sep 17 00:00:00 2001 From: AasheeshLikePanner Date: Thu, 11 Jun 2026 15:27:51 +0530 Subject: [PATCH 1/2] fix(web): use REST API endpoint for fetching PR diff instead of web diff_url The Review Agent's githubPrParser was using pullRequest.diff_url to fetch the PR diff, which points to a github.com web URL. GitHub App installation tokens are only accepted on the REST API (api.github.com), so requests to the web domain fail with 404 for private repositories. Fix by using the REST API endpoint GET /repos/{owner}/{repo}/pulls/{pull_number} with mediaType: { format: 'diff' }, which correctly authenticates with the installation token and works for both public and private repos. Fixes #1277 --- CHANGELOG.md | 3 + .../review-agent/nodes/githubPrParser.test.ts | 192 ++++++++++-------- .../review-agent/nodes/githubPrParser.ts | 100 +++++---- 3 files changed, 169 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fadcf22d..397836c08 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. [#1277](https://github.com/sourcebot-dev/sourcebot/issues/1277) + ## [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, + }; +}; From 7ad1c69299c1df14e003ce6cf97aa8ee5a6d97be Mon Sep 17 00:00:00 2001 From: AasheeshLikePanner Date: Thu, 11 Jun 2026 15:44:15 +0530 Subject: [PATCH 2/2] docs: update CHANGELOG to reference PR #1302 instead of issue #1277 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 397836c08..a36c9fe10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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. [#1277](https://github.com/sourcebot-dev/sourcebot/issues/1277) +- 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