
Agent Reviews
- 37 installs
- 221 repo stars
- Updated August 1, 2026
- pbakaus/agent-reviews
Helps with ai & agent building tasks during AI-assisted development.
About
agent-reviews is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- agent-reviews
- AI & Agent Building
- AI-coding skill
Agent Reviews by the numbers
- 37 all-time installs (skills.sh)
- Ranked #8,545 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pbakaus/agent-reviews --skill agent-reviewsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 37 |
|---|---|
| repo stars | ★ 221 |
| Last updated | August 1, 2026 |
| Repository | pbakaus/agent-reviews ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
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.
Path note: All scripts/agent-reviews.js references below are relative to this skill's directory (next to this SKILL.md file). Run them with node.
Phase 1: FETCH & FIX (synchronous)
Step 1: Identify Current PR
gh pr view --json number,url,headRefNameIf no PR exists, notify the user and exit.
Step 2: Fetch All Bot Comments (Expanded)
Run scripts/agent-reviews.js --bots-only --unanswered --expanded
This shows only unanswered bot comments with full detail: complete comment body (no truncation), diff hunk (code context), and all replies. Each comment shows its ID in brackets (e.g., [12345678]).
If zero comments are returned, print "No unanswered bot comments found" and skip to Phase 2.
Step 3: Process Each Unanswered Comment
For each comment from the expanded output:
A. Evaluate the Finding
Read the referenced code and determine:
1. TRUE POSITIVE - A real bug that needs fixing 2. FALSE POSITIVE - Not actually a bug (intentional behavior, bot misunderstanding) 3. UNCERTAIN - Not sure; ask the user
Likely TRUE POSITIVE:
- Code obviously violates stated behavior
- Missing null checks on potentially undefined values
- Type mismatches or incorrect function signatures
- Logic errors in conditionals
- Missing error handling for documented failure cases
Likely FALSE POSITIVE:
- Bot doesn't understand the framework/library patterns
- Code is intentionally structured that way (with comments explaining why)
- Bot is flagging style preferences, not bugs
- The "bug" is actually a feature or intentional behavior
- Bot misread the code flow
When UNCERTAIN — use `AskUserQuestion`:
- The fix would require architectural changes
- You're genuinely unsure if the behavior is intentional
- The "bug" relates to business logic you don't fully understand
- Multiple valid interpretations exist
- The fix could have unintended side effects
B. Act on Evaluation
If TRUE POSITIVE: Fix the code. Track the comment ID and a brief description of the fix.
If FALSE POSITIVE: Do NOT change the code. Track the comment ID and the reason it's not a real bug.
If UNCERTAIN: Use AskUserQuestion. If the user says skip, track it as skipped.
Do NOT reply to comments yet. Replies happen after the commit (Step 5).
Step 4: Commit and Push
After evaluating and fixing ALL unanswered comments:
1. Run your project's lint and type-check 2. Stage, commit, and push:
git add -A
git commit -m "fix: address PR review bot findings
{List of bugs fixed, grouped by bot}"
git push3. Capture the commit hash from the output.
Step 5: Reply to All Comments
Now that the commit hash exists, reply to every processed comment:
For each TRUE POSITIVE:
Run scripts/agent-reviews.js --reply <comment_id> "Fixed in {hash}. {Brief description of the fix}"
For each FALSE POSITIVE:
Run scripts/agent-reviews.js --reply <comment_id> "Won't fix: {reason}. {Explanation of why this is intentional or not applicable}"
For each SKIPPED:
Run scripts/agent-reviews.js --reply <comment_id> "Skipped per user request"
DO NOT start Phase 2 until all replies are posted.
---
Phase 2: POLL FOR NEW COMMENTS (loop until quiet)
The watcher exits immediately when new comments are found (after a 5s grace period to catch batch posts). This means you run it in a loop: start watcher, process any comments it returns, restart watcher, repeat until the watcher times out with no new comments.
Step 6: Start Watcher Loop
Repeat the following until the watcher exits with no new comments:
6a. Launch the watcher in the background:
Run scripts/agent-reviews.js --watch --bots-only as a background task.
6b. Use TaskOutput to wait for the watcher to complete (blocks up to 12 minutes).
6c. Check the output:
- If new comments were found (output contains
EXITING WITH NEW COMMENTS):
1. Use --detail <id> to read each new comment's full detail 2. Process them exactly as in Phase 1, Steps 3-5 (evaluate, fix, commit, push, reply) 3. Go back to Step 6a to restart the watcher
- If no new comments (output contains
WATCH COMPLETE):
Stop looping and move to the Summary Report.
---
Summary Report
After both phases complete, provide a summary:
## PR Review Bot Resolution Summary
### Results
- Fixed: X bugs
- Already fixed: X bugs
- Won't fix (false positives): X
- Skipped per user: X
### By Bot
#### cursor[bot]
- BUG-001: {description} - Fixed in {commit}
- BUG-002: {description} - Won't fix: {reason}
#### Copilot
- {description} - Fixed in {commit}
### Status
✅ All findings addressed. Watch completed.Important Notes
Response Policy
- Every finding gets a response - No silent ignores
- Responses help train bots and document decisions
- "Won't fix" responses prevent the same false positive from being re-raised
User Interaction
- Use
AskUserQuestionwhen uncertain about a finding - Don't guess on architectural or business logic questions
- It's better to ask than to make a wrong fix or wrong dismissal
Best Practices
- Verify findings before fixing - bots have false positives
- Keep fixes minimal and focused - don't refactor unrelated code
- Ensure type-check and lint pass before committing
- Group related fixes into a single commit
- Copilot
suggestionblocks often contain ready-to-use fixes
#!/usr/bin/env node
/**
* agent-reviews — CLI for managing GitHub PR review comments
*
* List, filter, reply to, and watch PR review comments from the terminal.
* Designed for both human use and as a tool for AI coding agents.
*
* Usage:
* agent-reviews # List all review comments
* agent-reviews --unresolved # List unresolved comments only
* agent-reviews --unanswered # List comments without replies
* agent-reviews --reply <id> "msg" # Reply to a specific comment
* agent-reviews --detail <id> # Show full detail (no truncation)
* agent-reviews --json # Output as JSON for scripting
* agent-reviews --watch # Watch for new comments (poll mode)
*
* Options:
* --pr <number> Target specific PR (auto-detects from branch)
* --bots-only Only show bot comments
* --humans-only Only show human comments
*/
const {
getProxyFetch,
getGitHubToken,
getRepoInfo,
getCurrentBranch,
} = require("./github");
const {
findPRForBranch,
fetchPRComments,
processComments,
filterComments,
replyToComment,
} = require("./comments");
const {
colors,
formatComment,
formatDetailedComment,
formatOutput,
} = require("./format");
const proxyFetch = getProxyFetch();
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const result = {
command: "list",
prNumber: null,
filter: null,
replyTo: null,
replyMessage: null,
json: false,
botsOnly: false,
humansOnly: false,
detail: null,
help: false,
version: false,
expanded: false,
watch: false,
watchInterval: 30,
watchTimeout: 600,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--unresolved":
case "-u":
result.filter = "unresolved";
break;
case "--unanswered":
case "-a":
result.filter = "unanswered";
break;
case "--reply":
case "-r":
result.command = "reply";
result.replyTo = args[++i];
result.replyMessage = args[++i];
break;
case "--pr":
case "-p":
result.prNumber = Number.parseInt(args[++i], 10);
break;
case "--json":
case "-j":
result.json = true;
break;
case "--bots-only":
case "-b":
result.botsOnly = true;
break;
case "--humans-only":
case "-H":
result.humansOnly = true;
break;
case "--detail":
case "-d":
result.command = "detail";
result.detail = args[++i];
break;
case "--watch":
case "-w":
result.watch = true;
result.command = "watch";
break;
case "--interval":
case "-i":
result.watchInterval = Number.parseInt(args[++i], 10);
break;
case "--exit-after":
case "--timeout":
result.watchTimeout = Number.parseInt(args[++i], 10);
break;
case "--expanded":
case "-e":
result.expanded = true;
break;
case "--help":
case "-h":
result.help = true;
break;
case "--version":
case "-v":
result.version = true;
break;
default:
break;
}
}
return result;
}
function showHelp() {
console.log(`
${colors.bright}agent-reviews${colors.reset} — Manage PR review comments from the CLI
Designed for both human use and as a tool for AI coding agents (Claude Code, etc.).
${colors.bright}Usage:${colors.reset}
agent-reviews List all review comments
agent-reviews --unresolved List unresolved comments only
agent-reviews --unanswered List comments without replies
agent-reviews --reply <id> "msg" Reply to a specific comment
agent-reviews --detail <id> Show full detail for a comment
agent-reviews --expanded Show full detail for each comment
agent-reviews --watch Watch for new comments (poll mode)
agent-reviews --json Output as JSON for scripting
${colors.bright}Options:${colors.reset}
-u, --unresolved Show only unresolved/pending comments
-a, --unanswered Show only comments without any replies
-r, --reply Reply to a comment (requires ID and message)
-d, --detail Show full detail for a specific comment
-p, --pr Target specific PR number (auto-detects from branch)
-j, --json Output as JSON instead of formatted text
-b, --bots-only Only show comments from bots
-H, --humans-only Only show comments from humans
-e, --expanded Show full detail (body, diff hunk, replies) for each comment
-h, --help Show this help
-v, --version Show version
${colors.bright}Watch Mode:${colors.reset}
-w, --watch Poll for new comments (exits on detection)
-i, --interval Poll interval in seconds (default: 30)
--timeout Exit after N seconds of inactivity (default: 600)
${colors.bright}Examples:${colors.reset}
agent-reviews # Show all comments
agent-reviews -u # Show unresolved only
agent-reviews -a --bots-only # Unanswered bot comments
agent-reviews -a --bots-only --expanded # Full detail for unanswered bot comments
agent-reviews --reply 12345 "Fixed!" # Reply to comment #12345
agent-reviews --detail 12345 # Full detail for a comment
agent-reviews --detail 12345 --json # Detail as JSON
agent-reviews --json | jq '.[]' # Pipe to jq
agent-reviews --watch --bots-only # Watch for new bot comments
agent-reviews -w -i 15 --timeout 300 # Poll every 15s, exit after 5 min
${colors.bright}Authentication:${colors.reset}
Set GITHUB_TOKEN env var, or use 'gh auth login' (gh CLI).
${colors.dim}Comment IDs are shown in brackets, e.g., [12345678]${colors.reset}
`);
}
// ---------------------------------------------------------------------------
// Watch mode
// ---------------------------------------------------------------------------
function formatTimestamp() {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}
function sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
async function watchForComments(context, options) {
const { owner, repo, prNumber, prUrl, token } = context;
const seenIds = new Set();
let lastActivityTime = Date.now();
let pollCount = 0;
function getWatchFilterDesc() {
if (options.botsOnly) return "bots-only";
if (options.humansOnly) return "humans-only";
return "all";
}
const filterDesc = getWatchFilterDesc();
console.log(
`\n${colors.bright}=== PR Comments Watch Mode ===${colors.reset}`
);
console.log(`${colors.dim}PR #${prNumber}: ${prUrl}${colors.reset}`);
console.log(
`${colors.dim}Polling every ${options.watchInterval}s, exit after ${options.watchTimeout}s of inactivity${colors.reset}`
);
console.log(
`${colors.dim}Filters: ${filterDesc}, ${options.filter || "all comments"}${colors.reset}`
);
console.log(
`${colors.dim}Started at ${formatTimestamp()}${colors.reset}\n`
);
// Initial fetch to populate seen IDs
const initialData = await fetchPRComments(
owner,
repo,
prNumber,
token,
proxyFetch
);
const initialProcessed = processComments(initialData);
const initialFiltered = filterComments(initialProcessed, options);
for (const comment of initialFiltered) {
seenIds.add(comment.id);
}
console.log(
`${colors.dim}[${formatTimestamp()}] Initial state: ${initialFiltered.length} existing comments tracked${colors.reset}`
);
if (initialFiltered.length > 0) {
console.log(`\n${colors.yellow}=== EXISTING COMMENTS ===${colors.reset}`);
for (const comment of initialFiltered) {
console.log(formatComment(comment));
console.log("");
}
}
// Watch loop
while (true) {
await sleep(options.watchInterval);
pollCount++;
const rawData = await fetchPRComments(
owner,
repo,
prNumber,
token,
proxyFetch
);
const processed = processComments(rawData);
const filtered = filterComments(processed, options);
const newComments = filtered.filter((c) => !seenIds.has(c.id));
if (newComments.length > 0) {
for (const comment of newComments) {
seenIds.add(comment.id);
}
console.log(
`\n${colors.green}=== NEW COMMENTS DETECTED [${formatTimestamp()}] ===${colors.reset}`
);
console.log(
`${colors.bright}Found ${newComments.length} new comment${newComments.length === 1 ? "" : "s"}${colors.reset}`
);
// Brief grace period to catch any stragglers from the same bot batch
console.log(
`${colors.dim}Waiting 5s for additional comments...${colors.reset}`
);
await sleep(5);
// Re-fetch to catch any comments posted during the grace period
const graceData = await fetchPRComments(
owner,
repo,
prNumber,
token,
proxyFetch
);
const graceProcessed = processComments(graceData);
const graceFiltered = filterComments(graceProcessed, options);
const lateComments = graceFiltered.filter((c) => !seenIds.has(c.id));
for (const comment of lateComments) {
seenIds.add(comment.id);
newComments.push(comment);
}
if (lateComments.length > 0) {
console.log(
`${colors.bright}Caught ${lateComments.length} additional comment${lateComments.length === 1 ? "" : "s"}${colors.reset}`
);
}
console.log("");
for (const comment of newComments) {
console.log(formatComment(comment));
console.log("");
}
// JSON output for AI agent parsing
console.log(`${colors.dim}--- JSON for processing ---${colors.reset}`);
console.log(JSON.stringify(newComments, null, 2));
console.log(`${colors.dim}--- end JSON ---${colors.reset}`);
// Exit immediately so the caller can process and restart if needed
console.log(
`\n${colors.green}=== WATCH: EXITING WITH NEW COMMENTS ===${colors.reset}`
);
console.log(
`${colors.dim}Restart watcher after processing to catch further comments.${colors.reset}`
);
return;
} else {
const inactiveSeconds = Math.round(
(Date.now() - lastActivityTime) / 1000
);
console.log(
`${colors.dim}[${formatTimestamp()}] Poll #${pollCount}: No new comments (${inactiveSeconds}s/${options.watchTimeout}s idle)${colors.reset}`
);
if (inactiveSeconds >= options.watchTimeout) {
console.log(`\n${colors.green}=== WATCH COMPLETE ===${colors.reset}`);
console.log(
`${colors.dim}No new comments after ${options.watchTimeout}s of inactivity.${colors.reset}`
);
console.log(
`${colors.dim}Total comments tracked: ${seenIds.size}${colors.reset}`
);
console.log(
`${colors.dim}Exiting at ${formatTimestamp()}${colors.reset}`
);
return;
}
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const options = parseArgs();
if (options.version) {
console.log("0.6.0");
process.exit(0);
}
if (options.help) {
showHelp();
process.exit(0);
}
// Get GitHub token
const token = getGitHubToken();
if (!token) {
console.error(`${colors.red}Error: GitHub token not found${colors.reset}`);
console.error(
"Set GITHUB_TOKEN env var, or authenticate with: gh auth login"
);
process.exit(1);
}
// Get repo info
const repoInfo = getRepoInfo();
if (!repoInfo) {
console.error(
`${colors.red}Error: Could not determine repository from git remote${colors.reset}`
);
process.exit(1);
}
// Find PR
let prNumber = options.prNumber;
let prUrl = null;
if (!prNumber) {
const branch = getCurrentBranch();
if (!branch) {
console.error(
`${colors.red}Error: Could not determine current branch${colors.reset}`
);
process.exit(1);
}
const pr = await findPRForBranch(
repoInfo.owner,
repoInfo.repo,
branch,
token,
proxyFetch
);
if (!pr) {
console.error(
`${colors.red}Error: No open PR found for branch '${branch}'${colors.reset}`
);
process.exit(1);
}
prNumber = pr.number;
prUrl = pr.html_url;
}
// Handle reply command
if (options.command === "reply") {
if (!(options.replyTo && options.replyMessage)) {
console.error(
`${colors.red}Error: --reply requires comment ID and message${colors.reset}`
);
console.error('Usage: agent-reviews --reply <id> "message"');
process.exit(1);
}
const result = await replyToComment(
repoInfo.owner,
repoInfo.repo,
prNumber,
options.replyTo,
options.replyMessage,
token,
proxyFetch
);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(
`${colors.green}✓ Reply posted successfully${colors.reset}`
);
console.log(` ${colors.dim}${result.html_url}${colors.reset}`);
}
return;
}
// Handle detail command
if (options.command === "detail") {
if (!options.detail) {
console.error(
`${colors.red}Error: --detail requires a comment ID${colors.reset}`
);
process.exit(1);
}
const rawData = await fetchPRComments(
repoInfo.owner,
repoInfo.repo,
prNumber,
token,
proxyFetch
);
const processed = processComments(rawData);
const targetId = Number(options.detail);
const comment = processed.find((c) => c.id === targetId);
if (!comment) {
console.error(
`${colors.red}Error: Comment ${options.detail} not found in PR #${prNumber}${colors.reset}`
);
process.exit(1);
}
if (options.json) {
console.log(JSON.stringify(comment, null, 2));
} else {
console.log(formatDetailedComment(comment));
}
return;
}
// Handle watch command
if (options.command === "watch") {
await watchForComments(
{ owner: repoInfo.owner, repo: repoInfo.repo, prNumber, prUrl, token },
options
);
return;
}
// Default: fetch and display comments
const rawData = await fetchPRComments(
repoInfo.owner,
repoInfo.repo,
prNumber,
token,
proxyFetch
);
const processed = processComments(rawData);
const filtered = filterComments(processed, options);
console.log(formatOutput(filtered, options));
}
main().catch((error) => {
console.error(`${colors.red}Error: ${error.message}${colors.reset}`);
process.exit(1);
});
/**
* PR comment fetching, processing, and filtering
*
* Fetches all comment types (review comments, issue comments, reviews)
* from GitHub's API, processes them into a unified format, and provides
* filtering capabilities.
*/
const USER_AGENT = "agent-reviews";
// ---------------------------------------------------------------------------
// GitHub API helpers
// ---------------------------------------------------------------------------
async function findPRForBranch(owner, repo, branch, token, proxyFetch) {
const response = await proxyFetch(
`https://api.github.com/repos/${owner}/${repo}/pulls?head=${owner}:${branch}&state=open`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": USER_AGENT,
},
}
);
if (!response.ok) {
throw new Error(`Failed to find PR: ${response.status}`);
}
const prs = await response.json();
return prs[0] || null;
}
// ---------------------------------------------------------------------------
// Paginated fetch
// ---------------------------------------------------------------------------
async function fetchAllPages(url, token, proxyFetch) {
const results = [];
let nextUrl = url;
while (nextUrl) {
const response = await proxyFetch(nextUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": USER_AGENT,
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
results.push(...data);
// Check for next page in Link header
const linkHeader = response.headers.get("link");
nextUrl = null;
if (linkHeader) {
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
if (nextMatch) {
nextUrl = nextMatch[1];
}
}
}
return results;
}
async function fetchPRComments(owner, repo, prNumber, token, proxyFetch) {
const baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
// Fetch all comment types in parallel
const [reviewComments, issueComments, reviews] = await Promise.all([
fetchAllPages(
`${baseUrl}/pulls/${prNumber}/comments?per_page=100`,
token,
proxyFetch
),
fetchAllPages(
`${baseUrl}/issues/${prNumber}/comments?per_page=100`,
token,
proxyFetch
),
fetchAllPages(
`${baseUrl}/pulls/${prNumber}/reviews?per_page=100`,
token,
proxyFetch
),
]);
return { reviewComments, issueComments, reviews };
}
// ---------------------------------------------------------------------------
// Comment classification
// ---------------------------------------------------------------------------
/**
* Default meta-comment filters.
* These are auto-generated status updates, not actionable review findings.
* Users can extend this list via the `metaFilters` option.
*/
const DEFAULT_META_FILTERS = [
// Vercel deployment status
(user, body) => user === "vercel[bot]" && body.startsWith("[vc]:"),
// Supabase branch status
(user, body) => user === "supabase[bot]" && body.startsWith("[supa]:"),
// cursor[bot] summary (not the actual findings)
(user, body) =>
user === "cursor[bot]" &&
body.startsWith("Cursor Bugbot has reviewed your changes"),
];
function isMetaComment(user, body, metaFilters = DEFAULT_META_FILTERS) {
if (!body) return false;
return metaFilters.some((filter) => filter(user, body));
}
function isBot(username) {
if (!username) return false;
return (
username.endsWith("[bot]") ||
username === "Copilot" ||
username.includes("bot") ||
username === "github-actions"
);
}
// ---------------------------------------------------------------------------
// Body cleanup
// ---------------------------------------------------------------------------
/**
* Strip bot boilerplate from comment bodies:
* - HTML comments (<!-- ... -->)
* - Cursor "Fix in Cursor" / "Fix in Web" button blocks
* - "Additional Locations" <details> blocks
* - Collapse leftover blank lines
*/
function cleanBody(body) {
if (!body) return body;
let cleaned = body;
// Remove HTML comments (single and multi-line)
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, "");
// Remove <details> blocks containing "Additional Locations"
cleaned = cleaned.replace(
/<details>\s*<summary>\s*Additional Locations[\s\S]*?<\/details>/gi,
""
);
// Remove <p> blocks containing cursor.com links
cleaned = cleaned.replace(/<p>\s*<a [^>]*cursor\.com[\s\S]*?<\/p>/gi, "");
// Collapse runs of 3+ newlines into 2
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
return cleaned.trim();
}
// ---------------------------------------------------------------------------
// Processing
// ---------------------------------------------------------------------------
function processComments(data, options = {}) {
const { reviewComments, issueComments, reviews } = data;
const metaFilters = options.metaFilters || DEFAULT_META_FILTERS;
// Build a map of comment replies
const repliesMap = new Map();
for (const comment of reviewComments) {
if (comment.in_reply_to_id) {
if (!repliesMap.has(comment.in_reply_to_id)) {
repliesMap.set(comment.in_reply_to_id, []);
}
repliesMap.get(comment.in_reply_to_id).push({
id: comment.id,
user: comment.user?.login,
body: cleanBody(comment.body),
createdAt: comment.created_at,
isBot: isBot(comment.user?.login),
});
}
}
const processed = [];
// Process review comments (inline code comments)
for (const comment of reviewComments) {
if (comment.in_reply_to_id) continue;
if (isMetaComment(comment.user?.login, comment.body, metaFilters)) continue;
const replies = repliesMap.get(comment.id) || [];
const hasHumanReply = replies.some((r) => !r.isBot);
const hasAnyReply = replies.length > 0;
processed.push({
id: comment.id,
type: "review_comment",
user: comment.user?.login,
isBot: isBot(comment.user?.login),
path: comment.path,
line: comment.line || comment.original_line,
diffHunk: comment.diff_hunk || null,
body: cleanBody(comment.body),
createdAt: comment.created_at,
updatedAt: comment.updated_at,
url: comment.html_url,
replies,
hasHumanReply,
hasAnyReply,
isResolved: false,
});
}
// Process issue comments (general PR comments)
for (const comment of issueComments) {
if (isMetaComment(comment.user?.login, comment.body, metaFilters)) continue;
processed.push({
id: comment.id,
type: "issue_comment",
user: comment.user?.login,
isBot: isBot(comment.user?.login),
path: null,
line: null,
diffHunk: null,
body: cleanBody(comment.body),
createdAt: comment.created_at,
updatedAt: comment.updated_at,
url: comment.html_url,
replies: [],
hasHumanReply: false,
hasAnyReply: false,
isResolved: false,
});
}
// Process review bodies (only if they have content)
for (const review of reviews) {
if (isMetaComment(review.user?.login, review.body, metaFilters)) continue;
if (!review.body?.trim()) continue;
processed.push({
id: review.id,
type: "review",
user: review.user?.login,
isBot: isBot(review.user?.login),
path: null,
line: null,
diffHunk: null,
body: cleanBody(review.body),
state: review.state,
createdAt: review.submitted_at,
updatedAt: review.submitted_at,
url: review.html_url,
replies: [],
hasHumanReply: false,
hasAnyReply: false,
isResolved: review.state === "APPROVED" || review.state === "DISMISSED",
});
}
// Sort by date (newest first)
processed.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return processed;
}
// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------
function filterComments(comments, options) {
let filtered = comments;
if (options.botsOnly) {
filtered = filtered.filter((c) => c.isBot);
} else if (options.humansOnly) {
filtered = filtered.filter((c) => !c.isBot);
}
if (options.filter === "unresolved") {
filtered = filtered.filter((c) => !(c.isResolved || c.hasHumanReply));
} else if (options.filter === "unanswered") {
filtered = filtered.filter((c) => !c.hasAnyReply);
}
return filtered;
}
// ---------------------------------------------------------------------------
// Reply
// ---------------------------------------------------------------------------
async function replyToComment(
owner,
repo,
prNumber,
commentId,
message,
token,
proxyFetch
) {
// Try review comment reply endpoint first
const response = await proxyFetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments/${commentId}/replies`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ body: message }),
}
);
if (!response.ok) {
// Fallback to issue comment endpoint
const issueResponse = await proxyFetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
body: `> Re: comment ${commentId}\n\n${message}`,
}),
}
);
if (!issueResponse.ok) {
const error = await issueResponse.text();
throw new Error(`Failed to reply: ${issueResponse.status} - ${error}`);
}
return issueResponse.json();
}
return response.json();
}
module.exports = {
findPRForBranch,
fetchAllPages,
fetchPRComments,
processComments,
filterComments,
replyToComment,
isBot,
isMetaComment,
cleanBody,
DEFAULT_META_FILTERS,
};
/**
* Terminal output formatting for PR comments
*/
// ANSI colors
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
magenta: "\x1b[35m",
};
function truncate(str, maxLength) {
if (!str) return "";
const oneLine = str.replace(/\n/g, " ").trim();
if (oneLine.length <= maxLength) return oneLine;
return `${oneLine.slice(0, maxLength - 3)}...`;
}
function getReplyStatus(comment) {
if (!comment.hasAnyReply) {
return `${colors.red}○ no reply${colors.reset}`;
}
if (comment.hasHumanReply) {
return `${colors.green}✓ replied${colors.reset}`;
}
return `${colors.yellow}⚡ bot replied${colors.reset}`;
}
function formatComment(comment) {
const typeColors = {
review_comment: colors.cyan,
issue_comment: colors.blue,
review: colors.magenta,
};
const typeLabels = {
review_comment: "CODE",
issue_comment: "COMMENT",
review: "REVIEW",
};
const typeColor = typeColors[comment.type] || colors.reset;
const typeLabel = typeLabels[comment.type] || comment.type.toUpperCase();
const userColor = comment.isBot ? colors.yellow : colors.green;
const replyStatus = getReplyStatus(comment);
let location = "";
if (comment.path) {
location = `${colors.dim}${comment.path}`;
if (comment.line) {
location += `:${comment.line}`;
}
location += colors.reset;
}
const lines = [
`${colors.bright}[${comment.id}]${colors.reset} ${typeColor}${typeLabel}${colors.reset} by ${userColor}${comment.user}${colors.reset} ${replyStatus}`,
];
if (location) {
lines.push(` ${location}`);
}
lines.push(` ${colors.dim}${truncate(comment.body, 100)}${colors.reset}`);
if (comment.replies.length > 0) {
lines.push(
` ${colors.dim}└ ${comment.replies.length} repl${comment.replies.length === 1 ? "y" : "ies"}${colors.reset}`
);
}
return lines.join("\n");
}
function formatDetailedComment(comment) {
const typeLabels = {
review_comment: "CODE",
issue_comment: "COMMENT",
review: "REVIEW",
};
const typeLabel = typeLabels[comment.type] || comment.type.toUpperCase();
const replyStatus = comment.hasAnyReply
? comment.hasHumanReply
? "✓ replied"
: "⚡ bot replied"
: "○ no reply";
const lines = [];
lines.push(`=== Comment [${comment.id}] ===`);
lines.push(
`Type: ${typeLabel} | By: ${comment.user} | Status: ${replyStatus}`
);
if (comment.path) {
let location = `File: ${comment.path}`;
if (comment.line) location += `:${comment.line}`;
lines.push(location);
}
lines.push(`URL: ${comment.url}`);
if (comment.diffHunk) {
lines.push("");
lines.push("--- Code Context ---");
lines.push(comment.diffHunk);
lines.push("--- End Code Context ---");
}
lines.push("");
lines.push(comment.body || "(no body)");
if (comment.replies.length > 0) {
lines.push("");
lines.push(`--- Replies (${comment.replies.length}) ---`);
for (const reply of comment.replies) {
const date = reply.createdAt
? new Date(reply.createdAt)
.toISOString()
.replace("T", " ")
.slice(0, 16)
: "unknown";
lines.push(`[${reply.id}] ${reply.user} (${date}):`);
lines.push(reply.body || "(no body)");
lines.push("");
}
lines.push("--- End Replies ---");
}
return lines.join("\n");
}
function formatOutput(comments, options) {
if (options.json) {
return JSON.stringify(comments, null, 2);
}
if (comments.length === 0) {
const filterDesc =
options.filter === "unresolved"
? "unresolved "
: options.filter === "unanswered"
? "unanswered "
: "";
return `${colors.green}No ${filterDesc}comments found.${colors.reset}`;
}
const header = `${colors.bright}Found ${comments.length} comment${comments.length === 1 ? "" : "s"}${colors.reset}\n`;
const formatter = options.expanded ? formatDetailedComment : formatComment;
const separator = options.expanded ? "\n\n" + "=".repeat(60) + "\n\n" : "\n\n";
const formatted = comments.map((c) => formatter(c)).join(separator);
return `${header}\n${formatted}`;
}
module.exports = {
colors,
truncate,
formatComment,
formatDetailedComment,
formatOutput,
};
/**
* GitHub API utilities for agent-reviews
*
* Handles authentication, proxy support, and repository detection.
* Works in both local and cloud environments (HTTPS_PROXY, etc.).
*/
const { execSync } = require("node:child_process");
const { existsSync, readFileSync } = require("node:fs");
const path = require("node:path");
// ---------------------------------------------------------------------------
// Proxy-aware fetch (for cloud/corporate environments)
// ---------------------------------------------------------------------------
function getProxyFetch() {
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
if (proxyUrl) {
try {
const { ProxyAgent, fetch: undiciFetch } = require("undici");
const agent = new ProxyAgent(proxyUrl);
return (url, options = {}) =>
undiciFetch(url, { ...options, dispatcher: agent });
} catch {
// undici not available, fall back to native fetch
}
}
return globalThis.fetch;
}
// ---------------------------------------------------------------------------
// GitHub token resolution
// ---------------------------------------------------------------------------
/**
* Resolve a GitHub token from (in priority order):
* 1. GITHUB_TOKEN env var
* 2. .env.local files in the repo root
* 3. `gh auth token` CLI
*/
function getGitHubToken() {
if (process.env.GITHUB_TOKEN) {
return process.env.GITHUB_TOKEN;
}
const root = getRepoRoot();
if (root) {
const envFile = path.join(root, ".env.local");
if (existsSync(envFile)) {
const content = readFileSync(envFile, "utf8");
const match = content.match(/^GITHUB_TOKEN=["']?([^"'\n]+)["']?/m);
if (match) {
return match[1];
}
}
}
try {
const token = execSync("gh auth token", {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
if (token) {
return token;
}
} catch {
// gh CLI not available or not authenticated
}
return null;
}
// ---------------------------------------------------------------------------
// Repository info
// ---------------------------------------------------------------------------
function getRepoRoot() {
try {
return execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return null;
}
}
function getRepoInfo() {
try {
const remoteUrl = execSync("git remote get-url origin", {
encoding: "utf8",
}).trim();
const sshMatch = remoteUrl.match(
/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/
);
const httpsMatch = remoteUrl.match(
/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/
);
const proxyMatch = remoteUrl.match(/\/git\/([^/]+)\/([^/]+)$/);
const match = sshMatch || httpsMatch || proxyMatch;
if (match) {
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
}
} catch {
// Ignore errors
}
return null;
}
function getCurrentBranch() {
try {
return execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
}).trim();
} catch {
return null;
}
}
module.exports = {
getProxyFetch,
getGitHubToken,
getRepoInfo,
getRepoRoot,
getCurrentBranch,
};