@krotovm/gitlab-ai-review 1.0.42 → 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 +13 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/ci-review.js +46 -32
- package/dist/cli/inline-comments.js +205 -0
- package/dist/cli/tooling.js +6 -0
- package/dist/cli.js +26 -13
- package/dist/gitlab/services.js +86 -67
- package/dist/gitlab/types.js +0 -2
- package/dist/prompt/index.js +6 -7
- package/dist/prompt/messages.js +0 -4
- package/dist/prompt/templates/postprocess-system.js +2 -1
- package/dist/prompt/templates/review-system.js +0 -4
- package/dist/prompt/templates/triage-system.js +0 -2
- package/dist/prompt/templates/user-prompts.js +3 -0
- package/dist/prompt/utils.js +43 -7
- package/package.json +3 -2
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.
|
|
44
|
+
- component: gitlab.com/KrotovM/gitlab-ai-review/review@1.0.3
|
|
45
45
|
inputs:
|
|
46
46
|
args: "--include-artifacts"
|
|
47
47
|
```
|
|
@@ -76,7 +76,7 @@ Set these in your project/group CI settings:
|
|
|
76
76
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
77
77
|
- `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
|
|
78
78
|
- `AI_PROMPT_PROFILE` (optional, default: `default`; one of `default` \| `weak`). Use `weak` for small or heavily-quantized models (≤ ~7B, local Ollama, etc.). The `weak` profile uses shorter system prompts, positive rules instead of negations, and inline few-shot examples for triage / per-file review / consolidation / verification, which dramatically improves output-format adherence on weak models. When the variable is not set, the profile is auto-detected: if the triage pass returns unparseable JSON with `default` prompts, triage is retried once with `weak` and, on success, the whole run continues with it. Set the variable explicitly to pin a profile and disable auto-detection.
|
|
79
|
-
- `PROJECT_ACCESS_TOKEN` (
|
|
79
|
+
- `PROJECT_ACCESS_TOKEN` (required; project or personal access token with `api` scope — `CI_JOB_TOKEN` can't post MR comments or use the search API)
|
|
80
80
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
81
81
|
- `AI_REVIEW_CONCURRENCY` (optional) — same as `--max-review-concurrency`, handy as a CI/CD variable; the flag wins when both are set.
|
|
82
82
|
- `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
|
|
@@ -88,7 +88,7 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
88
88
|
- `CI_API_V4_URL`
|
|
89
89
|
- `CI_PROJECT_ID`
|
|
90
90
|
- `CI_MERGE_REQUEST_IID`
|
|
91
|
-
- `
|
|
91
|
+
- `CI_MERGE_REQUEST_PROJECT_ID` (the MR's target project; used instead of `CI_PROJECT_ID`, so MRs from forks work)
|
|
92
92
|
|
|
93
93
|
## Flags
|
|
94
94
|
|
|
@@ -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);
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
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";
|
|
7
7
|
/** Hard deadline per model call, retries included. Must exceed the gateway's own
|
|
8
8
|
* proxy timeout so the SDK's 5xx retries stay the first line of defense. */
|
|
9
9
|
const COMPLETION_TIMEOUT_MS = 180_000;
|
|
@@ -14,6 +14,7 @@ let streamUsageSupported = true;
|
|
|
14
14
|
* round, so oversized results multiply prefill time on self-hosted models. */
|
|
15
15
|
const MAX_TOOL_FILE_CHARS = 12_000;
|
|
16
16
|
const MAX_TOOL_GREP_CHARS = 800;
|
|
17
|
+
const MAX_TOOL_GREP_RESULTS = 10;
|
|
17
18
|
function buildTriageDecisions(params) {
|
|
18
19
|
const { changes, triageResult } = params;
|
|
19
20
|
const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
|
|
@@ -149,7 +150,7 @@ function buildReviewMetadata(changes, refs) {
|
|
|
149
150
|
],
|
|
150
151
|
}, null, 2);
|
|
151
152
|
}
|
|
152
|
-
async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
153
|
+
async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep) {
|
|
153
154
|
try {
|
|
154
155
|
const parsed = JSON.parse(argsRaw);
|
|
155
156
|
const path = parsed.path?.trim();
|
|
@@ -167,62 +168,80 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
|
167
168
|
ref,
|
|
168
169
|
});
|
|
169
170
|
if (fileText instanceof Error) {
|
|
171
|
+
logToolFailure(logStep, TOOL_NAME_GET_FILE, fileText);
|
|
170
172
|
return JSON.stringify({
|
|
171
173
|
ok: false,
|
|
172
174
|
path,
|
|
173
175
|
ref,
|
|
176
|
+
status: fileText.statusCode,
|
|
174
177
|
error: fileText.message,
|
|
175
178
|
});
|
|
176
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");
|
|
177
185
|
return JSON.stringify({
|
|
178
186
|
ok: true,
|
|
179
187
|
path,
|
|
180
188
|
ref,
|
|
181
|
-
content:
|
|
182
|
-
truncated:
|
|
189
|
+
content: numbered.slice(0, MAX_TOOL_FILE_CHARS),
|
|
190
|
+
truncated: numbered.length > MAX_TOOL_FILE_CHARS,
|
|
183
191
|
});
|
|
184
192
|
}
|
|
185
193
|
catch (error) {
|
|
194
|
+
logToolFailure(logStep, TOOL_NAME_GET_FILE, error);
|
|
186
195
|
return JSON.stringify({
|
|
187
196
|
ok: false,
|
|
188
|
-
error: `
|
|
197
|
+
error: `Tool call failed: ${String(error?.message ?? error)}`,
|
|
189
198
|
raw: argsRaw,
|
|
190
199
|
});
|
|
191
200
|
}
|
|
192
201
|
}
|
|
193
|
-
async function handleGrepTool(argsRaw,
|
|
202
|
+
async function handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep) {
|
|
194
203
|
try {
|
|
195
204
|
const parsed = JSON.parse(argsRaw);
|
|
196
205
|
const query = parsed.query?.trim();
|
|
197
206
|
if (!query)
|
|
198
207
|
return JSON.stringify({ ok: false, error: "query is required." });
|
|
199
|
-
|
|
208
|
+
// GitLab search answers 200 [] for unknown refs, so only the MR's own refs are searched.
|
|
209
|
+
const ref = parsed.ref?.trim() === refs.base ? refs.base : refs.head;
|
|
200
210
|
const results = await searchRepository({
|
|
201
211
|
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
202
212
|
headers,
|
|
203
213
|
query,
|
|
204
214
|
ref,
|
|
205
|
-
projectId,
|
|
206
215
|
});
|
|
207
216
|
if (results instanceof Error) {
|
|
217
|
+
logToolFailure(logStep, TOOL_NAME_GREP, results);
|
|
208
218
|
return JSON.stringify({
|
|
209
219
|
ok: false,
|
|
210
220
|
query,
|
|
211
221
|
ref,
|
|
222
|
+
status: results.statusCode,
|
|
212
223
|
error: results.message,
|
|
213
224
|
});
|
|
214
225
|
}
|
|
215
|
-
|
|
226
|
+
// GitLab lists file-name matches (file head, startline 1) before content matches;
|
|
227
|
+
// rank hits that contain the query first so they aren't crowded out.
|
|
228
|
+
const needle = query.toLowerCase();
|
|
229
|
+
const hasNeedle = (r) => (r.data ?? "").toLowerCase().includes(needle);
|
|
230
|
+
const trimmed = results
|
|
231
|
+
.sort((a, b) => Number(hasNeedle(b)) - Number(hasNeedle(a)))
|
|
232
|
+
.slice(0, MAX_TOOL_GREP_RESULTS)
|
|
233
|
+
.map((r) => ({
|
|
216
234
|
path: r.path,
|
|
217
235
|
startline: r.startline,
|
|
218
|
-
data: r.data.slice(0, MAX_TOOL_GREP_CHARS),
|
|
236
|
+
data: (r.data ?? "").slice(0, MAX_TOOL_GREP_CHARS),
|
|
219
237
|
}));
|
|
220
238
|
return JSON.stringify({ ok: true, query, ref, matches: trimmed });
|
|
221
239
|
}
|
|
222
240
|
catch (error) {
|
|
241
|
+
logToolFailure(logStep, TOOL_NAME_GREP, error);
|
|
223
242
|
return JSON.stringify({
|
|
224
243
|
ok: false,
|
|
225
|
-
error: `
|
|
244
|
+
error: `Tool call failed: ${String(error?.message ?? error)}`,
|
|
226
245
|
raw: argsRaw,
|
|
227
246
|
});
|
|
228
247
|
}
|
|
@@ -241,12 +260,12 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
241
260
|
await Promise.all(workers);
|
|
242
261
|
return results;
|
|
243
262
|
}
|
|
244
|
-
|
|
245
|
-
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl,
|
|
263
|
+
async function reviewMergeRequestWithTools(params) {
|
|
264
|
+
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
246
265
|
const { logDebug, logStep } = loggers;
|
|
247
266
|
logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
|
|
248
267
|
const messages = buildPrompt({
|
|
249
|
-
changes: changes.map((change) => ({ diff: change.diff })),
|
|
268
|
+
changes: changes.map((change) => ({ path: change.new_path, diff: change.diff })),
|
|
250
269
|
limits: promptLimits,
|
|
251
270
|
allowTools: true,
|
|
252
271
|
profile: promptProfile,
|
|
@@ -260,7 +279,7 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
260
279
|
type: "function",
|
|
261
280
|
function: {
|
|
262
281
|
name: TOOL_NAME_GET_FILE,
|
|
263
|
-
description: "Fetch
|
|
282
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
264
283
|
parameters: {
|
|
265
284
|
type: "object",
|
|
266
285
|
additionalProperties: false,
|
|
@@ -343,10 +362,10 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
343
362
|
logToolUsageMinimal(logStep, toolName, argsRaw);
|
|
344
363
|
let toolContent;
|
|
345
364
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
346
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
365
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
347
366
|
}
|
|
348
367
|
else if (toolName === TOOL_NAME_GREP) {
|
|
349
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
368
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
350
369
|
}
|
|
351
370
|
else {
|
|
352
371
|
toolContent = JSON.stringify({
|
|
@@ -390,7 +409,7 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
390
409
|
return buildAnswer(finalCompletion);
|
|
391
410
|
}
|
|
392
411
|
async function runFileReviewWithTools(params) {
|
|
393
|
-
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl,
|
|
412
|
+
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
394
413
|
const { logDebug, logStep } = loggers;
|
|
395
414
|
const messages = buildFileReviewPrompt({
|
|
396
415
|
filePath,
|
|
@@ -405,7 +424,7 @@ async function runFileReviewWithTools(params) {
|
|
|
405
424
|
type: "function",
|
|
406
425
|
function: {
|
|
407
426
|
name: TOOL_NAME_GET_FILE,
|
|
408
|
-
description: "Fetch
|
|
427
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
409
428
|
parameters: {
|
|
410
429
|
type: "object",
|
|
411
430
|
additionalProperties: false,
|
|
@@ -487,10 +506,10 @@ async function runFileReviewWithTools(params) {
|
|
|
487
506
|
logToolUsageMinimal(logStep, toolName, argsRaw, filePath);
|
|
488
507
|
let toolContent;
|
|
489
508
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
490
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
509
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
491
510
|
}
|
|
492
511
|
else if (toolName === TOOL_NAME_GREP) {
|
|
493
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
512
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
494
513
|
}
|
|
495
514
|
else {
|
|
496
515
|
toolContent = JSON.stringify({
|
|
@@ -537,7 +556,7 @@ function draftHasStructuredFindings(consolidatedText) {
|
|
|
537
556
|
return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
|
|
538
557
|
}
|
|
539
558
|
async function runVerificationWithTools(params) {
|
|
540
|
-
const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl,
|
|
559
|
+
const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
541
560
|
const { logDebug, logStep } = loggers;
|
|
542
561
|
const messages = [...baseMessages];
|
|
543
562
|
const tools = [
|
|
@@ -545,7 +564,7 @@ async function runVerificationWithTools(params) {
|
|
|
545
564
|
type: "function",
|
|
546
565
|
function: {
|
|
547
566
|
name: TOOL_NAME_GET_FILE,
|
|
548
|
-
description: "Fetch
|
|
567
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
549
568
|
parameters: {
|
|
550
569
|
type: "object",
|
|
551
570
|
additionalProperties: false,
|
|
@@ -627,10 +646,10 @@ async function runVerificationWithTools(params) {
|
|
|
627
646
|
logToolUsageMinimal(logStep, toolName, argsRaw, "(verify)");
|
|
628
647
|
let toolContent;
|
|
629
648
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
630
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
649
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
631
650
|
}
|
|
632
651
|
else if (toolName === TOOL_NAME_GREP) {
|
|
633
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
652
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
634
653
|
}
|
|
635
654
|
else {
|
|
636
655
|
toolContent = JSON.stringify({
|
|
@@ -672,7 +691,7 @@ async function runVerificationWithTools(params) {
|
|
|
672
691
|
});
|
|
673
692
|
}
|
|
674
693
|
export async function reviewMergeRequestMultiPass(params) {
|
|
675
|
-
const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl,
|
|
694
|
+
const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
676
695
|
const { logStep } = loggers;
|
|
677
696
|
let effectiveProfile = promptProfile;
|
|
678
697
|
logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
|
|
@@ -773,7 +792,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
773
792
|
changes,
|
|
774
793
|
refs,
|
|
775
794
|
gitLabProjectApiUrl,
|
|
776
|
-
projectId,
|
|
777
795
|
headers,
|
|
778
796
|
forceTools,
|
|
779
797
|
promptProfile: effectiveProfile,
|
|
@@ -821,7 +839,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
821
839
|
otherChangedFiles: otherFiles,
|
|
822
840
|
refs,
|
|
823
841
|
gitLabProjectApiUrl,
|
|
824
|
-
projectId,
|
|
825
842
|
headers,
|
|
826
843
|
forceTools,
|
|
827
844
|
promptProfile: effectiveProfile,
|
|
@@ -873,7 +890,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
873
890
|
profile: effectiveProfile,
|
|
874
891
|
});
|
|
875
892
|
if (consolidateMessages == null) {
|
|
876
|
-
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
877
893
|
return withHeader(`${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
|
|
878
894
|
}
|
|
879
895
|
try {
|
|
@@ -909,7 +925,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
909
925
|
baseMessages: verificationMessages,
|
|
910
926
|
refs,
|
|
911
927
|
gitLabProjectApiUrl,
|
|
912
|
-
projectId,
|
|
913
928
|
headers,
|
|
914
929
|
forceTools,
|
|
915
930
|
consolidatedDraft: consolidatedText,
|
|
@@ -926,7 +941,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
926
941
|
}
|
|
927
942
|
catch (error) {
|
|
928
943
|
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
|
929
|
-
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
930
944
|
const raw = perFileFindings
|
|
931
945
|
.filter((f) => !isEmptyReviewBody(f.findings))
|
|
932
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/tooling.js
CHANGED
|
@@ -27,3 +27,9 @@ export function logToolUsageMinimal(logStep, toolName, argsRaw, contextFile) {
|
|
|
27
27
|
const suffix = contextFile ? ` review_file=${contextFile}` : "";
|
|
28
28
|
logStep(`[tools] ${toolName} args=${argsRaw.slice(0, 120)}${suffix}`);
|
|
29
29
|
}
|
|
30
|
+
/** Operator-facing line for a failed tool call: GitLab status and request URL when known. */
|
|
31
|
+
export function logToolFailure(logStep, toolName, error) {
|
|
32
|
+
const status = error?.statusCode != null ? ` status=${error.statusCode}` : "";
|
|
33
|
+
const url = typeof error?.cause?.url === "string" ? ` url=${error.cause.url}` : "";
|
|
34
|
+
logStep(`[tools] ${toolName} failed: ${String(error?.message ?? error)}${status}${url}`);
|
|
35
|
+
}
|
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.
|
|
@@ -38,15 +40,16 @@ Env vars:
|
|
|
38
40
|
positive rules, and few-shot examples.
|
|
39
41
|
When unset, an unparseable triage response auto-retries
|
|
40
42
|
with "weak" and keeps it for the whole run.
|
|
41
|
-
PROJECT_ACCESS_TOKEN (
|
|
43
|
+
PROJECT_ACCESS_TOKEN (required) GitLab project/personal access token with api scope (alias: GITLAB_TOKEN). CI_JOB_TOKEN can't post MR comments.
|
|
42
44
|
|
|
43
45
|
CI-only env vars (provided by GitLab):
|
|
44
|
-
CI_API_V4_URL, CI_PROJECT_ID,
|
|
46
|
+
CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_PROJECT_ID, CI_MERGE_REQUEST_IID
|
|
45
47
|
`);
|
|
46
48
|
}
|
|
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
|
}
|
|
@@ -107,25 +110,24 @@ async function main() {
|
|
|
107
110
|
"CI_PROJECT_ID",
|
|
108
111
|
"CI_MERGE_REQUEST_IID",
|
|
109
112
|
];
|
|
113
|
+
// CI_JOB_TOKEN can't post MR notes or call the search API, so a real token is required.
|
|
110
114
|
if (projectAccessToken == null)
|
|
111
|
-
gitlabRequired.push("
|
|
115
|
+
gitlabRequired.push("PROJECT_ACCESS_TOKEN");
|
|
112
116
|
const envs = requireEnvs(gitlabRequired);
|
|
113
117
|
const openaiApiKey = envs["OPENAI_API_KEY"];
|
|
114
118
|
const ciApiV4Url = envs["CI_API_V4_URL"];
|
|
115
|
-
|
|
119
|
+
// MR endpoints live in the MR's target project; in fork pipelines CI_PROJECT_ID is the fork.
|
|
120
|
+
const projectId = envOrUndefined("CI_MERGE_REQUEST_PROJECT_ID") ?? envs["CI_PROJECT_ID"];
|
|
116
121
|
const mergeRequestIid = envs["CI_MERGE_REQUEST_IID"];
|
|
117
122
|
const gitLabProjectApiUrl = new URL(`${ciApiV4Url}/projects/${projectId}`);
|
|
118
|
-
const headers = {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
else
|
|
122
|
-
headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
|
|
123
|
+
const headers = {
|
|
124
|
+
"PRIVATE-TOKEN": projectAccessToken,
|
|
125
|
+
};
|
|
123
126
|
try {
|
|
124
127
|
logStep("Fetching merge request changes");
|
|
125
128
|
const mrChanges = await fetchMergeRequestChanges({
|
|
126
129
|
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
127
130
|
headers,
|
|
128
|
-
projectId,
|
|
129
131
|
mergeRequestIid,
|
|
130
132
|
});
|
|
131
133
|
if (mrChanges instanceof Error)
|
|
@@ -149,7 +151,6 @@ async function main() {
|
|
|
149
151
|
head: mrChanges.diff_refs?.head_sha ?? "HEAD",
|
|
150
152
|
},
|
|
151
153
|
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
152
|
-
projectId,
|
|
153
154
|
headers,
|
|
154
155
|
maxFindings,
|
|
155
156
|
reviewConcurrency,
|
|
@@ -159,12 +160,24 @@ async function main() {
|
|
|
159
160
|
loggers,
|
|
160
161
|
debugRecordWriter,
|
|
161
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;
|
|
162
174
|
logStep("Posting AI review note to merge request");
|
|
163
175
|
const noteRes = await postMergeRequestNote({
|
|
164
176
|
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
165
177
|
headers,
|
|
166
178
|
mergeRequestIid,
|
|
167
|
-
|
|
179
|
+
note,
|
|
180
|
+
});
|
|
168
181
|
if (noteRes instanceof Error)
|
|
169
182
|
throw noteRes;
|
|
170
183
|
process.stdout.write("Posted AI review comment to merge request.\n");
|
package/dist/gitlab/services.js
CHANGED
|
@@ -1,47 +1,5 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import
|
|
3
|
-
import { AI_MODEL_TEMPERATURE } from "../prompt/index.js";
|
|
4
|
-
import { GitLabError, OpenAIError, } from "./types.js";
|
|
5
|
-
export const fetchPreEditFiles = async ({ gitLabProjectApiUrl, headers, changesOldPaths, ref }) => {
|
|
6
|
-
const oldFilesRequestUrls = changesOldPaths.map((filePath) => {
|
|
7
|
-
const url = new URL(`${gitLabProjectApiUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
|
|
8
|
-
url.searchParams.set("ref", ref);
|
|
9
|
-
return url;
|
|
10
|
-
});
|
|
11
|
-
let oldFiles;
|
|
12
|
-
try {
|
|
13
|
-
oldFiles = await Promise.allSettled(oldFilesRequestUrls.map(async (url) => {
|
|
14
|
-
const res = await fetch(url, { headers: { ...headers } });
|
|
15
|
-
if (!res.ok) {
|
|
16
|
-
const bodyText = await res.text().catch(() => "");
|
|
17
|
-
throw new Error(`Failed to fetch old file: ${url.toString()} (status ${res.status} ${res.statusText}, body: ${bodyText.slice(0, 500)})`);
|
|
18
|
-
}
|
|
19
|
-
return await res.text();
|
|
20
|
-
}));
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
oldFiles = error;
|
|
24
|
-
}
|
|
25
|
-
if (oldFiles instanceof Error) {
|
|
26
|
-
return new GitLabError({
|
|
27
|
-
name: "MISSING_OLD_FILES",
|
|
28
|
-
message: "Failed to fetch old files",
|
|
29
|
-
cause: {
|
|
30
|
-
message: oldFiles.message,
|
|
31
|
-
stack: oldFiles.stack,
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
return oldFiles.reduce((acc, file, index) => {
|
|
36
|
-
if (file.status === "fulfilled") {
|
|
37
|
-
acc.push({
|
|
38
|
-
fileName: changesOldPaths[index],
|
|
39
|
-
fileContent: file.value,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
return acc;
|
|
43
|
-
}, []);
|
|
44
|
-
};
|
|
2
|
+
import { GitLabError, } from "./types.js";
|
|
45
3
|
export const fetchFileAtRef = async ({ gitLabProjectApiUrl, headers, filePath, ref }) => {
|
|
46
4
|
const url = new URL(`${gitLabProjectApiUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
|
|
47
5
|
url.searchParams.set("ref", ref);
|
|
@@ -81,29 +39,14 @@ export const fetchFileAtRef = async ({ gitLabProjectApiUrl, headers, filePath, r
|
|
|
81
39
|
}
|
|
82
40
|
return await res.text();
|
|
83
41
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
stream: false,
|
|
91
|
-
messages,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
completion = error;
|
|
96
|
-
}
|
|
97
|
-
if (completion instanceof Error) {
|
|
98
|
-
return new OpenAIError({
|
|
99
|
-
name: "MISSING_AI_COMPLETION",
|
|
100
|
-
message: "Failed to generate AI completion",
|
|
101
|
-
cause: completion,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return completion;
|
|
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, " /");
|
|
105
48
|
}
|
|
106
|
-
export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, mergeRequestIid }
|
|
49
|
+
export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, note }) => {
|
|
107
50
|
const commentUrl = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/notes`);
|
|
108
51
|
let aiComment;
|
|
109
52
|
try {
|
|
@@ -113,7 +56,7 @@ export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, merge
|
|
|
113
56
|
...headers,
|
|
114
57
|
"Content-Type": "application/json",
|
|
115
58
|
},
|
|
116
|
-
body: JSON.stringify(
|
|
59
|
+
body: JSON.stringify({ body: neutralizeQuickActions(note) }),
|
|
117
60
|
});
|
|
118
61
|
}
|
|
119
62
|
catch (error) {
|
|
@@ -152,7 +95,8 @@ export const searchRepository = async ({ gitLabProjectApiUrl, headers, query, re
|
|
|
152
95
|
url.searchParams.set("scope", "blobs");
|
|
153
96
|
url.searchParams.set("search", query);
|
|
154
97
|
url.searchParams.set("ref", ref);
|
|
155
|
-
|
|
98
|
+
// Name matches come before content matches; the grep tool re-ranks and keeps 10.
|
|
99
|
+
url.searchParams.set("per_page", "50");
|
|
156
100
|
let res;
|
|
157
101
|
try {
|
|
158
102
|
res = await fetch(url, { headers: { ...headers } });
|
|
@@ -225,3 +169,78 @@ export const fetchMergeRequestChanges = async ({ gitLabProjectApiUrl, headers, m
|
|
|
225
169
|
}
|
|
226
170
|
return (await res.json());
|
|
227
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
|
+
};
|
package/dist/gitlab/types.js
CHANGED
package/dist/prompt/index.js
CHANGED
|
@@ -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";
|
|
@@ -12,8 +12,6 @@ export const DEFAULT_PROMPT_LIMITS = {
|
|
|
12
12
|
};
|
|
13
13
|
/** Max chars of each file diff sent to the triage pass (Pass 1). */
|
|
14
14
|
export const DEFAULT_TRIAGE_DIFF_CHARS = 2000;
|
|
15
|
-
export const AI_MODEL_TEMPERATURE = 0.2;
|
|
16
|
-
export const AI_MAX_OUTPUT_TOKENS = 600;
|
|
17
15
|
export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
|
|
18
16
|
const effectiveLimits = {
|
|
19
17
|
...DEFAULT_PROMPT_LIMITS,
|
|
@@ -23,10 +21,11 @@ export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEF
|
|
|
23
21
|
const capped = changes.slice(0, effectiveLimits.maxDiffs);
|
|
24
22
|
const omittedFiles = totalFiles - capped.length;
|
|
25
23
|
let truncatedCount = 0;
|
|
26
|
-
const diffsTrimmed = capped.map((change
|
|
24
|
+
const diffsTrimmed = capped.map((change) => {
|
|
27
25
|
if (change.diff.length > effectiveLimits.maxDiffChars)
|
|
28
26
|
truncatedCount += 1;
|
|
29
|
-
|
|
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))}`;
|
|
30
29
|
});
|
|
31
30
|
const changesText = diffsTrimmed.join("\n\n");
|
|
32
31
|
const statsFragments = [`${capped.length} file diff(s) included.`];
|
|
@@ -141,7 +140,7 @@ export function buildFileReviewPrompt(params) {
|
|
|
141
140
|
role: "user",
|
|
142
141
|
content: buildFileReviewUserContent({
|
|
143
142
|
filePath,
|
|
144
|
-
fileDiff,
|
|
143
|
+
fileDiff: numberDiffLines(fileDiff),
|
|
145
144
|
summary,
|
|
146
145
|
otherChangedFiles,
|
|
147
146
|
toolNote,
|
|
@@ -219,7 +218,7 @@ export function extractCompletionText(completion) {
|
|
|
219
218
|
return null;
|
|
220
219
|
}
|
|
221
220
|
const ERROR_ANSWER = "AI review could not be completed. Please ask a human to review this code change.";
|
|
222
|
-
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
221
|
+
export const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
223
222
|
export const buildAnswer = (completion) => {
|
|
224
223
|
if (completion instanceof Error) {
|
|
225
224
|
const maybeCause = completion.cause;
|
package/dist/prompt/messages.js
CHANGED
|
@@ -16,7 +16,3 @@ export const buildFileReviewSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) =
|
|
|
16
16
|
role: "system",
|
|
17
17
|
content: getFileReviewSystemPrompt(profile),
|
|
18
18
|
});
|
|
19
|
-
/** @deprecated kept for backward compatibility — use buildTriageSystemMessage(). */
|
|
20
|
-
export const TRIAGE_SYSTEM = buildTriageSystemMessage();
|
|
21
|
-
/** @deprecated kept for backward compatibility — use buildFileReviewSystemMessage(). */
|
|
22
|
-
export const FILE_REVIEW_SYSTEM = buildFileReviewSystemMessage();
|
|
@@ -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
|
|
|
@@ -106,7 +106,3 @@ export function getMainSystemPrompt(profile) {
|
|
|
106
106
|
export function getFileReviewSystemPrompt(profile) {
|
|
107
107
|
return profile === "weak" ? FILE_WEAK : FILE_DEFAULT;
|
|
108
108
|
}
|
|
109
|
-
/** @deprecated kept for backward compatibility — use getMainSystemPrompt("default"). */
|
|
110
|
-
export const MAIN_SYSTEM_LINES = MAIN_DEFAULT.split("\n");
|
|
111
|
-
/** @deprecated kept for backward compatibility — use getFileReviewSystemPrompt("default"). */
|
|
112
|
-
export const FILE_REVIEW_SYSTEM_LINES = FILE_DEFAULT.split("\n");
|
|
@@ -42,5 +42,3 @@ Example output (copy this exactly — one line, one JSON object, nothing else):
|
|
|
42
42
|
export function getTriageSystemPrompt(profile) {
|
|
43
43
|
return profile === "weak" ? WEAK : DEFAULT;
|
|
44
44
|
}
|
|
45
|
-
/** @deprecated kept for backward compatibility — use getTriageSystemPrompt("default"). */
|
|
46
|
-
export const TRIAGE_SYSTEM_LINES = DEFAULT.split("\n");
|
|
@@ -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
|
|
package/dist/prompt/utils.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|