Skip to content

Commit 143ba90

Browse files
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
1 parent 1387c46 commit 143ba90

3 files changed

Lines changed: 169 additions & 126 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- 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)
12+
1013
## [5.0.2] - 2026-06-11
1114

1215
### Changed
Lines changed: 104 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { expect, test, vi, describe } from 'vitest';
2-
import { Octokit } from 'octokit';
3-
import { githubPrParser } from './githubPrParser';
4-
import { GitHubPullRequest } from '../types';
1+
import { expect, test, vi, describe } from "vitest";
2+
import { Octokit } from "octokit";
3+
import { githubPrParser } from "./githubPrParser";
4+
import { GitHubPullRequest } from "../types";
55

6-
vi.mock('@sourcebot/shared', () => ({
6+
vi.mock("@sourcebot/shared", () => ({
77
createLogger: () => ({
88
debug: vi.fn(),
99
info: vi.fn(),
@@ -13,23 +13,25 @@ vi.mock('@sourcebot/shared', () => ({
1313
}));
1414

1515
// Minimal shape satisfying the fields accessed by githubPrParser
16-
function makePullRequest(overrides: Partial<{
17-
number: number;
18-
title: string;
19-
body: string | null;
20-
head_sha: string;
21-
owner: string;
22-
repo: string;
23-
diff_url: string;
24-
}> = {}): GitHubPullRequest {
16+
function makePullRequest(
17+
overrides: Partial<{
18+
number: number;
19+
title: string;
20+
body: string | null;
21+
head_sha: string;
22+
owner: string;
23+
repo: string;
24+
diff_url: string;
25+
}> = {},
26+
): GitHubPullRequest {
2527
const opts = {
2628
number: 7,
27-
title: 'My PR title',
28-
body: 'My PR description',
29-
head_sha: 'sha_abc123',
30-
owner: 'my-org',
31-
repo: 'my-repo',
32-
diff_url: 'https://github.com/my-org/my-repo/pull/7.diff',
29+
title: "My PR title",
30+
body: "My PR description",
31+
head_sha: "sha_abc123",
32+
owner: "my-org",
33+
repo: "my-repo",
34+
diff_url: "https://github.com/my-org/my-repo/pull/7.diff",
3335
...overrides,
3436
};
3537

@@ -54,140 +56,154 @@ function makeMockOctokit(diffText: string) {
5456
} as unknown as Octokit;
5557
}
5658

57-
describe('githubPrParser', () => {
58-
test('maps pull request metadata correctly', async () => {
59-
const octokit = makeMockOctokit('');
59+
describe("githubPrParser", () => {
60+
test("maps pull request metadata correctly", async () => {
61+
const octokit = makeMockOctokit("");
6062
const pr = makePullRequest();
6163

6264
const result = await githubPrParser(octokit, pr);
6365

64-
expect(result.title).toBe('My PR title');
65-
expect(result.description).toBe('My PR description');
66+
expect(result.title).toBe("My PR title");
67+
expect(result.description).toBe("My PR description");
6668
expect(result.number).toBe(7);
67-
expect(result.head_sha).toBe('sha_abc123');
68-
expect(result.owner).toBe('my-org');
69-
expect(result.repo).toBe('my-repo');
70-
expect(result.hostDomain).toBe('github.com');
69+
expect(result.head_sha).toBe("sha_abc123");
70+
expect(result.owner).toBe("my-org");
71+
expect(result.repo).toBe("my-repo");
72+
expect(result.hostDomain).toBe("github.com");
7173
});
7274

73-
test('uses empty string when body is null', async () => {
74-
const octokit = makeMockOctokit('');
75+
test("uses empty string when body is null", async () => {
76+
const octokit = makeMockOctokit("");
7577
const pr = makePullRequest({ body: null });
7678

7779
const result = await githubPrParser(octokit, pr);
7880

79-
expect(result.description).toBe('');
81+
expect(result.description).toBe("");
8082
});
8183

82-
test('fetches diff using the pull request diff_url', async () => {
83-
const mockRequest = vi.fn().mockResolvedValue({ data: '' });
84+
test("fetches diff using the REST API endpoint instead of the web diff_url", async () => {
85+
const mockRequest = vi.fn().mockResolvedValue({ data: "" });
8486
const octokit = { request: mockRequest } as unknown as Octokit;
85-
const pr = makePullRequest({ diff_url: 'https://github.com/my-org/my-repo/pull/7.diff' });
87+
const pr = makePullRequest({
88+
owner: "my-org",
89+
repo: "my-repo",
90+
number: 7,
91+
});
8692

8793
await githubPrParser(octokit, pr);
8894

89-
expect(mockRequest).toHaveBeenCalledWith('https://github.com/my-org/my-repo/pull/7.diff');
95+
expect(mockRequest).toHaveBeenCalledWith(
96+
"GET /repos/{owner}/{repo}/pulls/{pull_number}",
97+
{
98+
owner: "my-org",
99+
repo: "my-repo",
100+
pull_number: 7,
101+
mediaType: { format: "diff" },
102+
},
103+
);
90104
});
91105

92-
test('returns empty file_diffs for an empty diff', async () => {
93-
const octokit = makeMockOctokit('');
106+
test("returns empty file_diffs for an empty diff", async () => {
107+
const octokit = makeMockOctokit("");
94108
const pr = makePullRequest();
95109

96110
const result = await githubPrParser(octokit, pr);
97111

98112
expect(result.file_diffs).toEqual([]);
99113
});
100114

101-
test('parses a unified diff with added and context lines', async () => {
115+
test("parses a unified diff with added and context lines", async () => {
102116
const unifiedDiff = [
103-
'diff --git a/src/foo.ts b/src/foo.ts',
104-
'--- a/src/foo.ts',
105-
'+++ b/src/foo.ts',
106-
'@@ -1,2 +1,3 @@',
107-
' context line',
108-
'+added line',
109-
' another context',
110-
].join('\n');
117+
"diff --git a/src/foo.ts b/src/foo.ts",
118+
"--- a/src/foo.ts",
119+
"+++ b/src/foo.ts",
120+
"@@ -1,2 +1,3 @@",
121+
" context line",
122+
"+added line",
123+
" another context",
124+
].join("\n");
111125
const octokit = makeMockOctokit(unifiedDiff);
112126
const pr = makePullRequest();
113127

114128
const result = await githubPrParser(octokit, pr);
115129

116130
expect(result.file_diffs).toHaveLength(1);
117-
expect(result.file_diffs[0].from).toBe('src/foo.ts');
118-
expect(result.file_diffs[0].to).toBe('src/foo.ts');
131+
expect(result.file_diffs[0].from).toBe("src/foo.ts");
132+
expect(result.file_diffs[0].to).toBe("src/foo.ts");
119133
expect(result.file_diffs[0].diffs).toHaveLength(1);
120134
});
121135

122-
test('newSnippet contains added lines and context', async () => {
136+
test("newSnippet contains added lines and context", async () => {
123137
const unifiedDiff = [
124-
'diff --git a/src/foo.ts b/src/foo.ts',
125-
'--- a/src/foo.ts',
126-
'+++ b/src/foo.ts',
127-
'@@ -1,1 +1,2 @@',
128-
' context',
129-
'+new line here',
130-
].join('\n');
138+
"diff --git a/src/foo.ts b/src/foo.ts",
139+
"--- a/src/foo.ts",
140+
"+++ b/src/foo.ts",
141+
"@@ -1,1 +1,2 @@",
142+
" context",
143+
"+new line here",
144+
].join("\n");
131145
const octokit = makeMockOctokit(unifiedDiff);
132146
const pr = makePullRequest();
133147

134148
const result = await githubPrParser(octokit, pr);
135149
const diff = result.file_diffs[0].diffs[0];
136150

137-
expect(diff.newSnippet).toContain('+new line here');
138-
expect(diff.oldSnippet).not.toContain('+new line here');
151+
expect(diff.newSnippet).toContain("+new line here");
152+
expect(diff.oldSnippet).not.toContain("+new line here");
139153
});
140154

141-
test('oldSnippet contains deleted lines', async () => {
155+
test("oldSnippet contains deleted lines", async () => {
142156
const unifiedDiff = [
143-
'diff --git a/src/foo.ts b/src/foo.ts',
144-
'--- a/src/foo.ts',
145-
'+++ b/src/foo.ts',
146-
'@@ -1,2 +1,1 @@',
147-
' context',
148-
'-removed line',
149-
].join('\n');
157+
"diff --git a/src/foo.ts b/src/foo.ts",
158+
"--- a/src/foo.ts",
159+
"+++ b/src/foo.ts",
160+
"@@ -1,2 +1,1 @@",
161+
" context",
162+
"-removed line",
163+
].join("\n");
150164
const octokit = makeMockOctokit(unifiedDiff);
151165
const pr = makePullRequest();
152166

153167
const result = await githubPrParser(octokit, pr);
154168
const diff = result.file_diffs[0].diffs[0];
155169

156-
expect(diff.oldSnippet).toContain('-removed line');
157-
expect(diff.newSnippet).not.toContain('-removed line');
170+
expect(diff.oldSnippet).toContain("-removed line");
171+
expect(diff.newSnippet).not.toContain("-removed line");
158172
});
159173

160-
test('parses multiple files from a diff', async () => {
174+
test("parses multiple files from a diff", async () => {
161175
const unifiedDiff = [
162-
'diff --git a/src/a.ts b/src/a.ts',
163-
'--- a/src/a.ts',
164-
'+++ b/src/a.ts',
165-
'@@ -1,1 +1,2 @@',
166-
' ctx',
167-
'+add',
168-
'diff --git a/src/b.ts b/src/b.ts',
169-
'--- a/src/b.ts',
170-
'+++ b/src/b.ts',
171-
'@@ -1,2 +1,1 @@',
172-
' ctx',
173-
'-remove',
174-
].join('\n');
176+
"diff --git a/src/a.ts b/src/a.ts",
177+
"--- a/src/a.ts",
178+
"+++ b/src/a.ts",
179+
"@@ -1,1 +1,2 @@",
180+
" ctx",
181+
"+add",
182+
"diff --git a/src/b.ts b/src/b.ts",
183+
"--- a/src/b.ts",
184+
"+++ b/src/b.ts",
185+
"@@ -1,2 +1,1 @@",
186+
" ctx",
187+
"-remove",
188+
].join("\n");
175189
const octokit = makeMockOctokit(unifiedDiff);
176190
const pr = makePullRequest();
177191

178192
const result = await githubPrParser(octokit, pr);
179193

180194
expect(result.file_diffs).toHaveLength(2);
181-
expect(result.file_diffs[0].to).toBe('src/a.ts');
182-
expect(result.file_diffs[1].to).toBe('src/b.ts');
195+
expect(result.file_diffs[0].to).toBe("src/a.ts");
196+
expect(result.file_diffs[1].to).toBe("src/b.ts");
183197
});
184198

185-
test('throws when the diff request fails', async () => {
199+
test("throws when the diff request fails", async () => {
186200
const octokit = {
187-
request: vi.fn().mockRejectedValue(new Error('Network error')),
201+
request: vi.fn().mockRejectedValue(new Error("Network error")),
188202
} as unknown as Octokit;
189203
const pr = makePullRequest();
190204

191-
await expect(githubPrParser(octokit, pr)).rejects.toThrow('Network error');
205+
await expect(githubPrParser(octokit, pr)).rejects.toThrow(
206+
"Network error",
207+
);
192208
});
193209
});

0 commit comments

Comments
 (0)