@krotovm/gitlab-ai-review 1.0.43 → 1.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Or include it as a [CI/CD component from the GitLab Catalog](https://gitlab.com/
41
41
 
42
42
  ```yaml
43
43
  include:
44
- - component: gitlab.com/KrotovM/gitlab-ai-review/review@1.0.2
44
+ - component: gitlab.com/KrotovM/gitlab-ai-review/review@1.0.3
45
45
  inputs:
46
46
  args: "--include-artifacts"
47
47
  ```
@@ -101,8 +101,18 @@ GitLab provides these automatically in Merge Request pipelines:
101
101
  - `--max-review-concurrency=2` - Parallel per-file review API calls (CI multi-pass only). Set to 1 for single-GPU backends that queue-starve long requests.
102
102
  - `--debug` - Print full error details (stack and API error fields).
103
103
  - `--include-artifacts` - Generate a local HTML debug artifact with per-pass outputs/tokens.
104
+ - `--inline-comments` - Post findings on changed lines as resolvable diff threads; the rest stays in the summary comment. See [Inline comments](#inline-comments).
104
105
  - `--help` - Show help output.
105
106
 
107
+ ## Inline comments
108
+
109
+ With `--inline-comments`, each finding whose line falls inside the MR diff becomes a resolvable diff thread. Findings outside the diff (unchanged code, other files) stay in the summary comment, which also counts the threads. On later runs:
110
+
111
+ - A finding is not posted again when an AI thread already sits within 3 lines of it on unchanged code — open or resolved, so resolving a false positive dismisses it.
112
+ - An open AI thread whose code changed (GitLab shows it as outdated) is resolved automatically with a short reply, unless someone replied to it; if you reopen it afterwards, later runs leave it alone. If the review reports the issue again, it is posted as a new thread. GitLab's own **Automatically resolve merge request diff threads when they become outdated** setting does this for every thread.
113
+
114
+ Turn on **Settings → Merge requests → All threads must be resolved** to make AI findings block the merge until someone fixes or dismisses them. Line numbers come from the model, which sees numbered diffs and files, so a thread can still occasionally land a line away from the issue. Resolving threads needs a token with at least the Developer role.
115
+
106
116
  ## Benchmark
107
117
 
108
118
  Head-to-head against [Alibaba OpenCodeReview](https://github.com/alibaba/open-code-review) v1.8.10 (via its official GitLab CI recipe) on a demo MR with 4 seeded bugs: an auth bypass (`API_KEYS` unset → empty key accepted), a rate limiter whose window never resets, an unbounded per-IP counters Map, and a null-deref crash in a stats endpoint. Same MR, same commit range, same backends.
package/dist/cli/args.js CHANGED
@@ -40,6 +40,10 @@ export function hasIncludeArtifactsFlag(argv) {
40
40
  const args = new Set(argv.slice(2));
41
41
  return args.has("--include-artifacts");
42
42
  }
43
+ export function hasInlineCommentsFlag(argv) {
44
+ const args = new Set(argv.slice(2));
45
+ return args.has("--inline-comments");
46
+ }
43
47
  export function parseIgnoreExtensions(argv) {
44
48
  const parsed = [];
45
49
  const args = argv.slice(2);
@@ -1,6 +1,6 @@
1
1
  /** @format */
2
2
  import OpenAI from "openai";
3
- import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
3
+ import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, DISCLAIMER, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
4
4
  import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
5
5
  import { parseReviewFindings, truncateWithMarker } from "../prompt/utils.js";
6
6
  import { logToolFailure, logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
@@ -177,12 +177,17 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep)
177
177
  error: fileText.message,
178
178
  });
179
179
  }
180
+ // Numbered like `cat -n`, so findings cite exact lines instead of counted ones.
181
+ const numbered = fileText
182
+ .split("\n")
183
+ .map((line, i) => `${i + 1}\t${line}`)
184
+ .join("\n");
180
185
  return JSON.stringify({
181
186
  ok: true,
182
187
  path,
183
188
  ref,
184
- content: fileText.slice(0, MAX_TOOL_FILE_CHARS),
185
- truncated: fileText.length > MAX_TOOL_FILE_CHARS,
189
+ content: numbered.slice(0, MAX_TOOL_FILE_CHARS),
190
+ truncated: numbered.length > MAX_TOOL_FILE_CHARS,
186
191
  });
187
192
  }
