Skip to content

Commit e017721

Browse files
fatmcgavclaudebrendan-kellam
authored
fix(web): fix GitLab MR inline comment 400 errors on context lines and renames (#1149)
* 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 | * Add CHANGELOG entry * Address Coderabbit review comments * fix(web): fix type error in gitlabPushMrReviews position object Spread oldPath/newPath as optional object entries instead of passing string | undefined, which is not assignable to the gitbeaker DiscussionNotePositionOptions type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): fix DiscussionNotePosition type error in gitlabPushMrReviews Spreading union types (`{} | { oldPath: string }`) into an object literal causes TypeScript to widen the result to a union that doesn't satisfy the gitbeaker discriminated union type. Also, passing `string | undefined` for optional position properties fails due to the `Record<string, unknown>` base type camelizing to `Record<string, {}>`, which excludes `undefined`. Fix by building the position as a `Record<string, string>` imperatively (only ever string values, properties conditionally added) and casting to the position type derived from the `Gitlab` client via `Parameters<>` — no `as any` and no direct import from `@gitbeaker/core`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): always set oldPath/newPath in GitLab MR discussion position GitLab requires both oldPath and newPath in the position object. Previously they were omitted when the path was '/dev/null', breaking inline positioning for added and deleted files. Now for added files (old path is /dev/null) both are set to the new path, and for deleted files (new path is /dev/null) both are set to the old path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(web): remove Record<string,string> cast in gitlabPushMrReviews Now that oldPath/newPath are always plain strings, declare position directly as DiscussionNotePosition and use a single conditional spread for the optional oldLine field. Removes the redundant `as unknown as DiscussionNotePosition` cast. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update CHANGELOG.md --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Brendan Kellam <brendan@sourcebot.dev>
1 parent dc5c7ad commit e017721

5 files changed

Lines changed: 216 additions & 11 deletions

File tree

CHANGELOG.md

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

1414
### Fixed
1515
- Upgraded `protobufjs` to `^7.6.2`. [#1281](https://github.com/sourcebot-dev/sourcebot/pull/1281)
16+
- Fixed GitLab MR inline review comments returning 400 Bad Request on context (unchanged) lines and renamed files. [#1149](https://github.com/sourcebot-dev/sourcebot/pull/1149)
1617

1718
## [5.0.1] - 2026-06-04
1819

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: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect, test, vi, describe } from 'vitest';
22
import { Gitlab } from '@gitbeaker/rest';
33
import { gitlabPushMrReviews } from './gitlabPushMrReviews';
4-
import { sourcebot_pr_payload, sourcebot_file_diff_review } from '../types';
4+
import { sourcebot_pr_payload, sourcebot_file_diff_review, sourcebot_diff_refs } from '../types';
55

66
type GitlabClient = InstanceType<typeof Gitlab>;
77

@@ -67,6 +67,7 @@ describe('gitlabPushMrReviews', () => {
6767
baseSha: 'base_sha_value',
6868
headSha: 'head_sha_value',
6969
startSha: 'start_sha_value',
70+
oldPath: 'src/foo.ts',
7071
newPath: 'src/foo.ts',
7172
newLine: '5',
7273
}),
@@ -187,4 +188,142 @@ describe('gitlabPushMrReviews', () => {
187188
gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW),
188189
).resolves.not.toThrow();
189190
});
191+
192+
test('uses oldFilename as oldPath when file was renamed', async () => {
193+
const renamedReview: sourcebot_file_diff_review[] = [
194+
{
195+
filename: 'src/new-name.ts',
196+
oldFilename: 'src/old-name.ts',
197+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on renamed file' }],
198+
},
199+
];
200+
const client = makeMockClient();
201+
202+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, renamedReview);
203+
204+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
205+
101,
206+
42,
207+
'Comment on renamed file',
208+
expect.objectContaining({
209+
position: expect.objectContaining({
210+
oldPath: 'src/old-name.ts',
211+
newPath: 'src/new-name.ts',
212+
}),
213+
}),
214+
);
215+
});
216+
217+
test('passes oldLine for context lines', async () => {
218+
// old line 47 = new line 48 (a line was added at new line 47 before it)
219+
const payloadWithDiffs: sourcebot_pr_payload = {
220+
...MOCK_PAYLOAD,
221+
file_diffs: [{
222+
from: 'src/foo.ts',
223+
to: 'src/foo.ts',
224+
diffs: [{
225+
oldSnippet: '@@ -47,1 +48,1 @@\n47: context line\n',
226+
newSnippet: '@@ -47,1 +48,1 @@\n48: context line\n',
227+
}],
228+
}],
229+
};
230+
const contextReview: sourcebot_file_diff_review[] = [
231+
{
232+
filename: 'src/foo.ts',
233+
reviews: [{ line_start: 48, line_end: 48, review: 'Context line comment' }],
234+
},
235+
];
236+
const client = makeMockClient();
237+
238+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, contextReview);
239+
240+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
241+
101,
242+
42,
243+
'Context line comment',
244+
expect.objectContaining({
245+
position: expect.objectContaining({
246+
newLine: '48',
247+
oldLine: '47',
248+
}),
249+
}),
250+
);
251+
});
252+
253+
test('does not pass oldLine for added lines', async () => {
254+
const payloadWithDiffs: sourcebot_pr_payload = {
255+
...MOCK_PAYLOAD,
256+
file_diffs: [{
257+
from: 'src/foo.ts',
258+
to: 'src/foo.ts',
259+
diffs: [{
260+
oldSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n',
261+
newSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n2:+added line\n',
262+
}],
263+
}],
264+
};
265+
const addedLineReview: sourcebot_file_diff_review[] = [
266+
{
267+
filename: 'src/foo.ts',
268+
reviews: [{ line_start: 2, line_end: 2, review: 'Comment on added line' }],
269+
},
270+
];
271+
const client = makeMockClient();
272+
273+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, addedLineReview);
274+
275+
const call = client.MergeRequestDiscussions.create.mock.calls[0][3];
276+
expect(call.position).not.toHaveProperty('oldLine');
277+
expect(call.position.newLine).toBe('2');
278+
});
279+
280+
test('uses new path for both oldPath and newPath when old path is /dev/null (added file)', async () => {
281+
const addedFileReview: sourcebot_file_diff_review[] = [
282+
{
283+
filename: 'src/new-file.ts',
284+
oldFilename: '/dev/null',
285+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on new file' }],
286+
},
287+
];
288+
const client = makeMockClient();
289+
290+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, addedFileReview);
291+
292+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
293+
101,
294+
42,
295+
'Comment on new file',
296+
expect.objectContaining({
297+
position: expect.objectContaining({
298+
oldPath: 'src/new-file.ts',
299+
newPath: 'src/new-file.ts',
300+
}),
301+
}),
302+
);
303+
});
304+
305+
test('uses old path for both oldPath and newPath when new path is /dev/null (deleted file)', async () => {
306+
const deletedFileReview: sourcebot_file_diff_review[] = [
307+
{
308+
filename: '/dev/null',
309+
oldFilename: 'src/deleted-file.ts',
310+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on deleted file' }],
311+
},
312+
];
313+
const client = makeMockClient();
314+
315+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, deletedFileReview);
316+
317+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
318+
101,
319+
42,
320+
'Comment on deleted file',
321+
expect.objectContaining({
322+
position: expect.objectContaining({
323+
oldPath: 'src/deleted-file.ts',
324+
newPath: 'src/deleted-file.ts',
325+
}),
326+
}),
327+
);
328+
});
190329
});

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

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,61 @@ import { sourcebot_pr_payload, sourcebot_file_diff_review } from "@/features/age
22
import { Gitlab } from "@gitbeaker/rest";
33
import { createLogger } from "@sourcebot/shared";
44

