@krotovm/gitlab-ai-review 1.0.16 → 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.
@@ -0,0 +1,467 @@
1
+ /** @format */
2
+ import OpenAI from "openai";
3
+ import { readFile } from "node:fs/promises";
4
+ import { AI_MAX_OUTPUT_TOKENS, buildAnswer, buildPrompt, extractCompletionText, } from "../prompt/index.js";
5
+ import { generateAICompletion } from "../gitlab/services.js";
6
+ import { buildGitExcludePathspecs, parseIgnoreExtensions, } from "./args.js";
7
+ import { logToolUsageMinimal, MAX_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
8
+ async function runGit(args) {
9
+ const { spawn } = await import("node:child_process");
10
+ return await new Promise((resolve, reject) => {
11
+ const child = spawn("git", args, { stdio: ["ignore", "pipe", "pipe"] });
12
+ let stdout = "";
13
+ let stderr = "";
14
+ child.stdout.on("data", (d) => {
15
+ stdout += String(d);
16
+ });
17
+ child.stderr.on("data", (d) => {
18
+ stderr += String(d);
19
+ });
20
+ child.on("error", reject);
21
+ child.on("close", (code) => {
22
+ if (code === 0)
23
+ return resolve(stdout);
24
+ reject(new Error(stderr.trim() || `git ${args.join(" ")} failed with exit code ${code}`));
25
+ });
26
+ });
27
+ }
28
+ async function runCommand(command, args) {
29
+ const { spawn } = await import("node:child_process");
30
+ return await new Promise((resolve, reject) => {
31
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
32
+ let stdout = "";
33
+ let stderr = "";
34
+ child.stdout.on("data", (d) => {
35
+ stdout += String(d);
36
+ });
37
+ child.stderr.on("data", (d) => {
38
+ stderr += String(d);
39
+ });
40
+ child.on("error", reject);
41
+ child.on("close", (code) => {
42
+ if (code === 0)
43
+ return resolve(stdout);
44
+ reject(new Error(stderr.trim() ||
45
+ `${command} ${args.join(" ")} failed with exit code ${code}`));
46
+ });
47
+ });
48
+ }
49
+ export async function localDiffWorktree(argv) {
50
+ const ignoreExtensions = parseIgnoreExtensions(argv);
51
+ const pathspecs = buildGitExcludePathspecs(ignoreExtensions);
52
+ const unstagedArgs = pathspecs.length > 0 ? ["diff", "--", ...pathspecs] : ["diff"];
53
+ const stagedArgs = pathspecs.length > 0
54
+ ? ["diff", "--staged", "--", ...pathspecs]
55
+ : ["diff", "--staged"];
56
+ const unstaged = await runGit(unstagedArgs);
57
+ const staged = await runGit(stagedArgs);
58
+ return [staged.trim(), unstaged.trim()].filter(Boolean).join("\n\n");
59
+ }
60
+ export async function localDiffLastCommit(argv) {
61
+ const ignoreExtensions = parseIgnoreExtensions(argv);
62
+ const pathspecs = buildGitExcludePathspecs(ignoreExtensions);
63
+ const args = pathspecs.length > 0
64
+ ? ["show", "--format=", "HEAD", "--", ...pathspecs]
65
+ : ["show", "--format=", "HEAD"];
66
+ return await runGit(args);
67
+ }
68
+ export async function diffFromFile(filePath) {
69
+ return await readFile(filePath, "utf8");
70
+ }
71
+ function parseChangedPathsFromDiff(diff) {
72
+ const paths = [];
73
+ const lines = diff.split(/\r?\n/);
74
+ for (const line of lines) {
75
+ if (!line.startsWith("diff --git "))
76
+ continue;
77
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
78
+ if (match == null)
79
+ continue;
80
+ const bPath = match[2]?.trim();
81
+ if (bPath && bPath !== "/dev/null")
82
+ paths.push(bPath);
83
+ }
84
+ return Array.from(new Set(paths));
85
+ }
86
+ async function readLocalFileIfExists(path) {
87
+ try {
88
+ return await readFile(path, "utf8");
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ async function readFileAtHead(path) {
95
+ try {
96
+ return await runGit(["show", `HEAD:${path}`]);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ async function buildLocalReviewContext(diff) {
103
+ const paths = parseChangedPathsFromDiff(diff).slice(0, 8);
104
+ if (paths.length === 0)
105
+ return "";
106
+ const blocks = [];
107
+ for (const path of paths) {
108
+ const worktreeText = await readLocalFileIfExists(path);
109
+ const headText = await readFileAtHead(path);
110
+ const current = worktreeText?.slice(0, 1200);
111
+ const previous = headText?.slice(0, 1200);
112
+ if (current == null && previous == null)
113
+ continue;
114
+ blocks.push([
115
+ `File: ${path}`,
116
+ current != null
117
+ ? `Current snippet:\n\`\`\`\n${current}\n\`\`\``
118
+ : "Current snippet: (unavailable)",
119
+ previous != null
120
+ ? `HEAD snippet:\n\`\`\`\n${previous}\n\`\`\``
121
+ : "HEAD snippet: (unavailable)",
122
+ ].join("\n"));
123
+ }
124
+ return blocks.join("\n\n");
125
+ }
126
+ function editDistanceAtMostTwo(a, b) {
127
+ const la = a.length;
128
+ const lb = b.length;
129
+ if (Math.abs(la - lb) > 2)
130
+ return 3;
131
+ const dp = Array.from({ length: la + 1 }, (_, i) => Array.from({ length: lb + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
132
+ for (let i = 1; i <= la; i += 1) {
133
+ let rowMin = Number.POSITIVE_INFINITY;
134
+ for (let j = 1; j <= lb; j += 1) {
135
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
136
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
137
+ rowMin = Math.min(rowMin, dp[i][j]);
138
+ }
139
+ if (rowMin > 2)
140
+ return 3;
141
+ }
142
+ return dp[la][lb];
143
+ }
144
+ function collectCallIdentifiersFromDiffLines(lines, prefixes) {
145
+ const out = new Set();
146
+ const callPattern = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
147
+ for (const line of lines) {
148
+ if (!prefixes.some((p) => line.startsWith(p)))
149
+ continue;
150
+ const code = line.slice(1);
151
+ let match;
152
+ while ((match = callPattern.exec(code)) != null) {
153
+ const ident = match[1];
154
+ if (ident === "if" ||
155
+ ident === "for" ||
156
+ ident === "while" ||
157
+ ident === "switch" ||
158
+ ident === "catch") {
159
+ continue;
160
+ }
161
+ out.add(ident);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ function findDeterministicDiffFindings(diff) {
167
+ const lines = diff.split(/\r?\n/);
168
+ const addedCalls = collectCallIdentifiersFromDiffLines(lines, ["+"]);
169
+ const nearbyCalls = collectCallIdentifiersFromDiffLines(lines, [" ", "-", "+"]);
170
+ const findings = [];
171
+ for (const added of addedCalls) {
172
+ if (added.length < 7)
173
+ continue;
174
+ let closest;
175
+ let closestDistance = 3;
176
+ for (const candidate of nearbyCalls) {
177
+ if (candidate === added)
178
+ continue;
179
+ const distance = editDistanceAtMostTwo(added, candidate);
180
+ if (distance < closestDistance) {
181
+ closestDistance = distance;
182
+ closest = candidate;
183
+ }
184
+ }
185
+ if (closest != null && closestDistance <= 2) {
186
+ findings.push({
187
+ title: "Possible symbol typo",
188
+ detail: `Call \`${added}(...)\` is very close to \`${closest}(...)\` and may be a misspelling causing runtime/reference errors.`,
189
+ });
190
+ }
191
+ }
192
+ return Array.from(new Map(findings.map((f) => [`${f.title}:${f.detail}`, f])).values()).slice(0, 3);
193
+ }
194
+ function injectDeterministicFindings(answer, findings) {
195
+ if (findings.length === 0)
196
+ return answer;
197
+ if (!answer.includes("No confirmed bugs or high-value optimizations found.")) {
198
+ return answer;
199
+ }
200
+ const bullets = findings.map((f) => `- [high] ${f.title}: ${f.detail}`).join("\n");
201
+ const disclaimerIndex = answer.indexOf("\n---\n_This comment was generated by an artificial intelligence duck._");
202
+ const disclaimer = disclaimerIndex >= 0
203
+ ? answer.slice(disclaimerIndex)
204
+ : "\n---\n_This comment was generated by an artificial intelligence duck._";
205
+ return `${bullets}${disclaimer}`;
206
+ }
207
+ async function handleLocalGetFileTool(argsRaw) {
208
+ try {
209
+ const parsed = JSON.parse(argsRaw);
210
+ const path = parsed.path?.trim();
211
+ const ref = (parsed.ref?.trim() || "WORKTREE").toUpperCase();
212
+ if (!path)
213
+ return JSON.stringify({ ok: false, error: "path is required." });
214
+ if (ref === "WORKTREE") {
215
+ const text = await readLocalFileIfExists(path);
216
+ if (text == null) {
217
+ return JSON.stringify({
218
+ ok: false,
219
+ path,
220
+ ref,
221
+ error: "File not found in worktree.",
222
+ });
223
+ }
224
+ return JSON.stringify({
225
+ ok: true,
226
+ path,
227
+ ref,
228
+ content: text.slice(0, 30000),
229
+ truncated: text.length > 30000,
230
+ });
231
+ }
232
+ const text = await runGit(["show", `${ref}:${path}`]);
233
+ return JSON.stringify({
234
+ ok: true,
235
+ path,
236
+ ref,
237
+ content: text.slice(0, 30000),
238
+ truncated: text.length > 30000,
239
+ });
240
+ }
241
+ catch (error) {
242
+ return JSON.stringify({
243
+ ok: false,
244
+ error: `Failed local get_file_at_ref: ${String(error?.message ?? error)}`,
245
+ raw: argsRaw,
246
+ });
247
+ }
248
+ }
249
+ async function handleLocalGrepTool(argsRaw, logDebug) {
250
+ function parseSearchOutput(raw) {
251
+ return raw
252
+ .split(/\r?\n/)
253
+ .filter(Boolean)
254
+ .map((line) => {
255
+ const m = /^(.+?):(\d+):(.*)$/.exec(line);
256
+ if (m == null)
257
+ return null;
258
+ return {
259
+ path: m[1],
260
+ startline: Number(m[2]),
261
+ data: (m[3] ?? "").slice(0, 2000),
262
+ };
263
+ })
264
+ .filter((v) => v != null)
265
+ .slice(0, 10);
266
+ }
267
+ try {
268
+ const parsed = JSON.parse(argsRaw);
269
+ const query = parsed.query?.trim();
270
+ if (!query)
271
+ return JSON.stringify({ ok: false, error: "query is required." });
272
+ let raw = "";
273
+ let searchBackend = "rg";
274
+ try {
275
+ raw = await runCommand("rg", ["-n", "--no-heading", "--max-count", "30", query, "."]);
276
+ }
277
+ catch (error) {
278
+ if (String(error?.message ?? error).includes("spawn rg ENOENT")) {
279
+ searchBackend = "grep";
280
+ raw = await runCommand("grep", ["-R", "-n", "-m", "30", "--", query, "."]);
281
+ }
282
+ else {
283
+ throw error;
284
+ }
285
+ }
286
+ const matches = parseSearchOutput(raw);
287
+ logDebug(`local grep backend=${searchBackend} query="${query}" matches=${matches.length}`);
288
+ return JSON.stringify({
289
+ ok: true,
290
+ query,
291
+ ref: (parsed.ref?.trim() || "WORKTREE").toUpperCase(),
292
+ matches,
293
+ });
294
+ }
295
+ catch (error) {
296
+ return JSON.stringify({
297
+ ok: false,
298
+ error: `Failed local grep_repository: ${String(error?.message ?? error)}`,
299
+ raw: argsRaw,
300
+ });
301
+ }
302
+ }
303
+ export async function reviewDiffToConsole(params) {
304
+ const { diff, openaiApiKey, aiModel, promptLimits, forceTools, loggers } = params;
305
+ const { logStep } = loggers;
306
+ if (diff.trim() === "") {
307
+ process.stdout.write("No diff found. Skipping review.\n");
308
+ return;
309
+ }
310
+ const localContext = await buildLocalReviewContext(diff);
311
+ const messageParams = buildPrompt({
312
+ changes: [{ diff }],
313
+ limits: promptLimits,
314
+ additionalContext: localContext,
315
+ });
316
+ const openaiInstance = new OpenAI({ apiKey: openaiApiKey });
317
+ const completion = await generateAICompletion(messageParams, openaiInstance, aiModel);
318
+ if (extractCompletionText(completion) == null) {
319
+ logStep("Primary completion was empty. Retrying once with local tool-enabled flow.");
320
+ await reviewDiffToConsoleWithToolsLocal({
321
+ diff,
322
+ openaiApiKey,
323
+ aiModel,
324
+ promptLimits,
325
+ forceTools,
326
+ loggers,
327
+ });
328
+ return;
329
+ }
330
+ const answer = buildAnswer(completion);
331
+ const finalAnswer = injectDeterministicFindings(answer, findDeterministicDiffFindings(diff));
332
+ process.stdout.write(`${finalAnswer}\n`);
333
+ }
334
+ export async function reviewDiffToConsoleWithToolsLocal(params) {
335
+ const { diff, openaiApiKey, aiModel, promptLimits, forceTools, loggers } = params;
336
+ const { logDebug, logStep } = loggers;
337
+ if (diff.trim() === "") {
338
+ process.stdout.write("No diff found. Skipping review.\n");
339
+ return;
340
+ }
341
+ const localContext = await buildLocalReviewContext(diff);
342
+ const messages = buildPrompt({
343
+ changes: [{ diff }],
344
+ limits: promptLimits,
345
+ allowTools: true,
346
+ additionalContext: localContext,
347
+ });
348
+ messages.push({
349
+ role: "user",
350
+ content: "Local review context: use ref=WORKTREE for current files, ref=HEAD for last commit snapshot.",
351
+ });
352
+ const openaiInstance = new OpenAI({ apiKey: openaiApiKey });
353
+ const toolResultCache = new Map();
354
+ const tools = [
355
+ {
356
+ type: "function",
357
+ function: {
358
+ name: TOOL_NAME_GET_FILE,
359
+ description: "Fetch local file content. Use ref=WORKTREE for current file, ref=HEAD for committed snapshot.",
360
+ parameters: {
361
+ type: "object",
362
+ additionalProperties: false,
363
+ properties: {
364
+ path: { type: "string", description: "Repository file path." },
365
+ ref: {
366
+ type: "string",
367
+ description: 'Ref value: "WORKTREE" or "HEAD". Defaults to WORKTREE.',
368
+ },
369
+ },
370
+ required: ["path"],
371
+ },
372
+ },
373
+ },
374
+ {
375
+ type: "function",
376
+ function: {
377
+ name: TOOL_NAME_GREP,
378
+ description: "Search current local repository with ripgrep. Returns up to 10 matches.",
379
+ parameters: {
380
+ type: "object",
381
+ additionalProperties: false,
382
+ properties: {
383
+ query: {
384
+ type: "string",
385
+ description: "Search string (keyword, function, symbol).",
386
+ },
387
+ ref: {
388
+ type: "string",
389
+ description: "Optional logical ref (WORKTREE/HEAD); search runs on current tree.",
390
+ },
391
+ },
392
+ required: ["query"],
393
+ },
394
+ },
395
+ },
396
+ ];
397
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
398
+ const completion = await openaiInstance.chat.completions.create({
399
+ model: aiModel,
400
+ temperature: 0.2,
401
+ stream: false,
402
+ messages,
403
+ tools,
404
+ tool_choice: forceTools && round === 0 ? "required" : "auto",
405
+ });
406
+ const message = completion.choices[0]?.message;
407
+ if (message == null) {
408
+ process.stdout.write(`${buildAnswer(completion)}\n`);
409
+ return;
410
+ }
411
+ const toolCalls = message.tool_calls ?? [];
412
+ logDebug(`local-review round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
413
+ if (toolCalls.length === 0) {
414
+ process.stdout.write(`${buildAnswer(completion)}\n`);
415
+ return;
416
+ }
417
+ messages.push({
418
+ role: "assistant",
419
+ content: message.content ?? "",
420
+ tool_calls: toolCalls,
421
+ });
422
+ for (const toolCall of toolCalls) {
423
+ const argsRaw = toolCall.function.arguments ?? "{}";
424
+ logToolUsageMinimal(logStep, toolCall.function.name, argsRaw);
425
+ logDebug(`tool request local id=${toolCall.id} name=${toolCall.function.name} args=${argsRaw.slice(0, 300)}`);
426
+ const cacheKey = `${toolCall.function.name}:${argsRaw}`;
427
+ const cached = toolResultCache.get(cacheKey);
428
+ let toolContent;
429
+ if (cached != null) {
430
+ toolContent = cached;
431
+ }
432
+ else if (toolCall.function.name === TOOL_NAME_GET_FILE) {
433
+ toolContent = await handleLocalGetFileTool(argsRaw);
434
+ toolResultCache.set(cacheKey, toolContent);
435
+ }
436
+ else if (toolCall.function.name === TOOL_NAME_GREP) {
437
+ toolContent = await handleLocalGrepTool(argsRaw, logDebug);
438
+ toolResultCache.set(cacheKey, toolContent);
439
+ }
440
+ else {
441
+ toolContent = JSON.stringify({
442
+ ok: false,
443
+ error: `Unknown tool "${toolCall.function.name}"`,
444
+ });
445
+ }
446
+ messages.push({
447
+ role: "tool",
448
+ tool_call_id: toolCall.id,
449
+ content: toolContent,
450
+ });
451
+ logDebug(`tool response local id=${toolCall.id} name=${toolCall.function.name} payload=${toolContent.slice(0, 300)}`);
452
+ }
453
+ }
454
+ messages.push({
455
+ role: "user",
456
+ content: "Tool-call limit reached. Do not call tools anymore. Provide final review now in required format.",
457
+ });
458
+ const finalCompletion = await openaiInstance.chat.completions.create({
459
+ model: aiModel,
460
+ temperature: 0.2,
461
+ max_tokens: AI_MAX_OUTPUT_TOKENS,
462
+ stream: false,
463
+ messages,
464
+ });
465
+ process.stdout.write(`${buildAnswer(finalCompletion)}\n`);
466
+ }
467
+ //# sourceMappingURL=local-review.js.map
@@ -0,0 +1,28 @@
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
+ }
28
+ //# sourceMappingURL=tooling.js.map