@krotovm/gitlab-ai-review 1.0.16 → 1.0.18
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 +15 -3
- package/dist/cli/args.js +136 -0
- package/dist/cli/ci-review.js +441 -0
- package/dist/cli/local-review.js +467 -0
- package/dist/cli/tooling.js +28 -0
- package/dist/cli.js +88 -559
- package/dist/gitlab/services.js +2 -1
- package/dist/prompt/index.js +154 -61
- package/dist/prompt/messages.js +146 -0
- package/dist/prompt/utils.js +15 -0
- package/package.json +3 -2
- package/dist/cli.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/gitlab/services.js.map +0 -1
- package/dist/gitlab/types.js.map +0 -1
- package/dist/prompt/index.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
/** @format */
|
|
3
3
|
import OpenAI from "openai";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
|
|
6
|
+
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIgnoredExtension, hasModeFlag, parseDiffFileFlag, parseIgnoreExtensions, parseMode, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
|
|
7
|
+
import { diffFromFile, localDiffLastCommit, localDiffWorktree, reviewDiffToConsole, reviewDiffToConsoleWithToolsLocal, } from "./cli/local-review.js";
|
|
8
|
+
import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
|
|
9
|
+
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
7
10
|
function printHelp() {
|
|
8
11
|
process.stdout.write([
|
|
9
12
|
"gitlab-ai-review",
|
|
@@ -25,10 +28,13 @@ function printHelp() {
|
|
|
25
28
|
"",
|
|
26
29
|
"Debug:",
|
|
27
30
|
" --debug Print full error details (stack, API error fields).",
|
|
31
|
+
" --force-tools Force at least one tool-call round in tool-enabled review paths.",
|
|
28
32
|
" --ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock",
|
|
29
33
|
" --max-diffs=50",
|
|
30
34
|
" --max-diff-chars=16000",
|
|
31
35
|
" --max-total-prompt-chars=220000",
|
|
36
|
+
" --max-findings=5 Max findings in final review (CI multi-pass only).",
|
|
37
|
+
" --max-review-concurrency=5 Parallel per-file review calls (CI multi-pass only).",
|
|
32
38
|
"",
|
|
33
39
|
"Env vars:",
|
|
34
40
|
" OPENAI_API_KEY (required) OpenAI API key.",
|
|
@@ -44,146 +50,16 @@ function printHelp() {
|
|
|
44
50
|
"",
|
|
45
51
|
].join("\n"));
|
|
46
52
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (value == null || value.trim() === "") {
|
|
50
|
-
throw new Error(`Missing required env var: ${name}`);
|
|
51
|
-
}
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
54
|
-
function requireEnvs(names) {
|
|
55
|
-
const missing = names.filter((name) => {
|
|
56
|
-
const value = process.env[name];
|
|
57
|
-
return value == null || value.trim() === "";
|
|
58
|
-
});
|
|
59
|
-
if (missing.length > 0) {
|
|
60
|
-
throw new Error(`Missing required env vars: ${missing.join(", ")}`);
|
|
61
|
-
}
|
|
62
|
-
const result = {};
|
|
63
|
-
for (const name of names) {
|
|
64
|
-
result[name] = process.env[name].trim();
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
function envOrDefault(name, defaultValue) {
|
|
69
|
-
const value = process.env[name];
|
|
70
|
-
if (value == null)
|
|
71
|
-
return defaultValue;
|
|
72
|
-
const trimmed = value.trim();
|
|
73
|
-
return trimmed === "" ? defaultValue : trimmed;
|
|
74
|
-
}
|
|
75
|
-
function envOrUndefined(name) {
|
|
76
|
-
const value = process.env[name];
|
|
77
|
-
if (value == null)
|
|
78
|
-
return undefined;
|
|
79
|
-
const trimmed = value.trim();
|
|
80
|
-
return trimmed === "" ? undefined : trimmed;
|
|
81
|
-
}
|
|
82
|
-
function hasDebugFlag(argv) {
|
|
83
|
-
const args = new Set(argv.slice(2));
|
|
84
|
-
return args.has("--debug");
|
|
85
|
-
}
|
|
86
|
-
function parseIgnoreExtensions(argv) {
|
|
87
|
-
const parsed = [];
|
|
88
|
-
const args = argv.slice(2);
|
|
89
|
-
for (const current of args) {
|
|
90
|
-
if (current === "--ignore-ext") {
|
|
91
|
-
throw new Error("Use comma-separated format: --ignore-ext=md,lock");
|
|
92
|
-
}
|
|
93
|
-
if (!current.startsWith("--ignore-ext="))
|
|
94
|
-
continue;
|
|
95
|
-
const value = current.slice("--ignore-ext=".length);
|
|
96
|
-
if (value.trim() === "")
|
|
97
|
-
continue;
|
|
98
|
-
const pieces = value
|
|
99
|
-
.split(",")
|
|
100
|
-
.map((part) => part.trim().toLowerCase())
|
|
101
|
-
.filter(Boolean);
|
|
102
|
-
for (const piece of pieces) {
|
|
103
|
-
parsed.push(piece.startsWith(".") ? piece : `.${piece}`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return Array.from(new Set(parsed));
|
|
107
|
-
}
|
|
108
|
-
function parseNumberFlag(argv, flagName, defaultValue, minValue) {
|
|
109
|
-
const prefix = `--${flagName}=`;
|
|
110
|
-
let value = defaultValue;
|
|
111
|
-
for (const arg of argv.slice(2)) {
|
|
112
|
-
if (!arg.startsWith(prefix))
|
|
113
|
-
continue;
|
|
114
|
-
const raw = arg.slice(prefix.length).trim();
|
|
115
|
-
if (raw === "") {
|
|
116
|
-
throw new Error(`Missing value for --${flagName}. Expected integer.`);
|
|
117
|
-
}
|
|
118
|
-
const parsed = Number(raw);
|
|
119
|
-
if (!Number.isInteger(parsed) || parsed < minValue) {
|
|
120
|
-
throw new Error(`Invalid value for --${flagName}: "${raw}". Expected integer >= ${minValue}.`);
|
|
121
|
-
}
|
|
122
|
-
value = parsed;
|
|
123
|
-
}
|
|
124
|
-
return value;
|
|
125
|
-
}
|
|
126
|
-
function parsePromptLimits(argv) {
|
|
127
|
-
return {
|
|
128
|
-
maxDiffs: parseNumberFlag(argv, "max-diffs", DEFAULT_PROMPT_LIMITS.maxDiffs, 0),
|
|
129
|
-
maxDiffChars: parseNumberFlag(argv, "max-diff-chars", DEFAULT_PROMPT_LIMITS.maxDiffChars, 1),
|
|
130
|
-
maxTotalPromptChars: parseNumberFlag(argv, "max-total-prompt-chars", DEFAULT_PROMPT_LIMITS.maxTotalPromptChars, 1),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
function parseDiffFileFlag(argv) {
|
|
134
|
-
const args = argv.slice(2);
|
|
135
|
-
for (const current of args) {
|
|
136
|
-
if (current === "--diff-file") {
|
|
137
|
-
throw new Error("Use equals format: --diff-file=./path/to/file.diff");
|
|
138
|
-
}
|
|
139
|
-
if (!current.startsWith("--diff-file="))
|
|
140
|
-
continue;
|
|
141
|
-
const value = current.slice("--diff-file=".length).trim();
|
|
142
|
-
if (value === "") {
|
|
143
|
-
throw new Error("Missing value for --diff-file. Example: --diff-file=./changes.diff");
|
|
144
|
-
}
|
|
145
|
-
return value;
|
|
146
|
-
}
|
|
147
|
-
return undefined;
|
|
148
|
-
}
|
|
149
|
-
function hasModeFlag(argv) {
|
|
150
|
-
const args = new Set(argv.slice(2));
|
|
151
|
-
return (args.has("--ci") || args.has("--worktree") || args.has("--last-commit"));
|
|
152
|
-
}
|
|
153
|
-
function hasIgnoredExtension(filePath, ignoredExtensions) {
|
|
154
|
-
const lowerPath = filePath.toLowerCase();
|
|
155
|
-
return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
|
|
156
|
-
}
|
|
157
|
-
function buildGitExcludePathspecs(ignoredExtensions) {
|
|
158
|
-
return ignoredExtensions.map((ext) => `:(exclude,glob)**/*${ext}`);
|
|
159
|
-
}
|
|
160
|
-
function parseMode(argv) {
|
|
161
|
-
const args = new Set(argv.slice(2));
|
|
162
|
-
if (args.has("--help") || args.has("-h")) {
|
|
163
|
-
printHelp();
|
|
164
|
-
process.exit(0);
|
|
165
|
-
}
|
|
166
|
-
const hasCi = args.has("--ci");
|
|
167
|
-
const hasWorktree = args.has("--worktree");
|
|
168
|
-
const hasLastCommit = args.has("--last-commit");
|
|
169
|
-
const count = Number(hasCi) + Number(hasWorktree) + Number(hasLastCommit);
|
|
170
|
-
if (count > 1) {
|
|
171
|
-
throw new Error("Choose only one mode: --ci, --worktree, or --last-commit");
|
|
172
|
-
}
|
|
173
|
-
if (hasCi)
|
|
174
|
-
return "ci";
|
|
175
|
-
if (hasWorktree)
|
|
176
|
-
return "worktree";
|
|
177
|
-
if (hasLastCommit)
|
|
178
|
-
return "last-commit";
|
|
179
|
-
const mergeRequestIid = process.env.CI_MERGE_REQUEST_IID;
|
|
180
|
-
if (mergeRequestIid != null && mergeRequestIid.trim() !== "")
|
|
181
|
-
return "ci";
|
|
182
|
-
return "worktree";
|
|
183
|
-
}
|
|
53
|
+
const DEBUG_MODE = hasDebugFlag(process.argv);
|
|
54
|
+
const FORCE_TOOLS = hasForceToolsFlag(process.argv);
|
|
184
55
|
function logStep(message) {
|
|
185
56
|
process.stdout.write(`${message}\n`);
|
|
186
57
|
}
|
|
58
|
+
function logDebug(message) {
|
|
59
|
+
if (!DEBUG_MODE)
|
|
60
|
+
return;
|
|
61
|
+
process.stdout.write(`[debug] ${message}\n`);
|
|
62
|
+
}
|
|
187
63
|
async function getCliVersion() {
|
|
188
64
|
try {
|
|
189
65
|
const packageJsonUrl = new URL("../package.json", import.meta.url);
|
|
@@ -198,384 +74,6 @@ async function getCliVersion() {
|
|
|
198
74
|
}
|
|
199
75
|
return "unknown";
|
|
200
76
|
}
|
|
201
|
-
async function runGit(args) {
|
|
202
|
-
const { spawn } = await import("node:child_process");
|
|
203
|
-
return await new Promise((resolve, reject) => {
|
|
204
|
-
const child = spawn("git", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
205
|
-
let stdout = "";
|
|
206
|
-
let stderr = "";
|
|
207
|
-
child.stdout.on("data", (d) => {
|
|
208
|
-
stdout += String(d);
|
|
209
|
-
});
|
|
210
|
-
child.stderr.on("data", (d) => {
|
|
211
|
-
stderr += String(d);
|
|
212
|
-
});
|
|
213
|
-
child.on("error", reject);
|
|
214
|
-
child.on("close", (code) => {
|
|
215
|
-
if (code === 0)
|
|
216
|
-
return resolve(stdout);
|
|
217
|
-
reject(new Error(stderr.trim() ||
|
|
218
|
-
`git ${args.join(" ")} failed with exit code ${code}`));
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
async function localDiffWorktree() {
|
|
223
|
-
const ignoreExtensions = parseIgnoreExtensions(process.argv);
|
|
224
|
-
const pathspecs = buildGitExcludePathspecs(ignoreExtensions);
|
|
225
|
-
const unstagedArgs = pathspecs.length > 0 ? ["diff", "--", ...pathspecs] : ["diff"];
|
|
226
|
-
const stagedArgs = pathspecs.length > 0
|
|
227
|
-
? ["diff", "--staged", "--", ...pathspecs]
|
|
228
|
-
: ["diff", "--staged"];
|
|
229
|
-
const unstaged = await runGit(unstagedArgs);
|
|
230
|
-
const staged = await runGit(stagedArgs);
|
|
231
|
-
const combined = [staged.trim(), unstaged.trim()]
|
|
232
|
-
.filter(Boolean)
|
|
233
|
-
.join("\n\n");
|
|
234
|
-
return combined;
|
|
235
|
-
}
|
|
236
|
-
async function localDiffLastCommit() {
|
|
237
|
-
const ignoreExtensions = parseIgnoreExtensions(process.argv);
|
|
238
|
-
const pathspecs = buildGitExcludePathspecs(ignoreExtensions);
|
|
239
|
-
// show patch for HEAD, but avoid commit message metadata
|
|
240
|
-
const args = pathspecs.length > 0
|
|
241
|
-
? ["show", "--format=", "HEAD", "--", ...pathspecs]
|
|
242
|
-
: ["show", "--format=", "HEAD"];
|
|
243
|
-
return await runGit(args);
|
|
244
|
-
}
|
|
245
|
-
async function diffFromFile(filePath) {
|
|
246
|
-
const text = await readFile(filePath, "utf8");
|
|
247
|
-
return text;
|
|
248
|
-
}
|
|
249
|
-
function editDistanceAtMostTwo(a, b) {
|
|
250
|
-
const la = a.length;
|
|
251
|
-
const lb = b.length;
|
|
252
|
-
if (Math.abs(la - lb) > 2)
|
|
253
|
-
return 3;
|
|
254
|
-
const dp = Array.from({ length: la + 1 }, (_, i) => Array.from({ length: lb + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
255
|
-
for (let i = 1; i <= la; i += 1) {
|
|
256
|
-
let rowMin = Number.POSITIVE_INFINITY;
|
|
257
|
-
for (let j = 1; j <= lb; j += 1) {
|
|
258
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
259
|
-
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
260
|
-
rowMin = Math.min(rowMin, dp[i][j]);
|
|
261
|
-
}
|
|
262
|
-
if (rowMin > 2)
|
|
263
|
-
return 3;
|
|
264
|
-
}
|
|
265
|
-
return dp[la][lb];
|
|
266
|
-
}
|
|
267
|
-
function collectCallIdentifiersFromDiffLines(lines, prefixes) {
|
|
268
|
-
const out = new Set();
|
|
269
|
-
const callPattern = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
|
270
|
-
for (const line of lines) {
|
|
271
|
-
if (!prefixes.some((p) => line.startsWith(p)))
|
|
272
|
-
continue;
|
|
273
|
-
const code = line.slice(1);
|
|
274
|
-
let match;
|
|
275
|
-
while ((match = callPattern.exec(code)) != null) {
|
|
276
|
-
const ident = match[1];
|
|
277
|
-
// Ignore common JS control keywords.
|
|
278
|
-
if (ident === "if" ||
|
|
279
|
-
ident === "for" ||
|
|
280
|
-
ident === "while" ||
|
|
281
|
-
ident === "switch" ||
|
|
282
|
-
ident === "catch") {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
out.add(ident);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return out;
|
|
289
|
-
}
|
|
290
|
-
function findDeterministicDiffFindings(diff) {
|
|
291
|
-
const lines = diff.split(/\r?\n/);
|
|
292
|
-
const addedCalls = collectCallIdentifiersFromDiffLines(lines, ["+"]);
|
|
293
|
-
const nearbyCalls = collectCallIdentifiersFromDiffLines(lines, [
|
|
294
|
-
" ",
|
|
295
|
-
"-",
|
|
296
|
-
"+",
|
|
297
|
-
]);
|
|
298
|
-
const findings = [];
|
|
299
|
-
for (const added of addedCalls) {
|
|
300
|
-
if (added.length < 7)
|
|
301
|
-
continue;
|
|
302
|
-
let closest;
|
|
303
|
-
let closestDistance = 3;
|
|
304
|
-
for (const candidate of nearbyCalls) {
|
|
305
|
-
if (candidate === added)
|
|
306
|
-
continue;
|
|
307
|
-
const distance = editDistanceAtMostTwo(added, candidate);
|
|
308
|
-
if (distance < closestDistance) {
|
|
309
|
-
closestDistance = distance;
|
|
310
|
-
closest = candidate;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (closest != null && closestDistance <= 2) {
|
|
314
|
-
findings.push({
|
|
315
|
-
title: "Possible symbol typo",
|
|
316
|
-
detail: `Call \`${added}(...)\` is very close to \`${closest}(...)\` and may be a misspelling causing runtime/reference errors.`,
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
// De-duplicate repeated matches.
|
|
321
|
-
return Array.from(new Map(findings.map((f) => [`${f.title}:${f.detail}`, f])).values()).slice(0, 3);
|
|
322
|
-
}
|
|
323
|
-
function injectDeterministicFindings(answer, findings) {
|
|
324
|
-
if (findings.length === 0)
|
|
325
|
-
return answer;
|
|
326
|
-
const noIssues = answer.includes("No confirmed bugs or high-value optimizations found.");
|
|
327
|
-
if (!noIssues)
|
|
328
|
-
return answer;
|
|
329
|
-
const bullets = findings
|
|
330
|
-
.map((f) => `- [high] ${f.title}: ${f.detail}`)
|
|
331
|
-
.join("\n");
|
|
332
|
-
const disclaimerIndex = answer.indexOf("\n---\n_This comment was generated by an artificial intelligence duck._");
|
|
333
|
-
const disclaimer = disclaimerIndex >= 0
|
|
334
|
-
? answer.slice(disclaimerIndex)
|
|
335
|
-
: "\n---\n_This comment was generated by an artificial intelligence duck._";
|
|
336
|
-
return `${bullets}${disclaimer}`;
|
|
337
|
-
}
|
|
338
|
-
async function reviewDiffToConsole(diff, openaiApiKey, aiModel, promptLimits) {
|
|
339
|
-
if (diff.trim() === "") {
|
|
340
|
-
process.stdout.write("No diff found. Skipping review.\n");
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
const messageParams = buildPrompt({
|
|
344
|
-
changes: [{ diff }],
|
|
345
|
-
limits: promptLimits,
|
|
346
|
-
});
|
|
347
|
-
const openaiInstance = new OpenAI({ apiKey: openaiApiKey });
|
|
348
|
-
const completion = await generateAICompletion(messageParams, openaiInstance, aiModel);
|
|
349
|
-
const answer = buildAnswer(completion);
|
|
350
|
-
const deterministicFindings = findDeterministicDiffFindings(diff);
|
|
351
|
-
const finalAnswer = injectDeterministicFindings(answer, deterministicFindings);
|
|
352
|
-
process.stdout.write(`${finalAnswer}\n`);
|
|
353
|
-
}
|
|
354
|
-
const TOOL_NAME_GET_FILE = "get_file_at_ref";
|
|
355
|
-
const TOOL_NAME_GREP = "grep_repository";
|
|
356
|
-
const MAX_TOOL_ROUNDS = 12;
|
|
357
|
-
function buildReviewMetadata(changes, refs) {
|
|
358
|
-
const files = changes.map((change, index) => ({
|
|
359
|
-
index: index + 1,
|
|
360
|
-
old_path: change.old_path,
|
|
361
|
-
new_path: change.new_path,
|
|
362
|
-
new_file: change.new_file ?? false,
|
|
363
|
-
deleted_file: change.deleted_file ?? false,
|
|
364
|
-
renamed_file: change.renamed_file ?? false,
|
|
365
|
-
}));
|
|
366
|
-
return JSON.stringify({
|
|
367
|
-
refs,
|
|
368
|
-
changed_files: files,
|
|
369
|
-
tool_usage_guidance: [
|
|
370
|
-
"If diff context is insufficient, call get_file_at_ref to read a specific file.",
|
|
371
|
-
"Use grep_repository to search for usages, definitions, or patterns across the codebase.",
|
|
372
|
-
"Use refs.base to inspect pre-change content and refs.head for current content.",
|
|
373
|
-
"Prefer targeted searches and file fetches; avoid broad context requests.",
|
|
374
|
-
],
|
|
375
|
-
}, null, 2);
|
|
376
|
-
}
|
|
377
|
-
async function reviewMergeRequestWithTools(params) {
|
|
378
|
-
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, } = params;
|
|
379
|
-
const messages = buildPrompt({
|
|
380
|
-
changes: changes.map((change) => ({ diff: change.diff })),
|
|
381
|
-
limits: promptLimits,
|
|
382
|
-
allowTools: true,
|
|
383
|
-
});
|
|
384
|
-
messages.push({
|
|
385
|
-
role: "user",
|
|
386
|
-
content: `Merge request metadata:\n${buildReviewMetadata(changes, refs)}`,
|
|
387
|
-
});
|
|
388
|
-
const tools = [
|
|
389
|
-
{
|
|
390
|
-
type: "function",
|
|
391
|
-
function: {
|
|
392
|
-
name: TOOL_NAME_GET_FILE,
|
|
393
|
-
description: "Fetch raw file content at a specific git ref for review context.",
|
|
394
|
-
parameters: {
|
|
395
|
-
type: "object",
|
|
396
|
-
additionalProperties: false,
|
|
397
|
-
properties: {
|
|
398
|
-
path: {
|
|
399
|
-
type: "string",
|
|
400
|
-
description: "Repository file path.",
|
|
401
|
-
},
|
|
402
|
-
ref: {
|
|
403
|
-
type: "string",
|
|
404
|
-
description: `Git ref or sha. Prefer "${refs.base}" (base) or "${refs.head}" (head).`,
|
|
405
|
-
},
|
|
406
|
-
},
|
|
407
|
-
required: ["path", "ref"],
|
|
408
|
-
},
|
|
409
|
-
},
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
type: "function",
|
|
413
|
-
function: {
|
|
414
|
-
name: TOOL_NAME_GREP,
|
|
415
|
-
description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
416
|
-
parameters: {
|
|
417
|
-
type: "object",
|
|
418
|
-
additionalProperties: false,
|
|
419
|
-
properties: {
|
|
420
|
-
query: {
|
|
421
|
-
type: "string",
|
|
422
|
-
description: "Search string (keyword, function name, variable, etc.).",
|
|
423
|
-
},
|
|
424
|
-
ref: {
|
|
425
|
-
type: "string",
|
|
426
|
-
description: `Git ref to search in. Prefer "${refs.head}" (head).`,
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
required: ["query"],
|
|
430
|
-
},
|
|
431
|
-
},
|
|
432
|
-
},
|
|
433
|
-
];
|
|
434
|
-
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
435
|
-
const completion = await openaiInstance.chat.completions.create({
|
|
436
|
-
model: aiModel,
|
|
437
|
-
temperature: 0.2,
|
|
438
|
-
stream: false,
|
|
439
|
-
messages,
|
|
440
|
-
tools,
|
|
441
|
-
tool_choice: "auto",
|
|
442
|
-
});
|
|
443
|
-
const message = completion.choices[0]?.message;
|
|
444
|
-
if (message == null)
|
|
445
|
-
return buildAnswer(completion);
|
|
446
|
-
const toolCalls = message.tool_calls ?? [];
|
|
447
|
-
if (toolCalls.length === 0)
|
|
448
|
-
return buildAnswer(completion);
|
|
449
|
-
messages.push({
|
|
450
|
-
role: "assistant",
|
|
451
|
-
content: message.content ?? "",
|
|
452
|
-
tool_calls: toolCalls,
|
|
453
|
-
});
|
|
454
|
-
for (const toolCall of toolCalls) {
|
|
455
|
-
const argsRaw = toolCall.function.arguments ?? "{}";
|
|
456
|
-
let toolContent;
|
|
457
|
-
if (toolCall.function.name === TOOL_NAME_GET_FILE) {
|
|
458
|
-
try {
|
|
459
|
-
const parsed = JSON.parse(argsRaw);
|
|
460
|
-
const path = parsed.path?.trim();
|
|
461
|
-
const ref = parsed.ref?.trim();
|
|
462
|
-
if (path == null || path === "" || ref == null || ref === "") {
|
|
463
|
-
toolContent = JSON.stringify({
|
|
464
|
-
ok: false,
|
|
465
|
-
error: "Both path and ref are required.",
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
const fileText = await fetchFileAtRef({
|
|
470
|
-
gitLabBaseUrl: gitLabProjectApiUrl,
|
|
471
|
-
headers,
|
|
472
|
-
filePath: path,
|
|
473
|
-
ref,
|
|
474
|
-
});
|
|
475
|
-
if (fileText instanceof Error) {
|
|
476
|
-
toolContent = JSON.stringify({
|
|
477
|
-
ok: false,
|
|
478
|
-
path,
|
|
479
|
-
ref,
|
|
480
|
-
error: fileText.message,
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
toolContent = JSON.stringify({
|
|
485
|
-
ok: true,
|
|
486
|
-
path,
|
|
487
|
-
ref,
|
|
488
|
-
content: fileText.slice(0, 30000),
|
|
489
|
-
truncated: fileText.length > 30000,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
catch (error) {
|
|
495
|
-
toolContent = JSON.stringify({
|
|
496
|
-
ok: false,
|
|
497
|
-
error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
|
|
498
|
-
raw: argsRaw,
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
else if (toolCall.function.name === TOOL_NAME_GREP) {
|
|
503
|
-
try {
|
|
504
|
-
const parsed = JSON.parse(argsRaw);
|
|
505
|
-
const query = parsed.query?.trim();
|
|
506
|
-
if (query == null || query === "") {
|
|
507
|
-
toolContent = JSON.stringify({
|
|
508
|
-
ok: false,
|
|
509
|
-
error: "query is required.",
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
else {
|
|
513
|
-
const ref = parsed.ref?.trim() || refs.head;
|
|
514
|
-
const results = await searchRepository({
|
|
515
|
-
gitLabBaseUrl: gitLabProjectApiUrl,
|
|
516
|
-
headers,
|
|
517
|
-
query,
|
|
518
|
-
ref,
|
|
519
|
-
projectId,
|
|
520
|
-
});
|
|
521
|
-
if (results instanceof Error) {
|
|
522
|
-
toolContent = JSON.stringify({
|
|
523
|
-
ok: false,
|
|
524
|
-
query,
|
|
525
|
-
ref,
|
|
526
|
-
error: results.message,
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
else {
|
|
530
|
-
const trimmed = results.map((r) => ({
|
|
531
|
-
path: r.path,
|
|
532
|
-
startline: r.startline,
|
|
533
|
-
data: r.data.slice(0, 2000),
|
|
534
|
-
}));
|
|
535
|
-
toolContent = JSON.stringify({
|
|
536
|
-
ok: true,
|
|
537
|
-
query,
|
|
538
|
-
ref,
|
|
539
|
-
matches: trimmed,
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
catch (error) {
|
|
545
|
-
toolContent = JSON.stringify({
|
|
546
|
-
ok: false,
|
|
547
|
-
error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
|
|
548
|
-
raw: argsRaw,
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
toolContent = JSON.stringify({
|
|
554
|
-
ok: false,
|
|
555
|
-
error: `Unknown tool "${toolCall.function.name}"`,
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
messages.push({
|
|
559
|
-
role: "tool",
|
|
560
|
-
tool_call_id: toolCall.id,
|
|
561
|
-
content: toolContent,
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
// Graceful fallback: ask model for a final best-effort answer
|
|
566
|
-
// using already collected context, without allowing more tool calls.
|
|
567
|
-
messages.push({
|
|
568
|
-
role: "user",
|
|
569
|
-
content: `Tool-call limit reached (${MAX_TOOL_ROUNDS}). Do not call any tools. Provide your best-effort final review now, strictly following the required output format. If confidence is low, return the exact no-issues sentence.`,
|
|
570
|
-
});
|
|
571
|
-
const finalCompletion = await openaiInstance.chat.completions.create({
|
|
572
|
-
model: aiModel,
|
|
573
|
-
temperature: 0.2,
|
|
574
|
-
stream: false,
|
|
575
|
-
messages,
|
|
576
|
-
});
|
|
577
|
-
return buildAnswer(finalCompletion);
|
|
578
|
-
}
|
|
579
77
|
async function main() {
|
|
580
78
|
const cliVersion = await getCliVersion();
|
|
581
79
|
logStep(`gitlab-ai-review v${cliVersion}`);
|
|
@@ -583,35 +81,69 @@ async function main() {
|
|
|
583
81
|
if (diffFilePath != null && hasModeFlag(process.argv)) {
|
|
584
82
|
throw new Error("Do not combine --diff-file with --ci/--worktree/--last-commit");
|
|
585
83
|
}
|
|
586
|
-
const mode = diffFilePath != null
|
|
84
|
+
const mode = diffFilePath != null
|
|
85
|
+
? "worktree"
|
|
86
|
+
: parseMode(process.argv, printHelp, process.env.CI_MERGE_REQUEST_IID);
|
|
587
87
|
const ignoredExtensions = parseIgnoreExtensions(process.argv);
|
|
588
88
|
const promptLimits = parsePromptLimits(process.argv);
|
|
89
|
+
const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
|
|
90
|
+
const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
|
|
589
91
|
const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
|
|
92
|
+
const loggers = { logStep, logDebug };
|
|
590
93
|
if (diffFilePath != null) {
|
|
591
94
|
logStep(`Reading diff file: ${diffFilePath}`);
|
|
592
|
-
const
|
|
593
|
-
const openaiApiKey = openaiEnvs["OPENAI_API_KEY"];
|
|
95
|
+
const openaiApiKey = requireEnvs(["OPENAI_API_KEY"])["OPENAI_API_KEY"];
|
|
594
96
|
const diff = await diffFromFile(diffFilePath);
|
|
595
97
|
logStep(`Requesting AI completion with model: ${aiModel}`);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
98
|
+
if (FORCE_TOOLS) {
|
|
99
|
+
await reviewDiffToConsoleWithToolsLocal({
|
|
100
|
+
diff,
|
|
101
|
+
openaiApiKey,
|
|
102
|
+
aiModel,
|
|
103
|
+
promptLimits,
|
|
104
|
+
forceTools: FORCE_TOOLS,
|
|
105
|
+
loggers,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
await reviewDiffToConsole({
|
|
110
|
+
diff,
|
|
111
|
+
openaiApiKey,
|
|
112
|
+
aiModel,
|
|
113
|
+
promptLimits,
|
|
114
|
+
forceTools: FORCE_TOOLS,
|
|
115
|
+
loggers,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
606
118
|
return;
|
|
607
119
|
}
|
|
608
|
-
if (mode === "last-commit") {
|
|
609
|
-
logStep("Collecting HEAD diff");
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
|
|
120
|
+
if (mode === "worktree" || mode === "last-commit") {
|
|
121
|
+
logStep(mode === "worktree" ? "Collecting local changes" : "Collecting HEAD diff");
|
|
122
|
+
const openaiApiKey = requireEnvs(["OPENAI_API_KEY"])["OPENAI_API_KEY"];
|
|
123
|
+
const diff = mode === "worktree"
|
|
124
|
+
? await localDiffWorktree(process.argv)
|
|
125
|
+
: await localDiffLastCommit(process.argv);
|
|
613
126
|
logStep(`Requesting AI completion with model: ${aiModel}`);
|
|
614
|
-
|
|
127
|
+
if (FORCE_TOOLS) {
|
|
128
|
+
await reviewDiffToConsoleWithToolsLocal({
|
|
129
|
+
diff,
|
|
130
|
+
openaiApiKey,
|
|
131
|
+
aiModel,
|
|
132
|
+
promptLimits,
|
|
133
|
+
forceTools: FORCE_TOOLS,
|
|
134
|
+
loggers,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
await reviewDiffToConsole({
|
|
139
|
+
diff,
|
|
140
|
+
openaiApiKey,
|
|
141
|
+
aiModel,
|
|
142
|
+
promptLimits,
|
|
143
|
+
forceTools: FORCE_TOOLS,
|
|
144
|
+
loggers,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
615
147
|
return;
|
|
616
148
|
}
|
|
617
149
|
const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
|
|
@@ -621,44 +153,37 @@ async function main() {
|
|
|
621
153
|
"CI_PROJECT_ID",
|
|
622
154
|
"CI_MERGE_REQUEST_IID",
|
|
623
155
|
];
|
|
624
|
-
if (projectAccessToken == null)
|
|
156
|
+
if (projectAccessToken == null)
|
|
625
157
|
gitlabRequired.push("CI_JOB_TOKEN");
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
const mergeRequestIid = gitlabEnvs["CI_MERGE_REQUEST_IID"];
|
|
632
|
-
const gitLabBaseUrl = new URL(ciApiV4Url);
|
|
158
|
+
const envs = requireEnvs(gitlabRequired);
|
|
159
|
+
const openaiApiKey = envs["OPENAI_API_KEY"];
|
|
160
|
+
const ciApiV4Url = envs["CI_API_V4_URL"];
|
|
161
|
+
const projectId = envs["CI_PROJECT_ID"];
|
|
162
|
+
const mergeRequestIid = envs["CI_MERGE_REQUEST_IID"];
|
|
633
163
|
const headers = {};
|
|
634
|
-
if (projectAccessToken != null)
|
|
164
|
+
if (projectAccessToken != null)
|
|
635
165
|
headers["PRIVATE-TOKEN"] = projectAccessToken;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
headers["JOB-TOKEN"] = gitlabEnvs["CI_JOB_TOKEN"];
|
|
639
|
-
}
|
|
166
|
+
else
|
|
167
|
+
headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
|
|
640
168
|
logStep("Fetching merge request changes");
|
|
641
169
|
const mrChanges = await fetchMergeRequestChanges({
|
|
642
|
-
gitLabBaseUrl,
|
|
170
|
+
gitLabBaseUrl: new URL(ciApiV4Url),
|
|
643
171
|
headers,
|
|
644
172
|
projectId,
|
|
645
173
|
mergeRequestIid,
|
|
646
174
|
});
|
|
647
175
|
if (mrChanges instanceof Error)
|
|
648
176
|
throw mrChanges;
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
: changes.filter((change) => !hasIgnoredExtension(change.new_path, ignoredExtensions) &&
|
|
653
|
-
!hasIgnoredExtension(change.old_path, ignoredExtensions));
|
|
177
|
+
const filteredChanges = (mrChanges.changes ?? []).filter((change) => ignoredExtensions.length === 0 ||
|
|
178
|
+
(!hasIgnoredExtension(change.new_path, ignoredExtensions) &&
|
|
179
|
+
!hasIgnoredExtension(change.old_path, ignoredExtensions)));
|
|
654
180
|
if (filteredChanges.length === 0) {
|
|
655
181
|
process.stdout.write("No changes found in merge request. Skipping review.\n");
|
|
656
182
|
return;
|
|
657
183
|
}
|
|
658
|
-
logStep(`Requesting AI
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
openaiInstance,
|
|
184
|
+
logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
|
|
185
|
+
const answer = await reviewMergeRequestMultiPass({
|
|
186
|
+
openaiInstance: new OpenAI({ apiKey: openaiApiKey }),
|
|
662
187
|
aiModel,
|
|
663
188
|
promptLimits,
|
|
664
189
|
changes: filteredChanges,
|
|
@@ -669,6 +194,10 @@ async function main() {
|
|
|
669
194
|
gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
|
|
670
195
|
projectId,
|
|
671
196
|
headers,
|
|
197
|
+
maxFindings,
|
|
198
|
+
reviewConcurrency,
|
|
199
|
+
forceTools: FORCE_TOOLS,
|
|
200
|
+
loggers,
|
|
672
201
|
});
|
|
673
202
|
logStep("Posting AI review note to merge request");
|
|
674
203
|
const noteRes = await postMergeRequestNote({
|