UNPKG

ds-sfcoe-ailabs

Version:

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

1,012 lines 62.3 kB
// Import necessary modules import * as path from 'node:path'; import * as fs from 'node:fs'; import * as process from 'node:process'; import { fileURLToPath, URL } from 'node:url'; import { Command, Flags } from '@oclif/core'; import * as fse from 'fs-extra'; // Import custom modules import { GitHelper, GitProviderFactory, } from '../../common/gitProvider/index.js'; import { SCATProviderFactory, SCATProviderType, } from '../../common/contextProviders/scat/index.js'; import { AIProviderFactory, } from '../../common/aiProvider/index.js'; import { Logger, Metrics, ObservabilityInitializer, } from '../../common/observability/index.js'; import { FileUtils } from '../../common/utils/fileUtils.js'; import { DocuSignRuleUtils } from '../../common/utils/index.js'; import { COMMAND_DEFAULTS, REVIEW_COMMAND_HINTS_DEFAULTS, AI_PROVIDERS, EXCLUDED_DIRECTORIES, DOCUSIGN_SPECIFIC_ISSUES, SUPPORTED_SALESFORCE_EXTENSIONS, SUPPORTED_FILE_EXTENSIONS, } from '../../common/utils/constants.js'; import { MESSAGES } from '../../messages/index.js'; const currentFilename = fileURLToPath(import.meta.url); const currentDirname = path.dirname(currentFilename); /** * @file index.ts * * AI-powered Pull Request review command that performs comprehensive static code analysis * using a hybrid approach combining Salesforce Code Analyzer (SCA) for Salesforce-specific files * and PMD for general file types, then leverages AI providers to generate intelligent code reviews. * * This command: * 1. Analyzes code diffs between two Git references * 2. Runs static analysis tools (SCA + PMD) to detect code quality issues * 3. Uses AI providers (OpenAI, Anthropic) to generate contextual review comments * 4. Identifies Docusign-specific patterns and best practices * 5. Posts comprehensive review comments to Pull Requests or outputs locally * * @author SF COE Team * @since 0.1.0 */ export default class AIPRReview extends Command { static summary = MESSAGES['pr-review'].summary; static description = MESSAGES['pr-review'].description; static examples = MESSAGES['pr-review'].examples; /** * Command line flags configuration for the AIPRReview command. * Defines all available command line options including their types, defaults, and validation */ static flags = { 'repo-dir': Flags.directory({ summary: MESSAGES['pr-review'].flags.repoDir, char: 'r', default: process.cwd(), exists: true, required: false, }), from: Flags.string({ summary: MESSAGES['pr-review'].flags.from, char: 'f', default: COMMAND_DEFAULTS.GIT_FROM_REF, required: false, }), to: Flags.string({ summary: MESSAGES['pr-review'].flags.to, char: 't', default: COMMAND_DEFAULTS.GIT_TO_REF, required: false, }), 'pull-request-id': Flags.string({ summary: MESSAGES['pr-review'].flags.pullRequestId, char: 'p', required: false, }), 'sf-config-file': Flags.file({ summary: MESSAGES['pr-review'].flags.sfConfigFile, char: 's', exists: true, required: false, }), 'pmd-config-file': Flags.file({ summary: MESSAGES['pr-review'].flags.pmdConfigFile, char: 'x', exists: true, required: false, }), 'ai-provider': Flags.option({ summary: MESSAGES['pr-review'].flags.aiProvider, char: 'a', required: true, multiple: false, default: COMMAND_DEFAULTS.AI_PROVIDER, options: [ AI_PROVIDERS.ANTHROPIC, AI_PROVIDERS.AZURE_OPENAI, AI_PROVIDERS.OPENAI, ], })(), 'ai-token': Flags.string({ summary: MESSAGES['pr-review'].flags.aiToken, char: 'n', required: true, }), 'ai-model': Flags.string({ summary: MESSAGES['pr-review'].flags.aiModel, char: 'm', default: COMMAND_DEFAULTS.AI_MODEL, required: true, }), 'ai-api-endpoint': Flags.string({ summary: MESSAGES['pr-review'].flags.aiApiEndpoint, char: 'e', default: COMMAND_DEFAULTS.AI_API_ENDPOINT, required: false, }), 'ai-api-version': Flags.string({ summary: MESSAGES['pr-review'].flags.aiApiVersion, char: 'v', default: COMMAND_DEFAULTS.AI_API_VERSION, required: false, }), 'git-provider': Flags.option({ summary: MESSAGES['pr-review'].flags.gitProvider, char: 'g', required: false, multiple: false, options: [COMMAND_DEFAULTS.GIT_PROVIDER], })(), 'git-token': Flags.string({ summary: MESSAGES['pr-review'].flags.gitToken, char: 'k', required: false, }), 'git-owner': Flags.string({ summary: MESSAGES['pr-review'].flags.gitOwner, char: 'w', required: false, }), 'git-repo': Flags.string({ summary: MESSAGES['pr-review'].flags.gitRepo, char: 'o', required: false, }), }; /** * Initialize the AIProvider and GitProvider based on command flags * * @param flags Command line flags containing provider configuration * @returns Object containing initialized AIProvider and optional GitProvider instances * @throws Error if AI provider initialization fails * @example * ```typescript * const { aiProvider, gitProvider } = AIPRReview.initializeProviders(flags); * ``` */ static initializeProviders(flags) { const aiProvider = AIProviderFactory.getInstance(flags['ai-provider'], flags['ai-token'], flags['ai-model'], flags['ai-api-endpoint'], flags['ai-api-version']); let gitProvider; if (flags['git-provider'] && flags['git-token'] && flags['git-owner'] && flags['git-repo']) { gitProvider = GitProviderFactory.getInstance(flags['git-provider'], flags['git-token'], flags['git-owner'], flags['git-repo']); } return { aiProvider, gitProvider }; } /** * Get the commit SHA for PR comments with fallback mechanism * * @param repoDir Path to the Git repository directory * @param flags Command line flags containing the 'to' commit reference * @returns Promise resolving to the commit SHA for the PR commit * @throws Error if unable to determine any valid commit SHA * @example * ```typescript * const commitSha = await AIPRReview.getCommitSha('/path/to/repo', flags); * ``` */ static async getCommitSha(repoDir, flags) { let commitSHA = ''; try { commitSHA = await GitHelper.verifyCommitSha(repoDir, flags.to); } catch { Logger.warn(`Failed to get commit SHA from ${flags.to}, trying to get latest commit from HEAD.`); try { commitSHA = await GitHelper.verifyCommitSha(repoDir, COMMAND_DEFAULTS.GIT_TO_REF); } catch { Logger.error('Failed to get any valid commit SHA'); } } return commitSHA; } /** * Create a file map from SARIF data by parsing runs and results * * @param sarifData Parsed SARIF data from PMD or SCA analysis * @returns Map of normalized file paths to review hints with rule violations * @example * ```typescript * const fileMap = AIPRReview.createFileMap(sarifData); * console.log(`Found issues in ${fileMap.size} files`); * ``` */ static createFileMap(sarifData) { const fileMap = new Map(); sarifData.runs.forEach((run) => { run.results?.forEach((result) => { if (!result.ruleId || !result.message?.text || !result.locations) return; result.locations.forEach((location) => { if (!location.physicalLocation?.artifactLocation?.uri || !location.physicalLocation?.region) return; const filePathBase = location.physicalLocation.artifactLocation.uriBaseId ?? ''; let filePath = location.physicalLocation.artifactLocation.uri; // Handle file:// URI format if (filePath.startsWith('file:')) { try { // Convert URI to local path const url = new URL(filePath); filePath = url.pathname; } catch { // If URL parsing fails, try simple string replacement filePath = filePath.replace(/^file:\/?\/?/, ''); } } // Join with base path if provided if (filePathBase) { filePath = path.join(filePathBase, filePath); } // Remove any duplicate path segments that might have been created filePath = path.normalize(filePath); const region = location.physicalLocation.region; const hint = { ruleId: result.ruleId ?? REVIEW_COMMAND_HINTS_DEFAULTS.RULE_ID, message: result.message.text ?? REVIEW_COMMAND_HINTS_DEFAULTS.MESSAGE, region, className: filePath.split('/').pop() ?? filePath, // Use file name as className }; if (!fileMap.has(filePath)) { fileMap.set(filePath, [hint]); } else { fileMap.get(filePath).push(hint); } }); }); }); return fileMap; } /** * Main command execution method that orchestrates the entire PR review workflow * * @returns Promise that resolves when the command execution is complete * @throws Error if any critical step in the workflow fails * @example * ```typescript * const diffCommand = new AIPRReview(); * await diffCommand.run(); * ``` */ async run() { // Obtain process start date/time const startTime = new Date(); // Parse command line flags const flags = (await this.parse(AIPRReview)).flags; // Initialize observability for Datadog logging and metrics ObservabilityInitializer.initialize(flags); // Initialise the const { aiProvider, gitProvider } = AIPRReview.initializeProviders(flags); // Create the Git repository directory with diff files const repoDir = flags['repo-dir']; const diffDir = await GitHelper.createDirectoryWithDiff(repoDir, flags.from, flags.to); Logger.debug(`Starting hybrid SCA+PMD code analysis workflow on: \n repoDir = ${repoDir} \n diffDir = ${diffDir}`); // Get changed line ranges for filtering vulnerabilities to only changed lines const changedLinesRanges = await GitHelper.getChangedLineRanges(repoDir, flags.from, flags.to); Logger.debug(`Changed line ranges found for ${changedLinesRanges.size} files`, Array.from(changedLinesRanges.entries())); // Get file statuses to identify new files for DocuSign-specific checks const fileStatuses = await GitHelper.getFileStatuses(repoDir, flags.from, flags.to); Logger.debug(`File statuses found for ${fileStatuses.size} files`, Array.from(fileStatuses.entries())); // Run hybrid scan: SCA for Salesforce files, PMD for others const combinedFileMap = await this.runHybridAnalysis(diffDir, flags); Logger.debug(`Combined analysis found issues in ${combinedFileMap.size} files`, JSON.stringify(combinedFileMap)); // Get the commit SHA for PR comments const commitSha = await AIPRReview.getCommitSha(repoDir, flags); // If no commit SHA is found, perform completion if (!commitSha) { await this.performCompletion(diffDir, startTime); return; } // Process combined results and generate AI-powered review if (combinedFileMap.size > 0) { await this.processSCAResults(combinedFileMap, diffDir, fileStatuses, aiProvider, flags, commitSha, gitProvider, changedLinesRanges); } else { Logger.debug('No issues found in either SCA or PMD analysis, so will delete existing bot comments if any'); // Handle the case where no violations are found - delete existing comments directly if present if (flags['pull-request-id'] && gitProvider) { await gitProvider.deleteBotComment(flags['pull-request-id'], true // General comment ); } } // Perform completion since all operations are completed await this.performCompletion(diffDir, startTime); } /** * Perform cleanup and completion tasks after the review process * * @param diffDir Directory containing the diff files * @param startTime Start time of the process */ async performCompletion(diffDir, startTime) { // Delete the temporary diff directory if it exists if (fs.existsSync(diffDir)) { fs.rmdirSync(diffDir, { recursive: true }); } // Set process duration runtime metric const processDuration = (new Date().getTime() - startTime.getTime()) / 1000; Metrics.gauge('ai_review.duration', processDuration); Metrics.gauge('ai_review.process_completed_successfully', 1); Logger.debug(`Process started at ${startTime.toISOString()} completed successfully within ${processDuration} seconds`); // Close metrics client so that all metrics are flushed to DD Metrics.close(); } /** * Process PMD/SCA results through AI analysis and generate comprehensive review comments * * @param fileMap Map of file paths to review hints from static analysis * @param diffDir Directory containing the diff files to analyze * @param fileStatuses Map of file paths to their status (modified, added, deleted) * @param aiProvider AI provider instance for code review generation * @param flags Command line flags containing configuration options * @param commitSha Git commit SHA for PR comment posting * @param repoDir Path to the repository directory * @param gitProvider Optional git provider for posting PR comments * @param changedLinesRanges Map of file paths to changed line ranges for filtering * @returns Promise that resolves when processing is complete * @throws Error if file reading or AI analysis fails */ async processSCAResults(fileMap, diffDir, fileStatuses, aiProvider, flags, commitSha, gitProvider, changedLinesRanges) { Logger.debug(`Processing PMD results for AI review with ${fileMap.size} files`); const fileContexts = await this.readAndProcessFiles(fileMap, diffDir); // Create comprehensive code snippet for AI analysis const comprehensiveCodeSnippet = this.buildCodeSnippet(fileContexts); // Flatten all hints from file contexts for AI analysis const allHintsForAI = []; for (const context of fileContexts) { allHintsForAI.push(...context.hints); } // Get AI Recommendations for the comprehensive code snippet const aiResponse = await aiProvider.reviewCode(comprehensiveCodeSnippet, allHintsForAI); Logger.debug(`Received AI Provider response: ${aiResponse.comments.length} comments for AI-assisted review`, { aiResponseCommentsCount: aiResponse.comments?.length || 0, aiResponseComments: aiResponse.comments, }); Metrics.gauge('ai_review.ai_comments_count', aiResponse.comments?.length || 0); // Build comprehensive PMD review message const comprehensiveMessage = await this.buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir); // Post the comprehensive review comment to PR or output locally await this.postPRComment(comprehensiveMessage, flags, commitSha, gitProvider); } /** * Read and process files from the file map, handling path resolution and duplicate prevention * * @param fileMap Map of file paths to review hints from static analysis * @param diffDir Directory containing the diff files to read * @returns Promise resolving to object containing file contexts with hints and status * @throws Error if file reading operations fail critically * @example * ```typescript * const fileContexts = await this.readAndProcessFiles(fileMap, diffDir); * ``` */ async readAndProcessFiles(fileMap, diffDir) { const fileContexts = []; // Read all files and collect hints const fileReadPromises = Array.from(fileMap.entries()).map(async ([filePath, hints]) => { // Normalize the file path to prevent duplicates const normalizedPath = FileUtils.normalizePath(filePath); return this.tryReadFile(normalizedPath, diffDir, hints, undefined); }); const fileReadResults = await Promise.all(fileReadPromises); const fileNames = []; for (const result of fileReadResults) { if (result) { fileNames.push(result.file.split('/').pop()); fileContexts.push(result); } } Logger.debug(`Files to process for AI-review: \n ${fileNames.join(',')}`, fileNames); return fileContexts; } /** * Try to read a file from multiple possible paths with fallback strategies * * @param filePath Original file path from static analysis * @param diffDir Directory containing diff files * @param hints Review hints associated with this file * @param fileStatus Git status of the file (A=added, M=modified, etc.) * @returns Promise resolving to FileContext object if successful, null if all attempts fail * @example * ```typescript * const fileContext = await this.tryReadFile('src/test.js', diffDir, hints, 'A'); * ``` */ async tryReadFile(filePath, diffDir, hints, fileStatus) { // Try multiple path resolution strategies and remove duplicates const uniquePaths = [ ...new Set([ path.resolve(diffDir, filePath), path.resolve(filePath), filePath.startsWith('/') ? filePath : path.resolve(diffDir, filePath), ]), ]; // Attempt to read the file from each path const readFileAttempts = uniquePaths.map((fullFilePath) => { try { const fileContent = fs.readFileSync(fullFilePath, 'utf-8'); return { file: fullFilePath, content: fileContent, hints, success: true, }; } catch (error) { Logger.debug(`Failed to read ${fullFilePath}, trying next path.`, error); return { file: filePath, content: '', hints, success: false }; } }); const results = await Promise.all(readFileAttempts); const successfulResult = results.find((result) => result.success); if (successfulResult) { return { file: successfulResult.file, content: successfulResult.content, hints: successfulResult.hints, fileStatus, }; } Logger.warn(`Failed to read file ${filePath} from any of the attempted paths:`, uniquePaths); return null; } /** * Add missing new Apex files to fileContexts for DocuSign analysis * Reuses existing tryReadFile method for consistent file reading behavior * * @param fileContexts Array of file contexts to potentially add new files to (modified in place) * @param fileStatuses Map of file paths to their status for identifying new files * @param diffDir Directory containing diff files for fallback file reading * @returns Promise that resolves when all new Apex files have been processed * @example * ```typescript * await this.addMissingNewApexFiles(fileContexts, fileStatuses, diffDir); * ``` */ async addMissingNewApexFiles(fileContexts, fileStatuses, diffDir) { const newApexFilePromises = []; // Filter for new Apex files that are not already in fileContexts for (const [filePath, status] of fileStatuses) { if (status === 'A' && FileUtils.isApexFile(filePath)) { // Reuse existing tryReadFile method for consistent behavior const promise = this.tryReadFile(filePath, diffDir, [], status); newApexFilePromises.push(promise); } } // If no new Apex files found, exit early if (newApexFilePromises.length === 0) { return; } // Process all new Apex files concurrently const results = await Promise.all(newApexFilePromises); for (const result of results) { // check if fileContexts already have this file if (result && !fileContexts.some((context) => context.file === result.file)) { fileContexts.push(result); } } } /** * Separate hints into those that occur on changed lines and those that don't, grouped by file * * @param fileContexts Array of file contexts containing file information and hints * @param changedLinesRanges Map of file paths to changed line ranges * @returns Object containing changedLinesHintsPerFile and remainingHintsPerFile maps * @example * ```typescript * const { changedLinesHintsPerFile, remainingHintsPerFile } = this.separateHintsByChangedLines(fileContexts, changedLinesRanges); * ``` */ separateHintsByChangedLines(fileContexts, changedLinesRanges) { const changedLinesHintsPerFile = new Map(); const remainingHintsPerFile = new Map(); // If no changed line ranges, all hints are remaining if (!changedLinesRanges || changedLinesRanges.size === 0) { for (const { file, hints } of fileContexts) { const shortFileName = FileUtils.getFileName(file); if (hints.length > 0) { remainingHintsPerFile.set(shortFileName, [...hints]); } } return { changedLinesHintsPerFile, remainingHintsPerFile }; } // Create a map of file names to their contexts for quick lookup const fileContextMap = new Map(); for (const context of fileContexts) { const shortFileName = FileUtils.getFileName(context.file); fileContextMap.set(shortFileName, context); } // Process hints from all file contexts and separate them for (const context of fileContexts) { const shortFileName = FileUtils.getFileName(context.file); const filePath = context.file; const normalizedPath = FileUtils.normalizePath(filePath); // Try to find changed line ranges using different path formats let changedRanges; // Try exact path match first changedRanges = changedLinesRanges.get(normalizedPath); // If not found, try relative path variants if (!changedRanges) { const fileName = FileUtils.getFileName(normalizedPath); for (const [changedPath] of changedLinesRanges) { if (changedPath.endsWith(fileName) || changedPath.includes(fileName)) { changedRanges = changedLinesRanges.get(changedPath); break; } } } // Separate hints for this file into changed and remaining const changedHints = []; const remainingHints = []; for (const hint of context.hints) { // Find the file context for this hint using className const hintFileName = hint.className || ''; const fileContext = fileContextMap.get(hintFileName); if (!fileContext || !changedRanges || changedRanges.length === 0) { // If no file context or no changed ranges, it's a remaining hint remainingHints.push(hint); continue; } // Check if this hint's line falls within any changed range const hintStartLine = hint.region.startLine ?? 1; const hintEndLine = hint.region.endLine ?? hintStartLine; const isInChangedLines = changedRanges.some((range) => (hintStartLine >= range.start && hintStartLine <= range.end) || (hintEndLine >= range.start && hintEndLine <= range.end) || (hintStartLine <= range.start && hintEndLine >= range.end)); if (isInChangedLines) { changedHints.push(hint); } else { remainingHints.push(hint); } } // Store the separated hints for this file if (changedHints.length > 0) { changedLinesHintsPerFile.set(shortFileName, changedHints); } if (remainingHints.length > 0) { remainingHintsPerFile.set(shortFileName, remainingHints); } } return { changedLinesHintsPerFile, remainingHintsPerFile }; } /** * Separate AI analysis entries into changed lines and remaining entries * * @param allTableEntries All AI analysis table entries * @param changedLinesHints Hints that are on changed lines * @returns Object containing changedLineEntries and remainingEntries */ separateAIAnalysisByChangedLines(allTableEntries, changedLinesHints) { const changedLinesAIAnalysis = []; const remainingIssuesAIAnalysis = []; // Create a set of changed line hint identifiers for quick lookup const changedLineIdentifiers = new Set(changedLinesHints.map((hint) => `${hint.className}-${hint.region.startLine ?? 1}`)); for (const entry of allTableEntries) { const entryIdentifier = `${entry.className}-${entry.lineNumber}`; if (changedLineIdentifiers.has(entryIdentifier)) { changedLinesAIAnalysis.push(entry); } else { remainingIssuesAIAnalysis.push(entry); } } return { changedLinesAIAnalysis, remainingIssuesAIAnalysis }; } /** * Build comprehensive code snippet for AI analysis by concatenating all file contents * * @param fileContexts Array of file contexts containing file names and content * @returns Formatted code snippet string with file separators for AI consumption * @example * ```typescript * const codeSnippet = this.buildCodeSnippet(fileContexts); * ``` */ buildCodeSnippet(fileContexts) { let comprehensiveCodeSnippet = ''; for (const { file, content } of fileContexts) { const shortFileName = FileUtils.getFileName(file); comprehensiveCodeSnippet += `\n=== FILE: ${shortFileName} ===\n`; comprehensiveCodeSnippet += content; comprehensiveCodeSnippet += '\n\n'; } return comprehensiveCodeSnippet; } /** * Post PR comment with the review results, either to git provider or local output * * @param message The comprehensive review message to post * @param flags Command line flags containing PR and git configuration * @param commitSha Git commit SHA for associating the comment with specific commit * @param gitProvider Optional git provider for posting comments to remote repository * @returns Promise that resolves when comment posting is complete * @throws Error if PR comment posting fails when git provider is configured */ async postPRComment(message, flags, commitSha, gitProvider) { const prCommentRequest = { prNumber: flags['pull-request-id'] ?? '', message, suggestedCodeChange: undefined, sourceFile: '', startLine: 0, endLine: 0, commitId: commitSha, }; if (flags['pull-request-id'] && gitProvider) { try { await gitProvider.upsertPRComment(prCommentRequest); Metrics.gauge('ai_review.pr_comment_posted_success', 1); } catch (error) { Logger.error('Failed to add comprehensive PR comment:', error); Metrics.gauge('ai_review.pr_comment_posted_failure', 1); } } else { Logger.debug(`No PR ID or git provider configured, outputting review locally. Generated review: ${message}`); } } /** * Build comprehensive PMD review comment including AI analysis and Docusign-specific issues * Shows both total vulnerabilities and vulnerabilities only on changed lines. * * @param aiResponse Response from AI provider containing code review comments * @param fileContexts Array of file contexts with content and associated hints * @param changedLinesRanges Map of file paths to changed line ranges for filtering * @param fileStatuses Map of file paths to their status (modified, added, etc.) * @param diffDir Directory containing diff files for reading new Apex files * @returns Formatted markdown message for PR comment with issues breakdown and AI analysis table * @example * ```typescript * const message = await this.buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir); * ``` */ async buildReviewComment(aiResponse, fileContexts, changedLinesRanges, fileStatuses, diffDir) { // Separate hints into changed lines and remaining hints in one pass const { changedLinesHintsPerFile, remainingHintsPerFile } = this.separateHintsByChangedLines(fileContexts, changedLinesRanges); /* Add all new Apex files to fileContexts if they don't already exist, ensures DocuSign-specific checks run on all new Apex files, even without vulnerabilities */ await this.addMissingNewApexFiles(fileContexts, fileStatuses, diffDir); // Get Docusign specific issues per file const docuSignIssuesPerFile = this.getDocuSignIssues(fileContexts); // Calculate counts from pre-computed maps let changedLinesIssueCount = 0; for (const fileHints of changedLinesHintsPerFile.values()) { changedLinesIssueCount += fileHints.length; } let remainingIssueCount = 0; for (const fileHints of remainingHintsPerFile.values()) { remainingIssueCount += fileHints.length; } // Calculate total DocuSign issues count let docuSignIssuesCount = 0; for (const fileIssues of docuSignIssuesPerFile.values()) { docuSignIssuesCount += fileIssues.length; } // Calculate total hints count from all file contexts const totalHintsCount = changedLinesIssueCount + remainingIssueCount; const totalIssues = totalHintsCount + docuSignIssuesCount; // Build the review message header let changedLinesIssuesMessage = ''; let docusignIssuesMessage = ''; let remainingIssuesMessage = ''; let message = ''; // Build the summary message with total issues let summaryMessage = '### 📊 Vulnerability Summary ⬇️ \n' + `- **Total Vulnerabilities:** ${totalIssues} across $files file(s)\n`; if (changedLinesIssueCount > 0) { summaryMessage += `- **Vulnerabilities in New/Modified Code:** ${changedLinesIssueCount} (Most critical; review first)\n`; changedLinesIssuesMessage += `<details><summary>‼️ ${changedLinesIssueCount} Vulnerabilities in New/Modified Code (Fix these first – they're in the code you just changed!)</summary>\n\n`; } if (docuSignIssuesCount > 0) { summaryMessage += `- **Docusign-Specific Vulnerabilities:** ${docuSignIssuesCount} (High priority due to specific requirements)\n`; docusignIssuesMessage += `<details><summary>🏢 ${docuSignIssuesCount} Docusign-Specific Vulnerabilities (These are crucial for DocuSign compliance – address them next!)</summary>\n\n`; } if (remainingIssueCount > 0) { summaryMessage += `- **Vulnerabilities in Existing unchanged Code:** ${remainingIssueCount} (Address as time permits)\n`; remainingIssuesMessage += `<details><summary>📄 ${remainingIssueCount} Vulnerabilities in Existing Code (Found in unchanged code; fix these when you have time)</summary>\n\n`; } // Build file analysis content for Changed lines, Docusign issues and remaining issues let codeAnalysisMessage = '### 🔎 Detailed Vulnerability Breakdown ⬇️\n\n'; const processedFiles = new Set(); const metricsPerType = { issueViolationsPerType: {}, issueFilesPerType: {}, docusignIssuesPerType: {}, docusignFilesPerType: {}, }; let totalVulnerableFiles = 0; for (const { file, hints } of fileContexts) { const shortFileName = FileUtils.getFileName(file); const fileType = FileUtils.getFileExtension(file); // Skip if we've already processed this file if (processedFiles.has(shortFileName)) { Logger.debug(`Skipping duplicate file: ${shortFileName}`); continue; } processedFiles.add(shortFileName); // Get Changed, DocuSign specific and Remaining issues for this file const changedHints = changedLinesHintsPerFile.get(shortFileName) ?? []; const remainingHints = remainingHintsPerFile.get(shortFileName) ?? []; const fileDocuSignIssues = docuSignIssuesPerFile.get(shortFileName) ?? []; // Add file to vulnerable files count if it has any issues if (changedHints.length !== 0 || remainingHints.length !== 0 || fileDocuSignIssues.length !== 0) { totalVulnerableFiles++; } // Count violations and #files per type from file type metricsPerType.issueFilesPerType[fileType] = (metricsPerType.issueFilesPerType[fileType] ?? 0) + 1; metricsPerType.issueViolationsPerType[fileType] = (metricsPerType.issueViolationsPerType[fileType] ?? 0) + hints.length; // Count Docusign violations and #files per type from file type if (fileDocuSignIssues.length > 0) { metricsPerType.docusignFilesPerType[fileType] = (metricsPerType.docusignFilesPerType[fileType] ?? 0) + 1; metricsPerType.docusignIssuesPerType[fileType] = (metricsPerType.docusignIssuesPerType[fileType] ?? 0) + fileDocuSignIssues.length; } // Add changed line issues for the file if (changedHints.length > 0) { changedLinesIssuesMessage += `#### 🆕 ${shortFileName} (${changedHints.length} issues)\n`; for (const hint of changedHints) { changedLinesIssuesMessage += `- **Line ${hint.region.startLine ?? 1}**: [${hint.ruleId}] ${hint.message}\n`; } changedLinesIssuesMessage += '\n'; } // Add DocuSign specific issues for this file if (fileDocuSignIssues.length > 0) { docusignIssuesMessage += `#### 🏢 ${shortFileName} (${fileDocuSignIssues.length} issues)\n`; for (const issue of fileDocuSignIssues) { docusignIssuesMessage += `- **Line ${issue.lineNumber}**: [${issue.ruleType}] ${issue.issue}\n`; } docusignIssuesMessage += '\n'; } // Add remaining issues (not in changed lines) if (remainingHints.length > 0) { remainingIssuesMessage += `#### 📄 ${shortFileName} (${remainingHints.length} issues)\n`; for (const hint of remainingHints) { remainingIssuesMessage += `- **Line ${hint.region.startLine ?? 1}**: [${hint.ruleId}] ${hint.message}\n`; } } Logger.debug(`Processed File ${shortFileName} has ${changedHints.length} changed line hints, ${remainingHints.length} remaining hints, and ${fileDocuSignIssues.length} Docusign issues`, { changedHints, remainingHints, docuSignIssues: fileDocuSignIssues, }); } // Add summary message with total vulnerable files message += summaryMessage.replace('$files', totalVulnerableFiles.toString()) + '\n---\n\n'; // Build the code analysis message with sections // Section 1: Changed Lines Analysis (PRIORITY - shown first) if (changedLinesIssuesMessage.trim()) { codeAnalysisMessage += changedLinesIssuesMessage + '</details>'; } // Section 2: Docusign Specific Issues if (docusignIssuesMessage.trim()) { codeAnalysisMessage += docusignIssuesMessage + '</details>'; } // Section 3: Remaining Issues Analysis if (remainingIssuesMessage.trim()) { codeAnalysisMessage += remainingIssuesMessage + '</details>'; } codeAnalysisMessage += '\n\n---\n\n'; message += codeAnalysisMessage; // Log the metrics for observability Logger.debug(`Total issues calculated: ${totalIssues} = SCA (${totalHintsCount}) + DocuSign (${docuSignIssuesCount}). ` + `Changed line issues: ${changedLinesIssueCount} with ${totalVulnerableFiles} vulnerable files. `, metricsPerType); Metrics.gauge('ai_review.total_issues', totalIssues); Metrics.gauge('ai_review.changed_lines_issues', changedLinesIssueCount); Metrics.gauge('ai_review.docusign_specific_issues', docuSignIssuesCount); Metrics.gauge('ai_review.remaining_issues', remainingIssueCount); Metrics.gauge('ai_review.total_vulnerable_files', totalVulnerableFiles); for (const [metricCategory, metricData] of Object.entries(metricsPerType)) { for (const [type, count] of Object.entries(metricData)) { if (!count || count === 0) continue; // Skip zero counts // Convert camelCase to snake_case for metric names const metricName = metricCategory .replace(/([A-Z])/g, '_$1') .toLowerCase(); Metrics.gauge(`ai_review.${metricName}.${type}`, count); } } // AI Suggestion Table - Include AI comments AND DocuSign specific issues const hasAiComments = aiResponse.comments && aiResponse.comments.length > 0; if (hasAiComments || docuSignIssuesCount > 0) { // Add Docusign specific entries to AI Suggestion table const docusignIssuesAIAnalysis = this.addDocuSignAnalysis(fileContexts); let allTableEntries = []; // Add AI-generated entries if (hasAiComments) { allTableEntries = this.addAIGeneratedAnalysis(aiResponse.comments, fileContexts); } // Convert changed lines hints map to array for the method const changedLinesHintsArray = []; for (const fileHints of changedLinesHintsPerFile.values()) { changedLinesHintsArray.push(...fileHints); } // Split AI analysis into changed lines and remaining const { changedLinesAIAnalysis, remainingIssuesAIAnalysis } = this.separateAIAnalysisByChangedLines(allTableEntries, changedLinesHintsArray); // Build the AI analysis message with sections let aiAnalysisMessage = '### 💡 AI Recommendations ⬇️\n\n'; // Section 1: AI Suggestion for Changed Lines Issues aiAnalysisMessage += this.generateAIAnalysisSection('‼️ Vulnerabilities in New/Modified Code (Check this first)', changedLinesAIAnalysis); // Section 2: AI Suggestion for Docusign Specific Issues aiAnalysisMessage += this.generateAIAnalysisSection('🏢 Docusign-Specific Vulnerabilities (Address these next)', docusignIssuesAIAnalysis); // Section 3: AI Suggestion for Other Issues aiAnalysisMessage += this.generateAIAnalysisSection('📄 Vulnerabilities in Existing Code (Fix when time permits)', remainingIssuesAIAnalysis); message += aiAnalysisMessage; } Logger.debug(`Final AI-review message: \n ${message}`, message); return message; } /** * Generate a formatted AI analysis table section with header, description, and entries * * @param sectionTitle The title for the AI analysis section (e.g., "Changed Files", "Docusign Specific Issues") * @param entries Array of table entries to display * @param description Optional custom description for the section * @returns Formatted markdown string for the AI analysis section * @example * ```typescript * const section = this.generateAIAnalysisSection("Changed Files", changedLineEntries, "issues in your modifications"); * ``` */ generateAIAnalysisSection(sectionTitle, entries) { if (entries.length === 0) { return ''; } let section = `<details> <summary>${sectionTitle} (${entries.length})</summary>\n\n\n`; section += REVIEW_COMMAND_HINTS_DEFAULTS.AI_ANALYSIS_TABLE_HEADERS; // Sort by class name, then by line number const sortedEntries = [...entries].sort((a, b) => { if (a.className !== b.className) { return a.className.localeCompare(b.className); } return a.lineNumber - b.lineNumber; }); // Generate table rows for (const entry of sortedEntries) { const lineInfo = entry.lineNumber > 0 ? entry.lineNumber.toString() : 'N/A'; section += `| ${entry.className} | ${lineInfo} | ${entry.ruleType} | ${entry.issue} | ${entry.suggestedFix} |\n`; } section += '</details>\n\n'; return section; } /** * Add Docusign-specific entries to the analysis table, including custom pattern checks * for JavaScript/TypeScript files and ESLint violations from static analysis hints * * @param fileContexts Array of file contexts containing file information, content, hints, and status * @returns tableEntries - Array of TableEntry objects with Docusign-specific issues * @example * ```typescript * this.addDocuSignAnalysis(fileContexts); * ``` */ addDocuSignAnalysis(fileContexts) { const tableEntries = []; for (const { file, content, fileStatus } of fileContexts) { // Process Apex files if (FileUtils.isApexFile(file)) { const shortFileName = FileUtils.getFileName(file); // Check if this is a new file (added in this commit) const isNewFile = fileStatus === 'A'; // Get all DocuSign issues for this file const docuSignIssues = DocuSignRuleUtils.getDocuSignIssuesForFile(file, content, isNewFile); // Convert DocuSign issues to table entries for (const issue of docuSignIssues) { let suggestedFix = ''; // Map issue types to their suggested fixes switch (issue.issue) { case DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.issue: suggestedFix = DOCUSIGN_SPECIFIC_ISSUES.TEST_FRAMEWORK.suggestedFix; break; case DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.issue: suggestedFix = DOCUSIGN_SPECIFIC_ISSUES.GLOBAL_DESCRIBE_USAGE.suggestedFix; break; case DOCUSIGN_SPECIFIC_ISSUES.TRANSACTION_LOGGER_MISSING.issue: suggestedFix = DOCUSIGN_SPECIFIC_ISSUES.TRANSACTION_LOGGER_MISSING .suggestedFix; break; default: suggestedFix = ''; } tableEntries.push({ className: shortFileName, lineNumber: issue.lineNumber, ruleType: issue.ruleType, issue: issue.issue, suggestedFix, }); } } } return tableEntries; } /** * Add AI-generated entries to the analysis table, filtering by PMD class name matches * * @param comments Array of AI-generated review comments * @param fileContexts Array of file contexts for class name matching * @param tableEntries Array to populate with AI-generated table entries * @returns void - method modifies tableEntries array in place * @example * ```typescript * this.addAIGeneratedAnalysis(aiResponse.comments, fileContexts, tableEntries); * ``` */ addAIGeneratedAnalysis(comments, fileContexts) { const tableEntries = []; if (!comments) { return tableEntries; } // Build set of PMD classNames (with extension) for exact matching const pmdClassNames = new Set(); for (const { file, hints } of fileContexts) { const shortFileName = file.split('/').pop() ?? file; // Add the full filename with extension pmdClassNames.add(shortFileName); // Also add className from hints if available (with extension) for (const hint of hints) { if (hint.className) { pmdClassNames.add(hint.className); } } } for (const comment of comments) { // Simple matching: only add AI comment if its className (with extension) matches a PMD className if (comment.className && pmdClassNames.has(comment.className)) { const messageText = comment.message ?? ''; const formattedMessage = messageText.replace(/\n/g, ' '); const suggestionText = comment.suggestion ?? ''; const formattedSuggestion = suggestionText ? suggestionText.replace(/\n/g, ' ') : REVIEW_COMMAND_HINTS_DEFAULTS.SUGGESTION; tableEntries.push({ className: comment.className, lineNumber: comment.startLine ?? 1, ruleType: REVIEW_COMMAND_HINTS_DEFAULTS.SUGGESTION_RULE_TYPE, issue: formattedMessage, suggestedFix: formattedSuggestion, }); } } return tableEntries; } /** * Get Docusign-specific issues for display in Code Quality Review section, grouped by file * * @param fileContexts Array of file contexts to analyze for Docusign-specific patterns * @param fileStatuses Map of file paths to their git status for identifying new files * @returns Map of file names to arrays of DocuSign-specific issues * @example * ```typescript * const issuesPerFile = this.getDocuSignIssues(fileContexts); * console.log(`Found DocuSign issues in ${issuesPerFile.size} files`); * ``` */ getDocuSignIssues(fileContexts) { const issuesPerFile = new Map(); for (const { file, content, fileStatus } of fileContexts) { const shortFileName = file.split('/').pop() ?? file; // Check if this is a new file (added in this commit) const isNewFile = fileStatus === 'A'; // Get all DocuSign issues for this file using the utility method const docuSignIssues = DocuSignRuleUtils.getDocuSignIssuesForFile(file, content, isNewFile); // Convert to the format expected by this method const fileIssues = docuSignIssues.map((issue) => ({ lineNumber: issue.lineNumber, ruleType: issue.ruleType, issue: issue.issue, })); // Add to map only if there are issues for this file if (fileIssues.length > 0) { issuesPerFile.set(shortFileName, fileIssues); } } return issuesPerFile; } /** * Run hybrid analysis workflow combining SCA for Salesforce files and PMD for other file types * * @param repoDir Path to the repository directory containing