UNPKG

pr-vibe

Version:

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

546 lines (469 loc) 18 kB
/** * Manages ongoing conversations with review bots * Handles the full dialogue until resolution */ export class ConversationManager { constructor(provider) { this.provider = provider; this.activeConversations = new Map(); this.maxRounds = 5; // Prevent infinite loops this.botResponseTimeout = 600000; // 10 minutes - bots should respond or rate limit within this time this.rateLimitCache = new Map(); // Track rate limits per bot // Enhanced metrics tracking this.metrics = { totalConversations: 0, totalRounds: 0, rateLimitsEncountered: 0, botCorrections: 0, totalDuration: 0, resolutionTypes: { accepted: 0, escalated: 0, timeout: 0, maxRounds: 0 } }; } /** * Start a conversation with a bot comment */ async startConversation(prNumber, comment, initialResponse) { const conversationId = `${prNumber}-${comment.id}`; const startTime = Date.now(); this.activeConversations.set(conversationId, { prNumber, originalComment: comment, rounds: [{ speaker: 'pr-vibe', message: initialResponse, timestamp: new Date() }], status: 'active', resolution: null, startTime: startTime, rateLimited: false, hadCorrection: false }); // Track metrics this.metrics.totalConversations++; // Post initial response await this.postResponse(prNumber, comment, initialResponse); // Wait for bot response const result = await this.waitForBotResponse(conversationId); // Update duration metric if (result) { result.duration = Date.now() - startTime; this.metrics.totalDuration += result.duration; } return result; } /** * Wait for bot to respond and continue conversation */ async waitForBotResponse(conversationId) { const conversation = this.activeConversations.get(conversationId); if (!conversation) return null; console.log('⏳ Waiting for bot response...'); const startTime = Date.now(); let lastCheckTime = null; let checkInterval = 3000; // Start with 3 second checks let checksWithoutResponse = 0; while (Date.now() - startTime < this.botResponseTimeout) { // Exponential backoff: increase check interval over time if (checksWithoutResponse > 10) { checkInterval = Math.min(checkInterval * 1.5, 30000); // Max 30 seconds } await new Promise(resolve => setTimeout(resolve, checkInterval)); checksWithoutResponse++; const newComments = await this.checkForNewComments( conversation.prNumber, conversation.originalComment.id, lastCheckTime ); lastCheckTime = new Date(); if (newComments.length > 0) { // Bot responded! const botResponse = newComments[0]; console.log(`\n📥 ${botResponse.user.login} responded after ${Math.round((Date.now() - startTime) / 1000)}s`); conversation.rounds.push({ speaker: botResponse.user.login, message: botResponse.body, timestamp: new Date(botResponse.created_at) }); // Track total rounds this.metrics.totalRounds++; // Analyze bot response const nextAction = await this.analyzeBotResponse(botResponse, conversation); if (nextAction.waitingForBot) { // Bot indicated rate limit, continue waiting conversation.rateLimited = true; this.metrics.rateLimitsEncountered++; checksWithoutResponse = 0; // Reset counter checkInterval = 3000; // Reset to faster checking continue; } if (nextAction.resolved) { conversation.status = 'resolved'; conversation.resolution = nextAction.resolution; console.log('✅ Conversation resolved: ' + nextAction.resolution); // Track resolution type if (nextAction.resolution.includes('accepted')) { this.metrics.resolutionTypes.accepted++; } else if (nextAction.resolution.includes('Escalated')) { this.metrics.resolutionTypes.escalated++; } return conversation; } else if (nextAction.needsReply && conversation.rounds.length < this.maxRounds) { // Continue conversation console.log('↪️ Continuing conversation...'); await this.postResponse( conversation.prNumber, botResponse, nextAction.reply ); conversation.rounds.push({ speaker: 'pr-vibe', message: nextAction.reply, timestamp: new Date() }); this.metrics.totalRounds++; // Recursively wait for next response return await this.waitForBotResponse(conversationId); } else if (conversation.rounds.length >= this.maxRounds) { // Max rounds reached conversation.status = 'max_rounds'; conversation.resolution = 'Maximum conversation rounds reached'; this.metrics.resolutionTypes.maxRounds++; return conversation; } } // Show progress occasionally if (checksWithoutResponse % 10 === 0) { const elapsed = Math.round((Date.now() - startTime) / 1000); console.log(`⏳ Still waiting... (${elapsed}s elapsed, checking every ${checkInterval/1000}s)`); } } // Timeout reached - this is unusual, log it conversation.status = 'timeout'; conversation.resolution = 'Bot did not respond within timeout period'; this.metrics.resolutionTypes.timeout++; console.log(`⚠️ Bot did not respond within ${this.botResponseTimeout/60000} minutes`); console.log('This is unusual - the bot may be experiencing issues.'); return conversation; } /** * Check for new comments in a thread */ async checkForNewComments(prNumber, originalCommentId, since) { // Get all comments on the PR const comments = await this.provider.getComments(prNumber); // Filter for replies to our comment that are newer than 'since' return comments.filter(c => { const isReply = c.in_reply_to_id === originalCommentId || (c.body && c.body.includes(`@${this.provider.currentUser}`)); const isNewer = !since || new Date(c.created_at) > since; const isBot = c.user && c.user.login && c.user.login.includes('[bot]'); return isReply && isNewer && isBot; }); } /** * Analyze bot's response to determine next action */ async analyzeBotResponse(botResponse, conversation) { const body = botResponse.body.toLowerCase(); const originalBody = botResponse.body; // Keep original for context const username = botResponse.user?.login || ''; // Check if this is Claude Code with analysis if (/claude.*code|claude\[bot\]/i.test(username)) { // Import bot detector to parse Claude's response const { botDetector } = await import('./bot-detector.js'); const parsed = botDetector.parseClaudeCodeReview(originalBody); if (parsed && parsed.hasApproval) { return { resolved: true, resolution: 'Claude Code approved the changes' }; } // If Claude Code has high confidence MUST_FIX, we should acknowledge and fix if (parsed && parsed.category === 'MUST_FIX' && parsed.confidence > 0.8) { return { resolved: false, needsReply: true, suggestedReply: 'Thanks Claude! I\'ll implement this fix right away.', action: 'acknowledge_and_fix' }; } } // First check for rate limit messages const rateLimitInfo = this.checkForRateLimit(body, botResponse.user.login); if (rateLimitInfo) { console.log(`⏳ Bot is rate limited. Will retry after ${rateLimitInfo.retryAfter}`); // Store rate limit info this.rateLimitCache.set(botResponse.user.login, { limitedAt: new Date(), retryAfter: rateLimitInfo.retryAfter, message: rateLimitInfo.message }); // Wait for the specified time await this.waitForRateLimit(rateLimitInfo.retryAfter); // Continue waiting for actual response return { resolved: false, needsReply: false, waitingForBot: true }; } // Check for resolution indicators if (body.includes('lgtm') || body.includes('looks good') || body.includes('thanks for the explanation') || body.includes('makes sense') || body.includes('acknowledged') || body.includes('thank you for clarifying')) { return { resolved: true, resolution: 'Bot accepted explanation' }; } // Check for corrections or clarifications if (body.includes('actually') || body.includes('clarify') || body.includes('you mentioned') || body.includes('misunderstood')) { // Bot is correcting our understanding conversation.hadCorrection = true; this.metrics.botCorrections++; return { resolved: false, needsReply: true, reply: this.generateCorrectedResponse(botResponse, conversation) }; } // Check if bot is asking for more info if (body.includes('?') || body.includes('could you') || body.includes('please explain') || body.includes('provide more')) { return { resolved: false, needsReply: true, reply: this.generateClarification(botResponse, conversation) }; } // Check if bot is insisting on change if (body.includes('still recommend') || body.includes('still strongly recommend') || body.includes('security risk') || body.includes('best practice') || body.includes('strongly suggest')) { // After 3 rounds, escalate to human if (conversation.rounds.length >= 3) { return { resolved: true, resolution: 'Escalated to human review after multiple rounds' }; } return { resolved: false, needsReply: true, reply: this.generateStrongerJustification(botResponse, conversation) }; } // Default: assume resolved if no clear action needed return { resolved: true, resolution: 'No further action needed from bot' }; } /** * Generate response when bot corrects our understanding */ generateCorrectedResponse(botResponse, conversation) { return `Thank you for the clarification. You're absolutely right - I misunderstood the issue. Let me address the actual concern you've raised. I'll make sure to apply the appropriate fix for this specific issue.`; } /** * Generate clarification based on bot's question */ generateClarification(botResponse, conversation) { // This would use context from the conversation to provide more details const originalContext = conversation.originalComment?.body || 'the implementation'; return `I understand your concern. To clarify: ${originalContext} This pattern is specifically documented in our architecture decisions and has been reviewed by the security team.`; } /** * Generate stronger justification when bot insists */ generateStrongerJustification(botResponse, conversation) { return `I appreciate your persistence on this point. While I understand the general best practice, in this specific case: 1. This is a documented exception in our codebase 2. The alternative would introduce more complexity 3. We have compensating controls in place If you still have concerns, I'm happy to escalate this to the team lead for review.`; } /** * Post a response in the conversation */ async postResponse(prNumber, comment, response) { // Add pr-vibe signature const fullResponse = `${response} --- 🎵 **[pr-vibe](https://github.com/stroupaloop/pr-vibe)** - Continuing conversation`; await this.provider.postComment(prNumber, fullResponse, { in_reply_to: comment.id }); } /** * Check if bot response indicates rate limiting */ checkForRateLimit(body, botName) { const patterns = [ // CodeRabbit patterns /rate limit.*?(\d+)\s*(seconds?|minutes?|hours?)/i, /please wait.*?(\d+)\s*(seconds?|minutes?|hours?)/i, /try again in.*?(\d+)\s*(seconds?|minutes?|hours?)/i, /quota exceeded.*?(\d+)\s*(seconds?|minutes?|hours?)/i, // Generic patterns /too many requests.*?(\d+)\s*(seconds?|minutes?|hours?)/i, /retry after.*?(\d+)\s*(seconds?|minutes?|hours?)/i ]; for (const pattern of patterns) { const match = body.match(pattern); if (match) { const [fullMatch, time, unit] = match; const seconds = this.convertToSeconds(parseInt(time), unit); return { isRateLimited: true, retryAfter: seconds * 1000, // Convert to milliseconds message: fullMatch, bot: botName }; } } // Check for specific CodeRabbit rate limit format if (botName.includes('coderabbit') && (body.includes('currently processing') || body.includes('high load') || body.includes('queued for processing') || body.includes('busy processing') || body.includes('will process your') || body.includes('apologize') && body.includes('delay'))) { // Default wait time when no specific time given return { isRateLimited: true, retryAfter: 60000, // 1 minute default message: 'Bot is busy, will retry in 1 minute', bot: botName }; } // Check for other bot rate limit patterns if (body.includes('rate limit') || body.includes('too many requests') || body.includes('please try again') || body.includes('temporarily unavailable')) { return { isRateLimited: true, retryAfter: 30000, // 30 seconds default for unknown patterns message: 'Bot appears to be rate limited', bot: botName }; } return null; } /** * Convert time to seconds */ convertToSeconds(time, unit) { const unitLower = unit.toLowerCase(); if (unitLower.includes('hour')) return time * 3600; if (unitLower.includes('minute')) return time * 60; return time; // seconds } /** * Wait for rate limit to expire */ async waitForRateLimit(waitTime) { const waitMinutes = Math.ceil(waitTime / 60000); console.log(`⏱️ Waiting ${waitMinutes} minute(s) for rate limit to clear...`); // Show progress const startTime = Date.now(); const interval = setInterval(() => { const elapsed = Date.now() - startTime; const remaining = Math.max(0, waitTime - elapsed); const remainingMin = Math.ceil(remaining / 60000); if (remaining > 0) { process.stdout.write(`\r⏱️ Rate limit wait: ${remainingMin} minute(s) remaining...`); } }, 5000); await new Promise(resolve => setTimeout(resolve, waitTime)); clearInterval(interval); console.log('\n✅ Rate limit wait complete, checking for response...'); } /** * Check if we should wait before posting to avoid rate limits */ async checkRateLimitBeforePosting(botName) { const rateLimitInfo = this.rateLimitCache.get(botName); if (!rateLimitInfo) return; const now = new Date(); const limitedAt = new Date(rateLimitInfo.limitedAt); const waitTime = rateLimitInfo.retryAfter - (now - limitedAt); if (waitTime > 0) { console.log(`⏳ Preemptively waiting ${Math.ceil(waitTime / 1000)}s to avoid rate limit`); await new Promise(resolve => setTimeout(resolve, waitTime)); } } /** * Get summary of all conversations */ getConversationSummary() { const summary = { total: this.metrics.totalConversations, resolved: this.metrics.resolutionTypes.accepted, timeout: this.metrics.resolutionTypes.timeout, escalated: this.metrics.resolutionTypes.escalated, maxRounds: this.metrics.resolutionTypes.maxRounds, totalRounds: this.metrics.totalRounds, averageRounds: this.metrics.totalConversations > 0 ? (this.metrics.totalRounds / this.metrics.totalConversations).toFixed(1) : 0, rateLimitsHit: this.metrics.rateLimitsEncountered, botCorrections: this.metrics.botCorrections, totalDuration: this.metrics.totalDuration, averageDuration: this.metrics.totalConversations > 0 ? Math.round(this.metrics.totalDuration / this.metrics.totalConversations / 1000) : 0 }; // Also include active conversation details const activeDetails = []; this.activeConversations.forEach((conv, id) => { activeDetails.push({ id: id, status: conv.status, rounds: conv.rounds.length, resolution: conv.resolution, rateLimited: conv.rateLimited, hadCorrection: conv.hadCorrection, duration: conv.startTime ? Date.now() - conv.startTime : 0 }); }); summary.activeConversations = activeDetails; return summary; } /** * Get detailed conversation data for a specific conversation */ getConversationDetails(conversationId) { const conv = this.activeConversations.get(conversationId); if (!conv) return null; return { ...conv, duration: conv.startTime ? Date.now() - conv.startTime : 0, metrics: { rounds: conv.rounds.length, rateLimited: conv.rateLimited, hadCorrection: conv.hadCorrection } }; } }