UNPKG

@entro314labs/ai-changelog-generator

Version:

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

548 lines (539 loc) • 23.2 kB
import { execSync } from 'node:child_process'; import process from 'node:process'; import colors from '../../shared/constants/colors.js'; import { EnhancedConsole, SimpleSpinner } from '../../shared/utils/cli-ui.js'; import { runInteractiveMode, selectSpecificCommits } from '../../shared/utils/utils.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) { let analysisContext = ''; try { // If no message provided, analyze current changes 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. Original: "${message}" Provide EXACTLY 3 alternative commit messages, one per line, numbered 1-3. Do NOT include any other text, explanations, or conversational phrases. Requirements: - Use conventional commit format: type(scope): description - Keep first line under 72 characters - Use imperative mood - Be specific and descriptive Example output format: 1. feat(auth): add OAuth2 authentication support 2. fix(api): resolve timeout in user endpoint 3. refactor(db): optimize query performance` : `Generate commit messages for these changes: ${analysisContext} Provide EXACTLY 3 commit message suggestions, one per line, numbered 1-3. Do NOT include any other text, explanations, or conversational phrases. Requirements: - Use conventional commit format: type(scope): description - Keep first line under 72 characters - Use imperative mood - Be specific and descriptive Example output format: 1. feat(auth): add OAuth2 authentication support 2. fix(api): resolve timeout in user endpoint 3. refactor(db): optimize query performance`; const response = await this.aiAnalysisService.generateCompletion([ { role: 'user', content: prompt, }, ], { max_tokens: 200 }); return { success: true, original: message, suggestions: this.parseCommitSuggestions(response.content), context: analysisContext, }; } // Rule-based suggestions return this.generateRuleBasedCommitSuggestion(message, analysisContext); } catch (error) { EnhancedConsole.error(`Error generating commit suggestion: ${error.message}`); return { success: false, error: error.message, fallback: this.generateRuleBasedCommitSuggestion(message, analysisContext), }; } } 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(', ')})`; } // Add actual change summaries from git diff for better context summary += '\n\nKey changes:\n'; try { // Get a concise summary of actual changes, limiting to top 10 files const filesToAnalyze = changes.slice(0, 10); filesToAnalyze.forEach((change) => { const filePath = change.filePath || change.path; const status = change.status; try { if (status === 'M' || status === 'MM') { // For modified files, get a brief diff stat const diffStat = execSync(`git diff HEAD -- "${filePath}" | head -50`, { encoding: 'utf8', maxBuffer: 1024 * 100, }); if (diffStat) { // Extract some meaningful info from diff const addedLines = (diffStat.match(/^\+(?!\+)/gm) || []).length; const removedLines = (diffStat.match(/^-(?!-)/gm) || []).length; // Try to extract function/class names or key changes const meaningfulLines = diffStat .split('\n') .filter((line) => line.startsWith('+') && (line.includes('function') || line.includes('class') || line.includes('const') || line.includes('export') || line.includes('import') || line.includes('async') || line.includes('//'))) .slice(0, 2) .map((line) => line.substring(1).trim()); if (meaningfulLines.length > 0) { summary += `- ${filePath}: +${addedLines}/-${removedLines} lines (${meaningfulLines[0].substring(0, 60)}...)\n`; } else { summary += `- ${filePath}: +${addedLines}/-${removedLines} lines\n`; } } } else if (status === 'A' || status === '??') { summary += `- ${filePath}: New file\n`; } else if (status === 'D') { summary += `- ${filePath}: Deleted\n`; } } catch { // Skip files that can't be diffed } }); if (changes.length > 10) { summary += `... and ${changes.length - 10} more files\n`; } } catch (error) { // If diff analysis fails, fall back to basic summary console.warn('Could not analyze diffs:', error.message); } return summary; } async generateChangelogForCommitHashes(commitHashes) { return await this.generateChangelogForCommits(commitHashes); } async selectSpecificCommits() { return await selectSpecificCommits(); } async generateChangelogForRecentCommits(count = 10) { let spinner = null; try { spinner = new SimpleSpinner(`Generating changelog for recent ${count} commits...`); spinner.start(); const commits = (await this.gitService.getCommitsSince?.(null)) || (await this.gitService.getCommitAnalysis?.()) || []; const recentCommits = commits.slice(0, count); if (recentCommits.length === 0) { spinner.stop(); EnhancedConsole.info('No recent commits found.'); return null; } const result = await this.changelogService.generateChangelogBatch(recentCommits.map((c) => c.hash)); spinner.succeed(`Generated changelog for ${recentCommits.length} commits`); return result; } catch (error) { if (spinner) { spinner.fail('Failed to generate changelog'); } EnhancedConsole.error(`Error generating changelog for recent commits: ${error.message}`); throw error; } } /** * Resolve user-supplied commit references into full commit SHAs. * * Accepts (C-6): * - full or short hashes (e.g. `a1b2c3d`) * - symbolic refs (e.g. `HEAD~2`, `main`, tags like `v1.2.0`) * - ranges (e.g. `v1.0.0..v2.0.0`, `HEAD~5..HEAD`), expanded to every commit in range * * Each non-range ref is verified with `git rev-parse --verify <ref>^{commit}` so * that short hashes and tags resolve to a full SHA the analysis pipeline accepts * (its hash validator only admits 6-40 char hex, which would otherwise reject * tags/symbolic refs). Invalid refs are warned about and skipped. Order is * preserved and duplicates removed. */ resolveCommitRefs(refs) { const gitManager = this.gitService?.gitManager; const resolved = []; const seen = new Set(); const add = (hash) => { const normalized = String(hash || '').trim(); if (normalized && !seen.has(normalized)) { seen.add(normalized); resolved.push(normalized); } }; for (const ref of refs) { const value = String(ref || '').trim(); if (!value) { continue; } // Range syntax (e.g. v1.0.0..v2.0.0): expand to every commit in range via // rev-list. --reverse yields chronological (oldest-first) order. if (value.includes('..')) { const [fromRef, toRef] = value.split('..'); if (!fromRef) { EnhancedConsole.warn(`Invalid commit range (missing start): ${value}`); continue; } const output = gitManager?.execGitSafe?.(`git rev-list --reverse ${fromRef}..${toRef || 'HEAD'}`); if (output?.trim()) { output .split('\n') .map((line) => line.trim()) .filter(Boolean) .forEach(add); } else { EnhancedConsole.warn(`No commits found (or invalid range): ${value}`); } continue; } // Single ref/hash: verify and normalize to a full SHA. const fullHash = gitManager ?.execGitSafe?.(`git rev-parse --verify "${value}^{commit}"`) ?.trim(); if (fullHash) { add(fullHash); } else { EnhancedConsole.warn(`Invalid or inaccessible commit ref: ${value}`); } } return resolved; } async generateChangelogForCommits(commitHashes, options = {}) { if (!commitHashes || commitHashes.length === 0) { EnhancedConsole.warn('No commit hashes provided'); return null; } try { const spinner = new SimpleSpinner(`Generating changelog for ${commitHashes.length} specific commits...`); spinner.start(); // Resolve refs/ranges/short-hashes to full SHAs the analysis pipeline accepts. const resolvedHashes = this.resolveCommitRefs(commitHashes); if (resolvedHashes.length === 0) { spinner.fail('No valid commits found'); return null; } // Use the SAME analysis path as the default changelog flow so commits are // RETAINED even when AI is unavailable. processCommitsSequentially / // generateChangelogBatch classify each commit via classifyCommit (rule-based // fallback) and push every analyzed commit, instead of the old behaviour that // dropped commits whenever aiSummary was missing. const analyzedCommits = resolvedHashes.length > 20 ? await this.changelogService.generateChangelogBatch(resolvedHashes, { includeAIAnalysis: options.includeAIAnalysis, }) : await this.changelogService.processCommitsSequentially(resolvedHashes, { includeAIAnalysis: options.includeAIAnalysis, }); process.stdout.write(`\r${' '.repeat(80)}\r`); // Clear processing line if (!analyzedCommits || analyzedCommits.length === 0) { spinner.fail('No commits could be analyzed'); return null; } // Generate release insights and build changelog (matches default flow shape). const insights = await this.changelogService.generateReleaseInsights(analyzedCommits, 'Selected Commits'); const changelog = await this.changelogService.buildChangelog(analyzedCommits, insights, 'Selected Commits', null, { includeAttribution: options.includeAttribution, template: options.template }); spinner.succeed(`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.at(-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 valid commit message suggestions const lines = content.split('\n').filter((line) => line.trim()); const suggestions = []; // Valid conventional commit types const validTypes = [ 'feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'perf', 'ci', 'build', 'revert', ]; lines.forEach((line) => { // Remove numbering and clean up const cleaned = line .replace(/^\d+\.\s*/, '') .replace(/^-\s*/, '') .trim(); // Must be a reasonable length if (!cleaned || cleaned.length < 10 || cleaned.length > 100) { return; } // Must have a colon (conventional format) if (!cleaned.includes(':')) { return; } // Must start with a valid conventional commit type const startsWithValidType = validTypes.some((type) => { const pattern = new RegExp(`^${type}(\\(|:)`, 'i'); return pattern.test(cleaned); }); if (startsWithValidType) { 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`)); } } }