UNPKG

sfcoe-ailabs

Version:

AI-powered code review tool with static analysis integration for comprehensive code quality assessment.

310 lines (295 loc) 14.6 kB
import got from 'got'; import { Logger, Metrics } from '../utils/index.js'; import { GITHUB_BOT_IDENTIFIER_DEFAULTS, GITHUB_API_DEFAULTS, } from '../utils/constants.js'; import GitProvider from './gitProvider.js'; export default class GitHub extends GitProvider { /** * Check if a comment was created by this bot using multiple identification methods * * Identification Methods (in order of priority): * 1. Hidden HTML Comment: <!-- AI-PR-Review-Bot: sfcoe-ailabs --> (backward compatibility) * 2. Visible Bot Header: Comments starting with "🤖 **AI Code Quality Review Bot**" * 3. Footer Signature: "*Generated using the npm package sfcoe-ailabs" * * @param comment - GitHub comment object * @returns true if comment was created by this bot */ static isBotComment(comment) { return (comment.body.startsWith(GITHUB_BOT_IDENTIFIER_DEFAULTS.IDENTIFIER) || comment.body.includes(GITHUB_BOT_IDENTIFIER_DEFAULTS.COMMENT_HEADER) || comment.body.includes(GITHUB_BOT_IDENTIFIER_DEFAULTS.COMMENT_FOOTER)); } /** * Create a PR comment using GitHub API * * @param url - The GitHub API URL for creating comments * @param headers - HTTP headers including authorization * @param prComment - The pull request comment data to post * @param isGeneralComment - Whether this is a general comment (Issues API) or line-specific (Pull Requests API) * @returns Promise that resolves when comment is created * @throws Error if GitHub API request fails */ static async createComment(url, headers, prComment, isGeneralComment) { const requestBody = { body: prComment.message, }; // Add line-specific properties only for line-specific comments if (!isGeneralComment) { requestBody.path = prComment.sourceFile; requestBody.start_side = GITHUB_API_DEFAULTS.START_SIDE; requestBody.side = GITHUB_API_DEFAULTS.START_SIDE; requestBody.commit_id = prComment.commitId; // For single line comments, only use 'line' // For multi-line comments, use both 'start_line' and 'line' if (prComment.startLine === prComment.endLine) { requestBody.line = prComment.endLine; } else { requestBody.start_line = prComment.startLine; requestBody.line = prComment.endLine; } } await got.post(url, { json: requestBody, headers, responseType: GITHUB_API_DEFAULTS.RESPONSE_TYPE, }); } /** * Upserts a pull request comment to GitHub. * This method handles both general comments and line-specific comments. * It checks for existing bot comments and updates them if found, or creates a new comment if not. * * @param prComment - The pull request comment data to post */ async upsertPRComment(prComment) { try { Logger.debug(`Adding/updating AI-reviewed PR comment to GitHub: \n ${prComment.message}`, prComment.message); // Check if this is a general PR comment (no source file specified) const isGeneralComment = !prComment.sourceFile || prComment.sourceFile === ''; // Add bot identifier to the message - using visible header instead of hidden comment const messageWithIdentifier = `${GITHUB_BOT_IDENTIFIER_DEFAULTS.IDENTIFIER}\n## ${GITHUB_BOT_IDENTIFIER_DEFAULTS.COMMENT_HEADER}\n\n${prComment.message}${prComment.suggestedCodeChange ? '\n\n```suggestion\n' + prComment.suggestedCodeChange + '\n```' : ''}\n\n---\n${GITHUB_BOT_IDENTIFIER_DEFAULTS.COMMENT_FOOTER}`; // Check for existing bot comment const existingCommentId = await this.findExistingBotComment(prComment.prNumber, isGeneralComment); if (existingCommentId) { // Update existing comment await this.updateExistingComment(existingCommentId, messageWithIdentifier, isGeneralComment); Logger.debug(`Successfully updated existing PR comment with ID: ${existingCommentId}`); } else { // Create new comment Logger.debug('Creating new bot comment'); // Get URL and headers using helper methods const url = this.getCommentUrl(prComment.prNumber, isGeneralComment); const headers = this.getApiHeaders(); // Create comment with modified prComment that includes bot identifier const modifiedPrComment = { ...prComment, message: messageWithIdentifier, suggestedCodeChange: undefined, // Already included in message }; // Upsert the comment await GitHub.createComment(url, headers, modifiedPrComment, isGeneralComment); Logger.info('Successfully posted new PR comment'); } } catch (error) { const gitError = error; Logger.error('Failed to add PR comment:', gitError.message ?? 'Unknown error'); if (gitError.response) { if (gitError.response.statusCode === 403) { const responseBody = gitError.response.body; // Check for SAML enforcement error if (responseBody?.message?.includes('SAML enforcement')) { throw new Error(GITHUB_API_DEFAULTS.SAML_ERROR_MESSAGE); } throw new Error(`GitHub API access forbidden. Please check: 1. Your GitHub token has the required permissions (repo scope for private repos, public_repo for public repos) 2. The token is valid and not expired 3. You have write access to the repository 4. The repository owner and name are correct: ${this.getOwner()}/${this.getRepo()} 5. The pull request ID ${prComment.prNumber} exists and is open 6. If your organization uses SAML SSO, ensure your token is authorized for the organization`); } if (gitError.response.statusCode === 404) { throw new Error(`GitHub resource not found. Please verify: \n 1. Repository exists: ${this.getOwner()}/${this.getRepo()} \n 2. Pull request exists: #${prComment.prNumber} \n3. Your token has access to this repository`); } if (gitError.response.statusCode === 422) { const responseBody = gitError.response.body; // Check for specific GitHub PR comment validation errors if (responseBody?.errors) { const hasLineValidationError = responseBody.errors.some((err) => err.field?.includes('line') && err.message?.includes('must be part of the diff')); const hasDiffHunkError = responseBody.errors.some((err) => err.field?.includes('diff_hunk')); if (hasLineValidationError || hasDiffHunkError) { throw new Error(`❌ GitHub PR Comment Validation Failed: Line not in diff The line numbers specified are not part of the actual diff shown in the Pull Request. COMMON CAUSES & SOLUTIONS: 1. **Line numbers don't match the diff**: GitHub can only comment on lines that are actually changed - The code analyzer found issues on lines that weren't modified in the PR - Solution: Only comment on changed lines, or create general PR comments instead 2. **File path issues**: The file path might not match exactly - File: ${prComment.sourceFile} - Ensure the file path is relative to the repository root 3. **Diff context**: GitHub requires comments to be on lines within the diff context - Lines: ${prComment.startLine}-${prComment.endLine} - Try running with a larger diff context or focus on modified files only 4. **Commit mismatch**: The commit SHA might not reflect the current PR state - Commit: ${prComment.commitId} - Try: git fetch && git pull to update your local repository POSSIBLE FIXES: - Use --from and --to flags to specify exact commit range - Focus analysis on only modified files - Use general PR comments instead of line-specific comments DEBUG INFO: - Repository: ${this.getOwner()}/${this.getRepo()} - PR: #${prComment.prNumber} - File: ${prComment.sourceFile} - Lines: ${prComment.startLine}-${prComment.endLine} - Commit: ${prComment.commitId}`); } } // Check for commit SHA not in PR error if (responseBody?.message?.includes("doesn't exist in the PR") ?? (responseBody?.message?.includes('commit') && responseBody?.message?.includes('not found'))) { throw new Error(`❌ GitHub PR Comment Failed: Commit not found in PR The commit SHA (${prComment.commitId}) doesn't exist in this Pull Request. COMMON CAUSES & SOLUTIONS: 1. **Wrong branch comparison**: Ensure you're comparing the correct branches - Use --from=origin/main --to=HEAD (or your PR branch) - The 'to' branch should be the one with your changes 2. **Outdated local repository**: Update your local repo - git fetch origin - git pull origin main 3. **Force-pushed changes**: If the PR was force-pushed, the old commits no longer exist - Try running the command again with the latest commit 4. **Detached HEAD**: Ensure you're on the correct branch - git checkout your-feature-branch DEBUG INFO: - Repository: ${this.getOwner()}/${this.getRepo()} - PR Number: ${prComment.prNumber} - File: ${prComment.sourceFile} - Commit SHA: ${prComment.commitId} - Lines: ${prComment.startLine}-${prComment.endLine}`); } throw new Error(`GitHub API validation failed. Response: ${JSON.stringify(responseBody, null, 2)}`); } } throw error; } } /** * Delete an existing bot comment from the PR * * @param prNumber - Pull request number * @param isGeneralComment - Whether to look for general or line-specific comments * @returns Promise that resolves when comment is deleted, or null if no comment found */ async deleteBotComment(prNumber, isGeneralComment = true) { try { // Find existing bot comment const existingCommentId = await this.findExistingBotComment(prNumber, isGeneralComment); if (!existingCommentId) { Logger.debug('No existing bot comment found to delete, exiting the process'); return; } // Get URL and headers using helper methods const url = this.getCommentUrl('', isGeneralComment, existingCommentId); const headers = this.getApiHeaders(); await got.delete(url, { headers, responseType: GITHUB_API_DEFAULTS.RESPONSE_TYPE, }); Logger.info(`✅ Successfully deleted existing bot comment with ID: ${existingCommentId} since all vulnerabilities are fixed.`); Metrics.gauge('ai_review.vulnerabilities_fixed', 1); return; } catch (error) { Logger.error('Failed to delete bot comment:', error); return; } } /** * Get the appropriate GitHub API URL for PR comments * * @param prNumber - Pull request number * @param isGeneralComment - Whether this is a general comment (issues API) or line-specific (pulls API) * @param commentId - Optional comment ID for update operations * @returns The GitHub API URL for the comment operation */ getCommentUrl(prNumber, isGeneralComment, commentId) { const baseUrl = `${GITHUB_API_DEFAULTS.PR_BASE_URL}/${this.getOwner()}/${this.getRepo()}`; if (isGeneralComment) { return commentId ? `${baseUrl}/issues/comments/${commentId}` : `${baseUrl}/issues/${prNumber}/comments`; } else { return commentId ? `${baseUrl}/pulls/comments/${commentId}` : `${baseUrl}/pulls/${prNumber}/comments`; } } /** * Get the standard headers for GitHub API requests * * @returns Headers object with authorization and API version */ getApiHeaders() { return { Authorization: `token ${this.getToken()}`, 'X-GitHub-Api-Version': GITHUB_API_DEFAULTS.API_VERSION, Accept: GITHUB_API_DEFAULTS.ACCEPT_HEADER, 'User-Agent': GITHUB_API_DEFAULTS.USER_AGENT, }; } /** * Find existing bot comment in the PR using multiple identification methods * * @param prNumber - Pull request number * @param isGeneralComment - Whether to look for general or line-specific comments * @returns Promise that resolves to the comment ID if found, null otherwise */ async findExistingBotComment(prNumber, isGeneralComment) { try { // Get URL and headers using helper methods const url = this.getCommentUrl(prNumber, isGeneralComment); const headers = this.getApiHeaders(); const response = await got.get(url, { headers, responseType: 'json', }); const comments = response.body; // Use the new bot identification method const botComment = comments.find((comment) => GitHub.isBotComment(comment)); return botComment ? botComment.id : null; } catch (error) { Logger.error('Failed to find existing bot comment:', error); return null; } } /** * Update an existing comment * * @param commentId - The ID of the comment to update * @param body - The new comment body * @param isGeneralComment - Whether this is a general or line-specific comment * @returns Promise that resolves when comment is updated */ async updateExistingComment(commentId, body, isGeneralComment) { // Get URL and headers using helper methods const url = this.getCommentUrl('', isGeneralComment, commentId); const headers = this.getApiHeaders(); await got.patch(url, { json: { body, }, headers, responseType: GITHUB_API_DEFAULTS.RESPONSE_TYPE, }); } }