@krotovm/gitlab-ai-review 1.0.35 → 1.0.38

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
@@ -55,7 +55,7 @@ Set these in your project/group CI settings:
55
55
  - `OPENAI_API_KEY` (required)
56
56
  - `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
57
57
  - `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
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.
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
61
  - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
@@ -2,7 +2,15 @@
2
2
  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
+ import { truncateWithMarker } from "../prompt/utils.js";
5
6
  import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
7
+ /** Hard deadline per model call, retries included. Must exceed the gateway's own
8
+ * proxy timeout so the SDK's 5xx retries stay the first line of defense. */
9
+ const COMPLETION_TIMEOUT_MS = 180_000;
10
+ /** Tool-result caps. Every tool response is re-sent as prompt on each following
11
+ * round, so oversized results multiply prefill time on self-hosted models. */
12
+ const MAX_TOOL_FILE_CHARS = 12_000;
13
+ const MAX_TOOL_GREP_CHARS = 800;
6
14
  function buildTriageDecisions(params) {
7
15
  const { changes, triageResult } = params;
8
16
  const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
@@ -63,10 +71,14 @@ async function createCompletionWithDebug(params) {
63
71
  });
64
72
  // Stream and reassemble: reverse proxies (nginx proxy_read_timeout) return 504 on
65
73
  // long silent non-streamed completions; streamed chunks keep the connection alive.
74
+ // The SDK's own timeout only covers time-to-headers; a stalled SSE body would hang
75
+ // forever without the explicit AbortSignal deadline.
66
76
  const { stream: _stream, ...createParams } = request;
67
77
  try {
68
78
  const completion = await openaiInstance.chat.completions
69
- .stream(createParams)
79
+ .stream(createParams, {
80
+ signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS),
81
+ })
70
82
  .finalChatCompletion();
71
83
  await appendDebugDump(debugDumpFile, debugRecordWriter, {
72
84
  kind: "openai_response",
@@ -148,8 +160,8 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
148
160
  ok: true,
149
161
  path,
150
162
  ref,
151
- content: fileText.slice(0, 30000),
152
- truncated: fileText.length > 30000,
163
+ content: fileText.slice(0, MAX_TOOL_FILE_CHARS),
164
+ truncated: fileText.length > MAX_TOOL_FILE_CHARS,
153
165
  });
154
166
  }
155
167
  catch (error) {
@@ -185,7 +197,7 @@ async function handleGrepTool(argsRaw, defaultRef, gitLabProjectApiUrl, headers,
185
197
  const trimmed = results.map((r) => ({
186
198
  path: r.path,
187
199
  startline: r.startline,
188
- data: r.data.slice(0, 2000),
200
+ data: r.data.slice(0, MAX_TOOL_GREP_CHARS),
189
201
  }));
190
202
  return JSON.stringify({ ok: true, query, ref, matches: trimmed });
191
203
  }
@@ -642,8 +654,9 @@ async function runVerificationWithTools(params) {
642
654
  });
643
655
  }
644
656
  export async function reviewMergeRequestMultiPass(params) {
645
- const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
657
+ const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
646
658
  const { logStep } = loggers;
659
+ let effectiveProfile = promptProfile;
647
660
  logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
648
661
  `(prompt profile=${promptProfile}, triage_diff_chars=${triageDiffChars})`);
