@krotovm/gitlab-ai-review 1.0.37 → 1.0.39

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.
@@ -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,
@@ -827,6 +845,10 @@ export async function reviewMergeRequestMultiPass(params) {
827
845
  if (failedPaths.length === reviewFiles.length) {
828
846
  throw new Error(`All ${reviewFiles.length} per-file reviews failed (e.g. ${failedPaths[0]}). See log above.`);
829
847
  }
848
+ // Deterministic note header: title + triage summary. Prepended in code so
849
+ // every final-answer path gets it regardless of model formatting discipline.
850
+ const summaryText = triageResult.summary.trim().replace(/\s*\n\s*/g, " ");
851
+ const withHeader = (body) => `### 🤖 AI Code Review\n\n${summaryText === "" ? "" : `> ${summaryText}\n\n`}${body}`;
830
852
  logStep("Pass 3/4: consolidating findings");
831
853
  const consolidateMessages = buildConsolidatePrompt({
832
854
  perFileFindings,
@@ -836,7 +858,7 @@ export async function reviewMergeRequestMultiPass(params) {
836
858
  });
837
859
  if (consolidateMessages == null) {
838
860
  const DISCLAIMER = "This comment was generated by AI review bot.";
839
- return `${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
861
+ return withHeader(`${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
840
862
  }
841
863
  try {
842
864
  const consolidateCompletion = await createCompletionWithDebug({
@@ -853,7 +875,7 @@ export async function reviewMergeRequestMultiPass(params) {
853
875
  });
854
876
  const consolidatedText = extractCompletionText(consolidateCompletion);
855
877
  if (consolidatedText == null || consolidatedText.trim() === "") {
856
- return buildAnswer(consolidateCompletion);
878
+ return withHeader(buildAnswer(consolidateCompletion));
857
879
  }
858
880
  logStep("Pass 4/4: verifying consolidated findings (repo tools)");
859
881
  const verificationMessages = buildVerificationPrompt({
@@ -879,11 +901,11 @@ export async function reviewMergeRequestMultiPass(params) {
879
901
  debugDumpFile,
880
902
  debugRecordWriter,
881
903
  });
882
- return buildAnswer(verificationCompletion);
904
+ return withHeader(buildAnswer(verificationCompletion));
883
905
  }
884
906
  catch (error) {
885
907
  logStep(`Verification failed: ${error?.message ?? error}. Returning consolidated findings.`);
886
- return buildAnswer(consolidateCompletion);
908
+ return withHeader(buildAnswer(consolidateCompletion));
887
909
  }
888
910
  }
889
911
  catch (error) {
@@ -893,6 +915,6 @@ export async function reviewMergeRequestMultiPass(params) {
893
915
  .filter((f) => !isEmptyReviewBody(f.findings))
894
916
  .map((f) => f.findings)
895
917
  .join("\n");
896
- return `${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
918
+ return withHeader(`${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
897
919
  }
898
920
  }
@@ -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>
@@ -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
@@ -70,11 +70,16 @@ export function normalizeReviewFindingsMarkdown(input) {
70
70
  const lines = expanded.split("\n");
71
71
  const findings = [];
72
72
  const headerRe = /^\s*(?:[-*•]\s*)?\[(high|medium)\]\s+(.+?)\s*$/i;
73
+ // Already-rendered block (see renderer below) — parsing it too keeps this
74
+ // function idempotent when a model echoes pretty findings back verbatim.
75
+ const prettyHeaderRe = /^\s*(?:[-*•]\s*)?\*\*(?:🔴|🟠)?\s*(high|medium)\s*[—–-]\s*(.+?)\*\*\s*$/i;
76
+ const matchHeader = (line) => line.match(headerRe) ?? line.match(prettyHeaderRe);
73
77
  const fileRe = /^\s*[-*]?\s*File:\s*(.+?)\s*$/i;
74
78
  const lineRe = /^\s*[-*]?\s*Line:\s*(.+?)\s*$/i;
75
79
  const whyRe = /^\s*[-*]?\s*Why:\s*(.+?)\s*$/i;
80
+ const prettyFileLineRe = /^\s*`([^`\n]+?):(~?[^`\n]*)`\s*$/;
76
81
  for (let i = 0; i < lines.length; i += 1) {
77
- const headerMatch = lines[i].match(headerRe);
82
+ const headerMatch = matchHeader(lines[i]);
78
83
  if (headerMatch == null)
79
84
  continue;
80
85
  const severity = headerMatch[1].toLowerCase();
@@ -84,9 +89,18 @@ export function normalizeReviewFindingsMarkdown(input) {
84
89
  let why = null;
85
90
  let j = i + 1;
86
91
  while (j < lines.length) {
87
- const nextHeader = lines[j].match(headerRe);
92
+ const nextHeader = matchHeader(lines[j]);
88
93
  if (nextHeader != null)
89
94
  break;
95
+ if (file == null && line == null) {
96
+ const m = lines[j].match(prettyFileLineRe);
97
+ if (m != null) {
98
+ file = m[1].trim();
99
+ line = m[2].trim();
100
+ j += 1;
101
+ continue;
102
+ }
103
+ }
90
104
  if (file == null) {
91
105
  const m = lines[j].match(fileRe);
92
106
  if (m != null) {
@@ -111,6 +125,15 @@ export function normalizeReviewFindingsMarkdown(input) {
111
125
  continue;
112
126
  }
113
127
  }
128
+ // Pretty blocks carry the why as an unlabeled line after `file:line`.
129
+ if (why == null && file != null && line != null) {
130
+ const text = lines[j].trim();
131
+ if (text !== "" && !/^(-{3,}|_)/.test(text)) {
132
+ why = text;
133
+ j += 1;
134
+ continue;
135
+ }
136
+ }
114
137
  j += 1;
115
138
  }
116
139
  if (file != null && line != null && why != null) {
@@ -118,9 +141,20 @@ export function normalizeReviewFindingsMarkdown(input) {
118
141
  i = j - 1;
119
142
  }
120
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);
121
151
  if (findings.length === 0)
122
152
  return normalized;
153
+ const severityLabel = {
154
+ high: "🔴 High",
155
+ medium: "🟠 Medium",
156
+ };
123
157
  return findings
124
- .map((f) => `- [${f.severity}] ${f.title} \n File: ${f.file} \n Line: ${f.line} \n Why: ${f.why}`)
158
+ .map((f) => `- **${severityLabel[f.severity]} — ${f.title}** \n \`${f.file}:${f.line}\` \n ${f.why}`)
125
159
  .join("\n\n");
126
160
  }
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.37",
4
+ "version": "1.0.39",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {