@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,336 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const simpleGit = require('simple-git');
|
|
4
|
+
|
|
5
|
+
const { runEngineeringAgent, runRevisionPass } = require('./agents/engineeringAgent');
|
|
6
|
+
const { runReviewCopilot } = require('./agents/reviewCopilot');
|
|
7
|
+
const { MAX_REVISION_PASSES } = require('./config');
|
|
8
|
+
|
|
9
|
+
function detectsRequestChanges(reviewText) {
|
|
10
|
+
return /\bREQUEST_CHANGES\b/.test(reviewText);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractVerdict(reviewText) {
|
|
14
|
+
if (/\bAPPROVE\b/.test(reviewText)) return 'APPROVE';
|
|
15
|
+
if (/\bREQUEST_CHANGES\b/.test(reviewText)) return 'REQUEST_CHANGES';
|
|
16
|
+
if (/\bNEEDS_DISCUSSION\b/.test(reviewText)) return 'NEEDS_DISCUSSION';
|
|
17
|
+
return 'UNKNOWN';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runEngineeringWithSelfReview({
|
|
21
|
+
issue, repoPath, testCommand, costLimitUsd, onEvent,
|
|
22
|
+
lintCommands, subPackage, contributing, relevantFileHints
|
|
23
|
+
}) {
|
|
24
|
+
onEvent({ stage: 'engineering_start' });
|
|
25
|
+
const engineering = await runEngineeringAgent({
|
|
26
|
+
issue, repoPath, testCommand, costLimitUsd, onEvent,
|
|
27
|
+
lintCommands, subPackage, contributing, relevantFileHints
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!engineering.finalSummary) {
|
|
31
|
+
onEvent({
|
|
32
|
+
stage: 'engineering_aborted',
|
|
33
|
+
reason: engineering.aborted || 'no_finish',
|
|
34
|
+
gaveUp: engineering.gaveUp || undefined
|
|
35
|
+
});
|
|
36
|
+
return { engineering, review: null, revision: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const diff = await simpleGit(repoPath).diff();
|
|
40
|
+
if (!diff.trim()) {
|
|
41
|
+
onEvent({ stage: 'no_diff' });
|
|
42
|
+
return { engineering, review: null, revision: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onEvent({ stage: 'self_review_start' });
|
|
46
|
+
const review = await runReviewCopilot({
|
|
47
|
+
pr: { title: issue.title, body: engineering.finalSummary },
|
|
48
|
+
diff,
|
|
49
|
+
fileMap: {},
|
|
50
|
+
issueTitle: issue.title,
|
|
51
|
+
issueBody: issue.body || ''
|
|
52
|
+
});
|
|
53
|
+
onEvent({ stage: 'self_review_done', verdict: extractVerdict(review) });
|
|
54
|
+
|
|
55
|
+
let revision = null;
|
|
56
|
+
if (detectsRequestChanges(review) && MAX_REVISION_PASSES > 0) {
|
|
57
|
+
onEvent({ stage: 'revision_start' });
|
|
58
|
+
revision = await runRevisionPass({
|
|
59
|
+
issue, repoPath, testCommand, costLimitUsd,
|
|
60
|
+
reviewText: review,
|
|
61
|
+
currentDiff: diff,
|
|
62
|
+
onEvent
|
|
63
|
+
});
|
|
64
|
+
onEvent({ stage: 'revision_done', summary: revision.finalSummary });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { engineering, review, revision };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function ensureFork(octokit, upstreamOwner, repo, onEvent = () => {}) {
|
|
71
|
+
const { data: user } = await octokit.users.getAuthenticated();
|
|
72
|
+
const username = user.login;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await octokit.repos.get({ owner: username, repo });
|
|
76
|
+
onEvent({ stage: 'fork_exists', user: username });
|
|
77
|
+
return username;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (e.status !== 404) throw e;
|
|
80
|
+
onEvent({ stage: 'fork_creating', user: username });
|
|
81
|
+
await octokit.repos.createFork({ owner: upstreamOwner, repo });
|
|
82
|
+
|
|
83
|
+
// Fork creation is async on GitHub's side. Poll briefly.
|
|
84
|
+
for (let i = 0; i < 10; i++) {
|
|
85
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
86
|
+
try {
|
|
87
|
+
await octokit.repos.get({ owner: username, repo });
|
|
88
|
+
onEvent({ stage: 'fork_ready', user: username });
|
|
89
|
+
return username;
|
|
90
|
+
} catch {
|
|
91
|
+
// Polling loop: GitHub returns 404 until the fork is ready.
|
|
92
|
+
// Swallow the 404 and try again next iteration.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Fork ${username}/${repo} did not become ready within 20s`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function commitAndPush({ repoPath, branch, message, pushOwner, repo, token }) {
|
|
100
|
+
// Use `git -c http.extraheader=...` for auth so the token never appears
|
|
101
|
+
// in any URL, .git/config entry, or git error message containing the URL.
|
|
102
|
+
const auth = token ? Buffer.from(`x-access-token:${token}`).toString('base64') : null;
|
|
103
|
+
const git = simpleGit(auth
|
|
104
|
+
? { baseDir: repoPath, config: [`http.extraheader=AUTHORIZATION: Basic ${auth}`] }
|
|
105
|
+
: { baseDir: repoPath });
|
|
106
|
+
await git.add('.');
|
|
107
|
+
await git.commit(message);
|
|
108
|
+
const cleanUrl = `https://github.com/${pushOwner}/${repo}.git`;
|
|
109
|
+
await git.push(cleanUrl, branch, ['--set-upstream']);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function openPullRequest({ octokit, owner, repo, headOwner, branch, base, title, body }) {
|
|
113
|
+
const head = headOwner === owner ? branch : `${headOwner}:${branch}`;
|
|
114
|
+
const { data: pr } = await octokit.pulls.create({
|
|
115
|
+
owner, repo, head, base, title, body
|
|
116
|
+
});
|
|
117
|
+
return pr;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function exists(p) { try { return fs.existsSync(p); } catch { return false; } }
|
|
121
|
+
function readIfExists(p) {
|
|
122
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Test command detection
|
|
127
|
+
//
|
|
128
|
+
// Walked in priority order; returns the *most specific* runner the project
|
|
129
|
+
// defines, not just "pytest" as a fallback. For scientific-Python repos like
|
|
130
|
+
// Qiskit/Cirq/TQEC the actual test entrypoint is usually tox, nox, or a
|
|
131
|
+
// Makefile target — not bare pytest.
|
|
132
|
+
function detectTestCommand(repoPath) {
|
|
133
|
+
// 1. Makefile with a `test:` target.
|
|
134
|
+
for (const name of ['Makefile', 'makefile', 'GNUmakefile']) {
|
|
135
|
+
const mk = readIfExists(path.join(repoPath, name));
|
|
136
|
+
if (mk && /^\s*test\s*:/m.test(mk)) return 'make test';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. tox / nox — strong signals when present.
|
|
140
|
+
if (exists(path.join(repoPath, 'tox.ini'))) return 'tox';
|
|
141
|
+
if (exists(path.join(repoPath, 'noxfile.py'))) return 'nox';
|
|
142
|
+
|
|
143
|
+
// 3. Node (package.json scripts.test).
|
|
144
|
+
const pkgPath = path.join(repoPath, 'package.json');
|
|
145
|
+
if (exists(pkgPath)) {
|
|
146
|
+
try {
|
|
147
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
148
|
+
if (pkg.scripts && pkg.scripts.test) return 'npm test';
|
|
149
|
+
} catch {
|
|
150
|
+
// Malformed package.json — fall through to the next detection step.
|
|
151
|
+
// Don't crash project discovery on a broken JSON file.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Python.
|
|
156
|
+
if (exists(path.join(repoPath, 'pytest.ini')) ||
|
|
157
|
+
exists(path.join(repoPath, 'pyproject.toml')) ||
|
|
158
|
+
exists(path.join(repoPath, 'setup.cfg'))) {
|
|
159
|
+
return 'pytest';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 5. Go / Rust.
|
|
163
|
+
if (exists(path.join(repoPath, 'go.mod'))) return 'go test ./...';
|
|
164
|
+
if (exists(path.join(repoPath, 'Cargo.toml'))) return 'cargo test';
|
|
165
|
+
|
|
166
|
+
return 'npm test';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Lint / format / typecheck command detection.
|
|
171
|
+
//
|
|
172
|
+
// Returns a list — a well-run Python project has several of these gated in
|
|
173
|
+
// CI, and getting tests green while failing ruff is a common silent failure.
|
|
174
|
+
function detectLintCommands(repoPath) {
|
|
175
|
+
const cmds = [];
|
|
176
|
+
const pyproject = readIfExists(path.join(repoPath, 'pyproject.toml')) || '';
|
|
177
|
+
const setupCfg = readIfExists(path.join(repoPath, 'setup.cfg')) || '';
|
|
178
|
+
const tooling = pyproject + '\n' + setupCfg;
|
|
179
|
+
|
|
180
|
+
if (exists(path.join(repoPath, 'ruff.toml')) || /\[tool\.ruff\b/.test(tooling)) cmds.push('ruff check .');
|
|
181
|
+
if (/\[tool\.black\b/.test(tooling) || exists(path.join(repoPath, '.black'))) cmds.push('black --check .');
|
|
182
|
+
if (/\[tool\.mypy\b/.test(tooling) || exists(path.join(repoPath, 'mypy.ini'))) cmds.push('mypy .');
|
|
183
|
+
if (exists(path.join(repoPath, '.flake8')) || /\[flake8\b/.test(tooling)) cmds.push('flake8 .');
|
|
184
|
+
if (exists(path.join(repoPath, '.pylintrc')) || /\[tool\.pylint\b/.test(tooling)) cmds.push('pylint');
|
|
185
|
+
|
|
186
|
+
// JS/TS
|
|
187
|
+
if (exists(path.join(repoPath, '.eslintrc')) ||
|
|
188
|
+
exists(path.join(repoPath, '.eslintrc.json')) ||
|
|
189
|
+
exists(path.join(repoPath, '.eslintrc.js')) ||
|
|
190
|
+
exists(path.join(repoPath, 'eslint.config.js'))) {
|
|
191
|
+
cmds.push('eslint .');
|
|
192
|
+
}
|
|
193
|
+
if (exists(path.join(repoPath, '.prettierrc')) ||
|
|
194
|
+
exists(path.join(repoPath, '.prettierrc.json')) ||
|
|
195
|
+
exists(path.join(repoPath, 'prettier.config.js'))) {
|
|
196
|
+
cmds.push('prettier --check .');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return cmds;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Monorepo sub-package detection.
|
|
204
|
+
//
|
|
205
|
+
// Looks for Python/Node/Rust sub-packages one level deep. Returns
|
|
206
|
+
// [{ path, kind, name }]. Empty = flat repo.
|
|
207
|
+
function detectSubPackages(repoPath) {
|
|
208
|
+
const results = [];
|
|
209
|
+
let entries = [];
|
|
210
|
+
try {
|
|
211
|
+
entries = fs.readdirSync(repoPath, { withFileTypes: true });
|
|
212
|
+
} catch { return results; }
|
|
213
|
+
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
216
|
+
const sub = path.join(repoPath, entry.name);
|
|
217
|
+
if (exists(path.join(sub, 'pyproject.toml')) || exists(path.join(sub, 'setup.py'))) {
|
|
218
|
+
results.push({ path: entry.name, kind: 'python', name: entry.name });
|
|
219
|
+
} else if (exists(path.join(sub, 'package.json'))) {
|
|
220
|
+
results.push({ path: entry.name, kind: 'node', name: entry.name });
|
|
221
|
+
} else if (exists(path.join(sub, 'Cargo.toml'))) {
|
|
222
|
+
results.push({ path: entry.name, kind: 'rust', name: entry.name });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Guess which sub-package an issue refers to, by scoring issue text against
|
|
229
|
+
// sub-package names.
|
|
230
|
+
function guessSubPackageForIssue(subPackages, issueText) {
|
|
231
|
+
if (!subPackages.length || !issueText) return null;
|
|
232
|
+
const text = String(issueText).toLowerCase();
|
|
233
|
+
let best = null;
|
|
234
|
+
let bestScore = 0;
|
|
235
|
+
for (const sub of subPackages) {
|
|
236
|
+
const name = sub.name.toLowerCase();
|
|
237
|
+
// Match on full name and on the last hyphen-segment (qiskit-terra → terra).
|
|
238
|
+
const tail = name.split(/[-_]/).pop();
|
|
239
|
+
let score = 0;
|
|
240
|
+
if (text.includes(name)) score += 5;
|
|
241
|
+
if (tail && tail.length >= 3 && text.includes(tail)) score += 2;
|
|
242
|
+
if (score > bestScore) { bestScore = score; best = sub; }
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Contribution guidelines: reads CONTRIBUTING + PR template, detects DCO.
|
|
249
|
+
function readContributionGuidelines(repoPath) {
|
|
250
|
+
const candidates = [
|
|
251
|
+
'CONTRIBUTING.md', 'CONTRIBUTING.rst', 'CONTRIBUTING',
|
|
252
|
+
path.join('.github', 'CONTRIBUTING.md'),
|
|
253
|
+
path.join('docs', 'CONTRIBUTING.md')
|
|
254
|
+
];
|
|
255
|
+
let contributing = null;
|
|
256
|
+
for (const c of candidates) {
|
|
257
|
+
const full = path.join(repoPath, c);
|
|
258
|
+
const txt = readIfExists(full);
|
|
259
|
+
if (txt) { contributing = { path: c, text: txt }; break; }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const prTemplateCandidates = [
|
|
263
|
+
path.join('.github', 'PULL_REQUEST_TEMPLATE.md'),
|
|
264
|
+
path.join('.github', 'pull_request_template.md'),
|
|
265
|
+
path.join('docs', 'pull_request_template.md'),
|
|
266
|
+
'PULL_REQUEST_TEMPLATE.md'
|
|
267
|
+
];
|
|
268
|
+
let prTemplate = null;
|
|
269
|
+
for (const c of prTemplateCandidates) {
|
|
270
|
+
const full = path.join(repoPath, c);
|
|
271
|
+
const txt = readIfExists(full);
|
|
272
|
+
if (txt) { prTemplate = { path: c, text: txt }; break; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// DCO detection: most projects mention "Signed-off-by" or "DCO" in
|
|
276
|
+
// CONTRIBUTING or ship a .github/dco.yml.
|
|
277
|
+
const dcoYml = exists(path.join(repoPath, '.github', 'dco.yml')) ||
|
|
278
|
+
exists(path.join(repoPath, '.dco.yml'));
|
|
279
|
+
const dcoMentioned = contributing && /signed[- ]off[- ]by|\bDCO\b|Developer Certificate of Origin/i.test(contributing.text);
|
|
280
|
+
const requiresDco = Boolean(dcoYml || dcoMentioned);
|
|
281
|
+
|
|
282
|
+
return { contributing, prTemplate, requiresDco };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Duplicate PR guard: scan open PRs for ones that already resolve the issue.
|
|
287
|
+
//
|
|
288
|
+
// Returns:
|
|
289
|
+
// { pr: <PR>, ok: true } — found a matching open PR
|
|
290
|
+
// { pr: null, ok: true } — no match, dedup check ran cleanly
|
|
291
|
+
// { pr: null, ok: false,
|
|
292
|
+
// error: <message> } — API call failed, dedup result is unknown.
|
|
293
|
+
// Caller decides whether to proceed.
|
|
294
|
+
//
|
|
295
|
+
// Uses octokit.paginate so repos with hundreds of open PRs (Qiskit-class)
|
|
296
|
+
// don't silently miss duplicates on page 2+.
|
|
297
|
+
async function findExistingPrForIssue(octokit, owner, repo, issueNumber) {
|
|
298
|
+
let prs;
|
|
299
|
+
try {
|
|
300
|
+
prs = await octokit.paginate(octokit.pulls.list, {
|
|
301
|
+
owner, repo, state: 'open', per_page: 100
|
|
302
|
+
});
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// Don't silently bypass the safety check on API failure (rate limit,
|
|
305
|
+
// auth issue, GitHub down). Surface it so the caller can either error
|
|
306
|
+
// out or override with --force-pr.
|
|
307
|
+
return {
|
|
308
|
+
pr: null,
|
|
309
|
+
ok: false,
|
|
310
|
+
error: `pulls.list failed: ${e.status || e.code || e.message}`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const needleBranch = `fix/issue-${issueNumber}`;
|
|
314
|
+
const needleBody = new RegExp(`(resolves|closes|fixes)\\s+#${issueNumber}\\b`, 'i');
|
|
315
|
+
for (const pr of prs) {
|
|
316
|
+
if (pr.head && pr.head.ref === needleBranch) return { pr, ok: true };
|
|
317
|
+
if (pr.body && needleBody.test(pr.body)) return { pr, ok: true };
|
|
318
|
+
if (pr.title && needleBody.test(pr.title)) return { pr, ok: true };
|
|
319
|
+
}
|
|
320
|
+
return { pr: null, ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
runEngineeringWithSelfReview,
|
|
325
|
+
ensureFork,
|
|
326
|
+
commitAndPush,
|
|
327
|
+
openPullRequest,
|
|
328
|
+
detectTestCommand,
|
|
329
|
+
detectLintCommands,
|
|
330
|
+
detectSubPackages,
|
|
331
|
+
guessSubPackageForIssue,
|
|
332
|
+
readContributionGuidelines,
|
|
333
|
+
findExistingPrForIssue,
|
|
334
|
+
extractVerdict,
|
|
335
|
+
detectsRequestChanges
|
|
336
|
+
};
|