UNPKG

pr-vibe

Version:

AI-powered PR review responder that vibes with CodeRabbit, DeepSource, and other bots to automate repetitive feedback

1,220 lines (1,050 loc) โ€ข 67.2 kB
#!/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, PRIORITY_LEVELS } 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'; import { botDetector } from '../lib/bot-detector.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') .option('--show-all', 'show all suggestions including non-critical ones') .option('--critical-only', 'only show must-fix issues (security, bugs, breaking changes)') .option('--priority-threshold <level>', 'filter by priority: must-fix, suggestion, or nitpick') .action(async (prNumber, options) => { console.log(chalk.blue('\n๐Ÿ” PR Review Assistant - Prototype\n')); // Display PR URL with hyperlink if terminal supports it const repoName = options.repo || 'owner/repo'; const prUrl = `https://github.com/${repoName}/pull/${prNumber}`; const supportsHyperlinks = process.stdout.isTTY && process.env.TERM_PROGRAM !== 'Apple_Terminal'; if (supportsHyperlinks) { console.log(`Analyzing PR #${prNumber} on ${repoName}...`); console.log(`๐Ÿ”— \x1b]8;;${prUrl}\x1b\\${prUrl}\x1b]8;;\x1b\\\n`); } else { console.log(`Analyzing PR #${prNumber} on ${repoName}...`); console.log(`๐Ÿ”— ${prUrl}\n`); } // Validate priority options if (options.criticalOnly && options.priorityThreshold) { console.log(chalk.red('Error: Cannot use both --critical-only and --priority-threshold')); process.exit(1); } if (options.priorityThreshold) { const validLevels = Object.values(PRIORITY_LEVELS); if (!validLevels.includes(options.priorityThreshold)) { console.log(chalk.red(`Error: Invalid priority threshold. Must be one of: ${validLevels.join(', ')}`)); process.exit(1); } } // Determine priority filter let priorityFilter = null; if (options.criticalOnly) { priorityFilter = PRIORITY_LEVELS.MUST_FIX; } else if (options.priorityThreshold) { priorityFilter = options.priorityThreshold; } 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) { // Positive messaging when no issues found console.log(chalk.green('\nโœ… All bot reviews passed! No issues found.\n')); // Show bot review summary console.log(chalk.bold('Bot Review Summary:')); // Analyze bot approvals from debug info if (debugInfo && debugInfo.reviewDetails) { const botApprovals = []; for (const review of debugInfo.reviewDetails) { if (review.author && review.hasBody) { // Try to detect approval from review body const approval = botDetector.detectApproval(review.author, review.body || ''); const summary = botDetector.extractIssueSummary(review.author, review.body || ''); botApprovals.push({ bot: review.author, approved: approval.hasApproval, signals: approval.signals, summary: summary }); } } // Display bot reviews botApprovals.forEach(({ bot, approved, signals, summary }) => { const icon = approved ? 'โœ…' : 'โŒ'; let statusText = `${icon} ${bot}: ${approved ? 'Approved' : 'Reviewed'}`; if (summary && summary.total > 0) { const parts = []; if (summary.mustFix > 0) parts.push(`${summary.mustFix} must-fix`); if (summary.suggestions > 0) parts.push(`${summary.suggestions} suggestions`); if (summary.nitpicks > 0) parts.push(`${summary.nitpicks} nitpicks`); statusText += ` (${parts.join(', ')})`; } else if (approved && signals.length > 0) { statusText += ` - ${signals[0]}`; } console.log(`- ${statusText}`); }); // If no bot reviews found if (botApprovals.length === 0) { console.log(chalk.gray('- No bot reviews detected')); } } // Add DeepSource placeholder if not configured if (!debugInfo?.reviewDetails?.some(r => r.author?.toLowerCase().includes('deepsource'))) { console.log('- DeepSource: โœ… Not configured'); } // Show human reviews console.log(`- Human reviews: ${humanComments.length} ${humanComments.length > 0 ? 'pending' : 'pending'}`); // Show CI status let ciStatus = null; try { spinner.start('Checking CI status...'); ciStatus = await provider.getPRChecks(prNumber); spinner.stop(); if (ciStatus && ciStatus.total > 0) { let ciText = `\nCI Status: ${ciStatus.passing}/${ciStatus.total} checks passing`; if (ciStatus.pending > 0) { ciText += ` (${ciStatus.pending} pending)`; } if (ciStatus.failing > 0) { ciText += chalk.red(` (${ciStatus.failing} failing)`); } console.log(ciText); } } catch (error) { spinner.stop(); // Silently ignore CI status errors } // Show non-critical suggestions if any if (debugInfo && debugInfo.skippedComments) { const nitSkips = debugInfo.skippedComments.filter(s => s.isNit); if (nitSkips.length > 0) { console.log(chalk.gray(`\n๐Ÿ’ก ${nitSkips.length} non-critical suggestions available (use --show-all to view)`)); if (options.showAll) { console.log(chalk.bold('\nNon-Critical Suggestions:')); nitSkips.forEach((skip, idx) => { console.log(`${idx + 1}. ${skip.username}: ${skip.body?.substring(0, 100)}...`); }); } } } // Only show debug info if --debug flag is used if (options.debug && debugInfo) { console.log(chalk.yellow('\n๐Ÿ“Š Debug: 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}`)); } // Show merge readiness when no issues found console.log(chalk.bold('\n๐Ÿ“‹ Merge Readiness:')); const checkMark = chalk.green('โœ…'); const crossMark = chalk.red('โŒ'); console.log(` ${checkMark} All bot reviews passed`); console.log(` ${checkMark} No critical issues found`); // Check CI status if available if (ciStatus) { const ciPassing = ciStatus.failing === 0 && ciStatus.pending === 0; const ciText = ciStatus.failing > 0 ? `CI checks failing (${ciStatus.failing}/${ciStatus.total})` : ciStatus.pending > 0 ? `CI checks pending (${ciStatus.pending}/${ciStatus.total})` : `CI checks passing (${ciStatus.passing}/${ciStatus.total})`; console.log(` ${ciPassing ? checkMark : crossMark} ${ciText}`); if (ciPassing) { console.log(chalk.green.bold('\n โœ… Ready to merge!')); } else { console.log(chalk.yellow.bold('\n โš ๏ธ Not ready to merge - CI checks must pass')); } } else { console.log(chalk.green.bold('\n โœ… Ready to merge!')); } 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 filteredCounts = { [PRIORITY_LEVELS.MUST_FIX]: 0, [PRIORITY_LEVELS.SUGGESTION]: 0, [PRIORITY_LEVELS.NITPICK]: 0 }; 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(); // Apply priority filtering if (priorityFilter) { const commentPriority = analysis.priority || PRIORITY_LEVELS.SUGGESTION; const priorityOrder = [PRIORITY_LEVELS.MUST_FIX, PRIORITY_LEVELS.SUGGESTION, PRIORITY_LEVELS.NITPICK]; const filterIndex = priorityOrder.indexOf(priorityFilter); const commentIndex = priorityOrder.indexOf(commentPriority); // Skip if comment priority is lower than filter threshold if (commentIndex > filterIndex) { console.log(chalk.gray(`\nโญ๏ธ Skipping ${commentPriority} (filtered by --${options.criticalOnly ? 'critical-only' : 'priority-threshold'})`)); filteredCounts[commentPriority]++; continue; } } // Show AI analysis console.log(chalk.bold('\n๐Ÿค– AI Analysis:')); console.log(` Decision: ${chalk.cyan(analysis.action)}`); console.log(` Reason: ${analysis.reason}`); console.log(` Priority: ${chalk.yellow(analysis.priority || PRIORITY_LEVELS.SUGGESTION)}`); 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 // Check if this is a non-critical suggestion if (!options.showAll && (analysis.priority === 'low' || analysis.category === 'NITPICK' || analysis.category === 'STYLE')) { // Track as non-critical but don't process further reportBuilder.addNonCriticalSuggestion(mainComment, analysis); } else { 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}`); } // Show filtered counts if priority filtering is active if (priorityFilter) { const totalFiltered = Object.values(filteredCounts).reduce((sum, count) => sum + count, 0); if (totalFiltered > 0) { console.log(chalk.yellow('\n ๐Ÿ“‹ Filtered by priority:')); if (filteredCounts[PRIORITY_LEVELS.MUST_FIX] > 0) { console.log(` Must Fix: ${filteredCounts[PRIORITY_LEVELS.MUST_FIX]} (hidden)`); } if (filteredCounts[PRIORITY_LEVELS.SUGGESTION] > 0) { console.log(` Suggestions: ${filteredCounts[PRIORITY_LEVELS.SUGGESTION]} (hidden)`); } if (filteredCounts[PRIORITY_LEVELS.NITPICK] > 0) { console.log(` Nitpicks: ${filteredCounts[PRIORITY_LEVELS.NITPICK]} (hidden)`); } } } 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}`)); // Show merge readiness summary console.log(chalk.bold('\n๐Ÿ“‹ Merge Readiness:')); // Check bot approvals const botApprovals = reportBuilder.report.summary.botApprovals || {}; const allBotsApproved = Object.values(botApprovals).every(approval => approval.approved); const botCount = Object.keys(botApprovals).length; // Check critical issues const hasCriticalIssues = reportBuilder.report.summary.actions.escalated > 0 || reportBuilder.report.summary.actions.fixed > 0; // Get CI status if available let ciStatus = null; let ciPassing = false; try { ciStatus = await provider.getPRChecks(prNumber); ciPassing = ciStatus && ciStatus.total > 0 && ciStatus.failing === 0 && ciStatus.pending === 0; } catch (error) { // Silently ignore CI status errors } // Display checklist const checkMark = chalk.green('โœ…'); const crossMark = chalk.red('โŒ'); console.log(` ${allBotsApproved || botCount === 0 ? checkMark : crossMark} All bot reviews passed`); console.log(` ${!hasCriticalIssues ? checkMark : crossMark} No critical issues found`); if (ciStatus) { const ciText = ciStatus.failing > 0 ? `CI checks failing (${ciStatus.failing}/${ciStatus.total})` : ciStatus.pending > 0 ? `CI checks pending (${ciStatus.pending}/${ciStatus.total})` : `CI checks passing (${ciStatus.passing}/${ciStatus.total})`; console.log(` ${ciPassing ? checkMark : crossMark} ${ciText}`); } // Overall readiness const isReadyToMerge = (allBotsApproved || botCount === 0) && !hasCriticalIssues && (ciPassing || !ciStatus); if (isReadyToMerge) { console.log(chalk.green.bold('\n โœ… Ready to merge!')); } else { console.log(chalk.yellow.bold('\n โš ๏ธ Not ready to merge - address the issues above')); } } 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') .option('--auto-process', 'automatically process when all expected bots have responded') .action(async (prNumber, options) => { console.log(chalk.blue('\n๐Ÿ‘€ PR Watch Mode - Smart bot detection enabled\n')); const timeout = parseInt(options.timeout) * 60 * 1000; // Convert to ms const startTime = Date.now(); // Expected bots and their typical response times const expectedBots = { 'coderabbit[bot]': { avgTime: 90, maxTime: 180 }, 'deepsource[bot]': { avgTime: 60, maxTime: 120 }, 'sonarcloud[bot]': { avgTime: 120, maxTime: 240 }, 'snyk[bot]': { avgTime: 60, maxTime: 120 }, 'claude[bot]': { avgTime: 45, maxTime: 90 } }; // Adaptive polling intervals const getPollingInterval = (elapsedSeconds) => { if (elapsedSeconds < 30) return 5000; // First 30s: check every 5s if (elapsedSeconds < 120) return 10000; // Next 90s: check every 10s if (elapsedSeconds < 300) return 20000; // Next 3min: check every 20s return 30000; // After 5min: check every 30s }; const foundBots = new Set(); const processedComments = new Set(); const botCompletionSignals = new Map(); // Track completion signals const detectedBots = new Set(); // Track which bots we've seen on this PR // First, check what bots have already commented const initialCheck = await analyzeGitHubPR(prNumber, options.repo, { skipNits: false }); initialCheck.comments.forEach(comment => { const bot = comment.user?.login || comment.author?.login || 'unknown'; if (bot && expectedBots[bot]) { detectedBots.add(bot); } }); const spinner = ora('Checking for bot activity...').start(); // Show expected bots const activeBots = Array.from(detectedBots); if (activeBots.length > 0) { spinner.text = `Detected active bots: ${activeBots.join(', ')}. Waiting for completion...`; } while (Date.now() - startTime < timeout) { const elapsed = Date.now() - startTime; const elapsedSeconds = Math.floor(elapsed / 1000); // Update spinner with smart status const waitingFor = []; detectedBots.forEach(bot => { if (!botCompletionSignals.has(bot)) { const botInfo = expectedBots[bot]; if (botInfo && elapsedSeconds < botInfo.avgTime) { waitingFor.push(`${bot} (usually ~${botInfo.avgTime}s)`); } else if (botInfo && elapsedSeconds < botInfo.maxTime) { waitingFor.push(`${bot} (should finish soon)`); } else { waitingFor.push(`${bot} (taking longer than usual)`); } } }); if (waitingFor.length > 0) { spinner.text = `โณ Waiting for: ${waitingFor.join(', ')}`; } // Determine current interval const currentInterval = getPollingInterval(elapsedSeconds); try { // Fetch PR data with debug flag const { botComments } = await analyzeGitHubPR(prNumber, options.repo, { debug: false }); // Track new bots and detect completion signals botComments.forEach(comment => { const bot = comment.user?.login || comment.author?.login || 'unknown'; const body = comment.body || ''; if (!foundBots.has(bot)) { foundBots.add(bot); spinner.succeed(`${bot} posted ${comment.type === 'pr_review' ? 'review' : 'comment'}`); // Add to detected bots if it's an expected bot if (expectedBots[bot]) { detectedBots.add(bot); } spinner.start('Analyzing bot responses...'); } // Detect completion signals if (!botCompletionSignals.has(bot)) { const completionPatterns = [ /review\s+complete/i, /analysis\s+(complete|finished)/i, /actionable\s+comments\s+posted:\s*\d+/i, /found\s+\d+\s+issues?/i, /all\s+checks\s+pass/i, /no\s+issues?\s+found/i, /approved/i, /lgtm/i ]; const hasCompletionSignal = completionPatterns.some(pattern => pattern.test(body)); if (hasCompletionSignal) { botCompletionSignals.set(bot, true); spinner.succeed(`${bot} review complete!`); spinner.start('Checking other 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); }); // Check if all detected bots have completed const allBotsComplete = detectedBots.size > 0 && Array.from(detectedBots).every(bot => botCompletionSignals.has(bot)); 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(', ')}`); }); // Show completion status if (detectedBots.size > 0) { console.log(chalk.bold('\n๐ŸŽฏ Bot Completion Status:')); detectedBots.forEach(bot => { const isComplete = botCompletionSignals.has(bot); console.log(` ${isComplete ? 'โœ…' : 'โณ'} ${bot}: ${isComplete ? 'Complete' : 'In progress'}`); }); } // Auto-process if enabled and all bots complete if (options.autoProcess && allBotsComplete) { console.log(chalk.green.bold('\nโœ… All expected bots have completed their reviews!')); console.log(chalk.blue('\n๐ŸŽต Auto-processing bot comments...\n')); // Run the normal pr command const args = ['node', 'pr-vibe', 'pr', prNumber, '-r', options.repo]; if (options.autoFix) args.push('--auto-fix'); if (options.llm !== 'none') args.push('--llm', options.llm); await program.parseAsync(args, { from: 'user' }); return; } // 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...'); } } // Check for completion without new comments (in case we missed the initial check) if (allBotsComplete && !newComments.length && options.autoProcess) { spinner.stop(); console.log(chalk.green.bold('\nโœ… All expected bots have completed their reviews!')); console.log(chalk.blue('\n๐ŸŽต Auto-processing bot comments...\n')); const args = ['node', 'pr-vibe', 'pr', prNumber, '-r', options.repo]; if (options.autoFix) args.push('--auto-fix'); if (options.llm !== 'none') args.push('--llm', options.llm); await program.parseAsync(args, { from: 'user' }); return; } // Update spinner with elapsed time and status const elapsedMin = Math.floor(elapsed / 60000); const elapsedSec = Math.floor((elapsed % 60000) / 1000); const timeStr = `${elapsedMin}:${elapsedSec.toString().padStart(2, '0')}`; if (waitingFor.length === 0 && detectedBots.size > 0) { spinner.text = `All detected bots have responded! (${timeStr} elapsed)`; } else if (waitingFor.length > 0) { // Already set above with bot-specific info } else { spinner.text = `Waiting for bot reviews... (${timeStr} 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.13.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');