@krotovm/gitlab-ai-review 1.0.18 → 1.0.21
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 +11 -35
- package/dist/cli/args.js +0 -47
- package/dist/cli/ci-review.js +29 -6
- package/dist/cli/tooling.js +0 -1
- package/dist/cli.js +9 -81
- package/dist/errors.js +0 -1
- package/dist/gitlab/services.js +0 -1
- package/dist/gitlab/types.js +0 -1
- package/dist/prompt/index.js +42 -5
- package/dist/prompt/messages.js +0 -1
- package/dist/prompt/utils.js +0 -1
- package/package.json +2 -3
- package/dist/cli/local-review.js +0 -467
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,35 +27,17 @@ 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
|
|
52
34
|
|
|
53
|
-
Set these in your project/group CI settings
|
|
35
|
+
Set these in your project/group CI settings:
|
|
54
36
|
|
|
55
37
|
- `OPENAI_API_KEY` (required)
|
|
56
38
|
- `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
|
|
57
39
|
- `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
|
|
58
|
-
- `PROJECT_ACCESS_TOKEN` (optional but
|
|
40
|
+
- `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
|
|
59
41
|
- `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
|
|
60
42
|
|
|
61
43
|
`OPENAI_BASE_URL` is passed through to the `openai` SDK client, so you can use any OpenAI-compatible gateway/provider endpoint.
|
|
@@ -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,12 +62,10 @@ GitLab provides these automatically in Merge Request pipelines:
|
|
|
84
62
|
|
|
85
63
|
## Architecture
|
|
86
64
|
|
|
87
|
-
|
|
65
|
+
The reviewer uses a three-pass pipeline optimized for large merge requests:
|
|
88
66
|
|
|
89
|
-
1. **Triage**
|
|
90
|
-
2. **Per-file review**
|
|
91
|
-
3. **Consolidate**
|
|
67
|
+
1. **Triage** - A fast LLM pass classifies each changed file as `NEEDS_REVIEW` or `SKIP` and generates a short MR summary.
|
|
68
|
+
2. **Per-file review** - Only `NEEDS_REVIEW` files are reviewed, each in a dedicated LLM call running in parallel (with tools to fetch full files or grep the repository).
|
|
69
|
+
3. **Consolidate** - Per-file findings are merged, deduplicated, ranked by severity, and trimmed to top N (default 5).
|
|
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
|
-
|
|
95
|
-
Local modes (`--worktree`, `--last-commit`, `--diff-file`) use a simpler single-pass pipeline.
|
package/dist/cli/args.js
CHANGED
|
@@ -83,54 +83,7 @@ export function parsePromptLimits(argv) {
|
|
|
83
83
|
maxTotalPromptChars: parseNumberFlag(argv, "max-total-prompt-chars", DEFAULT_PROMPT_LIMITS.maxTotalPromptChars, 1),
|
|
84
84
|
};
|
|
85
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
86
|
export function hasIgnoredExtension(filePath, ignoredExtensions) {
|
|
107
87
|
const lowerPath = filePath.toLowerCase();
|
|
108
88
|
return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
|
|
109
89
|
}
|
|
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
|
package/dist/cli/ci-review.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @format */
|
|
2
2
|
import OpenAI from "openai";
|
|
3
|
-
import { AI_MAX_OUTPUT_TOKENS, buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
|
|
3
|
+
import { AI_MAX_OUTPUT_TOKENS, buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
|
|
4
4
|
import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
|
|
5
5
|
import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
|
|
6
6
|
function buildReviewMetadata(changes, refs) {
|
|
@@ -339,7 +339,7 @@ async function runFileReviewWithTools(params) {
|
|
|
339
339
|
export async function reviewMergeRequestMultiPass(params) {
|
|
340
340
|
const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, } = params;
|
|
341
341
|
const { logStep } = loggers;
|
|
342
|
-
logStep(`Pass 1/
|
|
342
|
+
logStep(`Pass 1/4: triaging ${changes.length} file(s)`);
|
|
343
343
|
const triageInputs = changes.map((c) => ({
|
|
344
344
|
path: c.new_path,
|
|
345
345
|
new_file: c.new_file,
|
|
@@ -387,7 +387,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
387
387
|
const DISCLAIMER = "This comment was generated by an artificial intelligence duck.";
|
|
388
388
|
return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
|
|
389
389
|
}
|
|
390
|
-
logStep(`Pass 2/
|
|
390
|
+
logStep(`Pass 2/4: reviewing ${reviewFiles.length} file(s) (concurrency=${reviewConcurrency})`);
|
|
391
391
|
const allChangedPaths = changes.map((c) => c.new_path);
|
|
392
392
|
const perFileFindings = await mapWithConcurrency(reviewFiles, reviewConcurrency, async (change) => {
|
|
393
393
|
const otherFiles = allChangedPaths.filter((p) => p !== change.new_path);
|
|
@@ -407,7 +407,7 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
407
407
|
});
|
|
408
408
|
return { path: change.new_path, findings };
|
|
409
409
|
});
|
|
410
|
-
logStep("Pass 3/
|
|
410
|
+
logStep("Pass 3/4: consolidating findings");
|
|
411
411
|
const consolidateMessages = buildConsolidatePrompt({
|
|
412
412
|
perFileFindings,
|
|
413
413
|
summary: triageResult.summary,
|
|
@@ -425,7 +425,31 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
425
425
|
stream: false,
|
|
426
426
|
messages: consolidateMessages,
|
|
427
427
|
});
|
|
428
|
-
|
|
428
|
+
const consolidatedText = extractCompletionText(consolidateCompletion);
|
|
429
|
+
if (consolidatedText == null || consolidatedText.trim() === "") {
|
|
430
|
+
return buildAnswer(consolidateCompletion);
|
|
431
|
+
}
|
|
432
|
+
logStep("Pass 4/4: verifying consolidated findings");
|
|
433
|
+
const verificationMessages = buildVerificationPrompt({
|
|
434
|
+
perFileFindings,
|
|
435
|
+
summary: triageResult.summary,
|
|
436
|
+
consolidatedFindings: consolidatedText,
|
|
437
|
+
maxFindings,
|
|
438
|
+
});
|
|
439
|
+
try {
|
|
440
|
+
const verificationCompletion = await openaiInstance.chat.completions.create({
|
|
441
|
+
model: aiModel,
|
|
442
|
+
temperature: 0.0,
|
|
443
|
+
max_tokens: AI_MAX_OUTPUT_TOKENS,
|
|
444
|
+
stream: false,
|
|
445
|
+
messages: verificationMessages,
|
|
446
|
+
});
|
|
447
|
+
return buildAnswer(verificationCompletion);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
logStep(`Verification failed: ${error?.message ?? error}. Returning consolidated findings.`);
|
|
451
|
+
return buildAnswer(consolidateCompletion);
|
|
452
|
+
}
|
|
429
453
|
}
|
|
430
454
|
catch (error) {
|
|
431
455
|
logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
|
|
@@ -438,4 +462,3 @@ export async function reviewMergeRequestMultiPass(params) {
|
|
|
438
462
|
return `${raw || "No confirmed bugs or high-value optimizations found."}\n\n---\n_${DISCLAIMER}_`;
|
|
439
463
|
}
|
|
440
464
|
}
|
|
441
|
-
//# sourceMappingURL=ci-review.js.map
|
package/dist/cli/tooling.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
import OpenAI from "openai";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
|
|
6
|
-
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIgnoredExtension,
|
|
7
|
-
import { diffFromFile, localDiffLastCommit, localDiffWorktree, reviewDiffToConsole, reviewDiffToConsoleWithToolsLocal, } from "./cli/local-review.js";
|
|
6
|
+
import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
|
|
8
7
|
import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
|
|
9
8
|
import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
|
|
10
9
|
function printHelp() {
|
|
@@ -12,19 +11,10 @@ function printHelp() {
|
|
|
12
11
|
"gitlab-ai-review",
|
|
13
12
|
"",
|
|
14
13
|
"Usage:",
|
|
15
|
-
" gitlab-ai-review
|
|
16
|
-
" gitlab-ai-review --worktree",
|
|
17
|
-
" gitlab-ai-review --last-commit",
|
|
18
|
-
" gitlab-ai-review --diff-file=./changes.diff",
|
|
14
|
+
" gitlab-ai-review",
|
|
19
15
|
" gitlab-ai-review --help",
|
|
20
16
|
" gitlab-ai-review --debug",
|
|
21
|
-
" gitlab-ai-review --
|
|
22
|
-
"",
|
|
23
|
-
"Modes (choose one):",
|
|
24
|
-
" --ci Run in GitLab MR pipeline: fetch MR changes and post a new MR note.",
|
|
25
|
-
" --worktree Review local uncommitted changes (staged + unstaged) and print to stdout.",
|
|
26
|
-
" --last-commit Review last commit (HEAD) and print to stdout.",
|
|
27
|
-
" --diff-file Review git-diff content from a file and print to stdout.",
|
|
17
|
+
" gitlab-ai-review --ignore-ext=md,lock",
|
|
28
18
|
"",
|
|
29
19
|
"Debug:",
|
|
30
20
|
" --debug Print full error details (stack, API error fields).",
|
|
@@ -40,14 +30,11 @@ function printHelp() {
|
|
|
40
30
|
" OPENAI_API_KEY (required) OpenAI API key.",
|
|
41
31
|
" OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
|
|
42
32
|
" AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
|
|
43
|
-
" PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (
|
|
33
|
+
" PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
|
|
44
34
|
"",
|
|
45
35
|
"CI-only env vars (provided by GitLab):",
|
|
46
36
|
" CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, CI_JOB_TOKEN (only if PROJECT_ACCESS_TOKEN is not set)",
|
|
47
37
|
"",
|
|
48
|
-
"Notes:",
|
|
49
|
-
" - If no mode is specified, it defaults to --ci when CI_MERGE_REQUEST_IID is set, otherwise --worktree.",
|
|
50
|
-
"",
|
|
51
38
|
].join("\n"));
|
|
52
39
|
}
|
|
53
40
|
const DEBUG_MODE = hasDebugFlag(process.argv);
|
|
@@ -75,77 +62,19 @@ async function getCliVersion() {
|
|
|
75
62
|
return "unknown";
|
|
76
63
|
}
|
|
77
64
|
async function main() {
|
|
65
|
+
const args = new Set(process.argv.slice(2));
|
|
66
|
+
if (args.has("--help") || args.has("-h")) {
|
|
67
|
+
printHelp();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
78
70
|
const cliVersion = await getCliVersion();
|
|
79
71
|
logStep(`gitlab-ai-review v${cliVersion}`);
|
|
80
|
-
const diffFilePath = parseDiffFileFlag(process.argv);
|
|
81
|
-
if (diffFilePath != null && hasModeFlag(process.argv)) {
|
|
82
|
-
throw new Error("Do not combine --diff-file with --ci/--worktree/--last-commit");
|
|
83
|
-
}
|
|
84
|
-
const mode = diffFilePath != null
|
|
85
|
-
? "worktree"
|
|
86
|
-
: parseMode(process.argv, printHelp, process.env.CI_MERGE_REQUEST_IID);
|
|
87
72
|
const ignoredExtensions = parseIgnoreExtensions(process.argv);
|
|
88
73
|
const promptLimits = parsePromptLimits(process.argv);
|
|
89
74
|
const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
|
|
90
75
|
const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
|
|
91
76
|
const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
|
|
92
77
|
const loggers = { logStep, logDebug };
|
|
93
|
-
if (diffFilePath != null) {
|
|
94
|
-
logStep(`Reading diff file: ${diffFilePath}`);
|
|
95
|
-
const openaiApiKey = requireEnvs(["OPENAI_API_KEY"])["OPENAI_API_KEY"];
|
|
96
|
-
const diff = await diffFromFile(diffFilePath);
|
|
97
|
-
logStep(`Requesting AI completion with model: ${aiModel}`);
|
|
98
|
-
if (FORCE_TOOLS) {
|
|
99
|
-
await reviewDiffToConsoleWithToolsLocal({
|
|
100
|
-
diff,
|
|
101
|
-
openaiApiKey,
|
|
102
|
-
aiModel,
|
|
103
|
-
promptLimits,
|
|
104
|
-
forceTools: FORCE_TOOLS,
|
|
105
|
-
loggers,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
await reviewDiffToConsole({
|
|
110
|
-
diff,
|
|
111
|
-
openaiApiKey,
|
|
112
|
-
aiModel,
|
|
113
|
-
promptLimits,
|
|
114
|
-
forceTools: FORCE_TOOLS,
|
|
115
|
-
loggers,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (mode === "worktree" || mode === "last-commit") {
|
|
121
|
-
logStep(mode === "worktree" ? "Collecting local changes" : "Collecting HEAD diff");
|
|
122
|
-
const openaiApiKey = requireEnvs(["OPENAI_API_KEY"])["OPENAI_API_KEY"];
|
|
123
|
-
const diff = mode === "worktree"
|
|
124
|
-
? await localDiffWorktree(process.argv)
|
|
125
|
-
: await localDiffLastCommit(process.argv);
|
|
126
|
-
logStep(`Requesting AI completion with model: ${aiModel}`);
|
|
127
|
-
if (FORCE_TOOLS) {
|
|
128
|
-
await reviewDiffToConsoleWithToolsLocal({
|
|
129
|
-
diff,
|
|
130
|
-
openaiApiKey,
|
|
131
|
-
aiModel,
|
|
132
|
-
promptLimits,
|
|
133
|
-
forceTools: FORCE_TOOLS,
|
|
134
|
-
loggers,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
await reviewDiffToConsole({
|
|
139
|
-
diff,
|
|
140
|
-
openaiApiKey,
|
|
141
|
-
aiModel,
|
|
142
|
-
promptLimits,
|
|
143
|
-
forceTools: FORCE_TOOLS,
|
|
144
|
-
loggers,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
78
|
const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
|
|
150
79
|
const gitlabRequired = [
|
|
151
80
|
"OPENAI_API_KEY",
|
|
@@ -241,4 +170,3 @@ main().catch((err) => {
|
|
|
241
170
|
}
|
|
242
171
|
process.exitCode = 1;
|
|
243
172
|
});
|
|
244
|
-
//# sourceMappingURL=cli.js.map
|
package/dist/errors.js
CHANGED
package/dist/gitlab/services.js
CHANGED
package/dist/gitlab/types.js
CHANGED
package/dist/prompt/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export const DEFAULT_PROMPT_LIMITS = {
|
|
|
9
9
|
const MESSAGES = buildMainSystemMessages();
|
|
10
10
|
export const AI_MODEL_TEMPERATURE = 0.2;
|
|
11
11
|
export const AI_MAX_OUTPUT_TOKENS = 600;
|
|
12
|
-
export const buildPrompt = ({ changes, limits, allowTools = false,
|
|
12
|
+
export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
|
|
13
13
|
const effectiveLimits = {
|
|
14
14
|
...DEFAULT_PROMPT_LIMITS,
|
|
15
15
|
...(limits ?? {}),
|
|
@@ -36,9 +36,6 @@ export const buildPrompt = ({ changes, limits, allowTools = false, additionalCon
|
|
|
36
36
|
const userContent = [
|
|
37
37
|
`Review the following code changes (git diff format). ${stats}`,
|
|
38
38
|
toolNote,
|
|
39
|
-
additionalContext?.trim()
|
|
40
|
-
? `\nAdditional local context (best-effort snippets):\n${additionalContext}`
|
|
41
|
-
: "",
|
|
42
39
|
"",
|
|
43
40
|
"Changes:",
|
|
44
41
|
changesText || "(no changes provided)",
|
|
@@ -169,6 +166,47 @@ export function buildConsolidatePrompt(params) {
|
|
|
169
166
|
},
|
|
170
167
|
];
|
|
171
168
|
}
|
|
169
|
+
export function buildVerificationPrompt(params) {
|
|
170
|
+
const { perFileFindings, summary, consolidatedFindings, maxFindings } = params;
|
|
171
|
+
const findingsText = perFileFindings
|
|
172
|
+
.map((f) => `### ${f.path}\n${f.findings}`)
|
|
173
|
+
.join("\n\n");
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
role: "system",
|
|
177
|
+
content: [
|
|
178
|
+
"You are a skeptical verifier of a merge request review.",
|
|
179
|
+
"Your job is to remove weak, speculative, or unsupported findings from the draft list.",
|
|
180
|
+
"Do not add new findings. Keep, rewrite for clarity, or remove existing findings only.",
|
|
181
|
+
"A finding can stay only if it is directly supported by evidence from the per-file findings.",
|
|
182
|
+
"If confidence is not high, drop the finding.",
|
|
183
|
+
"Preserve this exact per-finding markdown block:",
|
|
184
|
+
"`- [high|medium] <title>`",
|
|
185
|
+
"` File: <path>`",
|
|
186
|
+
"` Line: ~<N>`",
|
|
187
|
+
"` Why: <one concise sentence with key evidence>`",
|
|
188
|
+
"Do not add headings, summaries, or extra commentary.",
|
|
189
|
+
`Return at most ${maxFindings} findings.`,
|
|
190
|
+
'If no findings survive verification, return exactly: "No confirmed bugs or high-value optimizations found."',
|
|
191
|
+
"GitLab-flavoured markdown.",
|
|
192
|
+
].join("\n"),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
role: "user",
|
|
196
|
+
content: [
|
|
197
|
+
`MR Summary: ${summary}`,
|
|
198
|
+
"",
|
|
199
|
+
"Per-file findings (evidence pool):",
|
|
200
|
+
findingsText,
|
|
201
|
+
"",
|
|
202
|
+
"Draft consolidated findings to verify:",
|
|
203
|
+
consolidatedFindings,
|
|
204
|
+
"",
|
|
205
|
+
"Return only the verified final findings.",
|
|
206
|
+
].join("\n"),
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
172
210
|
export function extractCompletionText(completion) {
|
|
173
211
|
if (completion instanceof Error || completion == null)
|
|
174
212
|
return null;
|
|
@@ -238,4 +276,3 @@ export const buildAnswer = (completion) => {
|
|
|
238
276
|
const safe = sanitizeGitLabMarkdown(content);
|
|
239
277
|
return `${safe}\n\n---\n_${DISCLAIMER}_`;
|
|
240
278
|
};
|
|
241
|
-
//# sourceMappingURL=index.js.map
|
package/dist/prompt/messages.js
CHANGED
package/dist/prompt/utils.js
CHANGED
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@krotovm/gitlab-ai-review",
|
|
4
|
-
"version": "1.0.
|
|
5
|
-
"description": "CLI tool to generate AI code reviews for GitLab merge requests
|
|
4
|
+
"version": "1.0.21",
|
|
5
|
+
"description": "CLI tool to generate AI code reviews for GitLab merge requests.",
|
|
6
6
|
"main": "dist/cli.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"gitlab-ai-review": "dist/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/**/*.js",
|
|
12
|
-
"dist/**/*.d.ts",
|
|
13
12
|
"README.md",
|
|
14
13
|
"LICENSE"
|
|
15
14
|
],
|
package/dist/cli/local-review.js
DELETED
|
@@ -1,467 +0,0 @@
|
|
|
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
|