agent-reviews 0.2.1 → 0.3.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-reviews",
3
3
  "description": "Manage GitHub PR review comments from Claude Code. Automatically triage, fix, and respond to bot findings.",
4
- "version": "0.2.1",
4
+ "version": "0.3.1",
5
5
  "author": {
6
6
  "name": "Paul Bakaus",
7
7
  "url": "https://github.com/pbakaus"
@@ -362,12 +362,6 @@ async function main() {
362
362
  process.exit(1);
363
363
  }
364
364
 
365
- if (!options.json) {
366
- console.log(
367
- `${colors.dim}Finding PR for branch: ${branch}...${colors.reset}`
368
- );
369
- }
370
-
371
365
  const pr = await findPRForBranch(
372
366
  repoInfo.owner,
373
367
  repoInfo.repo,
@@ -396,12 +390,6 @@ async function main() {
396
390
  process.exit(1);
397
391
  }
398
392
 
399
- if (!options.json) {
400
- console.log(
401
- `${colors.dim}Replying to comment ${options.replyTo}...${colors.reset}`
402
- );
403
- }
404
-
405
393
  const result = await replyToComment(
406
394
  repoInfo.owner,
407
395
  repoInfo.repo,
@@ -433,12 +421,6 @@ async function main() {
433
421
  process.exit(1);
434
422
  }
435
423
 
436
- if (!options.json) {
437
- console.log(
438
- `${colors.dim}Fetching comments for PR #${prNumber}...${colors.reset}`
439
- );
440
- }
441
-
442
424
  const rawData = await fetchPRComments(
443
425
  repoInfo.owner,
444
426
  repoInfo.repo,
@@ -476,12 +458,6 @@ async function main() {
476
458
  }
477
459
 
478
460
  // Default: fetch and display comments
479
- if (!options.json) {
480
- console.log(
481
- `${colors.dim}Fetching comments for PR #${prNumber}...${colors.reset}`
482
- );
483
- }
484
-
485
461
  const rawData = await fetchPRComments(
486
462
  repoInfo.owner,
487
463
  repoInfo.repo,
@@ -495,9 +471,6 @@ async function main() {
495
471
 
496
472
  console.log(formatOutput(filtered, options));
497
473
 
498
- if (!options.json && prUrl) {
499
- console.log(`\n${colors.dim}PR: ${prUrl}${colors.reset}`);
500
- }
501
474
  }
502
475
 
503
476
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-reviews",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "CLI and Claude Code skill for managing GitHub PR review comments. List, filter, reply, and watch for bot findings.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Bakaus",
@@ -27,6 +27,9 @@
27
27
  "skills/",
28
28
  ".claude-plugin/"
29
29
  ],
30
+ "scripts": {
31
+ "build:skill": "node scripts/build-skill.js"
32
+ },
30
33
  "engines": {
31
34
  "node": ">=18"
32
35
  }
@@ -1,7 +1,13 @@
1
1
  ---
2
2
  name: agent-reviews
3
- description: Review and fix PR review bot findings on current PR, loop until resolved
4
- allowed-tools: Bash(npx -y agent-reviews *), Bash(npx agent-reviews *), Bash(gh *), Bash(git *), Read, Glob, Grep, Edit, Write, AskUserQuestion, Task, TaskOutput
3
+ description: Review and fix PR review bot findings on current PR, loop until resolved. Fetches unanswered bot comments, evaluates each finding, fixes real bugs, dismisses false positives, and replies to every comment with the outcome.
4
+ license: MIT
5
+ compatibility: Requires git and gh (GitHub CLI) installed. Designed for Claude Code.
6
+ metadata:
7
+ author: pbakaus
8
+ version: "0.3.1"
9
+ homepage: https://github.com/pbakaus/agent-reviews
10
+ allowed-tools: Bash(scripts/agent-reviews.js *), Bash(gh *), Bash(git *), Read, Glob, Grep, Edit, Write, AskUserQuestion, Task, TaskOutput
5
11
  ---
6
12
 
7
13
  Automatically review, fix, and respond to findings from PR review bots on the current PR. Uses a deterministic two-phase workflow: first fix all existing issues, then poll once for new ones.
@@ -19,12 +25,12 @@ If no PR exists, notify the user and exit.
19
25
  ### Step 2: Fetch All Bot Comments
20
26
 
21
27
  ```bash
22
- npx -y agent-reviews --bots-only --json
28
+ scripts/agent-reviews.js --bots-only --unanswered
23
29
  ```
24
30
 
25
- Parse the JSON output. Count how many have `hasAnyReply: false` (unanswered).
31
+ This shows only unanswered bot comments. Each comment shows its ID in brackets (e.g., `[12345678]`), the author, file path, and a truncated body.
26
32
 
27
- If zero unanswered comments, print "No unanswered bot comments found" and skip to Phase 2.
33
+ If zero comments are returned, print "No unanswered bot comments found" and skip to Phase 2.
28
34
 
29
35
  ### Step 3: Process Each Unanswered Comment
30
36
 
@@ -33,16 +39,11 @@ For each comment where `hasAnyReply === false`:
33
39
  #### A. Get Full Detail
34
40
 
35
41
  ```bash
36
- npx -y agent-reviews --detail <comment_id>
42
+ scripts/agent-reviews.js --detail <comment_id>
37
43
  ```
38
44
 
39
45
  This shows the full comment body (no truncation), the diff hunk (code context), and all replies. Use this instead of `gh` CLI for comment details.
40
46
 
41
- For structured data, use:
42
- ```bash
43
- npx -y agent-reviews --detail <comment_id> --json
44
- ```
45
-
46
47
  #### B. Evaluate the Finding
47
48
 
48
49
  Read the referenced code and determine:
@@ -72,35 +73,19 @@ Read the referenced code and determine:
72
73
  - Multiple valid interpretations exist
73
74
  - The fix could have unintended side effects
74
75
 
75
- #### C. Handle Based on Evaluation
76
+ #### C. Act on Evaluation
76
77
 
77
- **If TRUE POSITIVE:**
78
- 1. Fix the code
79
- 2. Run type-check and lint to verify the fix
80
- 3. Reply to the comment:
81
- ```bash
82
- npx -y agent-reviews --reply <comment_id> "✅ **Fixed in commit {hash}**
83
-
84
- {Brief description of the fix}"
85
- ```
78
+ **If TRUE POSITIVE:** Fix the code. Track the comment ID and a brief description of the fix.
86
79
 
87
- **If FALSE POSITIVE:**
88
- 1. Do NOT change the code
89
- 2. Reply to the comment:
90
- ```bash
91
- npx -y agent-reviews --reply <comment_id> "⚠️ **Won't fix - {reason}**
80
+ **If FALSE POSITIVE:** Do NOT change the code. Track the comment ID and the reason it's not a real bug.
92
81
 
93
- {Explanation of why this is intentional or not applicable}"
94
- ```
82
+ **If UNCERTAIN:** Use `AskUserQuestion`. If the user says skip, track it as skipped.
95
83
 
96
- **If user chose to skip:**
97
- ```bash
98
- npx -y agent-reviews --reply <comment_id> "⏭️ Skipped per user request"
99
- ```
84
+ Do NOT reply to comments yet. Replies happen after the commit (Step 5).
100
85
 
101
86
  ### Step 4: Commit and Push
102
87
 
103
- After processing ALL unanswered comments (not one at a time):
88
+ After evaluating and fixing ALL unanswered comments:
104
89
 
105
90
  1. Run your project's lint and type-check
106
91
  2. Stage, commit, and push:
@@ -111,33 +96,57 @@ After processing ALL unanswered comments (not one at a time):
111
96
  {List of bugs fixed, grouped by bot}"
112
97
  git push
113
98
  ```
99
+ 3. Capture the commit hash from the output.
100
+
101
+ ### Step 5: Reply to All Comments
102
+
103
+ Now that the commit hash exists, reply to every processed comment:
104
+
105
+ **For each TRUE POSITIVE:**
106
+ ```bash
107
+ scripts/agent-reviews.js --reply <comment_id> "Fixed in {hash}.
108
+
109
+ {Brief description of the fix}"
110
+ ```
111
+
112
+ **For each FALSE POSITIVE:**
113
+ ```bash
114
+ scripts/agent-reviews.js --reply <comment_id> "Won't fix: {reason}
115
+
116
+ {Explanation of why this is intentional or not applicable}"
117
+ ```
118
+
119
+ **For each SKIPPED:**
120
+ ```bash
121
+ scripts/agent-reviews.js --reply <comment_id> "Skipped per user request"
122
+ ```
114
123
 
115
- **DO NOT start Phase 2 until all current issues are fixed and pushed.**
124
+ **DO NOT start Phase 2 until all replies are posted.**
116
125
 
117
126
  ---
118
127
 
119
128
  ## Phase 2: POLL FOR NEW COMMENTS (10-minute inactivity timeout)
120
129
 
121
- ### Step 5: Start Watcher
130
+ ### Step 6: Start Watcher
122
131
 
123
132
  Launch the watcher in the background. It polls every 30 seconds and exits after 10 minutes of inactivity (no new comments):
124
133
 
125
134
  ```bash
126
- npx -y agent-reviews --watch --bots-only
135
+ scripts/agent-reviews.js --watch --bots-only
127
136
  ```
128
137
 
129
138
  This runs as a background task.
130
139
 
131
140
  **CRITICAL: DO NOT cancel the background task early. Let it complete its full cycle.**
132
141
 
133
- ### Step 6: Wait for Results
142
+ ### Step 7: Wait for Results
134
143
 
135
144
  Use `TaskOutput` to wait for the watcher to complete (blocks up to 12 minutes).
136
145
 
137
- ### Step 7: Process New Comments (if any)
146
+ ### Step 8: Process New Comments (if any)
138
147
 
139
148
  If the watcher found new comments:
140
- 1. Process them exactly as in Phase 1, Steps 3-4
149
+ 1. Process them exactly as in Phase 1, Steps 3-5
141
150
  2. Use `--detail <id>` to read each new comment
142
151
 
143
152
  If no new comments were found, move to the summary.
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * agent-reviews — CLI for managing GitHub PR review comments
5
+ *
6
+ * List, filter, reply to, and watch PR review comments from the terminal.
7
+ * Designed for both human use and as a tool for AI coding agents.
8
+ *
9
+ * Usage:
10
+ * agent-reviews # List all review comments
11
+ * agent-reviews --unresolved # List unresolved comments only
12
+ * agent-reviews --unanswered # List comments without replies
13
+ * agent-reviews --reply <id> "msg" # Reply to a specific comment
14
+ * agent-reviews --detail <id> # Show full detail (no truncation)
15
+ * agent-reviews --json # Output as JSON for scripting
16
+ * agent-reviews --watch # Watch for new comments (poll mode)
17
+ *
18
+ * Options:
19
+ * --pr <number> Target specific PR (auto-detects from branch)
20
+ * --bots-only Only show bot comments
21
+ * --humans-only Only show human comments
22
+ */
23
+
24
+ const {
25
+ getProxyFetch,
26
+ getGitHubToken,
27
+ getRepoInfo,
28
+ getCurrentBranch,
29
+ } = require("./github");
30
+
31
+ const {
32
+ findPRForBranch,
33
+ fetchPRComments,
34
+ processComments,
35
+ filterComments,
36
+ replyToComment,
37
+ } = require("./comments");
38
+
39
+ const {
40
+ colors,
41
+ formatComment,
42
+ formatDetailedComment,
43
+ formatOutput,
44
+ } = require("./format");
45
+
46
+ const proxyFetch = getProxyFetch();
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Argument parsing
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function parseArgs() {
53
+ const args = process.argv.slice(2);
54
+ const result = {
55
+ command: "list",
56
+ prNumber: null,
57
+ filter: null,
58
+ replyTo: null,
59
+ replyMessage: null,
60
+ json: false,
61
+ botsOnly: false,
62
+ humansOnly: false,
63
+ detail: null,
64
+ help: false,
65
+ version: false,
66
+ watch: false,
67
+ watchInterval: 30,
68
+ watchTimeout: 600,
69
+ };
70
+
71
+ for (let i = 0; i < args.length; i++) {
72
+ switch (args[i]) {
73
+ case "--unresolved":
74
+ case "-u":
75
+ result.filter = "unresolved";
76
+ break;
77
+ case "--unanswered":
78
+ case "-a":
79
+ result.filter = "unanswered";
80
+ break;
81
+ case "--reply":
82
+ case "-r":
83
+ result.command = "reply";
84
+ result.replyTo = args[++i];
85
+ result.replyMessage = args[++i];
86
+ break;
87
+ case "--pr":
88
+ case "-p":
89
+ result.prNumber = Number.parseInt(args[++i], 10);
90
+ break;
91
+ case "--json":
92
+ case "-j":
93
+ result.json = true;
94
+ break;
95
+ case "--bots-only":
96
+ case "-b":
97
+ result.botsOnly = true;
98
+ break;
99
+ case "--humans-only":
100
+ case "-H":
101
+ result.humansOnly = true;
102
+ break;
103
+ case "--detail":
104
+ case "-d":
105
+ result.command = "detail";
106
+ result.detail = args[++i];
107
+ break;
108
+ case "--watch":
109
+ case "-w":
110
+ result.watch = true;
111
+ result.command = "watch";
112
+ break;
113
+ case "--interval":
114
+ case "-i":
115
+ result.watchInterval = Number.parseInt(args[++i], 10);
116
+ break;
117
+ case "--exit-after":
118
+ case "--timeout":
119
+ result.watchTimeout = Number.parseInt(args[++i], 10);
120
+ break;
121
+ case "--help":
122
+ case "-h":
123
+ result.help = true;
124
+ break;
125
+ case "--version":
126
+ case "-v":
127
+ result.version = true;
128
+ break;
129
+ default:
130
+ break;
131
+ }
132
+ }
133
+
134
+ return result;
135
+ }
136
+
137
+ function showHelp() {
138
+ console.log(`
139
+ ${colors.bright}agent-reviews${colors.reset} — Manage PR review comments from the CLI
140
+
141
+ Designed for both human use and as a tool for AI coding agents (Claude Code, etc.).
142
+
143
+ ${colors.bright}Usage:${colors.reset}
144
+ agent-reviews List all review comments
145
+ agent-reviews --unresolved List unresolved comments only
146
+ agent-reviews --unanswered List comments without replies
147
+ agent-reviews --reply <id> "msg" Reply to a specific comment
148
+ agent-reviews --detail <id> Show full detail for a comment
149
+ agent-reviews --watch Watch for new comments (poll mode)
150
+ agent-reviews --json Output as JSON for scripting
151
+
152
+ ${colors.bright}Options:${colors.reset}
153
+ -u, --unresolved Show only unresolved/pending comments
154
+ -a, --unanswered Show only comments without any replies
155
+ -r, --reply Reply to a comment (requires ID and message)
156
+ -d, --detail Show full detail for a specific comment
157
+ -p, --pr Target specific PR number (auto-detects from branch)
158
+ -j, --json Output as JSON instead of formatted text
159
+ -b, --bots-only Only show comments from bots
160
+ -H, --humans-only Only show comments from humans
161
+ -h, --help Show this help
162
+ -v, --version Show version
163
+
164
+ ${colors.bright}Watch Mode:${colors.reset}
165
+ -w, --watch Poll for new comments periodically
166
+ -i, --interval Poll interval in seconds (default: 30)
167
+ --timeout Exit after N seconds of inactivity (default: 600)
168
+
169
+ ${colors.bright}Examples:${colors.reset}
170
+ agent-reviews # Show all comments
171
+ agent-reviews -u # Show unresolved only
172
+ agent-reviews -a --bots-only # Unanswered bot comments
173
+ agent-reviews --reply 12345 "Fixed!" # Reply to comment #12345
174
+ agent-reviews --detail 12345 # Full detail for a comment
175
+ agent-reviews --detail 12345 --json # Detail as JSON
176
+ agent-reviews --json | jq '.[]' # Pipe to jq
177
+ agent-reviews --watch --bots-only # Watch for new bot comments
178
+ agent-reviews -w -i 15 --timeout 300 # Poll every 15s, exit after 5 min
179
+
180
+ ${colors.bright}Authentication:${colors.reset}
181
+ Set GITHUB_TOKEN env var, or use 'gh auth login' (gh CLI).
182
+
183
+ ${colors.dim}Comment IDs are shown in brackets, e.g., [12345678]${colors.reset}
184
+ `);
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Watch mode
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function formatTimestamp() {
192
+ return new Date().toISOString().replace("T", " ").slice(0, 19);
193
+ }
194
+
195
+ function sleep(seconds) {
196
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
197
+ }
198
+
199
+ async function watchForComments(context, options) {
200
+ const { owner, repo, prNumber, prUrl, token } = context;
201
+ const seenIds = new Set();
202
+ let lastActivityTime = Date.now();
203
+ let pollCount = 0;
204
+
205
+ function getWatchFilterDesc() {
206
+ if (options.botsOnly) return "bots-only";
207
+ if (options.humansOnly) return "humans-only";
208
+ return "all";
209
+ }
210
+ const filterDesc = getWatchFilterDesc();
211
+
212
+ console.log(
213
+ `\n${colors.bright}=== PR Comments Watch Mode ===${colors.reset}`
214
+ );
215
+ console.log(`${colors.dim}PR #${prNumber}: ${prUrl}${colors.reset}`);
216
+ console.log(
217
+ `${colors.dim}Polling every ${options.watchInterval}s, exit after ${options.watchTimeout}s of inactivity${colors.reset}`
218
+ );
219
+ console.log(
220
+ `${colors.dim}Filters: ${filterDesc}, ${options.filter || "all comments"}${colors.reset}`
221
+ );
222
+ console.log(
223
+ `${colors.dim}Started at ${formatTimestamp()}${colors.reset}\n`
224
+ );
225
+
226
+ // Initial fetch to populate seen IDs
227
+ const initialData = await fetchPRComments(
228
+ owner,
229
+ repo,
230
+ prNumber,
231
+ token,
232
+ proxyFetch
233
+ );
234
+ const initialProcessed = processComments(initialData);
235
+ const initialFiltered = filterComments(initialProcessed, options);
236
+
237
+ for (const comment of initialFiltered) {
238
+ seenIds.add(comment.id);
239
+ }
240
+
241
+ console.log(
242
+ `${colors.dim}[${formatTimestamp()}] Initial state: ${initialFiltered.length} existing comments tracked${colors.reset}`
243
+ );
244
+
245
+ if (initialFiltered.length > 0) {
246
+ console.log(`\n${colors.yellow}=== EXISTING COMMENTS ===${colors.reset}`);
247
+ for (const comment of initialFiltered) {
248
+ console.log(formatComment(comment));
249
+ console.log("");
250
+ }
251
+ }
252
+
253
+ // Watch loop
254
+ while (true) {
255
+ await sleep(options.watchInterval);
256
+ pollCount++;
257
+
258
+ const rawData = await fetchPRComments(
259
+ owner,
260
+ repo,
261
+ prNumber,
262
+ token,
263
+ proxyFetch
264
+ );
265
+ const processed = processComments(rawData);
266
+ const filtered = filterComments(processed, options);
267
+
268
+ const newComments = filtered.filter((c) => !seenIds.has(c.id));
269
+
270
+ if (newComments.length > 0) {
271
+ lastActivityTime = Date.now();
272
+
273
+ console.log(
274
+ `\n${colors.green}=== NEW COMMENTS DETECTED [${formatTimestamp()}] ===${colors.reset}`
275
+ );
276
+ console.log(
277
+ `${colors.bright}Found ${newComments.length} new comment${newComments.length === 1 ? "" : "s"}${colors.reset}\n`
278
+ );
279
+
280
+ for (const comment of newComments) {
281
+ seenIds.add(comment.id);
282
+ console.log(formatComment(comment));
283
+ console.log("");
284
+ }
285
+
286
+ // JSON output for AI agent parsing
287
+ console.log(`${colors.dim}--- JSON for processing ---${colors.reset}`);
288
+ console.log(JSON.stringify(newComments, null, 2));
289
+ console.log(`${colors.dim}--- end JSON ---${colors.reset}\n`);
290
+ } else {
291
+ const inactiveSeconds = Math.round(
292
+ (Date.now() - lastActivityTime) / 1000
293
+ );
294
+ console.log(
295
+ `${colors.dim}[${formatTimestamp()}] Poll #${pollCount}: No new comments (${inactiveSeconds}s/${options.watchTimeout}s idle)${colors.reset}`
296
+ );
297
+
298
+ if (inactiveSeconds >= options.watchTimeout) {
299
+ console.log(`\n${colors.green}=== WATCH COMPLETE ===${colors.reset}`);
300
+ console.log(
301
+ `${colors.dim}No new comments after ${options.watchTimeout}s of inactivity.${colors.reset}`
302
+ );
303
+ console.log(
304
+ `${colors.dim}Total comments tracked: ${seenIds.size}${colors.reset}`
305
+ );
306
+ console.log(
307
+ `${colors.dim}Exiting at ${formatTimestamp()}${colors.reset}`
308
+ );
309
+ return;
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Main
317
+ // ---------------------------------------------------------------------------
318
+
319
+ async function main() {
320
+ const options = parseArgs();
321
+
322
+ if (options.version) {
323
+ console.log("0.3.1");
324
+ process.exit(0);
325
+ }
326
+
327
+ if (options.help) {
328
+ showHelp();
329
+ process.exit(0);
330
+ }
331
+
332
+ // Get GitHub token
333
+ const token = getGitHubToken();
334
+ if (!token) {
335
+ console.error(`${colors.red}Error: GitHub token not found${colors.reset}`);
336
+ console.error(
337
+ "Set GITHUB_TOKEN env var, or authenticate with: gh auth login"
338
+ );
339
+ process.exit(1);
340
+ }
341
+
342
+ // Get repo info
343
+ const repoInfo = getRepoInfo();
344
+ if (!repoInfo) {
345
+ console.error(
346
+ `${colors.red}Error: Could not determine repository from git remote${colors.reset}`
347
+ );
348
+ process.exit(1);
349
+ }
350
+
351
+ // Find PR
352
+ let prNumber = options.prNumber;
353
+ let prUrl = null;
354
+
355
+ if (!prNumber) {
356
+ const branch = getCurrentBranch();
357
+ if (!branch) {
358
+ console.error(
359
+ `${colors.red}Error: Could not determine current branch${colors.reset}`
360
+ );
361
+ process.exit(1);
362
+ }
363
+
364
+ const pr = await findPRForBranch(
365
+ repoInfo.owner,
366
+ repoInfo.repo,
367
+ branch,
368
+ token,
369
+ proxyFetch
370
+ );
371
+ if (!pr) {
372
+ console.error(
373
+ `${colors.red}Error: No open PR found for branch '${branch}'${colors.reset}`
374
+ );
375
+ process.exit(1);
376
+ }
377
+
378
+ prNumber = pr.number;
379
+ prUrl = pr.html_url;
380
+ }
381
+
382
+ // Handle reply command
383
+ if (options.command === "reply") {
384
+ if (!(options.replyTo && options.replyMessage)) {
385
+ console.error(
386
+ `${colors.red}Error: --reply requires comment ID and message${colors.reset}`
387
+ );
388
+ console.error('Usage: agent-reviews --reply <id> "message"');
389
+ process.exit(1);
390
+ }
391
+
392
+ const result = await replyToComment(
393
+ repoInfo.owner,
394
+ repoInfo.repo,
395
+ prNumber,
396
+ options.replyTo,
397
+ options.replyMessage,
398
+ token,
399
+ proxyFetch
400
+ );
401
+
402
+ if (options.json) {
403
+ console.log(JSON.stringify(result, null, 2));
404
+ } else {
405
+ console.log(
406
+ `${colors.green}✓ Reply posted successfully${colors.reset}`
407
+ );
408
+ console.log(` ${colors.dim}${result.html_url}${colors.reset}`);
409
+ }
410
+
411
+ return;
412
+ }
413
+
414
+ // Handle detail command
415
+ if (options.command === "detail") {
416
+ if (!options.detail) {
417
+ console.error(
418
+ `${colors.red}Error: --detail requires a comment ID${colors.reset}`
419
+ );
420
+ process.exit(1);
421
+ }
422
+
423
+ const rawData = await fetchPRComments(
424
+ repoInfo.owner,
425
+ repoInfo.repo,
426
+ prNumber,
427
+ token,
428
+ proxyFetch
429
+ );
430
+ const processed = processComments(rawData);
431
+ const targetId = Number(options.detail);
432
+ const comment = processed.find((c) => c.id === targetId);
433
+
434
+ if (!comment) {
435
+ console.error(
436
+ `${colors.red}Error: Comment ${options.detail} not found in PR #${prNumber}${colors.reset}`
437
+ );
438
+ process.exit(1);
439
+ }
440
+
441
+ if (options.json) {
442
+ console.log(JSON.stringify(comment, null, 2));
443
+ } else {
444
+ console.log(formatDetailedComment(comment));
445
+ }
446
+
447
+ return;
448
+ }
449
+
450
+ // Handle watch command
451
+ if (options.command === "watch") {
452
+ await watchForComments(
453
+ { owner: repoInfo.owner, repo: repoInfo.repo, prNumber, prUrl, token },
454
+ options
455
+ );
456
+ return;
457
+ }
458
+
459
+ // Default: fetch and display comments
460
+ const rawData = await fetchPRComments(
461
+ repoInfo.owner,
462
+ repoInfo.repo,
463
+ prNumber,
464
+ token,
465
+ proxyFetch
466
+ );
467
+
468
+ const processed = processComments(rawData);
469
+ const filtered = filterComments(processed, options);
470
+
471
+ console.log(formatOutput(filtered, options));
472
+
473
+ }
474
+
475
+ main().catch((error) => {
476
+ console.error(`${colors.red}Error: ${error.message}${colors.reset}`);
477
+ process.exit(1);
478
+ });
@@ -0,0 +1,334 @@
1
+ /**
2
+ * PR comment fetching, processing, and filtering
3
+ *
4
+ * Fetches all comment types (review comments, issue comments, reviews)
5
+ * from GitHub's API, processes them into a unified format, and provides
6
+ * filtering capabilities.
7
+ */
8
+
9
+ const USER_AGENT = "agent-reviews";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // GitHub API helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ async function findPRForBranch(owner, repo, branch, token, proxyFetch) {
16
+ const response = await proxyFetch(
17
+ `https://api.github.com/repos/${owner}/${repo}/pulls?head=${owner}:${branch}&state=open`,
18
+ {
19
+ headers: {
20
+ Authorization: `Bearer ${token}`,
21
+ Accept: "application/vnd.github.v3+json",
22
+ "User-Agent": USER_AGENT,
23
+ },
24
+ }
25
+ );
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to find PR: ${response.status}`);
29
+ }
30
+
31
+ const prs = await response.json();
32
+ return prs[0] || null;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Paginated fetch
37
+ // ---------------------------------------------------------------------------
38
+
39
+ async function fetchAllPages(url, token, proxyFetch) {
40
+ const results = [];
41
+ let nextUrl = url;
42
+
43
+ while (nextUrl) {
44
+ const response = await proxyFetch(nextUrl, {
45
+ headers: {
46
+ Authorization: `Bearer ${token}`,
47
+ Accept: "application/vnd.github.v3+json",
48
+ "User-Agent": USER_AGENT,
49
+ },
50
+ });
51
+
52
+ if (!response.ok) {
53
+ throw new Error(`API request failed: ${response.status}`);
54
+ }
55
+
56
+ const data = await response.json();
57
+ results.push(...data);
58
+
59
+ // Check for next page in Link header
60
+ const linkHeader = response.headers.get("link");
61
+ nextUrl = null;
62
+ if (linkHeader) {
63
+ const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
64
+ if (nextMatch) {
65
+ nextUrl = nextMatch[1];
66
+ }
67
+ }
68
+ }
69
+
70
+ return results;
71
+ }
72
+
73
+ async function fetchPRComments(owner, repo, prNumber, token, proxyFetch) {
74
+ const baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
75
+
76
+ // Fetch all comment types in parallel
77
+ const [reviewComments, issueComments, reviews] = await Promise.all([
78
+ fetchAllPages(
79
+ `${baseUrl}/pulls/${prNumber}/comments?per_page=100`,
80
+ token,
81
+ proxyFetch
82
+ ),
83
+ fetchAllPages(
84
+ `${baseUrl}/issues/${prNumber}/comments?per_page=100`,
85
+ token,
86
+ proxyFetch
87
+ ),
88
+ fetchAllPages(
89
+ `${baseUrl}/pulls/${prNumber}/reviews?per_page=100`,
90
+ token,
91
+ proxyFetch
92
+ ),
93
+ ]);
94
+
95
+ return { reviewComments, issueComments, reviews };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Comment classification
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Default meta-comment filters.
104
+ * These are auto-generated status updates, not actionable review findings.
105
+ * Users can extend this list via the `metaFilters` option.
106
+ */
107
+ const DEFAULT_META_FILTERS = [
108
+ // Vercel deployment status
109
+ (user, body) => user === "vercel[bot]" && body.startsWith("[vc]:"),
110
+ // Supabase branch status
111
+ (user, body) => user === "supabase[bot]" && body.startsWith("[supa]:"),
112
+ // cursor[bot] summary (not the actual findings)
113
+ (user, body) =>
114
+ user === "cursor[bot]" &&
115
+ body.startsWith("Cursor Bugbot has reviewed your changes"),
116
+ ];
117
+
118
+ function isMetaComment(user, body, metaFilters = DEFAULT_META_FILTERS) {
119
+ if (!body) return false;
120
+ return metaFilters.some((filter) => filter(user, body));
121
+ }
122
+
123
+ function isBot(username) {
124
+ if (!username) return false;
125
+ return (
126
+ username.endsWith("[bot]") ||
127
+ username === "Copilot" ||
128
+ username.includes("bot") ||
129
+ username === "github-actions"
130
+ );
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Processing
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function processComments(data, options = {}) {
138
+ const { reviewComments, issueComments, reviews } = data;
139
+ const metaFilters = options.metaFilters || DEFAULT_META_FILTERS;
140
+
141
+ // Build a map of comment replies
142
+ const repliesMap = new Map();
143
+ for (const comment of reviewComments) {
144
+ if (comment.in_reply_to_id) {
145
+ if (!repliesMap.has(comment.in_reply_to_id)) {
146
+ repliesMap.set(comment.in_reply_to_id, []);
147
+ }
148
+ repliesMap.get(comment.in_reply_to_id).push({
149
+ id: comment.id,
150
+ user: comment.user?.login,
151
+ body: comment.body,
152
+ createdAt: comment.created_at,
153
+ isBot: isBot(comment.user?.login),
154
+ });
155
+ }
156
+ }
157
+
158
+ const processed = [];
159
+
160
+ // Process review comments (inline code comments)
161
+ for (const comment of reviewComments) {
162
+ if (comment.in_reply_to_id) continue;
163
+ if (isMetaComment(comment.user?.login, comment.body, metaFilters)) continue;
164
+
165
+ const replies = repliesMap.get(comment.id) || [];
166
+ const hasHumanReply = replies.some((r) => !r.isBot);
167
+ const hasAnyReply = replies.length > 0;
168
+
169
+ processed.push({
170
+ id: comment.id,
171
+ type: "review_comment",
172
+ user: comment.user?.login,
173
+ isBot: isBot(comment.user?.login),
174
+ path: comment.path,
175
+ line: comment.line || comment.original_line,
176
+ diffHunk: comment.diff_hunk || null,
177
+ body: comment.body,
178
+ createdAt: comment.created_at,
179
+ updatedAt: comment.updated_at,
180
+ url: comment.html_url,
181
+ replies,
182
+ hasHumanReply,
183
+ hasAnyReply,
184
+ isResolved: false,
185
+ });
186
+ }
187
+
188
+ // Process issue comments (general PR comments)
189
+ for (const comment of issueComments) {
190
+ if (isMetaComment(comment.user?.login, comment.body, metaFilters)) continue;
191
+
192
+ processed.push({
193
+ id: comment.id,
194
+ type: "issue_comment",
195
+ user: comment.user?.login,
196
+ isBot: isBot(comment.user?.login),
197
+ path: null,
198
+ line: null,
199
+ diffHunk: null,
200
+ body: comment.body,
201
+ createdAt: comment.created_at,
202
+ updatedAt: comment.updated_at,
203
+ url: comment.html_url,
204
+ replies: [],
205
+ hasHumanReply: false,
206
+ hasAnyReply: false,
207
+ isResolved: false,
208
+ });
209
+ }
210
+
211
+ // Process review bodies (only if they have content)
212
+ for (const review of reviews) {
213
+ if (isMetaComment(review.user?.login, review.body, metaFilters)) continue;
214
+ if (!review.body?.trim()) continue;
215
+
216
+ processed.push({
217
+ id: review.id,
218
+ type: "review",
219
+ user: review.user?.login,
220
+ isBot: isBot(review.user?.login),
221
+ path: null,
222
+ line: null,
223
+ diffHunk: null,
224
+ body: review.body,
225
+ state: review.state,
226
+ createdAt: review.submitted_at,
227
+ updatedAt: review.submitted_at,
228
+ url: review.html_url,
229
+ replies: [],
230
+ hasHumanReply: false,
231
+ hasAnyReply: false,
232
+ isResolved: review.state === "APPROVED" || review.state === "DISMISSED",
233
+ });
234
+ }
235
+
236
+ // Sort by date (newest first)
237
+ processed.sort(
238
+ (a, b) =>
239
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
240
+ );
241
+
242
+ return processed;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Filtering
247
+ // ---------------------------------------------------------------------------
248
+
249
+ function filterComments(comments, options) {
250
+ let filtered = comments;
251
+
252
+ if (options.botsOnly) {
253
+ filtered = filtered.filter((c) => c.isBot);
254
+ } else if (options.humansOnly) {
255
+ filtered = filtered.filter((c) => !c.isBot);
256
+ }
257
+
258
+ if (options.filter === "unresolved") {
259
+ filtered = filtered.filter((c) => !(c.isResolved || c.hasHumanReply));
260
+ } else if (options.filter === "unanswered") {
261
+ filtered = filtered.filter((c) => !c.hasAnyReply);
262
+ }
263
+
264
+ return filtered;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Reply
269
+ // ---------------------------------------------------------------------------
270
+
271
+ async function replyToComment(
272
+ owner,
273
+ repo,
274
+ prNumber,
275
+ commentId,
276
+ message,
277
+ token,
278
+ proxyFetch
279
+ ) {
280
+ // Try review comment reply endpoint first
281
+ const response = await proxyFetch(
282
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments/${commentId}/replies`,
283
+ {
284
+ method: "POST",
285
+ headers: {
286
+ Authorization: `Bearer ${token}`,
287
+ "Content-Type": "application/json",
288
+ Accept: "application/vnd.github.v3+json",
289
+ "User-Agent": USER_AGENT,
290
+ },
291
+ body: JSON.stringify({ body: message }),
292
+ }
293
+ );
294
+
295
+ if (!response.ok) {
296
+ // Fallback to issue comment endpoint
297
+ const issueResponse = await proxyFetch(
298
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
299
+ {
300
+ method: "POST",
301
+ headers: {
302
+ Authorization: `Bearer ${token}`,
303
+ "Content-Type": "application/json",
304
+ Accept: "application/vnd.github.v3+json",
305
+ "User-Agent": USER_AGENT,
306
+ },
307
+ body: JSON.stringify({
308
+ body: `> Re: comment ${commentId}\n\n${message}`,
309
+ }),
310
+ }
311
+ );
312
+
313
+ if (!issueResponse.ok) {
314
+ const error = await issueResponse.text();
315
+ throw new Error(`Failed to reply: ${issueResponse.status} - ${error}`);
316
+ }
317
+
318
+ return issueResponse.json();
319
+ }
320
+
321
+ return response.json();
322
+ }
323
+
324
+ module.exports = {
325
+ findPRForBranch,
326
+ fetchAllPages,
327
+ fetchPRComments,
328
+ processComments,
329
+ filterComments,
330
+ replyToComment,
331
+ isBot,
332
+ isMetaComment,
333
+ DEFAULT_META_FILTERS,
334
+ };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Terminal output formatting for PR comments
3
+ */
4
+
5
+ // ANSI colors
6
+ const colors = {
7
+ reset: "\x1b[0m",
8
+ bright: "\x1b[1m",
9
+ dim: "\x1b[2m",
10
+ red: "\x1b[31m",
11
+ green: "\x1b[32m",
12
+ yellow: "\x1b[33m",
13
+ blue: "\x1b[34m",
14
+ cyan: "\x1b[36m",
15
+ magenta: "\x1b[35m",
16
+ };
17
+
18
+ function truncate(str, maxLength) {
19
+ if (!str) return "";
20
+ const oneLine = str.replace(/\n/g, " ").trim();
21
+ if (oneLine.length <= maxLength) return oneLine;
22
+ return `${oneLine.slice(0, maxLength - 3)}...`;
23
+ }
24
+
25
+ function getReplyStatus(comment) {
26
+ if (!comment.hasAnyReply) {
27
+ return `${colors.red}○ no reply${colors.reset}`;
28
+ }
29
+ if (comment.hasHumanReply) {
30
+ return `${colors.green}✓ replied${colors.reset}`;
31
+ }
32
+ return `${colors.yellow}⚡ bot replied${colors.reset}`;
33
+ }
34
+
35
+ function formatComment(comment) {
36
+ const typeColors = {
37
+ review_comment: colors.cyan,
38
+ issue_comment: colors.blue,
39
+ review: colors.magenta,
40
+ };
41
+
42
+ const typeLabels = {
43
+ review_comment: "CODE",
44
+ issue_comment: "COMMENT",
45
+ review: "REVIEW",
46
+ };
47
+
48
+ const typeColor = typeColors[comment.type] || colors.reset;
49
+ const typeLabel = typeLabels[comment.type] || comment.type.toUpperCase();
50
+ const userColor = comment.isBot ? colors.yellow : colors.green;
51
+ const replyStatus = getReplyStatus(comment);
52
+
53
+ let location = "";
54
+ if (comment.path) {
55
+ location = `${colors.dim}${comment.path}`;
56
+ if (comment.line) {
57
+ location += `:${comment.line}`;
58
+ }
59
+ location += colors.reset;
60
+ }
61
+
62
+ const lines = [
63
+ `${colors.bright}[${comment.id}]${colors.reset} ${typeColor}${typeLabel}${colors.reset} by ${userColor}${comment.user}${colors.reset} ${replyStatus}`,
64
+ ];
65
+
66
+ if (location) {
67
+ lines.push(` ${location}`);
68
+ }
69
+
70
+ lines.push(` ${colors.dim}${truncate(comment.body, 100)}${colors.reset}`);
71
+
72
+ if (comment.replies.length > 0) {
73
+ lines.push(
74
+ ` ${colors.dim}└ ${comment.replies.length} repl${comment.replies.length === 1 ? "y" : "ies"}${colors.reset}`
75
+ );
76
+ }
77
+
78
+ return lines.join("\n");
79
+ }
80
+
81
+ function formatDetailedComment(comment) {
82
+ const typeLabels = {
83
+ review_comment: "CODE",
84
+ issue_comment: "COMMENT",
85
+ review: "REVIEW",
86
+ };
87
+ const typeLabel = typeLabels[comment.type] || comment.type.toUpperCase();
88
+ const replyStatus = comment.hasAnyReply
89
+ ? comment.hasHumanReply
90
+ ? "✓ replied"
91
+ : "⚡ bot replied"
92
+ : "○ no reply";
93
+
94
+ const lines = [];
95
+
96
+ lines.push(`=== Comment [${comment.id}] ===`);
97
+ lines.push(
98
+ `Type: ${typeLabel} | By: ${comment.user} | Status: ${replyStatus}`
99
+ );
100
+
101
+ if (comment.path) {
102
+ let location = `File: ${comment.path}`;
103
+ if (comment.line) location += `:${comment.line}`;
104
+ lines.push(location);
105
+ }
106
+
107
+ lines.push(`URL: ${comment.url}`);
108
+
109
+ if (comment.diffHunk) {
110
+ lines.push("");
111
+ lines.push("--- Code Context ---");
112
+ lines.push(comment.diffHunk);
113
+ lines.push("--- End Code Context ---");
114
+ }
115
+
116
+ lines.push("");
117
+ lines.push(comment.body || "(no body)");
118
+
119
+ if (comment.replies.length > 0) {
120
+ lines.push("");
121
+ lines.push(`--- Replies (${comment.replies.length}) ---`);
122
+ for (const reply of comment.replies) {
123
+ const date = reply.createdAt
124
+ ? new Date(reply.createdAt)
125
+ .toISOString()
126
+ .replace("T", " ")
127
+ .slice(0, 16)
128
+ : "unknown";
129
+ lines.push(`[${reply.id}] ${reply.user} (${date}):`);
130
+ lines.push(reply.body || "(no body)");
131
+ lines.push("");
132
+ }
133
+ lines.push("--- End Replies ---");
134
+ }
135
+
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function formatOutput(comments, options) {
140
+ if (options.json) {
141
+ return JSON.stringify(comments, null, 2);
142
+ }
143
+
144
+ if (comments.length === 0) {
145
+ const filterDesc =
146
+ options.filter === "unresolved"
147
+ ? "unresolved "
148
+ : options.filter === "unanswered"
149
+ ? "unanswered "
150
+ : "";
151
+ return `${colors.green}No ${filterDesc}comments found.${colors.reset}`;
152
+ }
153
+
154
+ const header = `${colors.bright}Found ${comments.length} comment${comments.length === 1 ? "" : "s"}${colors.reset}\n`;
155
+ const formatted = comments.map((c) => formatComment(c)).join("\n\n");
156
+
157
+ return `${header}\n${formatted}`;
158
+ }
159
+
160
+ module.exports = {
161
+ colors,
162
+ truncate,
163
+ formatComment,
164
+ formatDetailedComment,
165
+ formatOutput,
166
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * GitHub API utilities for agent-reviews
3
+ *
4
+ * Handles authentication, proxy support, and repository detection.
5
+ * Works in both local and cloud environments (HTTPS_PROXY, etc.).
6
+ */
7
+
8
+ const { execSync } = require("node:child_process");
9
+ const { existsSync, readFileSync } = require("node:fs");
10
+ const path = require("node:path");
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Proxy-aware fetch (for cloud/corporate environments)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function getProxyFetch() {
17
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
18
+ if (proxyUrl) {
19
+ try {
20
+ const { ProxyAgent, fetch: undiciFetch } = require("undici");
21
+ const agent = new ProxyAgent(proxyUrl);
22
+ return (url, options = {}) =>
23
+ undiciFetch(url, { ...options, dispatcher: agent });
24
+ } catch {
25
+ // undici not available, fall back to native fetch
26
+ }
27
+ }
28
+ return globalThis.fetch;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // GitHub token resolution
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Resolve a GitHub token from (in priority order):
37
+ * 1. GITHUB_TOKEN env var
38
+ * 2. .env.local files in the repo root
39
+ * 3. `gh auth token` CLI
40
+ */
41
+ function getGitHubToken() {
42
+ if (process.env.GITHUB_TOKEN) {
43
+ return process.env.GITHUB_TOKEN;
44
+ }
45
+
46
+ const root = getRepoRoot();
47
+ if (root) {
48
+ const envFile = path.join(root, ".env.local");
49
+ if (existsSync(envFile)) {
50
+ const content = readFileSync(envFile, "utf8");
51
+ const match = content.match(/^GITHUB_TOKEN=["']?([^"'\n]+)["']?/m);
52
+ if (match) {
53
+ return match[1];
54
+ }
55
+ }
56
+ }
57
+
58
+ try {
59
+ const token = execSync("gh auth token", {
60
+ encoding: "utf8",
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ }).trim();
63
+ if (token) {
64
+ return token;
65
+ }
66
+ } catch {
67
+ // gh CLI not available or not authenticated
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Repository info
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function getRepoRoot() {
78
+ try {
79
+ return execSync("git rev-parse --show-toplevel", {
80
+ encoding: "utf8",
81
+ stdio: ["pipe", "pipe", "pipe"],
82
+ }).trim();
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function getRepoInfo() {
89
+ try {
90
+ const remoteUrl = execSync("git remote get-url origin", {
91
+ encoding: "utf8",
92
+ }).trim();
93
+
94
+ const sshMatch = remoteUrl.match(
95
+ /git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/
96
+ );
97
+ const httpsMatch = remoteUrl.match(
98
+ /github\.com\/([^/]+)\/(.+?)(?:\.git)?$/
99
+ );
100
+ const proxyMatch = remoteUrl.match(/\/git\/([^/]+)\/([^/]+)$/);
101
+
102
+ const match = sshMatch || httpsMatch || proxyMatch;
103
+ if (match) {
104
+ return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
105
+ }
106
+ } catch {
107
+ // Ignore errors
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function getCurrentBranch() {
113
+ try {
114
+ return execSync("git rev-parse --abbrev-ref HEAD", {
115
+ encoding: "utf8",
116
+ }).trim();
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ module.exports = {
123
+ getProxyFetch,
124
+ getGitHubToken,
125
+ getRepoInfo,
126
+ getRepoRoot,
127
+ getCurrentBranch,
128
+ };