@aarushpandey/gitagent 1.0.0
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/CONTRIBUTING.md +104 -0
- package/LICENSE +21 -0
- package/README.md +570 -0
- package/TESTING.md +290 -0
- package/action.yml +113 -0
- package/examples/README.md +124 -0
- package/examples/sample-audit-trail-issue-4.md +112 -0
- package/examples/sample-review-tqec-pr894-v1-raw-flawed.md +71 -0
- package/examples/sample-review-tqec-pr894-v2-raw.md +48 -0
- package/examples/sample-review-tqec-pr894-v3-curated.md +118 -0
- package/examples/verify-marker-precedence/README.md +97 -0
- package/examples/verify-marker-precedence/conftest.py +15 -0
- package/examples/verify-marker-precedence/pyproject.toml +8 -0
- package/examples/verify-marker-precedence/test_marker_precedence.py +56 -0
- package/examples/verify-marker-precedence/verify_precedence.py +67 -0
- package/examples/workflows/issue-fix.yml +32 -0
- package/examples/workflows/pr-review.yml +34 -0
- package/package.json +75 -0
- package/scripts/verify.js +478 -0
- package/src/agents/agentLoop.js +176 -0
- package/src/agents/engineeringAgent.js +51 -0
- package/src/agents/reviewCopilot.js +79 -0
- package/src/agents/tools.js +486 -0
- package/src/cli/output.js +137 -0
- package/src/config.js +22 -0
- package/src/mapper/fileRelevance.js +113 -0
- package/src/mapper/repoMap.js +105 -0
- package/src/orchestrator.js +336 -0
- package/src/pipeline.js +985 -0
- package/src/prompts/engineering.js +189 -0
- package/src/prompts/review.js +149 -0
- package/src/utils/cost.js +47 -0
- package/src/utils/diffLines.js +67 -0
- package/src/utils/githubUrl.js +8 -0
- package/src/web/public/index.html +128 -0
- package/src/web/server.js +51 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { runAgentLoop } = require('./agentLoop');
|
|
2
|
+
const {
|
|
3
|
+
SYSTEM_PROMPT,
|
|
4
|
+
buildIssuePrompt,
|
|
5
|
+
buildRevisionPrompt
|
|
6
|
+
} = require('../prompts/engineering');
|
|
7
|
+
|
|
8
|
+
async function runEngineeringAgent({
|
|
9
|
+
issue, repoPath, testCommand, costLimitUsd, onEvent,
|
|
10
|
+
// New optional context passed through to the prompt so the agent starts
|
|
11
|
+
// oriented instead of blindly exploring on turn 1.
|
|
12
|
+
lintCommands,
|
|
13
|
+
subPackage,
|
|
14
|
+
contributing,
|
|
15
|
+
relevantFileHints
|
|
16
|
+
}) {
|
|
17
|
+
return runAgentLoop({
|
|
18
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
19
|
+
userPrompt: buildIssuePrompt({
|
|
20
|
+
issueTitle: issue.title,
|
|
21
|
+
issueBody: issue.body || '',
|
|
22
|
+
testCommand,
|
|
23
|
+
lintCommands,
|
|
24
|
+
subPackage,
|
|
25
|
+
contributing,
|
|
26
|
+
relevantFileHints
|
|
27
|
+
}),
|
|
28
|
+
ctx: { repoPath },
|
|
29
|
+
costLimitUsd,
|
|
30
|
+
onEvent
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runRevisionPass({
|
|
35
|
+
issue, repoPath, testCommand, reviewText, currentDiff, costLimitUsd, onEvent
|
|
36
|
+
}) {
|
|
37
|
+
return runAgentLoop({
|
|
38
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
39
|
+
userPrompt: buildRevisionPrompt({
|
|
40
|
+
issueTitle: issue.title,
|
|
41
|
+
reviewText,
|
|
42
|
+
currentDiff,
|
|
43
|
+
testCommand
|
|
44
|
+
}),
|
|
45
|
+
ctx: { repoPath },
|
|
46
|
+
costLimitUsd,
|
|
47
|
+
onEvent
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { runEngineeringAgent, runRevisionPass };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
2
|
+
const { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } = require('../prompts/review');
|
|
3
|
+
const { MODEL } = require('../config');
|
|
4
|
+
|
|
5
|
+
async function runReviewCopilot({ pr, diff, fileMap, issueTitle, issueBody }) {
|
|
6
|
+
const client = new Anthropic();
|
|
7
|
+
const response = await client.messages.create({
|
|
8
|
+
model: MODEL,
|
|
9
|
+
max_tokens: 4096,
|
|
10
|
+
system: [
|
|
11
|
+
{ type: 'text', text: REVIEW_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }
|
|
12
|
+
],
|
|
13
|
+
messages: [
|
|
14
|
+
{
|
|
15
|
+
role: 'user',
|
|
16
|
+
content: buildReviewPrompt({
|
|
17
|
+
prTitle: pr.title,
|
|
18
|
+
prBody: pr.body || '',
|
|
19
|
+
diff,
|
|
20
|
+
fileMap,
|
|
21
|
+
issueTitle,
|
|
22
|
+
issueBody
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return response.content.map(b => b.text || '').join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const INLINE_HEADING = /##\s*6\.?\s*Inline Comments[^\n]*\n/i;
|
|
32
|
+
|
|
33
|
+
// Pull the machine-readable findings out of the review's section 6. Returns a
|
|
34
|
+
// clean array of { file, line, severity, comment }. Defensive by construction:
|
|
35
|
+
// any malformed model output (no block, bad JSON, wrong shape) yields [] rather
|
|
36
|
+
// than throwing — a missing inline block must never break the review flow.
|
|
37
|
+
function parseInlineComments(text) {
|
|
38
|
+
if (!text || typeof text !== 'string') return [];
|
|
39
|
+
|
|
40
|
+
// Prefer the json block that follows the section-6 heading; fall back to the
|
|
41
|
+
// last json block in the document if the heading drifted.
|
|
42
|
+
const headingMatch = text.match(INLINE_HEADING);
|
|
43
|
+
const scope = headingMatch
|
|
44
|
+
? text.slice(headingMatch.index + headingMatch[0].length)
|
|
45
|
+
: text;
|
|
46
|
+
const fence = /```json\s*\n([\s\S]*?)```/gi;
|
|
47
|
+
let raw = null;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = fence.exec(scope)) !== null) raw = m[1]; // keep the last match
|
|
50
|
+
if (raw === null) return [];
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try { parsed = JSON.parse(raw.trim()); } catch { return []; }
|
|
54
|
+
if (!Array.isArray(parsed)) return [];
|
|
55
|
+
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const item of parsed) {
|
|
58
|
+
if (!item || typeof item !== 'object') continue;
|
|
59
|
+
const file = typeof item.file === 'string' ? item.file.trim() : '';
|
|
60
|
+
const line = Number.isInteger(item.line) ? item.line : parseInt(item.line, 10);
|
|
61
|
+
const comment = typeof item.comment === 'string' ? item.comment.trim() : '';
|
|
62
|
+
if (!file || !Number.isInteger(line) || line <= 0 || !comment) continue;
|
|
63
|
+
const severity = item.severity === 'blocking' ? 'blocking' : 'nit';
|
|
64
|
+
out.push({ file, line, severity, comment });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Remove the machine-readable section 6 from the human-facing report so a
|
|
70
|
+
// reviewer reading review-report.md / the PR body never sees raw JSON.
|
|
71
|
+
function stripInlineCommentsBlock(text) {
|
|
72
|
+
if (!text || typeof text !== 'string') return text;
|
|
73
|
+
const headingMatch = text.match(INLINE_HEADING);
|
|
74
|
+
if (headingMatch) return text.slice(0, headingMatch.index).trimEnd() + '\n';
|
|
75
|
+
// No heading — strip a trailing json fence if one is present.
|
|
76
|
+
return text.replace(/```json\s*\n[\s\S]*?```\s*$/i, '').trimEnd() + '\n';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { runReviewCopilot, parseInlineComments, stripInlineCommentsBlock };
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const simpleGit = require('simple-git');
|
|
5
|
+
|
|
6
|
+
const { TEST_COMMAND_TIMEOUT_MS, TOOL_OUTPUT_TRUNCATE } = require('../config');
|
|
7
|
+
const { buildRepoMap } = require('../mapper/repoMap');
|
|
8
|
+
const { rankFiles } = require('../mapper/fileRelevance');
|
|
9
|
+
|
|
10
|
+
const TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: 'read_file',
|
|
13
|
+
description: 'Read the full contents of a file from the working repository. Returns the file text.',
|
|
14
|
+
input_schema: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
path: { type: 'string', description: 'Path relative to the repo root' }
|
|
18
|
+
},
|
|
19
|
+
required: ['path']
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'list_files',
|
|
24
|
+
description: 'List source + config files under a directory. Skips node_modules, .git, target, vendor, .mypy_cache, .pytest_cache, .tox, and other build/cache dirs. Use "" for repo root. If the result is truncated, pass a more specific dir.',
|
|
25
|
+
input_schema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
dir: { type: 'string', description: 'Directory relative to the repo root' }
|
|
29
|
+
},
|
|
30
|
+
required: ['dir']
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'find_relevant_files',
|
|
35
|
+
description: 'Score files against a natural-language query (e.g. the issue title + keywords) and return the top-N most likely relevant paths. Use this on large repos before list_files to avoid context-window blowout.',
|
|
36
|
+
input_schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
query: { type: 'string', description: 'Issue text or search keywords' },
|
|
40
|
+
top_k: { type: 'number', description: 'Max results to return (default 30)' }
|
|
41
|
+
},
|
|
42
|
+
required: ['query']
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'write_file',
|
|
47
|
+
description: 'Create a new file with the given contents. REFUSES to overwrite an existing file unless overwrite:true is passed (use apply_patch or apply_patch_range for edits).',
|
|
48
|
+
input_schema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
path: { type: 'string' },
|
|
52
|
+
content: { type: 'string' },
|
|
53
|
+
overwrite: { type: 'boolean', description: 'Explicit opt-in to replace an existing file wholesale. Default false.' }
|
|
54
|
+
},
|
|
55
|
+
required: ['path', 'content']
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'apply_patch',
|
|
60
|
+
description: 'Replace an exact string in a file. Fails if old_string is not found OR appears more than once. If exact match fails, the tool automatically retries with whitespace-normalized matching; if that uniquely matches, the patch applies. Error messages include the 3 closest lines so you can retry with better context.',
|
|
61
|
+
input_schema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
path: { type: 'string' },
|
|
65
|
+
old_string: { type: 'string' },
|
|
66
|
+
new_string: { type: 'string' }
|
|
67
|
+
},
|
|
68
|
+
required: ['path', 'old_string', 'new_string']
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'apply_patch_range',
|
|
73
|
+
description: 'Replace lines start_line..end_line (inclusive, 1-indexed) in a file with new_content. Use this when apply_patch keeps failing because of whitespace/indentation weirdness in the target (common in deeply-nested Python). Always read_file first to verify line numbers.',
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
path: { type: 'string' },
|
|
78
|
+
start_line: { type: 'number' },
|
|
79
|
+
end_line: { type: 'number' },
|
|
80
|
+
new_content: { type: 'string', description: 'Replacement text (do NOT add a trailing newline unless you want one).' }
|
|
81
|
+
},
|
|
82
|
+
required: ['path', 'start_line', 'end_line', 'new_content']
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'run_tests',
|
|
87
|
+
description: 'Run the project test suite. Retries automatically up to 3 times on failure and flags flaky results. Allowed commands: npm test, npm run test, yarn test, pnpm test, pytest, python -m pytest, go test, cargo test, tox, nox, make.',
|
|
88
|
+
input_schema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
command: { type: 'string' }
|
|
92
|
+
},
|
|
93
|
+
required: ['command']
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'run_lint',
|
|
98
|
+
description: 'Run a project linter / formatter / type checker. Allowed: ruff, black, mypy, flake8, pylint, eslint, prettier, gofmt, rustfmt, cargo fmt, cargo clippy. Returns exit code + stdout/stderr.',
|
|
99
|
+
input_schema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
command: { type: 'string', description: 'A lint command such as "ruff check ." or "eslint src/"' }
|
|
103
|
+
},
|
|
104
|
+
required: ['command']
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'git_diff',
|
|
109
|
+
description: 'Show the git diff of all uncommitted changes in the working repository.',
|
|
110
|
+
input_schema: { type: 'object', properties: {} }
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'git_status',
|
|
114
|
+
description: 'Show which files are modified, created, or deleted in the working repository.',
|
|
115
|
+
input_schema: { type: 'object', properties: {} }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'finish',
|
|
119
|
+
description: 'Signal that you have completed the engineering work AND verified it with passing tests. Provide a one-paragraph PR summary describing what changed and why.',
|
|
120
|
+
input_schema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
pr_summary: { type: 'string' }
|
|
124
|
+
},
|
|
125
|
+
required: ['pr_summary']
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'give_up',
|
|
130
|
+
description: 'Abort the run gracefully when the issue is out of scope for automated handling. Use this — NOT finish — if you would need to: change more than ~5 files, understand undocumented DSL semantics, touch compiled C/C++ extensions, reproduce behaviour that requires a GPU or specialised environment, or make architectural decisions no human has ratified. Shipping half-work is worse than shipping nothing. A human will take over.',
|
|
131
|
+
input_schema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
reason: { type: 'string', description: 'Short label: too_complex, missing_env, out_of_scope, needs_human, test_env_missing, insufficient_info' },
|
|
135
|
+
explanation: { type: 'string', description: 'One-paragraph explanation of what blocked you, for the human who picks this up.' },
|
|
136
|
+
blockers: {
|
|
137
|
+
type: 'array',
|
|
138
|
+
items: { type: 'string' },
|
|
139
|
+
description: 'Concrete list of things that would unblock progress.'
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
required: ['reason', 'explanation']
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
// Each entry is a token sequence. A caller's command must tokenize to start
|
|
148
|
+
// with one of these exact sequences — no shell splitting, no glob expansion,
|
|
149
|
+
// no `;`/`&&`/backticks. Extra positional arguments (flags, paths) are
|
|
150
|
+
// allowed after the prefix.
|
|
151
|
+
const ALLOWED_TEST_COMMANDS = [
|
|
152
|
+
['npm', 'test'],
|
|
153
|
+
['npm', 'run', 'test'],
|
|
154
|
+
['yarn', 'test'],
|
|
155
|
+
['pnpm', 'test'],
|
|
156
|
+
['pytest'],
|
|
157
|
+
['python', '-m', 'pytest'],
|
|
158
|
+
['go', 'test'],
|
|
159
|
+
['cargo', 'test'],
|
|
160
|
+
// Scientific-Python / monorepo common runners
|
|
161
|
+
['tox'],
|
|
162
|
+
['nox'],
|
|
163
|
+
['make', 'test'],
|
|
164
|
+
['make', 'check']
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const ALLOWED_LINT_COMMANDS = [
|
|
168
|
+
['ruff', 'check'],
|
|
169
|
+
['ruff', 'format', '--check'],
|
|
170
|
+
['black', '--check'],
|
|
171
|
+
['mypy'],
|
|
172
|
+
['flake8'],
|
|
173
|
+
['pylint'],
|
|
174
|
+
['eslint'],
|
|
175
|
+
['prettier', '--check'],
|
|
176
|
+
['gofmt', '-l'],
|
|
177
|
+
['go', 'vet'],
|
|
178
|
+
['cargo', 'fmt', '--check'],
|
|
179
|
+
['cargo', 'clippy']
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const SHELL_METACHARS = /[;&|<>`$(){}\[\]\\!*?"']/;
|
|
183
|
+
|
|
184
|
+
function parseAllowlistedCommand(command, allowlist) {
|
|
185
|
+
if (typeof command !== 'string') {
|
|
186
|
+
return { error: 'command must be a string' };
|
|
187
|
+
}
|
|
188
|
+
const trimmed = command.trim();
|
|
189
|
+
if (!trimmed) return { error: 'command is empty' };
|
|
190
|
+
if (SHELL_METACHARS.test(trimmed)) {
|
|
191
|
+
return { error: 'command contains disallowed shell metacharacters' };
|
|
192
|
+
}
|
|
193
|
+
const tokens = trimmed.split(/\s+/);
|
|
194
|
+
const match = allowlist.find(prefix =>
|
|
195
|
+
prefix.length <= tokens.length &&
|
|
196
|
+
prefix.every((t, i) => t === tokens[i])
|
|
197
|
+
);
|
|
198
|
+
if (!match) {
|
|
199
|
+
const pretty = allowlist.map(p => p.join(' ')).join(', ');
|
|
200
|
+
return { error: `Command not in allowlist. Allowed prefixes: ${pretty}` };
|
|
201
|
+
}
|
|
202
|
+
// Windows needs the .cmd shim for node-based tools when shell: false.
|
|
203
|
+
let exe = tokens[0];
|
|
204
|
+
if (process.platform === 'win32' &&
|
|
205
|
+
['npm', 'yarn', 'pnpm', 'eslint', 'prettier'].includes(exe)) {
|
|
206
|
+
exe = `${exe}.cmd`;
|
|
207
|
+
}
|
|
208
|
+
return { exe, args: tokens.slice(1) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseTestCommand(command) {
|
|
212
|
+
return parseAllowlistedCommand(command, ALLOWED_TEST_COMMANDS);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseLintCommand(command) {
|
|
216
|
+
return parseAllowlistedCommand(command, ALLOWED_LINT_COMMANDS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function safeJoin(repoPath, relPath) {
|
|
220
|
+
if (typeof relPath !== 'string') {
|
|
221
|
+
throw new Error('path must be a string');
|
|
222
|
+
}
|
|
223
|
+
const root = path.resolve(repoPath);
|
|
224
|
+
const resolved = path.resolve(root, relPath);
|
|
225
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
226
|
+
throw new Error(`Refusing path outside repo root: ${relPath}`);
|
|
227
|
+
}
|
|
228
|
+
return resolved;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function truncate(s) {
|
|
232
|
+
if (typeof s !== 'string') return s;
|
|
233
|
+
if (s.length <= TOOL_OUTPUT_TRUNCATE) return s;
|
|
234
|
+
return s.slice(0, TOOL_OUTPUT_TRUNCATE) + `\n...[truncated ${s.length - TOOL_OUTPUT_TRUNCATE} chars]`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const HANDLERS = {
|
|
238
|
+
async read_file({ path: rel }, ctx) {
|
|
239
|
+
const full = safeJoin(ctx.repoPath, rel);
|
|
240
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
241
|
+
return { ok: true, content: truncate(content) };
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async list_files({ dir }, ctx) {
|
|
245
|
+
const base = dir ? safeJoin(ctx.repoPath, dir) : ctx.repoPath;
|
|
246
|
+
if (!fs.existsSync(base)) {
|
|
247
|
+
return { ok: false, error: `Directory not found: ${dir}` };
|
|
248
|
+
}
|
|
249
|
+
// Paths are returned relative to the repo root (prefixed with `dir` when
|
|
250
|
+
// walking a subdirectory), so the agent can feed them straight back to
|
|
251
|
+
// read_file / apply_patch without losing its place.
|
|
252
|
+
const result = buildRepoMap(base, { prefix: dir || '', maxFiles: 500 });
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
count: result.files.length,
|
|
256
|
+
total: result.total,
|
|
257
|
+
truncated: result.truncated,
|
|
258
|
+
files: result.files,
|
|
259
|
+
note: result.truncated
|
|
260
|
+
? `Result truncated at ${result.cap} of ${result.total} files. Pass a more specific dir (e.g. "src/submodule") to narrow the view.`
|
|
261
|
+
: undefined
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async find_relevant_files({ query, top_k }, ctx) {
|
|
266
|
+
const repoResult = buildRepoMap(ctx.repoPath, { maxFiles: 5000 });
|
|
267
|
+
const ranked = rankFiles({
|
|
268
|
+
repoPath: ctx.repoPath,
|
|
269
|
+
files: repoResult.files,
|
|
270
|
+
issueText: query,
|
|
271
|
+
topK: top_k || 30
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
scanned: repoResult.total,
|
|
276
|
+
truncated_scan: repoResult.truncated,
|
|
277
|
+
candidates: ranked.map(r => ({ path: r.path, score: r.score }))
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async write_file({ path: rel, content, overwrite }, ctx) {
|
|
282
|
+
const full = safeJoin(ctx.repoPath, rel);
|
|
283
|
+
const exists = fs.existsSync(full);
|
|
284
|
+
if (exists && !overwrite) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: `File ${rel} already exists. Use apply_patch or apply_patch_range for edits, or pass overwrite:true if you genuinely want to replace the whole file.`
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
291
|
+
fs.writeFileSync(full, content);
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
message: `${exists ? 'Overwrote' : 'Created'} ${rel} (${content.length} bytes)`,
|
|
295
|
+
overwrote: exists
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async apply_patch({ path: rel, old_string, new_string }, ctx) {
|
|
300
|
+
const full = safeJoin(ctx.repoPath, rel);
|
|
301
|
+
const current = fs.readFileSync(full, 'utf8');
|
|
302
|
+
|
|
303
|
+
// Strategy 1 — exact match (current behaviour).
|
|
304
|
+
const exact = current.split(old_string).length - 1;
|
|
305
|
+
if (exact === 1) {
|
|
306
|
+
fs.writeFileSync(full, current.replace(old_string, new_string));
|
|
307
|
+
return { ok: true, message: `Patched ${rel} (exact match)` };
|
|
308
|
+
}
|
|
309
|
+
if (exact > 1) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
error: `old_string appears ${exact} times in ${rel}. Add more surrounding context to make it unique, or use apply_patch_range with line numbers.`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Strategy 2 — whitespace-normalized match. Build a pattern where runs
|
|
317
|
+
// of whitespace in old_string match any whitespace in the file. This
|
|
318
|
+
// handles the common failure mode of tabs-vs-spaces and trailing
|
|
319
|
+
// whitespace drift in deeply-nested Python.
|
|
320
|
+
const normalizedPattern = old_string
|
|
321
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
322
|
+
.replace(/\s+/g, '\\s+');
|
|
323
|
+
const re = new RegExp(normalizedPattern, 'g');
|
|
324
|
+
const matches = current.match(re) || [];
|
|
325
|
+
if (matches.length === 1) {
|
|
326
|
+
const patched = current.replace(re, () => new_string);
|
|
327
|
+
fs.writeFileSync(full, patched);
|
|
328
|
+
return { ok: true, message: `Patched ${rel} (whitespace-normalized match)` };
|
|
329
|
+
}
|
|
330
|
+
if (matches.length > 1) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: `old_string is ambiguous even with whitespace normalization (${matches.length} matches in ${rel}). Add more surrounding context or use apply_patch_range.`
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Strategy 3 — return the three closest lines so the agent can retry.
|
|
338
|
+
const snippet = old_string.split('\n')[0].trim();
|
|
339
|
+
const lines = current.split('\n');
|
|
340
|
+
const candidates = lines
|
|
341
|
+
.map((line, idx) => ({ line: line.trim(), idx: idx + 1 }))
|
|
342
|
+
.filter(l => l.line.length > 0 && snippet && l.line.includes(snippet.slice(0, 20)))
|
|
343
|
+
.slice(0, 3);
|
|
344
|
+
const hint = candidates.length
|
|
345
|
+
? ` Closest candidate lines: ${candidates.map(c => `L${c.idx}: ${c.line.slice(0, 80)}`).join(' | ')}`
|
|
346
|
+
: '';
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: `old_string not found in ${rel}. Re-read the file and check whitespace/newlines.${hint}`
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
async apply_patch_range({ path: rel, start_line, end_line, new_content }, ctx) {
|
|
354
|
+
const full = safeJoin(ctx.repoPath, rel);
|
|
355
|
+
const current = fs.readFileSync(full, 'utf8');
|
|
356
|
+
const lines = current.split('\n');
|
|
357
|
+
if (!Number.isInteger(start_line) || !Number.isInteger(end_line)) {
|
|
358
|
+
return { ok: false, error: 'start_line and end_line must be integers (1-indexed, inclusive).' };
|
|
359
|
+
}
|
|
360
|
+
if (start_line < 1 || end_line < start_line || end_line > lines.length) {
|
|
361
|
+
return { ok: false, error: `Invalid range ${start_line}..${end_line} for file with ${lines.length} lines.` };
|
|
362
|
+
}
|
|
363
|
+
// Replace slice. end_line is inclusive; splice deleteCount = end - start + 1.
|
|
364
|
+
const newLines = new_content.split('\n');
|
|
365
|
+
const before = lines.slice(0, start_line - 1);
|
|
366
|
+
const after = lines.slice(end_line);
|
|
367
|
+
const patched = [...before, ...newLines, ...after].join('\n');
|
|
368
|
+
fs.writeFileSync(full, patched);
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
message: `Replaced lines ${start_line}..${end_line} in ${rel} (${end_line - start_line + 1} old → ${newLines.length} new)`
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async run_tests({ command }, ctx) {
|
|
376
|
+
const parsed = parseTestCommand(command);
|
|
377
|
+
if (parsed.error) {
|
|
378
|
+
return { ok: false, error: parsed.error };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const MAX_ATTEMPTS = 3;
|
|
382
|
+
let last = null;
|
|
383
|
+
let attempts = 0;
|
|
384
|
+
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
|
385
|
+
attempts = i;
|
|
386
|
+
last = spawnSync(parsed.exe, parsed.args, {
|
|
387
|
+
shell: false,
|
|
388
|
+
cwd: ctx.repoPath,
|
|
389
|
+
encoding: 'utf8',
|
|
390
|
+
timeout: TEST_COMMAND_TIMEOUT_MS,
|
|
391
|
+
maxBuffer: 16 * 1024 * 1024
|
|
392
|
+
});
|
|
393
|
+
if (last.status === 0) break;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const passed = last.status === 0;
|
|
397
|
+
const flaky = passed && attempts > 1;
|
|
398
|
+
// Detect "test env not installed" failures so the agent can give_up
|
|
399
|
+
// gracefully rather than looping forever on import errors.
|
|
400
|
+
const stderr = (last.stderr || '') + (last.stdout || '');
|
|
401
|
+
const envError = !passed && /ModuleNotFoundError|ImportError|No module named|command not found|cannot find module/i.test(stderr);
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
exit_code: last.status,
|
|
405
|
+
passed,
|
|
406
|
+
flaky,
|
|
407
|
+
attempts,
|
|
408
|
+
env_error: envError,
|
|
409
|
+
stdout: truncate(last.stdout || ''),
|
|
410
|
+
stderr: truncate(last.stderr || '')
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async run_lint({ command }, ctx) {
|
|
415
|
+
const parsed = parseLintCommand(command);
|
|
416
|
+
if (parsed.error) {
|
|
417
|
+
return { ok: false, error: parsed.error };
|
|
418
|
+
}
|
|
419
|
+
const result = spawnSync(parsed.exe, parsed.args, {
|
|
420
|
+
shell: false,
|
|
421
|
+
cwd: ctx.repoPath,
|
|
422
|
+
encoding: 'utf8',
|
|
423
|
+
timeout: TEST_COMMAND_TIMEOUT_MS,
|
|
424
|
+
maxBuffer: 16 * 1024 * 1024
|
|
425
|
+
});
|
|
426
|
+
return {
|
|
427
|
+
ok: true,
|
|
428
|
+
exit_code: result.status,
|
|
429
|
+
passed: result.status === 0,
|
|
430
|
+
stdout: truncate(result.stdout || ''),
|
|
431
|
+
stderr: truncate(result.stderr || '')
|
|
432
|
+
};
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
async git_diff(_input, ctx) {
|
|
436
|
+
const diff = await simpleGit(ctx.repoPath).diff();
|
|
437
|
+
return { ok: true, diff: truncate(diff || '(no changes)') };
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
async git_status(_input, ctx) {
|
|
441
|
+
const s = await simpleGit(ctx.repoPath).status();
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
modified: s.modified,
|
|
445
|
+
created: s.created,
|
|
446
|
+
deleted: s.deleted,
|
|
447
|
+
not_added: s.not_added,
|
|
448
|
+
renamed: s.renamed
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async finish({ pr_summary }, _ctx) {
|
|
453
|
+
return { ok: true, pr_summary };
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
async give_up({ reason, explanation, blockers }, _ctx) {
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
gave_up: true,
|
|
460
|
+
reason: String(reason || 'unspecified'),
|
|
461
|
+
explanation: String(explanation || ''),
|
|
462
|
+
blockers: Array.isArray(blockers) ? blockers : []
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
async function dispatchTool(toolName, input, ctx) {
|
|
468
|
+
const handler = HANDLERS[toolName];
|
|
469
|
+
if (!handler) return { ok: false, error: `Unknown tool: ${toolName}` };
|
|
470
|
+
try {
|
|
471
|
+
return await handler(input, ctx);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
return { ok: false, error: err.message };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
module.exports = {
|
|
478
|
+
TOOLS,
|
|
479
|
+
HANDLERS,
|
|
480
|
+
dispatchTool,
|
|
481
|
+
safeJoin,
|
|
482
|
+
ALLOWED_TEST_COMMANDS,
|
|
483
|
+
ALLOWED_LINT_COMMANDS,
|
|
484
|
+
parseTestCommand,
|
|
485
|
+
parseLintCommand
|
|
486
|
+
};
|