Skip to content

Commit 2afa854

Browse files
author
Gavin Williams
committed
fix(web): fix GitLab MR inline comment 400 errors on context lines and renames
Two bugs caused `MergeRequestDiscussions.create` to return 400 Bad Request for a subset of inline comments. --- ### Missing `oldPath` on modified files GitLab's Discussions API requires both `oldPath` and `newPath` in the position object for any file that exists in both the base and head commits. Previously only `newPath` was set, causing GitLab to reject comments on modified files. `oldPath` is now always included, falling back to `filename` when no rename is present. ### Missing `oldLine` for context (unchanged) lines GitLab requires `oldLine` in addition to `newLine` when the target line is a context line (unchanged in the diff). Without it the API returns 400 for those lines while accepting added-line comments — explaining why only *some* comments in a given MR failed. The fix parses `oldSnippet`/`newSnippet` from the diff payload to build a new→old line number map. Context lines appear at the same index in both snippets, so zipping them gives the mapping. `oldLine` is only included when the line is confirmed to be a context line; added lines continue to use `newLine` only. ### Rename support `oldFilename` is now propagated through `generatePrReview.ts` → `sourcebot_file_diff_review` → `gitlabPushMrReviews`, so renamed files correctly pass the old path as `oldPath`. --- ### Files changed | File | Change | |------|--------| | `types.ts` | Added `oldFilename?: string` to `sourcebot_file_diff_review_schema` | | `generatePrReview.ts` | Sets `oldFilename: file_diff.from` when building review objects | | `gitlabPushMrReviews.ts` | Adds `oldPath`, context-line `oldLine` mapping via `buildContextLineMap` | | `gitlabPushMrReviews.test.ts` | Adds `oldPath` to position assertion; new tests for renames and context vs added line behaviour |
1 parent f49932d commit 2afa854

4 files changed

Lines changed: 137 additions & 1 deletion

File tree

packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const generatePrReviews = async (reviewAgentLogFileName: string | undefin
4242
if (reviews.length > 0) {
4343
file_diff_reviews.push({
4444
filename: file_diff.to,
45+
oldFilename: file_diff.from,
4546
reviews: reviews,
4647
});
4748
}

packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test, vi, describe } from 'vitest';
22
import { gitlabPushMrReviews } from './gitlabPushMrReviews';
3-
import { sourcebot_pr_payload, sourcebot_file_diff_review } from '../types';
3+
import { sourcebot_pr_payload, sourcebot_file_diff_review, sourcebot_diff_refs } from '../types';
44

55
vi.mock('@sourcebot/shared', () => ({
66
createLogger: () => ({
@@ -64,6 +64,7 @@ describe('gitlabPushMrReviews', () => {
6464
baseSha: 'base_sha_value',
6565
headSha: 'head_sha_value',
6666
startSha: 'start_sha_value',
67+
oldPath: 'src/foo.ts',
6768
newPath: 'src/foo.ts',
6869
newLine: '5',
6970
}),
@@ -184,4 +185,92 @@ describe('gitlabPushMrReviews', () => {
184185
gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW),
185186
).resolves.not.toThrow();
186187
});
188+
189+
test('uses oldFilename as oldPath when file was renamed', async () => {
190+
const renamedReview: sourcebot_file_diff_review[] = [
191+
{
192+
filename: 'src/new-name.ts',
193+
oldFilename: 'src/old-name.ts',
194+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on renamed file' }],
195+
},
196+
];
197+
const client = makeMockClient();
198+
199+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, renamedReview);
200+
201+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
202+
101,
203+
42,
204+
'Comment on renamed file',
205+
expect.objectContaining({
206+
position: expect.objectContaining({
207+
oldPath: 'src/old-name.ts',
208+
newPath: 'src/new-name.ts',
209+
}),
210+
}),
211+
);
212+
});
213+
214+
test('passes oldLine for context lines and omits it for added lines', async () => {
215+
// old line 47 = new line 48 (a line was added at new line 47 before it)
216+
const payloadWithDiffs: sourcebot_pr_payload = {
217+
...MOCK_PAYLOAD,
218+
file_diffs: [{
219+
from: 'src/foo.ts',
220+
to: 'src/foo.ts',
221+
diffs: [{
222+
oldSnippet: '@@ -47,1 +48,1 @@\n47: context line\n',
223+
newSnippet: '@@ -47,1 +48,1 @@\n48: context line\n',
224+
}],
225+
}],
226+
};
227+
const contextReview: sourcebot_file_diff_review[] = [
228+
{
229+
filename: 'src/foo.ts',
230+
reviews: [{ line_start: 48, line_end: 48, review: 'Context line comment' }],
231+
},
232+
];
233+
const client = makeMockClient();
234+
235+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, contextReview);
236+
237+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
238+
101,
239+
42,
240+
'Context line comment',
241+
expect.objectContaining({
242+
position: expect.objectContaining({
243+
newLine: '48',
244+
oldLine: '47',
245+
}),
246+
}),
247+
);
248+
});
249+
250+
test('does not pass oldLine for added lines', async () => {
251+
const payloadWithDiffs: sourcebot_pr_payload = {
252+
...MOCK_PAYLOAD,
253+
file_diffs: [{
254+
from: 'src/foo.ts',
255+
to: 'src/foo.ts',
256+
diffs: [{
257+
oldSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n',
258+
newSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n2:+added line\n',
259+
}],
260+
}],
261+
};
262+
const addedLineReview: sourcebot_file_diff_review[] = [
263+
{
264+
filename: 'src/foo.ts',
265+
reviews: [{ line_start: 2, line_end: 2, review: 'Comment on added line' }],
266+
},
267+
];
268+
const client = makeMockClient();
269+
270+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, addedLineReview);
271+
272+
const call = client.MergeRequestDiscussions.create.mock.calls[0][3];
273+
expect(call.position).not.toHaveProperty('oldLine');
274+
expect(call.position.newLine).toBe('2');
275+
});
187276
});

packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,46 @@ import { createLogger } from "@sourcebot/shared";
44

55
const logger = createLogger('gitlab-push-mr-reviews');
66

7+
/**
8+
* Extracts new-file line numbers for context (unchanged) lines from a snippet.
9+
* Snippet lines have the format `<lineNum>:<content>` where content starts with
10+
* a space for context lines, `+` for additions, and `-` for deletions.
11+
*/
12+
function extractContextLineNumbers(snippet: string): number[] {
13+
const result: number[] = [];
14+
for (const line of snippet.split('\n')) {
15+
if (line.startsWith('@@')) continue;
16+
const colonIdx = line.indexOf(':');
17+
if (colonIdx === -1) continue;
18+
const lineNum = parseInt(line.substring(0, colonIdx), 10);
19+
if (isNaN(lineNum)) continue;
20+
const content = line.substring(colonIdx + 1);
21+
if (content.startsWith(' ')) result.push(lineNum);
22+
}
23+
return result;
24+
}
25+
26+
/**
27+
* Builds a per-file map from new-file line number → old-file line number for
28+
* context lines. Context lines appear at the same index in oldSnippet and
29+
* newSnippet, so zipping them gives the mapping.
30+
*/
31+
function buildContextLineMap(prPayload: sourcebot_pr_payload): Map<string, Map<number, number>> {
32+
const fileMap = new Map<string, Map<number, number>>();
33+
for (const fileDiff of prPayload.file_diffs) {
34+
const lineMap = new Map<number, number>();
35+
for (const diff of fileDiff.diffs) {
36+
const oldNums = extractContextLineNumbers(diff.oldSnippet);
37+
const newNums = extractContextLineNumbers(diff.newSnippet);
38+
for (let i = 0; i < Math.min(oldNums.length, newNums.length); i++) {
39+
lineMap.set(newNums[i], oldNums[i]);
40+
}
41+
}
42+
fileMap.set(fileDiff.to, lineMap);
43+
}
44+
return fileMap;
45+
}
46+
747
export const gitlabPushMrReviews = async (
848
gitlabClient: InstanceType<typeof Gitlab>,
949
projectId: number,
@@ -18,9 +58,12 @@ export const gitlabPushMrReviews = async (
1858
}
1959

2060
const { base_sha, head_sha, start_sha } = prPayload.diff_refs;
61+
const contextLineMap = buildContextLineMap(prPayload);
2162

2263
for (const fileDiffReview of fileDiffReviews) {
64+
const fileContextMap = contextLineMap.get(fileDiffReview.filename);
2365
for (const review of fileDiffReview.reviews) {
66+
const oldLine = fileContextMap?.get(review.line_end);
2467
try {
2568
await gitlabClient.MergeRequestDiscussions.create(
2669
projectId,
@@ -32,8 +75,10 @@ export const gitlabPushMrReviews = async (
3275
baseSha: base_sha,
3376
headSha: head_sha,
3477
startSha: start_sha,
78+
oldPath: fileDiffReview.oldFilename ?? fileDiffReview.filename,
3579
newPath: fileDiffReview.filename,
3680
newLine: String(review.line_end),
81+
...(oldLine !== undefined ? { oldLine: String(oldLine) } : {}),
3782
},
3883
},
3984
);

packages/web/src/features/agents/review-agent/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type sourcebot_diff_review = z.infer<typeof sourcebot_diff_review_schema>
5252

5353
export const sourcebot_file_diff_review_schema = z.object({
5454
filename: z.string(),
55+
oldFilename: z.string().optional(),
5556
reviews: z.array(sourcebot_diff_review_schema),
5657
});
5758
export type sourcebot_file_diff_review = z.infer<typeof sourcebot_file_diff_review_schema>;

0 commit comments

Comments
 (0)