pr-vibe
Version:
AI-powered PR review responder that vibes with CodeRabbit, DeepSource, and other bots to automate repetitive feedback
1,267 lines (1,083 loc) โข 52.2 kB
JavaScript
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import inquirer from 'inquirer';
import { writeFileSync, readFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import updateNotifier from 'update-notifier';
import { analyzeGitHubPR } from '../lib/github.js';
import { analyzeComment } from '../lib/decision-engine.js';
import { createLLMService } from '../lib/llm-integration.js';
import { createFileModifier } from '../lib/file-modifier.js';
import { createCommentPoster } from '../lib/comment-poster.js';
import { GitHubProvider } from '../lib/providers/github-provider.js';
import { displayThread } from '../lib/ui.js';
import { patternManager } from '../lib/pattern-manager.js';
import { runDemo } from '../lib/demo.js';
import { createReportBuilder } from '../lib/report-builder.js';
import { createReportStorage } from '../lib/report-storage.js';
import { ConversationManager } from '../lib/conversation-manager.js';
// Check for updates
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
const notifier = updateNotifier({
pkg,
updateCheckInterval: 1000 * 60 * 60 * 24 // Check once per day
});
// Show update notification with custom message
if (notifier.update) {
const { current, latest } = notifier.update;
console.log(chalk.yellow(`
๐ต pr-vibe ${latest} available! (you have ${current})
โจ What's new: ${getUpdateHighlight(current, latest)}
Run: ${chalk.cyan('npm update -g pr-vibe')}
`));
}
function getUpdateHighlight(current, latest) {
// Simple version comparison for highlight messages
const [currMajor, currMinor] = current.split('.').map(Number);
const [latestMajor, latestMinor] = latest.split('.').map(Number);
if (latestMajor > currMajor) {
return 'Major update with new features!';
} else if (latestMinor > currMinor) {
// Specific highlights for known versions
if (latest.startsWith('0.4')) {
return 'Comprehensive reporting and pre-merge safety checks';
} else if (latest.startsWith('0.3')) {
return 'Conversation management and security fixes';
} else if (latest.startsWith('0.2')) {
return 'Human review support with --include-human-reviews flag';
}
return 'New features and improvements';
}
return 'Bug fixes and improvements';
}
const program = new Command();
program
.name('pr-vibe')
.description('AI-powered PR review responder that vibes with bots')
.version(pkg.version);
// Demo command - no auth required!
program
.command('demo')
.description('See pr-vibe in action with sample data (no setup required)')
.action(async () => {
await runDemo();
});
program
.command('pr <number>')
.description('Review a pull request')
.option('-r, --repo <repo>', 'repository (owner/name)')
.option('--auto-fix', 'automatically apply safe fixes')
.option('--llm <provider>', 'LLM provider (openai/anthropic/none)', 'none')
.option('--dry-run', 'preview changes without applying')
.option('--no-comments', 'skip posting comments to PR')
.option('--experimental', 'enable experimental features (human review analysis)')
.option('--debug', 'show detailed debug information')
.option('--skip-nits', 'skip nitpick/minor comments from bots')
.option('--nits-only', 'only process nitpick comments')
.option('--create-issues', 'create GitHub issues for deferred items')
.action(async (prNumber, options) => {
console.log(chalk.blue('\n๐ PR Review Assistant - Prototype\n'));
const spinner = ora('Initializing services...').start();
try {
// Initialize services
const provider = new GitHubProvider({ repo: options.repo });
const llm = options.llm !== 'none' ? createLLMService(options.llm) : null;
const fileModifier = createFileModifier(provider, prNumber);
const commentPoster = createCommentPoster(provider);
const conversationManager = new ConversationManager(provider);
const reportBuilder = createReportBuilder().setPRNumber(prNumber);
const reportStorage = createReportStorage();
// 1. Fetch PR and comments
spinner.text = 'Fetching PR comments...';
const { comments, threads, humanComments, humanThreads, debugInfo } = await analyzeGitHubPR(prNumber, options.repo, {
debug: options.debug,
skipNits: options.skipNits,
nitsOnly: options.nitsOnly
});
// Update success message based on nit filtering
let successMsg = `Found ${comments.length} bot comments and ${humanComments.length} human reviews on PR #${prNumber}`;
if (options.skipNits) {
successMsg += ' (nits excluded)';
} else if (options.nitsOnly) {
successMsg += ' (nits only)';
}
spinner.succeed(successMsg);
// Show human reviews if present
if (humanComments.length > 0) {
console.log(chalk.bold('\n๐ฅ Human Reviews:'));
const humanReviewers = [...new Set(humanComments.map(c => c.user.login))];
console.log(` Reviewers: ${humanReviewers.join(', ')}`);
console.log(` Comments: ${humanComments.length}`);
// TODO: Show human review summary
console.log(chalk.gray('\n (Human review analysis coming soon...)'));
}
if (comments.length === 0) {
// Show detailed information about what was found
if (debugInfo) {
console.log(chalk.yellow('\n๐ Comment Detection Summary:'));
console.log(chalk.gray(` โข Issue comments: ${debugInfo.issueComments}`));
console.log(chalk.gray(` โข Review comments: ${debugInfo.reviewComments}`));
console.log(chalk.gray(` โข PR reviews: ${debugInfo.prReviews}`));
if (debugInfo.prReviews > 0) {
console.log(chalk.gray('\n PR Review Details:'));
debugInfo.reviewDetails.forEach(review => {
console.log(chalk.gray(` - ${review.author}: ${review.state} (${review.commentsCount} comments)`));
});
}
if (debugInfo.skippedComments && debugInfo.skippedComments.length > 0) {
console.log(chalk.gray('\n Skipped Comments:'));
const nitSkips = debugInfo.skippedComments.filter(s => s.isNit);
const otherSkips = debugInfo.skippedComments.filter(s => !s.isNit);
if (nitSkips.length > 0) {
console.log(chalk.gray(` Nits skipped: ${nitSkips.length}`));
}
otherSkips.forEach(skip => {
console.log(chalk.gray(` - ${skip.username}: ${skip.reason} (${(skip.confidence * 100).toFixed(0)}% confidence)`));
});
}
}
console.log(chalk.yellow('\nโ ๏ธ No actionable bot comments found.'));
console.log(chalk.gray('This could mean:'));
console.log(chalk.gray(' โข Bots haven\'t commented yet (use `pr-vibe watch` to wait)'));
console.log(chalk.gray(' โข All bot comments are summaries or non-actionable'));
console.log(chalk.gray(' โข The PR has no issues that bots can detect'));
console.log(chalk.gray('\nUse --debug flag for more details'));
return;
}
// 2. Show bot summary
console.log(chalk.bold('\n๐ค Bot Review Summary:'));
const reviewers = [...new Set(comments.map(c => c.user.login))];
console.log(` Bot reviewers: ${reviewers.join(', ')}`);
console.log(` Bot comments: ${comments.length}`);
console.log(` Bot threads: ${Object.keys(threads).length}\n`);
// Track bot comments in report
comments.forEach(comment => {
const botName = comment.user?.login || comment.author?.login || 'unknown';
reportBuilder.addBotComment(botName, comment);
});
const decisions = [];
const projectContext = {
projectName: options.repo,
validPatterns: [
'console.log in Lambda functions for CloudWatch',
'any types for complex webhook payloads'
]
};
// 3. Process each thread
for (const [threadId, threadComments] of Object.entries(threads)) {
console.log(chalk.gray('โ'.repeat(50)));
// Display thread
displayThread(threadComments);
// Get the main comment (first in thread)
const mainComment = threadComments[0];
// Analyze with decision engine + LLM
spinner.start('Analyzing comment...');
let analysis;
if (llm) {
try {
analysis = await llm.analyzeComment(mainComment, projectContext);
} catch (error) {
console.warn(chalk.yellow(`LLM failed, using rule-based analysis: ${error.message}`));
analysis = analyzeComment(mainComment);
}
} else {
analysis = analyzeComment(mainComment);
}
spinner.stop();
// Show AI analysis
console.log(chalk.bold('\n๐ค AI Analysis:'));
console.log(` Decision: ${chalk.cyan(analysis.action)}`);
console.log(` Reason: ${analysis.reason}`);
if (analysis.confidence) {
console.log(` Confidence: ${(analysis.confidence * 100).toFixed(0)}%`);
}
if (analysis.suggestedFix) {
console.log(chalk.bold('\n๐ Suggested Fix:'));
console.log(chalk.green(analysis.suggestedFix));
}
// Interactive mode
let userAction = 'skip';
if (options.autoFix && analysis.action === 'AUTO_FIX' && analysis.suggestedFix && mainComment.path) {
// Auto-fix mode - apply fixes automatically
userAction = 'fix';
} else if (!options.autoFix) {
// Manual mode - ask user
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Apply fix', value: 'fix', disabled: !analysis.suggestedFix || !mainComment.path },
{ name: 'Post reply', value: 'reply' },
{ name: 'Skip', value: 'skip' },
{ name: 'Mark for backlog', value: 'backlog' }
]
}
]);
userAction = action;
}
// Handle user action
if (userAction === 'fix' && analysis.suggestedFix && mainComment.path) {
try {
await fileModifier.applyFix(mainComment, analysis.suggestedFix);
console.log(chalk.green(` โ
Fix queued for ${mainComment.path}`));
} catch (error) {
console.error(chalk.red(` โ Failed to queue fix: ${error.message}`));
}
} else if (userAction === 'reply' && !options.noComments) {
const replyText = llm
? await llm.generateReply(mainComment, analysis, projectContext)
: `${analysis.action}: ${analysis.reason}`;
if (!options.dryRun) {
await commentPoster.replyToComment(prNumber, mainComment, replyText, {
reaction: analysis.action,
action: analysis.action
});
console.log(chalk.green(' โ
Reply posted'));
} else {
console.log(chalk.gray(` Would post: "${replyText}"`));
}
} else if (userAction === 'backlog') {
analysis.action = 'DEFER';
// Create GitHub issue if requested
if (options.createIssues && !options.dryRun) {
const botName = mainComment.user?.login || mainComment.author?.login || 'unknown-bot';
const truncatedBody = mainComment.body.length > 50
? mainComment.body.substring(0, 50) + '...'
: mainComment.body;
const issueTitle = `[Bot: ${botName}] ${truncatedBody}`;
const issueBody = `## Bot Feedback from PR #${prNumber}
**Bot:** ${botName}
**PR:** #${prNumber}
**Comment:** ${provider.repo}#${prNumber} (comment)
### Original Comment
${mainComment.body}
${mainComment.path ? `**File:** \`${mainComment.path}\`${mainComment.line ? ` (line ${mainComment.line})` : ''}` : ''}
### Context
This feedback was deferred from PR review for future consideration.
---
*Created by [pr-vibe](https://github.com/stroupaloop/pr-vibe)*`;
try {
const issueResult = await provider.createIssue({
title: issueTitle,
body: issueBody,
labels: ['bot-feedback', 'pr-vibe', botName.toLowerCase().replace(/\[bot\]/, '')]
});
if (issueResult.success) {
console.log(chalk.green(` ๐ Created issue #${issueResult.number}`));
// Store issue URL in the decision
analysis.issueUrl = issueResult.url;
analysis.issueNumber = issueResult.number;
} else {
console.log(chalk.yellow(` โ ๏ธ Failed to create issue: ${issueResult.error}`));
}
} catch (error) {
console.log(chalk.yellow(` โ ๏ธ Failed to create issue: ${error.message}`));
}
}
}
decisions.push({ comment: mainComment, decision: analysis, userAction });
// Track in report builder
reportBuilder.addDecision(mainComment, analysis, userAction);
console.log(chalk.dim(` โ Action: ${userAction}\n`));
}
// 4. Process human reviews (experimental)
if (options.experimental && humanComments.length > 0) {
console.log(chalk.bold('\n๐งช EXPERIMENTAL: Processing Human Reviews\n'));
console.log(chalk.gray('โ'.repeat(50)));
for (const [threadId, threadComments] of Object.entries(humanThreads)) {
const mainComment = threadComments[0];
const author = mainComment.user?.login || 'Unknown';
console.log(chalk.bold(`\n๐ค ${author} commented:`));
console.log(chalk.white(mainComment.body));
if (mainComment.path) {
console.log(chalk.dim(` ๐ ${mainComment.path}${mainComment.line ? `:${mainComment.line}` : ''}`));
}
// Ask what to do with human feedback
console.log(chalk.yellow('\n๐ค pr-vibe is learning from human reviews...'));
console.log(chalk.gray(' This feedback will help pr-vibe understand your team\'s standards.'));
// For now, just acknowledge - pattern learning comes next
const { humanAction } = await inquirer.prompt([
{
type: 'list',
name: 'humanAction',
message: 'How should pr-vibe interpret this feedback?',
choices: [
{ name: 'Learn pattern (will remember for future)', value: 'learn' },
{ name: 'Just acknowledge (one-time feedback)', value: 'acknowledge' },
{ name: 'Skip for now', value: 'skip' }
]
}
]);
if (humanAction === 'learn') {
// Learn from this human review
const learningResult = await patternManager.learnFromHumanReview(
mainComment,
'human_feedback',
{ pr: prNumber, repo: options.repo }
);
console.log(chalk.green(` โ
Pattern learned from ${learningResult.reviewer}!`));
console.log(chalk.dim(` Confidence: ${(learningResult.confidence * 100).toFixed(0)}%`));
console.log(chalk.dim(` Patterns updated: ${learningResult.patternsUpdated}`));
// Check if this matches existing patterns
const match = patternManager.matchHumanPattern(mainComment);
if (match.matched) {
console.log(chalk.cyan(` ๐ This matches a pattern learned from: ${match.learnedFrom.join(', ')}`));
}
} else if (humanAction === 'acknowledge') {
console.log(chalk.blue(' ๐ Acknowledged'));
}
}
console.log(chalk.gray('\nโ'.repeat(50)));
}
// 5. Apply all fixes
const fixesToApply = decisions.filter(d => d.userAction === 'fix');
if (fixesToApply.length > 0) {
if (!options.dryRun) {
const applySpinner = ora('Applying fixes and creating commit...').start();
const result = await fileModifier.createCommit(
`Apply PR review fixes\n\nAddressed ${fixesToApply.length} review comments.`
);
if (result.success) {
applySpinner.succeed(`Applied ${fixesToApply.length} fixes to branch ${result.branch}`);
} else {
applySpinner.fail(`Failed to apply fixes: ${result.error}`);
}
} else {
console.log(chalk.bold('\n๐ Dry Run - Changes that would be applied:'));
const summary = fileModifier.getChangesSummary();
summary.forEach(change => {
console.log(chalk.gray(` - ${change.path}: ${change.description}`));
});
}
}
// 5. Post summary comment
if (!options.noComments && !options.dryRun) {
const summarySpinner = ora('Posting summary...').start();
try {
await commentPoster.postSummary(prNumber, decisions, fileModifier.getChangesSummary());
summarySpinner.succeed('Summary posted to PR');
} catch (error) {
summarySpinner.fail(`Failed to post summary: ${error.message}`);
}
}
// 6. Final summary
console.log(chalk.green('\nโ
Review complete!\n'));
const stats = {
total: decisions.length,
fixed: decisions.filter(d => d.userAction === 'fix').length,
replied: decisions.filter(d => d.userAction === 'reply').length,
skipped: decisions.filter(d => d.userAction === 'skip').length,
backlogged: decisions.filter(d => d.userAction === 'backlog').length
};
console.log(chalk.bold('๐ Final Statistics:'));
console.log(` Total comments processed: ${stats.total}`);
console.log(` Fixes applied: ${stats.fixed}`);
console.log(` Replies posted: ${stats.replied}`);
console.log(` Skipped: ${stats.skipped}`);
console.log(` Added to backlog: ${stats.backlogged}`);
// Show priority breakdown
const byPriority = reportBuilder.report.summary.byPriority || {};
if (byPriority['must-fix'] > 0 || byPriority.suggestion > 0 || byPriority.nitpick > 0) {
console.log(`\n By Priority:`);
if (byPriority['must-fix'] > 0) console.log(` Must Fix: ${byPriority['must-fix']}`);
if (byPriority.suggestion > 0) console.log(` Suggestions: ${byPriority.suggestion}`);
if (byPriority.nitpick > 0) console.log(` Nitpicks: ${byPriority.nitpick}`);
}
const nonCriticalCount = (reportBuilder.report.detailedActions.nonCritical || []).length;
if (options.showAll && nonCriticalCount > 0) {
console.log(`\n ${chalk.yellow(`โน๏ธ Showing ${nonCriticalCount} non-critical suggestions`)}`);
} else if (!options.showAll && nonCriticalCount > 0) {
console.log(`\n ${chalk.gray(`(${nonCriticalCount} non-critical suggestions hidden - use --show-all to view)`)}`);
}
// Generate and save report
reportBuilder.addConversationSummary(conversationManager);
reportBuilder.finalize(fileModifier.getChangesSummary());
const reportFiles = reportStorage.saveReport(prNumber, reportBuilder);
console.log(chalk.green(`\n๐ Report saved to ${reportFiles.markdown}`));
// Show preview of report
console.log(chalk.gray('\nReport preview:'));
const reportPreview = reportBuilder.toMarkdown().split('\n').slice(0, 15).join('\n');
console.log(chalk.gray(reportPreview));
console.log(chalk.gray('...\n'));
console.log(chalk.cyan(`View full report: pr-vibe report ${prNumber}`));
} catch (error) {
spinner.fail('Error during review');
console.error(chalk.red(error.message));
console.error(chalk.gray(error.stack));
process.exit(1);
}
});
program
.command('watch <number>')
.description('Watch a PR for bot comments and auto-process when they appear')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('--timeout <minutes>', 'max time to wait (default: 10)', '10')
.option('--auto-fix', 'automatically apply safe fixes')
.option('--llm <provider>', 'LLM provider (openai/anthropic/none)', 'none')
.action(async (prNumber, options) => {
console.log(chalk.blue('\n๐ PR Watch Mode - Waiting for bot reviews...\n'));
const timeout = parseInt(options.timeout) * 60 * 1000; // Convert to ms
const startTime = Date.now();
// Polling intervals
const intervals = [
{ duration: 30000, interval: 5000 }, // First 30s: check every 5s
{ duration: 120000, interval: 15000 }, // Next 2min: check every 15s
{ duration: Infinity, interval: 30000 } // After that: check every 30s
];
const foundBots = new Set();
const processedComments = new Set();
const spinner = ora('Checking for bot activity...').start();
while (Date.now() - startTime < timeout) {
const elapsed = Date.now() - startTime;
// Determine current interval
let currentInterval = 30000;
for (const { duration, interval } of intervals) {
if (elapsed < duration) {
currentInterval = interval;
break;
}
}
try {
// Fetch PR data with debug flag
const { botComments } = await analyzeGitHubPR(prNumber, options.repo, { debug: false });
// Track new bots
botComments.forEach(comment => {
const bot = comment.user?.login || comment.author?.login || 'unknown';
if (!foundBots.has(bot)) {
foundBots.add(bot);
spinner.succeed(`${bot} posted ${comment.type === 'pr_review' ? 'review' : 'comment'}`);
spinner.start('Waiting for more bots...');
}
});
// Check for new comments since last check
const newComments = botComments.filter(c => {
const id = c.id || `${c.user?.login}-${c.created_at}`;
return !processedComments.has(id);
});
if (newComments.length > 0) {
spinner.stop();
console.log(chalk.green(`\nโ Found ${newComments.length} new bot comments from ${foundBots.size} bots\n`));
// Show what we found
const botSummary = {};
newComments.forEach(comment => {
const bot = comment.user?.login || comment.author?.login || 'unknown';
const type = comment.type || 'comment';
if (!botSummary[bot]) botSummary[bot] = { reviews: 0, comments: 0 };
if (type === 'pr_review') {
botSummary[bot].reviews++;
} else {
botSummary[bot].comments++;
}
});
console.log(chalk.bold('๐ Bot Activity Summary:'));
Object.entries(botSummary).forEach(([bot, stats]) => {
const parts = [];
if (stats.reviews > 0) parts.push(`${stats.reviews} review${stats.reviews > 1 ? 's' : ''}`);
if (stats.comments > 0) parts.push(`${stats.comments} comment${stats.comments > 1 ? 's' : ''}`);
console.log(` โข ${bot}: ${parts.join(', ')}`);
});
// Ask user if they want to process now or wait for more
const { action } = await inquirer.prompt([{
type: 'list',
name: 'action',
message: '\nWhat would you like to do?',
choices: [
{ name: 'Process these comments now', value: 'process' },
{ name: 'Wait for more bots (continue watching)', value: 'wait' },
{ name: 'Exit watch mode', value: 'exit' }
]
}]);
if (action === 'process') {
console.log(chalk.blue('\n๐ต Processing bot comments...\n'));
// Run the normal pr command
await program.parseAsync(['node', 'pr-vibe', 'pr', prNumber,
'-r', options.repo,
...(options.autoFix ? ['--auto-fix'] : []),
...(options.llm !== 'none' ? ['--llm', options.llm] : [])
], { from: 'user' });
break;
} else if (action === 'exit') {
console.log(chalk.gray('\nExiting watch mode...'));
break;
} else {
// Mark these as processed so we don't alert again
newComments.forEach(c => {
const id = c.id || `${c.user?.login}-${c.created_at}`;
processedComments.add(id);
});
spinner.start('Continuing to watch for more bots...');
}
}
// Update spinner with elapsed time
const elapsedMin = Math.floor(elapsed / 60000);
const elapsedSec = Math.floor((elapsed % 60000) / 1000);
spinner.text = `Waiting for bot reviews... (${elapsedMin}:${elapsedSec.toString().padStart(2, '0')} elapsed, checking every ${currentInterval/1000}s)`;
} catch (error) {
spinner.fail(`Error: ${error.message}`);
console.error(chalk.red('Failed to check PR. Retrying...'));
spinner.start('Retrying...');
}
// Wait for next check
await new Promise(resolve => setTimeout(resolve, currentInterval));
}
if (Date.now() - startTime >= timeout) {
spinner.fail(`Timeout reached (${options.timeout} minutes)`);
console.log(chalk.yellow('\nโฐ Watch timeout reached.'));
console.log(chalk.gray(`Found ${foundBots.size} bots during watch period.`));
if (foundBots.size > 0) {
console.log(chalk.cyan(`\nRun 'pr-vibe pr ${prNumber}' to process the comments.`));
}
}
});
program
.command('test')
.description('Run decision engine tests')
.action(() => {
import('./test-runner.js');
});
program
.command('export <prNumber>')
.description('Export PR data for external analysis (Claude Code mode)')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('-o, --output <file>', 'output file', 'pr-review.json')
.action(async (prNumber, options) => {
const spinner = ora('Exporting PR data...').start();
try {
const { pr, comments, threads } = await analyzeGitHubPR(prNumber, options.repo);
// Analyze each comment with pattern learning
const analyzedComments = comments.map(comment => {
// First check patterns
const patternMatch = patternManager.findPattern(comment, {
path: comment.path,
repo: options.repo
});
let analysis;
if (patternMatch && patternMatch.confidence > 0.85) {
// Use pattern-based decision
analysis = {
action: patternMatch.action,
reason: patternMatch.pattern.reason || patternMatch.pattern.description,
confidence: patternMatch.confidence,
source: patternMatch.source,
suggestedReply: patternMatch.reply
};
} else {
// Fall back to rule-based analysis
analysis = analyzeComment(comment);
analysis.source = 'rules';
}
return {
id: comment.id,
author: comment.user?.login || comment.author?.login,
body: comment.body,
path: comment.path,
line: comment.line,
created_at: comment.created_at || comment.createdAt,
analysis: analysis,
thread: Object.entries(threads).find(([id, thread]) =>
thread.some(c => c.id === comment.id)
)?.[0]
};
});
const exportData = {
pr: {
number: prNumber,
title: pr.title,
author: pr.author?.login,
state: pr.state
},
comments: analyzedComments,
stats: {
total: comments.length,
byAuthor: analyzedComments.reduce((acc, c) => {
acc[c.author] = (acc[c.author] || 0) + 1;
return acc;
}, {}),
byAction: analyzedComments.reduce((acc, c) => {
acc[c.analysis.action] = (acc[c.analysis.action] || 0) + 1;
return acc;
}, {})
},
metadata: {
exported_at: new Date().toISOString(),
tool_version: '0.0.1',
repo: options.repo
}
};
writeFileSync(options.output, JSON.stringify(exportData, null, 2));
spinner.succeed(`Exported ${comments.length} comments to ${options.output}`);
// Also output summary to console for Claude Code
console.log(chalk.bold('\n๐ Summary for Claude Code:'));
console.log(`Total comments: ${comments.length}`);
console.log(`Suggested auto-fixes: ${exportData.stats.byAction.AUTO_FIX || 0}`);
console.log(`Valid patterns to reject: ${exportData.stats.byAction.REJECT || 0}`);
console.log(`Needs discussion: ${exportData.stats.byAction.DISCUSS || 0}`);
} catch (error) {
spinner.fail(`Export failed: ${error.message}`);
process.exit(1);
}
});
program
.command('apply <prNumber>')
.description('Apply decisions from Claude Code')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('-d, --decisions <file>', 'decisions file', 'decisions.json')
.option('--dry-run', 'preview changes without applying')
.action(async (prNumber, options) => {
console.log(chalk.blue('\n๐ค Applying Claude Code decisions...\n'));
try {
// Read decisions file
const decisions = JSON.parse(readFileSync(options.decisions, 'utf-8'));
// Initialize services
const provider = new GitHubProvider({ repo: options.repo });
const fileModifier = createFileModifier(provider, prNumber);
const commentPoster = createCommentPoster(provider);
// Apply each decision
for (const decision of decisions) {
console.log(chalk.gray(`Processing: ${decision.commentId}`));
// Handle different action types
if (decision.action === 'FIX' && decision.fix) {
if (!options.dryRun) {
await fileModifier.applyFix(
{ path: decision.path, line: decision.line, body: decision.fix },
decision.fix
);
}
console.log(chalk.green(` โ
Fix ${options.dryRun ? 'would be' : ''} applied to ${decision.path}`));
} else if (decision.action === 'REJECT') {
console.log(chalk.yellow(' โ Pattern rejected - valid in this codebase'));
} else if (decision.action === 'DEFER') {
console.log(chalk.blue(' ๐ Deferred to backlog'));
} else if (decision.action === 'ESCALATE') {
console.log(chalk.red(' โ ๏ธ Escalated for human review'));
}
if (decision.reply && !options.dryRun) {
// Post reply
await commentPoster.replyToComment(prNumber,
{ id: decision.commentId },
decision.reply,
{ reaction: decision.reaction }
);
console.log(chalk.blue(' ๐ฌ Reply posted'));
} else if (decision.reply && options.dryRun) {
console.log(chalk.gray(` ๐ฌ Would post reply: "${decision.reply.substring(0, 50)}..."`));
}
// Record pattern learning
if (decision.learned) {
console.log(chalk.magenta(` ๐ง Pattern learned: ${decision.pattern || 'new pattern'}`));
}
}
// Commit changes if any
if (fileModifier.changes.length > 0 && !options.dryRun) {
const result = await fileModifier.createCommit(
'Apply Claude Code review decisions'
);
console.log(chalk.green(`\nโ
Committed ${fileModifier.changes.length} changes`));
}
} catch (error) {
console.error(chalk.red(`Failed: ${error.message}`));
process.exit(1);
}
});
program
.command('init-patterns')
.description('Initialize patterns file for this repository')
.action(() => {
const template = `# PR Bot Response Patterns
version: 1.0
project: ${process.cwd().split('/').pop()}
created_at: ${new Date().toISOString()}
# Valid patterns to reject with explanation
valid_patterns:
- id: console-log-lambda
pattern: "console.log"
condition:
files: ["**/lambda/**", "**/*-handler.js"]
reason: "We use console.log for CloudWatch logging"
confidence: 1.0
auto_reply: |
Thanks for the feedback. We use console.log for CloudWatch
logging in our Lambda functions.
# Auto-fix rules
auto_fixes:
- id: api-key-to-env
trigger: "hardcoded.+(api|key|token)"
severity: CRITICAL
fix_template: |
const {{CONST_NAME}} = process.env.{{ENV_NAME}};
# When to escalate
escalation_rules:
- id: architecture
pattern: "refactor|architecture"
notify: ["@stroupaloop"]
`;
try {
const dir = join(process.cwd(), '.pr-bot');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'patterns.yml'), template);
console.log(chalk.green('โ
Created .pr-bot/patterns.yml'));
console.log(chalk.gray('Edit this file to add your project-specific patterns.'));
} catch (error) {
console.error(chalk.red(`Failed: ${error.message}`));
}
});
program
.command('changelog')
.description('Show recent changes and updates')
.option('--full', 'show full changelog')
.action(async (options) => {
console.log(chalk.bold('\n๐ต pr-vibe Changelog\n'));
if (options.full) {
// Show full changelog
try {
const changelogPath = join(__dirname, '../CHANGELOG.md');
const changelog = readFileSync(changelogPath, 'utf-8');
console.log(changelog);
} catch (error) {
console.log(chalk.yellow('Full changelog not available in this version.'));
}
} else {
// Show recent highlights
console.log(chalk.cyan('## Version 0.8.0 (Current)'));
console.log(' โจ New features and improvements');
console.log(' ๐ Bug fixes and enhancements\n');
console.log(chalk.cyan('## Version 0.3.x'));
console.log(' ๐ค Full conversation management with bots');
console.log(' ๐ Security fix for shell injection vulnerability');
console.log(' ๐ GitHub comment length handling');
console.log(' ๐ฏ Zero-setup demo experience\n');
console.log(chalk.cyan('## Version 0.2.0'));
console.log(' โจ Human review support with --include-human-reviews flag');
console.log(' ๐ Automatic update notifications');
console.log(' ๐ Case-insensitive bot detection');
console.log(' ๐ Pattern learning from team feedback\n');
console.log(chalk.gray('Run with --full to see complete changelog'));
}
});
program
.command('check <prNumber>')
.description('Check if PR is ready to merge (all bot comments resolved)')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('-v, --verbose', 'show detailed output')
.action(async (prNumber, options) => {
const spinner = ora('Checking PR merge readiness...').start();
try {
const provider = new GitHubProvider({ repo: options.repo });
const reportStorage = createReportStorage();
// Check if we have a report for this PR
const reportStatus = reportStorage.checkPRStatus(prNumber);
// Get current PR comments
spinner.text = 'Fetching current PR comments...';
const { pr, comments, threads } = await analyzeGitHubPR(prNumber, options.repo);
// Filter for bot comments
const botComments = comments.filter(c =>
c.user?.login?.includes('[bot]') || c.author?.login?.includes('[bot]')
);
// Group by bot
const botStats = {};
botComments.forEach(comment => {
const botName = comment.user?.login || comment.author?.login || 'unknown';
if (!botStats[botName]) {
botStats[botName] = { total: 0, resolved: 0 };
}
botStats[botName].total++;
});
// Check resolved status from report if available
let resolvedCount = 0;
let unresolvedComments = [];
if (reportStatus.hasReports && reportStatus.summary) {
// Use report data
Object.entries(reportStatus.summary.bots).forEach(([bot, stats]) => {
if (botStats[bot]) {
botStats[bot].resolved = stats.processed;
resolvedCount += stats.processed;
}
});
// Find unresolved comments
unresolvedComments = botComments.filter(comment => {
const botName = comment.user?.login || comment.author?.login;
const botData = reportStatus.summary.bots[botName];
return !botData || botData.processed < botData.total;
});
}
spinner.stop();
// Display results
console.log(chalk.bold('\n๐ PR #' + prNumber + ' Merge Readiness Check\n'));
console.log(chalk.bold('โ
Bot Comment Status:'));
let allResolved = true;
Object.entries(botStats).forEach(([bot, stats]) => {
const resolved = stats.resolved || 0;
const icon = resolved === stats.total ? 'โ' : 'โ';
const color = resolved === stats.total ? chalk.green : chalk.red;
console.log(color(` - ${bot}: ${resolved}/${stats.total} resolved ${icon}`));
if (resolved < stats.total) allResolved = false;
});
console.log(` - Total: ${resolvedCount}/${botComments.length} (${Math.round(resolvedCount/botComments.length*100)}%)`);
if (reportStatus.hasReports) {
const timeSince = Date.now() - new Date(reportStatus.lastReport).getTime();
const minutes = Math.round(timeSince / 60000);
console.log(chalk.gray(`\nโฐ Last Checked: ${minutes} minutes ago`));
console.log(chalk.gray(`๐ Last pr-vibe run: ${reportStatus.lastReportId}`));
}
// Check for new comments since last run
let hasNewComments = false;
if (reportStatus.hasReports && reportStatus.lastReport) {
const newComments = botComments.filter(c =>
new Date(c.created_at || c.createdAt) > new Date(reportStatus.lastReport)
);
if (newComments.length > 0) {
hasNewComments = true;
console.log(chalk.yellow('\nโ ๏ธ New Activity:'));
console.log(chalk.yellow(` - ${newComments.length} new bot comments since last run`));
}
}
// Final status
console.log('');
if (allResolved && !hasNewComments) {
console.log(chalk.green.bold('โ
PR is ready to merge!\n'));
process.exit(0);
} else {
if (unresolvedComments.length > 0 && options.verbose) {
console.log(chalk.red.bold('โ Unresolved Comments:\n'));
unresolvedComments.slice(0, 5).forEach(comment => {
const bot = comment.user?.login || comment.author?.login;
console.log(chalk.yellow(` ${bot}:`));
console.log(chalk.gray(` "${comment.body.substring(0, 100)}..."`));
});
if (unresolvedComments.length > 5) {
console.log(chalk.gray(` ... and ${unresolvedComments.length - 5} more\n`));
}
}
console.log(chalk.red.bold('โ PR is not ready to merge'));
if (hasNewComments) {
console.log(chalk.yellow(`Run: pr-vibe pr ${prNumber}`));
}
console.log('');
process.exit(1);
}
} catch (error) {
spinner.fail('Error checking PR status');
console.error(chalk.red(error.message));
process.exit(1);
}
});
program
.command('status <prNumber>')
.description('Post or check GitHub status check for PR')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('--post', 'post status check to GitHub')
.action(async (prNumber, options) => {
const spinner = ora('Checking PR status...').start();
try {
const provider = new GitHubProvider({ repo: options.repo });
const reportStorage = createReportStorage();
const reportStatus = reportStorage.checkPRStatus(prNumber);
// Get current bot comment status
const { pr, comments } = await analyzeGitHubPR(prNumber, options.repo);
const botComments = comments.filter(c =>
c.user?.login?.includes('[bot]') || c.author?.login?.includes('[bot]')
);
let status = 'pending';
let description = 'Checking bot comments...';
if (reportStatus.hasReports && reportStatus.isReadyToMerge) {
status = 'success';
description = `All bot comments resolved (${botComments.length}/${botComments.length})`;
} else if (reportStatus.hasReports) {
status = 'failure';
const unresolved = botComments.length - (reportStatus.summary?.actions?.total || 0);
description = `${unresolved} bot comments need attention`;
}
spinner.stop();
if (options.post) {
console.log(chalk.bold('๐ Posting status check to PR #' + prNumber + '...'));
// Get the latest commit SHA
const commitData = execSync(
`gh pr view ${prNumber} --repo ${options.repo} --json commits --jq '.commits[-1].oid'`,
{ encoding: 'utf-8' }
).trim();
// Post status check
execSync(
`gh api repos/${options.repo}/statuses/${commitData} ` +
`-f state="${status}" ` +
`-f description="${description}" ` +
'-f context="pr-vibe"',
{ stdio: 'pipe' }
);
console.log(chalk.green(`โ
Status posted: "${description}"`));
} else {
console.log(chalk.bold('\n๐ PR #' + prNumber + ' Status\n'));
const statusIcon = status === 'success' ? 'โ
' : status === 'failure' ? 'โ' : 'โณ';
const statusColor = status === 'success' ? chalk.green : status === 'failure' ? chalk.red : chalk.yellow;
console.log(statusColor(`${statusIcon} ${description}`));
if (reportStatus.hasReports) {
console.log(chalk.gray(`\nLast checked: ${new Date(reportStatus.lastReport).toLocaleString()}`));
console.log(chalk.gray(`Reports available: ${reportStatus.reportCount}`));
}
console.log(chalk.gray('\nTo post this status to GitHub, run with --post flag'));
}
} catch (error) {
spinner.fail('Error checking status');
console.error(chalk.red(error.message));
process.exit(1);
}
});
program
.command('report <prNumber>')
.description('View saved pr-vibe reports for a PR')
.option('-r, --repo <repo>', 'repository (owner/name)', 'stroupaloop/woodhouse-modern')
.option('-l, --list', 'list all available reports')
.option('-t, --timestamp <timestamp>', 'view specific report by timestamp')
.option('-j, --json', 'output in JSON format')
.action(async (prNumber, options) => {
const reportStorage = createReportStorage();
if (options.list) {
const reports = reportStorage.listReportsForPR(prNumber);
if (reports.length === 0) {
console.log(chalk.yellow(`\nNo reports found for PR #${prNumber}\n`));
return;
}
console.log(chalk.bold(`\n๐ Reports for PR #${prNumber}:\n`));
reports.forEach((report, idx) => {
const isLatest = idx === 0;
const icon = isLatest ? 'โญ' : '๐';
console.log(`${icon} ${report.timestamp}`);
console.log(chalk.gray(` Created: ${report.created.toLocaleString()}`));
console.log(chalk.gray(` Size: ${Math.round(report.size / 1024)}KB`));
if (isLatest) console.log(chalk.green(' (latest)'));
console.log('');
});
console.log(chalk.gray('Use --timestamp to view a specific report\n'));
return;
}
const report = reportStorage.getReport(prNumber, options.timestamp);
if (!report) {
console.log(chalk.red(`\nNo report found for PR #${prNumber}${options.timestamp ? ' at ' + options.timestamp : ''}\n`));
console.log(chalk.gray('Run `pr-vibe pr ' + prNumber + '` to generate a report\n'));
process.exit(1);
}
if (options.json) {
const jsonPath = report.path.replace('.md', '.json');
try {
const jsonContent = readFileSync(jsonPath, 'utf-8');
console.log(jsonContent);
} catch (error) {
console.error(chalk.red('JSON report not found'));
process.exit(1);
}
} else {
console.log(report.content);
if (!options.timestamp) {
console.log(chalk.gray('\n---'));
console.log(chalk.gray('This is the latest report. Use --list to see all available reports.'));
}
}
});
program
.command('cleanup')
.description('Clean up old pr-vibe reports')
.option('--reports', 'clean up old reports (default)')
.option('--all', 'clean up all pr-vibe data')
.option('--dry-run', 'show what would be deleted without deleting')
.action(async (options) => {
const reportStorage = createReportStorage();
console.log(chalk.bold('\n๐งน Running pr-vibe cleanup...\n'));
if (options.dryRun) {
console.log(chalk.yellow('DRY RUN - no files will be deleted\n'));
}
// For now, just run the report cleanup
reportStorage.runCleanup();
console.log(chalk.green('\nโ
Cleanup complete!\n'));
});
program
.command('update')
.description('Check for updates')
.action(() => {
const update = notifier.update;
if (update) {
console.log(chalk.yellow(`\n๐ต Update available: ${update.current} โ ${update.latest}`));
console.log(chalk.cyan('\nRun: npm update -g pr-vibe\n'));
} else {
console.log(chalk.green('\nโ
You are running the latest version of pr-vibe!\n'));
}
// Force check for updates
notifier.notify({ defer: false });
});
program
.command('issues <number>')
.description('Create GitHub issues for deferred items from a saved report')
.option('-r, --repo <repo>', 'repository (owner/name)')
.option('--dry-run', 'preview issues without creating them')
.action(async (prNumber, options) => {
console.log(chalk.blue('\n๐ Creating Issues from Deferred Items\n'));
const spinner = ora('Loading report...').start();
try {
// Initialize services
const provider = new GitHubProvider({ repo: options.repo });
const reportStorage = createReportStorage();
// Load the latest report
const report = reportStorage.loadReport(prNumber);
if (!report) {
spinner.fail('No report found for PR #' + prNumber);
console.log(chalk.gray('\nRun `pr-vibe pr ' + prNumber + '` first to generate a report.'));
process.exit(1);
}
spinner.succeed('Report loaded');
// Find deferred items without issues
const deferredItems = report.detailedActions?.deferred || [];
const itemsNeedingIssues = deferredItems.filter(item => !item.issue);
if (itemsNeedingIssues.length === 0) {
console.log(chalk.green('\nโ
All deferred items already have issues created!\n'));
if (deferredItems.length > 0) {
console.log(chalk.bold('Existing Issues:'));
deferredItems.filter(item => item.issue).forEach(item => {
console.log(` - [#${item.issue.number}](${item.issue.url}) - ${item.bot}`);
});
}
return;
}
console.log(chalk.bold(`\nFound ${itemsNeedingIssues.length} deferred items without issues:\n`));
// Show what will be created
itemsNeedingIssues.forEach((item, idx) => {
console.log(chalk.cyan(`${idx + 1}. ${item.bot}:`));
console.log(` ${item.comment.body.substring(0, 100)}...`);
if (item.comment.path) {
console.log(chalk.dim(` ๐ ${item.comment.path}${item.comment.line ? `:${item.comment.line}` : ''}`));
}
console.log();
});
if (options.dryRun) {
console.log(chalk.yellow('\n๐ Dry run - no issues will be created\n'));
return;
}
// Confirm creation
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Create ${itemsNeedingIssues.length} GitHub issues?`,
default: true
}]);
if (!confirm) {
console.log(chalk.gray('\nCancelled.'));
return;
}
// Create issues
const createdIssues = [];
const createSpinner = ora('Creating issues...').start();
for (const item of itemsNeedingIssues) {
const botName = item.bot;
const truncatedBody = item.comment.fullBody?.substring(0, 50) ||
item.comment.body.substring(0, 50);
const issueTitle = `[Bot: ${botName}] ${truncatedBody}...`;
const issueBody = `## Bot Feedback from PR #${prNumber}
**Bot:** ${botName}
**PR:** #${prNumber}
**Comment:** ${provider.repo}#${prNumber} (comment)
### Original Comment
${item.com