188
193
  catch (error) {
@@ -260,7 +265,7 @@ async function reviewMergeRequestWithTools(params) {
260
265
  const { logDebug, logStep } = loggers;
261
266
  logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
262
267
  const messages = buildPrompt({
263
- changes: changes.map((change) => ({ diff: change.diff })),
268
+ changes: changes.map((change) => ({ path: change.new_path, diff: change.diff })),
264
269
  limits: promptLimits,
265
270
  allowTools: true,
266
271
  profile: promptProfile,
@@ -274,7 +279,7 @@ async function reviewMergeRequestWithTools(params) {
274
279
  type: "function",
275
280
  function: {
276
281
  name: TOOL_NAME_GET_FILE,
277
- description: "Fetch raw file content at a specific git ref for review context.",
282
+ description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
278
283
  parameters: {
279
284
  type: "object",
280
285
  additionalProperties: false,
@@ -419,7 +424,7 @@ async function runFileReviewWithTools(params) {
419
424
  type: "function",
420
425
  function: {
421
426
  name: TOOL_NAME_GET_FILE,
422
- description: "Fetch raw file content at a specific git ref for review context.",
427
+ description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
423
428
  parameters: {
424
429
  type: "object",
425
430
  additionalProperties: false,
@@ -559,7 +564,7 @@ async function runVerificationWithTools(params) {
559
564
  type: "function",
560
565
  function: {
561
566
  name: TOOL_NAME_GET_FILE,
562
- description: "Fetch raw file content at a specific git ref for review context.",
567
+ description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
563
568
  parameters: {
564
569
  type: "object",
565
570
  additionalProperties: false,
@@ -885,7 +890,6 @@ export async function reviewMergeRequestMultiPass(params) {
885
890
  profile: effectiveProfile,
886
891
  });
887
892
  if (consolidateMessages == null) {
888
- const DISCLAIMER = "This comment was generated by AI review bot.";
889
893
  return withHeader(`${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
890
894
  }
891
895
  try {
@@ -937,7 +941,6 @@ export async function reviewMergeRequestMultiPass(params) {
937
941
  }
938
942
  catch (error) {
939
943
  logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
940
- const DISCLAIMER = "This comment was generated by AI review bot.";
941
944
  const raw = perFileFindings
942
945
  .filter((f) => !isEmptyReviewBody(f.findings))
943
946
  .map((f) => f.findings)
@@ -0,0 +1,205 @@
1
+ /** @format */
2
+ import { DISCLAIMER } from "../prompt/index.js";
3
+ import { isEmptyReviewBody } from "../prompt/profile.js";
4
+ import { parseReviewFindings, renderReviewFinding, } from "../prompt/utils.js";
5
+ import { createMergeRequestDiscussion, fetchMergeRequestDiffRefs, listMergeRequestDiscussions, replyToMergeRequestDiscussion, resolveMergeRequestDiscussion, } from "../gitlab/services.js";
6
+ import { logToolFailure } from "./tooling.js";
7
+ /** Hidden marker that lets later runs recognise this tool's diff threads. */
8
+ export const INLINE_MARKER = "<!-- gitlab-ai-review:inline -->";
9
+ /** A finding this close to an existing AI thread counts as the same finding:
10
+ * model line numbers are approximate and titles vary between runs. */
11
+ const SAME_PLACE_LINES = 3;
12
+ const AUTO_RESOLVED_REPLY = "Resolved automatically: the code under this thread changed. If the review reports the issue again, it opens a new thread.";
13
+ const sameRefs = (a, b) => a.base_sha === b.base_sha &&
14
+ a.start_sha === b.start_sha &&
15
+ a.head_sha === b.head_sha;
16
+ /** Maps every new-side line a diff thread can sit on to its old-side line
17
+ * (context line) or null (added line). */
18
+ export function mapNewLines(diff) {
19
+ const lines = new Map();
20
+ let oldLine = 0;
21
+ let newLine = 0;
22
+ for (const text of diff.split("\n")) {
23
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
24
+ if (hunk != null) {
25
+ oldLine = Number(hunk[1]);
26
+ newLine = Number(hunk[2]);
27
+ }
28
+ else if (newLine > 0 && text.startsWith("+")) {
29
+ lines.set(newLine++, null);
30
+ }
31
+ else if (newLine > 0 && text.startsWith(" ")) {
32
+ lines.set(newLine++, oldLine++);
33
+ }
34
+ else if (text.startsWith("-")) {
35
+ oldLine++;
36
+ }
37
+ }
38
+ return lines;
39
+ }
40
+ function positionFor(finding, changes, refs) {
41
+ const path = finding.file.replace(/`/g, "").replace(/^\.?\//, "");
42
+ const line = Number(/\d+/.exec(finding.line)?.[0]);
43
+ const change = changes.find((c) => c.new_path === path && !c.deleted_file);
44
+ if (change == null || !Number.isInteger(line))
45
+ return null;
46
+ const lines = mapNewLines(change.diff);
47
+ if (!lines.has(line))
48
+ return null;
49
+ const oldLine = lines.get(line);
50
+ return {
51
+ position_type: "text",
52
+ ...refs,
53
+ old_path: change.old_path,
54
+ new_path: change.new_path,
55
+ new_line: line,
56
+ ...(oldLine != null ? { old_line: oldLine } : {}),
57
+ };
58
+ }
59
+ /** Findings that are not in `post` or `known` stay in the summary comment. */
60
+ export function planInlineComments(params) {
61
+ const { findings, changes, refs, discussions } = params;
62
+ const threads = discussions.flatMap((d) => {
63
+ const note = d.notes[0];
64
+ if (note?.position == null || !note.body.includes(INLINE_MARKER))
65
+ return [];
66
+ return [
67
+ {
68
+ id: d.id,
69
+ new_path: note.position.new_path,
70
+ new_line: note.position.new_line,
71
+ // GitLab moves a thread to the new diff version on every push while its
72
+ // line is unchanged; a thread left on older refs is outdated.
73
+ outdated: !sameRefs(note.position, refs),
74
+ resolved: note.resolved === true,
75
+ // GitLab adds its "changed this line" system note to the thread; any
76
+ // other reply (a person, or our auto-resolve note) means hands off.
77
+ untouched: d.notes.filter((n) => n.system !== true).length === 1,
78
+ },
79
+ ];
80
+ });
81
+ const plan = { post: [], known: [], resolve: [] };
82
+ for (const finding of findings) {
83
+ const position = positionFor(finding, changes, refs);
84
+ if (position == null)
85
+ continue;
86
+ const known = threads.some((t) => !t.outdated &&
87
+ t.new_path === position.new_path &&
88
+ t.new_line != null &&
89
+ Math.abs(t.new_line - position.new_line) <= SAME_PLACE_LINES);
90
+ if (known)
91
+ plan.known.push(finding);
92
+ else
93
+ plan.post.push({ finding, position });
94
+ }
95
+ plan.resolve = threads
96
+ .filter((t) => t.outdated && !t.resolved && t.untouched)
97
+ .map((t) => t.id);
98
+ return plan;
99
+ }
100
+ function summarize(answer, covered, counts) {
101
+ let body = answer;
102
+ for (const finding of covered) {
103
+ const block = renderReviewFinding(finding);
104
+ body = body.replace(body.includes(`${block}\n\n`) ? `${block}\n\n` : block, "");
105
+ }
106
+ const parts = [
107
+ counts.posted > 0 ? `${counts.posted} new` : "",
108
+ counts.known > 0 ? `${counts.known} already reported (open or resolved)` : "",
109
+ counts.resolved > 0 ? `${counts.resolved} outdated resolved` : "",
110
+ ].filter(Boolean);
111
+ if (parts.length === 0)
112
+ return body;
113
+ const note = `\n\n💬 Diff threads: ${parts.join(", ")}.`;
114
+ const disclaimerAt = body.lastIndexOf("\n\n---\n_");
115
+ return disclaimerAt === -1
116
+ ? body + note
117
+ : body.slice(0, disclaimerAt) + note + body.slice(disclaimerAt);
118
+ }
119
+ /** Posts findings that land on changed lines as resolvable diff threads,
120
+ * resolves AI threads whose code changed, and returns the summary comment
121
+ * without the findings now covered by threads. Falls back to the full
122
+ * summary comment when anything goes wrong. */
123
+ export async function postInlineFindings(params) {
124
+ const { answer, changes, diffRefs, logStep } = params;
125
+ const api = {
126
+ gitLabProjectApiUrl: params.gitLabProjectApiUrl,
127
+ headers: params.headers,
128
+ mergeRequestIid: params.mergeRequestIid,
129
+ };
130
+ const fallBack = (error) => {
131
+ logToolFailure(logStep, "inline_comments", error);
132
+ logStep("[inline] Posting all findings in the summary comment instead.");
133
+ return answer;
134
+ };
135
+ const { base_sha, start_sha, head_sha } = diffRefs ?? {};
136
+ if (base_sha == null || start_sha == null || head_sha == null) {
137
+ return fallBack(new Error("merge request has no diff refs"));
138
+ }
139
+ const refs = { base_sha, start_sha, head_sha };
140
+ try {
141
+ // List threads before re-reading the MR: GitLab moves threads only after it
142
+ // stores a new diff version, so a push that moved any thread we saw also
143
+ // shows up in the refs read below and stops this run.
144
+ const discussions = await listMergeRequestDiscussions(api);
145
+ if (discussions instanceof Error)
146
+ return fallBack(discussions);
147
+ const current = await fetchMergeRequestDiffRefs(api);
148
+ if (current instanceof Error)
149
+ return fallBack(current);
150
+ if (!sameRefs(current, refs)) {
151
+ logStep("[inline] MR has a newer diff version; posting findings in the summary comment only.");
152
+ return answer;
153
+ }
154
+ const findings = parseReviewFindings(answer);
155
+ // Only a finding whose rendered block sits in the comment verbatim can move
156
+ // to a thread without leaving a copy behind or unbalancing a code fence.
157
+ const movable = findings.filter((f) => {
158
+ const block = renderReviewFinding(f);
159
+ return answer.includes(block) && (block.match(/```/g) ?? []).length % 2 === 0;
160
+ });
161
+ const plan = planInlineComments({ findings: movable, changes, refs, discussions });
162
+ const posted = [];
163
+ for (const { finding, position } of plan.post) {
164
+ const body = `${renderReviewFinding(finding)}\n\n---\n_${DISCLAIMER}_\n\n${INLINE_MARKER}`;
165
+ const res = await createMergeRequestDiscussion({ ...api, body, position });
166
+ // A rejected position leaves the finding in the summary comment.
167
+ if (res instanceof Error)
168
+ logToolFailure(logStep, "inline_comments", res);
169
+ else
170
+ posted.push(finding);
171
+ }
172
+ // Resolve only after a real review whose findings could all become threads:
173
+ // a failed run, or an issue re-reported only in the summary, must not close
174
+ // threads. Header and triage-summary lines can't pass for "no findings".
175
+ const reviewed = movable.length === findings.length &&
176
+ (findings.length > 0 || isEmptyReviewBody(answer.replace(/^[#>].*$/gm, "")));
177
+ let resolved = 0;
178
+ for (const discussionId of reviewed ? plan.resolve : []) {
179
+ const res = await resolveMergeRequestDiscussion({ ...api, discussionId });
180
+ if (res instanceof Error) {
181
+ logToolFailure(logStep, "inline_comments", res);
182
+ continue;
183
+ }
184
+ resolved += 1;
185
+ // The reply also marks the thread as handled: if someone reopens it,
186
+ // later runs leave it alone.
187
+ const reply = await replyToMergeRequestDiscussion({
188
+ ...api,
189
+ discussionId,
190
+ body: AUTO_RESOLVED_REPLY,
191
+ });
192
+ if (reply instanceof Error)
193
+ logToolFailure(logStep, "inline_comments", reply);
194
+ }
195
+ logStep(`[inline] Diff threads: ${posted.length} new, ${plan.known.length} already reported, ${resolved} outdated resolved.`);
196
+ return summarize(answer, [...posted, ...plan.known], {
197
+ posted: posted.length,
198
+ known: plan.known.length,
199
+ resolved,
200
+ });
201
+ }
202
+ catch (error) {
203
+ return fallBack(error);
204
+ }
205
+ }
package/dist/cli.js CHANGED
@@ -3,8 +3,9 @@
3
3
  import OpenAI from "openai";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
6
- import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, parseTriageDiffChars, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
6
+ import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, hasInlineCommentsFlag, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, parseTriageDiffChars, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
7
7
  import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
8
+ import { postInlineFindings } from "./cli/inline-comments.js";
8
9
  import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
9
10
  import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
10
11
  function printHelp() {
@@ -27,6 +28,7 @@ Debug:
27
28
  --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).
28
29
  --max-findings=5 Max findings in final review (CI multi-pass only).
29
30
  --max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).
31
+ --inline-comments Post findings on changed lines as resolvable diff threads; the rest stays in the summary comment.
30
32
 
31
33
  Env vars:
32
34
  OPENAI_API_KEY (required) OpenAI API key.
@@ -47,6 +49,7 @@ CI-only env vars (provided by GitLab):
47
49
  const DEBUG_MODE = hasDebugFlag(process.argv);
48
50
  const FORCE_TOOLS = hasForceToolsFlag(process.argv);
49
51
  const INCLUDE_ARTIFACTS = hasIncludeArtifactsFlag(process.argv);
52
+ const INLINE_COMMENTS = hasInlineCommentsFlag(process.argv);
50
53
  function logStep(message) {
51
54
  process.stdout.write(`${message}\n`);
52
55
  }
@@ -157,12 +160,23 @@ async function main() {
157
160
  loggers,
158
161
  debugRecordWriter,
159
162
  });
163
+ const note = INLINE_COMMENTS
164
+ ? await postInlineFindings({
165
+ answer,
166
+ changes: filteredChanges,
167
+ diffRefs: mrChanges.diff_refs,
168
+ gitLabProjectApiUrl,
169
+ headers,
170
+ mergeRequestIid,
171
+ logStep,
172
+ })
173
+ : answer;
160
174
  logStep("Posting AI review note to merge request");
161
175
  const noteRes = await postMergeRequestNote({
162
176
  gitLabProjectApiUrl: gitLabProjectApiUrl,
163
177
  headers,
164
178
  mergeRequestIid,
165
- note: answer,
179
+ note,
166
180
  });
167
181
  if (noteRes instanceof Error)
168
182
  throw noteRes;
@@ -39,6 +39,13 @@ export const fetchFileAtRef = async ({ gitLabProjectApiUrl, headers, filePath, r
39
39
  }
40
40
  return await res.text();
41
41
  };
42
+ /** GitLab runs quick actions (`/merge`, `/approve`, …) that start a line of a
43
+ * note, with the token's permissions. Model text must never trigger them, so
44
+ * indent such lines by a space (renders the same, no longer a command).
45
+ * Command names start with a letter, so `// comment` lines stay as they are. */
46
+ function neutralizeQuickActions(body) {
47
+ return body.replace(/^\/(?=[a-z])/gim, " /");
48
+ }
42
49
  export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, note }) => {
43
50
  const commentUrl = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/notes`);
44
51
  let aiComment;
@@ -49,7 +56,7 @@ export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, merge
49
56
  ...headers,
50
57
  "Content-Type": "application/json",
51
58
  },
52
- body: JSON.stringify({ body: note }),
59
+ body: JSON.stringify({ body: neutralizeQuickActions(note) }),
53
60
  });
54
61
  }
55
62
  catch (error) {
@@ -162,3 +169,78 @@ export const fetchMergeRequestChanges = async ({ gitLabProjectApiUrl, headers, m
162
169
  }
163
170
  return (await res.json());
164
171
  };
172
+ /** fetch with the same error details as above, for the inline-comment calls. */
173
+ async function gitlabRequest(url, init, message) {
174
+ let res;
175
+ try {
176
+ res = await fetch(url, init);
177
+ }
178
+ catch (error) {
179
+ res = error;
180
+ }
181
+ if (!(res instanceof Error) && res.ok)
182
+ return res;
183
+ const cause = res instanceof Error
184
+ ? { url: url.toString(), error: { name: res.name, message: res.message } }
185
+ : {
186
+ url: url.toString(),
187
+ status: res.status,
188
+ statusText: res.statusText,
189
+ body: (await res.text().catch(() => "")).slice(0, 1000),
190
+ };
191
+ return new GitLabError({
192
+ name: "INLINE_COMMENTS_FAILED",
193
+ message,
194
+ statusCode: res instanceof Error ? 502 : res.status,
195
+ cause,
196
+ });
197
+ }
198
+ export const fetchMergeRequestDiffRefs = async ({ gitLabProjectApiUrl, headers, mergeRequestIid }) => {
199
+ const res = await gitlabRequest(new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}`), { headers: { ...headers } }, "Failed to fetch merge request");
200
+ if (res instanceof Error)
201
+ return res;
202
+ const mr = (await res.json());
203
+ return mr.diff_refs ?? {};
204
+ };
205
+ export const listMergeRequestDiscussions = async ({ gitLabProjectApiUrl, headers, mergeRequestIid }) => {
206
+ const discussions = [];
207
+ // ponytail: reads at most 50 pages (5000 threads); raise if an MR ever has more.
208
+ for (let page = 1; page <= 50; page += 1) {
209
+ const url = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/discussions`);
210
+ url.searchParams.set("per_page", "100");
211
+ url.searchParams.set("page", String(page));
212
+ const res = await gitlabRequest(url, { headers: { ...headers } }, "Failed to list merge request threads");
213
+ if (res instanceof Error)
214
+ return res;
215
+ const batch = (await res.json());
216
+ discussions.push(...batch);
217
+ // A page can be short while more follow (GitLab drops notes the token
218
+ // can't read after paginating), so trust X-Next-Page when it is sent.
219
+ const next = res.headers.get("x-next-page");
220
+ if (next === "" || (next == null && batch.length < 100))
221
+ break;
222
+ }
223
+ return discussions;
224
+ };
225
+ export const createMergeRequestDiscussion = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, body, position }) => {
226
+ const res = await gitlabRequest(new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/discussions`), {
227
+ method: "POST",
228
+ headers: { ...headers, "Content-Type": "application/json" },
229
+ body: JSON.stringify({ body: neutralizeQuickActions(body), position }),
230
+ }, `Failed to create a diff thread at ${position.new_path}:${position.new_line}`);
231
+ return res instanceof Error ? res : undefined;
232
+ };
233
+ export const resolveMergeRequestDiscussion = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, discussionId }) => {
234
+ const url = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/discussions/${discussionId}`);
235
+ url.searchParams.set("resolved", "true");
236
+ const res = await gitlabRequest(url, { method: "PUT", headers: { ...headers } }, `Failed to resolve thread ${discussionId}`);
237
+ return res instanceof Error ? res : undefined;
238
+ };
239
+ export const replyToMergeRequestDiscussion = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, discussionId, body }) => {
240
+ const res = await gitlabRequest(new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes`), {
241
+ method: "POST",
242
+ headers: { ...headers, "Content-Type": "application/json" },
243
+ body: JSON.stringify({ body: neutralizeQuickActions(body) }),
244
+ }, `Failed to reply to thread ${discussionId}`);
245
+ return res instanceof Error ? res : undefined;
246
+ };
@@ -1,6 +1,6 @@
1
1
  /** @format */
2
2
  import { buildFileReviewSystemMessage, buildMainSystemMessages, buildTriageSystemMessage, } from "./messages.js";
3
- import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
3
+ import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, numberDiffLines, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
4
4
  import { getConsolidateSystemPrompt, getVerificationSystemPrompt, } from "./templates/postprocess-system.js";
5
5
  import { buildConsolidateUserContent, buildFileReviewUserContent, buildMainReviewUserContent, buildTriageUserContent, buildVerificationUserContent, } from "./templates/user-prompts.js";
6
6
  import { DEFAULT_PROMPT_PROFILE, isEmptyReviewBody } from "./profile.js";
@@ -21,10 +21,11 @@ export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEF
21
21
  const capped = changes.slice(0, effectiveLimits.maxDiffs);
22
22
  const omittedFiles = totalFiles - capped.length;
23
23
  let truncatedCount = 0;
24
- const diffsTrimmed = capped.map((change, index) => {
24
+ const diffsTrimmed = capped.map((change) => {
25
25
  if (change.diff.length > effectiveLimits.maxDiffChars)
26
26
  truncatedCount += 1;
27
- return truncateWithMarker(change.diff, effectiveLimits.maxDiffChars, `diff #${index + 1}`);
27
+ // GitLab's per-file diff starts at the first @@ line, without a file header.
28
+ return `### ${change.path}\n${numberDiffLines(truncateWithMarker(change.diff, effectiveLimits.maxDiffChars, change.path))}`;
28
29
  });
29
30
  const changesText = diffsTrimmed.join("\n\n");
30
31
  const statsFragments = [`${capped.length} file diff(s) included.`];
@@ -139,7 +140,7 @@ export function buildFileReviewPrompt(params) {
139
140
  role: "user",
140
141
  content: buildFileReviewUserContent({
141
142
  filePath,
142
- fileDiff,
143
+ fileDiff: numberDiffLines(fileDiff),
143
144
  summary,
144
145
  otherChangedFiles,
145
146
  toolNote,
@@ -217,7 +218,7 @@ export function extractCompletionText(completion) {
217
218
  return null;
218
219
  }
219
220
  const ERROR_ANSWER = "AI review could not be completed. Please ask a human to review this code change.";
220
- const DISCLAIMER = "This comment was generated by AI review bot.";
221
+ export const DISCLAIMER = "This comment was generated by AI review bot.";
221
222
  export const buildAnswer = (completion) => {
222
223
  if (completion instanceof Error) {
223
224
  const maybeCause = completion.cause;
@@ -51,6 +51,7 @@ Your job is to remove weak, speculative, or unsupported findings from the draft
51
51
  Tools get_file_at_ref and grep_repository are available. Use them to check claims about current code against the repository at the MR head ref.
52
52
  Drop a finding if file contents at refs.head contradict it, or if it cannot be verified after reasonable tool use.
53
53
  Do not add new findings. Keep, rewrite for clarity, or remove existing findings only.
54
+ get_file_at_ref numbers every line: when it shows the offending code on a different line than cited, set Line: to that number.
54
55
  A finding can stay only if supported by the per-file evidence pool and not contradicted by tools when the claim is about code that exists at refs.head.
55
56
  If confidence is not high, drop the finding.
56
57
  Preserve this exact per-finding markdown block:
@@ -70,7 +71,7 @@ Return at most ${maxFindings} findings.
70
71
 
71
72
  For each draft finding, follow these steps in order:
72
73
  1. Look it up in the per-file evidence pool. If it is not in the evidence pool, drop it.
73
- 2. If the finding makes a claim about code that exists at refs.head, call get_file_at_ref(path, refs.head) and confirm the offending text really sits at the cited line (±10 lines is fine).
74
+ 2. If the finding makes a claim about code that exists at refs.head, call get_file_at_ref(path, refs.head) and confirm the offending text really sits at the cited line (±10 lines is fine). Each line of its output starts with the line number; set Line: to the number of the offending line.
74
75
  3. If the file contents at refs.head do not contain the offending text, or contain a corrected version of it, drop the finding.
75
76
  4. If you cannot confirm the claim with one or two tool calls, drop the finding.
76
77
 
@@ -1,8 +1,10 @@
1
1
  /** @format */
2
2
  import { truncateWithMarker } from "../utils.js";
3
+ const LINE_NUMBERS_NOTE = "Each diff line starts with its line number in the new file (removed lines have none); use these numbers for Line:.";
3
4
  export function buildMainReviewUserContent(params) {
4
5
  const { stats, toolNote, changesText } = params;
5
6
  return `Review the following code changes (git diff format). ${stats}
7
+ ${LINE_NUMBERS_NOTE}
6
8
  ${toolNote}
7
9
 
8
10
  Changes:
@@ -36,6 +38,7 @@ ${otherFilesNote}
36
38
  ${toolNote}
37
39
 
38
40
  File: ${filePath}
41
+ ${LINE_NUMBERS_NOTE}
39
42
  Diff:
40
43
  ${fileDiff}
41
44
 
@@ -47,6 +47,28 @@ export function truncateWithMarker(value, maxChars, markerLabel) {
47
47
  const omitted = value.length - maxChars;
48
48
  return `${value.slice(0, maxChars)}\n\n[... ${markerLabel} truncated, omitted ${omitted} chars ...]`;
49
49
  }
50
+ /** Prefixes added and context lines of a unified diff with their line number in
51
+ * the new file (removed lines get a blank column), so the model cites exact
52
+ * lines instead of counting from @@ headers. Same rules as mapNewLines. */
53
+ export function numberDiffLines(diff) {
54
+ const rows = [];
55
+ let newLine = 0;
56
+ for (const text of diff.split("\n")) {
57
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
58
+ if (hunk != null)
59
+ newLine = Number(hunk[1]);
60
+ const numbered = hunk == null && newLine > 0 && /^[+ ]/.test(text);
61
+ rows.push([numbered ? newLine++ : null, text]);
62
+ }
63
+ const width = String(Math.max(1, newLine - 1)).length;
64
+ return rows
65
+ .map(([n, text]) => n != null
66
+ ? `${String(n).padStart(width)} ${text}`
67
+ : text.startsWith("-")
68
+ ? `${" ".repeat(width)} ${text}`
69
+ : text)
70
+ .join("\n");
71
+ }
50
72
  export function sanitizeGitLabMarkdown(input) {
51
73
  const normalized = input.replace(/\r\n/g, "\n").trim();
52
74
  // If the model forgets to close a fenced code block, GitLab will render the rest (incl. disclaimer) inside it.
@@ -97,6 +119,18 @@ export function parseReviewFindings(input) {
97
119
  if (m != null) {
98
120
  file = m[1].trim();
99
121
  line = m[2].trim();
122
+ // renderReviewFinding puts the why on the very next line, even when
123
+ // it starts with `_` (e.g. `__init__`), which the fallback below skips.
124
+ // Separators and whole-line italics (the disclaimer) are never a why.
125
+ const next = lines[j + 1]?.trim() ?? "";
126
+ if (why == null &&
127
+ next !== "" &&
128
+ !/^(-{3,}|_.*_$)/.test(next) &&
129
+ matchHeader(next) == null) {
130
+ why = next.match(whyRe)?.[1]?.trim() ?? next;
131
+ j += 2;
132
+ continue;
133
+ }
100
134
  j += 1;
101
135
  continue;
102
136
  }
@@ -143,6 +177,14 @@ export function parseReviewFindings(input) {
143
177
  }
144
178
  return findings;
145
179
  }
180
+ const SEVERITY_LABEL = {
181
+ high: "🔴 High",
182
+ medium: "🟠 Medium",
183
+ };
184
+ /** One finding as rendered in the MR comment (and in inline diff threads). */
185
+ export function renderReviewFinding(f) {
186
+ return `- **${SEVERITY_LABEL[f.severity]} — ${f.title}** \n \`${f.file}:${f.line}\` \n ${f.why}`;
187
+ }
146
188
  export function normalizeReviewFindingsMarkdown(input) {
147
189
  const normalized = input.replace(/\r\n/g, "\n").trim();
148
190
  if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
@@ -150,11 +192,5 @@ export function normalizeReviewFindingsMarkdown(input) {
150
192
  const findings = parseReviewFindings(normalized);
151
193
  if (findings.length === 0)
152
194
  return normalized;
153
- const severityLabel = {
154
- high: "🔴 High",
155
- medium: "🟠 Medium",
156
- };
157
- return findings
158
- .map((f) => `- **${severityLabel[f.severity]} — ${f.title}** \n \`${f.file}:${f.line}\` \n ${f.why}`)
159
- .join("\n\n");
195
+ return findings.map(renderReviewFinding).join("\n\n");
160
196
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@krotovm/gitlab-ai-review",
4
- "version": "1.0.43",
4
+ "version": "1.0.44",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
19
- "prepare": "tsc"
19
+ "prepare": "tsc",
20
+ "test": "tsc && node --test"
20
21
  },
21
22
  "keywords": [
22
23
  "gitlab",