// Removed @actions imports for Probot migration import {octokit} from './octokit' import {als} from './context' import pino from 'pino' import pRetry from 'p-retry' const logger = pino({ level: process.env.LOG_LEVEL || 'info' }) const info = (msg: string) => logger.info(msg) const warning = (msg: string) => logger.warn(msg) const getInput = (name: string) => process.env[name.toUpperCase()] || '' // Retry configuration for GitHub API calls const GITHUB_RETRY_OPTIONS = { retries: 3, minTimeout: 1000, maxTimeout: 10000, factor: 2, onFailedAttempt: (error: any) => { logger.warn({ attemptNumber: error.attemptNumber, retriesLeft: error.retriesLeft, error: error.message }, 'GitHub API call failed, retrying...') } } // Wrapper for retryable GitHub API calls async function withRetry(fn: () => Promise): Promise { return pRetry(fn, GITHUB_RETRY_OPTIONS) } export const context: any = new Proxy({} as any, { get(target, prop) { return (als.getStore()?.probotContext as any)[prop] } }) export const repo: any = new Proxy({} as any, { get(target, prop) { return (als.getStore()?.repo as any)[prop] } }) export const setCommenterContext = (newContext: any) => { // context and repo are now Proxies } export const LOGO_URL = 'https://www.prixai.xyz/logo.png' export const COMMENT_GREETING = `
PRIX Logo # šŸ¤– PRIX AI Code Review
--- Welcome! I'm PRIX, your AI code reviewer. I've analyzed this pull request and here are my findings: ---` export const COMMENT_TAG = '' export const COMMENT_REPLY_TAG = '' export const SUMMARIZE_TAG = '' export const IN_PROGRESS_START_TAG = '' export const IN_PROGRESS_END_TAG = '' export const DESCRIPTION_START_TAG = '' export const DESCRIPTION_END_TAG = '' export const RAW_SUMMARY_START_TAG = ` ` export const SHORT_SUMMARY_START_TAG = ` ` export const COMMIT_ID_START_TAG = '' export const COMMIT_ID_END_TAG = '' export const VERIFIED_TAG = '' export const VERIFICATION_FAILED_TAG = '' export const ISSUE_REPLY_TAG = '' export const PRIX_BRANDING = ` ---
šŸš€ **[Get PRIX for your team](https://www.prixai.xyz/)** — AI-powered code reviews that catch bugs before they ship
` export const PLAN_BRANDING = ` ---
šŸš€ **[Get PRIX for your team](https://www.prixai.xyz/)** — AI-powered code reviews that catch bugs before they ship
` export class Commenter { /** * @param mode Can be "create", "replace". Default is "replace". */ async comment(message: string, tag: string, mode: string) { let target: number if (context.payload.pull_request != null) { target = context.payload.pull_request.number } else if (context.payload.issue != null) { target = context.payload.issue.number } else { warning( 'Skipped: context.payload.pull_request and context.payload.issue are both null' ) return } if (!tag) { tag = COMMENT_TAG } const body = `${COMMENT_GREETING} ${message} ${tag}${PRIX_BRANDING}` if (mode === 'create') { await this.create(body, target) } else if (mode === 'replace') { await this.replace(body, tag, target) } else { warning(`Unknown mode: ${mode}, use "replace" instead`) await this.replace(body, tag, target) } } getContentWithinTags(content: string, startTag: string, endTag: string) { const start = content.indexOf(startTag) const end = content.indexOf(endTag) if (start >= 0 && end >= 0) { return content.slice(start + startTag.length, end) } return '' } removeContentWithinTags(content: string, startTag: string, endTag: string) { const start = content.indexOf(startTag) const end = content.lastIndexOf(endTag) if (start >= 0 && end >= 0) { return content.slice(0, start) + content.slice(end + endTag.length) } return content } getRawSummary(summary: string) { return this.getContentWithinTags( summary, RAW_SUMMARY_START_TAG, RAW_SUMMARY_END_TAG ) } getShortSummary(summary: string) { return this.getContentWithinTags( summary, SHORT_SUMMARY_START_TAG, SHORT_SUMMARY_END_TAG ) } getDescription(description: string) { return this.removeContentWithinTags( description, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG ) } getReleaseNotes(description: string) { const releaseNotes = this.getContentWithinTags( description, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG ) return releaseNotes.replace(/(^|\n)> .*/g, '') } async updateDescription(pullNumber: number, message: string) { // add this response to the description field of the PR as release notes by looking // for the tag (marker) try { // get latest description from PR const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, request: { timeout: 10000 } }) let body = '' if (pr.data.body) { body = pr.data.body } const description = this.getDescription(body) const messageClean = this.removeContentWithinTags( message, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG ) const newDescription = `${description}\n${DESCRIPTION_START_TAG}\n${messageClean}\n${DESCRIPTION_END_TAG}` await octokit.rest.pulls.update({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, body: newDescription, request: { timeout: 10000 } }) } catch (e) { warning( `Failed to get PR: ${e}, skipping adding release notes to description.` ) } } private readonly reviewCommentsBuffer: Array<{ path: string startLine: number endLine: number message: string verified?: boolean verificationFeedback?: string level?: string type?: string }> = [] async bufferReviewComment( path: string, startLine: number, endLine: number, message: string, verified?: boolean, verificationFeedback?: string, testCase?: string ) { // Premium UI Formatting: Convert "[LEVEL] | [TYPE]" to emoji badges const levelTypeRegex = /^(\w+)\s*\|\s*(\w+)/ const match = message.match(levelTypeRegex) let level = 'info' let type = 'General' let severityEmoji = 'ā„¹ļø' let color = 'Info' if (match != null) { level = match[1].toLowerCase() type = match[2] if (level === 'critical') { severityEmoji = '🚨' color = 'Critical' } else if (level === 'major') { severityEmoji = 'šŸ”“' color = 'Major' } else if (level === 'minor') { severityEmoji = '🟠' color = 'Minor' } else if (level === 'info') { severityEmoji = 'ā„¹ļø' color = 'Info' } message = message.replace(levelTypeRegex, '').trim() } // Generate AI Agent Prompt for actionable issues let aiAgentSection = '' if (level === 'critical' || level === 'major') { const aiPrompt = this.generateAIAgentPrompt(path, startLine, endLine, message) aiAgentSection = `
šŸ¤– Prompt for AI Agents Copy and paste this prompt into your AI IDE (Cursor, Windsurf, etc.) to auto-fix: \`\`\` ${aiPrompt} \`\`\`
` } let verificationBadge = '' if (verified === true) { verificationBadge = '\n---\nāœ… **Verified**: This suggestion passed syntax, lint, and AST validation.\n' } else if (verified === false && verificationFeedback) { verificationBadge = `\n---\nāš ļø **Verification Failed**: ${verificationFeedback}\n` } // Add test case section if available const testCaseSection = testCase ? `\n\n${testCase}` : '' // CodeRabbit-style formatted comment const formattedMessage = `**${severityEmoji} Potential Issue | ${color} (${type})** ${message}${aiAgentSection}${testCaseSection}${verificationBadge} ${COMMENT_TAG}` this.reviewCommentsBuffer.push({ path, startLine, endLine, message: formattedMessage, verified, verificationFeedback, level, type }) } private generateAIAgentPrompt( path: string, startLine: number, endLine: number, issue: string ): string { return `Fix the following issue in ${path} around lines ${startLine}-${endLine}: Issue: ${issue.split('\n')[0]} Steps: 1. Read the file ${path} 2. Locate lines ${startLine}-${endLine} 3. Fix the issue described above 4. Ensure the fix follows the existing code style 5. Verify no other parts of the code are broken Provide the complete fixed code block.` } async deletePendingReview(pullNumber: number) { try { const reviews = await octokit.rest.pulls.listReviews({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, request: { timeout: 10000 } }) const pendingReview = reviews.data.find( review => review.state === 'PENDING' ) if (pendingReview) { info( `Deleting pending review for PR #${pullNumber} id: ${pendingReview.id}` ) try { await octokit.rest.pulls.deletePendingReview({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, // eslint-disable-next-line camelcase review_id: pendingReview.id, request: { timeout: 10000 } }) } catch (e) { warning(`Failed to delete pending review: ${e}`) } } } catch (e) { warning(`Failed to list reviews: ${e}`) } } async submitReview(pullNumber: number, commitId: string, statusMsg: string) { const bodyHeader = `${COMMENT_GREETING} ${statusMsg} ${PRIX_BRANDING}` if (this.reviewCommentsBuffer.length === 0) { // Submit empty review with statusMsg info(`Submitting empty review for PR #${pullNumber}`) try { await octokit.rest.pulls.createReview({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, // eslint-disable-next-line camelcase commit_id: commitId, event: 'COMMENT', body: bodyHeader, request: { timeout: 10000 } }) } catch (e) { warning(`Failed to submit empty review: ${e}`) } return } // De-duplicate and clear existing bot comments in the same range for (const comment of this.reviewCommentsBuffer) { const comments = await this.getCommentsAtRange( pullNumber, comment.path, comment.startLine, comment.endLine ) for (const c of comments) { if (c.body.includes(COMMENT_TAG)) { try { await octokit.rest.pulls.deleteReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase comment_id: c.id, request: { timeout: 10000 } }) } catch (e) { warning(`Failed to delete review comment: ${e}`) } } } } await this.deletePendingReview(pullNumber) // Unified Review: All comments are standalone threads to support suggestions const pushableComments = this.reviewCommentsBuffer.map(comment => this.generateCommentData(comment) ) try { const review = await octokit.rest.pulls.createReview({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, // eslint-disable-next-line camelcase commit_id: commitId, comments: pushableComments.map(comment => comment), request: { timeout: 10000 } }) info( `Submitting review for PR #${pullNumber}, total comments: ${pushableComments.length}, review id: ${review.data.id}` ) await octokit.rest.pulls.submitReview({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, // eslint-disable-next-line camelcase review_id: review.data.id, event: 'COMMENT', body: bodyHeader, request: { timeout: 10000 } }) } catch (e) { warning( `Failed to create review: ${e}. Falling back to individual comments.` ) await this.deletePendingReview(pullNumber) let commentCounter = 0 for (const comment of pushableComments) { info( `Creating new review comment for ${comment.path}:${comment.start_line || comment.line}-${comment.line}: ${comment.body}` ) const commentData: any = { owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, // eslint-disable-next-line camelcase commit_id: commitId, ...comment } try { await octokit.rest.pulls.createReviewComment({...commentData, request: { timeout: 10000 }}) } catch (ee) { warning(`Failed to create review comment: ${ee}`) } commentCounter++ info(`Comment ${commentCounter}/${pushableComments.length} posted`) } } } private generateCommentData(comment: any) { const commentData: any = { path: comment.path, body: comment.message, line: comment.endLine } if (comment.startLine !== comment.endLine) { // eslint-disable-next-line camelcase commentData.start_line = comment.startLine // eslint-disable-next-line camelcase commentData.start_side = 'RIGHT' } return commentData } async reviewCommentReply( pullNumber: number, topLevelComment: any, message: string ) { const reply = `${COMMENT_GREETING} ${message} ${COMMENT_REPLY_TAG}${PRIX_BRANDING} ` try { // Post the reply to the user comment await octokit.rest.pulls.createReplyForReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, body: reply, // eslint-disable-next-line camelcase comment_id: topLevelComment.id, request: { timeout: 10000 } }) } catch (error) { warning(`Failed to reply to the top-level comment ${error}`) try { await octokit.rest.pulls.createReplyForReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: pullNumber, body: `Could not post the reply to the top-level comment due to the following error: ${error}`, // eslint-disable-next-line camelcase comment_id: topLevelComment.id, request: { timeout: 10000 } }) } catch (e) { warning(`Failed to reply to the top-level comment ${e}`) } } try { if (topLevelComment.body.includes(COMMENT_TAG)) { // replace COMMENT_TAG with COMMENT_REPLY_TAG in topLevelComment const newBody = topLevelComment.body.replace( COMMENT_TAG, COMMENT_REPLY_TAG ) await octokit.rest.pulls.updateReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase comment_id: topLevelComment.id, body: newBody, request: { timeout: 10000 } }) } } catch (error) { warning(`Failed to update the top-level comment ${error}`) } } async getCommentsWithinRange( pullNumber: number, path: string, startLine: number, endLine: number ) { const comments = await this.listReviewComments(pullNumber) return comments.filter( (comment: any) => comment.path === path && comment.body !== '' && ((comment.start_line !== undefined && comment.start_line >= startLine && comment.line <= endLine) || (startLine === endLine && comment.line === endLine)) ) } async getCommentsAtRange( pullNumber: number, path: string, startLine: number, endLine: number ) { const comments = await this.listReviewComments(pullNumber) return comments.filter( (comment: any) => comment.path === path && comment.body !== '' && ((comment.start_line !== undefined && comment.start_line === startLine && comment.line === endLine) || (startLine === endLine && comment.line === endLine)) ) } async getCommentChainsWithinRange( pullNumber: number, path: string, startLine: number, endLine: number, tag = '' ) { const existingComments = await this.getCommentsWithinRange( pullNumber, path, startLine, endLine ) // find all top most comments const topLevelComments = [] for (const comment of existingComments) { if (!comment.in_reply_to_id) { topLevelComments.push(comment) } } let allChains = '' let chainNum = 0 for (const topLevelComment of topLevelComments) { // get conversation chain const chain = await this.composeCommentChain( existingComments, topLevelComment ) if (chain && chain.includes(tag)) { chainNum += 1 allChains += `Conversation Chain ${chainNum}: ${chain} --- ` } } return allChains } async composeCommentChain(reviewComments: any[], topLevelComment: any) { const conversationChain = reviewComments .filter((cmt: any) => cmt.in_reply_to_id === topLevelComment.id) .map((cmt: any) => `${cmt.user.login}: ${cmt.body}`) conversationChain.unshift( `${topLevelComment.user.login}: ${topLevelComment.body}` ) return conversationChain.join('\n---\n') } async getCommentChain(pullNumber: number, comment: any) { try { const reviewComments = await this.listReviewComments(pullNumber) const topLevelComment = await this.getTopLevelComment( reviewComments, comment ) const chain = await this.composeCommentChain( reviewComments, topLevelComment ) return {chain, topLevelComment} } catch (e) { warning(`Failed to get conversation chain: ${e}`) return { chain: '', topLevelComment: null } } } async getTopLevelComment(reviewComments: any[], comment: any) { let topLevelComment = comment while (topLevelComment.in_reply_to_id) { const parentComment = reviewComments.find( (cmt: any) => cmt.id === topLevelComment.in_reply_to_id ) if (parentComment) { topLevelComment = parentComment } else { break } } return topLevelComment } private reviewCommentsCache: Record = {} async listReviewComments(target: number) { if (this.reviewCommentsCache[target]) { return this.reviewCommentsCache[target] } const allComments: any[] = [] let page = 1 try { for (;;) { const {data: comments} = await octokit.rest.pulls.listReviewComments({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: target, page, // eslint-disable-next-line camelcase per_page: 100, request: { timeout: 10000 } }) allComments.push(...comments) page++ if (!comments || comments.length < 100) { break } } this.reviewCommentsCache[target] = allComments return allComments } catch (e) { warning(`Failed to list review comments: ${e}`) return allComments } } async create(body: string, target: number) { try { // get comment ID from the response const response = await octokit.rest.issues.createComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase issue_number: target, body, request: { timeout: 10000 } }) // add comment to issueCommentsCache if (this.issueCommentsCache[target]) { this.issueCommentsCache[target].push(response.data) } else { this.issueCommentsCache[target] = [response.data] } } catch (e) { warning(`Failed to create comment: ${e}`) } } async replace(body: string, tag: string, target: number) { try { const cmt = await this.findCommentWithTag(tag, target) if (cmt) { await octokit.rest.issues.updateComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase comment_id: cmt.id, body, request: { timeout: 10000 } }) } else { await this.create(body, target) } } catch (e) { warning(`Failed to replace comment: ${e}`) } } async findCommentWithTag(tag: string, target: number) { try { const comments = await this.listComments(target) for (const cmt of comments) { if (cmt.body && cmt.body.includes(tag)) { return cmt } } return null } catch (e: unknown) { warning(`Failed to find comment with tag: ${e}`) return null } } private issueCommentsCache: Record = {} async listComments(target: number) { if (this.issueCommentsCache[target]) { return this.issueCommentsCache[target] } const allComments: any[] = [] let page = 1 try { for (;;) { const {data: comments} = await octokit.rest.issues.listComments({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase issue_number: target, page, // eslint-disable-next-line camelcase per_page: 100, request: { timeout: 10000 } }) allComments.push(...comments) page++ if (!comments || comments.length < 100) { break } } this.issueCommentsCache[target] = allComments return allComments } catch (e: any) { warning(`Failed to list comments: ${e}`) return allComments } } // function that takes a comment body and returns the list of commit ids that have been reviewed // commit ids are comments between the commit_ids_reviewed_start and commit_ids_reviewed_end markers // getReviewedCommitIds(commentBody: string): string[] { const start = commentBody.indexOf(COMMIT_ID_START_TAG) const end = commentBody.indexOf(COMMIT_ID_END_TAG) if (start === -1 || end === -1) { return [] } const ids = commentBody.substring(start + COMMIT_ID_START_TAG.length, end) // remove the markers from each id and extract the id and remove empty strings return ids .split('', '').trim()) .filter(id => id !== '') } // get review commit ids comment block from the body as a string // including markers getReviewedCommitIdsBlock(commentBody: string): string { const start = commentBody.indexOf(COMMIT_ID_START_TAG) const end = commentBody.indexOf(COMMIT_ID_END_TAG) if (start === -1 || end === -1) { return '' } return commentBody.substring(start, end + COMMIT_ID_END_TAG.length) } // add a commit id to the list of reviewed commit ids // if the marker doesn't exist, add it addReviewedCommitId(commentBody: string, commitId: string): string { const start = commentBody.indexOf(COMMIT_ID_START_TAG) const end = commentBody.indexOf(COMMIT_ID_END_TAG) if (start === -1 || end === -1) { return `${commentBody}\n${COMMIT_ID_START_TAG}\n\n${COMMIT_ID_END_TAG}` } const ids = commentBody.substring(start + COMMIT_ID_START_TAG.length, end) return `${commentBody.substring( 0, start + COMMIT_ID_START_TAG.length )}${ids}\n${commentBody.substring(end)}` } // given a list of commit ids provide the highest commit id that has been reviewed getHighestReviewedCommitId( commitIds: string[], reviewedCommitIds: string[] ): string { for (let i = commitIds.length - 1; i >= 0; i--) { if (reviewedCommitIds.includes(commitIds[i])) { return commitIds[i] } } return '' } async getAllCommitIds(): Promise { const allCommits = [] let page = 1 let commits if (context && context.payload && context.payload.pull_request != null) { do { commits = await octokit.rest.pulls.listCommits({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase pull_number: context.payload.pull_request.number, // eslint-disable-next-line camelcase per_page: 100, page, request: { timeout: 10000 } }) allCommits.push(...commits.data.map(commit => commit.sha)) page++ } while (commits.data.length > 0) } return allCommits } // add in-progress status to the comment body addInProgressStatus(commentBody: string, statusMsg: string): string { const start = commentBody.indexOf(IN_PROGRESS_START_TAG) const end = commentBody.indexOf(IN_PROGRESS_END_TAG) // add to the beginning of the comment body if the marker doesn't exist // otherwise do nothing if (start === -1 || end === -1) { return `${IN_PROGRESS_START_TAG} Currently reviewing new changes in this PR... ${statusMsg} ${IN_PROGRESS_END_TAG} --- ${commentBody}` } return commentBody } // remove in-progress status from the comment body removeInProgressStatus(commentBody: string): string { const start = commentBody.indexOf(IN_PROGRESS_START_TAG) const end = commentBody.indexOf(IN_PROGRESS_END_TAG) // remove the in-progress status if the marker exists // otherwise do nothing if (start !== -1 && end !== -1) { return ( commentBody.substring(0, start) + commentBody.substring(end + IN_PROGRESS_END_TAG.length) ) } return commentBody } }