@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.
Files changed (36) hide show
  1. package/CONTRIBUTING.md +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +570 -0
  4. package/TESTING.md +290 -0
  5. package/action.yml +113 -0
  6. package/examples/README.md +124 -0
  7. package/examples/sample-audit-trail-issue-4.md +112 -0
  8. package/examples/sample-review-tqec-pr894-v1-raw-flawed.md +71 -0
  9. package/examples/sample-review-tqec-pr894-v2-raw.md +48 -0
  10. package/examples/sample-review-tqec-pr894-v3-curated.md +118 -0
  11. package/examples/verify-marker-precedence/README.md +97 -0
  12. package/examples/verify-marker-precedence/conftest.py +15 -0
  13. package/examples/verify-marker-precedence/pyproject.toml +8 -0
  14. package/examples/verify-marker-precedence/test_marker_precedence.py +56 -0
  15. package/examples/verify-marker-precedence/verify_precedence.py +67 -0
  16. package/examples/workflows/issue-fix.yml +32 -0
  17. package/examples/workflows/pr-review.yml +34 -0
  18. package/package.json +75 -0
  19. package/scripts/verify.js +478 -0
  20. package/src/agents/agentLoop.js +176 -0
  21. package/src/agents/engineeringAgent.js +51 -0
  22. package/src/agents/reviewCopilot.js +79 -0
  23. package/src/agents/tools.js +486 -0
  24. package/src/cli/output.js +137 -0
  25. package/src/config.js +22 -0
  26. package/src/mapper/fileRelevance.js +113 -0
  27. package/src/mapper/repoMap.js +105 -0
  28. package/src/orchestrator.js +336 -0
  29. package/src/pipeline.js +985 -0
  30. package/src/prompts/engineering.js +189 -0
  31. package/src/prompts/review.js +149 -0
  32. package/src/utils/cost.js +47 -0
  33. package/src/utils/diffLines.js +67 -0
  34. package/src/utils/githubUrl.js +8 -0
  35. package/src/web/public/index.html +128 -0
  36. 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
+ };