@krotovm/gitlab-ai-review 1.0.25 → 1.0.27

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 CHANGED
@@ -30,6 +30,24 @@ ai_review:
30
30
  - npx -y @krotovm/gitlab-ai-review
31
31
  ```
32
32
 
33
+ Save debug HTML as a CI artifact:
34
+
35
+ ```yaml
36
+ stages: [review]
37
+
38
+ ai_review:
39
+ stage: review
40
+ image: node:20
41
+ rules:
42
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
43
+ script:
44
+ - npx -y @krotovm/gitlab-ai-review --include-artifacts
45
+ artifacts:
46
+ expire_in: 7 days
47
+ paths:
48
+ - ai-review-report.html
49
+ ```
50
+
33
51
  ## Env variables
34
52
 
35
53
  Set these in your project/group CI settings:
@@ -39,6 +57,7 @@ Set these in your project/group CI settings:
39
57
  - `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
40
58
  - `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
41
59
  - `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
60
+ - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
42
61
 
43
62
  `OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
44
63
 
@@ -58,6 +77,7 @@ GitLab provides these automatically in Merge Request pipelines:
58
77
  - `--max-findings=5` - Max findings in the final review (CI multi-pass only).
59
78
  - `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
60
79
  - `--debug` - Print full error details (stack and API error fields).
80
+ - `--include-artifacts` - Generate a local HTML debug artifact with per-pass outputs/tokens.
61
81
  - `--help` - Show help output.
62
82
 
63
83
  ## Architecture
package/dist/cli/args.js CHANGED
@@ -36,6 +36,10 @@ export function hasForceToolsFlag(argv) {
36
36
  const args = new Set(argv.slice(2));
37
37
  return args.has("--force-tools");
38
38
  }
39
+ export function hasIncludeArtifactsFlag(argv) {
40
+ const args = new Set(argv.slice(2));
41
+ return args.has("--include-artifacts");
42
+ }
39
43
  export function parseIgnoreExtensions(argv) {
40
44
  const parsed = [];
41
45
  const args = argv.slice(2);
@@ -2,7 +2,53 @@
2
2
  import OpenAI from "openai";
3
3
  import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
4
4
  import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
5
- import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
5
+ import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
6
+ async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
7
+ const withTs = { ts: new Date().toISOString(), ...record };
8
+ if (debugRecordWriter != null) {
9
+ await debugRecordWriter(withTs);
10
+ }
11
+ }
12
+ async function createCompletionWithDebug(params) {
13
+ const { openaiInstance, requestLabel, request, debugDumpFile, debugRecordWriter } = params;
14
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
15
+ kind: "openai_request",
16
+ label: requestLabel,
17
+ request,
18
+ });
19
+ try {
20
+ const completion = await openaiInstance.chat.completions.create(request);
21
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
22
+ kind: "openai_response",
23
+ label: requestLabel,
24
+ response: {
25
+ id: completion.id,
26
+ model: completion.model,
27
+ usage: completion.usage,
28
+ choices: completion.choices.map((c) => ({
29
+ index: c.index,
30
+ finish_reason: c.finish_reason,
31
+ message: c.message,
32
+ })),
33
+ },
34
+ });
35
+ return completion;
36
+ }
37
+ catch (error) {
38
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
39
+ kind: "openai_error",
40
+ label: requestLabel,
41
+ error: {
42
+ name: error?.name,
43
+ message: error?.message,
44
+ code: error?.code,
45
+ status: error?.status,
46
+ type: error?.type,
47
+ },
48
+ });
49
+ throw error;
50
+ }
51
+ }
6
52
  function buildReviewMetadata(changes, refs) {
7
53
  const files = changes.map((change, index) => ({
8
54
  index: index + 1,
@@ -29,7 +75,10 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
29
75
  const path = parsed.path?.trim();
30
76
  const ref = parsed.ref?.trim();
31
77
  if (!path || !ref) {
32
- return JSON.stringify({ ok: false, error: "Both path and ref are required." });
78
+ return JSON.stringify({
79
+ ok: false,
80
+ error: "Both path and ref are required.",
81
+ });
33
82
  }
34
83
  const fileText = await fetchFileAtRef({
35
84
  gitLabBaseUrl: gitLabProjectApiUrl,
@@ -113,7 +162,7 @@ async function mapWithConcurrency(items, concurrency, fn) {
113
162
  return results;
114
163
  }
115
164
  export async function reviewMergeRequestWithTools(params) {
116
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, } = params;
165
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
117
166
  const { logDebug, logStep } = loggers;
118
167
  const messages = buildPrompt({
119
168
  changes: changes.map((change) => ({ diff: change.diff })),
@@ -168,13 +217,19 @@ export async function reviewMergeRequestWithTools(params) {
168
217
  },
169
218
  ];
170
219
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
171
- const completion = await openaiInstance.chat.completions.create({
172
- model: aiModel,
173
- temperature: 0.2,
174
- stream: false,
175
- messages,
176
- tools,
177
- tool_choice: forceTools && round === 0 ? "required" : "auto",
220
+ const completion = await createCompletionWithDebug({
221
+ openaiInstance,
222
+ requestLabel: `main_review_round_${round + 1}`,
223
+ debugDumpFile,
224
+ debugRecordWriter,
225
+ request: {
226
+ model: aiModel,
227
+ temperature: 0.2,
228
+ stream: false,
229
+ messages,
230
+ tools,
231
+ tool_choice: forceTools && round === 0 ? "required" : "auto",
232
+ },
178
233
  });
179
234
  const message = completion.choices[0]?.message;
180
235
  if (message == null)
@@ -189,19 +244,30 @@ export async function reviewMergeRequestWithTools(params) {
189
244
  tool_calls: toolCalls,
190
245
  });
191
246
  for (const toolCall of toolCalls) {
247
+ if (toolCall.type !== "function")
248
+ continue;
249
+ const toolName = toolCall.function.name;
192
250
  const argsRaw = toolCall.function.arguments ?? "{}";
193
- logToolUsageMinimal(logStep, toolCall.function.name, argsRaw);
251
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
252
+ kind: "tool_call",
253
+ phase: "main_review",
254
+ round: round + 1,
255
+ id: toolCall.id,
256
+ name: toolName,
257
+ arguments: argsRaw,
258
+ });
259
+ logToolUsageMinimal(logStep, toolName, argsRaw);
194
260
  let toolContent;
195
- if (toolCall.function.name === TOOL_NAME_GET_FILE) {
261
+ if (toolName === TOOL_NAME_GET_FILE) {
196
262
  toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
197
263
  }
198
- else if (toolCall.function.name === TOOL_NAME_GREP) {
264
+ else if (toolName === TOOL_NAME_GREP) {
199
265
  toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
200
266
  }
201
267
  else {
202
268
  toolContent = JSON.stringify({
203
269
  ok: false,
204
- error: `Unknown tool "${toolCall.function.name}"`,
270
+ error: `Unknown tool "${toolName}"`,
205
271
  });
206
272
  }
207
273
  messages.push({
@@ -209,23 +275,37 @@ export async function reviewMergeRequestWithTools(params) {
209
275
  tool_call_id: toolCall.id,
210
276
  content: toolContent,
211
277
  });
212
- logDebug(`tool response id=${toolCall.id} name=${toolCall.function.name} payload=${toolContent.slice(0, 300)}`);
278
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
279
+ kind: "tool_response",
280
+ phase: "main_review",
281
+ round: round + 1,
282
+ id: toolCall.id,
283
+ name: toolName,
284
+ content: toolContent,
285
+ });
286
+ logDebug(`tool response id=${toolCall.id} name=${toolName} payload=${toolContent.slice(0, 300)}`);
213
287
  }
214
288
  }
215
289
  messages.push({
216
290
  role: "user",
217
291
  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.`,
218
292
  });
219
- const finalCompletion = await openaiInstance.chat.completions.create({
220
- model: aiModel,
221
- temperature: 0.2,
222
- stream: false,
223
- messages,
293
+ const finalCompletion = await createCompletionWithDebug({
294
+ openaiInstance,
295
+ requestLabel: "main_review_final_after_tool_limit",
296
+ debugDumpFile,
297
+ debugRecordWriter,
298
+ request: {
299
+ model: aiModel,
300
+ temperature: 0.2,
301
+ stream: false,
302
+ messages,
303
+ },
224
304
  });
225
305
  return buildAnswer(finalCompletion);
226
306
  }
227
307
  async function runFileReviewWithTools(params) {
228
- const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, } = params;
308
+ const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
229
309
  const { logDebug, logStep } = loggers;
230
310
  const messages = buildFileReviewPrompt({
231
311
  filePath,
@@ -278,13 +358,19 @@ async function runFileReviewWithTools(params) {
278
358
  },
279
359
  ];
280
360
  for (let round = 0; round < MAX_FILE_TOOL_ROUNDS; round += 1) {
281
- const completion = await openaiInstance.chat.completions.create({
282
- model: aiModel,
283
- temperature: 0.2,
284
- stream: false,
285
- messages,
286
- tools,
287
- tool_choice: forceTools && round === 0 ? "required" : "auto",
361
+ const completion = await createCompletionWithDebug({
362
+ openaiInstance,
363
+ requestLabel: `file_review_${filePath}_round_${round + 1}`,
364
+ debugDumpFile,
365
+ debugRecordWriter,
366
+ request: {
367
+ model: aiModel,
368
+ temperature: 0.2,
369
+ stream: false,
370
+ messages,
371
+ tools,
372
+ tool_choice: forceTools && round === 0 ? "required" : "auto",
373
+ },
288
374
  });
289
375
  const msg = completion.choices[0]?.message;
290
376
  if (msg == null)
@@ -299,19 +385,31 @@ async function runFileReviewWithTools(params) {
299
385
  tool_calls: toolCalls,
300
386
  });
301
387
  for (const toolCall of toolCalls) {
388
+ if (toolCall.type !== "function")
389
+ continue;
390
+ const toolName = toolCall.function.name;
302
391
  const argsRaw = toolCall.function.arguments ?? "{}";
303
- logToolUsageMinimal(logStep, toolCall.function.name, argsRaw, filePath);
392
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
393
+ kind: "tool_call",
394
+ phase: "file_review",
395
+ filePath,
396
+ round: round + 1,
397
+ id: toolCall.id,
398
+ name: toolName,
399
+ arguments: argsRaw,
400
+ });
401
+ logToolUsageMinimal(logStep, toolName, argsRaw, filePath);
304
402
  let toolContent;
305
- if (toolCall.function.name === TOOL_NAME_GET_FILE) {
403
+ if (toolName === TOOL_NAME_GET_FILE) {
306
404
  toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
307
405
  }
308
- else if (toolCall.function.name === TOOL_NAME_GREP) {
406
+ else if (toolName === TOOL_NAME_GREP) {
309
407
  toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
310
408
  }
311
409
  else {
312
410
  toolContent = JSON.stringify({
313
411
  ok: false,
314
- error: `Unknown tool "${toolCall.function.name}"`,
412
+ error: `Unknown tool "${toolName}"`,
315
413
  });
316
414
  }
317
415
  messages.push({
@@ -319,23 +417,176 @@ async function runFileReviewWithTools(params) {
319
417
  tool_call_id: toolCall.id,
320
418
  content: toolContent,
321
419
  });
322
- logDebug(`tool response file=${filePath} id=${toolCall.id} name=${toolCall.function.name} payload=${toolContent.slice(0, 300)}`);
420
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
421
+ kind: "tool_response",
422
+ phase: "file_review",
423
+ filePath,
424
+ round: round + 1,
425
+ id: toolCall.id,
426
+ name: toolName,
427
+ content: toolContent,
428
+ });
429
+ logDebug(`tool response file=${filePath} id=${toolCall.id} name=${toolName} payload=${toolContent.slice(0, 300)}`);
323
430
  }
324
431
  }
325
432
  messages.push({
326
433
  role: "user",
327
434
  content: "Tool-call limit reached. Provide your final review now without any tool calls.",
328
435
  });
329
- const final = await openaiInstance.chat.completions.create({
330
- model: aiModel,
331
- temperature: 0.2,
332
- stream: false,
333
- messages,
436
+ const final = await createCompletionWithDebug({
437
+ openaiInstance,
438
+ requestLabel: `file_review_${filePath}_final_after_tool_limit`,
439
+ debugDumpFile,
440
+ debugRecordWriter,
441
+ request: {
442
+ model: aiModel,
443
+ temperature: 0.2,
444
+ stream: false,
445
+ messages,
446
+ },
334
447
  });
335
448
  return extractCompletionText(final) ?? "No issues found.";
336
449
  }
450
+ function draftHasStructuredFindings(consolidatedText) {
451
+ return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
452
+ }
453
+ async function runVerificationWithTools(params) {
454
+ const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl, projectId, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
455
+ const { logDebug, logStep } = loggers;
456
+ const messages = [...baseMessages];
457
+ const tools = [
458
+ {
459
+ type: "function",
460
+ function: {
461
+ name: TOOL_NAME_GET_FILE,
462
+ description: "Fetch raw file content at a specific git ref for review context.",
463
+ parameters: {
464
+ type: "object",
465
+ additionalProperties: false,
466
+ properties: {
467
+ path: { type: "string", description: "Repository file path." },
468
+ ref: {
469
+ type: "string",
470
+ description: `Git ref or sha. Prefer "${refs.base}" (base) or "${refs.head}" (head).`,
471
+ },
472
+ },
473
+ required: ["path", "ref"],
474
+ },
475
+ },
476
+ },
477
+ {
478
+ type: "function",
479
+ function: {
480
+ name: TOOL_NAME_GREP,
481
+ description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
482
+ parameters: {
483
+ type: "object",
484
+ additionalProperties: false,
485
+ properties: {
486
+ query: {
487
+ type: "string",
488
+ description: "Search string (keyword, function name, variable, etc.).",
489
+ },
490
+ ref: {
491
+ type: "string",
492
+ description: `Git ref to search in. Prefer "${refs.head}" (head).`,
493
+ },
494
+ },
495
+ required: ["query"],
496
+ },
497
+ },
498
+ },
499
+ ];
500
+ const verificationForceRound0 = forceTools && draftHasStructuredFindings(consolidatedDraft);
501
+ for (let round = 0; round < MAX_VERIFICATION_TOOL_ROUNDS; round += 1) {
502
+ const completion = await createCompletionWithDebug({
503
+ openaiInstance,
504
+ requestLabel: `verification_pass_round_${round + 1}`,
505
+ debugDumpFile,
506
+ debugRecordWriter,
507
+ request: {
508
+ model: aiModel,
509
+ temperature: 0,
510
+ stream: false,
511
+ messages,
512
+ tools,
513
+ tool_choice: verificationForceRound0 && round === 0 ? "required" : "auto",
514
+ },
515
+ });
516
+ const message = completion.choices[0]?.message;
517
+ if (message == null)
518
+ return completion;
519
+ const toolCalls = message.tool_calls ?? [];
520
+ logDebug(`verification round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
521
+ if (toolCalls.length === 0)
522
+ return completion;
523
+ messages.push({
524
+ role: "assistant",
525
+ content: message.content ?? "",
526
+ tool_calls: toolCalls,
527
+ });
528
+ for (const toolCall of toolCalls) {
529
+ if (toolCall.type !== "function")
530
+ continue;
531
+ const toolName = toolCall.function.name;
532
+ const argsRaw = toolCall.function.arguments ?? "{}";
533
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
534
+ kind: "tool_call",
535
+ phase: "verification",
536
+ round: round + 1,
537
+ id: toolCall.id,
538
+ name: toolName,
539
+ arguments: argsRaw,
540
+ });
541
+ logToolUsageMinimal(logStep, toolName, argsRaw, "(verify)");
542
+ let toolContent;
543
+ if (toolName === TOOL_NAME_GET_FILE) {
544
+ toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
545
+ }
546
+ else if (toolName === TOOL_NAME_GREP) {
547
+ toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
548
+ }
549
+ else {
550
+ toolContent = JSON.stringify({
551
+ ok: false,
552
+ error: `Unknown tool "${toolName}"`,
553
+ });
554
+ }
555
+ messages.push({
556
+ role: "tool",
557
+ tool_call_id: toolCall.id,
558
+ content: toolContent,
559
+ });
560
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
561
+ kind: "tool_response",
562
+ phase: "verification",
563
+ round: round + 1,
564
+ id: toolCall.id,
565
+ name: toolName,
566
+ content: toolContent,
567
+ });
568
+ logDebug(`verification tool id=${toolCall.id} name=${toolName} payload=${toolContent.slice(0, 300)}`);
569
+ }
570
+ }
571
+ messages.push({
572
+ role: "user",
573
+ content: `Tool-call limit reached (${MAX_VERIFICATION_TOOL_ROUNDS}). Do not call tools. Output only the verified findings in the required format.`,
574
+ });
575
+ return createCompletionWithDebug({
576
+ openaiInstance,
577
+ requestLabel: "verification_pass_final_after_tool_limit",
578
+ debugDumpFile,
579
+ debugRecordWriter,
580
+ request: {
581
+ model: aiModel,
582
+ temperature: 0,
583
+ stream: false,
584
+ messages,
585
+ },
586
+ });
587
+ }
337
588
  export async function reviewMergeRequestMultiPass(params) {
338
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, } = params;
589
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
339
590
  const { logStep } = loggers;
340
591
  logStep(`Pass 1/4: triaging ${changes.length} file(s)`);
341
592
  const triageInputs = changes.map((c) => ({
@@ -348,12 +599,18 @@ export async function reviewMergeRequestMultiPass(params) {
348
599
  const triageMessages = buildTriagePrompt(triageInputs);
349
600
  let triageResult = null;
350
601
  try {
351
- const triageCompletion = await openaiInstance.chat.completions.create({
352
- model: aiModel,
353
- temperature: 0.1,
354
- stream: false,
355
- messages: triageMessages,
356
- response_format: { type: "json_object" },
602
+ const triageCompletion = await createCompletionWithDebug({
603
+ openaiInstance,
604
+ requestLabel: "triage_pass",
605
+ debugDumpFile,
606
+ debugRecordWriter,
607
+ request: {
608
+ model: aiModel,
609
+ temperature: 0.1,
610
+ stream: false,
611
+ messages: triageMessages,
612
+ response_format: { type: "json_object" },
613
+ },
357
614
  });
358
615
  const triageText = extractCompletionText(triageCompletion);
359
616
  if (triageText != null)
@@ -375,6 +632,8 @@ export async function reviewMergeRequestMultiPass(params) {
375
632
  headers,
376
633
  forceTools,
377
634
  loggers,
635
+ debugDumpFile,
636
+ debugRecordWriter,
378
637
  });
379
638
  }
380
639
  const triageMap = new Map(triageResult.files.map((f) => [f.path, f.verdict]));
@@ -402,6 +661,8 @@ export async function reviewMergeRequestMultiPass(params) {
402
661
  headers,
403
662
  forceTools,
404
663
  loggers,
664
+ debugDumpFile,
665
+ debugRecordWriter,
405
666
  });
406
667
  return { path: change.new_path, findings };
407
668
  });
@@ -416,29 +677,44 @@ export async function reviewMergeRequestMultiPass(params) {
416
677
  return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
417
678
  }
418
679
  try {
419
- const consolidateCompletion = await openaiInstance.chat.completions.create({
420
- model: aiModel,
421
- temperature: 0.1,
422
- stream: false,
423
- messages: consolidateMessages,
680
+ const consolidateCompletion = await createCompletionWithDebug({
681
+ openaiInstance,
682
+ requestLabel: "consolidate_pass",
683
+ debugDumpFile,
684
+ debugRecordWriter,
685
+ request: {
686
+ model: aiModel,
687
+ temperature: 0.1,
688
+ stream: false,
689
+ messages: consolidateMessages,
690
+ },
424
691
  });
425
692
  const consolidatedText = extractCompletionText(consolidateCompletion);
426
693
  if (consolidatedText == null || consolidatedText.trim() === "") {
427
694
  return buildAnswer(consolidateCompletion);
428
695
  }
429
- logStep("Pass 4/4: verifying consolidated findings");
696
+ logStep("Pass 4/4: verifying consolidated findings (repo tools)");
430
697
  const verificationMessages = buildVerificationPrompt({
431
698
  perFileFindings,
432
699
  summary: triageResult.summary,
433
700
  consolidatedFindings: consolidatedText,
434
701
  maxFindings,
702
+ refs,
435
703
  });
436
704
  try {
437
- const verificationCompletion = await openaiInstance.chat.completions.create({
438
- model: aiModel,
439
- temperature: 0.0,
440
- stream: false,
441
- messages: verificationMessages,
705
+ const verificationCompletion = await runVerificationWithTools({
706
+ openaiInstance,
707
+ aiModel,
708
+ baseMessages: verificationMessages,
709
+ refs,
710
+ gitLabProjectApiUrl,
711
+ projectId,
712
+ headers,
713
+ forceTools,
714
+ consolidatedDraft: consolidatedText,
715
+ loggers,
716
+ debugDumpFile,
717
+ debugRecordWriter,
442
718
  });
443
719
  return buildAnswer(verificationCompletion);
444
720
  }
@@ -0,0 +1,176 @@
1
+ /** @format */
2
+ import { writeFile } from "node:fs/promises";
3
+ function escapeHtml(value) {
4
+ return value
5
+ .replaceAll("&", "&amp;")
6
+ .replaceAll("<", "&lt;")
7
+ .replaceAll(">", "&gt;")
8
+ .replaceAll('"', "&quot;")
9
+ .replaceAll("'", "&#39;");
10
+ }
11
+ export async function renderDebugArtifactsHtml(params) {
12
+ const { records, artifactHtmlFile, cliVersion, aiModel } = params;
13
+ const responses = records.filter((r) => r.kind === "openai_response");
14
+ const byLabel = new Map();
15
+ for (const response of responses) {
16
+ if (typeof response.label === "string")
17
+ byLabel.set(response.label, response);
18
+ }
19
+ const totalTokens = responses.reduce((sum, r) => sum + Number(r?.response?.usage?.total_tokens ?? 0), 0);
20
+ const tokenLine = responses
21
+ .map((r) => {
22
+ const label = String(r.label ?? "unknown");
23
+ const tokens = Number(r?.response?.usage?.total_tokens ?? 0);
24
+ return `${label}: ${tokens}`;
25
+ })
26
+ .join(" • ");
27
+ function getContent(label) {
28
+ const content = byLabel.get(label)?.response?.choices?.[0]?.message?.content;
29
+ return typeof content === "string" ? content : "";
30
+ }
31
+ function getTokenTriplet(label) {
32
+ const usage = byLabel.get(label)?.response?.usage;
33
+ if (usage == null)
34
+ return "prompt: 0 • completion: 0 • total: 0";
35
+ return `prompt: ${usage.prompt_tokens ?? 0} • completion: ${usage.completion_tokens ?? 0} • total: ${usage.total_tokens ?? 0}`;
36
+ }
37
+ function findTs(label) {
38
+ return String(byLabel.get(label)?.ts ?? "");
39
+ }
40
+ function formatAsPrettyJsonIfPossible(value) {
41
+ const trimmed = value.trim();
42
+ if (trimmed === "")
43
+ return value;
44
+ const fencedMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
45
+ const normalized = (fencedMatch?.[1] ?? trimmed).trim();
46
+ try {
47
+ return JSON.stringify(JSON.parse(normalized), null, 2);
48
+ }
49
+ catch {
50
+ return value;
51
+ }
52
+ }
53
+ function renderFindings(markdown) {
54
+ const trimmed = markdown.trim();
55
+ if (trimmed === "")
56
+ return "<pre>No data</pre>";
57
+ const blocks = trimmed.split(/\n\s*\n/);
58
+ const items = [];
59
+ for (const block of blocks) {
60
+ const lines = block.split("\n").map((l) => l.trimEnd());
61
+ const title = lines[0] ?? "";
62
+ const file = lines.find((l) => l.trimStart().startsWith("File:")) ?? "";
63
+ const line = lines.find((l) => l.trimStart().startsWith("Line:")) ?? "";
64
+ const why = lines.find((l) => l.trimStart().startsWith("Why:")) ?? "";
65
+ if (!title.startsWith("- ["))
66
+ continue;
67
+ const isHigh = title.toLowerCase().includes("[high]");
68
+ items.push(`<div class="finding${isHigh ? " high" : ""}"><div class="title">${escapeHtml(title)}</div><div class="meta">${escapeHtml(file)} • ${escapeHtml(line)}</div><div>${escapeHtml(why)}</div></div>`);
69
+ }
70
+ if (items.length === 0)
71
+ return `<pre>${escapeHtml(trimmed)}</pre>`;
72
+ return items.join("\n");
73
+ }
74
+ const triageContent = getContent("triage_pass");
75
+ const triageContentPretty = formatAsPrettyJsonIfPossible(triageContent);
76
+ const fileServerLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_server.js_round_"));
77
+ const fileCiLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_.gitlab-ci.yml_round_"));
78
+ const consolidateLabel = "consolidate_pass";
79
+ function pickVerificationSection() {
80
+ const afterLimit = getContent("verification_pass_final_after_tool_limit");
81
+ if (afterLimit.trim() !== "")
82
+ return {
83
+ label: "verification_pass_final_after_tool_limit",
84
+ content: afterLimit,
85
+ };
86
+ const roundLabels = Array.from(byLabel.keys())
87
+ .filter((k) => k.startsWith("verification_pass_round_"))
88
+ .sort((a, b) => {
89
+ const na = Number(a.replace("verification_pass_round_", ""));
90
+ const nb = Number(b.replace("verification_pass_round_", ""));
91
+ return na - nb;
92
+ });
93
+ for (let i = roundLabels.length - 1; i >= 0; i--) {
94
+ const lbl = roundLabels[i];
95
+ const c = getContent(lbl);
96
+ if (c.trim() !== "")
97
+ return { label: lbl, content: c };
98
+ }
99
+ const legacy = getContent("verification_pass");
100
+ if (legacy.trim() !== "")
101
+ return { label: "verification_pass", content: legacy };
102
+ return { label: "verification_pass_round_1", content: "" };
103
+ }
104
+ const verificationSection = pickVerificationSection();
105
+ const verificationLabel = verificationSection.label;
106
+ const finalStatus = verificationSection.content.trim() !== "" ? "Verified" : "Fallback";
107
+ const html = `<!doctype html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="UTF-8" />
111
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
112
+ <title>AI Review Debug Report</title>
113
+ <style>
114
+ :root { --bg:#0b1020; --panel:#121a2b; --muted:#8ea0c0; --text:#e8eefc; --ok:#2ecc71; --high:#ff6b6b; --med:#f4b942; --line:#24314f; --mono-bg:#0f1526; }
115
+ * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 Inter,system-ui,sans-serif; padding:24px; }
116
+ .wrap{max-width:1100px;margin:0 auto;} h1,h2{margin:0 0 10px;} h1{font-size:24px;} h2{font-size:18px;margin-top:26px;} .sub{color:var(--muted);margin-bottom:18px;}
117
+ .grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0 22px;} .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;}
118
+ .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);}
119
+ .section{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;}
120
+ .row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
121
+ .badge{border:1px solid var(--line);background:#16223a;border-radius:999px;padding:2px 10px;font-size:12px;color:var(--muted);}
122
+ .tokens{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--muted);}
123
+ .finding{border-left:3px solid var(--med);background:#131f36;padding:10px 12px;border-radius:8px;margin:8px 0;} .finding.high{border-left-color:var(--high);}
124
+ .finding .title{font-weight:700;} .meta{color:var(--muted);font-size:12px;margin:4px 0;}
125
+ pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;background:var(--mono-bg);border:1px solid var(--line);padding:10px;border-radius:8px;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7e3ff;}
126
+ @media (max-width:900px){.grid{grid-template-columns:1fr 1fr;}} @media (max-width:520px){.grid{grid-template-columns:1fr;}}
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <div class="wrap">
131
+ <h1>AI Review Debug Report</h1>
132
+ <div class="sub">cli v${escapeHtml(cliVersion)} • model ${escapeHtml(aiModel)} • records ${records.length}</div>
133
+
134
+ <div class="grid">
135
+ <div class="card"><div class="k">Model</div><div class="v">${escapeHtml(aiModel)}</div></div>
136
+ <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(records.filter((r) => r.kind === "openai_request").length))}</div></div>
137
+ <div class="card"><div class="k">Responses</div><div class="v">${escapeHtml(String(responses.length))}</div></div>
138
+ <div class="card"><div class="k">Final Status</div><div class="v ok">${escapeHtml(finalStatus)}</div></div>
139
+ </div>
140
+
141
+ <h2>Token Usage</h2>
142
+ <div class="section">
143
+ <div class="tokens">${escapeHtml(tokenLine)}</div>
144
+ <div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
145
+ </div>
146
+
147
+ <h2>Pass 1 — Triage</h2>
148
+ <div class="section">
149
+ <div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
150
+ <pre>${escapeHtml(triageContentPretty)}</pre>
151
+ </div>
152
+
153
+ <h2>Pass 2 — File Reviews</h2>
154
+ ${fileServerLabel == null
155
+ ? ""
156
+ : `<div class="section"><div class="row"><span class="badge">label: ${escapeHtml(fileServerLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(fileServerLabel))}</span></div>${renderFindings(getContent(fileServerLabel))}</div>`}
157
+ ${fileCiLabel == null
158
+ ? ""
159
+ : `<div class="section"><div class="row"><span class="badge">label: ${escapeHtml(fileCiLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(fileCiLabel))}</span></div>${renderFindings(getContent(fileCiLabel))}</div>`}
160
+
161
+ <h2>Pass 3 — Consolidation</h2>
162
+ <div class="section">
163
+ <div class="row"><span class="badge">label: ${escapeHtml(consolidateLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(consolidateLabel))}</span></div>
164
+ ${renderFindings(getContent(consolidateLabel))}
165
+ </div>
166
+
167
+ <h2>Pass 4 — Verification</h2>
168
+ <div class="section">
169
+ <div class="row"><span class="badge">label: ${escapeHtml(verificationLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(verificationLabel))}</span></div>
170
+ ${renderFindings(verificationSection.content)}
171
+ </div>
172
+ </div>
173
+ </body>
174
+ </html>`;
175
+ await writeFile(artifactHtmlFile, html, "utf8");
176
+ }
@@ -3,6 +3,8 @@ export const TOOL_NAME_GET_FILE = "get_file_at_ref";
3
3
  export const TOOL_NAME_GREP = "grep_repository";
4
4
  export const MAX_TOOL_ROUNDS = 12;
5
5
  export const MAX_FILE_TOOL_ROUNDS = 5;
6
+ /** Pass 4 (verification): confirm drafts against repo without duplicating main-review depth. */
7
+ export const MAX_VERIFICATION_TOOL_ROUNDS = 10;
6
8
  export function logToolUsageMinimal(logStep, toolName, argsRaw, contextFile) {
7
9
  try {
8
10
  const parsed = JSON.parse(argsRaw);
package/dist/cli.js CHANGED
@@ -3,9 +3,10 @@
3
3
  import OpenAI from "openai";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
6
- import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
6
+ import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
7
7
  import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
8
8
  import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
9
+ import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
9
10
  function printHelp() {
10
11
  process.stdout.write([
11
12
  "gitlab-ai-review",
@@ -18,6 +19,7 @@ function printHelp() {
18
19
  "",
19
20
  "Debug:",
20
21
  " --debug Print full error details (stack, API error fields).",
22
+ " --include-artifacts Generate local HTML artifact without printing payloads to console.",
21
23
  " --force-tools Force at least one tool-call round in tool-enabled review paths.",
22
24
  " --ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock",
23
25
  " --max-diffs=50",
@@ -39,6 +41,7 @@ function printHelp() {
39
41
  }
40
42
  const DEBUG_MODE = hasDebugFlag(process.argv);
41
43
  const FORCE_TOOLS = hasForceToolsFlag(process.argv);
44
+ const INCLUDE_ARTIFACTS = hasIncludeArtifactsFlag(process.argv);
42
45
  function logStep(message) {
43
46
  process.stdout.write(`${message}\n`);
44
47
  }
@@ -74,6 +77,15 @@ async function main() {
74
77
  const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
75
78
  const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
76
79
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
80
+ const artifactHtmlFile = INCLUDE_ARTIFACTS
81
+ ? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", "ai-review-report.html")
82
+ : undefined;
83
+ const artifactRecords = [];
84
+ const debugRecordWriter = INCLUDE_ARTIFACTS
85
+ ? async (record) => {
86
+ artifactRecords.push(record);
87
+ }
88
+ : undefined;
77
89
  const loggers = { logStep, logDebug };
78
90
  const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
79
91
  const gitlabRequired = [
@@ -127,6 +139,7 @@ async function main() {
127
139
  reviewConcurrency,
128
140
  forceTools: FORCE_TOOLS,
129
141
  loggers,
142
+ debugRecordWriter,
130
143
  });
131
144
  logStep("Posting AI review note to merge request");
132
145
  const noteRes = await postMergeRequestNote({
@@ -136,6 +149,14 @@ async function main() {
136
149
  }, { body: answer });
137
150
  if (noteRes instanceof Error)
138
151
  throw noteRes;
152
+ if (INCLUDE_ARTIFACTS && artifactHtmlFile != null) {
153
+ await renderDebugArtifactsHtml({
154
+ records: artifactRecords,
155
+ artifactHtmlFile,
156
+ cliVersion,
157
+ aiModel,
158
+ });
159
+ }
139
160
  process.stdout.write("Posted AI review comment to merge request.\n");
140
161
  }
141
162
  main().catch((err) => {
@@ -126,7 +126,7 @@ export function buildConsolidatePrompt(params) {
126
126
  ];
127
127
  }
128
128
  export function buildVerificationPrompt(params) {
129
- const { perFileFindings, summary, consolidatedFindings, maxFindings } = params;
129
+ const { perFileFindings, summary, consolidatedFindings, maxFindings, refs, } = params;
130
130
  const findingsText = perFileFindings
131
131
  .map((f) => `### ${f.path}\n${f.findings}`)
132
132
  .join("\n\n");
@@ -141,6 +141,7 @@ export function buildVerificationPrompt(params) {
141
141
  summary,
142
142
  findingsText,
143
143
  consolidatedFindings,
144
+ refs,
144
145
  }),
145
146
  },
146
147
  ];
@@ -20,8 +20,10 @@ export function buildVerificationSystemLines(maxFindings) {
20
20
  return [
21
21
  "You are a skeptical verifier of a merge request review.",
22
22
  "Your job is to remove weak, speculative, or unsupported findings from the draft list.",
23
+ "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.",
24
+ "Drop a finding if file contents at refs.head contradict it, or if it cannot be verified after reasonable tool use.",
23
25
  "Do not add new findings. Keep, rewrite for clarity, or remove existing findings only.",
24
- "A finding can stay only if it is directly supported by evidence from the per-file findings.",
26
+ "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.",
25
27
  "If confidence is not high, drop the finding.",
26
28
  "Preserve this exact per-finding markdown block:",
27
29
  "`- [high|medium] <title>`",
@@ -57,10 +57,12 @@ export function buildConsolidateUserContent(params) {
57
57
  ].join("\n");
58
58
  }
59
59
  export function buildVerificationUserContent(params) {
60
- const { summary, findingsText, consolidatedFindings } = params;
60
+ const { summary, findingsText, consolidatedFindings, refs } = params;
61
61
  return [
62
62
  `MR Summary: ${summary}`,
63
63
  "",
64
+ `Refs for tools: head (post-change)="${refs.head}", base="${refs.base}". Prefer head when checking whether the issue exists in the MR.`,
65
+ "",
64
66
  "Per-file findings (evidence pool):",
65
67
  findingsText,
66
68
  "",
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.25",
4
+ "version": "1.0.27",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
@@ -37,11 +37,11 @@
37
37
  },
38
38
  "homepage": "https://github.com/KrotovM/gitlab-ai-mr-reviewer#readme",
39
39
  "dependencies": {
40
- "openai": "^4.47.2"
40
+ "openai": "^6.33.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "^20.4.4",
44
- "typescript": "^5.2.2"
43
+ "@types/node": "^25.5.0",
44
+ "typescript": "^6.0.2"
45
45
  },
46
46
  "packageManager": "pnpm@8.15.4+sha256.cea6d0bdf2de3a0549582da3983c70c92ffc577ff4410cbf190817ddc35137c2"
47
47
  }