@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 +2 -0
- package/dist/cli/args.js +4 -0
- package/dist/cli/ci-review.js +189 -54
- package/dist/cli/debug-artifacts-html.js +150 -0
- package/dist/cli.js +22 -1
- package/package.json +4 -4
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);
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -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({
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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 (
|
|
261
|
+
if (toolName === TOOL_NAME_GET_FILE) {
|
|
196
262
|
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
197
263
|
}
|
|
198
|
-
else if (
|
|
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 "${
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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 (
|
|
403
|
+
if (toolName === TOOL_NAME_GET_FILE) {
|
|
306
404
|
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
307
405
|
}
|
|
308
|
-
else if (
|
|
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 "${
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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("&", "&")
|
|
6
|
+
.replaceAll("<", "<")
|
|
7
|
+
.replaceAll(">", ">")
|
|
8
|
+
.replaceAll('"', """)
|
|
9
|
+
.replaceAll("'", "'");
|
|
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.
|
|
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": "^
|
|
40
|
+
"openai": "^6.33.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^
|
|
44
|
-
"typescript": "^
|
|
43
|
+
"@types/node": "^25.5.0",
|
|
44
|
+
"typescript": "^6.0.2"
|
|
45
45
|
},
|
|
46
46
|
"packageManager": "pnpm@8.15.4+sha256.cea6d0bdf2de3a0549582da3983c70c92ffc577ff4410cbf190817ddc35137c2"
|
|
47
47
|
}
|