@krotovm/gitlab-ai-review 1.0.19 → 1.0.22
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 +6 -8
- package/dist/cli/ci-review.js +28 -8
- package/dist/cli.js +1 -1
- package/dist/gitlab/services.js +11 -4
- package/dist/prompt/index.js +45 -6
- package/dist/prompt/utils.js +62 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,12 +32,12 @@ ai_review:
|
|
|
32
32
|
|
|
33
33
|
## Env variables
|
|
34
34
|
|
|
35
|
-
Set these in your project/group CI settings
|
|
35
|
+
Set these in your project/group CI settings:
|
|
36
36
|
|
|
37
37
|
- `OPENAI_API_KEY` (required)
|
|
38
38
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
39
39
|
- `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
|
|
40
|
-
- `PROJECT_ACCESS_TOKEN` (optional but
|
|
40
|
+
- `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
|
|
41
41
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
42
42
|
|
|
43
43
|
`OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
|
|
@@ -62,12 +62,10 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
62
62
|
|
|
63
63
|
## Architecture
|
|
64
64
|
|
|
65
|
-
The reviewer uses a three-pass pipeline
|
|
65
|
+
The reviewer uses a three-pass pipeline optimized for large merge requests:
|
|
66
66
|
|
|
67
|
-
1. **Triage**
|
|
68
|
-
2. **Per-file review**
|
|
69
|
-
3. **Consolidate**
|
|
67
|
+
1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP` and generates a short MR summary.
|
|
68
|
+
2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed, each in a dedicated LLM call running in parallel (with tools to fetch full files or grep the repository).
|
|
69
|
+
3. **Consolidate** - Per-file findings are merged, deduplicated, ranked by severity, and trimmed to top N (default 5).
|
|
70
70
|
|
|
71
71
|
If the triage pass fails (API error, unparseable response), the pipeline falls back to the original single-pass approach automatically.
|
|
72
|
-
|
|
73
|
-
The CI pipeline falls back to a simpler single-pass review automatically when triage cannot be used.
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import OpenAI from "openai";
|
|
3
|
-
import {
|
|
3
|
+
import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
|
|
4
4
|
import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
|
|
5
5
|
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
6
6
|
function buildReviewMetadata(changes, refs) {
|
|
@@ -219,7 +219,6 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
219
219
|
const finalCompletion = await openaiInstance.chat.completions.create({
|
|
220
220
|
model: aiModel,
|
|
221
221
|
temperature: 0.2,
|
|
222
|
-
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
223
222
|
stream: false,
|
|
224
223
|
messages,
|
|
225
224
|
});
|
|
@@ -330,7 +329,6 @@ async function runFileReviewWithTools(params) {
|
|
|
330
329
|
const final = await openaiInstance.chat.completions.create({
|
|
331
330
|
model: aiModel,
|
|
332
331
|
temperature: 0.2,
|
|
333
|
-
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
334
332
|
stream: false,
|
|
335
333
|
messages,
|
|
336
334
|
});
|
|
@@ -339,7 +337,7 @@ async function runFileReviewWithTools(params) {
|
|
|
339
337
|
export async function reviewMergeRequestMultiPass(params) {
|
|
340
338
|
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, } = params;
|
|
341
339
|
const { logStep } = loggers;
|
|
342
|
-
logStep(`Pass 1/
|
|
340
|
+
logStep(`Pass 1/4: triaging ${changes.length} file(s)`);
|
|
343
341
|
const triageInputs = changes.map((c) => ({
|
|
344
342
|
path: c.new_path,
|
|
345
343
|
new_file: c.new_file,
|
|
@@ -387,7 +385,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
387
385
|
const DISCLAIMER = "This comment was generated by an artificial intelligence duck.";
|
|
388
386
|
return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
|
|
389
387
|
}
|
|
390
|
-
logStep(`Pass 2/
|
|
388
|
+
logStep(`Pass 2/4: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
|
|
391
389
|
const allChangedPaths = changes.map((c) => c.new_path);
|
|
392
390
|
const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
|
|
393
391
|
const otherFiles = allChangedPaths.filter((p) => p !== change.new_path);
|
|
@@ -407,7 +405,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
407
405
|
});
|
|
408
406
|
return { path: change.new_path, findings };
|
|
409
407
|
});
|
|
410
|
-
logStep("Pass 3/
|
|
408
|
+
logStep("Pass 3/4: consolidating findings");
|
|
411
409
|
const consolidateMessages = buildConsolidatePrompt({
|
|
412
410
|
perFileFindings,
|
|
413
411
|
summary: triageResult.summary,
|
|
@@ -421,11 +419,33 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
421
419
|
const consolidateCompletion = await openaiInstance.chat.completions.create({
|
|
422
420
|
model: aiModel,
|
|
423
421
|
temperature: 0.1,
|
|
424
|
-
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
425
422
|
stream: false,
|
|
426
423
|
messages: consolidateMessages,
|
|
427
424
|
});
|
|
428
|
-
|
|
425
|
+
const consolidatedText = extractCompletionText(consolidateCompletion);
|
|
426
|
+
if (consolidatedText == null || consolidatedText.trim() === "") {
|
|
427
|
+
return buildAnswer(consolidateCompletion);
|
|
428
|
+
}
|
|
429
|
+
logStep("Pass 4/4: verifying consolidated findings");
|
|
430
|
+
const verificationMessages = buildVerificationPrompt({
|
|
431
|
+
perFileFindings,
|
|
432
|
+
summary: triageResult.summary,
|
|
433
|
+
consolidatedFindings: consolidatedText,
|
|
434
|
+
maxFindings,
|
|
435
|
+
});
|
|
436
|
+
try {
|
|
437
|
+
const verificationCompletion = await openaiInstance.chat.completions.create({
|
|
438
|
+
model: aiModel,
|
|
439
|
+
temperature: 0.0,
|
|
440
|
+
stream: false,
|
|
441
|
+
messages: verificationMessages,
|
|
442
|
+
});
|
|
443
|
+
return buildAnswer(verificationCompletion);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
logStep(`Verification failed: ${error?.message ?? error}. Returning consolidated findings.`);
|
|
447
|
+
return buildAnswer(consolidateCompletion);
|
|
448
|
+
}
|
|
429
449
|
}
|
|
430
450
|
catch (error) {
|
|
431
451
|
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
package/dist/cli.js
CHANGED
|
@@ -30,7 +30,7 @@ function printHelp() {
|
|
|
30
30
|
" OPENAI_API_KEY (required) OpenAI API key.",
|
|
31
31
|
" OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
|
|
32
32
|
" AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
|
|
33
|
-
" PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (
|
|
33
|
+
" PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
|
|
34
34
|
"",
|
|
35
35
|
"CI-only env vars (provided by GitLab):",
|
|
36
36
|
" CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, CI_JOB_TOKEN (only if PROJECT_ACCESS_TOKEN is not set)",
|
package/dist/gitlab/services.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import OpenAI from "openai";
|
|
3
|
-
import {
|
|
3
|
+
import { AI_MODEL_TEMPERATURE } from "../prompt/index.js";
|
|
4
4
|
import { GitLabError, OpenAIError, } from "./types.js";
|
|
5
5
|
export const fetchPreEditFiles = async ({ gitLabBaseUrl, headers, changesOldPaths, ref }) => {
|
|
6
6
|
const oldFilesRequestUrls = changesOldPaths.map((filePath) => {
|
|
@@ -87,7 +87,6 @@ export async function generateAICompletion(messages, openaiInstance, aiModel) {
|
|
|
87
87
|
completion = await openaiInstance.chat.completions.create({
|
|
88
88
|
model: aiModel,
|
|
89
89
|
temperature: AI_MODEL_TEMPERATURE,
|
|
90
|
-
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
91
90
|
stream: false,
|
|
92
91
|
messages,
|
|
93
92
|
});
|
|
@@ -164,10 +163,18 @@ export const searchRepository = async ({ gitLabBaseUrl, headers, query, ref, pro
|
|
|
164
163
|
if (res instanceof Error || !res.ok) {
|
|
165
164
|
const responseDetails = await (async () => {
|
|
166
165
|
if (res instanceof Error) {
|
|
167
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
url: url.toString(),
|
|
168
|
+
error: { name: res.name, message: res.message },
|
|
169
|
+
};
|
|
168
170
|
}
|
|
169
171
|
const bodyText = await res.text().catch(() => "");
|
|
170
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
url: url.toString(),
|
|
174
|
+
status: res.status,
|
|
175
|
+
statusText: res.statusText,
|
|
176
|
+
body: bodyText.slice(0, 1000),
|
|
177
|
+
};
|
|
171
178
|
})();
|
|
172
179
|
return new GitLabError({
|
|
173
180
|
name: "SEARCH_FAILED",
|
package/dist/prompt/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import { buildMainSystemMessages, FILE_REVIEW_SYSTEM, TRIAGE_SYSTEM, } from "./messages.js";
|
|
3
|
-
import { sanitizeGitLabMarkdown, truncateWithMarker } from "./utils.js";
|
|
3
|
+
import { normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
|
|
4
4
|
export const DEFAULT_PROMPT_LIMITS = {
|
|
5
5
|
maxDiffs: 50,
|
|
6
6
|
maxDiffChars: 16000,
|
|
@@ -9,7 +9,7 @@ export const DEFAULT_PROMPT_LIMITS = {
|
|
|
9
9
|
const MESSAGES = buildMainSystemMessages();
|
|
10
10
|
export const AI_MODEL_TEMPERATURE = 0.2;
|
|
11
11
|
export const AI_MAX_OUTPUT_TOKENS = 600;
|
|
12
|
-
export const buildPrompt = ({ changes, limits, allowTools = false,
|
|
12
|
+
export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
|
|
13
13
|
const effectiveLimits = {
|
|
14
14
|
...DEFAULT_PROMPT_LIMITS,
|
|
15
15
|
...(limits ?? {}),
|
|
@@ -36,9 +36,6 @@ export const buildPrompt = ({ changes, limits, allowTools = false, additionalCon
|
|
|
36
36
|
const userContent = [
|
|
37
37
|
`Review the following code changes (git diff format). ${stats}`,
|
|
38
38
|
toolNote,
|
|
39
|
-
additionalContext?.trim()
|
|
40
|
-
? `\nAdditional local context (best-effort snippets):\n${additionalContext}`
|
|
41
|
-
: "",
|
|
42
39
|
"",
|
|
43
40
|
"Changes:",
|
|
44
41
|
changesText || "(no changes provided)",
|
|
@@ -169,6 +166,47 @@ export function buildConsolidatePrompt(params) {
|
|
|
169
166
|
},
|
|
170
167
|
];
|
|
171
168
|
}
|
|
169
|
+
export function buildVerificationPrompt(params) {
|
|
170
|
+
const { perFileFindings, summary, consolidatedFindings, maxFindings } = params;
|
|
171
|
+
const findingsText = perFileFindings
|
|
172
|
+
.map((f) => `### ${f.path}\n${f.findings}`)
|
|
173
|
+
.join("\n\n");
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
role: "system",
|
|
177
|
+
content: [
|
|
178
|
+
"You are a skeptical verifier of a merge request review.",
|
|
179
|
+
"Your job is to remove weak, speculative, or unsupported findings from the draft list.",
|
|
180
|
+
"Do not add new findings. Keep, rewrite for clarity, or remove existing findings only.",
|
|
181
|
+
"A finding can stay only if it is directly supported by evidence from the per-file findings.",
|
|
182
|
+
"If confidence is not high, drop the finding.",
|
|
183
|
+
"Preserve this exact per-finding markdown block:",
|
|
184
|
+
"`- [high|medium] <title>`",
|
|
185
|
+
"` File: <path>`",
|
|
186
|
+
"` Line: ~<N>`",
|
|
187
|
+
"` Why: <one concise sentence with key evidence>`",
|
|
188
|
+
"Do not add headings, summaries, or extra commentary.",
|
|
189
|
+
`Return at most ${maxFindings} findings.`,
|
|
190
|
+
'If no findings survive verification, return exactly: "No confirmed bugs or high-value optimizations found."',
|
|
191
|
+
"GitLab-flavoured markdown.",
|
|
192
|
+
].join("\n"),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
role: "user",
|
|
196
|
+
content: [
|
|
197
|
+
`MR Summary: ${summary}`,
|
|
198
|
+
"",
|
|
199
|
+
"Per-file findings (evidence pool):",
|
|
200
|
+
findingsText,
|
|
201
|
+
"",
|
|
202
|
+
"Draft consolidated findings to verify:",
|
|
203
|
+
consolidatedFindings,
|
|
204
|
+
"",
|
|
205
|
+
"Return only the verified final findings.",
|
|
206
|
+
].join("\n"),
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
172
210
|
export function extractCompletionText(completion) {
|
|
173
211
|
if (completion instanceof Error || completion == null)
|
|
174
212
|
return null;
|
|
@@ -235,6 +273,7 @@ export const buildAnswer = (completion) => {
|
|
|
235
273
|
if (content === "") {
|
|
236
274
|
return `${ERROR_ANSWER}\n\nError: Model returned an empty response body. Try another model (for example, gpt-4o-mini) or a different provider endpoint.\n\n---\n_${DISCLAIMER}_`;
|
|
237
275
|
}
|
|
238
|
-
const
|
|
276
|
+
const normalizedFindings = normalizeReviewFindingsMarkdown(content);
|
|
277
|
+
const safe = sanitizeGitLabMarkdown(normalizedFindings);
|
|
239
278
|
return `${safe}\n\n---\n_${DISCLAIMER}_`;
|
|
240
279
|
};
|
package/dist/prompt/utils.js
CHANGED
|
@@ -12,3 +12,65 @@ export function sanitizeGitLabMarkdown(input) {
|
|
|
12
12
|
const withClosedFence = fenceCount % 2 === 1 ? `${normalized}\n\`\`\`` : normalized;
|
|
13
13
|
return withClosedFence;
|
|
14
14
|
}
|
|
15
|
+
const NO_ISSUES_SENTENCE = "No confirmed bugs or high-value optimizations found.";
|
|
16
|
+
export function normalizeReviewFindingsMarkdown(input) {
|
|
17
|
+
const normalized = input.replace(/\r\n/g, "\n").trim();
|
|
18
|
+
if (normalized === "" || normalized === NO_ISSUES_SENTENCE)
|
|
19
|
+
return normalized;
|
|
20
|
+
const lines = normalized.split("\n");
|
|
21
|
+
const findings = [];
|
|
22
|
+
const headerRe = /^\s*-?\s*\[(high|medium)\]\s+(.+?)\s*$/i;
|
|
23
|
+
const fileRe = /^\s*[-*]?\s*File:\s*(.+?)\s*$/i;
|
|
24
|
+
const lineRe = /^\s*[-*]?\s*Line:\s*(.+?)\s*$/i;
|
|
25
|
+
const whyRe = /^\s*[-*]?\s*Why:\s*(.+?)\s*$/i;
|
|
26
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
27
|
+
const headerMatch = lines[i].match(headerRe);
|
|
28
|
+
if (headerMatch == null)
|
|
29
|
+
continue;
|
|
30
|
+
const severity = headerMatch[1].toLowerCase();
|
|
31
|
+
const title = headerMatch[2].trim();
|
|
32
|
+
let file = null;
|
|
33
|
+
let line = null;
|
|
34
|
+
let why = null;
|
|
35
|
+
let j = i + 1;
|
|
36
|
+
while (j < lines.length) {
|
|
37
|
+
const nextHeader = lines[j].match(headerRe);
|
|
38
|
+
if (nextHeader != null)
|
|
39
|
+
break;
|
|
40
|
+
if (file == null) {
|
|
41
|
+
const m = lines[j].match(fileRe);
|
|
42
|
+
if (m != null) {
|
|
43
|
+
file = m[1].trim();
|
|
44
|
+
j += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (line == null) {
|
|
49
|
+
const m = lines[j].match(lineRe);
|
|
50
|
+
if (m != null) {
|
|
51
|
+
line = m[1].trim();
|
|
52
|
+
j += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (why == null) {
|
|
57
|
+
const m = lines[j].match(whyRe);
|
|
58
|
+
if (m != null) {
|
|
59
|
+
why = m[1].trim();
|
|
60
|
+
j += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
j += 1;
|
|
65
|
+
}
|
|
66
|
+
if (file != null && line != null && why != null) {
|
|
67
|
+
findings.push({ severity, title, file, line, why });
|
|
68
|
+
i = j - 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (findings.length === 0)
|
|
72
|
+
return normalized;
|
|
73
|
+
return findings
|
|
74
|
+
.map((f) => `- [${f.severity}] ${f.title}\n File: ${f.file}\n Line: ${f.line}\n Why: ${f.why}`)
|
|
75
|
+
.join("\n\n");
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@krotovm/gitlab-ai-review",
|
|
4
|
-
"version": "1.0.
|
|
5
|
-
"description": "CLI tool to generate AI code reviews for GitLab merge requests
|
|
4
|
+
"version": "1.0.22",
|
|
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"
|