@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,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
+ };