@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
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require('dotenv').config();
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const simpleGit = require('simple-git');
|
|
7
|
+
|
|
8
|
+
const { runReviewCopilot, parseInlineComments, stripInlineCommentsBlock } = require('./agents/reviewCopilot');
|
|
9
|
+
const { parseGithubUrl } = require('./utils/githubUrl');
|
|
10
|
+
const { parseDiffLines, isCommentable } = require('./utils/diffLines');
|
|
11
|
+
const { sumUsage, computeCost } = require('./utils/cost');
|
|
12
|
+
const { MAX_REVIEW_FILE_BYTES, DEFAULT_MAX_USD_PER_RUN } = require('./config');
|
|
13
|
+
const {
|
|
14
|
+
runEngineeringWithSelfReview,
|
|
15
|
+
ensureFork,
|
|
16
|
+
commitAndPush,
|
|
17
|
+
openPullRequest,
|
|
18
|
+
detectTestCommand,
|
|
19
|
+
detectLintCommands,
|
|
20
|
+
detectSubPackages,
|
|
21
|
+
guessSubPackageForIssue,
|
|
22
|
+
readContributionGuidelines,
|
|
23
|
+
findExistingPrForIssue,
|
|
24
|
+
extractVerdict
|
|
25
|
+
} = require('./orchestrator');
|
|
26
|
+
const { buildRepoMap } = require('./mapper/repoMap');
|
|
27
|
+
const { rankFiles } = require('./mapper/fileRelevance');
|
|
28
|
+
const {
|
|
29
|
+
banner, step, info, ok, warn, err,
|
|
30
|
+
usageSummary,
|
|
31
|
+
makeAgentEventHandler, makeStageEventHandler
|
|
32
|
+
} = require('./cli/output');
|
|
33
|
+
|
|
34
|
+
const { ANTHROPIC_API_KEY, GITHUB_TOKEN } = process.env;
|
|
35
|
+
const REVIEWABLE_EXTENSIONS = /\.(js|ts|tsx|jsx|py|mjs|cjs)$/i;
|
|
36
|
+
|
|
37
|
+
let _OctokitCtor = null;
|
|
38
|
+
async function getOctokit(token) {
|
|
39
|
+
if (!_OctokitCtor) {
|
|
40
|
+
_OctokitCtor = (await import('@octokit/rest')).Octokit;
|
|
41
|
+
}
|
|
42
|
+
return new _OctokitCtor({ auth: token });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- arg parsing ---
|
|
46
|
+
const RAW_ARGS = process.argv.slice(2);
|
|
47
|
+
const POSITIONAL = RAW_ARGS.filter(a => !a.startsWith('--'));
|
|
48
|
+
const FLAGS = new Set(RAW_ARGS.filter(a => a.startsWith('--') && !a.includes('=')));
|
|
49
|
+
function getOpt(name, fallback) {
|
|
50
|
+
const arg = RAW_ARGS.find(a => a.startsWith(`--${name}=`));
|
|
51
|
+
if (!arg) return fallback;
|
|
52
|
+
return arg.split('=').slice(1).join('=');
|
|
53
|
+
}
|
|
54
|
+
function getOptFloat(name, fallback) {
|
|
55
|
+
const v = getOpt(name, null);
|
|
56
|
+
if (v === null) return fallback;
|
|
57
|
+
const f = parseFloat(v);
|
|
58
|
+
return Number.isFinite(f) ? f : fallback;
|
|
59
|
+
}
|
|
60
|
+
function getOptInt(name, fallback) {
|
|
61
|
+
const v = getOpt(name, null);
|
|
62
|
+
if (v === null) return fallback;
|
|
63
|
+
const n = parseInt(v, 10);
|
|
64
|
+
return Number.isFinite(n) ? n : fallback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// When running inside GitHub Actions, surface the verdict as a step output
|
|
68
|
+
// (consumable by later workflow steps) and as a job-summary panel. No-ops
|
|
69
|
+
// locally — both env vars are only set on Actions runners.
|
|
70
|
+
function emitGithubActionVerdict(verdict, reportPath) {
|
|
71
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
72
|
+
if (out) {
|
|
73
|
+
try { fs.appendFileSync(out, `verdict=${verdict}\n`); } catch { /* best-effort */ }
|
|
74
|
+
}
|
|
75
|
+
const summary = process.env.GITHUB_STEP_SUMMARY;
|
|
76
|
+
if (summary) {
|
|
77
|
+
const icon = verdict === 'APPROVE' ? '✅'
|
|
78
|
+
: verdict === 'REQUEST_CHANGES' ? '🛑'
|
|
79
|
+
: verdict === 'NEEDS_DISCUSSION' ? '💬' : '❓';
|
|
80
|
+
let body = `## ${icon} github-agent review — ${verdict}\n\n`;
|
|
81
|
+
try {
|
|
82
|
+
if (reportPath && fs.existsSync(reportPath)) body += fs.readFileSync(reportPath, 'utf8');
|
|
83
|
+
} catch { /* best-effort */ }
|
|
84
|
+
try { fs.appendFileSync(summary, body + '\n'); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function usage() {
|
|
89
|
+
console.log(`
|
|
90
|
+
Usage:
|
|
91
|
+
node src/pipeline.js issue <github-issue-url> [flags]
|
|
92
|
+
node src/pipeline.js review <github-pr-url>
|
|
93
|
+
node src/pipeline.js triage <github-repo-url> [--label=bug] [--max=5] [flags]
|
|
94
|
+
|
|
95
|
+
Flags:
|
|
96
|
+
--dry-run Run engineering + self-review locally; skip commit/push/PR.
|
|
97
|
+
--fork Push to your fork of the repo; open PR from fork to upstream.
|
|
98
|
+
Use this when you don't have write access to the target.
|
|
99
|
+
--comment After opening the PR, post a comment on the original issue
|
|
100
|
+
linking to the PR. Works without repo write access.
|
|
101
|
+
--post (review only) Post the self-review as a PR review comment.
|
|
102
|
+
Works on any public PR — no repo write access needed.
|
|
103
|
+
--advisory (review only) Always exit 0, even on REQUEST_CHANGES /
|
|
104
|
+
NEEDS_DISCUSSION. Posts findings without failing the run.
|
|
105
|
+
Used by the GitHub Action's advisory (non-blocking) mode.
|
|
106
|
+
--force-pr Open the PR even if self-review verdict is REQUEST_CHANGES /
|
|
107
|
+
NEEDS_DISCUSSION / UNKNOWN, or if tests never passed.
|
|
108
|
+
Use only when you've inspected the audit trail manually.
|
|
109
|
+
--web Start a live dashboard on http://localhost:3000 (localhost only).
|
|
110
|
+
--web-bind-all Bind the dashboard to 0.0.0.0 instead of 127.0.0.1.
|
|
111
|
+
Anyone on your LAN can read agent output. Use with care.
|
|
112
|
+
--port=N Dashboard port (default 3000).
|
|
113
|
+
--max-cost=2.50 Abort the agent loop if cost (USD) exceeds this. Default ${DEFAULT_MAX_USD_PER_RUN}.
|
|
114
|
+
--label=bug (triage only) Issue label filter.
|
|
115
|
+
--max=5 (triage only) Max issues to process.
|
|
116
|
+
|
|
117
|
+
Environment (in .env):
|
|
118
|
+
ANTHROPIC_API_KEY required — Claude API key
|
|
119
|
+
GITHUB_TOKEN required — GitHub PAT with repo scope
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- shared helpers ---
|
|
124
|
+
|
|
125
|
+
// Build a simple-git instance whose `git` invocations carry an
|
|
126
|
+
// HTTP `Authorization: Basic ...` header at the COMMAND level (`git -c
|
|
127
|
+
// http.extraheader=...`). The header is never written to .git/config and
|
|
128
|
+
// the token never appears in any URL on disk. Drop-in replacement for the
|
|
129
|
+
// old "bake the token into the clone URL, then strip" pattern.
|
|
130
|
+
function gitWithToken(baseDir, token) {
|
|
131
|
+
if (!token) return simpleGit(baseDir);
|
|
132
|
+
const auth = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
133
|
+
return simpleGit({
|
|
134
|
+
baseDir,
|
|
135
|
+
config: [`http.extraheader=AUTHORIZATION: Basic ${auth}`]
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function cloneIfMissing(owner, repo, log) {
|
|
140
|
+
const reposDir = path.join(process.cwd(), 'repos');
|
|
141
|
+
const localPath = path.join(reposDir, `${owner}-${repo}`);
|
|
142
|
+
if (fs.existsSync(localPath)) {
|
|
143
|
+
log(info(`Repo already cloned at ${localPath}`));
|
|
144
|
+
return localPath;
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(reposDir, { recursive: true });
|
|
147
|
+
const cleanUrl = `https://github.com/${owner}/${repo}.git`;
|
|
148
|
+
log(info(`Cloning ${owner}/${repo} into ${localPath}`));
|
|
149
|
+
// Auth flows through `-c http.extraheader=...` set by gitWithToken;
|
|
150
|
+
// the URL itself stays clean and nothing token-bearing lands in
|
|
151
|
+
// .git/config or in the remote URL.
|
|
152
|
+
await gitWithToken(undefined, GITHUB_TOKEN).clone(cleanUrl, localPath);
|
|
153
|
+
return localPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function checkoutFixBranch(repoPath, issueNumber) {
|
|
157
|
+
const branch = `fix/issue-${issueNumber}`;
|
|
158
|
+
const git = simpleGit(repoPath);
|
|
159
|
+
const branches = await git.branchLocal();
|
|
160
|
+
if (branches.all.includes(branch)) {
|
|
161
|
+
await git.checkout(branch);
|
|
162
|
+
} else {
|
|
163
|
+
await git.checkoutLocalBranch(branch);
|
|
164
|
+
}
|
|
165
|
+
// Ensure a clean working tree — every run starts from a known state,
|
|
166
|
+
// not from whatever the previous run left behind.
|
|
167
|
+
await git.reset(['--hard', 'HEAD']);
|
|
168
|
+
await git.clean('f', ['-d']);
|
|
169
|
+
return branch;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- Audit trail rendering --------------------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// The audit trail is the artefact a human reads when they want to understand
|
|
175
|
+
// what the agent did — and, crucially, decide whether to trust the PR. The
|
|
176
|
+
// old format dumped every tool call as JSON which was exhaustive but
|
|
177
|
+
// unreadable. The new format leads with a human summary, then condenses the
|
|
178
|
+
// timeline, and keeps the full trace behind a collapsed `<details>` for the
|
|
179
|
+
// cases where someone needs to debug.
|
|
180
|
+
|
|
181
|
+
function summarizeTool(name, input, result) {
|
|
182
|
+
switch (name) {
|
|
183
|
+
case 'read_file': return `read \`${input.path}\``;
|
|
184
|
+
case 'list_files': return `listed \`${input.dir || '/'}\` (${result && result.count} files)`;
|
|
185
|
+
case 'find_relevant_files': return `ranked files for: "${String(input.query || '').slice(0, 60)}"`;
|
|
186
|
+
case 'write_file': return `wrote \`${input.path}\` (${(input.content || '').length} bytes)`;
|
|
187
|
+
case 'apply_patch': return `patched \`${input.path}\``;
|
|
188
|
+
case 'apply_patch_range': return `replaced lines ${input.start_line}-${input.end_line} of \`${input.path}\``;
|
|
189
|
+
case 'run_tests': return `ran tests: \`${input.command}\` → ${result && result.passed ? (result.flaky ? 'PASS (flaky)' : 'PASS') : 'FAIL'}`;
|
|
190
|
+
case 'run_lint': return `ran lint: \`${input.command}\` → ${result && result.passed ? 'PASS' : 'FAIL'}`;
|
|
191
|
+
case 'git_diff': return `inspected working diff`;
|
|
192
|
+
case 'git_status': return `checked git status`;
|
|
193
|
+
case 'finish': return `signalled finish`;
|
|
194
|
+
case 'give_up': return `gave up (${input.reason})`;
|
|
195
|
+
default: return name;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function condenseTimeline(history) {
|
|
200
|
+
// Collapse consecutive read_file/list_files from the same turn into one line
|
|
201
|
+
// each; keep edits, test runs, and decisions verbose.
|
|
202
|
+
const byTurn = new Map();
|
|
203
|
+
for (const entry of history) {
|
|
204
|
+
if (!byTurn.has(entry.turn)) byTurn.set(entry.turn, { thoughts: [], tools: [] });
|
|
205
|
+
if (entry.kind === 'thought') byTurn.get(entry.turn).thoughts.push(entry.text);
|
|
206
|
+
else if (entry.kind === 'tool') byTurn.get(entry.turn).tools.push(entry);
|
|
207
|
+
}
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (const [turn, { thoughts, tools }] of byTurn) {
|
|
210
|
+
const oneLineThought = thoughts.join(' ').replace(/\s+/g, ' ').trim();
|
|
211
|
+
const thoughtSnippet = oneLineThought
|
|
212
|
+
? ` — ${oneLineThought.length > 160 ? oneLineThought.slice(0, 157) + '…' : oneLineThought}`
|
|
213
|
+
: '';
|
|
214
|
+
const toolSummary = tools
|
|
215
|
+
.map(t => {
|
|
216
|
+
const label = summarizeTool(t.name, t.input, t.result);
|
|
217
|
+
if (t.result && t.result.ok === false) return `${label} ✗ (${t.result.error || 'error'})`;
|
|
218
|
+
if (t.name === 'run_tests' && t.result && t.result.flaky) return `${label} ⚠`;
|
|
219
|
+
return label;
|
|
220
|
+
})
|
|
221
|
+
.join('; ');
|
|
222
|
+
lines.push(`- **Turn ${turn}**${thoughtSnippet}${toolSummary ? `\n - ${toolSummary.replace(/; /g, '\n - ')}` : ''}`);
|
|
223
|
+
}
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function diffFileStats(history) {
|
|
228
|
+
// Approximate changed-file counts from edit tool calls. Not perfect (the
|
|
229
|
+
// source of truth is `git diff`), but fine for the timeline view.
|
|
230
|
+
const edits = new Map(); // path → { touches: n, kind }
|
|
231
|
+
for (const entry of history) {
|
|
232
|
+
if (entry.kind !== 'tool') continue;
|
|
233
|
+
const p = entry.input && entry.input.path;
|
|
234
|
+
if (!p) continue;
|
|
235
|
+
if (['write_file', 'apply_patch', 'apply_patch_range'].includes(entry.name) &&
|
|
236
|
+
entry.result && entry.result.ok) {
|
|
237
|
+
const e = edits.get(p) || { touches: 0, ops: new Set() };
|
|
238
|
+
e.touches += 1;
|
|
239
|
+
e.ops.add(entry.name);
|
|
240
|
+
edits.set(p, e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return edits;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summarizeTestRuns(history) {
|
|
247
|
+
let attempts = 0, passes = 0, flaky = 0, fails = 0, envErrors = 0;
|
|
248
|
+
for (const entry of history) {
|
|
249
|
+
if (entry.kind !== 'tool' || entry.name !== 'run_tests') continue;
|
|
250
|
+
const r = entry.result || {};
|
|
251
|
+
attempts++;
|
|
252
|
+
if (r.passed) passes++;
|
|
253
|
+
if (r.flaky) flaky++;
|
|
254
|
+
if (!r.passed) fails++;
|
|
255
|
+
if (r.env_error) envErrors++;
|
|
256
|
+
}
|
|
257
|
+
return { attempts, passes, flaky, fails, envErrors };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildAuditTrail({ issue, branch, engineering, review, revision, totalUsage, preFixSha }) {
|
|
261
|
+
const cost = computeCost(totalUsage);
|
|
262
|
+
const verdict = review ? extractVerdict(review) : 'NO_REVIEW';
|
|
263
|
+
const finalSummary = (revision && revision.finalSummary) || engineering.finalSummary;
|
|
264
|
+
const sawTests = engineering.sawPassingTests || (revision && revision.sawPassingTests);
|
|
265
|
+
const sawLint = engineering.sawPassingLint !== null
|
|
266
|
+
? engineering.sawPassingLint
|
|
267
|
+
: (revision && revision.sawPassingLint);
|
|
268
|
+
|
|
269
|
+
const engEdits = diffFileStats(engineering.history);
|
|
270
|
+
const revEdits = revision ? diffFileStats(revision.history) : new Map();
|
|
271
|
+
const allEdits = new Map([...engEdits, ...revEdits]);
|
|
272
|
+
const testStats = summarizeTestRuns([...(engineering.history || []), ...(revision ? revision.history : [])]);
|
|
273
|
+
|
|
274
|
+
const lines = [];
|
|
275
|
+
|
|
276
|
+
// -------------------- Header --------------------
|
|
277
|
+
lines.push(`# Audit trail — issue #${issue.number}: ${issue.title}`);
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(`**Issue:** ${issue.html_url}`);
|
|
280
|
+
lines.push(`**Branch:** \`${branch}\``);
|
|
281
|
+
if (preFixSha) {
|
|
282
|
+
lines.push(`**Pre-fix HEAD:** \`${preFixSha}\` — revert with \`git reset --hard ${preFixSha}\``);
|
|
283
|
+
}
|
|
284
|
+
lines.push(`**Turns used:** ${engineering.completedTurns}${revision ? ` + ${revision.completedTurns} (revision)` : ''} of ${require('./config').MAX_AGENT_ITERATIONS}`);
|
|
285
|
+
lines.push(`**Cost:** $${cost.total_usd.toFixed(4)} (${totalUsage.input_tokens.toLocaleString()} in, ${totalUsage.output_tokens.toLocaleString()} out, ${totalUsage.cache_read_input_tokens.toLocaleString()} cache-read)`);
|
|
286
|
+
lines.push('');
|
|
287
|
+
|
|
288
|
+
// -------------------- Outcome --------------------
|
|
289
|
+
lines.push('## Outcome');
|
|
290
|
+
if (engineering.gaveUp) {
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(`❌ **Gave up** — \`${engineering.gaveUp.reason}\``);
|
|
293
|
+
lines.push('');
|
|
294
|
+
lines.push(engineering.gaveUp.explanation);
|
|
295
|
+
if (engineering.gaveUp.blockers && engineering.gaveUp.blockers.length) {
|
|
296
|
+
lines.push('');
|
|
297
|
+
lines.push('**Blockers:**');
|
|
298
|
+
for (const b of engineering.gaveUp.blockers) lines.push(`- ${b}`);
|
|
299
|
+
}
|
|
300
|
+
} else if (finalSummary) {
|
|
301
|
+
lines.push('');
|
|
302
|
+
lines.push(`✅ **Finished** — ${revision ? 'after revision pass' : 'in single pass'}`);
|
|
303
|
+
lines.push('');
|
|
304
|
+
lines.push(finalSummary);
|
|
305
|
+
} else {
|
|
306
|
+
lines.push('');
|
|
307
|
+
lines.push(`⚠ **Did not finish** — ${engineering.aborted || engineering.stopReason || 'unknown'}`);
|
|
308
|
+
}
|
|
309
|
+
lines.push('');
|
|
310
|
+
|
|
311
|
+
// -------------------- Safety gates --------------------
|
|
312
|
+
lines.push('## Safety gates');
|
|
313
|
+
lines.push(`- Self-review verdict: **${verdict}**`);
|
|
314
|
+
lines.push(`- Tests observed passing: **${sawTests ? 'YES' : 'NO'}**`);
|
|
315
|
+
if (sawLint !== null && sawLint !== undefined) {
|
|
316
|
+
lines.push(`- Lint observed passing: **${sawLint ? 'YES' : 'NO'}**`);
|
|
317
|
+
}
|
|
318
|
+
lines.push('');
|
|
319
|
+
|
|
320
|
+
// -------------------- Changed files --------------------
|
|
321
|
+
if (allEdits.size) {
|
|
322
|
+
lines.push('## Files touched');
|
|
323
|
+
for (const [p, e] of allEdits) {
|
|
324
|
+
lines.push(`- \`${p}\` — ${e.touches} edit(s) via ${[...e.ops].join(', ')}`);
|
|
325
|
+
}
|
|
326
|
+
lines.push('');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// -------------------- Tests --------------------
|
|
330
|
+
if (testStats.attempts) {
|
|
331
|
+
lines.push('## Test runs');
|
|
332
|
+
lines.push(`- Total invocations: ${testStats.attempts}`);
|
|
333
|
+
lines.push(`- Passed: ${testStats.passes}${testStats.flaky ? ` (of which flaky: ${testStats.flaky})` : ''}`);
|
|
334
|
+
lines.push(`- Failed: ${testStats.fails}${testStats.envErrors ? ` (including ${testStats.envErrors} environment/import errors)` : ''}`);
|
|
335
|
+
lines.push('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// -------------------- Timeline --------------------
|
|
339
|
+
lines.push('## Timeline (condensed)');
|
|
340
|
+
lines.push('');
|
|
341
|
+
lines.push(condenseTimeline(engineering.history));
|
|
342
|
+
if (revision) {
|
|
343
|
+
lines.push('');
|
|
344
|
+
lines.push('### Revision pass');
|
|
345
|
+
lines.push('');
|
|
346
|
+
lines.push(condenseTimeline(revision.history));
|
|
347
|
+
}
|
|
348
|
+
lines.push('');
|
|
349
|
+
|
|
350
|
+
// -------------------- Self-review --------------------
|
|
351
|
+
if (review) {
|
|
352
|
+
lines.push('## Self-review report');
|
|
353
|
+
lines.push('');
|
|
354
|
+
lines.push(stripInlineCommentsBlock(review));
|
|
355
|
+
lines.push('');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// -------------------- Full transcript --------------------
|
|
359
|
+
lines.push('## Full tool transcript');
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push('<details><summary>Click to expand — raw tool-call trace for debugging</summary>');
|
|
362
|
+
lines.push('');
|
|
363
|
+
for (const entry of engineering.history) {
|
|
364
|
+
if (entry.kind === 'thought') {
|
|
365
|
+
lines.push(`**[engineering turn ${entry.turn}] thought:** ${entry.text}`);
|
|
366
|
+
} else if (entry.kind === 'tool') {
|
|
367
|
+
const status = entry.result && entry.result.ok ? 'ok' : `error: ${entry.result && entry.result.error}`;
|
|
368
|
+
lines.push(`**[engineering turn ${entry.turn}] ${entry.name}** — ${status}`);
|
|
369
|
+
const inputPreview = JSON.stringify(entry.input || {}).slice(0, 300);
|
|
370
|
+
lines.push(`\`\`\`json\n${inputPreview}\n\`\`\``);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (revision) {
|
|
374
|
+
for (const entry of revision.history) {
|
|
375
|
+
if (entry.kind === 'thought') {
|
|
376
|
+
lines.push(`**[revision turn ${entry.turn}] thought:** ${entry.text}`);
|
|
377
|
+
} else if (entry.kind === 'tool') {
|
|
378
|
+
const status = entry.result && entry.result.ok ? 'ok' : `error: ${entry.result && entry.result.error}`;
|
|
379
|
+
lines.push(`**[revision turn ${entry.turn}] ${entry.name}** — ${status}`);
|
|
380
|
+
const inputPreview = JSON.stringify(entry.input || {}).slice(0, 300);
|
|
381
|
+
lines.push(`\`\`\`json\n${inputPreview}\n\`\`\``);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
lines.push('');
|
|
386
|
+
lines.push('</details>');
|
|
387
|
+
|
|
388
|
+
return lines.join('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildPrBody({ issue, engineering, review, revision, prTemplate }) {
|
|
392
|
+
const summary = (revision && revision.finalSummary) || engineering.finalSummary;
|
|
393
|
+
const lines = [];
|
|
394
|
+
|
|
395
|
+
// If the project ships a PR template, honor its structure by putting the
|
|
396
|
+
// template text first, then our summary + review below. Keeps the PR from
|
|
397
|
+
// getting rejected on process grounds.
|
|
398
|
+
if (prTemplate && prTemplate.text) {
|
|
399
|
+
lines.push(prTemplate.text.trim());
|
|
400
|
+
lines.push('\n---\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
lines.push(`Resolves #${issue.number}`);
|
|
404
|
+
lines.push('\n## What changed\n');
|
|
405
|
+
lines.push(summary);
|
|
406
|
+
if (review) {
|
|
407
|
+
lines.push('\n## Automated self-review\n');
|
|
408
|
+
lines.push('<details><summary>Click to expand</summary>\n');
|
|
409
|
+
// Strip the machine-readable inline-findings block — it's noise in a PR body.
|
|
410
|
+
lines.push(stripInlineCommentsBlock(review));
|
|
411
|
+
lines.push('\n</details>');
|
|
412
|
+
}
|
|
413
|
+
lines.push('\n---\n🤖 Generated by [github-agent](https://github.com/Hadar01/github-agents) — autonomous engineering + self-review with Claude.');
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Map model-emitted inline findings onto GitHub `createReview` comment objects.
|
|
418
|
+
// Findings whose (file, line) is not a commentable diff line are returned in
|
|
419
|
+
// `dropped` so the caller can fold them into the review body — never silently
|
|
420
|
+
// discard them, and never let a bad anchor 422 the whole review.
|
|
421
|
+
function partitionInlineComments(inline, diff) {
|
|
422
|
+
const diffLines = parseDiffLines(diff);
|
|
423
|
+
const anchored = [];
|
|
424
|
+
const dropped = [];
|
|
425
|
+
for (const f of inline) {
|
|
426
|
+
const tag = f.severity === 'blocking' ? '🛑 **blocking**' : '💡 nit';
|
|
427
|
+
if (isCommentable(diffLines, f.file, f.line)) {
|
|
428
|
+
anchored.push({ path: f.file, line: f.line, side: 'RIGHT', body: `${tag} — ${f.comment}` });
|
|
429
|
+
} else {
|
|
430
|
+
dropped.push(f);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { anchored, dropped };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Render findings that couldn't be posted inline as a markdown list, appended to
|
|
437
|
+
// the review body so they remain visible with their file:line.
|
|
438
|
+
function formatDroppedFindings(findings, heading = 'Findings not anchored to a diff line') {
|
|
439
|
+
if (!findings || !findings.length) return '';
|
|
440
|
+
const lines = ['', '---', '', `### ${heading}`, ''];
|
|
441
|
+
for (const f of findings) {
|
|
442
|
+
const sev = f.severity === 'blocking' ? 'blocking' : 'nit';
|
|
443
|
+
lines.push(`- \`${f.file}:${f.line}\` (${sev}) — ${f.comment}`);
|
|
444
|
+
}
|
|
445
|
+
return lines.join('\n');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- dashboard wiring ---
|
|
449
|
+
async function maybeStartDashboard() {
|
|
450
|
+
if (!FLAGS.has('--web')) return null;
|
|
451
|
+
const { createDashboard } = require('./web/server');
|
|
452
|
+
const dashboard = createDashboard();
|
|
453
|
+
const port = getOptInt('port', 3000);
|
|
454
|
+
// Default to 127.0.0.1 — the dashboard streams raw agent output and is not
|
|
455
|
+
// authenticated. --web-bind-all binds 0.0.0.0 with a loud warning.
|
|
456
|
+
const bindAll = FLAGS.has('--web-bind-all');
|
|
457
|
+
const host = bindAll ? '0.0.0.0' : '127.0.0.1';
|
|
458
|
+
await dashboard.start(port, { host });
|
|
459
|
+
if (bindAll) {
|
|
460
|
+
console.log(warn(`Dashboard bound to 0.0.0.0:${port} — reachable from any network interface.`));
|
|
461
|
+
console.log(warn('Anyone on this LAN/VPN can read every agent thought, command output, and stack trace.'));
|
|
462
|
+
} else {
|
|
463
|
+
console.log(ok(`Dashboard live at http://localhost:${port}`));
|
|
464
|
+
}
|
|
465
|
+
return dashboard;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- core runner (reusable by both `issue` and `triage`) ---
|
|
469
|
+
async function runIssue({ url, octokit, dashboard, options, log }) {
|
|
470
|
+
const parsed = parseGithubUrl(url);
|
|
471
|
+
if (!parsed) return { ok: false, url, error: 'invalid URL' };
|
|
472
|
+
const { owner, repo, number } = parsed;
|
|
473
|
+
|
|
474
|
+
log(step(`Issue ${owner}/${repo}#${number}`));
|
|
475
|
+
|
|
476
|
+
const [{ data: issue }, { data: repoInfo }] = await Promise.all([
|
|
477
|
+
octokit.issues.get({ owner, repo, issue_number: number }),
|
|
478
|
+
octokit.repos.get({ owner, repo })
|
|
479
|
+
]);
|
|
480
|
+
log(info(`title: ${issue.title}`));
|
|
481
|
+
log(info(`default branch: ${repoInfo.default_branch}`));
|
|
482
|
+
|
|
483
|
+
// Duplicate-PR guard: skip cloning anything if a PR already claims this
|
|
484
|
+
// issue. Cheap remote check, saves a ~30s clone on dense backlogs.
|
|
485
|
+
if (!options.forcePr) {
|
|
486
|
+
const dup = await findExistingPrForIssue(octokit, owner, repo, number);
|
|
487
|
+
if (!dup.ok) {
|
|
488
|
+
log(err(`Duplicate-PR check failed: ${dup.error}`));
|
|
489
|
+
log(warn('Refusing to proceed without a clean dedup check. Re-run with --force-pr to override.'));
|
|
490
|
+
return { ok: false, url, error: 'dedup_check_failed', detail: dup.error };
|
|
491
|
+
}
|
|
492
|
+
if (dup.pr) {
|
|
493
|
+
log(warn(`An open PR already resolves issue #${number}: ${dup.pr.html_url}`));
|
|
494
|
+
log(warn('Skipping. Re-run with --force-pr to process anyway.'));
|
|
495
|
+
return {
|
|
496
|
+
ok: false, url, error: 'duplicate_pr',
|
|
497
|
+
existingPrUrl: dup.pr.html_url
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
log(step('Cloning + branching'));
|
|
503
|
+
const repoPath = await cloneIfMissing(owner, repo, log);
|
|
504
|
+
const branch = await checkoutFixBranch(repoPath, number);
|
|
505
|
+
log(ok(`branch: ${branch}`));
|
|
506
|
+
|
|
507
|
+
// --- Project context gathering (scientific-Python-class repos) ---
|
|
508
|
+
const testCommand = detectTestCommand(repoPath);
|
|
509
|
+
const lintCommands = detectLintCommands(repoPath);
|
|
510
|
+
const subPackages = detectSubPackages(repoPath);
|
|
511
|
+
const issueText = `${issue.title}\n${issue.body || ''}`;
|
|
512
|
+
const subPackage = guessSubPackageForIssue(subPackages, issueText);
|
|
513
|
+
const contributing = readContributionGuidelines(repoPath);
|
|
514
|
+
|
|
515
|
+
log(info(`test command: ${testCommand}`));
|
|
516
|
+
if (lintCommands.length) log(info(`lint commands: ${lintCommands.join(', ')}`));
|
|
517
|
+
if (subPackages.length) {
|
|
518
|
+
log(info(`monorepo sub-packages: ${subPackages.map(s => s.name).join(', ')}`));
|
|
519
|
+
if (subPackage) log(info(`guessed sub-package for issue: ${subPackage.name}`));
|
|
520
|
+
}
|
|
521
|
+
if (contributing.contributing) log(info(`CONTRIBUTING.md found at ${contributing.contributing.path}`));
|
|
522
|
+
if (contributing.requiresDco) log(warn('Project requires DCO Signed-off-by — will auto-sign commits.'));
|
|
523
|
+
log(info(`cost ceiling: $${options.maxCost.toFixed(2)}`));
|
|
524
|
+
|
|
525
|
+
// Pre-compute a shortlist of likely-relevant files from the issue text so
|
|
526
|
+
// the agent doesn't burn turns walking the tree on big repos.
|
|
527
|
+
let relevantFileHints = [];
|
|
528
|
+
try {
|
|
529
|
+
const repoMap = buildRepoMap(repoPath, { maxFiles: 5000 });
|
|
530
|
+
relevantFileHints = rankFiles({
|
|
531
|
+
repoPath, files: repoMap.files, issueText, topK: 20
|
|
532
|
+
});
|
|
533
|
+
if (relevantFileHints.length) {
|
|
534
|
+
log(info(`${relevantFileHints.length} file(s) prefiltered as likely relevant`));
|
|
535
|
+
}
|
|
536
|
+
} catch (e) {
|
|
537
|
+
log(warn(`relevance prefilter failed (${e.message}); agent will explore on its own`));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const preFixSha = (await simpleGit(repoPath).revparse(['HEAD'])).trim();
|
|
541
|
+
log(info(`pre-fix HEAD: ${preFixSha}`));
|
|
542
|
+
|
|
543
|
+
const onAgent = makeAgentEventHandler(log);
|
|
544
|
+
const onStage = makeStageEventHandler(log);
|
|
545
|
+
const onEvent = (e) => {
|
|
546
|
+
if (e.stage) onStage(e); else onAgent(e);
|
|
547
|
+
if (dashboard) dashboard.pushEvent(e);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const { engineering, review, revision } = await runEngineeringWithSelfReview({
|
|
551
|
+
issue, repoPath, testCommand,
|
|
552
|
+
costLimitUsd: options.maxCost,
|
|
553
|
+
onEvent,
|
|
554
|
+
lintCommands, subPackage, contributing: contributing.contributing,
|
|
555
|
+
relevantFileHints
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const totalUsage = sumUsage(engineering.usage, revision && revision.usage);
|
|
559
|
+
log('\n' + usageSummary('Token usage (engineering + revision)', totalUsage));
|
|
560
|
+
|
|
561
|
+
const audit = buildAuditTrail({
|
|
562
|
+
issue, branch, engineering, review, revision, totalUsage, preFixSha
|
|
563
|
+
});
|
|
564
|
+
const auditPath = path.join(repoPath, 'audit-trail.md');
|
|
565
|
+
fs.writeFileSync(auditPath, audit);
|
|
566
|
+
log(ok(`audit trail: ${auditPath}`));
|
|
567
|
+
|
|
568
|
+
// The agent may have gracefully given up. That's a recognised outcome, not
|
|
569
|
+
// a failure — surface it clearly and optionally drop an explanation comment
|
|
570
|
+
// on the issue so the human who picks it up has context.
|
|
571
|
+
if (engineering.gaveUp) {
|
|
572
|
+
const g = engineering.gaveUp;
|
|
573
|
+
log(warn(`Agent gave up: ${g.reason}`));
|
|
574
|
+
log(info(g.explanation));
|
|
575
|
+
if (g.blockers && g.blockers.length) {
|
|
576
|
+
log(info(`Blockers: ${g.blockers.join('; ')}`));
|
|
577
|
+
}
|
|
578
|
+
if (options.comment) {
|
|
579
|
+
try {
|
|
580
|
+
const body = `🤖 github-agent attempted this issue but could not complete it automatically.\n\n` +
|
|
581
|
+
`**Reason:** \`${g.reason}\`\n\n${g.explanation}\n\n` +
|
|
582
|
+
(g.blockers && g.blockers.length
|
|
583
|
+
? `**What would unblock progress:**\n${g.blockers.map(b => `- ${b}`).join('\n')}\n\n`
|
|
584
|
+
: '') +
|
|
585
|
+
`No PR was opened.`;
|
|
586
|
+
const { data: c } = await octokit.issues.createComment({
|
|
587
|
+
owner, repo, issue_number: number, body
|
|
588
|
+
});
|
|
589
|
+
log(ok(`posted give-up explanation to issue: ${c.html_url}`));
|
|
590
|
+
} catch (e) {
|
|
591
|
+
log(warn(`could not post give-up comment: ${e.message}`));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { ok: false, url, error: 'gave_up', gaveUp: g, totalUsage };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!engineering.finalSummary) {
|
|
598
|
+
log(err(`Engineering agent did not finish (${engineering.aborted || 'no finish'}). Skipping PR.`));
|
|
599
|
+
return { ok: false, url, error: engineering.aborted || 'no_finish', totalUsage };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const verdict = review ? extractVerdict(review) : 'NO_REVIEW';
|
|
603
|
+
log(info(`final review verdict: ${verdict}`));
|
|
604
|
+
|
|
605
|
+
// ---- SAFETY GATES ----
|
|
606
|
+
// We do not silently ship diffs that failed their own self-review, were
|
|
607
|
+
// produced without a passing test run, or whose review never completed.
|
|
608
|
+
// Pass --force-pr to override (e.g. for known-false-positive reviews).
|
|
609
|
+
const gateReasons = [];
|
|
610
|
+
if (verdict === 'REQUEST_CHANGES') gateReasons.push('self-review verdict is REQUEST_CHANGES');
|
|
611
|
+
if (verdict === 'UNKNOWN') gateReasons.push('review verdict could not be parsed');
|
|
612
|
+
if (verdict === 'NO_REVIEW') gateReasons.push('self-review did not complete');
|
|
613
|
+
if (verdict === 'NEEDS_DISCUSSION') gateReasons.push('self-review flagged NEEDS_DISCUSSION');
|
|
614
|
+
const sawPassingTests = engineering.sawPassingTests ||
|
|
615
|
+
(revision && revision.sawPassingTests);
|
|
616
|
+
if (!sawPassingTests) gateReasons.push('no successful test run observed during the agent session');
|
|
617
|
+
|
|
618
|
+
if (gateReasons.length && !options.forcePr) {
|
|
619
|
+
for (const reason of gateReasons) log(err(`gate: ${reason}`));
|
|
620
|
+
log(warn('Refusing to open a PR. Re-run with --force-pr to override, or inspect audit-trail.md and diff manually.'));
|
|
621
|
+
return { ok: false, url, verdict, totalUsage, error: 'pr_gate_blocked', gateReasons };
|
|
622
|
+
}
|
|
623
|
+
if (gateReasons.length && options.forcePr) {
|
|
624
|
+
for (const reason of gateReasons) log(warn(`--force-pr set; proceeding despite: ${reason}`));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (options.dryRun) {
|
|
628
|
+
log(warn('--dry-run: skipping commit/push/PR'));
|
|
629
|
+
return { ok: true, url, verdict, totalUsage, dryRun: true };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Determine push target (fork vs upstream)
|
|
633
|
+
let pushOwner = owner;
|
|
634
|
+
let headOwner = owner;
|
|
635
|
+
if (options.fork) {
|
|
636
|
+
log(step('Ensuring fork'));
|
|
637
|
+
const username = await ensureFork(octokit, owner, repo, onEvent);
|
|
638
|
+
pushOwner = username;
|
|
639
|
+
headOwner = username;
|
|
640
|
+
log(ok(`fork: ${username}/${repo}`));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
log(step('Committing + pushing'));
|
|
644
|
+
let commitMsg = `fix: ${issue.title} (#${number})\n\n${(revision && revision.finalSummary) || engineering.finalSummary}`;
|
|
645
|
+
if (contributing.requiresDco) {
|
|
646
|
+
// Use the authenticated GitHub user's identity for the sign-off — that's
|
|
647
|
+
// who's taking responsibility for the submission under DCO.
|
|
648
|
+
try {
|
|
649
|
+
const { data: user } = await octokit.users.getAuthenticated();
|
|
650
|
+
const signEmail = user.email || `${user.login}@users.noreply.github.com`;
|
|
651
|
+
const signName = user.name || user.login;
|
|
652
|
+
commitMsg += `\n\nSigned-off-by: ${signName} <${signEmail}>`;
|
|
653
|
+
log(info('added DCO Signed-off-by trailer'));
|
|
654
|
+
} catch (e) {
|
|
655
|
+
log(warn(`could not build DCO trailer: ${e.message}`));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
await commitAndPush({ repoPath, branch, message: commitMsg, pushOwner, repo, token: GITHUB_TOKEN });
|
|
659
|
+
log(ok(`pushed ${branch} to ${pushOwner}/${repo}`));
|
|
660
|
+
|
|
661
|
+
log(step('Opening pull request'));
|
|
662
|
+
const pr = await openPullRequest({
|
|
663
|
+
octokit, owner, repo, headOwner, branch,
|
|
664
|
+
base: repoInfo.default_branch,
|
|
665
|
+
title: `fix: ${issue.title}`,
|
|
666
|
+
body: buildPrBody({
|
|
667
|
+
issue, engineering, review, revision,
|
|
668
|
+
prTemplate: contributing.prTemplate
|
|
669
|
+
})
|
|
670
|
+
});
|
|
671
|
+
log(ok(`PR opened: ${pr.html_url}`));
|
|
672
|
+
if (dashboard) dashboard.pushEvent({ stage: 'pr_opened', url: pr.html_url });
|
|
673
|
+
|
|
674
|
+
// Optional: drop a comment on the original issue linking to the PR. Works
|
|
675
|
+
// on repos where you don't have write access — GitHub lets any
|
|
676
|
+
// authenticated user comment on public issues.
|
|
677
|
+
if (options.comment) {
|
|
678
|
+
try {
|
|
679
|
+
const { data: c } = await octokit.issues.createComment({
|
|
680
|
+
owner, repo, issue_number: number,
|
|
681
|
+
body: `🤖 I've opened a pull request addressing this issue: ${pr.html_url}\n\n(Generated by [github-agent](https://github.com/Hadar01/github-agents).)`
|
|
682
|
+
});
|
|
683
|
+
log(ok(`commented on issue: ${c.html_url}`));
|
|
684
|
+
} catch (e) {
|
|
685
|
+
log(warn(`could not comment on issue: ${e.message}`));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return { ok: true, url, prUrl: pr.html_url, verdict, totalUsage };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- handlers ---
|
|
693
|
+
async function handleIssue(url, options, dashboard) {
|
|
694
|
+
const octokit = await getOctokit(GITHUB_TOKEN);
|
|
695
|
+
const result = await runIssue({ url, octokit, dashboard, options, log: console.log });
|
|
696
|
+
if (!result.ok) process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function handleTriage(repoUrl, options, dashboard) {
|
|
700
|
+
const m = repoUrl.match(/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\/|$)/);
|
|
701
|
+
if (!m) { console.error('Invalid repo URL.'); process.exit(1); }
|
|
702
|
+
const [, owner, repo] = m;
|
|
703
|
+
|
|
704
|
+
const octokit = await getOctokit(GITHUB_TOKEN);
|
|
705
|
+
console.log(banner());
|
|
706
|
+
console.log(step(`Triage ${owner}/${repo}`));
|
|
707
|
+
console.log(info(`label filter: ${options.label || '(none)'}`));
|
|
708
|
+
console.log(info(`max issues: ${options.max}`));
|
|
709
|
+
|
|
710
|
+
const listParams = {
|
|
711
|
+
owner, repo, state: 'open', per_page: Math.max(options.max * 2, 30)
|
|
712
|
+
};
|
|
713
|
+
if (options.label) listParams.labels = options.label;
|
|
714
|
+
|
|
715
|
+
const { data: issues } = await octokit.issues.listForRepo(listParams);
|
|
716
|
+
const realIssues = issues.filter(i => !i.pull_request).slice(0, options.max);
|
|
717
|
+
console.log(ok(`found ${realIssues.length} issue(s) to triage`));
|
|
718
|
+
|
|
719
|
+
const results = [];
|
|
720
|
+
for (const issue of realIssues) {
|
|
721
|
+
console.log('\n' + '━'.repeat(60));
|
|
722
|
+
try {
|
|
723
|
+
const r = await runIssue({ url: issue.html_url, octokit, dashboard, options, log: console.log });
|
|
724
|
+
results.push(r);
|
|
725
|
+
} catch (e) {
|
|
726
|
+
console.error(err(`Failed on ${issue.html_url}: ${e.message}`));
|
|
727
|
+
results.push({ ok: false, url: issue.html_url, error: e.message });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
console.log('\n' + '━'.repeat(60));
|
|
732
|
+
console.log(step('Triage summary'));
|
|
733
|
+
let totalCost = 0;
|
|
734
|
+
for (const r of results) {
|
|
735
|
+
const status = r.ok ? (r.dryRun ? '[dry]' : '✓') : '✗';
|
|
736
|
+
const tail = r.prUrl ? r.prUrl : (r.error || r.verdict || '');
|
|
737
|
+
console.log(` ${status} ${r.url} ${tail}`);
|
|
738
|
+
if (r.totalUsage) totalCost += computeCost(r.totalUsage).total_usd;
|
|
739
|
+
}
|
|
740
|
+
console.log(`\n${ok(`total spend: $${totalCost.toFixed(4)}`)}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function fetchPrDiff(octokit, owner, repo, number) {
|
|
744
|
+
const res = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
|
|
745
|
+
owner, repo, pull_number: number,
|
|
746
|
+
headers: { accept: 'application/vnd.github.v3.diff' }
|
|
747
|
+
});
|
|
748
|
+
return res.data;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function fetchChangedFilesContent(octokit, owner, repo, number, headSha) {
|
|
752
|
+
const { data: changed } = await octokit.pulls.listFiles({
|
|
753
|
+
owner, repo, pull_number: number, per_page: 100
|
|
754
|
+
});
|
|
755
|
+
const fileMap = {};
|
|
756
|
+
// Use `changes` (additions + deletions) as a fast proxy for file size —
|
|
757
|
+
// `pulls.listFiles` does not return blob size. Skip generated-looking
|
|
758
|
+
// paths. Cap total bytes loaded so a multi-megabyte PR can't blow the
|
|
759
|
+
// review prompt's context window.
|
|
760
|
+
const CHANGE_LIMIT = 4000; // lines-changed proxy for "too big"
|
|
761
|
+
const GENERATED_PATH = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gopkg\.lock|poetry\.lock|composer\.lock|\.min\.(?:js|css)|dist\/|build\/)/;
|
|
762
|
+
let bytesLoaded = 0;
|
|
763
|
+
const skipped = [];
|
|
764
|
+
|
|
765
|
+
for (const f of changed) {
|
|
766
|
+
if (f.status === 'removed') continue;
|
|
767
|
+
if (!REVIEWABLE_EXTENSIONS.test(f.filename)) continue;
|
|
768
|
+
if (GENERATED_PATH.test(f.filename)) { skipped.push(`${f.filename} (generated)`); continue; }
|
|
769
|
+
if ((f.changes || 0) > CHANGE_LIMIT) { skipped.push(`${f.filename} (${f.changes} changed lines)`); continue; }
|
|
770
|
+
if (bytesLoaded >= MAX_REVIEW_FILE_BYTES * 5) { skipped.push(`${f.filename} (review budget exhausted)`); continue; }
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const { data } = await octokit.repos.getContent({
|
|
774
|
+
owner, repo, path: f.filename, ref: headSha
|
|
775
|
+
});
|
|
776
|
+
if (data.content) {
|
|
777
|
+
const text = Buffer.from(data.content, 'base64').toString('utf8');
|
|
778
|
+
if (text.length > MAX_REVIEW_FILE_BYTES) {
|
|
779
|
+
skipped.push(`${f.filename} (${text.length} bytes)`);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
fileMap[f.filename] = text;
|
|
783
|
+
bytesLoaded += text.length;
|
|
784
|
+
}
|
|
785
|
+
} catch (e) {
|
|
786
|
+
console.warn(`Could not fetch ${f.filename}: ${e.message}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (skipped.length) {
|
|
790
|
+
console.warn(warn(`Skipped ${skipped.length} file(s) from review context: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ` (+${skipped.length - 5} more)` : ''}`));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ALSO pull dependency-manifest files even if they're not in the diff.
|
|
794
|
+
// The review prompt requires "check the dep manifest before claiming a
|
|
795
|
+
// library might be missing" — without this, the rule can't be satisfied.
|
|
796
|
+
// Cheap (a handful of small files), high signal.
|
|
797
|
+
const MANIFEST_PATHS = [
|
|
798
|
+
'pyproject.toml', 'requirements.txt', 'requirements-dev.txt',
|
|
799
|
+
'setup.cfg', 'setup.py', 'tox.ini', 'noxfile.py',
|
|
800
|
+
'package.json',
|
|
801
|
+
'Cargo.toml',
|
|
802
|
+
'go.mod',
|
|
803
|
+
];
|
|
804
|
+
// Fetch in parallel — sequential awaits cost ~270 ms each on a typical
|
|
805
|
+
// octokit round trip, so 10 sequential calls add ~2.7 s of latency to
|
|
806
|
+
// every review. Promise.all collapses that to one round trip's worth.
|
|
807
|
+
await Promise.all(MANIFEST_PATHS.map(async (manifestPath) => {
|
|
808
|
+
if (fileMap[manifestPath]) return; // already fetched if it was in the diff
|
|
809
|
+
try {
|
|
810
|
+
const { data } = await octokit.repos.getContent({
|
|
811
|
+
owner, repo, path: manifestPath, ref: headSha
|
|
812
|
+
});
|
|
813
|
+
if (data.content) {
|
|
814
|
+
const text = Buffer.from(data.content, 'base64').toString('utf8');
|
|
815
|
+
if (text.length <= MAX_REVIEW_FILE_BYTES) fileMap[manifestPath] = text;
|
|
816
|
+
}
|
|
817
|
+
} catch (e) {
|
|
818
|
+
// 404 = manifest not in this project, which is the common case. Anything
|
|
819
|
+
// else (401, 403, 5xx, network) is a real signal we should surface so
|
|
820
|
+
// the caller knows the review's manifest context may be incomplete.
|
|
821
|
+
if (e.status !== 404) {
|
|
822
|
+
console.warn(warn(`Could not fetch ${manifestPath} for review context: ${e.status || e.code || e.message}`));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}));
|
|
826
|
+
return fileMap;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function handleReview(url, options = {}) {
|
|
830
|
+
const parsed = parseGithubUrl(url);
|
|
831
|
+
if (!parsed) { console.error('Invalid PR URL.'); process.exit(1); }
|
|
832
|
+
const { owner, repo, number } = parsed;
|
|
833
|
+
|
|
834
|
+
console.log(banner());
|
|
835
|
+
console.log(step(`PR review ${owner}/${repo}#${number}`));
|
|
836
|
+
|
|
837
|
+
const octokit = await getOctokit(GITHUB_TOKEN);
|
|
838
|
+
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: number });
|
|
839
|
+
const diff = await fetchPrDiff(octokit, owner, repo, number);
|
|
840
|
+
const fileMap = await fetchChangedFilesContent(octokit, owner, repo, number, pr.head.sha);
|
|
841
|
+
console.log(info(`Loaded ${Object.keys(fileMap).length} changed file(s)`));
|
|
842
|
+
|
|
843
|
+
console.log(step('Running review copilot'));
|
|
844
|
+
const output = await runReviewCopilot({
|
|
845
|
+
pr, diff, fileMap,
|
|
846
|
+
issueTitle: pr.title,
|
|
847
|
+
issueBody: pr.body
|
|
848
|
+
});
|
|
849
|
+
// Split the human-readable review from the machine-readable inline findings.
|
|
850
|
+
// review-report.md and any posted body show the clean prose; the structured
|
|
851
|
+
// findings become inline PR comments.
|
|
852
|
+
const inline = parseInlineComments(output);
|
|
853
|
+
const report = stripInlineCommentsBlock(output);
|
|
854
|
+
fs.writeFileSync('review-report.md', report);
|
|
855
|
+
console.log(ok('review-report.md'));
|
|
856
|
+
if (inline.length) console.log(info(`${inline.length} inline finding(s) parsed from review`));
|
|
857
|
+
|
|
858
|
+
// Prominent, machine-readable verdict — so humans and CI both see it.
|
|
859
|
+
const verdict = extractVerdict(report);
|
|
860
|
+
emitGithubActionVerdict(verdict, 'review-report.md');
|
|
861
|
+
console.log('\n' + step(`VERDICT: ${verdict}`));
|
|
862
|
+
if (verdict === 'APPROVE') console.log(ok('PR looks safe to merge (per automated review).'));
|
|
863
|
+
else if (verdict === 'REQUEST_CHANGES') console.log(err('PR has blocking issues. See review-report.md.'));
|
|
864
|
+
else if (verdict === 'NEEDS_DISCUSSION') console.log(warn('PR needs human discussion. See review-report.md.'));
|
|
865
|
+
else console.log(warn('Verdict could not be parsed. Treat as NEEDS_DISCUSSION.'));
|
|
866
|
+
|
|
867
|
+
if (options.post) {
|
|
868
|
+
// Works on any public PR: authenticated users can submit a COMMENT review
|
|
869
|
+
// without write access to the target repo.
|
|
870
|
+
console.log(step('Posting review as PR review comment'));
|
|
871
|
+
const event = verdict === 'APPROVE' ? 'APPROVE'
|
|
872
|
+
: verdict === 'REQUEST_CHANGES' ? 'REQUEST_CHANGES'
|
|
873
|
+
: 'COMMENT';
|
|
874
|
+
const credit = '🤖 Automated review by [github-agent](https://github.com/Hadar01/github-agents).';
|
|
875
|
+
|
|
876
|
+
// Findings that anchor to a changed line are posted inline; the rest are
|
|
877
|
+
// folded into the review body so nothing is lost.
|
|
878
|
+
const { anchored, dropped } = partitionInlineComments(inline, diff);
|
|
879
|
+
if (anchored.length) {
|
|
880
|
+
console.log(info(`anchoring ${anchored.length} inline comment(s)${dropped.length ? `; ${dropped.length} folded into summary` : ''}`));
|
|
881
|
+
}
|
|
882
|
+
const body = `${credit}\n\n**Verdict:** ${verdict}\n\n${report}${formatDroppedFindings(dropped)}`;
|
|
883
|
+
// Body used when inline anchoring is unavailable (422 retry / issue-comment
|
|
884
|
+
// fallback): list ALL findings with their file:line in the prose.
|
|
885
|
+
const bodyAllInline = `${credit}\n\n**Verdict:** ${verdict}\n\n${report}${formatDroppedFindings(inline)}`;
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
let submitted;
|
|
889
|
+
try {
|
|
890
|
+
({ data: submitted } = await octokit.pulls.createReview({
|
|
891
|
+
owner, repo, pull_number: number, event, body,
|
|
892
|
+
...(anchored.length ? { comments: anchored } : {})
|
|
893
|
+
}));
|
|
894
|
+
} catch (e) {
|
|
895
|
+
// A single bad inline anchor 422s the entire review. Retry once with a
|
|
896
|
+
// summary-only review (all findings in the body) so it still lands.
|
|
897
|
+
if (e.status === 422 && anchored.length) {
|
|
898
|
+
console.log(warn('inline anchors rejected (422); reposting as a summary-only review.'));
|
|
899
|
+
({ data: submitted } = await octokit.pulls.createReview({
|
|
900
|
+
owner, repo, pull_number: number, event, body: bodyAllInline
|
|
901
|
+
}));
|
|
902
|
+
} else {
|
|
903
|
+
throw e;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
console.log(ok(`Posted: ${submitted.html_url}`));
|
|
907
|
+
} catch (e) {
|
|
908
|
+
// Falling back to a plain issue-style comment — this works even when
|
|
909
|
+
// the token doesn't have permission to submit an APPROVE/REQUEST_CHANGES
|
|
910
|
+
// review on the target repo.
|
|
911
|
+
console.log(warn(`createReview failed (${e.status || e.code || e.message}); falling back to issue comment.`));
|
|
912
|
+
try {
|
|
913
|
+
const { data: comment } = await octokit.issues.createComment({
|
|
914
|
+
owner, repo, issue_number: number,
|
|
915
|
+
body: `🤖 **Automated review** (verdict: **${verdict}**)\n\n${report}${formatDroppedFindings(inline)}`
|
|
916
|
+
});
|
|
917
|
+
console.log(ok(`Posted comment: ${comment.html_url}`));
|
|
918
|
+
} catch (e2) {
|
|
919
|
+
console.log(err(`Comment post failed: ${e2.message}`));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Advisory mode (the Action's non-blocking default): post findings but never
|
|
925
|
+
// fail the run, so a REQUEST_CHANGES verdict shows up as a comment + job
|
|
926
|
+
// summary without turning the PR check red.
|
|
927
|
+
if (options.advisory) {
|
|
928
|
+
if (verdict !== 'APPROVE') {
|
|
929
|
+
console.log(warn(`--advisory: verdict is ${verdict} but exiting 0 (non-blocking).`));
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Exit code so CI can gate merges on this.
|
|
935
|
+
if (verdict === 'REQUEST_CHANGES') process.exit(1);
|
|
936
|
+
if (verdict === 'UNKNOWN' || verdict === 'NEEDS_DISCUSSION') process.exit(2);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function main() {
|
|
940
|
+
if (!ANTHROPIC_API_KEY) { console.error('Missing ANTHROPIC_API_KEY in .env'); process.exit(1); }
|
|
941
|
+
if (!GITHUB_TOKEN) { console.error('Missing GITHUB_TOKEN in .env'); process.exit(1); }
|
|
942
|
+
|
|
943
|
+
const [cmd, target] = POSITIONAL;
|
|
944
|
+
const options = {
|
|
945
|
+
dryRun: FLAGS.has('--dry-run'),
|
|
946
|
+
fork: FLAGS.has('--fork'),
|
|
947
|
+
forcePr: FLAGS.has('--force-pr'),
|
|
948
|
+
comment: FLAGS.has('--comment'),
|
|
949
|
+
post: FLAGS.has('--post'),
|
|
950
|
+
advisory: FLAGS.has('--advisory'),
|
|
951
|
+
maxCost: getOptFloat('max-cost', DEFAULT_MAX_USD_PER_RUN),
|
|
952
|
+
label: getOpt('label', null),
|
|
953
|
+
max: getOptInt('max', 5)
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const dashboard = await maybeStartDashboard();
|
|
957
|
+
if (cmd !== 'review') console.log(banner());
|
|
958
|
+
|
|
959
|
+
if (cmd === 'issue' && target) return handleIssue(target, options, dashboard);
|
|
960
|
+
if (cmd === 'review' && target) return handleReview(target, options);
|
|
961
|
+
if (cmd === 'triage' && target) return handleTriage(target, options, dashboard);
|
|
962
|
+
|
|
963
|
+
usage();
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Only run when invoked directly — this lets tests require() the pipeline
|
|
968
|
+
// helpers (buildAuditTrail, buildPrBody, runIssue) without auto-starting main.
|
|
969
|
+
if (require.main === module) {
|
|
970
|
+
main().catch(e => {
|
|
971
|
+
console.error(err('Pipeline failed:'), e);
|
|
972
|
+
process.exit(1);
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
module.exports = {
|
|
977
|
+
buildAuditTrail,
|
|
978
|
+
buildPrBody,
|
|
979
|
+
runIssue,
|
|
980
|
+
handleReview,
|
|
981
|
+
handleTriage,
|
|
982
|
+
emitGithubActionVerdict,
|
|
983
|
+
partitionInlineComments,
|
|
984
|
+
formatDroppedFindings
|
|
985
|
+
};
|