@krotovm/gitlab-ai-review 1.0.38 → 1.0.40

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
@@ -58,6 +58,7 @@ Set these in your project/group CI settings:
58
58
  - `AI_PROMPT_PROFILE` (optional, default: `default`; one of `default` \| `weak`). Use `weak` for small or heavily-quantized models (≤ ~7B, local Ollama, etc.). The `weak` profile uses shorter system prompts, positive rules instead of negations, and inline few-shot examples for triage / per-file review / consolidation / verification, which dramatically improves output-format adherence on weak models. When the variable is not set, the profile is auto-detected: if the triage pass returns unparseable JSON with `default` prompts, triage is retried once with `weak` and, on success, the whole run continues with it. Set the variable explicitly to pin a profile and disable auto-detection.
59
59
  - `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
60
60
  - `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
61
+ - `AI_REVIEW_CONCURRENCY` (optional) — same as `--max-review-concurrency`, handy as a CI/CD variable; the flag wins when both are set.
61
62
  - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
62
63
 
63
64
  `OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
@@ -77,11 +78,29 @@ GitLab provides these automatically in Merge Request pipelines:
77
78
  - `--max-total-prompt-chars=220000` - Final hard cap for prompt size (single-pass fallback only).
78
79
  - `--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.
79
80
  - `--max-findings=5` - Max findings in the final review (CI multi-pass only).
