@krotovm/gitlab-ai-review 1.0.35 → 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 +1 -1
- package/dist/cli/ci-review.js +61 -10
- package/dist/cli.js +6 -2
- package/dist/prompt/index.js +3 -1
- package/package.json +4 -1
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`)
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -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,
|
|
152
|
-
truncated: fileText.length >
|
|
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,
|
|
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,
|
|
@@ -781,7 +832,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
781
832
|
perFileFindings,
|
|
782
833
|
summary: triageResult.summary,
|
|
783
834
|
maxFindings,
|
|
784
|
-
profile:
|
|
835
|
+
profile: effectiveProfile,
|
|
785
836
|
});
|
|
786
837
|
if (consolidateMessages == null) {
|
|
787
838
|
const DISCLAIMER = "This comment was generated by AI review bot.";
|
|
@@ -811,7 +862,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
811
862
|
consolidatedFindings: consolidatedText,
|
|
812
863
|
maxFindings,
|
|
813
864
|
refs,
|
|
814
|
-
profile:
|
|
865
|
+
profile: effectiveProfile,
|
|
815
866
|
});
|
|
816
867
|
try {
|
|
817
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=
|
|
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
|
-
|
|
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
|
});
|
package/dist/prompt/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|