UNPKG

pr-vibe

Version:

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

268 lines (232 loc) • 8.31 kB
import { execSync } from 'child_process'; import chalk from 'chalk'; export class CommentPoster { constructor(provider) { this.provider = provider; this.postedComments = []; this.resolvedThreads = []; } /** * Post a reply to a specific comment */ async replyToComment(prId, comment, replyText, options = {}) { try { // Add pr-vibe signature const enhancedReply = this.addPrVibeSignature(replyText, options.action); // Add reaction emoji if specified if (options.reaction) { await this.addReaction(prId, comment.id, options.reaction); } // Post the reply const result = await this.provider.postComment(prId, enhancedReply, { inReplyTo: comment.id, isInline: comment.type === 'review' }); this.postedComments.push({ originalComment: comment.id, reply: result, timestamp: new Date().toISOString(), action: options.action }); // Mark thread as resolved if we handled it if (options.action && options.action !== 'ESCALATE') { await this.resolveThread(prId, comment); } return result; } catch (error) { console.error(`Failed to reply to comment ${comment.id}: ${error.message}`); throw error; } } /** * Post a general comment on the PR */ async postGeneralComment(prId, body) { try { const result = await this.provider.postComment(prId, body); this.postedComments.push({ type: 'general', reply: result, timestamp: new Date().toISOString() }); return result; } catch (error) { console.error(`Failed to post general comment: ${error.message}`); throw error; } } /** * Add a reaction to a comment */ async addReaction(prId, commentId, reaction) { // Map decision to emoji const reactionMap = { 'AUTO_FIX': 'āœ…', 'REJECT': 'āŒ', 'DISCUSS': 'šŸ’¬', 'DEFER': 'šŸ“', 'ESCALATE': 'āš ļø' }; const emoji = reactionMap[reaction] || reaction; try { // GitHub reactions API execSync( `gh api -X POST repos/${this.provider.repo}/issues/comments/${commentId}/reactions -f content='${this.getGitHubReaction(emoji)}'`, { stdio: 'pipe' } ); } catch (error) { // Reaction might not be supported, continue anyway console.warn(`Could not add reaction ${emoji}: ${error.message}`); } } /** * Convert emoji to GitHub reaction type */ getGitHubReaction(emoji) { const map = { 'āœ…': '+1', 'āŒ': '-1', 'šŸ’¬': 'eyes', 'šŸ“': 'heart', 'āš ļø': 'confused', 'šŸš€': 'rocket', 'šŸ‘€': 'eyes' }; return map[emoji] || 'eyes'; } /** * Post a summary comment with all actions taken */ async postSummary(prId, decisions, changes) { const summary = this.buildSummary(decisions, changes); return await this.postGeneralComment(prId, summary); } buildSummary(decisions, changes) { const stats = { total: decisions.length, autoFixed: decisions.filter(d => d.decision?.action === 'AUTO_FIX' || d.action === 'AUTO_FIX').length, rejected: decisions.filter(d => d.decision?.action === 'REJECT' || d.action === 'REJECT').length, discussed: decisions.filter(d => d.decision?.action === 'DISCUSS' || d.action === 'DISCUSS').length, deferred: decisions.filter(d => d.decision?.action === 'DEFER' || d.action === 'DEFER').length, escalated: decisions.filter(d => d.decision?.action === 'ESCALATE' || d.action === 'ESCALATE').length }; // Get deferred items with issues created const deferredWithIssues = decisions.filter(d => (d.decision?.action === 'DEFER' || d.action === 'DEFER') && d.decision?.issueNumber ); const timeSaved = Math.round(stats.total * 2.5); // ~2.5 min per comment let summary = '## šŸŽµ pr-vibe Review Summary\n\n'; summary += `I've reviewed and handled **${stats.total} bot comments** on this PR:\n\n`; if (stats.autoFixed > 0) { summary += `- šŸ”§ **Auto-fixed**: ${stats.autoFixed} issues\n`; } if (stats.rejected > 0) { summary += `- āœ… **Validated**: ${stats.rejected} patterns (explained why they're correct)\n`; } if (stats.discussed > 0) { summary += `- šŸ’¬ **Needs Discussion**: ${stats.discussed}\n`; } if (stats.deferred > 0) { summary += `- šŸ“ **Deferred**: ${stats.deferred} items to backlog`; if (deferredWithIssues.length > 0) { summary += ` (${deferredWithIssues.length} issues created)`; } summary += '\n'; } if (stats.escalated > 0) { summary += `- 🚨 **Escalated**: ${stats.escalated} complex issues\n`; } summary += `\nā±ļø **Time saved**: ~${timeSaved} minutes\n`; summary += `🧠 **Patterns learned**: ${stats.rejected} (will handle automatically next time)\n`; if (this.resolvedThreads.length > 0) { summary += `āœ… **Threads resolved**: ${this.resolvedThreads.length}\n`; } if (changes && changes.length > 0) { summary += '\n### Files Modified\n'; changes.forEach(change => { summary += `- \`${change.path}\`: ${change.description}\n`; }); } if (stats.escalated > 0) { summary += '\n### āš ļø Requires Your Attention\n'; summary += `${stats.escalated} complex issues need human review. Check the 🚨 comments above.\n`; } if (deferredWithIssues.length > 0) { summary += '\n### šŸ“‹ Created Issues\n'; deferredWithIssues.forEach(item => { const issueNum = item.decision.issueNumber; const issueUrl = item.decision.issueUrl; const botName = item.comment.user?.login || item.comment.author?.login || 'unknown'; summary += `- [#${issueNum}](${issueUrl}) - ${botName} feedback\n`; }); } summary += '\n---\n'; summary += '*Powered by [pr-vibe](https://github.com/stroupaloop/pr-vibe) - Built by AI, for AI collaboration* šŸŽµ'; return summary; } /** * Update a previous comment */ async updateComment(commentId, newBody) { try { execSync( `gh api -X PATCH repos/${this.provider.repo}/issues/comments/${commentId} -f body="${newBody}"`, { stdio: 'pipe' } ); return { success: true }; } catch (error) { return { success: false, error: error.message }; } } /** * Add pr-vibe signature to replies */ addPrVibeSignature(text, action) { const actionEmojis = { AUTO_FIX: 'šŸ”§', REJECT: 'āœ…', DEFER: 'šŸ“', ESCALATE: '🚨', DISCUSS: 'šŸ’¬' }; const emoji = actionEmojis[action] || 'šŸŽµ'; const signature = `\n\n---\n${emoji} **Handled by [pr-vibe](https://github.com/stroupaloop/pr-vibe)** • Action: \`${action || 'REVIEWED'}\``; return text + signature; } /** * Resolve a GitHub review thread */ async resolveThread(prId, comment) { try { // First, try to get the review thread ID const threadInfo = execSync( `gh api repos/${this.provider.repo}/pulls/${prId}/comments --jq '.[] | select(.id == ${comment.id}) | {thread_id: .pull_request_review_id, url: .url}'`, { encoding: 'utf8', stdio: 'pipe' } ).trim(); if (threadInfo) { // Mark the thread as resolved using GraphQL const mutation = ` mutation { resolveReviewThread(input: {threadId: "${comment.id}"}) { thread { isResolved } } } `; execSync( `gh api graphql -f query='${mutation.replace(/'/g, '\'')}' --silent`, { stdio: 'pipe' } ); this.resolvedThreads.push(comment.id); console.log(chalk.green(' āœ… Marked thread as resolved')); } } catch (error) { // Resolution might not be available for all comment types console.log(chalk.gray(` ā„¹ļø Could not mark as resolved (${error.message}))`)); } } } export function createCommentPoster(provider) { return new CommentPoster(provider); }