80
- - `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
81
+ - `--max-review-concurrency=2` - Parallel per-file review API calls (CI multi-pass only). Set to 1 for single-GPU backends that queue-starve long requests.
81
82
  - `--debug` - Print full error details (stack and API error fields).
82
83
  - `--include-artifacts` - Generate a local HTML debug artifact with per-pass outputs/tokens.
83
84
  - `--help` - Show help output.
84
85
 
86
+ ## Benchmark
87
+
88
+ Head-to-head against [Alibaba OpenCodeReview](https://github.com/alibaba/open-code-review) v1.8.10 (via its official GitLab CI recipe) on a demo MR with 4 seeded bugs: an auth bypass (`API_KEYS` unset → empty key accepted), a rate limiter whose window never resets, an unbounded per-IP counters Map, and a null-deref crash in a stats endpoint. Same MR, same commit range, same backends.
89
+
90
+ | Tool | Model | Seeded bugs found | Noise (dups / off-target) | Tokens | LLM time |
91
+ | --- | --- | --- | --- | --- | --- |
92
+ | **gitlab-ai-review** | gpt-5.4 | 3 / 4 | 0 | 15.7k | ~17s |
93
+ | OpenCodeReview | gpt-5.4 | 3 / 4 | 2 | 41.5k | ~11s |
94
+ | **gitlab-ai-review** | self-hosted (single GPU) | **4 / 4** | **0** | 37.2k | 7m 24s |
95
+ | OpenCodeReview | self-hosted (single GPU) | 3 / 4 | 8 (incl. 5 duplicates) | 151.8k | 11m 45s |
96
+
97
+ Notes:
98
+
99
+ - On gpt-5.4 both tools found the same 3 bugs and both missed the memory leak. OpenCodeReview added two design opinions, one of which (severity "high") suggested removing the very auth feature the MR introduces.
100
+ - On the self-hosted model the multi-pass pipeline (triage → per-file review → consolidate → verify) paid off: all 4 seeded bugs, zero noise. OpenCodeReview posted 11 comments: 3 unique real findings, 5 duplicates, and 3 off-target — its only rate-limiter comment misdiagnosed the bug, and several comments were attributed to the wrong file or line.
101
+ - Weak-model reviews are output-bound on a single GPU: OpenCodeReview generated 40.2k output tokens vs our 18.5k, and consumed 6× our prompt tokens (111.6k vs 18.7k) — that difference is the wall-clock gap.
102
+ - Methodology caveats: single run per cell on one MR; LLM variance applies.
103
+
85
104
  ## Architecture
86
105
 
87
106
  The reviewer uses a three-pass pipeline optimized for large merge requests:
@@ -7,6 +7,9 @@ import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFIC
7
7
  /** Hard deadline per model call, retries included. Must exceed the gateway's own
8
8
  * proxy timeout so the SDK's 5xx retries stay the first line of defense. */
9
9
  const COMPLETION_TIMEOUT_MS = 180_000;
10
+ /** Whether to request token usage in streamed completions. Flipped off for the
11
+ * rest of the run the first time a gateway rejects stream_options with a 400. */
12
+ let streamUsageSupported = true;
10
13
  /** Tool-result caps. Every tool response is re-sent as prompt on each following
11
14
  * round, so oversized results multiply prefill time on self-hosted models. */
12
15
  const MAX_TOOL_FILE_CHARS = 12_000;
@@ -74,12 +77,27 @@ async function createCompletionWithDebug(params) {
74
77
  // The SDK's own timeout only covers time-to-headers; a stalled SSE body would hang
75
78
  // forever without the explicit AbortSignal deadline.
76
79
  const { stream: _stream, ...createParams } = request;
80
+ const attempt = (withUsage) => openaiInstance.chat.completions
81
+ .stream(withUsage
82
+ ? { ...createParams, stream_options: { include_usage: true } }
83
+ : createParams, { signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS) })
84
+ .finalChatCompletion();
77
85
  try {
78
- const completion = await openaiInstance.chat.completions
79
- .stream(createParams, {
80
- signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS),
81
- })
82
- .finalChatCompletion();
86
+ let completion;
87
+ try {
88
+ completion = await attempt(streamUsageSupported);
89
+ }
90
+ catch (error) {
91
+ // Strict OpenAI-compatible gateways 400 on unknown params. Drop the
92
+ // usage request for the rest of the run and retry this call once.
93
+ const rejectedStreamOptions = streamUsageSupported &&
94
+ error?.status === 400 &&
95
+ String(error?.message ?? "").includes("stream_options");
96
+ if (!rejectedStreamOptions)
97
+ throw error;
98
+ streamUsageSupported = false;
99
+ completion = await attempt(false);
100
+ }
83
101
  await appendDebugDump(debugDumpFile, debugRecordWriter, {
84
102
  kind: "openai_response",
85
103
  label: requestLabel,
@@ -1,5 +1,6 @@
1
1
  /** @format */
2
2
  import { writeFile } from "node:fs/promises";
3
+ import { parseReviewFindings } from "../prompt/utils.js";
3
4
  function escapeHtml(value) {
4
5
  return value
5
6
  .replaceAll("&", "&")
@@ -11,31 +12,94 @@ function escapeHtml(value) {
11
12
  export async function renderDebugArtifactsHtml(params) {
12
13
  const { records, artifactHtmlFile, cliVersion, aiModel } = params;
13
14
  const responses = records.filter((r) => r.kind === "openai_response");
15
+ const requests = records.filter((r) => r.kind === "openai_request");
16
+ const errors = records.filter((r) => r.kind === "openai_error");
14
17
  const byLabel = new Map();
15
18
  for (const response of responses) {
16
19
  if (typeof response.label === "string")
17
20
  byLabel.set(response.label, response);
18
21
  }
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(" • ");
22
+ const requestTsByLabel = new Map();
23
+ for (const request of requests) {
24
+ if (typeof request.label === "string" && !requestTsByLabel.has(request.label))
25
+ requestTsByLabel.set(request.label, String(request.ts ?? ""));
26
+ }
27
+ function usageOf(record) {
28
+ const u = record?.response?.usage;
29
+ if (u == null)
30
+ return null;
31
+ return {
32
+ prompt: Number(u.prompt_tokens ?? 0),
33
+ completion: Number(u.completion_tokens ?? 0),
34
+ total: Number(u.total_tokens ?? 0),
35
+ };
36
+ }
37
+ function sumUsage(labels) {
38
+ const usage = { prompt: 0, completion: 0, total: 0 };
39
+ let reported = 0;
40
+ for (const label of labels) {
41
+ const u = usageOf(byLabel.get(label));
42
+ if (u == null)
43
+ continue;
44
+ reported += 1;
45
+ usage.prompt += u.prompt;
46
+ usage.completion += u.completion;
47
+ usage.total += u.total;
48
+ }
49
+ return { usage, reported };
50
+ }
51
+ function tokensText(labels) {
52
+ const { usage, reported } = sumUsage(labels);
53
+ if (reported === 0)
54
+ return "tokens: not reported by backend";
55
+ return `prompt: ${usage.prompt.toLocaleString()} • completion: ${usage.completion.toLocaleString()} • total: ${usage.total.toLocaleString()}`;
56
+ }
57
+ function parseTs(value) {
58
+ const ms = Date.parse(String(value ?? ""));
59
+ return Number.isFinite(ms) ? ms : null;
60
+ }
61
+ function durationText(labels) {
62
+ const starts = labels
63
+ .map((label) => parseTs(requestTsByLabel.get(label)))
64
+ .filter((n) => n != null);
65
+ const ends = labels
66
+ .map((label) => parseTs(byLabel.get(label)?.ts))
67
+ .filter((n) => n != null);
68
+ if (starts.length === 0 || ends.length === 0)
69
+ return "";
70
+ return `${((Math.max(...ends) - Math.min(...starts)) / 1000).toFixed(1)}s`;
71
+ }
72
+ const allLabels = Array.from(byLabel.keys());
73
+ const triageLabels = allLabels.filter((l) => l.startsWith("triage_pass"));
74
+ const consolidateLabels = allLabels.filter((l) => l === "consolidate_pass");
75
+ const verificationLabels = allLabels.filter((l) => l.startsWith("verification_pass"));
76
+ const mainLabels = allLabels.filter((l) => l.startsWith("main_review"));
77
+ // Per-file review request groups, in first-seen (chronological) order.
78
+ const fileGroups = new Map();
79
+ const fileLabelRe = /^file_review_(.+?)_(?:round_\d+|final_after_tool_limit)$/;
80
+ for (const response of responses) {
81
+ const label = typeof response.label === "string" ? response.label : "";
82
+ const match = label.match(fileLabelRe);
83
+ if (match == null)
84
+ continue;
85
+ const group = fileGroups.get(match[1]) ?? [];
86
+ group.push(label);
87
+ fileGroups.set(match[1], group);
88
+ }
27
89
  function getContent(label) {
28
90
  const content = byLabel.get(label)?.response?.choices?.[0]?.message?.content;
29
91
  return typeof content === "string" ? content : "";
30
92
  }
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 ?? "");
93
+ function lastContent(labels) {
94
+ const finalLabel = labels.find((l) => l.endsWith("_final_after_tool_limit"));
95
+ if (finalLabel != null && getContent(finalLabel).trim() !== "")
96
+ return getContent(finalLabel);
97
+ for (let i = labels.length - 1; i >= 0; i -= 1) {
98
+ const content = getContent(labels[i]);
99
+ if (content.trim() !== "")
100
+ return content;
101
+ }
102
+ return "";
39
103
  }
40
104
  function formatAsPrettyJsonIfPossible(value) {
41
105
  const trimmed = value.trim();
@@ -106,28 +170,22 @@ export async function renderDebugArtifactsHtml(params) {
106
170
  const trimmed = markdown.trim();
107
171
  if (trimmed === "")
108
172
  return "<pre>No data</pre>";
109
- const blocks = trimmed.split(/\n\s*\n/);
110
- const items = [];
111
- for (const block of blocks) {
112
- const lines = block.split("\n").map((l) => l.trimEnd());
113
- const title = lines[0] ?? "";
114
- const file = lines.find((l) => l.trimStart().startsWith("File:")) ?? "";
115
- const line = lines.find((l) => l.trimStart().startsWith("Line:")) ?? "";
116
- const why = lines.find((l) => l.trimStart().startsWith("Why:")) ?? "";
117
- if (!title.startsWith("- ["))
118
- continue;
119
- const isHigh = title.toLowerCase().includes("[high]");
120
- 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>`);
121
- }
122
- if (items.length === 0)
173
+ const findings = parseReviewFindings(trimmed);
174
+ if (findings.length === 0)
123
175
  return `<pre>${escapeHtml(trimmed)}</pre>`;