5+
// Derive the position type from the Gitlab client to avoid importing from @gitbeaker/core.
6+
type DiscussionNotePosition = NonNullable<
7+
NonNullable<Parameters<InstanceType<typeof Gitlab>['MergeRequestDiscussions']['create']>[3]>['position']
8+
>;
9+
510
const logger = createLogger('gitlab-push-mr-reviews');
611

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

2073
const { base_sha, head_sha, start_sha } = prPayload.diff_refs;
74+
const contextLineMap = buildContextLineMap(prPayload);
2175

2276
for (const fileDiffReview of fileDiffReviews) {
77+
const fileContextMap = contextLineMap.get(fileDiffReview.filename);
78+
const resolvedOldPath = fileDiffReview.oldFilename ?? fileDiffReview.filename;
79+
// GitLab requires both oldPath and newPath in the position object.
80+
// For added files (old is /dev/null) use the new path for both;
81+
// for deleted files (new is /dev/null) use the old path for both.
82+
const oldPath = resolvedOldPath !== '/dev/null' ? resolvedOldPath : fileDiffReview.filename;
83+
const newPath = fileDiffReview.filename !== '/dev/null' ? fileDiffReview.filename : resolvedOldPath;
2384
for (const review of fileDiffReview.reviews) {
85+
const oldLine = fileContextMap?.get(review.line_end);
86+
const position: DiscussionNotePosition = {
87+
positionType: 'text',
88+
baseSha: base_sha,
89+
headSha: head_sha,
90+
startSha: start_sha,
91+
oldPath,
92+
newPath,
93+
newLine: String(review.line_end),
94+
...(oldLine !== undefined ? { oldLine: String(oldLine) } : {}),
95+
};
2496
try {
2597
await gitlabClient.MergeRequestDiscussions.create(
2698
projectId,
2799
prPayload.number,
28100
review.review,
29-
{
30-
position: {
31-
positionType: "text",
32-
baseSha: base_sha,
33-
headSha: head_sha,
34-
startSha: start_sha,
35-
newPath: fileDiffReview.filename,
36-
newLine: String(review.line_end),
37-
},
38-
},
101+
{ position },
39102
);
40103
} catch (error) {
41104
// Inline comment failed (e.g. line not in diff) — fall back to a general MR note

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)