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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/agent-reviews.js +0 -27
- package/package.json +4 -1
- package/skills/agent-reviews/SKILL.md +48 -39
- package/skills/agent-reviews/scripts/agent-reviews.js +478 -0
- package/skills/agent-reviews/scripts/comments.js +334 -0
- package/skills/agent-reviews/scripts/format.js +166 -0
- package/skills/agent-reviews/scripts/github.js +128 -0
|
@@ -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.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Paul Bakaus",
|
|
7
7
|
"url": "https://github.com/pbakaus"
|
package/bin/agent-reviews.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
scripts/agent-reviews.js --bots-only --unanswered
|
|
23
29
|
```
|
|
24
30
|
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
94
|
-
```
|
|
82
|
+
**If UNCERTAIN:** Use `AskUserQuestion`. If the user says skip, track it as skipped.
|
|
95
83
|
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
+
};
|