@krotovm/gitlab-ai-review 1.0.41 → 1.0.43
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 +2 -2
- package/dist/cli/ci-review.js +43 -32
- package/dist/cli/tooling.js +6 -0
- package/dist/cli.js +48 -50
- package/dist/gitlab/services.js +12 -75
- package/dist/gitlab/types.js +0 -2
- package/dist/prompt/index.js +3 -5
- package/dist/prompt/messages.js +5 -9
- package/dist/prompt/templates/postprocess-system.js +94 -108
- package/dist/prompt/templates/review-system.js +104 -116
- package/dist/prompt/templates/triage-system.js +42 -48
- package/dist/prompt/templates/user-prompts.js +34 -42
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Set these in your project/group CI settings:
|
|
|
76
76
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
77
77
|
- `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
|
|
78
78
|
- `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.
|
|
79
|
-
- `PROJECT_ACCESS_TOKEN` (
|
|
79
|
+
- `PROJECT_ACCESS_TOKEN` (required; project or personal access token with `api` scope — `CI_JOB_TOKEN` can't post MR comments or use the search API)
|
|
80
80
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
81
81
|
- `AI_REVIEW_CONCURRENCY` (optional) — same as `--max-review-concurrency`, handy as a CI/CD variable; the flag wins when both are set.
|
|
82
82
|
- `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
|
|
@@ -88,7 +88,7 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
88
88
|
- `CI_API_V4_URL`
|
|
89
89
|
- `CI_PROJECT_ID`
|
|
90
90
|
- `CI_MERGE_REQUEST_IID`
|
|
91
|
-
- `
|
|
91
|
+
- `CI_MERGE_REQUEST_PROJECT_ID` (the MR's target project; used instead of `CI_PROJECT_ID`, so MRs from forks work)
|
|
92
92
|
|
|
93
93
|
## Flags
|
|
94
94
|
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
5
|
import { parseReviewFindings, truncateWithMarker } from "../prompt/utils.js";
|
|
6
|
-
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
6
|
+
import { logToolFailure, logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
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;
|
|
@@ -14,6 +14,7 @@ let streamUsageSupported = true;
|
|
|
14
14
|
* round, so oversized results multiply prefill time on self-hosted models. */
|
|
15
15
|
const MAX_TOOL_FILE_CHARS = 12_000;
|
|
16
16
|
const MAX_TOOL_GREP_CHARS = 800;
|
|
17
|
+
const MAX_TOOL_GREP_RESULTS = 10;
|
|
17
18
|
function buildTriageDecisions(params) {
|
|
18
19
|
const { changes, triageResult } = params;
|
|
19
20
|
const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
|
|
@@ -143,13 +144,13 @@ function buildReviewMetadata(changes, refs) {
|
|
|
143
144
|
changed_files: files,
|
|
144
145
|
tool_usage_guidance: [
|
|
145
146
|
"If diff context is insufficient, call get_file_at_ref to read a specific file.",
|
|
146
|
-
"Use grep_repository to
|
|
147
|
+
"Use grep_repository to find usages or definitions by literal text (no regex) across the codebase.",
|
|
147
148
|
"Use refs.base to inspect pre-change content and refs.head for current content.",
|
|
148
149
|
"Prefer targeted searches and file fetches; avoid broad context requests.",
|
|
149
150
|
],
|
|
150
151
|
}, null, 2);
|
|
151
152
|
}
|
|
152
|
-
async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
153
|
+
async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep) {
|
|
153
154
|
try {
|
|
154
155
|
const parsed = JSON.parse(argsRaw);
|
|
155
156
|
const path = parsed.path?.trim();
|
|
@@ -161,16 +162,18 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
const fileText = await fetchFileAtRef({
|
|
164
|
-
|
|
165
|
+
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
165
166
|
headers,
|
|
166
167
|
filePath: path,
|
|
167
168
|
ref,
|
|
168
169
|
});
|
|
169
170
|
if (fileText instanceof Error) {
|
|
171
|
+
logToolFailure(logStep, TOOL_NAME_GET_FILE, fileText);
|
|
170
172
|
return JSON.stringify({
|
|
171
173
|
ok: false,
|
|
172
174
|
path,
|
|
173
175
|
ref,
|
|
176
|
+
status: fileText.statusCode,
|
|
174
177
|
error: fileText.message,
|
|
175
178
|
});
|
|
176
179
|
}
|
|
@@ -183,46 +186,57 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
|
183
186
|
});
|
|
184
187
|
}
|
|
185
188
|
catch (error) {
|
|
189
|
+
logToolFailure(logStep, TOOL_NAME_GET_FILE, error);
|
|
186
190
|
return JSON.stringify({
|
|
187
191
|
ok: false,
|
|
188
|
-
error: `
|
|
192
|
+
error: `Tool call failed: ${String(error?.message ?? error)}`,
|
|
189
193
|
raw: argsRaw,
|
|
190
194
|
});
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
|
-
async function handleGrepTool(argsRaw,
|
|
197
|
+
async function handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep) {
|
|
194
198
|
try {
|
|
195
199
|
const parsed = JSON.parse(argsRaw);
|
|
196
200
|
const query = parsed.query?.trim();
|
|
197
201
|
if (!query)
|
|
198
202
|
return JSON.stringify({ ok: false, error: "query is required." });
|
|
199
|
-
|
|
203
|
+
// GitLab search answers 200 [] for unknown refs, so only the MR's own refs are searched.
|
|
204
|
+
const ref = parsed.ref?.trim() === refs.base ? refs.base : refs.head;
|
|
200
205
|
const results = await searchRepository({
|
|
201
|
-
|
|
206
|
+
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
202
207
|
headers,
|
|
203
208
|
query,
|
|
204
209
|
ref,
|
|
205
|
-
projectId,
|
|
206
210
|
});
|
|
207
211
|
if (results instanceof Error) {
|
|
212
|
+
logToolFailure(logStep, TOOL_NAME_GREP, results);
|
|
208
213
|
return JSON.stringify({
|
|
209
214
|
ok: false,
|
|
210
215
|
query,
|
|
211
216
|
ref,
|
|
217
|
+
status: results.statusCode,
|
|
212
218
|
error: results.message,
|
|
213
219
|
});
|
|
214
220
|
}
|
|
215
|
-
|
|
221
|
+
// GitLab lists file-name matches (file head, startline 1) before content matches;
|
|
222
|
+
// rank hits that contain the query first so they aren't crowded out.
|
|
223
|
+
const needle = query.toLowerCase();
|
|
224
|
+
const hasNeedle = (r) => (r.data ?? "").toLowerCase().includes(needle);
|
|
225
|
+
const trimmed = results
|
|
226
|
+
.sort((a, b) => Number(hasNeedle(b)) - Number(hasNeedle(a)))
|
|
227
|
+
.slice(0, MAX_TOOL_GREP_RESULTS)
|
|
228
|
+
.map((r) => ({
|
|
216
229
|
path: r.path,
|
|
217
230
|
startline: r.startline,
|
|
218
|
-
data: r.data.slice(0, MAX_TOOL_GREP_CHARS),
|
|
231
|
+
data: (r.data ?? "").slice(0, MAX_TOOL_GREP_CHARS),
|
|
219
232
|
}));
|
|
220
233
|
return JSON.stringify({ ok: true, query, ref, matches: trimmed });
|
|
221
234
|
}
|
|
222
235
|
catch (error) {
|
|
236
|
+
logToolFailure(logStep, TOOL_NAME_GREP, error);
|
|
223
237
|
return JSON.stringify({
|
|
224
238
|
ok: false,
|
|
225
|
-
error: `
|
|
239
|
+
error: `Tool call failed: ${String(error?.message ?? error)}`,
|
|
226
240
|
raw: argsRaw,
|
|
227
241
|
});
|
|
228
242
|
}
|
|
@@ -241,8 +255,8 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
241
255
|
await Promise.all(workers);
|
|
242
256
|
return results;
|
|
243
257
|
}
|
|
244
|
-
|
|
245
|
-
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl,
|
|
258
|
+
async function reviewMergeRequestWithTools(params) {
|
|
259
|
+
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
246
260
|
const { logDebug, logStep } = loggers;
|
|
247
261
|
logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
|
|
248
262
|
const messages = buildPrompt({
|
|
@@ -279,14 +293,14 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
279
293
|
type: "function",
|
|
280
294
|
function: {
|
|
281
295
|
name: TOOL_NAME_GREP,
|
|
282
|
-
description: "Search the repository for a
|
|
296
|
+
description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
283
297
|
parameters: {
|
|
284
298
|
type: "object",
|
|
285
299
|
additionalProperties: false,
|
|
286
300
|
properties: {
|
|
287
301
|
query: {
|
|
288
302
|
type: "string",
|
|
289
|
-
description: "
|
|
303
|
+
description: "Literal text to find, e.g. a function or variable name (no regex).",
|
|
290
304
|
},
|
|
291
305
|
ref: {
|
|
292
306
|
type: "string",
|
|
@@ -343,10 +357,10 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
343
357
|
logToolUsageMinimal(logStep, toolName, argsRaw);
|
|
344
358
|
let toolContent;
|
|
345
359
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
346
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
360
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
347
361
|
}
|
|
348
362
|
else if (toolName === TOOL_NAME_GREP) {
|
|
349
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
363
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
350
364
|
}
|
|
351
365
|
else {
|
|
352
366
|
toolContent = JSON.stringify({
|
|
@@ -390,7 +404,7 @@ export async function reviewMergeRequestWithTools(params) {
|
|
|
390
404
|
return buildAnswer(finalCompletion);
|
|
391
405
|
}
|
|
392
406
|
async function runFileReviewWithTools(params) {
|
|
393
|
-
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl,
|
|
407
|
+
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
394
408
|
const { logDebug, logStep } = loggers;
|
|
395
409
|
const messages = buildFileReviewPrompt({
|
|
396
410
|
filePath,
|
|
@@ -424,14 +438,14 @@ async function runFileReviewWithTools(params) {
|
|
|
424
438
|
type: "function",
|
|
425
439
|
function: {
|
|
426
440
|
name: TOOL_NAME_GREP,
|
|
427
|
-
description: "Search the repository for a
|
|
441
|
+
description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
428
442
|
parameters: {
|
|
429
443
|
type: "object",
|
|
430
444
|
additionalProperties: false,
|
|
431
445
|
properties: {
|
|
432
446
|
query: {
|
|
433
447
|
type: "string",
|
|
434
|
-
description: "
|
|
448
|
+
description: "Literal text to find, e.g. a function or variable name (no regex).",
|
|
435
449
|
},
|
|
436
450
|
ref: {
|
|
437
451
|
type: "string",
|
|
@@ -487,10 +501,10 @@ async function runFileReviewWithTools(params) {
|
|
|
487
501
|
logToolUsageMinimal(logStep, toolName, argsRaw, filePath);
|
|
488
502
|
let toolContent;
|
|
489
503
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
490
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
504
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
491
505
|
}
|
|
492
506
|
else if (toolName === TOOL_NAME_GREP) {
|
|
493
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
507
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
494
508
|
}
|
|
495
509
|
else {
|
|
496
510
|
toolContent = JSON.stringify({
|
|
@@ -537,7 +551,7 @@ function draftHasStructuredFindings(consolidatedText) {
|
|
|
537
551
|
return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
|
|
538
552
|
}
|
|
539
553
|
async function runVerificationWithTools(params) {
|
|
540
|
-
const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl,
|
|
554
|
+
const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
541
555
|
const { logDebug, logStep } = loggers;
|
|
542
556
|
const messages = [...baseMessages];
|
|
543
557
|
const tools = [
|
|
@@ -564,14 +578,14 @@ async function runVerificationWithTools(params) {
|
|
|
564
578
|
type: "function",
|
|
565
579
|
function: {
|
|
566
580
|
name: TOOL_NAME_GREP,
|
|
567
|
-
description: "Search the repository for a
|
|
581
|
+
description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
568
582
|
parameters: {
|
|
569
583
|
type: "object",
|
|
570
584
|
additionalProperties: false,
|
|
571
585
|
properties: {
|
|
572
586
|
query: {
|
|
573
587
|
type: "string",
|
|
574
|
-
description: "
|
|
588
|
+
description: "Literal text to find, e.g. a function or variable name (no regex).",
|
|
575
589
|
},
|
|
576
590
|
ref: {
|
|
577
591
|
type: "string",
|
|
@@ -627,10 +641,10 @@ async function runVerificationWithTools(params) {
|
|
|
627
641
|
logToolUsageMinimal(logStep, toolName, argsRaw, "(verify)");
|
|
628
642
|
let toolContent;
|
|
629
643
|
if (toolName === TOOL_NAME_GET_FILE) {
|
|
630
|
-
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
644
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
|
|
631
645
|
}
|
|
632
646
|
else if (toolName === TOOL_NAME_GREP) {
|
|
633
|
-
toolContent = await handleGrepTool(argsRaw, refs
|
|
647
|
+
toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
|
|
634
648
|
}
|
|
635
649
|
else {
|
|
636
650
|
toolContent = JSON.stringify({
|
|
@@ -672,7 +686,7 @@ async function runVerificationWithTools(params) {
|
|
|
672
686
|
});
|
|
673
687
|
}
|
|
674
688
|
export async function reviewMergeRequestMultiPass(params) {
|
|
675
|
-
const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl,
|
|
689
|
+
const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
|
|
676
690
|
const { logStep } = loggers;
|
|
677
691
|
let effectiveProfile = promptProfile;
|
|
678
692
|
logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
|
|
@@ -773,7 +787,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
773
787
|
changes,
|
|
774
788
|
refs,
|
|
775
789
|
gitLabProjectApiUrl,
|
|
776
|
-
projectId,
|
|
777
790
|
headers,
|
|
778
791
|
forceTools,
|
|
779
792
|
promptProfile: effectiveProfile,
|
|
@@ -821,7 +834,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
821
834
|
otherChangedFiles: otherFiles,
|
|
822
835
|
refs,
|
|
823
836
|
gitLabProjectApiUrl,
|
|
824
|
-
projectId,
|
|
825
837
|
headers,
|
|
826
838
|
forceTools,
|
|
827
839
|
promptProfile: effectiveProfile,
|
|
@@ -909,7 +921,6 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
909
921
|
baseMessages: verificationMessages,
|
|
910
922
|
refs,
|
|
911
923
|
gitLabProjectApiUrl,
|
|
912
|
-
projectId,
|
|
913
924
|
headers,
|
|
914
925
|
forceTools,
|
|
915
926
|
consolidatedDraft: consolidatedText,
|
package/dist/cli/tooling.js
CHANGED
|
@@ -27,3 +27,9 @@ export function logToolUsageMinimal(logStep, toolName, argsRaw, contextFile) {
|
|
|
27
27
|
const suffix = contextFile ? ` review_file=${contextFile}` : "";
|
|
28
28
|
logStep(`[tools] ${toolName} args=${argsRaw.slice(0, 120)}${suffix}`);
|
|
29
29
|
}
|
|
30
|
+
/** Operator-facing line for a failed tool call: GitLab status and request URL when known. */
|
|
31
|
+
export function logToolFailure(logStep, toolName, error) {
|
|
32
|
+
const status = error?.statusCode != null ? ` status=${error.statusCode}` : "";
|
|
33
|
+
const url = typeof error?.cause?.url === "string" ? ` url=${error.cause.url}` : "";
|
|
34
|
+
logStep(`[tools] ${toolName} failed: ${String(error?.message ?? error)}${status}${url}`);
|
|
35
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,43 +8,41 @@ import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
|
|
|
8
8
|
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
9
9
|
import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
|
|
10
10
|
function printHelp() {
|
|
11
|
-
process.stdout.write(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"",
|
|
47
|
-
].join("\n"));
|
|
11
|
+
process.stdout.write(`gitlab-ai-review
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
gitlab-ai-review
|
|
15
|
+
gitlab-ai-review --help
|
|
16
|
+
gitlab-ai-review --debug
|
|
17
|
+
gitlab-ai-review --ignore-ext=md,lock
|
|
18
|
+
|
|
19
|
+
Debug:
|
|
20
|
+
--debug Print full error details (stack, API error fields).
|
|
21
|
+
--include-artifacts Generate local HTML artifact without printing payloads to console.
|
|
22
|
+
--force-tools Force at least one tool-call round in tool-enabled review paths.
|
|
23
|
+
--ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock
|
|
24
|
+
--max-diffs=50
|
|
25
|
+
--max-diff-chars=16000
|
|
26
|
+
--max-total-prompt-chars=220000
|
|
27
|
+
--triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).
|
|
28
|
+
--max-findings=5 Max findings in final review (CI multi-pass only).
|
|
29
|
+
--max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).
|
|
30
|
+
|
|
31
|
+
Env vars:
|
|
32
|
+
OPENAI_API_KEY (required) OpenAI API key.
|
|
33
|
+
OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.
|
|
34
|
+
AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.
|
|
35
|
+
AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.
|
|
36
|
+
AI_PROMPT_PROFILE (optional) Prompt style: "default" | "weak". Default: default.
|
|
37
|
+
Use "weak" for small/quantized models — shorter prompts,
|
|
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.
|
|
41
|
+
PROJECT_ACCESS_TOKEN (required) GitLab project/personal access token with api scope (alias: GITLAB_TOKEN). CI_JOB_TOKEN can't post MR comments.
|
|
42
|
+
|
|
43
|
+
CI-only env vars (provided by GitLab):
|
|
44
|
+
CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_PROJECT_ID, CI_MERGE_REQUEST_IID
|
|
45
|
+
`);
|
|
48
46
|
}
|
|
49
47
|
const DEBUG_MODE = hasDebugFlag(process.argv);
|
|
50
48
|
const FORCE_TOOLS = hasForceToolsFlag(process.argv);
|
|
@@ -109,24 +107,24 @@ async function main() {
|
|
|
109
107
|
"CI_PROJECT_ID",
|
|
110
108
|
"CI_MERGE_REQUEST_IID",
|
|
111
109
|
];
|
|
110
|
+
// CI_JOB_TOKEN can't post MR notes or call the search API, so a real token is required.
|
|
112
111
|
if (projectAccessToken == null)
|
|
113
|
-
gitlabRequired.push("
|
|
112
|
+
gitlabRequired.push("PROJECT_ACCESS_TOKEN");
|
|
114
113
|
const envs = requireEnvs(gitlabRequired);
|
|
115
114
|
const openaiApiKey = envs["OPENAI_API_KEY"];
|
|
116
115
|
const ciApiV4Url = envs["CI_API_V4_URL"];
|
|
117
|
-
|
|
116
|
+
// MR endpoints live in the MR's target project; in fork pipelines CI_PROJECT_ID is the fork.
|
|
117
|
+
const projectId = envOrUndefined("CI_MERGE_REQUEST_PROJECT_ID") ?? envs["CI_PROJECT_ID"];
|
|
118
118
|
const mergeRequestIid = envs["CI_MERGE_REQUEST_IID"];
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
|
|
119
|
+
const gitLabProjectApiUrl = new URL(`${ciApiV4Url}/projects/${projectId}`);
|
|
120
|
+
const headers = {
|
|
121
|
+
"PRIVATE-TOKEN": projectAccessToken,
|
|
122
|
+
};
|
|
124
123
|
try {
|
|
125
124
|
logStep("Fetching merge request changes");
|
|
126
125
|
const mrChanges = await fetchMergeRequestChanges({
|
|
127
|
-
|
|
126
|
+
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
128
127
|
headers,
|
|
129
|
-
projectId,
|
|
130
128
|
mergeRequestIid,
|
|
131
129
|
});
|
|
132
130
|
if (mrChanges instanceof Error)
|
|
@@ -149,8 +147,7 @@ async function main() {
|
|
|
149
147
|
base: mrChanges.diff_refs?.base_sha ?? "HEAD",
|
|
150
148
|
head: mrChanges.diff_refs?.head_sha ?? "HEAD",
|
|
151
149
|
},
|
|
152
|
-
gitLabProjectApiUrl:
|
|
153
|
-
projectId,
|
|
150
|
+
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
154
151
|
headers,
|
|
155
152
|
maxFindings,
|
|
156
153
|
reviewConcurrency,
|
|
@@ -162,10 +159,11 @@ async function main() {
|
|
|
162
159
|
});
|
|
163
160
|
logStep("Posting AI review note to merge request");
|
|
164
161
|
const noteRes = await postMergeRequestNote({
|
|
165
|
-
|
|
162
|
+
gitLabProjectApiUrl: gitLabProjectApiUrl,
|
|
166
163
|
headers,
|
|
167
164
|
mergeRequestIid,
|
|
168
|
-
|
|
165
|
+
note: answer,
|
|
166
|
+
});
|
|
169
167
|
if (noteRes instanceof Error)
|
|
170
168
|
throw noteRes;
|
|
171
169
|
process.stdout.write("Posted AI review comment to merge request.\n");
|
package/dist/gitlab/services.js
CHANGED
|
@@ -1,49 +1,7 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export const fetchPreEditFiles = async ({ gitLabBaseUrl, headers, changesOldPaths, ref }) => {
|
|
6
|
-
const oldFilesRequestUrls = changesOldPaths.map((filePath) => {
|
|
7
|
-
const url = new URL(`${gitLabBaseUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
|
|
8
|
-
url.searchParams.set("ref", ref);
|
|
9
|
-
return url;
|
|
10
|
-
});
|
|
11
|
-
let oldFiles;
|
|
12
|
-
try {
|
|
13
|
-
oldFiles = await Promise.allSettled(oldFilesRequestUrls.map(async (url) => {
|
|
14
|
-
const res = await fetch(url, { headers: { ...headers } });
|
|
15
|
-
if (!res.ok) {
|
|
16
|
-
const bodyText = await res.text().catch(() => "");
|
|
17
|
-
throw new Error(`Failed to fetch old file: ${url.toString()} (status ${res.status} ${res.statusText}, body: ${bodyText.slice(0, 500)})`);
|
|
18
|
-
}
|
|
19
|
-
return await res.text();
|
|
20
|
-
}));
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
oldFiles = error;
|
|
24
|
-
}
|
|
25
|
-
if (oldFiles instanceof Error) {
|
|
26
|
-
return new GitLabError({
|
|
27
|
-
name: "MISSING_OLD_FILES",
|
|
28
|
-
message: "Failed to fetch old files",
|
|
29
|
-
cause: {
|
|
30
|
-
message: oldFiles.message,
|
|
31
|
-
stack: oldFiles.stack,
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
return oldFiles.reduce((acc, file, index) => {
|
|
36
|
-
if (file.status === "fulfilled") {
|
|
37
|
-
acc.push({
|
|
38
|
-
fileName: changesOldPaths[index],
|
|
39
|
-
fileContent: file.value,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
return acc;
|
|
43
|
-
}, []);
|
|
44
|
-
};
|
|
45
|
-
export const fetchFileAtRef = async ({ gitLabBaseUrl, headers, filePath, ref }) => {
|
|
46
|
-
const url = new URL(`${gitLabBaseUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
|
|
2
|
+
import { GitLabError, } from "./types.js";
|
|
3
|
+
export const fetchFileAtRef = async ({ gitLabProjectApiUrl, headers, filePath, ref }) => {
|
|
4
|
+
const url = new URL(`${gitLabProjectApiUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
|
|
47
5
|
url.searchParams.set("ref", ref);
|
|
48
6
|
let res;
|
|
49
7
|
try {
|
|
@@ -81,30 +39,8 @@ export const fetchFileAtRef = async ({ gitLabBaseUrl, headers, filePath, ref })
|
|
|
81
39
|
}
|
|
82
40
|
return await res.text();
|
|
83
41
|
};
|
|
84
|
-
export async
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
completion = await openaiInstance.chat.completions.create({
|
|
88
|
-
model: aiModel,
|
|
89
|
-
temperature: AI_MODEL_TEMPERATURE,
|
|
90
|
-
stream: false,
|
|
91
|
-
messages,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
completion = error;
|
|
96
|
-
}
|
|
97
|
-
if (completion instanceof Error) {
|
|
98
|
-
return new OpenAIError({
|
|
99
|
-
name: "MISSING_AI_COMPLETION",
|
|
100
|
-
message: "Failed to generate AI completion",
|
|
101
|
-
cause: completion,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return completion;
|
|
105
|
-
}
|
|
106
|
-
export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeRequestIid }, commentPayload) => {
|
|
107
|
-
const commentUrl = new URL(`${gitLabBaseUrl}/merge_requests/${mergeRequestIid}/notes`);
|
|
42
|
+
export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, note }) => {
|
|
43
|
+
const commentUrl = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/notes`);
|
|
108
44
|
let aiComment;
|
|
109
45
|
try {
|
|
110
46
|
aiComment = await fetch(commentUrl, {
|
|
@@ -113,7 +49,7 @@ export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeReques
|
|
|
113
49
|
...headers,
|
|
114
50
|
"Content-Type": "application/json",
|
|
115
51
|
},
|
|
116
|
-
body: JSON.stringify(
|
|
52
|
+
body: JSON.stringify({ body: note }),
|
|
117
53
|
});
|
|
118
54
|
}
|
|
119
55
|
catch (error) {
|
|
@@ -147,12 +83,13 @@ export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeReques
|
|
|
147
83
|
});
|
|
148
84
|
}
|
|
149
85
|
};
|
|
150
|
-
export const searchRepository = async ({
|
|
151
|
-
const url = new URL(`${
|
|
86
|
+
export const searchRepository = async ({ gitLabProjectApiUrl, headers, query, ref }) => {
|
|
87
|
+
const url = new URL(`${gitLabProjectApiUrl}/search`);
|
|
152
88
|
url.searchParams.set("scope", "blobs");
|
|
153
89
|
url.searchParams.set("search", query);
|
|
154
90
|
url.searchParams.set("ref", ref);
|
|
155
|
-
|
|
91
|
+
// Name matches come before content matches; the grep tool re-ranks and keeps 10.
|
|
92
|
+
url.searchParams.set("per_page", "50");
|
|
156
93
|
let res;
|
|
157
94
|
try {
|
|
158
95
|
res = await fetch(url, { headers: { ...headers } });
|
|
@@ -185,8 +122,8 @@ export const searchRepository = async ({ gitLabBaseUrl, headers, query, ref, pro
|
|
|
185
122
|
}
|
|
186
123
|
return (await res.json());
|
|
187
124
|
};
|
|
188
|
-
export const fetchMergeRequestChanges = async ({
|
|
189
|
-
const url = new URL(`${
|
|
125
|
+
export const fetchMergeRequestChanges = async ({ gitLabProjectApiUrl, headers, mergeRequestIid }) => {
|
|
126
|
+
const url = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/changes`);
|
|
190
127
|
// Ask GitLab for raw diffs to reduce truncation on larger merge requests.
|
|
191
128
|
url.searchParams.set("access_raw_diffs", "true");
|
|
192
129
|
let res;
|
package/dist/gitlab/types.js
CHANGED
package/dist/prompt/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import { buildFileReviewSystemMessage, buildMainSystemMessages, buildTriageSystemMessage, } from "./messages.js";
|
|
3
3
|
import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getConsolidateSystemPrompt, getVerificationSystemPrompt, } from "./templates/postprocess-system.js";
|
|
5
5
|
import { buildConsolidateUserContent, buildFileReviewUserContent, buildMainReviewUserContent, buildTriageUserContent, buildVerificationUserContent, } from "./templates/user-prompts.js";
|
|
6
6
|
import { DEFAULT_PROMPT_PROFILE, isEmptyReviewBody } from "./profile.js";
|
|
7
7
|
export { DEFAULT_PROMPT_PROFILE, NO_FINDINGS_SENTENCE, isEmptyReviewBody, parsePromptProfile, } from "./profile.js";
|
|
@@ -12,8 +12,6 @@ export const DEFAULT_PROMPT_LIMITS = {
|
|
|
12
12
|
};
|
|
13
13
|
/** Max chars of each file diff sent to the triage pass (Pass 1). */
|
|
14
14
|
export const DEFAULT_TRIAGE_DIFF_CHARS = 2000;
|
|
15
|
-
export const AI_MODEL_TEMPERATURE = 0.2;
|
|
16
|
-
export const AI_MAX_OUTPUT_TOKENS = 600;
|
|
17
15
|
export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
|
|
18
16
|
const effectiveLimits = {
|
|
19
17
|
...DEFAULT_PROMPT_LIMITS,
|
|
@@ -160,7 +158,7 @@ export function buildConsolidatePrompt(params) {
|
|
|
160
158
|
return [
|
|
161
159
|
{
|
|
162
160
|
role: "system",
|
|
163
|
-
content:
|
|
161
|
+
content: getConsolidateSystemPrompt(profile, maxFindings),
|
|
164
162
|
},
|
|
165
163
|
{
|
|
166
164
|
role: "user",
|
|
@@ -180,7 +178,7 @@ export function buildVerificationPrompt(params) {
|
|
|
180
178
|
return [
|
|
181
179
|
{
|
|
182
180
|
role: "system",
|
|
183
|
-
content:
|
|
181
|
+
content: getVerificationSystemPrompt(profile, maxFindings),
|
|
184
182
|
},
|
|
185
183
|
{
|
|
186
184
|
role: "user",
|
package/dist/prompt/messages.js
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { getFileReviewSystemPrompt, getMainSystemPrompt, } from "./templates/review-system.js";
|
|
3
|
+
import { getTriageSystemPrompt } from "./templates/triage-system.js";
|
|
4
4
|
import { DEFAULT_PROMPT_PROFILE } from "./profile.js";
|
|
5
5
|
export const buildMainSystemMessages = (profile = DEFAULT_PROMPT_PROFILE) => [
|
|
6
6
|
{
|
|
7
7
|
role: "system",
|
|
8
|
-
content:
|
|
8
|
+
content: getMainSystemPrompt(profile),
|
|
9
9
|
},
|
|
10
10
|
];
|
|
11
11
|
export const buildTriageSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
|
|
12
12
|
role: "system",
|
|
13
|
-
content:
|
|
13
|
+
content: getTriageSystemPrompt(profile),
|
|
14
14
|
});
|
|
15
15
|
export const buildFileReviewSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
|
|
16
16
|
role: "system",
|
|
17
|
-
content:
|
|
17
|
+
content: getFileReviewSystemPrompt(profile),
|
|
18
18
|
});
|
|
19
|
-
/** @deprecated kept for backward compatibility — use buildTriageSystemMessage(). */
|
|
20
|
-
export const TRIAGE_SYSTEM = buildTriageSystemMessage();
|
|
21
|
-
/** @deprecated kept for backward compatibility — use buildFileReviewSystemMessage(). */
|
|
22
|
-
export const FILE_REVIEW_SYSTEM = buildFileReviewSystemMessage();
|