@krotovm/gitlab-ai-review 1.0.34 → 1.0.37

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]));
@@ -61,8 +69,17 @@ async function createCompletionWithDebug(params) {
61
69
  label: requestLabel,
62
70
  request,
63
71
  });
72
+ // Stream and reassemble: reverse proxies (nginx proxy_read_timeout) return 504 on
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.
76
+ const { stream: _stream, ...createParams } = request;
64
77
  try {
65
- const completion = await openaiInstance.chat.completions.create(request);
78
+ const completion = await openaiInstance.chat.completions
79
+ .stream(createParams, {
80
+ signal: AbortSignal.timeout(COMPLETION_TIMEOUT_MS),
81
+ })
82
+ .finalChatCompletion();
66
83
  await appendDebugDump(debugDumpFile, debugRecordWriter, {
67
84
  kind: "openai_response",
68
85
  label: requestLabel,
@@ -143,8 +160,8 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
143
160
  ok: true,
144
161
  path,
145
162
  ref,
146
- content: fileText.slice(0, 30000),
147
- truncated: fileText.length > 30000,
163
+ content: fileText.slice(0, MAX_TOOL_FILE_CHARS),
164
+ truncated: fileText.length > MAX_TOOL_FILE_CHARS,
148
165
  });
149
166
  }
150
167
  catch (error) {
@@ -180,7 +197,7 @@ async function handleGrepTool(argsRaw, defaultRef, gitLabProjectApiUrl, headers,
180
197
  const trimmed = results.map((r) => ({
181
198
  path: r.path,
182
199
  startline: r.startline,
183
- data: r.data.slice(0, 2000),
200
+ data: r.data.slice(0, MAX_TOOL_GREP_CHARS),
184
201
  }));
185
202
  return JSON.stringify({ ok: true, query, ref, matches: trimmed });
186
203
  }
@@ -637,8 +654,9 @@ async function runVerificationWithTools(params) {
637
654
  });
638
655
  }
639
656
  export async function reviewMergeRequestMultiPass(params) {
640
- 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;
641
658
  const { logStep } = loggers;
659
+ let effectiveProfile = promptProfile;
642
660
  logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
643
661
  `(prompt profile=${promptProfile}, triage_diff_chars=${triageDiffChars})`);
644
662
  const triageInputs = changes.map((c) => ({
@@ -678,6 +696,44 @@ export async function reviewMergeRequestMultiPass(params) {
678
696
  catch (error) {
679
697
  logStep(`Triage pass failed: ${error?.message ?? error}. Falling back to single-pass.`);
680
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
+ }
681
737
  if (triageResult == null) {
682
738
  if (triageText != null) {
683
739
  const triagePreview = triageText.replace(/\s+/g, " ").trim().slice(0, 200);
@@ -702,7 +758,7 @@ export async function reviewMergeRequestMultiPass(params) {
702
758
  projectId,
703
759
  headers,
704
760
  forceTools,
705
- promptProfile,
761
+ promptProfile: effectiveProfile,
706
762
  loggers,
707
763
  debugDumpFile,
708
764
  debugRecordWriter,
@@ -733,33 +789,50 @@ export async function reviewMergeRequestMultiPass(params) {
733
789
  }
734
790
  logStep(`Pass 2/4: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
735
791
  const allChangedPaths = changes.map((c) => c.new_path);
792
+ const failedPaths = [];
736
793
  const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
737
794
  const otherFiles = allChangedPaths.filter((p) => p !== change.new_path);
738
- const findings = await runFileReviewWithTools({
739
- openaiInstance,
740
- aiModel,
741
- filePath: change.new_path,
742
- fileDiff: change.diff,
743
- summary: triageResult.summary,
744
- otherChangedFiles: otherFiles,
745
- refs,
746
- gitLabProjectApiUrl,
747
- projectId,
748
- headers,
749
- forceTools,
750
- promptProfile,
751
- loggers,
752
- debugDumpFile,
753
- debugRecordWriter,
754
- });
795
+ let findings;
796
+ try {
797
+ findings = await runFileReviewWithTools({
798
+ openaiInstance,
799
+ aiModel,
800
+ filePath: change.new_path,
801
+ fileDiff: truncateWithMarker(change.diff, promptLimits.maxDiffChars, change.new_path),
802
+ summary: triageResult.summary,
803
+ otherChangedFiles: otherFiles,
804
+ refs,
805
+ gitLabProjectApiUrl,
806
+ projectId,
807
+ headers,
808
+ forceTools,
809
+ promptProfile: effectiveProfile,
810
+ loggers,
811
+ debugDumpFile,
812
+ debugRecordWriter,
813
+ });
814
+ }
815
+ catch (error) {
816
+ const preview = String(error?.message ?? error)
817
+ .replace(/\s+/g, " ")
818
+ .slice(0, 200);
819
+ logStep(`Pass 2: review failed for ${change.new_path}: ${preview}. Continuing with remaining files.`);
820
+ failedPaths.push(change.new_path);
821
+ // ponytail: failed file surfaces as "no findings" in the MR comment;
822
+ // thread failedPaths into the consolidate prompt if that ever matters.
823
+ findings = NO_FINDINGS_SENTENCE;
824
+ }
755
825
  return { path: change.new_path, findings };
756
826
  });
827
+ if (failedPaths.length === reviewFiles.length) {
828
+ throw new Error(`All ${reviewFiles.length} per-file reviews failed (e.g. ${failedPaths[0]}). See log above.`);
829
+ }
757
830
  logStep("Pass 3/4: consolidating findings");
758
831
  const consolidateMessages = buildConsolidatePrompt({
759
832
  perFileFindings,
760
833
  summary: triageResult.summary,
761
834
  maxFindings,
762
- profile: promptProfile,
835
+ profile: effectiveProfile,
763
836
  });
764
837
  if (consolidateMessages == null) {
765
838
  const DISCLAIMER = "This comment was generated by AI review bot.";
@@ -789,7 +862,7 @@ export async function reviewMergeRequestMultiPass(params) {
789
862
  consolidatedFindings: consolidatedText,
790
863
  maxFindings,
791
864
  refs,
792
- profile: promptProfile,
865
+ profile: effectiveProfile,
793
866
  });
794
867
  try {
795
868
  const verificationCompletion = await runVerificationWithTools({
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),
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.34",
4
+ "version": "1.0.37",
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",