@krotovm/gitlab-ai-review 1.0.17 → 1.0.18
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/dist/cli/args.js +136 -0
- package/dist/cli/ci-review.js +441 -0
- package/dist/cli/local-review.js +467 -0
- package/dist/cli/tooling.js +28 -0
- package/dist/cli.js +80 -860
- package/dist/gitlab/services.js +2 -1
- package/dist/prompt/index.js +10 -127
- package/dist/prompt/messages.js +146 -0
- package/dist/prompt/utils.js +15 -0
- package/package.json +3 -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/dist/cli/args.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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 parseDiffFileFlag(argv) {
|
|
87
|
+
const args = argv.slice(2);
|
|
88
|
+
for (const current of args) {
|
|
89
|
+
if (current === "--diff-file") {
|
|
90
|
+
throw new Error("Use equals format: --diff-file=./path/to/file.diff");
|
|
91
|
+
}
|
|
92
|
+
if (!current.startsWith("--diff-file="))
|
|
93
|
+
continue;
|
|
94
|
+
const value = current.slice("--diff-file=".length).trim();
|
|
95
|
+
if (value === "") {
|
|
96
|
+
throw new Error("Missing value for --diff-file. Example: --diff-file=./changes.diff");
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
export function hasModeFlag(argv) {
|
|
103
|
+
const args = new Set(argv.slice(2));
|
|
104
|
+
return (args.has("--ci") || args.has("--worktree") || args.has("--last-commit"));
|
|
105
|
+
}
|
|
106
|
+
export function hasIgnoredExtension(filePath, ignoredExtensions) {
|
|
107
|
+
const lowerPath = filePath.toLowerCase();
|
|
108
|
+
return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
|
|
109
|
+
}
|
|
110
|
+
export function buildGitExcludePathspecs(ignoredExtensions) {
|
|
111
|
+
return ignoredExtensions.map((ext) => `:(exclude,glob)**/*${ext}`);
|
|
112
|
+
}
|
|
113
|
+
export function parseMode(argv, onHelp, mergeRequestIid) {
|
|
114
|
+
const args = new Set(argv.slice(2));
|
|
115
|
+
if (args.has("--help") || args.has("-h")) {
|
|
116
|
+
onHelp();
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
const hasCi = args.has("--ci");
|
|
120
|
+
const hasWorktree = args.has("--worktree");
|
|
121
|
+
const hasLastCommit = args.has("--last-commit");
|
|
122
|
+
const count = Number(hasCi) + Number(hasWorktree) + Number(hasLastCommit);
|
|
123
|
+
if (count > 1) {
|
|
124
|
+
throw new Error("Choose only one mode: --ci, --worktree, or --last-commit");
|
|
125
|
+
}
|
|
126
|
+
if (hasCi)
|
|
127
|
+
return "ci";
|
|
128
|
+
if (hasWorktree)
|
|
129
|
+
return "worktree";
|
|
130
|
+
if (hasLastCommit)
|
|
131
|
+
return "last-commit";
|
|
132
|
+
if (mergeRequestIid != null && mergeRequestIid.trim() !== "")
|
|
133
|
+
return "ci";
|
|
134
|
+
return "worktree";
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=args.js.map
|
|
@@ -0,0 +1,441 @@
|
|
|
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
|
+
}
|
|
441
|
+
//# sourceMappingURL=ci-review.js.map
|