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.
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/agent-reviews.js +506 -0
- package/lib/comments.js +334 -0
- package/lib/format.js +166 -0
- package/lib/github.js +128 -0
- package/package.json +33 -0
- package/skills/agent-reviews/SKILL.md +189 -0
package/lib/comments.js
ADDED
|
@@ -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
|
+
}
|