@krotovm/gitlab-ai-review 1.0.33 → 1.0.34

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
@@ -75,6 +75,7 @@ GitLab provides these automatically in Merge Request pipelines:
75
75
  - `--max-diffs=50` - Max number of diffs included in the prompt.
76
76
  - `--max-diff-chars=16000` - Max chars per diff chunk (single-pass fallback only).
77
77
  - `--max-total-prompt-chars=220000` - Final hard cap for prompt size (single-pass fallback only).
78
+ - `--triage-diff-chars=2000` - Max chars per file diff sent to the triage pass (Pass 1). Increase for large diffs where the first hunks are mostly git headers.
78
79
  - `--max-findings=5` - Max findings in the final review (CI multi-pass only).
79
80
  - `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
80
81
  - `--debug` - Print full error details (stack and API error fields).
@@ -85,8 +86,8 @@ GitLab provides these automatically in Merge Request pipelines:
85
86
 
86
87
  The reviewer uses a three-pass pipeline optimized for large merge requests:
87
88
 
88
- 1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP` and generates a short MR summary.
89
- 2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed, each in a dedicated LLM call running in parallel (with tools to fetch full files or grep the repository).
89
+ 1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP`, with a per-file `reason`, and generates a short MR summary. Each file diff is truncated to `--triage-diff-chars` (default 2000).
90
+ 2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed (if triage marks every file `SKIP`, all files are reviewed anyway). Each reviewed file gets a dedicated LLM call running in parallel (with tools to fetch full files or grep the repository).
90
91
  3. **Consolidate** - Per-file findings are merged, deduplicated, ranked by severity, and trimmed to top N (default 5).
91
92
 
92
93
  If the triage pass fails (API error, unparseable response), the pipeline falls back to the original single-pass approach automatically.
package/dist/cli/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** @format */
2
- import { DEFAULT_PROMPT_LIMITS, parsePromptProfile as parsePromptProfileValue, } from "../prompt/index.js";
2
+ import { DEFAULT_PROMPT_LIMITS, DEFAULT_TRIAGE_DIFF_CHARS, parsePromptProfile as parsePromptProfileValue, } from "../prompt/index.js";
3
3
  export function requireEnvs(names) {
4
4
  const missing = names.filter((name) => {
5
5
  const value = process.env[name];
@@ -87,6 +87,9 @@ export function parsePromptLimits(argv) {
87
87
  maxTotalPromptChars: parseNumberFlag(argv, "max-total-prompt-chars", DEFAULT_PROMPT_LIMITS.maxTotalPromptChars, 1),
88
88
  };
89
89
  }
90
+ export function parseTriageDiffChars(argv) {
91
+ return parseNumberFlag(argv, "triage-diff-chars", DEFAULT_TRIAGE_DIFF_CHARS, 1);
92
+ }
90
93
  export function hasIgnoredExtension(filePath, ignoredExtensions) {
91
94
  const lowerPath = filePath.toLowerCase();
92
95
  return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
@@ -3,6 +3,51 @@ import OpenAI from "openai";
3
3
  import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, 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, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
6
+ function buildTriageDecisions(params) {
7
+ const { changes, triageResult } = params;
8
+ const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
9
+ let reviewFiles = changes.filter((c) => triageMap.get(c.new_path)?.verdict !== "SKIP");
10
+ const skipAllOverride = reviewFiles.length === 0;
11
+ if (skipAllOverride)
12
+ reviewFiles = changes;
13
+ const decisions = changes.map((change) => {
14
+ const path = change.new_path;
15
+ const triageFile = triageMap.get(path);
16
+ const verdict = triageFile?.verdict ?? "NEEDS_REVIEW";
17
+ const reason = triageFile?.reason ??
18
+ (triageFile == null ? "not listed in triage response" : undefined);
19
+ let review_action;
20
+ if (verdict === "NEEDS_REVIEW" || triageFile == null) {
21
+ review_action = "review";
22
+ }
23
+ else if (skipAllOverride) {
24
+ review_action = "reviewed_via_override";
25
+ }
26
+ else {
27
+ review_action = "skipped";
28
+ }
29
+ return {
30
+ path,
31
+ verdict,
32
+ ...(reason != null ? { reason } : {}),
33
+ review_action,
34
+ };
35
+ });
36
+ return { decisions, skipAllOverride, reviewFiles };
37
+ }
38
+ function formatTriageDecisionLine(decision) {
39
+ const reasonSuffix = decision.reason != null && decision.reason !== ""
40
+ ? ` — ${decision.reason}`
41
+ : "";
42
+ switch (decision.review_action) {
43
+ case "skipped":
44
+ return ` Skipped: ${decision.path} (${decision.verdict})${reasonSuffix}`;
45
+ case "reviewed_via_override":
46
+ return ` Reviewed via override: ${decision.path} (triage=${decision.verdict})${reasonSuffix}`;
47
+ default:
48
+ return ` Review: ${decision.path} (${decision.verdict})${reasonSuffix}`;
49
+ }
50
+ }
6
51
  async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
7
52
  const withTs = { ts: new Date().toISOString(), ...record };
8
53
  if (debugRecordWriter != null) {
@@ -592,9 +637,10 @@ async function runVerificationWithTools(params) {
592
637
  });
593
638
  }
594
639
  export async function reviewMergeRequestMultiPass(params) {
595
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
640
+ const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
596
641
  const { logStep } = loggers;
597
- logStep(`Pass 1/4: triaging ${changes.length} file(s) (prompt profile=${promptProfile})`);
642
+ logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
643
+ `(prompt profile=${promptProfile}, triage_diff_chars=${triageDiffChars})`);
598
644
  const triageInputs = changes.map((c) => ({
599
645
  path: c.new_path,
600
646
  new_file: c.new_file,
@@ -602,7 +648,7 @@ export async function reviewMergeRequestMultiPass(params) {
602
648
  renamed_file: c.renamed_file,
603
649
  diff: c.diff,
604
650
  }));
605
- const triageMessages = buildTriagePrompt(triageInputs, promptProfile);
651
+ const triageMessages = buildTriagePrompt(triageInputs, promptProfile, triageDiffChars);
606
652
  let triageResult = null;
607
653
  let triageText = null;
608
654
  let triageParseReason = null;
@@ -662,16 +708,29 @@ export async function reviewMergeRequestMultiPass(params) {
662
708
  debugRecordWriter,
663
709
  });
664
710
  }
665
- const triageMap = new Map(triageResult.files.map((f) => [f.path, f.verdict]));
666
- let reviewFiles = changes.filter((c) => triageMap.get(c.new_path) !== "SKIP");
667
- const skippedCount = changes.length - reviewFiles.length;
668
- if (reviewFiles.length === 0) {
711
+ const { decisions, skipAllOverride, reviewFiles } = buildTriageDecisions({
712
+ changes,
713
+ triageResult,
714
+ });
715
+ const skippedCount = decisions.filter((d) => d.review_action === "skipped").length;
716
+ await appendDebugDump(debugDumpFile, debugRecordWriter, {
717
+ kind: "triage_decision",
718
+ summary: triageResult.summary,
719
+ skip_all_override: skipAllOverride,
720
+ triage_diff_chars: triageDiffChars,
721
+ files: decisions,
722
+ });
723
+ if (skipAllOverride) {
669
724
  logStep(`Triage wanted to skip all ${changes.length} file(s) — overriding to review all. Summary: ${triageResult.summary.slice(0, 120)}...`);
670
- reviewFiles = changes;
671
725
  }
672
726
  else {
673
727
  logStep(`Triage: ${reviewFiles.length} file(s) to review, ${skippedCount} skipped. Summary: ${triageResult.summary.slice(0, 120)}...`);
674
728
  }
729
+ for (const decision of decisions) {
730
+ if (decision.review_action === "review")
731
+ continue;
732
+ logStep(formatTriageDecisionLine(decision));
733
+ }
675
734
  logStep(`Pass 2/4: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
