@krotovm/gitlab-ai-review 1.0.44 → 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 +7 -3
- package/dist/cli/anthropic.js +138 -0
- package/dist/cli/ci-review.js +26 -22
- package/dist/cli.js +19 -8
- package/package.json +4 -1
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
|
|
|
@@ -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`
|
|
@@ -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/ci-review.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
3
|
import OpenAI from "openai";
|
|
4
|
+
import { createAnthropicCompletion } from "./anthropic.js";
|
|
3
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";
|
|
@@ -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 {
|
|
@@ -261,7 +265,7 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
261
265
|
return results;
|
|
262
266
|
}
|
|
263
267
|
async function reviewMergeRequestWithTools(params) {
|
|
264
|
-
const {
|
|
268
|
+
const { llm, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
265
269
|
const { logDebug, logStep } = loggers;
|
|
266
270
|
logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
|
|
267
271
|
const messages = buildPrompt({
|
|
@@ -320,7 +324,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
320
324
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
321
325
|
logStep(`Single-pass round ${round + 1}/${MAX_TOOL_ROUNDS}: requesting model response...`);
|
|
322
326
|
const completion = await createCompletionWithDebug({
|
|
323
|
-
|
|
327
|
+
llm,
|
|
324
328
|
requestLabel: `main_review_round_${round + 1}`,
|
|
325
329
|
debugDumpFile,
|
|
326
330
|
debugRecordWriter,
|
|
@@ -395,7 +399,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
395
399
|
});
|
|
396
400
|
logStep("Single-pass tool-call limit reached. Requesting final answer.");
|
|
397
401
|
const finalCompletion = await createCompletionWithDebug({
|
|
398
|
-
|
|
402
|
+
llm,
|
|
399
403
|
requestLabel: "main_review_final_after_tool_limit",
|
|
400
404
|
debugDumpFile,
|
|
401
405
|
debugRecordWriter,
|
|
@@ -409,7 +413,7 @@ async function reviewMergeRequestWithTools(params) {
|
|
|
409
413
|
return buildAnswer(finalCompletion);
|
|
410
414
|
}
|
|
411
415
|
async function runFileReviewWithTools(params) {
|
|
412
|
-
const {
|
|
416
|
+
const { llm, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
413
417
|
const { logDebug, logStep } = loggers;
|
|
414
418
|
const messages = buildFileReviewPrompt({
|
|
415
419
|
filePath,
|
|
@@ -464,7 +468,7 @@ async function runFileReviewWithTools(params) {
|
|
|
464
468
|
];
|
|
465
469
|
for (let round = 0; round < MAX_FILE_TOOL_ROUNDS; round += 1) {
|
|
466
470
|
const completion = await createCompletionWithDebug({
|
|
467
|
-
|
|
471
|
+
llm,
|
|
468
472
|
requestLabel: `file_review_${filePath}_round_${round + 1}`,
|
|
469
473
|
debugDumpFile,
|
|
470
474
|
debugRecordWriter,
|
|
@@ -539,7 +543,7 @@ async function runFileReviewWithTools(params) {
|
|
|
539
543
|
content: "Tool-call limit reached. Provide your final review now without any tool calls.",
|
|
540
544
|
});
|
|
541
545
|
const final = await createCompletionWithDebug({
|
|
542
|
-
|
|
546
|
+
llm,
|
|
543
547
|
requestLabel: `file_review_${filePath}_final_after_tool_limit`,
|
|
544
548
|
debugDumpFile,
|
|
545
549
|
debugRecordWriter,
|
|
@@ -556,7 +560,7 @@ function draftHasStructuredFindings(consolidatedText) {
|
|
|
556
560
|
return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
|
|
557
561
|
}
|
|
558
562
|
async function runVerificationWithTools(params) {
|
|
559
|
-
const {
|
|
563
|
+
const { llm, aiModel, baseMessages, refs, gitLabProjectApiUrl, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
560
564
|
const { logDebug, logStep } = loggers;
|
|
561
565
|
const messages = [...baseMessages];
|
|
562
566
|
const tools = [
|
|
@@ -605,7 +609,7 @@ async function runVerificationWithTools(params) {
|
|
|
605
609
|
const verificationForceRound0 = forceTools && draftHasStructuredFindings(consolidatedDraft);
|
|
606
610
|
for (let round = 0; round < MAX_VERIFICATION_TOOL_ROUNDS; round += 1) {
|
|
607
611
|
const completion = await createCompletionWithDebug({
|
|
608
|
-
|
|
612
|
+
llm,
|
|
609
613
|
requestLabel: `verification_pass_round_${round + 1}`,
|
|
610
614
|
debugDumpFile,
|
|
611
615
|
debugRecordWriter,
|
|
@@ -678,7 +682,7 @@ async function runVerificationWithTools(params) {
|
|
|
678
682
|
content: `Tool-call limit reached (${MAX_VERIFICATION_TOOL_ROUNDS}). Do not call tools. Output only the verified findings in the required format.`,
|
|
679
683
|
});
|
|
680
684
|
return createCompletionWithDebug({
|
|
681
|
-
|
|
685
|
+
llm,
|
|
682
686
|
requestLabel: "verification_pass_final_after_tool_limit",
|
|
683
687
|
debugDumpFile,
|
|
684
688
|
debugRecordWriter,
|
|
@@ -691,7 +695,7 @@ async function runVerificationWithTools(params) {
|
|
|
691
695
|
});
|
|
692
696
|
}
|
|
693
697
|
export async function reviewMergeRequestMultiPass(params) {
|
|
694
|
-
const {
|
|
698
|
+
const { llm, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
695
699
|
const { logStep } = loggers;
|
|
696
700
|
let effectiveProfile = promptProfile;
|
|
697
701
|
logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
|
|
@@ -710,7 +714,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
710
714
|
let triageParseError = null;
|
|
711
715
|
try {
|
|
712
716
|
const triageCompletion = await createCompletionWithDebug({
|
|
713
|
-
|
|
717
|
+
llm,
|
|
714
718
|
requestLabel: "triage_pass",
|
|
715
719
|
debugDumpFile,
|
|
716
720
|
debugRecordWriter,
|
|
@@ -745,7 +749,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
745
749
|
logStep("Triage response unparseable with prompt profile=default. Retrying triage with profile=weak.");
|
|
746
750
|
try {
|
|
747
751
|
const weakTriageCompletion = await createCompletionWithDebug({
|
|
748
|
-
|
|
752
|
+
llm,
|
|
749
753
|
requestLabel: "triage_pass_weak_retry",
|
|
750
754
|
debugDumpFile,
|
|
751
755
|
debugRecordWriter,
|
|
@@ -786,7 +790,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
786
790
|
}
|
|
787
791
|
logStep("Falling back to single-pass pipeline.");
|
|
788
792
|
return await reviewMergeRequestWithTools({
|
|
789
|
-
|
|
793
|
+
llm,
|
|
790
794
|
aiModel,
|
|
791
795
|
promptLimits,
|
|
792
796
|
changes,
|
|
@@ -831,7 +835,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
831
835
|
let findings;
|
|
832
836
|
try {
|
|
833
837
|
findings = await runFileReviewWithTools({
|
|
834
|
-
|
|
838
|
+
llm,
|
|
835
839
|
aiModel,
|
|
836
840
|
filePath: change.new_path,
|
|
837
841
|
fileDiff: truncateWithMarker(change.diff, promptLimits.maxDiffChars, change.new_path),
|
|
@@ -894,7 +898,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
894
898
|
}
|
|
895
899
|
try {
|
|
896
900
|
const consolidateCompletion = await createCompletionWithDebug({
|
|
897
|
-
|
|
901
|
+
llm,
|
|
898
902
|
requestLabel: "consolidate_pass",
|
|
899
903
|
debugDumpFile,
|
|
900
904
|
debugRecordWriter,
|
|
@@ -920,7 +924,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
920
924
|
});
|
|
921
925
|
try {
|
|
922
926
|
const verificationCompletion = await runVerificationWithTools({
|
|
923
|
-
|
|
927
|
+
llm,
|
|
924
928
|
aiModel,
|
|
925
929
|
baseMessages: verificationMessages,
|
|
926
930
|
refs,
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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
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";
|
|
8
10
|
import { postInlineFindings } from "./cli/inline-comments.js";
|
|
9
11
|
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
@@ -20,7 +22,7 @@ Usage:
|
|
|
20
22
|
Debug:
|
|
21
23
|
--debug Print full error details (stack, API error fields).
|
|
22
24
|
--include-artifacts Generate local HTML artifact without printing payloads to console.
|
|
23
|
-
--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).
|
|
24
26
|
--ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock
|
|
25
27
|
--max-diffs=50
|
|
26
28
|
--max-diff-chars=16000
|
|
@@ -31,9 +33,11 @@ Debug:
|
|
|
31
33
|
--inline-comments Post findings on changed lines as resolvable diff threads; the rest stays in the summary comment.
|
|
32
34
|
|
|
33
35
|
Env vars:
|
|
34
|
-
|
|
36
|
+
AI_PROVIDER (optional) "openai" | "anthropic". Default: openai.
|
|
37
|
+
OPENAI_API_KEY (required for openai) OpenAI API key.
|
|
35
38
|
OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.
|
|
36
|
-
|
|
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).
|
|
37
41
|
AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.
|
|
38
42
|
AI_PROMPT_PROFILE (optional) Prompt style: "default" | "weak". Default: default.
|
|
39
43
|
Use "weak" for small/quantized models — shorter prompts,
|
|
@@ -88,7 +92,11 @@ async function main() {
|
|
|
88
92
|
const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", Number.isFinite(envConcurrency) && envConcurrency >= 1
|
|
89
93
|
? Math.floor(envConcurrency)
|
|
90
94
|
: DEFAULT_REVIEW_CONCURRENCY, 1);
|
|
91
|
-
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");
|
|
92
100
|
const promptProfile = readPromptProfileFromEnv();
|
|
93
101
|
const allowProfileAutoDemote = envOrUndefined("AI_PROMPT_PROFILE") == null;
|
|
94
102
|
logStep(`Prompt profile: ${promptProfile}${allowProfileAutoDemote ? " (auto: falls back to weak if triage JSON fails)" : ""}`);
|
|
@@ -104,8 +112,9 @@ async function main() {
|
|
|
104
112
|
let artifactWritten = false;
|
|
105
113
|
const loggers = { logStep, logDebug };
|
|
106
114
|
const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
|
|
115
|
+
const apiKeyEnv = provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
107
116
|
const gitlabRequired = [
|
|
108
|
-
|
|
117
|
+
apiKeyEnv,
|
|
109
118
|
"CI_API_V4_URL",
|
|
110
119
|
"CI_PROJECT_ID",
|
|
111
120
|
"CI_MERGE_REQUEST_IID",
|
|
@@ -114,7 +123,7 @@ async function main() {
|
|
|
114
123
|
if (projectAccessToken == null)
|
|
115
124
|
gitlabRequired.push("PROJECT_ACCESS_TOKEN");
|
|
116
125
|
const envs = requireEnvs(gitlabRequired);
|
|
117
|
-
const
|
|
126
|
+
const apiKey = envs[apiKeyEnv];
|
|
118
127
|
const ciApiV4Url = envs["CI_API_V4_URL"];
|
|
119
128
|
// MR endpoints live in the MR's target project; in fork pipelines CI_PROJECT_ID is the fork.
|
|
120
129
|
const projectId = envOrUndefined("CI_MERGE_REQUEST_PROJECT_ID") ?? envs["CI_PROJECT_ID"];
|
|
@@ -139,9 +148,11 @@ async function main() {
|
|
|
139
148
|
process.stdout.write("No changes found in merge request. Skipping review.\n");
|
|
140
149
|
return;
|
|
141
150
|
}
|
|
142
|
-
logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
|
|
151
|
+
logStep(`Requesting AI review with model: ${aiModel} (${provider}, multi-pass pipeline)`);
|
|
143
152
|
const answer = await reviewMergeRequestMultiPass({
|
|
144
|
-
|
|
153
|
+
llm: provider === "anthropic"
|
|
154
|
+
? new Anthropic({ apiKey })
|
|
155
|
+
: new OpenAI({ apiKey }),
|
|
145
156
|
aiModel,
|
|
146
157
|
promptLimits,
|
|
147
158
|
triageDiffChars,
|
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": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"merge-request",
|
|
25
25
|
"code-review",
|
|
26
26
|
"openai",
|
|
27
|
+
"anthropic",
|
|
28
|
+
"claude",
|
|
27
29
|
"cli"
|
|
28
30
|
],
|
|
29
31
|
"author": "KrotovM",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
},
|
|
41
43
|
"homepage": "https://github.com/KrotovM/gitlab-ai-mr-reviewer#readme",
|
|
42
44
|
"dependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": "^0.133.0",
|
|
43
46
|
"openai": "^6.33.0"
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|