agent-reviews 0.1.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.
@@ -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
+ };
package/lib/format.js ADDED
@@ -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
+ };
package/lib/github.js ADDED
@@ -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
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "agent-reviews",
3
+ "version": "0.1.0",
4
+ "description": "CLI and Claude Code skill for managing GitHub PR review comments — list, filter, reply, and watch for bot findings",
5
+ "license": "MIT",
6
+ "author": "Paul Bakaus",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/pbakaus/agent-reviews.git"
10
+ },
11
+ "keywords": [
12
+ "github",
13
+ "pull-request",
14
+ "code-review",
15
+ "cli",
16
+ "claude-code",
17
+ "ai-agent",
18
+ "pr-comments",
19
+ "review-bot"
20
+ ],
21
+ "bin": {
22
+ "agent-reviews": "bin/agent-reviews.js"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "lib/",
27
+ "skills/",
28
+ ".claude-plugin/"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ }
33
+ }