124
- return items.join("\n");
176
+ return findings
177
+ .map((f) => `<div class="finding${f.severity === "high" ? " high" : ""}"><div class="title">${escapeHtml(`[${f.severity}] ${f.title}`)}</div><div class="meta">${escapeHtml(`${f.file}:${f.line}`)}</div><div>${escapeHtml(f.why)}</div></div>`)
178
+ .join("\n");
179
+ }
180
+ function sectionHeader(badge, labels) {
181
+ const duration = durationText(labels);
182
+ const parts = [
183
+ tokensText(labels),
184
+ duration === "" ? "" : `took ${duration}`,
185
+ `${labels.length} request(s)`,
186
+ ].filter((p) => p !== "");
187
+ return `<div class="row"><span class="badge">${escapeHtml(badge)}</span><span class="tokens">${escapeHtml(parts.join(" • "))}</span></div>`;
125
188
  }
126
- const triageContent = getContent("triage_pass");
127
- const triageContentPretty = formatAsPrettyJsonIfPossible(triageContent);
128
- const fileServerLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_server.js_round_"));
129
- const fileCiLabel = Array.from(byLabel.keys()).find((k) => k.startsWith("file_review_.gitlab-ci.yml_round_"));
130
- const consolidateLabel = "consolidate_pass";
131
189
  function pickVerificationSection() {
132
190
  const afterLimit = getContent("verification_pass_final_after_tool_limit");
133
191
  if (afterLimit.trim() !== "")
@@ -135,7 +193,7 @@ export async function renderDebugArtifactsHtml(params) {
135
193
  label: "verification_pass_final_after_tool_limit",
136
194
  content: afterLimit,
137
195
  };
138
- const roundLabels = Array.from(byLabel.keys())
196
+ const roundLabels = verificationLabels
139
197
  .filter((k) => k.startsWith("verification_pass_round_"))
140
198
  .sort((a, b) => {
141
199
  const na = Number(a.replace("verification_pass_round_", ""));
@@ -154,8 +212,53 @@ export async function renderDebugArtifactsHtml(params) {
154
212
  return { label: "verification_pass_round_1", content: "" };
155
213
  }
156
214
  const verificationSection = pickVerificationSection();
157
- const verificationLabel = verificationSection.label;
158
215
  const finalStatus = verificationSection.content.trim() !== "" ? "Verified" : "Fallback";
216
+ // Per-pass token/duration summary table.
217
+ const passRows = [
218
+ ["Triage", triageLabels],
219
+ ...Array.from(fileGroups.entries()).map(([path, labels]) => [`File review — ${path}`, labels]),
220
+ ["Consolidation", consolidateLabels],
221
+ ["Verification", verificationLabels],
222
+ ["Single-pass fallback", mainLabels],
223
+ ].filter(([, labels]) => labels.length > 0);
224
+ const cell = (value, opts) => `<td style="padding:6px 8px;border-bottom:1px solid var(--line);${opts?.strong ? "font-weight:700;" : ""}">${value}</td>`;
225
+ const usageCells = (labels, strong) => {
226
+ const { usage, reported } = sumUsage(labels);
227
+ if (reported === 0)
228
+ return `${cell("n/a", { strong })}${cell("n/a", { strong })}${cell("n/a", { strong })}`;
229
+ return [usage.prompt, usage.completion, usage.total]
230
+ .map((n) => cell(escapeHtml(n.toLocaleString()), { strong }))
231
+ .join("");
232
+ };
233
+ const passTableRows = passRows
234
+ .map(([name, labels]) => `<tr>${cell(escapeHtml(name))}${cell(String(labels.length))}${usageCells(labels)}${cell(escapeHtml(durationText(labels) || "—"))}</tr>`)
235
+ .join("\n");
236
+ const totalRow = `<tr>${cell("Total", { strong: true })}${cell(String(allLabels.length), { strong: true })}${usageCells(allLabels, true)}${cell(escapeHtml(durationText(allLabels) || "—"), { strong: true })}</tr>`;
237
+ const totalUsage = sumUsage(allLabels);
238
+ const totalTokensText = totalUsage.reported === 0 ? "n/a" : totalUsage.usage.total.toLocaleString();
239
+ const wallStarts = requests
240
+ .map((r) => parseTs(r.ts))
241
+ .filter((n) => n != null);
242
+ const wallEnds = [...responses, ...errors]
243
+ .map((r) => parseTs(r.ts))
244
+ .filter((n) => n != null);
245
+ const wallText = wallStarts.length > 0 && wallEnds.length > 0
246
+ ? `${((Math.max(...wallEnds) - Math.min(...wallStarts)) / 1000).toFixed(1)}s`
247
+ : "n/a";
248
+ const fileSections = Array.from(fileGroups.entries())
249
+ .map(([path, labels]) => `<div class="section">${sectionHeader(`file: ${path}`, labels)}${renderFindings(lastContent(labels))}</div>`)
250
+ .join("\n");
251
+ const triageRawSections = triageLabels
252
+ .map((label) => `<div class="section">${sectionHeader(`label: ${label}`, [label])}<pre>${escapeHtml(formatAsPrettyJsonIfPossible(getContent(label)))}</pre></div>`)
253
+ .join("\n");
254
+ const errorSection = errors.length === 0
255
+ ? ""
256
+ : `<h2>Errors</h2>
257
+ <div class="section">
258
+ ${errors
259
+ .map((e) => `<div class="finding high"><div class="title">${escapeHtml(String(e.label ?? "unknown"))}</div><div class="meta">${escapeHtml(String(e.ts ?? ""))}</div><div>${escapeHtml(`${e?.error?.name ?? ""} ${e?.error?.status ?? ""} ${e?.error?.message ?? ""}`.trim())}</div></div>`)
260
+ .join("\n")}
261
+ </div>`;
159
262
  const html = `<!doctype html>
160
263
  <html lang="en">
161
264
  <head>
@@ -167,13 +270,14 @@ export async function renderDebugArtifactsHtml(params) {
167
270
  * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 Inter,system-ui,sans-serif; padding:24px; }
168
271
  .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;}
169
272
  .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;}
170
- .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);}
273
+ .k{color:var(--muted);font-size:12px;} .v{font-size:20px;font-weight:700;margin-top:4px;} .ok{color:var(--ok);} .bad{color:var(--high);}
171
274
  .section{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;}
172
275
  .row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
173
276
  .badge{border:1px solid var(--line);background:#16223a;border-radius:999px;padding:2px 10px;font-size:12px;color:var(--muted);}
174
277
  .tokens{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--muted);}
175
278
  .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);}
176
279
  .finding .title{font-weight:700;} .meta{color:var(--muted);font-size:12px;margin:4px 0;}
280
+ table{width:100%;border-collapse:collapse;font-size:13px;}
177
281
  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;}
178
282
  @media (max-width:900px){.grid{grid-template-columns:1fr 1fr;}} @media (max-width:520px){.grid{grid-template-columns:1fr;}}
179
283
  </style>
@@ -185,16 +289,34 @@ export async function renderDebugArtifactsHtml(params) {
185
289
 
186
290
  <div class="grid">
187
291
  <div class="card"><div class="k">Model</div><div class="v">${escapeHtml(aiModel)}</div></div>
188
- <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(records.filter((r) => r.kind === "openai_request").length))}</div></div>
189
- <div class="card"><div class="k">Responses</div><div class="v">${escapeHtml(String(responses.length))}</div></div>
292
+ <div class="card"><div class="k">Requests</div><div class="v">${escapeHtml(String(requests.length))}</div></div>
293
+ <div class="card"><div class="k">Files reviewed</div><div class="v">${escapeHtml(String(fileGroups.size))}</div></div>
294
+ <div class="card"><div class="k">Errors</div><div class="v${errors.length > 0 ? " bad" : ""}">${escapeHtml(String(errors.length))}</div></div>
295
+ <div class="card"><div class="k">Total tokens</div><div class="v">${escapeHtml(totalTokensText)}</div></div>
296
+ <div class="card"><div class="k">Wall time</div><div class="v">${escapeHtml(wallText)}</div></div>
190
297
  <div class="card"><div class="k">Final Status</div><div class="v ok">${escapeHtml(finalStatus)}</div></div>
191
298
  </div>
192
299
 
193
- <h2>Token Usage</h2>
300
+ <h2>Token Usage by Pass</h2>
194
301
  <div class="section">
195
- <div class="tokens">${escapeHtml(tokenLine)}</div>
196
- <div class="tokens" style="margin-top:6px;"><strong>Total:</strong> ${escapeHtml(totalTokens.toLocaleString())} tokens</div>
302
+ <table>
303
+ <thead>
304
+ <tr style="text-align:left;color:var(--muted);">
305
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Pass</th>
306
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Requests</th>
307
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Prompt</th>
308
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Completion</th>
309
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Total</th>
310
+ <th style="padding:6px 8px;border-bottom:1px solid var(--line);">Duration</th>
311
+ </tr>
312
+ </thead>
313
+ <tbody>
314
+ ${passTableRows}
315
+ ${totalRow}
316
+ </tbody>
317
+ </table>
197
318
  </div>
319
+ ${errorSection}
198
320
 
199
321
  <h2>Pass 1 — Triage Decisions</h2>
200
322
  <div class="section">
@@ -202,28 +324,20 @@ export async function renderDebugArtifactsHtml(params) {
202
324
  </div>
203
325
 
204
326
  <h2>Pass 1 — Triage (raw model output)</h2>
205
- <div class="section">
206
- <div class="row"><span class="badge">label: triage_pass</span><span class="tokens">${escapeHtml(findTs("triage_pass"))}</span></div>
207
- <pre>${escapeHtml(triageContentPretty)}</pre>
208
- </div>
327
+ ${triageRawSections}
209
328
 
210
329
  <h2>Pass 2 — File Reviews</h2>
211
- ${fileServerLabel == null
212
- ? ""
213
- : `<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>`}
214
- ${fileCiLabel == null
215
- ? ""
216
- : `<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>`}
330
+ ${fileSections === "" ? "<div class=\"section\"><pre>No file review records</pre></div>" : fileSections}
217
331
 
218
332
  <h2>Pass 3 — Consolidation</h2>
219
333
  <div class="section">
220
- <div class="row"><span class="badge">label: ${escapeHtml(consolidateLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(consolidateLabel))}</span></div>
221
- ${renderFindings(getContent(consolidateLabel))}
334
+ ${sectionHeader("label: consolidate_pass", consolidateLabels)}
335
+ ${renderFindings(getContent("consolidate_pass"))}
222
336
  </div>
223
337
 
224
338
  <h2>Pass 4 — Verification</h2>
225
339
  <div class="section">
226
- <div class="row"><span class="badge">label: ${escapeHtml(verificationLabel)}</span><span class="tokens">${escapeHtml(getTokenTriplet(verificationLabel))}</span></div>
340
+ ${sectionHeader(`label: ${verificationSection.label}`, verificationLabels)}
227
341
  ${renderFindings(verificationSection.content)}
228
342
  </div>
229
343
  </div>
package/dist/cli.js CHANGED
@@ -33,6 +33,7 @@ function printHelp() {
33
33
  " OPENAI_API_KEY (required) OpenAI API key.",
34
34
  " OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
35
35
  " AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
36
+ " AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.",
36
37
  " AI_PROMPT_PROFILE (optional) Prompt style: \"default\" | \"weak\". Default: default.",
37
38
  " Use \"weak\" for small/quantized models — shorter prompts,",
38
39
  " positive rules, and few-shot examples.",
@@ -82,7 +83,10 @@ async function main() {
82
83
  const promptLimits = parsePromptLimits(process.argv);
83
84
  const triageDiffChars = parseTriageDiffChars(process.argv);
84
85
  const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
85
- const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
86
+ const envConcurrency = Number(envOrUndefined("AI_REVIEW_CONCURRENCY"));
87
+ const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", Number.isFinite(envConcurrency) && envConcurrency >= 1
88
+ ? Math.floor(envConcurrency)
89
+ : DEFAULT_REVIEW_CONCURRENCY, 1);
86
90
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
87
91
  const promptProfile = readPromptProfileFromEnv();
88
92
  const allowProfileAutoDemote = envOrUndefined("AI_PROMPT_PROFILE") == null;
@@ -53,8 +53,8 @@ export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEF
53
53
  // Multi-pass pipeline prompts
54
54
  // ---------------------------------------------------------------------------
55
55
  export const DEFAULT_MAX_FINDINGS = 5;
56
- // ponytail: 2 keeps self-hosted gateways under their proxy timeout; raise via
57
- // --max-review-concurrency on backends that batch well.
56
+ // ponytail: 2 suits most gateways; single-GPU backends that queue-starve past
57
+ // the per-call deadline should set AI_REVIEW_CONCURRENCY=1 (or the flag).
58
58
  export const DEFAULT_REVIEW_CONCURRENCY = 2;
59
59
  export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE, triageDiffChars = DEFAULT_TRIAGE_DIFF_CHARS) {
60
60
  return [
@@ -55,10 +55,10 @@ export function sanitizeGitLabMarkdown(input) {
55
55
  return withClosedFence;
56
56
  }
57
57
  const NO_ISSUES_SENTENCE = "No confirmed bugs or high-value optimizations found.";
58
- export function normalizeReviewFindingsMarkdown(input) {
58
+ /** Parse labeled (`- [high] … File:/Line:/Why:`) or pretty (`**🔴 High — …**`)
59
+ * finding blocks out of arbitrary model output. Returns [] when none match. */
60
+ export function parseReviewFindings(input) {
59
61
  const normalized = input.replace(/\r\n/g, "\n").trim();
60
- if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
61
- return normalized;
62
62
  // Some providers collapse each finding into one long line.
63
63
  // Expand inline markers so parser can recover structured blocks.
64
64
  const expanded = normalized
@@ -141,6 +141,13 @@ export function normalizeReviewFindingsMarkdown(input) {
141
141
  i = j - 1;
142
142
  }
143
143
  }
144
+ return findings;
145
+ }
146
+ export function normalizeReviewFindingsMarkdown(input) {
147
+ const normalized = input.replace(/\r\n/g, "\n").trim();
148
+ if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
149
+ return normalized;
150
+ const findings = parseReviewFindings(normalized);
144
151
  if (findings.length === 0)
145
152
  return normalized;
146
153
  const severityLabel = {
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.38",
4
+ "version": "1.0.40",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {