@krotovm/gitlab-ai-review 1.0.25 → 1.0.26

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
@@ -39,6 +39,7 @@ Set these in your project/group CI settings:
39
39
  - `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
40
40
  - `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
41
41
  - `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
42
+ - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `.ai-review-debug.html`; used with `--include-artifacts`)
42
43
 
43
44
  `OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
44
45
 
@@ -58,6 +59,7 @@ GitLab provides these automatically in Merge Request pipelines:
58
59
  - `--max-findings=5` - Max findings in the final review (CI multi-pass only).
59
60
  - `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
60
61
  - `--debug` - Print full error details (stack and API error fields).
62
+ - `--include-artifacts` - Generate a local HTML debug artifact with per-pass outputs/tokens.
61
63
  - `--help` - Show help output.
62
64
 
63
65
  ## 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);
@@ -3,6 +3,52 @@ 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
5
  import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_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,38 @@ 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
  }
337
450
  export async function reviewMergeRequestMultiPass(params) {
338
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, } = params;
451
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
339
452
  const { logStep } = loggers;
340
453
  logStep(`Pass 1/4: triaging ${changes.length} file(s)`);
341
454
  const triageInputs = changes.map((c) => ({
@@ -348,12 +461,18 @@ export async function reviewMergeRequestMultiPass(params) {
348
461
  const triageMessages = buildTriagePrompt(triageInputs);
349
462
  let triageResult = null;
350
463
  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" },
464
+ const triageCompletion = await createCompletionWithDebug({
465
+ openaiInstance,
466
+ requestLabel: "triage_pass",
467
+ debugDumpFile,
468
+ debugRecordWriter,
469
+ request: {
470
+ model: aiModel,
471
+ temperature: 0.1,
472
+ stream: false,
473
+ messages: triageMessages,
474
+ response_format: { type: "json_object" },
475
+ },
357
476
  });
358
477
  const triageText = extractCompletionText(triageCompletion);
359
478
  if (triageText != null)
@@ -375,6 +494,8 @@ export async function reviewMergeRequestMultiPass(params) {
375
494
  headers,
376
495
  forceTools,
377
496
  loggers,
497
+ debugDumpFile,
498
+ debugRecordWriter,
378
499
  });
379
500
  }
380
501
  const triageMap = new Map(triageResult.files.map((f) => [f.path, f.verdict]));
@@ -402,6 +523,8 @@ export async function reviewMergeRequestMultiPass(params) {
402
523
  headers,
403
524
  forceTools,
404
525
  loggers,
526
+ debugDumpFile,
527
+ debugRecordWriter,
405
528
  });
406
529
  return { path: change.new_path, findings };
407
530
  });
@@ -416,11 +539,17 @@ export async function reviewMergeRequestMultiPass(params) {
416
539
  return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
417
540
  }
418
541
  try {
419
- const consolidateCompletion = await openaiInstance.chat.completions.create({
420
- model: aiModel,
421
- temperature: 0.1,
422
- stream: false,
423
- messages: consolidateMessages,
542
+ const consolidateCompletion = await createCompletionWithDebug({
543
+ openaiInstance,
544
+ requestLabel: "consolidate_pass",
545
+ debugDumpFile,
546
+ debugRecordWriter,
547
+ request: {
548
+ model: aiModel,
549
+ temperature: 0.1,
550
+ stream: false,
551
+ messages: consolidateMessages,
552
+ },
424
553
  });
425
554
  const consolidatedText = extractCompletionText(consolidateCompletion);
426
555
  if (consolidatedText == null || consolidatedText.trim() === "") {
@@ -434,11 +563,17 @@ export async function reviewMergeRequestMultiPass(params) {
434
563
  maxFindings,
435
564
  });
436
565
  try {
437
- const verificationCompletion = await openaiInstance.chat.completions.create({
438
- model: aiModel,
439
- temperature: 0.0,
440
- stream: false,
441
- messages: verificationMessages,
566
+ const verificationCompletion = await createCompletionWithDebug({
567
+ openaiInstance,
568
+ requestLabel: "verification_pass",
569
+ debugDumpFile,
570
+ debugRecordWriter,
571
+ request: {
572
+ model: aiModel,
573
+ temperature: 0.0,
574
+ stream: false,
575
+ messages: verificationMessages,
576
+ },
442
577
  });
443
578
  return buildAnswer(verificationCompletion);
444
579
  }
@@ -0,0 +1,150 @@
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
+ const verificationLabel = "verification_pass";
80
+ const finalStatus = getContent(verificationLabel).trim() !== "" ? "Verified" : "Fallback";
81
+ const html = `<!doctype html>
82
+ <html lang="en">
83
+ <head>
84
+ <meta charset="UTF-8" />
85
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
86
+ <title>AI Review Debug Report</title>
87
+ <style>
88
+ :root { --bg:#0b1020; --panel:#121a2b; --muted:#8ea0c0; --text:#e8eefc; --ok:#2ecc71; --high:#ff6b6b; --med:#f4b942; --line:#24314f; --mono-bg:#0f1526; }
89
+ * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 Inter,system-ui,sans-serif; padding:24px; }
90
+ .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;}
91
+ .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;}
92
+ .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);}
93
+ .section{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;}
94
+ .row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
95
+ .badge{border:1px solid var(--line);background:#16223a;border-radius:999px;padding:2px 10px;font-size:12px;color:var(--muted);}
96
+ .tokens{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--muted);}
97
+ .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);}
98
+ .finding .title{font-weight:700;} .meta{color:var(--muted);font-size:12px;margin:4px 0;}
99
+ 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;}
100
+ @media (max-width:900px){.grid{grid-template-columns:1fr 1fr;}} @media (max-width:520px){.grid{grid-template-columns:1fr;}}
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div class="wrap">
105
+ <h1>AI Review Debug Report</h1>
106
+ <div class="sub">cli v${escapeHtml(cliVersion)} • model ${escapeHtml(aiModel)} • records ${records.length}</div>
107
+
108
+ <div class="grid">
109
+ <div class="card"><div class="k">Model</div><div class="v">${escapeHtml(aiModel)}</div></div>
110
+ <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(records.filter((r) => r.kind === "openai_request").length))}</div></div>
111
+ <div class="card"><div class="k">Responses</div><div class="v">${escapeHtml(String(responses.length))}</div></div>
112
+ <div class="card"><div class="k">Final Status</div><div class="v ok">${escapeHtml(finalStatus)}</div></div>
113
+ </div>
114
+
115
+ <h2>Token Usage</h2>
116
+ <div class="section">
117
+ <div class="tokens">${escapeHtml(tokenLine)}</div>
118
+ <div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
119
+ </div>
120
+
121
+ <h2>Pass 1 — Triage</h2>
122
+ <div class="section">
123
+ <div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
124
+ <pre>${escapeHtml(triageContentPretty)}</pre>
125
+ </div>
126
+
127
+ <h2>Pass 2 — File Reviews</h2>
128
+ ${fileServerLabel == null
129
+ ? ""
130
+ : `<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>`}
131
+ ${fileCiLabel == null
132
+ ? ""
133
+ : `<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>`}
134
+
135
+ <h2>Pass 3 — Consolidation</h2>
136
+ <div class="section">
137
+ <div class="row"><span class="badge">label: ${escapeHtml(consolidateLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(consolidateLabel))}</span></div>
138
+ ${renderFindings(getContent(consolidateLabel))}
139
+ </div>
140
+
141
+ <h2>Pass 4 — Verification</h2>
142
+ <div class="section">
143
+ <div class="row"><span class="badge">label: ${escapeHtml(verificationLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(verificationLabel))}</span></div>
144
+ ${renderFindings(getContent(verificationLabel))}
145
+ </div>
146
+ </div>
147
+ </body>
148
+ </html>`;
149
+ await writeFile(artifactHtmlFile, html, "utf8");
150
+ }
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-debug.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) => {
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.26",
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
  }