UNPKG

@entro314labs/ai-changelog-generator

Version:

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

444 lines (363 loc) • 13.9 kB
import { runInteractiveMode, analyzeChangesForCommitMessage, selectSpecificCommits } from '../../shared/utils/utils.js'; import colors from '../../shared/constants/colors.js'; /** * Interactive Workflow Service * * Interactive workflow management and commit message utilities * Provides user interaction features including: * - Interactive mode for guided changelog generation * - Commit message validation and suggestions * - Commit selection interfaces * - Change analysis for commit message generation */ export class InteractiveWorkflowService { constructor(gitService, aiAnalysisService, changelogService) { this.gitService = gitService; this.aiAnalysisService = aiAnalysisService; this.changelogService = changelogService; } async runInteractiveMode() { return await runInteractiveMode(); } async validateCommitMessage(message) { if (!message || message.trim().length === 0) { return { valid: false, issues: ['Commit message is empty'], suggestions: ['Provide a descriptive commit message'] }; } const issues = []; const suggestions = []; // Length validation if (message.length < 10) { issues.push('Commit message is too short (minimum 10 characters)'); suggestions.push('Add more detail about what was changed'); } if (message.length > 72) { issues.push('Commit message first line is too long (maximum 72 characters)'); suggestions.push('Keep the first line concise, add details in the body'); } // Format validation const lines = message.split('\n'); const firstLine = lines[0]; // Check for conventional commit format const conventionalPattern = /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .+/; if (!conventionalPattern.test(firstLine)) { suggestions.push('Consider using conventional commit format: type(scope): description'); } // Check for imperative mood const imperativeWords = ['add', 'fix', 'update', 'remove', 'create', 'implement', 'refactor']; const firstWord = firstLine.split(' ')[0].toLowerCase().replace(/[^a-z]/g, ''); if (!imperativeWords.some(word => firstWord.startsWith(word))) { suggestions.push('Use imperative mood (e.g., "Add feature" not "Added feature")'); } // Check for body separation if (lines.length > 1 && lines[1].trim() !== '') { issues.push('Missing blank line between subject and body'); suggestions.push('Add a blank line after the first line if including a body'); } return { valid: issues.length === 0, issues, suggestions, score: Math.max(0, 100 - (issues.length * 20) - (suggestions.length * 10)) }; } async generateCommitSuggestion(message = null) { try { // If no message provided, analyze current changes let analysisContext = ''; if (!message) { // Use the shared utility function for getting working directory changes const { getWorkingDirectoryChanges } = await import('../../shared/utils/utils.js'); const changes = getWorkingDirectoryChanges(); if (changes && changes.length > 0) { analysisContext = this.analyzeChangesForCommitMessage(changes, true); } } // Use AI to suggest better commit message if (this.aiAnalysisService.hasAI) { const prompt = message ? `Improve this commit message following conventional commit format and best practices: Original: "${message}" Requirements: - Use conventional commit format (type(scope): description) - Keep first line under 72 characters - Use imperative mood - Be specific and descriptive Provide 3 alternative suggestions.` : `Suggest a commit message for these changes: ${analysisContext} Requirements: - Use conventional commit format (type(scope): description) - Keep first line under 72 characters - Use imperative mood - Be specific and descriptive Provide 3 suggestions.`; const response = await this.aiAnalysisService.aiProvider.generateCompletion([{ role: 'user', content: prompt }], { max_tokens: 200 }); return { success: true, original: message, suggestions: this.parseCommitSuggestions(response.content), context: analysisContext }; } else { // Rule-based suggestions return this.generateRuleBasedCommitSuggestion(message, analysisContext); } } catch (error) { console.error(colors.errorMessage(`Error generating commit suggestion: ${error.message}`)); return { success: false, error: error.message, fallback: this.generateRuleBasedCommitSuggestion(message) }; } } analyzeChangesForCommitMessage(changes, includeScope = false) { if (!changes || changes.length === 0) { return 'No changes detected'; } const categories = this.categorizeChanges(changes); const primaryCategory = Object.keys(categories)[0]; const fileCount = changes.length; let summary = `${fileCount} file${fileCount === 1 ? '' : 's'} changed`; if (primaryCategory) { summary += ` in ${primaryCategory}`; } if (includeScope) { const scopes = this.extractScopes(changes); if (scopes.length > 0) { summary += ` (scope: ${scopes.join(', ')})`; } } // Add change details const additions = changes.filter(c => c.status === 'A').length; const modifications = changes.filter(c => c.status === 'M').length; const deletions = changes.filter(c => c.status === 'D').length; const details = []; if (additions > 0) details.push(`${additions} added`); if (modifications > 0) details.push(`${modifications} modified`); if (deletions > 0) details.push(`${deletions} deleted`); if (details.length > 0) { summary += ` (${details.join(', ')})`; } return summary; } async selectSpecificCommits() { return await selectSpecificCommits(); } async generateChangelogForRecentCommits(count = 10) { try { console.log(colors.processingMessage(`šŸ“ Generating changelog for recent ${count} commits...`)); const commits = await this.gitService.getCommitsSince(null); const recentCommits = commits.slice(0, count); if (recentCommits.length === 0) { console.log(colors.infoMessage('No recent commits found.')); return null; } const result = await this.changelogService.generateChangelogBatch( recentCommits.map(c => c.hash) ); console.log(colors.successMessage(`āœ… Generated changelog for ${recentCommits.length} commits`)); return result; } catch (error) { console.error(colors.errorMessage(`Error generating changelog for recent commits: ${error.message}`)); throw error; } } async generateChangelogForCommits(commitHashes) { if (!commitHashes || commitHashes.length === 0) { console.log(colors.warningMessage('No commit hashes provided')); return null; } try { console.log(colors.processingMessage(`šŸ“ Generating changelog for ${commitHashes.length} specific commits...`)); // Validate commit hashes const validCommits = []; for (const hash of commitHashes) { const analysis = await this.gitService.getCommitAnalysis(hash); if (analysis) { validCommits.push(analysis); } else { console.warn(colors.warningMessage(`Invalid or inaccessible commit: ${hash}`)); } } if (validCommits.length === 0) { console.log(colors.errorMessage('No valid commits found')); return null; } // Generate AI summaries for each commit const analyzedCommits = []; for (let i = 0; i < validCommits.length; i++) { const commit = validCommits[i]; console.log(colors.processingMessage(`Processing commit ${i + 1}/${validCommits.length}: ${colors.hash(commit.hash)}`)); const selectedModel = await this.aiAnalysisService.selectOptimalModel(commit); const aiSummary = await this.aiAnalysisService.generateAISummary(commit, selectedModel); if (aiSummary) { analyzedCommits.push({ ...commit, aiSummary, type: aiSummary.category || 'other', breaking: aiSummary.breaking || false }); } } if (analyzedCommits.length === 0) { console.log(colors.errorMessage('No commits could be analyzed')); return null; } // Generate release insights and build changelog const insights = await this.changelogService.generateReleaseInsights(analyzedCommits, 'Selected Commits'); const changelog = await this.changelogService.buildChangelog(analyzedCommits, insights, 'Selected Commits'); console.log(colors.successMessage(`āœ… Generated changelog for ${analyzedCommits.length} commits`)); return { changelog, insights, analyzedCommits, requestedCommits: commitHashes.length, processedCommits: analyzedCommits.length }; } catch (error) { console.error(colors.errorMessage(`Error generating changelog for commits: ${error.message}`)); throw error; } } // Helper methods categorizeChanges(changes) { const categories = {}; changes.forEach(change => { const category = this.getFileCategory(change.path); if (!categories[category]) categories[category] = []; categories[category].push(change); }); // Sort by count return Object.fromEntries( Object.entries(categories).sort(([,a], [,b]) => b.length - a.length) ); } getFileCategory(filePath) { if (!filePath || typeof filePath !== 'string') { return 'other'; } const path = filePath.toLowerCase(); if (path.includes('/test/') || path.includes('.test.') || path.includes('.spec.')) { return 'tests'; } if (path.includes('/doc/') || path.endsWith('.md') || path.endsWith('.txt')) { return 'documentation'; } if (path.includes('/config/') || path.endsWith('.json') || path.endsWith('.yaml')) { return 'configuration'; } if (path.includes('/src/') || path.includes('/lib/')) { return 'source'; } if (path.includes('/style/') || path.endsWith('.css') || path.endsWith('.scss')) { return 'styles'; } return 'other'; } extractScopes(changes) { const scopes = new Set(); changes.forEach(change => { const parts = change.path.split('/'); if (parts.length > 1) { // Extract directory name as scope const scope = parts[parts.length - 2]; if (scope && scope !== '.' && scope !== '..') { scopes.add(scope); } } }); return Array.from(scopes).slice(0, 3); // Limit to 3 scopes } parseCommitSuggestions(content) { // Parse AI response to extract suggestions const lines = content.split('\n').filter(line => line.trim()); const suggestions = []; lines.forEach(line => { // Remove numbering and clean up const cleaned = line.replace(/^\d+\.\s*/, '').replace(/^-\s*/, '').trim(); if (cleaned && cleaned.length > 10 && cleaned.includes(':')) { suggestions.push(cleaned); } }); return suggestions.length > 0 ? suggestions : [content.trim()]; } generateRuleBasedCommitSuggestion(message, context) { const suggestions = []; if (message) { // Improve existing message const improved = this.improveCommitMessage(message); suggestions.push(improved); } if (context) { // Generate from context const fromContext = this.generateFromContext(context); suggestions.push(fromContext); } // Add generic suggestions suggestions.push( 'feat: add new functionality', 'fix: resolve issue with component', 'docs: update documentation' ); return { success: true, suggestions: suggestions.slice(0, 3), source: 'rule-based' }; } improveCommitMessage(message) { // Basic improvements let improved = message.trim(); // Add conventional commit prefix if missing if (!/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)/.test(improved)) { improved = `feat: ${improved}`; } // Ensure imperative mood improved = improved.replace(/^(\w+)ed\s/, '$1 '); improved = improved.replace(/^(\w+)s\s/, '$1 '); return improved; } generateFromContext(context) { if (!context || typeof context !== 'string') { return 'feat: implement changes'; } if (context.includes('test')) { return 'test: add test coverage'; } if (context.includes('doc')) { return 'docs: update documentation'; } if (context.includes('config')) { return 'chore: update configuration'; } if (context.includes('fix') || context.includes('bug')) { return 'fix: resolve issue'; } return 'feat: implement changes'; } // Utility method for displaying interactive results displayInteractiveResults(results) { if (!results) return; console.log(colors.header('\nšŸ“Š Interactive Session Results:')); if (results.changelog) { console.log(colors.subheader('Generated Changelog:')); console.log(results.changelog); } if (results.insights) { console.log(colors.subheader('\nšŸ“ˆ Insights:')); console.log(`Total commits: ${colors.number(results.insights.totalCommits)}`); console.log(`Risk level: ${colors.highlight(results.insights.riskLevel)}`); } if (results.analyzedCommits) { console.log(colors.subheader(`\nšŸ” Processed ${colors.number(results.analyzedCommits.length)} commits`)); } } }