@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 +20 -0
- package/dist/cli/args.js +4 -0
- package/dist/cli/ci-review.js +332 -56
- package/dist/cli/debug-artifacts-html.js +176 -0
- package/dist/cli/tooling.js +2 -0
- package/dist/cli.js +22 -1
- package/dist/prompt/index.js +2 -1
- package/dist/prompt/templates/postprocess-system.js +3 -1
- package/dist/prompt/templates/user-prompts.js +3 -1
- package/package.json +4 -4
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);
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -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({
|
|
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,176 @@ 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
|
}
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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("&", "&")
|
|
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
|
+
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
|
+
}
|
package/dist/cli/tooling.js
CHANGED
|
@@ -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) => {
|
package/dist/prompt/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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": "^
|
|
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
|
}
|