@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/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 { buildAnswer, buildPrompt, DEFAULT_PROMPT_LIMITS, } from "./prompt/index.js";
6
- import { fetchFileAtRef, fetchMergeRequestChanges, generateAICompletion, postMergeRequestNote, searchRepository, } from "./gitlab/services.js";
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
- function requiredEnv(name) {
48
- const value = process.env[name];
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 ? "worktree" : parseMode(process.argv);
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 openaiEnvs = requireEnvs(["OPENAI_API_KEY"]);
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
- await reviewDiffToConsole(diff, openaiApiKey, aiModel, promptLimits);
597
- return;
598
- }
599
- if (mode === "worktree") {
600
- logStep("Collecting local changes");
601
- const openaiEnvs = requireEnvs(["OPENAI_API_KEY"]);
602
- const openaiApiKey = openaiEnvs["OPENAI_API_KEY"];
603
- const diff = await localDiffWorktree();
604
- logStep(`Requesting AI completion with model: ${aiModel}`);
605
- await reviewDiffToConsole(diff, openaiApiKey, aiModel, promptLimits);
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 openaiEnvs = requireEnvs(["OPENAI_API_KEY"]);
611
- const openaiApiKey = openaiEnvs["OPENAI_API_KEY"];
612
- const diff = await localDiffLastCommit();
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
- await reviewDiffToConsole(diff, openaiApiKey, aiModel, promptLimits);
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 gitlabEnvs = requireEnvs(gitlabRequired);
628
- const openaiApiKey = gitlabEnvs["OPENAI_API_KEY"];
629
- const ciApiV4Url = gitlabEnvs["CI_API_V4_URL"];
630
- const projectId = gitlabEnvs["CI_PROJECT_ID"];
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
- else {
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 changes = mrChanges.changes ?? [];
650
- const filteredChanges = ignoredExtensions.length === 0
651
- ? changes
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 completion with model: ${aiModel}`);
659
- const openaiInstance = new OpenAI({ apiKey: openaiApiKey });
660
- const answer = await reviewMergeRequestWithTools({
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({