@krotovm/gitlab-ai-review 1.0.17 → 1.0.19
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 +7 -29
- package/dist/cli/args.js +89 -0
- package/dist/cli/ci-review.js +440 -0
- package/dist/cli/tooling.js +27 -0
- package/dist/cli.js +35 -887
- package/dist/errors.js +0 -1
- package/dist/gitlab/services.js +2 -2
- package/dist/gitlab/types.js +0 -1
- package/dist/prompt/index.js +10 -128
- package/dist/prompt/messages.js +145 -0
- package/dist/prompt/utils.js +14 -0
- package/package.json +2 -2
- package/dist/cli.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/gitlab/services.js.map +0 -1
- package/dist/gitlab/types.js.map +0 -1
- package/dist/prompt/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# AI Code Reviewer
|
|
4
4
|
|
|
5
|
-
Gitlab AI Code Review is a CLI tool that leverages OpenAI models to automatically review code changes and
|
|
5
|
+
Gitlab AI Code Review is a CLI tool that leverages OpenAI models to automatically review code changes and post a Markdown review to GitLab merge requests from CI.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- Automatically reviews code changes in GitLab repositories
|
|
10
10
|
- Provides feedback on bugs and optimization opportunities
|
|
11
|
-
- Generates Markdown-formatted responses for easy readability in GitLab
|
|
11
|
+
- Generates Markdown-formatted responses for easy readability in GitLab as merge request comment
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
@@ -27,25 +27,7 @@ ai_review:
|
|
|
27
27
|
rules:
|
|
28
28
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
29
29
|
script:
|
|
30
|
-
- npx -y @krotovm/gitlab-ai-review
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
### Local CLI
|
|
34
|
-
|
|
35
|
-
Run locally to review diffs and print the review to stdout.
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
# review local uncommitted changes
|
|
39
|
-
npx -y @krotovm/gitlab-ai-review --worktree
|
|
40
|
-
|
|
41
|
-
# review last commit and ignore docs/lock changes by extension
|
|
42
|
-
npx -y @krotovm/gitlab-ai-review --last-commit --ignore-ext=md,lock
|
|
43
|
-
|
|
44
|
-
# review a prepared git diff from file
|
|
45
|
-
npx -y @krotovm/gitlab-ai-review --diff-file=./changes.diff
|
|
46
|
-
|
|
47
|
-
# use a custom OpenAI-compatible endpoint
|
|
48
|
-
OPENAI_BASE_URL="https://api.openai.com/v1" npx -y @krotovm/gitlab-ai-review --worktree
|
|
30
|
+
- npx -y @krotovm/gitlab-ai-review
|
|
49
31
|
```
|
|
50
32
|
|
|
51
33
|
## Env variables
|
|
@@ -69,14 +51,10 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
69
51
|
|
|
70
52
|
## Flags
|
|
71
53
|
|
|
72
|
-
- `--ci` - Run in GitLab MR pipeline mode and post a new MR note.
|
|
73
|
-
- `--worktree` - Review local uncommitted changes (staged + unstaged).
|
|
74
|
-
- `--last-commit` - Review the last commit (`HEAD`).
|
|
75
|
-
- `--diff-file=./changes.diff` - Review git-diff content from a file and print to stdout.
|
|
76
54
|
- `--ignore-ext=md,lock` - Exclude file extensions from review (comma-separated only).
|
|
77
55
|
- `--max-diffs=50` - Max number of diffs included in the prompt.
|
|
78
|
-
- `--max-diff-chars=16000` - Max chars per diff chunk (
|
|
79
|
-
- `--max-total-prompt-chars=220000` - Final hard cap for prompt size (
|
|
56
|
+
- `--max-diff-chars=16000` - Max chars per diff chunk (single-pass fallback only).
|
|
57
|
+
- `--max-total-prompt-chars=220000` - Final hard cap for prompt size (single-pass fallback only).
|
|
80
58
|
- `--max-findings=5` - Max findings in the final review (CI multi-pass only).
|
|
81
59
|
- `--max-review-concurrency=5` - Parallel per-file review API calls (CI multi-pass only).
|
|
82
60
|
- `--debug` - Print full error details (stack and API error fields).
|
|
@@ -84,7 +62,7 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
84
62
|
|
|
85
63
|
## Architecture
|
|
86
64
|
|
|
87
|
-
|
|
65
|
+
The reviewer uses a three-pass pipeline optimised for large merge requests:
|
|
88
66
|
|
|
89
67
|
1. **Triage** — a fast LLM call classifies each changed file as `NEEDS_REVIEW` or `SKIP` (cosmetic-only changes, docs, config) and produces a short MR summary.
|
|
90
68
|
2. **Per-file review** — only `NEEDS_REVIEW` files are reviewed, each in a dedicated LLM call running in parallel (with tool access to fetch full files or grep the repository). Each file gets full model attention instead of competing for it across a single giant prompt.
|
|
@@ -92,4 +70,4 @@ In CI MR mode, the reviewer uses a three-pass pipeline optimised for large merge
|
|
|
92
70
|
|
|
93
71
|
If the triage pass fails (API error, unparseable response), the pipeline falls back to the original single-pass approach automatically.
|
|
94
72
|
|
|
95
|
-
|
|
73
|
+
The CI pipeline falls back to a simpler single-pass review automatically when triage cannot be used.
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
import { DEFAULT_PROMPT_LIMITS, } from "../prompt/index.js";
|
|
3
|
+
export function requireEnvs(names) {
|
|
4
|
+
const missing = names.filter((name) => {
|
|
5
|
+
const value = process.env[name];
|
|
6
|
+
return value == null || value.trim() === "";
|
|
7
|
+
});
|
|
8
|
+
if (missing.length > 0) {
|
|
9
|
+
throw new Error(`Missing required env vars: ${missing.join(", ")}`);
|
|
10
|
+
}
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const name of names) {
|
|
13
|
+
result[name] = process.env[name].trim();
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
export function envOrDefault(name, defaultValue) {
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value == null)
|
|
20
|
+
return defaultValue;
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
return trimmed === "" ? defaultValue : trimmed;
|
|
23
|
+
}
|
|
24
|
+
export function envOrUndefined(name) {
|
|
25
|
+
const value = process.env[name];
|
|
26
|
+
if (value == null)
|
|
27
|
+
return undefined;
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed === "" ? undefined : trimmed;
|
|
30
|
+
}
|
|
31
|
+
export function hasDebugFlag(argv) {
|
|
32
|
+
const args = new Set(argv.slice(2));
|
|
33
|
+
return args.has("--debug");
|
|
34
|
+
}
|
|
35
|
+
export function hasForceToolsFlag(argv) {
|
|
36
|
+
const args = new Set(argv.slice(2));
|
|
37
|
+
return args.has("--force-tools");
|
|
38
|
+
}
|
|
39
|
+
export function parseIgnoreExtensions(argv) {
|
|
40
|
+
const parsed = [];
|
|
41
|
+
const args = argv.slice(2);
|
|
42
|
+
for (const current of args) {
|
|
43
|
+
if (current === "--ignore-ext") {
|
|
44
|
+
throw new Error("Use comma-separated format: --ignore-ext=md,lock");
|
|
45
|
+
}
|
|
46
|
+
if (!current.startsWith("--ignore-ext="))
|
|
47
|
+
continue;
|
|
48
|
+
const value = current.slice("--ignore-ext=".length);
|
|
49
|
+
if (value.trim() === "")
|
|
50
|
+
continue;
|
|
51
|
+
const pieces = value
|
|
52
|
+
.split(",")
|
|
53
|
+
.map((part) => part.trim().toLowerCase())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
for (const piece of pieces) {
|
|
56
|
+
parsed.push(piece.startsWith(".") ? piece : `.${piece}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return Array.from(new Set(parsed));
|
|
60
|
+
}
|
|
61
|
+
export function parseNumberFlag(argv, flagName, defaultValue, minValue) {
|
|
62
|
+
const prefix = `--${flagName}=`;
|
|
63
|
+
let value = defaultValue;
|
|
64
|
+
for (const arg of argv.slice(2)) {
|
|
65
|
+
if (!arg.startsWith(prefix))
|
|
66
|
+
continue;
|
|
67
|
+
const raw = arg.slice(prefix.length).trim();
|
|
68
|
+
if (raw === "") {
|
|
69
|
+
throw new Error(`Missing value for --${flagName}. Expected integer.`);
|
|
70
|
+
}
|
|
71
|
+
const parsed = Number(raw);
|
|
72
|
+
if (!Number.isInteger(parsed) || parsed < minValue) {
|
|
73
|
+
throw new Error(`Invalid value for --${flagName}: "${raw}". Expected integer >= ${minValue}.`);
|
|
74
|
+
}
|
|
75
|
+
value = parsed;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
export function parsePromptLimits(argv) {
|
|
80
|
+
return {
|
|
81
|
+
maxDiffs: parseNumberFlag(argv, "max-diffs", DEFAULT_PROMPT_LIMITS.maxDiffs, 0),
|
|
82
|
+
maxDiffChars: parseNumberFlag(argv, "max-diff-chars", DEFAULT_PROMPT_LIMITS.maxDiffChars, 1),
|
|
83
|
+
maxTotalPromptChars: parseNumberFlag(argv, "max-total-prompt-chars", DEFAULT_PROMPT_LIMITS.maxTotalPromptChars, 1),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function hasIgnoredExtension(filePath, ignoredExtensions) {
|
|
87
|
+
const lowerPath = filePath.toLowerCase();
|
|
88
|
+
return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
|
|
89
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import { AI_MAX_OUTPUT_TOKENS, buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
|
|
4
|
+
import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
|
|
5
|
+
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
6
|
+
function buildReviewMetadata(changes, refs) {
|
|
7
|
+
const files = changes.map((change, index) => ({
|
|
8
|
+
index: index + 1,
|
|
9
|
+
old_path: change.old_path,
|
|
10
|
+
new_path: change.new_path,
|
|
11
|
+
new_file: change.new_file ?? false,
|
|
12
|
+
deleted_file: change.deleted_file ?? false,
|
|
13
|
+
renamed_file: change.renamed_file ?? false,
|
|
14
|
+
}));
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
refs,
|
|
17
|
+
changed_files: files,
|
|
18
|
+
tool_usage_guidance: [
|
|
19
|
+
"If diff context is insufficient, call get_file_at_ref to read a specific file.",
|
|
20
|
+
"Use grep_repository to search for usages, definitions, or patterns across the codebase.",
|
|
21
|
+
"Use refs.base to inspect pre-change content and refs.head for current content.",
|
|
22
|
+
"Prefer targeted searches and file fetches; avoid broad context requests.",
|
|
23
|
+
],
|
|
24
|
+
}, null, 2);
|
|
25
|
+
}
|
|
26
|
+
async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(argsRaw);
|
|
29
|
+
const path = parsed.path?.trim();
|
|
30
|
+
const ref = parsed.ref?.trim();
|
|
31
|
+
if (!path || !ref) {
|
|
32
|
+
return JSON.stringify({ ok: false, error: "Both path and ref are required." });
|
|
33
|
+
}
|
|
34
|
+
const fileText = await fetchFileAtRef({
|
|
35
|
+
gitLabBaseUrl: gitLabProjectApiUrl,
|
|
36
|
+
headers,
|
|
37
|
+
filePath: path,
|
|
38
|
+
ref,
|
|
39
|
+
});
|
|
40
|
+
if (fileText instanceof Error) {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
ok: false,
|
|
43
|
+
path,
|
|
44
|
+
ref,
|
|
45
|
+
error: fileText.message,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify({
|
|
49
|
+
ok: true,
|
|
50
|
+
path,
|
|
51
|
+
ref,
|
|
52
|
+
content: fileText.slice(0, 30000),
|
|
53
|
+
truncated: fileText.length > 30000,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return JSON.stringify({
|
|
58
|
+
ok: false,
|
|
59
|
+
error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
|
|
60
|
+
raw: argsRaw,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function handleGrepTool(argsRaw, defaultRef, gitLabProjectApiUrl, headers, projectId) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(argsRaw);
|
|
67
|
+
const query = parsed.query?.trim();
|
|
68
|
+
if (!query)
|
|
69
|
+
return JSON.stringify({ ok: false, error: "query is required." });
|
|
70
|
+
const ref = parsed.ref?.trim() || defaultRef;
|
|
71
|
+
const results = await searchRepository({
|
|
72
|
+
gitLabBaseUrl: gitLabProjectApiUrl,
|
|
73
|
+
headers,
|
|
74
|
+
query,
|
|
75
|
+
ref,
|
|
76
|
+
projectId,
|
|
77
|
+
});
|
|
78
|
+
if (results instanceof Error) {
|
|
79
|
+
return JSON.stringify({
|
|
80
|
+
ok: false,
|
|
81
|
+
query,
|
|
82
|
+
ref,
|
|
83
|
+
error: results.message,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const trimmed = results.map((r) => ({
|
|
87
|
+
path: r.path,
|
|
88
|
+
startline: r.startline,
|
|
89
|
+
data: r.data.slice(0, 2000),
|
|
90
|
+
}));
|
|
91
|
+
return JSON.stringify({ ok: true, query, ref, matches: trimmed });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
|
|
97
|
+
raw: argsRaw,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
102
|
+
const results = new Array(items.length);
|
|
103
|
+
let nextIndex = 0;
|
|
104
|
+
async function worker() {
|
|
105
|
+
while (nextIndex < items.length) {
|
|
106
|
+
const idx = nextIndex;
|
|
107
|
+
nextIndex += 1;
|
|
108
|
+
results[idx] = await fn(items[idx]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
|
112
|
+
await Promise.all(workers);
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
export async function reviewMergeRequestWithTools(params) {
|
|
116
|
+
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, } = params;
|
|
117
|
+
const { logDebug, logStep } = loggers;
|
|
118
|
+
const messages = buildPrompt({
|
|
119
|
+
changes: changes.map((change) => ({ diff: change.diff })),
|
|
120
|
+
limits: promptLimits,
|
|
121
|
+
allowTools: true,
|
|
122
|
+
});
|
|
123
|
+
messages.push({
|
|
124
|
+
role: "user",
|
|
125
|
+
content: `Merge request metadata:\n${buildReviewMetadata(changes, refs)}`,
|
|
126
|
+
});
|
|
127
|
+
const tools = [
|
|
128
|
+
{
|
|
129
|
+
type: "function",
|
|
130
|
+
function: {
|
|
131
|
+
name: TOOL_NAME_GET_FILE,
|
|
132
|
+
description: "Fetch raw file content at a specific git ref for review context.",
|
|
133
|
+
parameters: {
|
|
134
|
+
type: "object",
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
properties: {
|
|
137
|
+
path: { type: "string", description: "Repository file path." },
|
|
138
|
+
ref: {
|
|
139
|
+
type: "string",
|
|
140
|
+
description: `Git ref or sha. Prefer "${refs.base}" (base) or "${refs.head}" (head).`,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
required: ["path", "ref"],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: "function",
|
|
149
|
+
function: {
|
|
150
|
+
name: TOOL_NAME_GREP,
|
|
151
|
+
description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
152
|
+
parameters: {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
query: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "Search string (keyword, function name, variable, etc.).",
|
|
159
|
+
},
|
|
160
|
+
ref: {
|
|
161
|
+
type: "string",
|
|
162
|
+
description: `Git ref to search in. Prefer "${refs.head}" (head).`,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
required: ["query"],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
171
|
+
const completion = await openaiInstance.chat.completions.create({
|
|
172
|
+
model: aiModel,
|
|
173
|
+
temperature: 0.2,
|
|
174
|
+
stream: false,
|
|
175
|
+
messages,
|
|
176
|
+
tools,
|
|
177
|
+
tool_choice: forceTools && round === 0 ? "required" : "auto",
|
|
178
|
+
});
|
|
179
|
+
const message = completion.choices[0]?.message;
|
|
180
|
+
if (message == null)
|
|
181
|
+
return buildAnswer(completion);
|
|
182
|
+
const toolCalls = message.tool_calls ?? [];
|
|
183
|
+
logDebug(`main-review round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
|
|
184
|
+
if (toolCalls.length === 0)
|
|
185
|
+
return buildAnswer(completion);
|
|
186
|
+
messages.push({
|
|
187
|
+
role: "assistant",
|
|
188
|
+
content: message.content ?? "",
|
|
189
|
+
tool_calls: toolCalls,
|
|
190
|
+
});
|
|
191
|
+
for (const toolCall of toolCalls) {
|
|
192
|
+
const argsRaw = toolCall.function.arguments ?? "{}";
|
|
193
|
+
logToolUsageMinimal(logStep, toolCall.function.name, argsRaw);
|
|
194
|
+
let toolContent;
|
|
195
|
+
if (toolCall.function.name === TOOL_NAME_GET_FILE) {
|
|
196
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
197
|
+
}
|
|
198
|
+
else if (toolCall.function.name === TOOL_NAME_GREP) {
|
|
199
|
+
toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
toolContent = JSON.stringify({
|
|
203
|
+
ok: false,
|
|
204
|
+
error: `Unknown tool "${toolCall.function.name}"`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
messages.push({
|
|
208
|
+
role: "tool",
|
|
209
|
+
tool_call_id: toolCall.id,
|
|
210
|
+
content: toolContent,
|
|
211
|
+
});
|
|
212
|
+
logDebug(`tool response id=${toolCall.id} name=${toolCall.function.name} payload=${toolContent.slice(0, 300)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
messages.push({
|
|
216
|
+
role: "user",
|
|
217
|
+
content: `Tool-call limit reached (${MAX_TOOL_ROUNDS}). Do not call any tools. Provide your best-effort final review now, strictly following the required output format. If confidence is low, return the exact no-issues sentence.`,
|
|
218
|
+
});
|
|
219
|
+
const finalCompletion = await openaiInstance.chat.completions.create({
|
|
220
|
+
model: aiModel,
|
|
221
|
+
temperature: 0.2,
|
|
222
|
+
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
223
|
+
stream: false,
|
|
224
|
+
messages,
|
|
225
|
+
});
|
|
226
|
+
return buildAnswer(finalCompletion);
|
|
227
|
+
}
|
|
228
|
+
async function runFileReviewWithTools(params) {
|
|
229
|
+
const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, } = params;
|
|
230
|
+
const { logDebug, logStep } = loggers;
|
|
231
|
+
const messages = buildFileReviewPrompt({
|
|
232
|
+
filePath,
|
|
233
|
+
fileDiff,
|
|
234
|
+
summary,
|
|
235
|
+
otherChangedFiles,
|
|
236
|
+
allowTools: true,
|
|
237
|
+
});
|
|
238
|
+
const tools = [
|
|
239
|
+
{
|
|
240
|
+
type: "function",
|
|
241
|
+
function: {
|
|
242
|
+
name: TOOL_NAME_GET_FILE,
|
|
243
|
+
description: "Fetch raw file content at a specific git ref for review context.",
|
|
244
|
+
parameters: {
|
|
245
|
+
type: "object",
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
properties: {
|
|
248
|
+
path: { type: "string", description: "Repository file path." },
|
|
249
|
+
ref: {
|
|
250
|
+
type: "string",
|
|
251
|
+
description: `Git ref or sha. Prefer "${refs.base}" (base) or "${refs.head}" (head).`,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["path", "ref"],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
type: "function",
|
|
260
|
+
function: {
|
|
261
|
+
name: TOOL_NAME_GREP,
|
|
262
|
+
description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
|
|
263
|
+
parameters: {
|
|
264
|
+
type: "object",
|
|
265
|
+
additionalProperties: false,
|
|
266
|
+
properties: {
|
|
267
|
+
query: {
|
|
268
|
+
type: "string",
|
|
269
|
+
description: "Search string (keyword, function name, variable, etc.).",
|
|
270
|
+
},
|
|
271
|
+
ref: {
|
|
272
|
+
type: "string",
|
|
273
|
+
description: `Git ref to search in. Prefer "${refs.head}" (head).`,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ["query"],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
for (let round = 0; round < MAX_FILE_TOOL_ROUNDS; round += 1) {
|
|
282
|
+
const completion = await openaiInstance.chat.completions.create({
|
|
283
|
+
model: aiModel,
|
|
284
|
+
temperature: 0.2,
|
|
285
|
+
stream: false,
|
|
286
|
+
messages,
|
|
287
|
+
tools,
|
|
288
|
+
tool_choice: forceTools && round === 0 ? "required" : "auto",
|
|
289
|
+
});
|
|
290
|
+
const msg = completion.choices[0]?.message;
|
|
291
|
+
if (msg == null)
|
|
292
|
+
return extractCompletionText(completion) ?? "No issues found.";
|
|
293
|
+
const toolCalls = msg.tool_calls ?? [];
|
|
294
|
+
logDebug(`file-review path=${filePath} round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
|
|
295
|
+
if (toolCalls.length === 0)
|
|
296
|
+
return extractCompletionText(completion) ?? "No issues found.";
|
|
297
|
+
messages.push({
|
|
298
|
+
role: "assistant",
|
|
299
|
+
content: msg.content ?? "",
|
|
300
|
+
tool_calls: toolCalls,
|
|
301
|
+
});
|
|
302
|
+
for (const toolCall of toolCalls) {
|
|
303
|
+
const argsRaw = toolCall.function.arguments ?? "{}";
|
|
304
|
+
logToolUsageMinimal(logStep, toolCall.function.name, argsRaw, filePath);
|
|
305
|
+
let toolContent;
|
|
306
|
+
if (toolCall.function.name === TOOL_NAME_GET_FILE) {
|
|
307
|
+
toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
|
|
308
|
+
}
|
|
309
|
+
else if (toolCall.function.name === TOOL_NAME_GREP) {
|
|
310
|
+
toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
toolContent = JSON.stringify({
|
|
314
|
+
ok: false,
|
|
315
|
+
error: `Unknown tool "${toolCall.function.name}"`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
messages.push({
|
|
319
|
+
role: "tool",
|
|
320
|
+
tool_call_id: toolCall.id,
|
|
321
|
+
content: toolContent,
|
|
322
|
+
});
|
|
323
|
+
logDebug(`tool response file=${filePath} id=${toolCall.id} name=${toolCall.function.name} payload=${toolContent.slice(0, 300)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
messages.push({
|
|
327
|
+
role: "user",
|
|
328
|
+
content: "Tool-call limit reached. Provide your final review now without any tool calls.",
|
|
329
|
+
});
|
|
330
|
+
const final = await openaiInstance.chat.completions.create({
|
|
331
|
+
model: aiModel,
|
|
332
|
+
temperature: 0.2,
|
|
333
|
+
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
334
|
+
stream: false,
|
|
335
|
+
messages,
|
|
336
|
+
});
|
|
337
|
+
return extractCompletionText(final) ?? "No issues found.";
|
|
338
|
+
}
|
|
339
|
+
export async function reviewMergeRequestMultiPass(params) {
|
|
340
|
+
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, } = params;
|
|
341
|
+
const { logStep } = loggers;
|
|
342
|
+
logStep(`Pass 1/3: triaging ${changes.length} file(s)`);
|
|
343
|
+
const triageInputs = changes.map((c) => ({
|
|
344
|
+
path: c.new_path,
|
|
345
|
+
new_file: c.new_file,
|
|
346
|
+
deleted_file: c.deleted_file,
|
|
347
|
+
renamed_file: c.renamed_file,
|
|
348
|
+
diff: c.diff,
|
|
349
|
+
}));
|
|
350
|
+
const triageMessages = buildTriagePrompt(triageInputs);
|
|
351
|
+
let triageResult = null;
|
|
352
|
+
try {
|
|
353
|
+
const triageCompletion = await openaiInstance.chat.completions.create({
|
|
354
|
+
model: aiModel,
|
|
355
|
+
temperature: 0.1,
|
|
356
|
+
stream: false,
|
|
357
|
+
messages: triageMessages,
|
|
358
|
+
response_format: { type: "json_object" },
|
|
359
|
+
});
|
|
360
|
+
const triageText = extractCompletionText(triageCompletion);
|
|
361
|
+
if (triageText != null)
|
|
362
|
+
triageResult = parseTriageResponse(triageText);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
logStep(`Triage pass failed: ${error?.message ?? error}. Falling back to single-pass.`);
|
|
366
|
+
}
|
|
367
|
+
if (triageResult == null) {
|
|
368
|
+
logStep("Triage parse failed. Falling back to single-pass pipeline.");
|
|
369
|
+
return await reviewMergeRequestWithTools({
|
|
370
|
+
openaiInstance,
|
|
371
|
+
aiModel,
|
|
372
|
+
promptLimits,
|
|
373
|
+
changes,
|
|
374
|
+
refs,
|
|
375
|
+
gitLabProjectApiUrl,
|
|
376
|
+
projectId,
|
|
377
|
+
headers,
|
|
378
|
+
forceTools,
|
|
379
|
+
loggers,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const triageMap = new Map(triageResult.files.map((f) => [f.path, f.verdict]));
|
|
383
|
+
const reviewFiles = changes.filter((c) => triageMap.get(c.new_path) !== "SKIP");
|
|
384
|
+
const skippedCount = changes.length - reviewFiles.length;
|
|
385
|
+
logStep(`Triage: ${reviewFiles.length} file(s) to review, ${skippedCount} skipped. Summary: ${triageResult.summary.slice(0, 120)}...`);
|
|
386
|
+
if (reviewFiles.length === 0) {
|
|
387
|
+
const DISCLAIMER = "This comment was generated by an artificial intelligence duck.";
|
|
388
|
+
return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
|
|
389
|
+
}
|
|
390
|
+
logStep(`Pass 2/3: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
|
|
391
|
+
const allChangedPaths = changes.map((c) => c.new_path);
|
|
392
|
+
const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
|
|
393
|
+
const otherFiles = allChangedPaths.filter((p) => p !== change.new_path);
|
|
394
|
+
const findings = await runFileReviewWithTools({
|
|
395
|
+
openaiInstance,
|
|
396
|
+
aiModel,
|
|
397
|
+
filePath: change.new_path,
|
|
398
|
+
fileDiff: change.diff,
|
|
399
|
+
summary: triageResult.summary,
|
|
400
|
+
otherChangedFiles: otherFiles,
|
|
401
|
+
refs,
|
|
402
|
+
gitLabProjectApiUrl,
|
|
403
|
+
projectId,
|
|
404
|
+
headers,
|
|
405
|
+
forceTools,
|
|
406
|
+
loggers,
|
|
407
|
+
});
|
|
408
|
+
return { path: change.new_path, findings };
|
|
409
|
+
});
|
|
410
|
+
logStep("Pass 3/3: consolidating findings");
|
|
411
|
+
const consolidateMessages = buildConsolidatePrompt({
|
|
412
|
+
perFileFindings,
|
|
413
|
+
summary: triageResult.summary,
|
|
414
|
+
maxFindings,
|
|
415
|
+
});
|
|
416
|
+
if (consolidateMessages == null) {
|
|
417
|
+
const DISCLAIMER = "This comment was generated by an artificial intelligence duck.";
|
|
418
|
+
return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const consolidateCompletion = await openaiInstance.chat.completions.create({
|
|
422
|
+
model: aiModel,
|
|
423
|
+
temperature: 0.1,
|
|
424
|
+
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
425
|
+
stream: false,
|
|
426
|
+
messages: consolidateMessages,
|
|
427
|
+
});
|
|
428
|
+
return buildAnswer(consolidateCompletion);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
|
432
|
+
const DISCLAIMER = "This comment was generated by an artificial intelligence duck.";
|
|
433
|
+
const raw = perFileFindings
|
|
434
|
+
.filter((f) => !f.findings.includes("No issues found.") &&
|
|
435
|
+
!f.findings.includes("No confirmed bugs"))
|
|
436
|
+
.map((f) => f.findings)
|
|
437
|
+
.join("\n");
|
|
438
|
+
return `${raw || "No confirmed bugs or high-value optimizations found."}\n\n---\n_${DISCLAIMER}_`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** @format */
|
|
2
|
+
export const TOOL_NAME_GET_FILE = "get_file_at_ref";
|
|
3
|
+
export const TOOL_NAME_GREP = "grep_repository";
|
|
4
|
+
export const MAX_TOOL_ROUNDS = 12;
|
|
5
|
+
export const MAX_FILE_TOOL_ROUNDS = 5;
|
|
6
|
+
export function logToolUsageMinimal(logStep, toolName, argsRaw, contextFile) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(argsRaw);
|
|
9
|
+
if (toolName === TOOL_NAME_GET_FILE) {
|
|
10
|
+
const path = parsed.path?.trim() || "(unknown-path)";
|
|
11
|
+
const suffix = contextFile ? ` review_file=${contextFile}` : "";
|
|
12
|
+
logStep(`[tools] ${toolName} path=${path}${suffix}`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (toolName === TOOL_NAME_GREP) {
|
|
16
|
+
const query = parsed.query?.trim() || "(empty-query)";
|
|
17
|
+
const suffix = contextFile ? ` review_file=${contextFile}` : "";
|
|
18
|
+
logStep(`[tools] ${toolName} query=${query}${suffix}`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Fall through to raw args logging.
|
|
24
|
+
}
|
|
25
|
+
const suffix = contextFile ? ` review_file=${contextFile}` : "";
|
|
26
|
+
logStep(`[tools] ${toolName} args=${argsRaw.slice(0, 120)}${suffix}`);
|
|
27
|
+
}
|