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
JavaScript
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);
}