676
735
  const allChangedPaths = changes.map((c) => c.new_path);
677
736
  const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
@@ -50,6 +50,58 @@ export async function renderDebugArtifactsHtml(params) {
50
50
  return value;
51
51
  }
52
52
  }
53
+ function renderTriageDecisions() {
54
+ const decisionRecord = records.find((r) => r.kind === "triage_decision");
55
+ if (decisionRecord == null)
56
+ return "<pre>No triage decision record</pre>";
57
+ const summary = typeof decisionRecord.summary === "string" ? decisionRecord.summary : "";
58
+ const skipAllOverride = decisionRecord.skip_all_override === true;
59
+ const triageDiffChars = Number(decisionRecord.triage_diff_chars ?? 0);
60
+ const files = Array.isArray(decisionRecord.files) ? decisionRecord.files : [];
61
+ const actionLabel = (action) => {
62
+ switch (action) {
63
+ case "skipped":
64
+ return "Skipped";
65
+ case "reviewed_via_override":
66
+ return "Reviewed (override)";
67
+ case "review":
68
+ return "Review";
69
+ default:
70
+ return String(action ?? "unknown");
71
+ }
72
+ };
73
+ const rows = files
74
+ .map((file) => {
75
+ const path = typeof file.path === "string" ? file.path : "?";
76
+ const verdict = typeof file.verdict === "string" ? file.verdict : "?";
77
+ const reason = typeof file.reason === "string" ? file.reason : "";
78
+ const action = actionLabel(file.review_action);
79
+ return `<tr>
80
+ <td><code>${escapeHtml(path)}</code></td>
81
+ <td>${escapeHtml(verdict)}</td>
82
+ <td>${escapeHtml(action)}</td>
83
+ <td>${escapeHtml(reason)}</td>
84
+ </tr>`;
85
+ })
86
+ .join("\n");
87
+ const overrideNote = skipAllOverride
88
+ ? `<p class="tokens" style="margin:0 0 10px;color:var(--med);">All files were marked SKIP — pipeline overrode and reviewed every file.</p>`
89
+ : "";
90
+ return `${overrideNote}
91
+ <p class="tokens" style="margin:0 0 10px;">triage_diff_chars=${escapeHtml(String(triageDiffChars))}</p>
92
+ <p style="margin:0 0 12px;">${escapeHtml(summary)}</p>
93
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">
94
+ <thead>
95
+ <tr style="text-align:left;color:var(--muted);">
96
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">File</th>
97
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Verdict</th>
98
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Action</th>
99
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Reason</th>
100
+ </tr>
101
+ </thead>
102
+ <tbody>${rows}</tbody>
103
+ </table>`;
104
+ }
53
105
  function renderFindings(markdown) {
54
106
  const trimmed = markdown.trim();
55
107
  if (trimmed === "")
@@ -144,7 +196,12 @@ export async function renderDebugArtifactsHtml(params) {
144
196
  <div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
145
197
  </div>
146
198
 
147
- <h2>Pass 1 — Triage</h2>
199
+ <h2>Pass 1 — Triage Decisions</h2>
200
+ <div class="section">
201
+ ${renderTriageDecisions()}
202
+ </div>
203
+
204
+ <h2>Pass 1 — Triage (raw model output)</h2>
148
205
  <div class="section">
149
206
  <div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
150
207
  <pre>${escapeHtml(triageContentPretty)}</pre>
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
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, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
6
+ import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, parseTriageDiffChars, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
7
7
  import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
8
8
  import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
9
9
  import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
@@ -25,6 +25,7 @@ function printHelp() {
25
25
  " --max-diffs=50",
26
26
  " --max-diff-chars=16000",
27
27
  " --max-total-prompt-chars=220000",
28
+ " --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).",
28
29
  " --max-findings=5 Max findings in final review (CI multi-pass only).",
29
30
  " --max-review-concurrency=5 Parallel per-file review calls (CI multi-pass only).",
30
31
  "",
@@ -77,6 +78,7 @@ async function main() {
77
78
  logStep(`gitlab-ai-review v${cliVersion}`);
78
79
  const ignoredExtensions = parseIgnoreExtensions(process.argv);
79
80
  const promptLimits = parsePromptLimits(process.argv);
81
+ const triageDiffChars = parseTriageDiffChars(process.argv);
80
82
  const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
81
83
  const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
82
84
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
@@ -134,6 +136,7 @@ async function main() {
134
136
  openaiInstance: new OpenAI({ apiKey: openaiApiKey }),
135
137
  aiModel,
136
138
  promptLimits,
139
+ triageDiffChars,
137
140
  changes: filteredChanges,
138
141
  refs: {
139
142
  base: mrChanges.diff_refs?.base_sha ?? "HEAD",
@@ -10,6 +10,8 @@ export const DEFAULT_PROMPT_LIMITS = {
10
10
  maxDiffChars: 16000,
11
11
  maxTotalPromptChars: 220000,
12
12
  };
13
+ /** Max chars of each file diff sent to the triage pass (Pass 1). */
14
+ export const DEFAULT_TRIAGE_DIFF_CHARS = 2000;
13
15
  export const AI_MODEL_TEMPERATURE = 0.2;
14
16
  export const AI_MAX_OUTPUT_TOKENS = 600;
15
17
  export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
@@ -52,12 +54,12 @@ export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEF
52
54
  // ---------------------------------------------------------------------------
53
55
  export const DEFAULT_MAX_FINDINGS = 5;
54
56
  export const DEFAULT_REVIEW_CONCURRENCY = 5;
55
- export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE) {
57
+ export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE, triageDiffChars = DEFAULT_TRIAGE_DIFF_CHARS) {
56
58
  return [
57
59
  buildTriageSystemMessage(profile),
58
60
  {
59
61
  role: "user",
60
- content: buildTriageUserContent(changes),
62
+ content: buildTriageUserContent(changes, triageDiffChars),
61
63
  },
62
64
  ];
63
65
  }
@@ -115,6 +117,9 @@ export function parseTriageResponseDetailed(text) {
115
117
  .map((f) => ({
116
118
  path: f.path,
117
119
  verdict: f.verdict,
120
+ ...(typeof f.reason === "string" && f.reason.trim() !== ""
121
+ ? { reason: f.reason.trim() }
122
+ : {}),
118
123
  })),
119
124
  },
120
125
  reason: null,
@@ -6,7 +6,9 @@ const DEFAULT_LINES = [
6
6
  "Also produce a concise summary (2-4 sentences) of the entire merge request: what it does and which areas it touches.",
7
7
  "",
8
8
  "Respond with a JSON object (no markdown fences, no prose before or after) in this exact schema:",
9
- '{ "summary": "<MR summary>", "files": [{ "path": "<file path>", "verdict": "NEEDS_REVIEW" | "SKIP" }] }',
9
+ '{ "summary": "<MR summary>", "files": [{ "path": "<file path>", "verdict": "NEEDS_REVIEW" | "SKIP", "reason": "<one short sentence explaining the verdict>" }] }',
10
+ "",
11
+ "The reason field is required for every file — cite what you saw in the diff (e.g. \"import reordering only\" or \"changes Input props and validation\").",
10
12
  "",
11
13
  "Rules:",
12
14
  "- When in doubt, verdict is NEEDS_REVIEW.",
@@ -20,13 +22,13 @@ const DEFAULT_LINES = [
20
22
  "- Config/CI/docs files are SKIP unless they modify build targets, env vars, or secrets.",
21
23
  "",
22
24
  "Example output (copy this style exactly — single JSON object, no fences, no preamble):",
23
- '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW"},{"path":"README.md","verdict":"SKIP"}]}',
25
+ '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW","reason":"adds retry loop and error handling in API client"},{"path":"README.md","verdict":"SKIP","reason":"badge URL change only"}]}',
24
26
  ];
25
27
  const WEAK_LINES = [
26
28
  "You triage merge-request files. Output one JSON object only — no prose, no markdown, no code fences, no preamble.",
27
29
  "",
28
30
  "Schema (use exactly these keys):",
29
- '{"summary":"<2-3 sentences about the MR>","files":[{"path":"<file path>","verdict":"NEEDS_REVIEW" | "SKIP"}]}',
31
+ '{"summary":"<2-3 sentences about the MR>","files":[{"path":"<file path>","verdict":"NEEDS_REVIEW" | "SKIP","reason":"<one short sentence>"}]}',
30
32
  "",
31
33
  "Mark a file NEEDS_REVIEW when ANY of these is true:",
32
34
  "- the diff adds or removes non-comment lines,",
@@ -39,7 +41,7 @@ const WEAK_LINES = [
39
41
  "When in doubt, choose NEEDS_REVIEW.",
40
42
  "",
41
43
  "Example output (copy this exactly — one line, one JSON object, nothing else):",
42
- '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW"},{"path":"README.md","verdict":"SKIP"}]}',
44
+ '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW","reason":"adds retry loop in API client"},{"path":"README.md","verdict":"SKIP","reason":"badge URL only"}]}',
43
45
  ];
44
46
  export function getTriageSystemLines(profile) {
45
47
  return profile === "weak" ? WEAK_LINES : DEFAULT_LINES;
@@ -1,4 +1,5 @@
1
1
  /** @format */
2
+ import { truncateWithMarker } from "../utils.js";
2
3
  export function buildMainReviewUserContent(params) {
3
4
  const { stats, toolNote, changesText } = params;
4
5
  return [
@@ -11,7 +12,7 @@ export function buildMainReviewUserContent(params) {
11
12
  "Produce your review now. Follow the system instructions strictly.",
12
13
  ].join("\n");
13
14
  }
14
- export function buildTriageUserContent(changes) {
15
+ export function buildTriageUserContent(changes, maxDiffChars) {
15
16
  const fileEntries = changes.map((c) => {
16
17
  const flags = [];
17
18
  if (c.new_file)
@@ -21,9 +22,8 @@ export function buildTriageUserContent(changes) {
21
22
  if (c.renamed_file)
22
23
  flags.push("renamed");
23
24
  const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
24
- const snippet = c.diff.slice(0, 300);
25
- const truncNote = c.diff.length > 300 ? "..." : "";
26
- return `### ${c.path}${flagStr}\n\`\`\`\n${snippet}${truncNote}\n\`\`\``;
25
+ const snippet = truncateWithMarker(c.diff, maxDiffChars, c.path);
26
+ return `### ${c.path}${flagStr}\n\`\`\`diff\n${snippet}\n\`\`\``;
27
27
  });
28
28
  return `Triage these ${changes.length} file(s):\n\n${fileEntries.join("\n\n")}`;
29
29
  }
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.33",
4
+ "version": "1.0.34",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {