sfcoe-ailabs
Version:
AI-powered code review tool with static analysis integration for comprehensive code quality assessment.
310 lines (295 loc) • 14.6 kB
JavaScript
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,
});
}
}