@krotovm/gitlab-ai-review 1.0.43 → 1.0.45
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 +18 -4
- package/dist/cli/anthropic.js +138 -0
- package/dist/cli/args.js +4 -0
- package/dist/cli/ci-review.js +38 -31
- package/dist/cli/inline-comments.js +205 -0
- package/dist/cli.js +35 -10
- package/dist/gitlab/services.js +83 -1
- package/dist/prompt/index.js +6 -5
- package/dist/prompt/templates/postprocess-system.js +2 -1
- package/dist/prompt/templates/user-prompts.js +3 -0
- package/dist/prompt/utils.js +43 -7
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/KrotovM/gitlab-ai-mr-reviewer/blob/main/LICENSE)
|
|
8
8
|
[](https://gitlab.com/explore/catalog/KrotovM/gitlab-ai-review)
|
|
9
9
|
|
|
10
|
-
Gitlab AI Code Review is a CLI tool that leverages OpenAI models to automatically review code changes and post a Markdown review to GitLab merge requests from CI.
|
|
10
|
+
Gitlab AI Code Review is a CLI tool that leverages OpenAI or Anthropic (Claude) models to automatically review code changes and post a Markdown review to GitLab merge requests from CI.
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
@@ -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
|
```
|
|
@@ -72,9 +72,11 @@ The HTML artifact breaks the run down per pass — tokens, durations, triage dec
|
|
|
72
72
|
|
|
73
73
|
Set these in your project/group CI settings:
|
|
74
74
|
|
|
75
|
-
- `
|
|
75
|
+
- `AI_PROVIDER` (optional, default: `openai`; one of `openai` \| `anthropic`)
|
|
76
|
+
- `OPENAI_API_KEY` (required for `openai`)
|
|
76
77
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
77
|
-
- `
|
|
78
|
+
- `ANTHROPIC_API_KEY` (required for `anthropic`)
|
|
79
|
+
- `AI_MODEL` (optional, default: `gpt-4o-mini` for `openai`, `claude-opus-5-5` for `anthropic`; examples: `gpt-4o`, `claude-sonnet-5-5`)
|
|
78
80
|
- `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
81
|
- `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
82
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
@@ -83,6 +85,8 @@ Set these in your project/group CI settings:
|
|
|
83
85
|
|
|
84
86
|
`OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
|
|
85
87
|
|
|
88
|
+
With `AI_PROVIDER=anthropic` the review calls the Anthropic Messages API directly (`ANTHROPIC_BASE_URL` overrides the endpoint the same way). Tool rounds use prompt caching, and a request declined by Claude's safety classifiers is re-run server-side on Anthropic's recommended fallback model. `--force-tools` is ignored there (Claude Opus/Sonnet 5.5 reject forced tool calls).
|
|
89
|
+
|
|
86
90
|
GitLab provides these automatically in Merge Request pipelines:
|
|
87
91
|
|
|
88
92
|
- `CI_API_V4_URL`
|
|
@@ -101,8 +105,18 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
101
105
|
- `--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
106
|
- `--debug` - Print full error details (stack and API error fields).
|
|
103
107
|
- `--include-artifacts` - Generate a local HTML debug artifact with per-pass outputs/tokens.
|
|
108
|
+
- `--inline-comments` - Post findings on changed lines as resolvable diff threads; the rest stays in the summary comment. See [Inline comments](#inline-comments).
|
|
104
109
|
- `--help` - Show help output.
|
|
105
110
|
|
|
111
|
+
## Inline comments
|
|
112
|
+
|
|
113
|
+
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:
|
|
114
|
+
|
|
115
|
+
- 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.
|
|
116
|
+
- 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.
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
106
120
|
## Benchmark
|
|
107
121
|
|
|
108
122
|
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.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
|
|
4
|
+
/** Raw assistant turns by their first tool_use id. The review loops rebuild
|
|
5
|
+
* assistant messages from content + tool_calls, but Claude needs its thinking
|
|
6
|
+
* blocks back verbatim, and the closing no-tools round must resend the same
|
|
7
|
+
* tools: both are part of the prefix the API validates thinking blocks against. */
|
|
8
|
+
const turns = new Map();
|
|
9
|
+
const textOf = (content) => typeof content === "string"
|
|
10
|
+
? content
|
|
11
|
+
: Array.isArray(content)
|
|
12
|
+
? content.map((part) => part?.text ?? "").join("")
|
|
13
|
+
: "";
|
|
14
|
+
/** Runs an OpenAI-shaped chat request (as built in ci-review.ts) on the Anthropic
|
|
15
|
+
* Messages API and returns the answer as a ChatCompletion. `temperature` and
|
|
16
|
+
* `response_format` are dropped: Claude 4.7+ reject non-default sampling, and the
|
|
17
|
+
* triage parser already tolerates replies without JSON mode. */
|
|
18
|
+
export async function createAnthropicCompletion(client, request, signal) {
|
|
19
|
+
let tools = request.tools?.flatMap((tool) => tool.type === "function"
|
|
20
|
+
? [
|
|
21
|
+
{
|
|
22
|
+
name: tool.function.name,
|
|
23
|
+
description: tool.function.description,
|
|
24
|
+
input_schema: tool.function.parameters,
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
: []);
|
|
28
|
+
const system = [];
|
|
29
|
+
const messages = [];
|
|
30
|
+
// Tool results of one round, and any user text after them, share one user turn.
|
|
31
|
+
const pushUser = (block) => {
|
|
32
|
+
const last = messages.at(-1);
|
|
33
|
+
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
34
|
+
last.content.push(block);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
messages.push({ role: "user", content: [block] });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
for (const message of request.messages) {
|
|
41
|
+
if (message.role === "system" || message.role === "developer") {
|
|
42
|
+
system.push(textOf(message.content));
|
|
43
|
+
}
|
|
44
|
+
else if (message.role === "user") {
|
|
45
|
+
pushUser({ type: "text", text: textOf(message.content) });
|
|
46
|
+
}
|
|
47
|
+
else if (message.role === "tool") {
|
|
48
|
+
pushUser({
|
|
49
|
+
type: "tool_result",
|
|
50
|
+
tool_use_id: message.tool_call_id,
|
|
51
|
+
content: textOf(message.content),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (message.role === "assistant") {
|
|
55
|
+
const turn = turns.get(message.tool_calls?.[0]?.id ?? "");
|
|
56
|
+
tools ??= turn?.tools;
|
|
57
|
+
messages.push({
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content: turn?.content ?? textOf(message.content),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const response = await client.beta.messages
|
|
64
|
+
.stream({
|
|
65
|
+
model: request.model,
|
|
66
|
+
max_tokens: 64_000,
|
|
67
|
+
...(system.length > 0 && { system: system.join("\n\n") }),
|
|
68
|
+
messages,
|
|
69
|
+
// Tool rounds resend the growing history, so cache it. --force-tools maps
|
|
70
|
+
// to auto: Claude Opus/Sonnet 5.5 reject forced tool use. The closing round
|
|
71
|
+
// after the tool limit turns the tools off instead of dropping them.
|
|
72
|
+
...(tools && {
|
|
73
|
+
tools,
|
|
74
|
+
tool_choice: request.tools ? { type: "auto" } : { type: "none" },
|
|
75
|
+
cache_control: { type: "ephemeral" },
|
|
76
|
+
}),
|
|
77
|
+
// A safety-classifier refusal is re-run on Anthropic's recommended fallback model.
|
|
78
|
+
betas: ["server-side-fallback-2026-07-01"],
|
|
79
|
+
fallbacks: "default",
|
|
80
|
+
}, { signal })
|
|
81
|
+
.finalMessage();
|
|
82
|
+
if (response.stop_reason === "refusal") {
|
|
83
|
+
throw new Error(`Model refused the request (category: ${response.stop_details?.category ?? "none"}).`);
|
|
84
|
+
}
|
|
85
|
+
// After a mid-output fallback only the declined model's text carries over; its
|
|
86
|
+
// thinking and tool calls are not part of the conversation the fallback continued.
|
|
87
|
+
const cut = response.content.map((block) => block.type).lastIndexOf("fallback");
|
|
88
|
+
const content = response.content.filter((block, i) => i >= cut || block.type === "text");
|
|
89
|
+
const toolUses = content.filter((block) => block.type === "tool_use");
|
|
90
|
+
if (toolUses[0] != null) {
|
|
91
|
+
turns.set(toolUses[0].id, { content, tools: tools ?? [] });
|
|
92
|
+
}
|
|
93
|
+
const usage = response.usage;
|
|
94
|
+
const promptTokens = usage.input_tokens +
|
|
95
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
96
|
+
(usage.cache_read_input_tokens ?? 0);
|
|
97
|
+
return {
|
|
98
|
+
id: response.id,
|
|
99
|
+
object: "chat.completion",
|
|
100
|
+
created: Math.floor(Date.now() / 1000),
|
|
101
|
+
model: response.model,
|
|
102
|
+
// Raw fields stay too, so debug artifacts show cache reads.
|
|
103
|
+
usage: {
|
|
104
|
+
...usage,
|
|
105
|
+
prompt_tokens: promptTokens,
|
|
106
|
+
completion_tokens: usage.output_tokens,
|
|
107
|
+
total_tokens: promptTokens + usage.output_tokens,
|
|
108
|
+
},
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
index: 0,
|
|
112
|
+
logprobs: null,
|
|
113
|
+
finish_reason: toolUses.length > 0
|
|
114
|
+
? "tool_calls"
|
|
115
|
+
: response.stop_reason === "max_tokens"
|
|
116
|
+
? "length"
|
|
117
|
+
: "stop",
|
|
118
|
+
message: {
|
|
119
|
+
role: "assistant",
|
|
120
|
+
content: content
|
|
121
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
122
|
+
.join(""),
|
|
123
|
+
refusal: null,
|
|
124
|
+
...(toolUses.length > 0 && {
|
|
125
|
+
tool_calls: toolUses.map((block) => ({
|
|
126
|
+
id: block.id,
|
|
127
|
+
type: "function",
|
|
128
|
+
function: {
|
|
129
|
+
name: block.name,
|
|
130
|
+
arguments: JSON.stringify(block.input),
|
|
131
|
+
},
|
|
132
|
+
})),
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
}
|
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,6 +1,8 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
3
|
import OpenAI from "openai";
|
|
3
|
-
import {
|
|
4
|
+
import { createAnthropicCompletion } from "./anthropic.js";
|
|
5
|
+
import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, DISCLAIMER, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
|
|
4
6
|
import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
|
|
5
7
|
import { parseReviewFindings, truncateWithMarker } from "../prompt/utils.js";
|
|
6
8
|
import { logToolFailure, logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
@@ -67,7 +69,7 @@ async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
async function createCompletionWithDebug(params) {
|
|
70
|
-
const {
|
|
72
|
+
const { llm, requestLabel, request, debugDumpFile, debugRecordWriter } = params;
|
|
71
73
|
await appendDebugDump(debugDumpFile, debugRecordWriter, {
|
|
72
74
|
kind: "openai_request",
|
|
73
75
|
label: requestLabel,
|
|
@@ -78,11 +80,13 @@ async function createCompletionWithDebug(params) {
|
|
|
78
80
|
// The SDK's own timeout only covers time-to-headers; a stalled SSE body would hang
|
|
79
81
|
// forever without the explicit AbortSignal deadline.
|
|
80
82
|
const { stream: _stream, ...createParams } = request;
|
|
81
|
-
const attempt = (withUsage) =>
|
|
82
|
-
.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const attempt = (withUsage) => llm instanceof Anthropic
|
|
84
|
+
? createAnthropicCompletion(llm, createParams, AbortSignal.timeout(COMPLETION_TIMEOUT_MS))
|
|
85
|
+
: llm.chat.completions
|
|
86
|
+
.stream(withUsage
|
|
87
|
+
? { ...createParams, stream_options: { include_usage: true } }
|
|
88
|
+
: createParams, { signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS) })
|
|
89
|
+
.finalChatCompletion();
|
|
86
90
|
try {
|
|
87
91
|
let completion;
|
|
88
92
|
try {
|
|
@@ -177,12 +181,17 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep)
|
|
|
177
181
|
error: fileText.message,
|
|
178
182
|
});
|
|
179
183
|
}
|
|
184
|
+
// Numbered like `cat -n`, so findings cite exact lines instead of counted ones.
|
|
185
|
+
const numbered = fileText
|
|
186
|
+
.split("\n")
|
|
187
|
+
.map((line, i) => `${i + 1}\t${line}`)
|
|
188
|
+
.join("\n");
|
|
180
189
|
return JSON.stringify({
|
|
181
190
|
ok: true,
|
|
182
191
|
path,
|
|
183
192
|
ref,
|
|
184
|
-
content:
|
|
185
|
-
truncated:
|
|
193
|
+
content: numbered.slice(0, MAX_TOOL_FILE_CHARS),
|
|
194
|
+
truncated: numbered.length > MAX_TOOL_FILE_CHARS,
|
|
186
195
|
});
|
|
187
196
|
}
|
|
188
197
|
catch (error) {
|
|
@@ -256,11 +265,11 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
256
265
|
return results;
|
|
257
266
|
}
|
|
258
267
|
async function reviewMergeRequestWithTools(params) {
|
|
259
|
-
const {
|
|
268
|
+
const { llm, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
260
269
|
const { logDebug, logStep } = loggers;
|
|
261
270
|
logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
|
|
262
271
|
const messages = buildPrompt({
|
|
263
|
-
changes: changes.map((change) => ({ diff: change.diff })),
|
|
272
|
+
changes: changes.map((change) => ({ path: change.new_path, diff: change.diff })),
|
|
264
273
|
limits: promptLimits,
|
|
265
274
|
allowTools: true,
|
|
266
275
|
profile: promptProfile,
|
|
@@ -274,7 +283,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
274
283
|
type: "function",
|
|
275
284
|
function: {
|
|
276
285
|
name: TOOL_NAME_GET_FILE,
|
|
277
|
-
description: "Fetch
|
|
286
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
278
287
|
parameters: {
|
|
279
288
|
type: "object",
|
|
280
289
|
additionalProperties: false,
|
|
@@ -315,7 +324,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
315
324
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
316
325
|
logStep(`Single-pass round ${round + 1}/${MAX_TOOL_ROUNDS}: requesting model response...`);
|
|
317
326
|
const completion = await createCompletionWithDebug({
|
|
318
|
-
|
|
327
|
+
llm,
|
|
319
328
|
requestLabel: `main_review_round_${round + 1}`,
|
|
320
329
|
debugDumpFile,
|
|
321
330
|
debugRecordWriter,
|
|
@@ -390,7 +399,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
390
399
|
});
|
|
391
400
|
logStep("Single-pass tool-call limit reached. Requesting final answer.");
|
|
392
401
|
const finalCompletion = await createCompletionWithDebug({
|
|
393
|
-
|
|
402
|
+
llm,
|
|
394
403
|
requestLabel: "main_review_final_after_tool_limit",
|
|
395
404
|
debugDumpFile,
|
|
396
405
|
debugRecordWriter,
|
|
@@ -404,7 +413,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
404
413
|
return buildAnswer(finalCompletion);
|
|
405
414
|
}
|
|
406
415
|
async function runFileReviewWithTools(params) {
|
|
407
|
-
const {
|
|
416
|
+
const { llm, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
408
417
|
const { logDebug, logStep } = loggers;
|
|
409
418
|
const messages = buildFileReviewPrompt({
|
|
410
419
|
filePath,
|
|
@@ -419,7 +428,7 @@ async function runFileReviewWithTools(params) {
|
|
|
419
428
|
type: "function",
|
|
420
429
|
function: {
|
|
421
430
|
name: TOOL_NAME_GET_FILE,
|
|
422
|
-
description: "Fetch
|
|
431
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
423
432
|
parameters: {
|
|
424
433
|
type: "object",
|
|
425
434
|
additionalProperties: false,
|
|
@@ -459,7 +468,7 @@ async function runFileReviewWithTools(params) {
|
|
|
459
468
|
];
|
|
460
469
|
for (let round = 0; round < MAX_FILE_TOOL_ROUNDS; round += 1) {
|
|
461
470
|
const completion = await createCompletionWithDebug({
|
|
462
|
-
|
|
471
|
+
llm,
|
|
463
472
|
requestLabel: `file_review_${filePath}_round_${round + 1}`,
|
|
464
473
|
debugDumpFile,
|
|
465
474
|
debugRecordWriter,
|
|
@@ -534,7 +543,7 @@ async function runFileReviewWithTools(params) {
|
|
|
534
543
|
content: "Tool-call limit reached. Provide your final review now without any tool calls.",
|
|
535
544
|
});
|
|
536
545
|
const final = await createCompletionWithDebug({
|
|
537
|
-
|
|
546
|
+
llm,
|
|
538
547
|
requestLabel: `file_review_${filePath}_final_after_tool_limit`,
|
|
539
548
|
debugDumpFile,
|
|
540
549
|
debugRecordWriter,
|
|
@@ -551,7 +560,7 @@ function draftHasStructuredFindings(consolidatedText) {
|
|
|
551
560
|
return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
|
|
552
561
|
}
|
|
553
562
|
async function runVerificationWithTools(params) {
|
|
554
|
-
const {
|
|
563
|
+
const { llm, aiModel, baseMessages, refs, gitLabProjectApiUrl, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
555
564
|
const { logDebug, logStep } = loggers;
|
|
556
565
|
const messages = [...baseMessages];
|
|
557
566
|
const tools = [
|
|
@@ -559,7 +568,7 @@ async function runVerificationWithTools(params) {
|
|
|
559
568
|
type: "function",
|
|
560
569
|
function: {
|
|
561
570
|
name: TOOL_NAME_GET_FILE,
|
|
562
|
-
description: "Fetch
|
|
571
|
+
description: "Fetch file content at a specific git ref for review context. Each line starts with its line number and a tab.",
|
|
563
572
|
parameters: {
|
|
564
573
|
type: "object",
|
|
565
574
|
additionalProperties: false,
|
|
@@ -600,7 +609,7 @@ async function runVerificationWithTools(params) {
|
|
|
600
609
|
const verificationForceRound0 = forceTools && draftHasStructuredFindings(consolidatedDraft);
|
|
601
610
|
for (let round = 0; round < MAX_VERIFICATION_TOOL_ROUNDS; round += 1) {
|
|
602
611
|
const completion = await createCompletionWithDebug({
|
|
603
|
-
|
|
612
|
+
llm,
|
|
604
613
|
requestLabel: `verification_pass_round_${round + 1}`,
|
|
605
614
|
debugDumpFile,
|
|
606
615
|
debugRecordWriter,
|
|
@@ -673,7 +682,7 @@ async function runVerificationWithTools(params) {
|
|
|
673
682
|
content: `Tool-call limit reached (${MAX_VERIFICATION_TOOL_ROUNDS}). Do not call tools. Output only the verified findings in the required format.`,
|
|
674
683
|
});
|
|
675
684
|
return createCompletionWithDebug({
|
|
676
|
-
|
|
685
|
+
llm,
|
|
677
686
|
requestLabel: "verification_pass_final_after_tool_limit",
|
|
678
687
|
debugDumpFile,
|
|
679
688
|
debugRecordWriter,
|
|
@@ -686,7 +695,7 @@ async function runVerificationWithTools(params) {
|
|
|
686
695
|
});
|
|
687
696
|
}
|
|
688
697
|
export async function reviewMergeRequestMultiPass(params) {
|
|
689
|
-
const {
|
|
698
|
+
const { llm, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
690
699
|
const { logStep } = loggers;
|
|
691
700
|
let effectiveProfile = promptProfile;
|
|
692
701
|
logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
|
|
@@ -705,7 +714,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
705
714
|
let triageParseError = null;
|
|
706
715
|
try {
|
|
707
716
|
const triageCompletion = await createCompletionWithDebug({
|
|
708
|
-
|
|
717
|
+
llm,
|
|
709
718
|
requestLabel: "triage_pass",
|
|
710
719
|
debugDumpFile,
|
|
711
720
|
debugRecordWriter,
|
|
@@ -740,7 +749,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
740
749
|
logStep("Triage response unparseable with prompt profile=default. Retrying triage with profile=weak.");
|
|
741
750
|
try {
|
|
742
751
|
const weakTriageCompletion = await createCompletionWithDebug({
|
|
743
|
-
|
|
752
|
+
llm,
|
|
744
753
|
requestLabel: "triage_pass_weak_retry",
|
|
745
754
|
debugDumpFile,
|
|
746
755
|
debugRecordWriter,
|
|
@@ -781,7 +790,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
781
790
|
}
|
|
782
791
|
logStep("Falling back to single-pass pipeline.");
|
|
783
792
|
return await reviewMergeRequestWithTools({
|
|
784
|
-
|
|
793
|
+
llm,
|
|
785
794
|
aiModel,
|
|
786
795
|
promptLimits,
|
|
787
796
|
changes,
|
|
@@ -826,7 +835,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
826
835
|
let findings;
|
|
827
836
|
try {
|
|
828
837
|
findings = await runFileReviewWithTools({
|
|
829
|
-
|
|
838
|
+
llm,
|
|
830
839
|
aiModel,
|
|
831
840
|
filePath: change.new_path,
|
|
832
841
|
fileDiff: truncateWithMarker(change.diff, promptLimits.maxDiffChars, change.new_path),
|
|
@@ -885,12 +894,11 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
885
894
|
profile: effectiveProfile,
|
|
886
895
|
});
|
|
887
896
|
if (consolidateMessages == null) {
|
|
888
|
-
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
889
897
|
return withHeader(`${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
|
|
890
898
|
}
|
|
891
899
|
try {
|
|
892
900
|
const consolidateCompletion = await createCompletionWithDebug({
|
|
893
|
-
|
|
901
|
+
llm,
|
|
894
902
|
requestLabel: "consolidate_pass",
|
|
895
903
|
debugDumpFile,
|
|
896
904
|
debugRecordWriter,
|
|
@@ -916,7 +924,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
916
924
|
});
|
|
917
925
|
try {
|
|
918
926
|
const verificationCompletion = await runVerificationWithTools({
|
|
919
|
-
|
|
927
|
+
llm,
|
|
920
928
|
aiModel,
|
|
921
929
|
baseMessages: verificationMessages,
|
|
922
930
|
refs,
|
|
@@ -937,7 +945,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
937
945
|
}
|
|
938
946
|
catch (error) {
|
|
939
947
|
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
|
940
|
-
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
941
948
|
const raw = perFileFindings
|
|
942
949
|
.filter((f) => !isEmptyReviewBody(f.findings))
|
|
943
950
|
.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
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** @format */
|
|
3
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
4
|
import OpenAI from "openai";
|
|
4
5
|
import { readFile } from "node:fs/promises";
|
|
5
6
|
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";
|
|
7
|
+
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, hasInlineCommentsFlag, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, parseTriageDiffChars, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
|
|
8
|
+
import { DEFAULT_ANTHROPIC_MODEL } from "./cli/anthropic.js";
|
|
7
9
|
import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
|
|
10
|
+
import { postInlineFindings } from "./cli/inline-comments.js";
|
|
8
11
|
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
9
12
|
import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
|
|
10
13
|
function printHelp() {
|
|
@@ -19,7 +22,7 @@ Usage:
|
|
|
19
22
|
Debug:
|
|
20
23
|
--debug Print full error details (stack, API error fields).
|
|
21
24
|
--include-artifacts Generate local HTML artifact without printing payloads to console.
|
|
22
|
-
--force-tools Force at least one tool-call round in tool-enabled review paths.
|
|
25
|
+
--force-tools Force at least one tool-call round in tool-enabled review paths (openai only).
|
|
23
26
|
--ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock
|
|
24
27
|
--max-diffs=50
|
|
25
28
|
--max-diff-chars=16000
|
|
@@ -27,11 +30,14 @@ Debug:
|
|
|
27
30
|
--triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).
|
|
28
31
|
--max-findings=5 Max findings in final review (CI multi-pass only).
|
|
29
32
|
--max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).
|
|
33
|
+
--inline-comments Post findings on changed lines as resolvable diff threads; the rest stays in the summary comment.
|
|
30
34
|
|
|
31
35
|
Env vars:
|
|
32
|
-
|
|
36
|
+
AI_PROVIDER (optional) "openai" | "anthropic". Default: openai.
|
|
37
|
+
OPENAI_API_KEY (required for openai) OpenAI API key.
|
|
33
38
|
OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.
|
|
34
|
-
|
|
39
|
+
ANTHROPIC_API_KEY (required for anthropic) Anthropic API key.
|
|
40
|
+
AI_MODEL (optional) Chat model. Default: gpt-4o-mini (openai), ${DEFAULT_ANTHROPIC_MODEL} (anthropic).
|
|
35
41
|
AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.
|
|
36
42
|
AI_PROMPT_PROFILE (optional) Prompt style: "default" | "weak". Default: default.
|
|
37
43
|
Use "weak" for small/quantized models — shorter prompts,
|
|
@@ -47,6 +53,7 @@ CI-only env vars (provided by GitLab):
|
|
|
47
53
|
const DEBUG_MODE = hasDebugFlag(process.argv);
|
|
48
54
|
const FORCE_TOOLS = hasForceToolsFlag(process.argv);
|
|
49
55
|
const INCLUDE_ARTIFACTS = hasIncludeArtifactsFlag(process.argv);
|
|
56
|
+
const INLINE_COMMENTS = hasInlineCommentsFlag(process.argv);
|
|
50
57
|
function logStep(message) {
|
|
51
58
|
process.stdout.write(`${message}\n`);
|
|
52
59
|
}
|
|
@@ -85,7 +92,11 @@ async function main() {
|
|
|
85
92
|
const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", Number.isFinite(envConcurrency) && envConcurrency >= 1
|
|
86
93
|
? Math.floor(envConcurrency)
|
|
87
94
|
: DEFAULT_REVIEW_CONCURRENCY, 1);
|
|
88
|
-
const
|
|
95
|
+
const provider = envOrDefault("AI_PROVIDER", "openai").toLowerCase();
|
|
96
|
+
if (provider !== "openai" && provider !== "anthropic") {
|
|
97
|
+
throw new Error(`AI_PROVIDER must be "openai" or "anthropic", got "${provider}".`);
|
|
98
|
+
}
|
|
99
|
+
const aiModel = envOrDefault("AI_MODEL", provider === "anthropic" ? DEFAULT_ANTHROPIC_MODEL : "gpt-4o-mini");
|
|
89
100
|
const promptProfile = readPromptProfileFromEnv();
|
|
90
101
|
const allowProfileAutoDemote = envOrUndefined("AI_PROMPT_PROFILE") == null;
|
|
91
102
|
logStep(`Prompt profile: ${promptProfile}${allowProfileAutoDemote ? " (auto: falls back to weak if triage JSON fails)" : ""}`);
|
|
@@ -101,8 +112,9 @@ async function main() {
|
|
|
101
112
|
let artifactWritten = false;
|
|
102
113
|
const loggers = { logStep, logDebug };
|
|
103
114
|
const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
|
|
115
|
+
const apiKeyEnv = provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
104
116
|
const gitlabRequired = [
|
|
105
|
-
|
|
117
|
+
apiKeyEnv,
|
|
106
118
|
"CI_API_V4_URL",
|
|
107
119
|
"CI_PROJECT_ID",
|
|
108
120
|
"CI_MERGE_REQUEST_IID",
|
|
@@ -111,7 +123,7 @@ async function main() {
|
|
|
111
123
|
if (projectAccessToken == null)
|
|
112
124
|
gitlabRequired.push("PROJECT_ACCESS_TOKEN");
|
|
113
125
|
const envs = requireEnvs(gitlabRequired);
|
|
114
|
-
const
|
|
126
|
+
const apiKey = envs[apiKeyEnv];
|
|
115
127
|
const ciApiV4Url = envs["CI_API_V4_URL"];
|
|
116
128
|
// MR endpoints live in the MR's target project; in fork pipelines CI_PROJECT_ID is the fork.
|
|
117
129
|
const projectId = envOrUndefined("CI_MERGE_REQUEST_PROJECT_ID") ?? envs["CI_PROJECT_ID"];
|
|
@@ -136,9 +148,11 @@ async function main() {
|
|
|
136
148
|
process.stdout.write("No changes found in merge request. Skipping review.\n");
|
|
137
149
|
return;
|
|
138
150
|
}
|
|
139
|
-
logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
|
|
151
|
+
logStep(`Requesting AI review with model: ${aiModel} (${provider}, multi-pass pipeline)`);
|
|
140
152
|
const answer = await reviewMergeRequestMultiPass({
|
|
141
|
-
|
|
153
|
+
llm: provider === "anthropic"
|
|
154
|
+
? new Anthropic({ apiKey })
|
|
155
|
+
: new OpenAI({ apiKey }),
|
|
142
156
|
aiModel,
|
|
143
157
|
promptLimits,
|
|
144
158
|
triageDiffChars,
|
|
@@ -157,12 +171,23 @@ async function main() {
|
|
|
157
171
|
loggers,
|
|
158
172
|
debugRecordWriter,
|
|
159
173
|
});
|
|
174
|
+
const note = INLINE_COMMENTS
|
|
175
|
+
? await postInlineFindings({
|
|
176
|
+
answer,
|
|
177
|
+
changes: filteredChanges,
|
|
178
|
+
diffRefs: mrChanges.diff_refs,
|
|
179
|
+
gitLabProjectApiUrl,
|
|
180
|
+
headers,
|
|
181
|
+
mergeRequestIid,
|
|
182
|
+
logStep,
|
|
183
|
+
})
|
|
184
|
+
: answer;
|
|
160
185
|
logStep("Posting AI review note to merge request");
|
|
161
186
|
const noteRes = await postMergeRequestNote({
|
|
162
187
|
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
163
188
|
headers,
|
|
164
189
|
mergeRequestIid,
|
|
165
|
-
note
|
|
190
|
+
note,
|
|
166
191
|
});
|
|
167
192
|
if (noteRes instanceof Error)
|
|
168
193
|
throw noteRes;
|
package/dist/gitlab/services.js
CHANGED
|
@@ -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
|
+
};
|
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";
|
|
@@ -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
|
|
24
|
+
const diffsTrimmed = capped.map((change) => {
|
|
25
25
|
if (change.diff.length > effectiveLimits.maxDiffChars)
|
|
26
26
|
truncatedCount += 1;
|
|
27
|
-
|
|
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
|
|
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.45",
|
|
5
5
|
"description": "CLI tool to generate AI code reviews for GitLab merge requests.",
|
|
6
6
|
"main": "dist/cli.js",
|
|
7
7
|
"bin": {
|
|
@@ -16,13 +16,16 @@
|
|
|
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",
|
|
23
24
|
"merge-request",
|
|
24
25
|
"code-review",
|
|
25
26
|
"openai",
|
|
27
|
+
"anthropic",
|
|
28
|
+
"claude",
|
|
26
29
|
"cli"
|
|
27
30
|
],
|
|
28
31
|
"author": "KrotovM",
|
|
@@ -39,6 +42,7 @@
|
|
|
39
42
|
},
|
|
40
43
|
"homepage": "https://github.com/KrotovM/gitlab-ai-mr-reviewer#readme",
|
|
41
44
|
"dependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": "^0.133.0",
|
|
42
46
|
"openai": "^6.33.0"
|
|
43
47
|
},
|
|
44
48
|
"devDependencies": {
|