UNPKG

@entro314labs/ai-changelog-generator

Version:

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

463 lines (462 loc) 20.2 kB
import process from 'node:process'; import { ConfigurationManager } from '../../infrastructure/config/configuration.manager.js'; import colors from '../../shared/constants/colors.js'; import { ChangelogOrchestrator } from '../orchestrators/changelog.orchestrator.js'; export class ApplicationService { constructor(options = {}) { this.options = options; // Normalize repositoryPath option (can be cwd or repositoryPath) const repositoryPath = options.repositoryPath || options.cwd; if (repositoryPath) { this.options.repositoryPath = repositoryPath; this.options.cwd = repositoryPath; } // Pass full options to ConfigurationManager so it can receive config object from extension this.configManager = new ConfigurationManager(options.configPath, null, options); this.orchestrator = new ChangelogOrchestrator(this.configManager, this.options); this.initialized = false; this.initializationError = null; // Apply options if (options.noColor || process.env.NO_COLOR) { colors.disable(); } // Start initialization (don't await here to keep constructor sync) this.initializationPromise = this.initializeAsync(); } async initializeAsync() { try { // Wait for orchestrator services to be ready with timeout const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Initialization timeout after 10 seconds')), 10000)); await Promise.race([this.orchestrator.ensureInitialized(), timeoutPromise]); this.initialized = true; } catch (error) { this.initializationError = error; console.error(colors.errorMessage('Application service initialization failed:'), error.message); // Don't throw - allow graceful degradation } } async ensureInitialized() { if (this.initialized) { return; } if (this.initializationError) { throw new Error(`Service not initialized: ${this.initializationError.message}`); } // Wait for initialization to complete await this.initializationPromise; if (this.initializationError) { throw new Error(`Service initialization failed: ${this.initializationError.message}`); } } async analyzeRepository(options = {}) { try { return await this.orchestrator.analyzeRepository(options); } catch (error) { console.error(colors.errorMessage('Repository analysis error:'), error.message); throw error; } } async generateChangelog(options = {}) { try { const { version, since, author, tagRange, format, output, outputFile, dryRun, includeWorkingDirectoryChanges, silent, analysisMode, includeAttribution, template, includeAIAnalysis, } = options; return await this.orchestrator.generateChangelog(version, since, { author, tagRange, format, outputFile: outputFile || output, dryRun, includeWorkingDirectoryChanges, silent: silent ?? this.options.silent, // Thread the remaining changelog-option contract so per-invocation settings // (analysis mode, attribution, template, AI toggle) reach the orchestrator // rather than only the constructor-level this.options defaults. analysisMode, includeAttribution, template, includeAIAnalysis, }); } catch (error) { console.error(colors.errorMessage('Changelog generation error:'), error.message); throw error; } } async analyzeCurrentChanges(config = {}) { try { await this.ensureInitialized(); // Forward the CLI/MCP filters + AI toggle so the engine honors --no-ai and scoping. return await this.orchestrator.analyzeRepository({ type: 'changes', includeAIAnalysis: config.includeAIAnalysis, since: config.since, author: config.author, tagRange: config.tagRange, }); } catch (error) { console.error(colors.errorMessage('Changes analysis error:'), error.message); throw error; } } async analyzeRecentCommits(limit = 10, config = {}) { try { return await this.orchestrator.analyzeRepository({ type: 'commits', limit, includeAIAnalysis: config.includeAIAnalysis, since: config.since, author: config.author, tagRange: config.tagRange, }); } catch (error) { console.error(colors.errorMessage('Commits analysis error:'), error.message); throw error; } } async assessHealth(options = {}) { try { return await this.orchestrator.analyzeRepository({ type: 'health', ...options, }); } catch (error) { console.error(colors.errorMessage('Health assessment error:'), error.message); throw error; } } async generateChangelogFromChanges(version, options = {}) { try { await this.ensureInitialized(); // Thread the working-dir options through to the orchestrator (C-5). Previously // only `version` was forwarded, so --format/--output/--dry-run/analysis-mode were // silently dropped for the working-directory changelog. return await this.orchestrator.generateChangelogFromChanges(version, { analysisMode: options.analysisMode, includeAttribution: options.includeAttribution, format: options.format, outputFile: options.outputFile ?? options.output, dryRun: options.dryRun ?? this.options.dryRun, }); } catch (error) { console.error(colors.errorMessage('Working directory changelog error:'), error.message); throw error; } } async runInteractive() { try { return await this.orchestrator.runInteractive(); } catch (error) { console.error(colors.errorMessage('Interactive mode error:'), error.message); throw error; } } // Configuration delegation setAnalysisMode(mode) { this.orchestrator.setAnalysisMode(mode); } setModelOverride(model) { this.orchestrator.setModelOverride(model); } // Metrics delegation getMetrics() { return this.orchestrator.getMetrics(); } resetMetrics() { this.orchestrator.resetMetrics(); } // Provider management async listProviders() { try { return await this.orchestrator.providerManager.listProviders(); } catch (error) { console.error(colors.errorMessage('Provider listing error:'), error.message); throw error; } } async switchProvider(providerName) { try { const result = this.orchestrator.providerManager.switchProvider(providerName); if (result.success) { // Await the rebuild so the newly-selected provider is the active one before the // next generation/analysis call (initializeServices is async). await this.orchestrator.initializeServices(); console.log(colors.successMessage(`✅ Switched to provider: ${providerName}`)); } return result; } catch (error) { console.error(colors.errorMessage('Provider switch error:'), error.message); return { success: false, error: error.message }; } } // Real available models for a provider (used by `providers models`). Delegates to the // provider instance's getAvailableModels() via the manager — no hardcoded model list. async getProviderModels(providerName) { try { await this.ensureInitialized(); return await this.orchestrator.providerManager.getProviderModels(providerName); } catch (error) { console.error(colors.errorMessage('Provider models error:'), error.message); throw error; } } // Securely store a provider credential via the UnifiedCredentialManager (OS keychain or // encrypted config file), so `providers configure` can persist secrets instead of only // printing .env instructions. Degrades with a clear error when the backend is unavailable // (e.g. keytar not installed for "keychain"). async storeCredential(providerName, value, storageType = 'keychain', metadata = {}) { try { const manager = this.configManager.getCredentialManager(); await manager.setCredential(providerName, value, storageType, metadata); return { success: true, storageType }; } catch (error) { console.error(colors.errorMessage('Credential storage error:'), error.message); return { success: false, error: error.message }; } } // Validation methods validateConfiguration() { try { const issues = []; // Validate we are operating on a real git repository. The previous guard // `config.GIT_PATH || process.cwd()` could never be falsy (process.cwd() always // returns a truthy string), so it never actually validated anything. if (!this.orchestrator.gitManager?.isGitRepo) { issues.push('Not a git repository'); } // Truthfully report AI availability: the active provider must exist, be available, // and not be the rule-based "dummy" fallback. Reporting "valid" with no usable // provider would contradict the tool's core "AI-analyzed" promise. const aiProvider = this.orchestrator.aiProvider; const providerName = aiProvider?.getName?.(); if (!aiProvider || providerName === 'dummy' || !aiProvider.isAvailable?.()) { issues.push('No usable AI provider configured (changelogs will fall back to pattern-based analysis)'); } return { valid: issues.length === 0, issues, recommendations: this.generateRecommendations(issues), }; } catch (error) { console.error(colors.errorMessage('Configuration validation error:'), error.message); return { valid: false, issues: [error.message], recommendations: [], }; } } generateRecommendations(issues) { const recommendations = []; issues.forEach((issue) => { if (issue.includes('git reposit')) { recommendations.push('Run the command from within a git repository (or set the repository path).'); } if (issue.includes('AI provider')) { recommendations.push('Configure an AI provider credential (e.g. OPENAI_API_KEY or ANTHROPIC_API_KEY in .env.local) or run `ai-changelog providers configure`.'); } }); return recommendations; } // Health check async healthCheck() { try { const health = { status: 'healthy', checks: {}, timestamp: new Date().toISOString(), }; // Git check try { health.checks.git = { status: this.orchestrator.gitManager.isGitRepo ? 'ok' : 'error', message: this.orchestrator.gitManager.isGitRepo ? 'Git repository detected' : 'Not a git repository', }; } catch (error) { health.checks.git = { status: 'error', message: error.message }; } // AI provider check — the rule-based "dummy" fallback is always "available" but is // NOT a usable AI provider, so report it truthfully as a warning rather than "ok". try { const aiProvider = this.orchestrator.aiProvider; const aiUsable = !!aiProvider && aiProvider.getName?.() !== 'dummy' && aiProvider.isAvailable?.(); health.checks.ai = { status: aiUsable ? 'ok' : 'warning', message: aiUsable ? 'AI provider available' : 'No usable AI provider (changelogs use pattern-based fallback)', }; } catch (error) { health.checks.ai = { status: 'error', message: error.message }; } // Configuration check try { const configValidation = await this.validateConfiguration(); health.checks.config = { status: configValidation.valid ? 'ok' : 'warning', message: configValidation.valid ? 'Configuration valid' : `${configValidation.issues.length} issues found`, }; } catch (error) { health.checks.config = { status: 'error', message: error.message }; } // Overall status const hasErrors = Object.values(health.checks).some((check) => check.status === 'error'); const hasWarnings = Object.values(health.checks).some((check) => check.status === 'warning'); if (hasErrors) { health.status = 'unhealthy'; } else if (hasWarnings) { health.status = 'degraded'; } return health; } catch (error) { return { status: 'unhealthy', checks: {}, error: error.message, timestamp: new Date().toISOString(), }; } } // Commit message generation with AI and validation async generateCommitMessage(options = {}) { try { await this.ensureInitialized(); // Get current staged files and branch context const { analyzeBranchIntelligence, getSuggestedCommitType, getStagedChanges } = await import('../../shared/utils/utils.js'); const cwd = this.options.cwd || this.options.repositoryPath; const branchAnalysis = analyzeBranchIntelligence(null, cwd); const stagedChanges = getStagedChanges(cwd); if (!stagedChanges || stagedChanges.length === 0) { throw new Error('No staged changes detected'); } const stagedFiles = []; for (const change of stagedChanges) { const fileWithDiff = await this.orchestrator.gitService.analyzeStagedFileChange(change.status, change.filePath); stagedFiles.push({ status: change.status, path: change.filePath, filePath: change.filePath, diff: fileWithDiff?.diff || '', additions: fileWithDiff?.additions || 0, deletions: fileWithDiff?.deletions || 0, }); } const suggestedType = getSuggestedCommitType(branchAnalysis, stagedFiles); let commitMessage; try { commitMessage = await this.orchestrator.generateAICommitMessage(branchAnalysis, suggestedType, stagedFiles); } catch { commitMessage = this.orchestrator.generateBranchAwareCommitMessage(branchAnalysis, suggestedType, stagedFiles); } if (!commitMessage) { throw new Error('Failed to generate commit message'); } const validationContext = { branchAnalysis, stagedFiles, suggestedType }; let validationResult = await this.orchestrator.validationService.validateCommitMessage(commitMessage, validationContext); let improvedMessage = commitMessage; if (options.enableValidation && !validationResult.valid) { const improvementResult = await this.orchestrator.validationService.improveCommitMessage(commitMessage, validationContext); if (improvementResult.improved) { improvedMessage = improvementResult.message; // Re-validate: the returned validation must describe the message we are // actually handing back, otherwise the UI shows errors belonging to a // message the user never sees. validationResult = await this.orchestrator.validationService.validateCommitMessage(improvedMessage, validationContext); } } // Strict mode means "reject invalid conventional commit messages". Surface // that decision so callers can block insertion instead of offering a // message their own setting says should be rejected. const blocked = options.strictValidation === true && !validationResult.valid; return { message: improvedMessage, validation: validationResult, blocked, blockedReason: blocked ? 'Strict conventional-commit validation failed for this message.' : undefined, branchAnalysis, suggestedType, stagedFiles, }; } catch (error) { console.error(colors.errorMessage('Commit message generation error:'), error.message); throw error; } } // Validate commit message async validateCommitMessage(message, context = {}) { try { await this.ensureInitialized(); return await this.orchestrator.validationService.validateCommitMessage(message, context); } catch (error) { console.error(colors.errorMessage('Commit validation error:'), error.message); throw error; } } // Provider validation methods async validateProvider(providerName) { try { await this.ensureInitialized(); return await this.orchestrator.providerManager.testProvider(providerName); } catch (error) { console.error(colors.errorMessage('Provider validation error:'), error.message); throw error; } } async validateAllProviders() { try { await this.ensureInitialized(); return await this.orchestrator.providerManager.validateAll(); } catch (error) { console.error(colors.errorMessage('All providers validation error:'), error.message); throw error; } } // Additional helper methods async generateChangelogFromCommits(commitHashes, options = {}) { try { return await this.orchestrator.interactiveService.generateChangelogForCommits(commitHashes, options); } catch (error) { console.error(colors.errorMessage('Changelog from commits error:'), error.message); throw error; } } // Interactive commit workflow async executeCommitWorkflow(options = {}) { try { await this.ensureInitialized(); // Delegate to orchestrator for the commit workflow return await this.orchestrator.executeCommitWorkflow(options); } catch (error) { console.error(colors.errorMessage('Commit workflow error:'), error.message); throw error; } } }