649
662
  const triageInputs = changes.map((c) => ({
@@ -683,6 +696,44 @@ export async function reviewMergeRequestMultiPass(params) {
683
696
  catch (error) {
684
697
  logStep(`Triage pass failed: ${error?.message ?? error}. Falling back to single-pass.`);
685
698
  }
699
+ // The triage pass doubles as a capability probe: models that cannot return the
700
+ // strict triage JSON with the default prompts follow the terser weak-profile
701
+ // prompts better. Retry triage once with "weak"; on success the remaining
702
+ // passes run with it. (Self-reported model names are unreliable behind
703
+ // gateway aliases, so we probe behavior instead of asking.)
704
+ if (triageResult == null &&
705
+ triageText != null &&
706
+ effectiveProfile === "default" &&
707
+ allowProfileAutoDemote) {
708
+ logStep("Triage response unparseable with prompt profile=default. Retrying triage with profile=weak.");
709
+ try {
710
+ const weakTriageCompletion = await createCompletionWithDebug({
711
+ openaiInstance,
712
+ requestLabel: "triage_pass_weak_retry",
713
+ debugDumpFile,
714
+ debugRecordWriter,
715
+ request: {
716
+ model: aiModel,
717
+ temperature: 0.1,
718
+ stream: false,
719
+ messages: buildTriagePrompt(triageInputs, "weak", triageDiffChars),
720
+ response_format: { type: "json_object" },
721
+ },
722
+ });
723
+ const weakTriageText = extractCompletionText(weakTriageCompletion);
724
+ const weakParse = weakTriageText != null
725
+ ? parseTriageResponseDetailed(weakTriageText)
726
+ : null;
727
+ if (weakParse?.result != null) {
728
+ triageResult = weakParse.result;
729
+ effectiveProfile = "weak";
730
+ logStep("Weak-profile triage parsed successfully. Continuing all passes with prompt profile=weak.");
731
+ }
732
+ }
733
+ catch (error) {
734
+ logStep(`Weak-profile triage retry failed: ${error?.message ?? error}.`);
735
+ }
736
+ }
686
737
  if (triageResult == null) {
687
738
  if (triageText != null) {
688
739
  const triagePreview = triageText.replace(/\s+/g, " ").trim().slice(0, 200);
@@ -707,7 +758,7 @@ export async function reviewMergeRequestMultiPass(params) {
707
758
  projectId,
708
759
  headers,
709
760
  forceTools,
710
- promptProfile,
761
+ promptProfile: effectiveProfile,
711
762
  loggers,
712
763
  debugDumpFile,
713
764
  debugRecordWriter,
@@ -747,7 +798,7 @@ export async function reviewMergeRequestMultiPass(params) {
747
798
  openaiInstance,
748
799
  aiModel,
749
800
  filePath: change.new_path,
750
- fileDiff: change.diff,
801
+ fileDiff: truncateWithMarker(change.diff, promptLimits.maxDiffChars, change.new_path),
751
802
  summary: triageResult.summary,
752
803
  otherChangedFiles: otherFiles,
753
804
  refs,
@@ -755,7 +806,7 @@ export async function reviewMergeRequestMultiPass(params) {
755
806
  projectId,
756
807
  headers,
757
808
  forceTools,
758
- promptProfile,
809
+ promptProfile: effectiveProfile,
759
810
  loggers,
760
811
  debugDumpFile,
761
812
  debugRecordWriter,
@@ -776,16 +827,20 @@ export async function reviewMergeRequestMultiPass(params) {
776
827
  if (failedPaths.length === reviewFiles.length) {
777
828
  throw new Error(`All ${reviewFiles.length} per-file reviews failed (e.g. ${failedPaths[0]}). See log above.`);
778
829
  }
830
+ // Deterministic note header: title + triage summary. Prepended in code so
831
+ // every final-answer path gets it regardless of model formatting discipline.
832
+ const summaryText = triageResult.summary.trim().replace(/\s*\n\s*/g, " ");
833
+ const withHeader = (body) => `### 🤖 AI Code Review\n\n${summaryText === "" ? "" : `> ${summaryText}\n\n`}${body}`;
779
834
  logStep("Pass 3/4: consolidating findings");
780
835
  const consolidateMessages = buildConsolidatePrompt({
781
836
  perFileFindings,
782
837
  summary: triageResult.summary,
783
838
  maxFindings,
784
- profile: promptProfile,
839
+ profile: effectiveProfile,
785
840
  });
786
841
  if (consolidateMessages == null) {
787
842
  const DISCLAIMER = "This comment was generated by AI review bot.";
788
- return `${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
843
+ return withHeader(`${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
789
844
  }
790
845
  try {
791
846
  const consolidateCompletion = await createCompletionWithDebug({
@@ -802,7 +857,7 @@ export async function reviewMergeRequestMultiPass(params) {
802
857
  });
803
858
  const consolidatedText = extractCompletionText(consolidateCompletion);
804
859
  if (consolidatedText == null || consolidatedText.trim() === "") {
805
- return buildAnswer(consolidateCompletion);
860
+ return withHeader(buildAnswer(consolidateCompletion));
806
861
  }
807
862
  logStep("Pass 4/4: verifying consolidated findings (repo tools)");
808
863
  const verificationMessages = buildVerificationPrompt({
@@ -811,7 +866,7 @@ export async function reviewMergeRequestMultiPass(params) {
811
866
  consolidatedFindings: consolidatedText,
812
867
  maxFindings,
813
868
  refs,
814
- profile: promptProfile,
869
+ profile: effectiveProfile,
815
870
  });
816
871
  try {
817
872
  const verificationCompletion = await runVerificationWithTools({
@@ -828,11 +883,11 @@ export async function reviewMergeRequestMultiPass(params) {
828
883
  debugDumpFile,
829
884
  debugRecordWriter,
830
885
  });
831
- return buildAnswer(verificationCompletion);
886
+ return withHeader(buildAnswer(verificationCompletion));
832
887
  }
833
888
  catch (error) {
834
889
  logStep(`Verification failed: ${error?.message ?? error}. Returning consolidated findings.`);
835
- return buildAnswer(consolidateCompletion);
890
+ return withHeader(buildAnswer(consolidateCompletion));
836
891
  }
837
892
  }
838
893
  catch (error) {
@@ -842,6 +897,6 @@ export async function reviewMergeRequestMultiPass(params) {
842
897
  .filter((f) => !isEmptyReviewBody(f.findings))
843
898
  .map((f) => f.findings)
844
899
  .join("\n");
845
- return `${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
900
+ return withHeader(`${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`);
846
901
  }
847
902
  }
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ function printHelp() {
27
27
  " --max-total-prompt-chars=220000",
28
28
  " --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).",
29
29
  " --max-findings=5 Max findings in final review (CI multi-pass only).",
30
- " --max-review-concurrency=5 Parallel per-file review calls (CI multi-pass only).",
30
+ " --max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).",
31
31
  "",
32
32
  "Env vars:",
33
33
  " OPENAI_API_KEY (required) OpenAI API key.",
@@ -36,6 +36,8 @@ function printHelp() {
36
36
  " AI_PROMPT_PROFILE (optional) Prompt style: \"default\" | \"weak\". Default: default.",
37
37
  " Use \"weak\" for small/quantized models — shorter prompts,",
38
38
  " positive rules, and few-shot examples.",
39
+ " When unset, an unparseable triage response auto-retries",
40
+ " with \"weak\" and keeps it for the whole run.",
39
41
  " PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
40
42
  "",
41
43
  "CI-only env vars (provided by GitLab):",
@@ -83,7 +85,8 @@ async function main() {
83
85
  const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
84
86
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
85
87
  const promptProfile = readPromptProfileFromEnv();
86
- logStep(`Prompt profile: ${promptProfile}`);
88
+ const allowProfileAutoDemote = envOrUndefined("AI_PROMPT_PROFILE") == null;
89
+ logStep(`Prompt profile: ${promptProfile}${allowProfileAutoDemote ? " (auto: falls back to weak if triage JSON fails)" : ""}`);
87
90
  const artifactHtmlFile = INCLUDE_ARTIFACTS
88
91
  ? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", "ai-review-report.html")
89
92
  : undefined;
@@ -149,6 +152,7 @@ async function main() {
149
152
  reviewConcurrency,
150
153
  forceTools: FORCE_TOOLS,
151
154
  promptProfile,
155
+ allowProfileAutoDemote,
152
156
  loggers,
153
157
  debugRecordWriter,
154
158
  });
@@ -53,7 +53,9 @@ 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
- export const DEFAULT_REVIEW_CONCURRENCY = 5;
56
+ // ponytail: 2 keeps self-hosted gateways under their proxy timeout; raise via
57
+ // --max-review-concurrency on backends that batch well.
58
+ export const DEFAULT_REVIEW_CONCURRENCY = 2;
57
59
  export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE, triageDiffChars = DEFAULT_TRIAGE_DIFF_CHARS) {
58
60
  return [
59
61
  buildTriageSystemMessage(profile),
@@ -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) {
@@ -120,7 +143,11 @@ export function normalizeReviewFindingsMarkdown(input) {
120
143
  }
121
144
  if (findings.length === 0)
122
145
  return normalized;
146
+ const severityLabel = {
147
+ high: "🔴 High",
148
+ medium: "🟠 Medium",
149
+ };
123
150
  return findings
124
- .map((f) => `- [${f.severity}] ${f.title} \n File: ${f.file} \n Line: ${f.line} \n Why: ${f.why}`)
151
+ .map((f) => `- **${severityLabel[f.severity]} — ${f.title}** \n \`${f.file}:${f.line}\` \n ${f.why}`)
125
152
  .join("\n\n");
126
153
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@krotovm/gitlab-ai-review",
4
- "version": "1.0.35",
4
+ "version": "1.0.38",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
8
8
  "gitlab-ai-review": "dist/cli.js"
9
9
  },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
10
13
  "files": [
11
14
  "dist/**/*.js",
12
15
  "README.md",