@krotovm/gitlab-ai-review 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,24 @@ ai_review:
30
30
  - npx -y @krotovm/gitlab-ai-review
31
31
  ```
32
32
 
33
+ Save debug HTML as a CI artifact:
34
+
35
+ ```yaml
36
+ stages: [review]
37
+
38
+ ai_review:
39
+ stage: review
40
+ image: node:20
41
+ rules:
42
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
43
+ script:
44
+ - npx -y @krotovm/gitlab-ai-review --include-artifacts
45
+ artifacts:
46
+ expire_in: 7 days
47
+ paths:
48
+ - ai-review-report.html
49
+ ```
50
+
33
51
  ## Env variables
34
52
 
35
53
  Set these in your project/group CI settings:
@@ -39,7 +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`)
42
- - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `.ai-review-debug.html`; used with `--include-artifacts`)
60
+ - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
43
61
 
44
62
  `OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
45
63
 
@@ -2,7 +2,7 @@
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
6
  async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
7
7
  const withTs = { ts: new Date().toISOString(), ...record };
8
8
  if (debugRecordWriter != null) {
@@ -447,6 +447,144 @@ async function runFileReviewWithTools(params) {
447
447
  });
448
448
  return extractCompletionText(final) ?? "No issues found.";
449
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
+ }
450
588
  export async function reviewMergeRequestMultiPass(params) {
451
589
  const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
452
590
  const { logStep } = loggers;
@@ -555,25 +693,28 @@ export async function reviewMergeRequestMultiPass(params) {
555
693
  if (consolidatedText == null || consolidatedText.trim() === "") {
556
694
  return buildAnswer(consolidateCompletion);
557
695
  }
558
- logStep("Pass 4/4: verifying consolidated findings");
696
+ logStep("Pass 4/4: verifying consolidated findings (repo tools)");
559
697
  const verificationMessages = buildVerificationPrompt({
560
698
  perFileFindings,
561
699
  summary: triageResult.summary,
562
700
  consolidatedFindings: consolidatedText,
563
701
  maxFindings,
702
+ refs,
564
703
  });
565
704
  try {
566
- const verificationCompletion = await createCompletionWithDebug({
705
+ const verificationCompletion = await runVerificationWithTools({
567
706
  openaiInstance,
568
- requestLabel: "verification_pass",
707
+ aiModel,
708
+ baseMessages: verificationMessages,
709
+ refs,
710
+ gitLabProjectApiUrl,
711
+ projectId,
712
+ headers,
713
+ forceTools,
714
+ consolidatedDraft: consolidatedText,
715
+ loggers,
569
716
  debugDumpFile,
570
717
  debugRecordWriter,
571
- request: {
572
- model: aiModel,
573
- temperature: 0.0,
574
- stream: false,
575
- messages: verificationMessages,
576
- },
577
718
  });
578
719
  return buildAnswer(verificationCompletion);
579
720
  }
@@ -76,8 +76,34 @@ export async function renderDebugArtifactsHtml(params) {
76
76
  const fileServerLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_server.js_round_"));
77
77
  const fileCiLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_.gitlab-ci.yml_round_"));
78
78
  const consolidateLabel = "consolidate_pass";
79
- const verificationLabel = "verification_pass";
80
- const finalStatus = getContent(verificationLabel).trim() !== "" ? "Verified" : "Fallback";
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";
81
107
  const html = `<!doctype html>
82
108
  <html lang="en">
83
109
  <head>
@@ -141,7 +167,7 @@ export async function renderDebugArtifactsHtml(params) {
141
167
  <h2>Pass 4 — Verification</h2>
142
168
  <div class="section">
143
169
  <div class="row"><span class="badge">label: ${escapeHtml(verificationLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(verificationLabel))}</span></div>
144
- ${renderFindings(getContent(verificationLabel))}
170
+ ${renderFindings(verificationSection.content)}
145
171
  </div>
146
172
  </div>
147
173
  </body>
@@ -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
@@ -78,7 +78,7 @@ async function main() {
78
78
  const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
79
79
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
80
80
  const artifactHtmlFile = INCLUDE_ARTIFACTS
81
- ? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", ".ai-review-debug.html")
81
+ ? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", "ai-review-report.html")
82
82
  : undefined;
83
83
  const artifactRecords = [];
84
84
  const debugRecordWriter = INCLUDE_ARTIFACTS
@@ -126,7 +126,7 @@ export function buildConsolidatePrompt(params) {
126
126
  ];
127
127
  }
128
128
  export function buildVerificationPrompt(params) {
129
- const { perFileFindings, summary, consolidatedFindings, maxFindings } = params;
129
+ const { perFileFindings, summary, consolidatedFindings, maxFindings, refs, } = params;
130
130
  const findingsText = perFileFindings
131
131
  .map((f) => `### ${f.path}\n${f.findings}`)
132
132
  .join("\n\n");
@@ -141,6 +141,7 @@ export function buildVerificationPrompt(params) {
141
141
  summary,
142
142
  findingsText,
143
143
  consolidatedFindings,
144
+ refs,
144
145
  }),
145
146
  },
146
147
  ];
@@ -20,8 +20,10 @@ export function buildVerificationSystemLines(maxFindings) {
20
20
  return [
21
21
  "You are a skeptical verifier of a merge request review.",
22
22
  "Your job is to remove weak, speculative, or unsupported findings from the draft list.",
23
+ "Tools get_file_at_ref and grep_repository are available. Use them to check claims about current code against the repository at the MR head ref.",
24
+ "Drop a finding if file contents at refs.head contradict it, or if it cannot be verified after reasonable tool use.",
23
25
  "Do not add new findings. Keep, rewrite for clarity, or remove existing findings only.",
24
- "A finding can stay only if it is directly supported by evidence from the per-file findings.",
26
+ "A finding can stay only if supported by the per-file evidence pool and not contradicted by tools when the claim is about code that exists at refs.head.",
25
27
  "If confidence is not high, drop the finding.",
26
28
  "Preserve this exact per-finding markdown block:",
27
29
  "`- [high|medium] <title>`",
@@ -57,10 +57,12 @@ export function buildConsolidateUserContent(params) {
57
57
  ].join("\n");
58
58
  }
59
59
  export function buildVerificationUserContent(params) {
60
- const { summary, findingsText, consolidatedFindings } = params;
60
+ const { summary, findingsText, consolidatedFindings, refs } = params;
61
61
  return [
62
62
  `MR Summary: ${summary}`,
63
63
  "",
64
+ `Refs for tools: head (post-change)="${refs.head}", base="${refs.base}". Prefer head when checking whether the issue exists in the MR.`,
65
+ "",
64
66
  "Per-file findings (evidence pool):",
65
67
  findingsText,
66
68
  "",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@krotovm/gitlab-ai-review",
4
- "version": "1.0.26",
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": {