UNPKG

@entro314labs/ai-changelog-generator

Version:

AI-powered changelog generator with MCP server support - works with most providers, online and local models

445 lines (381 loc) 14.8 kB
import { analyzeSemanticChanges, analyzeFunctionalImpact, generateAnalysisSummary, performSemanticAnalysis, assessOverallComplexity, assessRisk, assessBusinessRelevance, categorizeFile, detectLanguage, assessFileImportance, assessChangeComplexity, getWorkingDirectoryChanges } from '../../shared/utils/utils.js'; import colors from '../../shared/constants/colors.js'; export class AnalysisEngine { constructor(gitService, aiAnalysisService, gitManager = null) { this.gitService = gitService; this.aiAnalysisService = aiAnalysisService; this.gitManager = gitManager; this.gitRepoAnalyzer = null; } async _ensureGitAnalyzer() { if (!this.gitRepoAnalyzer && this.gitService?.gitManager) { const { GitRepositoryAnalyzer } = await import('../git/git-repository.analyzer.js'); this.gitRepoAnalyzer = new GitRepositoryAnalyzer(this.gitService.gitManager, this.aiAnalysisService); } return this.gitRepoAnalyzer; } async analyze(type, config = {}) { switch (type) { case 'changes': return this.analyzeCurrentChanges(config); case 'commits': return this.analyzeRecentCommits(config.limit || 10, config); case 'branches': return this.analyzeBranches(config); case 'comprehensive': return this.analyzeComprehensive(config); case 'health': return this.assessRepositoryHealth(config); default: throw new Error(`Unknown analysis type: ${type}`); } } async analyzeCurrentChanges(config = {}) { let enhancedChanges = []; try { const changes = getWorkingDirectoryChanges(); if (changes.length === 0) { console.log(colors.infoMessage('No changes detected in working directory.')); return { changes: [], analysis: null }; } console.log(colors.processingMessage(`Analyzing ${changes.length} working directory changes...`)); // Enhanced file analysis with actual diff content enhancedChanges = []; for (let i = 0; i < changes.length; i++) { const change = changes[i]; try { // Show progress every 5 files or for last file to avoid spam if (i % 5 === 0 || i === changes.length - 1) { process.stdout.write(`\r Progress: ${i + 1}/${changes.length} files analyzed`); } // Use git service to get detailed diff analysis let detailedAnalysis = null; if (this.gitService && this.gitService.analyzeWorkingDirectoryFileChange) { detailedAnalysis = await this.gitService.analyzeWorkingDirectoryFileChange(change.status, change.path); } if (detailedAnalysis) { // Use the rich analysis from git service enhancedChanges.push({ ...change, ...detailedAnalysis, // Preserve original fields for compatibility filePath: detailedAnalysis.filePath || change.path, path: change.path }); } else { // Fallback to basic analysis enhancedChanges.push({ ...change, category: categorizeFile(change.path), language: detectLanguage(change.path), importance: assessFileImportance(change.path, change.status), complexity: assessChangeComplexity(change.diff || ''), diff: change.diff || '', semanticChanges: { changeType: 'unknown', patterns: [], frameworks: [] }, functionalImpact: { scope: 'local', severity: 'low' } }); } } catch (error) { console.error(`Error processing change ${i}:`, change, error.message); enhancedChanges.push({ ...change, category: 'other', language: 'Unknown', importance: 'medium', complexity: { score: 1 }, diff: 'Analysis failed', semanticChanges: { changeType: 'unknown', patterns: [], frameworks: [] }, functionalImpact: { scope: 'local', severity: 'low' } }); } } // Clear the progress line process.stdout.write('\n'); // AI analysis if available let aiAnalysis = null; if (this.aiAnalysisService.hasAI) { try { aiAnalysis = await this.aiAnalysisService.analyzeChanges(enhancedChanges, 'working-directory'); } catch (error) { console.error('Error in AI analysis:', error.message); aiAnalysis = null; } } // Rule-based analysis let ruleBasedAnalysis = null; try { ruleBasedAnalysis = this.analyzeChangesRuleBased(enhancedChanges); } catch (error) { console.error('Error in rule-based analysis:', error.message); ruleBasedAnalysis = { summary: 'Analysis failed', category: 'other', impact: 'medium', userFacing: false }; } // Combine analyses let finalAnalysis = null; try { finalAnalysis = this.combineAnalyses(aiAnalysis, ruleBasedAnalysis); } catch (error) { console.error('Error combining analyses:', error.message); finalAnalysis = { summary: `${enhancedChanges.length} working directory changes detected`, category: 'working-directory', impact: 'medium', userFacing: false }; } return { changes: enhancedChanges, analysis: finalAnalysis, summary: generateAnalysisSummary(enhancedChanges, finalAnalysis) }; } catch (error) { console.error(colors.errorMessage('Error analyzing current changes:'), error.message); // Return enhanced changes if they were created before the error return { changes: enhancedChanges, analysis: enhancedChanges.length > 0 ? { summary: `${enhancedChanges.length} working directory changes detected (analysis failed)`, category: 'working-directory', impact: 'medium', userFacing: false } : null, error: error.message }; } } async analyzeRecentCommits(limit = 10, config = {}) { try { console.log(colors.processingMessage(`Analyzing recent ${limit} commits...`)); const commits = await this.gitService.getCommitsSince(null); const recentCommits = commits.slice(0, limit); if (recentCommits.length === 0) { console.log(colors.infoMessage('No recent commits found.')); return { commits: [], analysis: null }; } // Analyze each commit const analyzedCommits = []; for (const commit of recentCommits) { const analysis = await this.gitService.getCommitAnalysis(commit.hash); if (analysis) { // Enhanced analysis const enhancedAnalysis = await this.enhanceCommitAnalysis(analysis); analyzedCommits.push(enhancedAnalysis); } } // Generate overall analysis const overallAnalysis = this.generateOverallCommitAnalysis(analyzedCommits); return { commits: analyzedCommits, analysis: overallAnalysis, summary: this.generateCommitsSummary(analyzedCommits, overallAnalysis) }; } catch (error) { console.error(colors.errorMessage('Error analyzing recent commits:'), error.message); return { commits: [], analysis: null, error: error.message }; } } async enhanceCommitAnalysis(commitAnalysis) { try { // Add complexity assessment const complexity = assessOverallComplexity( commitAnalysis.files.map(f => f.diff).join('\n'), commitAnalysis.files.length ); // Add risk assessment const risk = assessRisk( commitAnalysis.files.map(f => f.diff).join('\n'), commitAnalysis.files.length, commitAnalysis.subject ); // Add business relevance const businessRelevance = assessBusinessRelevance( commitAnalysis.subject, commitAnalysis.files.map(f => f.filePath) ); // Enhanced semantic analysis const semanticAnalysis = performSemanticAnalysis( commitAnalysis.files, commitAnalysis.subject ); return { ...commitAnalysis, complexity, risk, businessRelevance, semanticAnalysis: { ...commitAnalysis.semanticAnalysis, ...semanticAnalysis } }; } catch (error) { console.warn(colors.warningMessage(`Failed to enhance analysis for commit ${commitAnalysis.hash}:`, error.message)); return commitAnalysis; } } analyzeChangesRuleBased(changes) { const categories = this.categorizeChanges(changes); const complexity = this.assessOverallChangeComplexity(changes); const risk = this.assessOverallChangeRisk(changes); return { categories, complexity, risk, totalFiles: changes.length, primaryCategory: Object.keys(categories)[0] || 'other', impact: this.assessChangeImpact(changes), userFacing: this.hasUserFacingChanges(changes) }; } categorizeChanges(changes) { const categories = {}; changes.forEach(change => { const category = change.category || 'other'; if (!categories[category]) categories[category] = []; categories[category].push(change); }); return categories; } assessOverallChangeComplexity(changes) { const totalComplexity = changes.reduce((sum, change) => { return sum + (change.complexity?.score || 0); }, 0); const avgComplexity = totalComplexity / changes.length; if (avgComplexity > 0.7) return 'high'; if (avgComplexity > 0.4) return 'medium'; return 'low'; } assessOverallChangeRisk(changes) { const riskFactors = changes.filter(change => change.importance === 'critical' || change.category === 'core' || change.status === 'D' // Deletions are risky ); if (riskFactors.length > changes.length * 0.3) return 'high'; if (riskFactors.length > 0) return 'medium'; return 'low'; } assessChangeImpact(changes) { if (changes.length > 20) return 'high'; if (changes.length > 5) return 'medium'; return 'low'; } hasUserFacingChanges(changes) { return changes.some(change => change.category === 'ui' || change.category === 'frontend' || (change.path && change.path.includes('/component/')) || (change.path && change.path.includes('/page/')) || (change.path && change.path.includes('/ui/')) ); } generateOverallCommitAnalysis(commits) { const types = {}; let totalFiles = 0; let totalLines = 0; let breakingChanges = 0; let highRiskCommits = 0; commits.forEach(commit => { const type = commit.categories?.[0] || 'other'; types[type] = (types[type] || 0) + 1; totalFiles += commit.files?.length || 0; totalLines += (commit.diffStats?.insertions || 0) + (commit.diffStats?.deletions || 0); if (commit.breakingChanges?.length > 0) breakingChanges++; if (commit.risk === 'high') highRiskCommits++; }); return { totalCommits: commits.length, commitTypes: types, totalFiles, totalLines, breakingChanges, highRiskCommits, riskLevel: this.calculateOverallRisk(commits), complexity: this.calculateOverallComplexity(commits), trends: this.analyzeTrends(commits) }; } calculateOverallRisk(commits) { const highRisk = commits.filter(c => c.risk === 'high').length; const ratio = highRisk / commits.length; if (ratio > 0.3) return 'high'; if (ratio > 0.1) return 'medium'; return 'low'; } calculateOverallComplexity(commits) { const avgFiles = commits.reduce((sum, c) => sum + (c.files?.length || 0), 0) / commits.length; const avgLines = commits.reduce((sum, c) => sum + ((c.diffStats?.insertions || 0) + (c.diffStats?.deletions || 0)), 0) / commits.length; if (avgFiles > 15 || avgLines > 200) return 'high'; if (avgFiles > 5 || avgLines > 50) return 'medium'; return 'low'; } analyzeTrends(commits) { // Simple trend analysis based on recent patterns const recentCommits = commits.slice(0, Math.min(5, commits.length)); const categories = recentCommits.map(c => c.categories?.[0] || 'other'); const categoryCount = {}; categories.forEach(cat => { categoryCount[cat] = (categoryCount[cat] || 0) + 1; }); const dominantCategory = Object.entries(categoryCount) .sort(([,a], [,b]) => b - a)[0]; return { dominantCategory: dominantCategory?.[0] || 'mixed', frequency: dominantCategory?.[1] || 0, pattern: this.identifyPattern(categories) }; } identifyPattern(categories) { if (categories.every(cat => cat === categories[0])) return 'focused'; if (new Set(categories).size === categories.length) return 'diverse'; return 'mixed'; } combineAnalyses(aiAnalysis, ruleBasedAnalysis) { if (!aiAnalysis) return ruleBasedAnalysis; return { ...ruleBasedAnalysis, aiInsights: aiAnalysis, confidence: aiAnalysis ? 'high' : 'medium', source: aiAnalysis ? 'ai+rules' : 'rules' }; } generateCommitsSummary(commits, analysis) { const { totalCommits, commitTypes, riskLevel, complexity } = analysis; let summary = `Analyzed ${totalCommits} recent commits. `; if (Object.keys(commitTypes).length > 0) { const dominantType = Object.entries(commitTypes) .sort(([,a], [,b]) => b - a)[0]; summary += `Primary activity: ${dominantType[1]} ${dominantType[0]} commits. `; } summary += `Risk level: ${riskLevel}. Complexity: ${complexity}.`; if (analysis.breakingChanges > 0) { summary += ` ⚠️ ${analysis.breakingChanges} commits contain breaking changes.`; } return summary; } // Delegate to GitRepositoryAnalyzer for specialized analysis async analyzeBranches(config) { const analyzer = await this._ensureGitAnalyzer(); if (analyzer) { return await analyzer.analyzeBranches(config?.format || 'markdown'); } return { branches: [], analysis: 'Git analyzer not available' }; } async analyzeComprehensive(config) { const analyzer = await this._ensureGitAnalyzer(); if (analyzer) { return await analyzer.analyzeComprehensive(config?.format || 'markdown'); } return { comprehensive: true, analysis: 'Git analyzer not available' }; } async assessRepositoryHealth(config) { const analyzer = await this._ensureGitAnalyzer(); if (analyzer) { return await analyzer.assessRepositoryHealth(config || {}); } return { health: 'unknown', analysis: 'Git analyzer not available' }; } }