Skip to main content
Glama
ai-release-notes.ts28.5 kB
#!/usr/bin/env node import { execSync } from 'child_process'; import { writeFileSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import OpenAI from 'openai'; interface ReleaseAnalysis { version: string; commits: CommitInfo[]; features: string[]; bugFixes: string[]; securityUpdates: string[]; breakingChanges: string[]; performance: string[]; dependencies: string[]; documentation: string[]; tests: string[]; infrastructure: string[]; contributors: string[]; pullRequests: PRInfo[]; filesChanged: FileChange[]; } interface CommitInfo { hash: string; message: string; author: string; date: string; files: string[]; additions: number; deletions: number; prNumber?: number; } interface PRInfo { number: number; title: string; body: string; labels: string[]; } interface FileChange { path: string; additions: number; deletions: number; changeType: 'added' | 'modified' | 'deleted'; } /** * Enhanced AI Release Notes Generator with proper OpenAI integration */ export class EnhancedAIReleaseNotesGenerator { private readonly openaiApiKey: string; private readonly model: string; private readonly projectRoot: string; private openai: OpenAI | null = null; constructor() { this.openaiApiKey = process.env.OPENAI_API_KEY || ''; this.model = process.env.RELEASE_NOTES_MODEL || 'gpt-4o-mini'; this.projectRoot = process.cwd(); if (this.openaiApiKey) { this.openai = new OpenAI({ apiKey: this.openaiApiKey, }); console.log('✅ OpenAI API configured for AI enhancement'); } else { console.warn('⚠️ OPENAI_API_KEY not set. Using enhanced template-based generation.'); } } /** * Generate release notes for the latest tag or version */ async generateReleaseNotes(): Promise<string> { try { const analysis = await this.analyzeChanges(); let releaseNotes = await this.generateMarkdown(analysis); // Apply AI enhancement if available if (this.openai) { releaseNotes = await this.enhanceWithAI(releaseNotes, analysis); } // Write to CHANGELOG.md this.updateChangelog(releaseNotes); console.log('✅ Release notes generated successfully!'); return releaseNotes; } catch (error) { console.error('❌ Failed to generate release notes:', error); throw error; } } /** * Analyze git commits and changes since last release with enhanced detection */ private async analyzeChanges(): Promise<ReleaseAnalysis> { const latestTag = this.getLatestTag(); const commits = this.getCommitsSinceTag(latestTag); const version = this.getNextVersion(latestTag); const filesChanged = this.getFilesChanged(latestTag); const pullRequests = this.extractPullRequests(commits); const analysis: ReleaseAnalysis = { version, commits, features: [], bugFixes: [], securityUpdates: [], breakingChanges: [], performance: [], dependencies: [], documentation: [], tests: [], infrastructure: [], contributors: [], pullRequests, filesChanged, }; // Enhanced categorization with file analysis for (const commit of commits) { await this.categorizeCommitEnhanced(commit, analysis); } // Analyze file changes for additional insights this.analyzeFileChanges(filesChanged, analysis); // Extract unique contributors analysis.contributors = [...new Set(commits.map(c => c.author))]; // Sort and deduplicate categories Object.keys(analysis).forEach(key => { if ( Array.isArray(analysis[key as keyof ReleaseAnalysis]) && key !== 'commits' && key !== 'pullRequests' && key !== 'filesChanged' ) { const array = analysis[key as keyof ReleaseAnalysis] as string[]; analysis[key as keyof ReleaseAnalysis] = [...new Set(array)] as any; } }); return analysis; } /** * Get detailed file changes since tag */ private getFilesChanged(tag: string): FileChange[] { try { const diffStat = execSync(`git diff ${tag}..HEAD --numstat`, { encoding: 'utf8' }); const files: FileChange[] = []; const lines = diffStat.split('\n').filter(line => line.trim()); for (const line of lines) { const [additions, deletions, path] = line.split('\t'); if (path) { const changeType = this.detectChangeType(tag, path); files.push({ path, additions: parseInt(additions) || 0, deletions: parseInt(deletions) || 0, changeType, }); } } return files; } catch { return []; } } /** * Detect if file was added, modified, or deleted */ private detectChangeType(tag: string, path: string): 'added' | 'modified' | 'deleted' { try { execSync(`git cat-file -e ${tag}:${path} 2>/dev/null`); // File existed in previous tag try { execSync(`git cat-file -e HEAD:${path} 2>/dev/null`); return 'modified'; } catch { return 'deleted'; } } catch { // File didn't exist in previous tag return 'added'; } } /** * Extract PR numbers from commit messages */ private extractPullRequests(commits: CommitInfo[]): PRInfo[] { const prs: PRInfo[] = []; const prNumbers = new Set<number>(); for (const commit of commits) { const prMatch = commit.message.match(/#(\d+)/); if (prMatch) { const prNumber = parseInt(prMatch[1]); if (!prNumbers.has(prNumber)) { prNumbers.add(prNumber); // In a real implementation, fetch PR details from GitHub API prs.push({ number: prNumber, title: commit.message, body: '', labels: [], }); } } } return prs; } /** * Analyze file changes to detect patterns */ private analyzeFileChanges(files: FileChange[], analysis: ReleaseAnalysis): void { for (const file of files) { const path = file.path.toLowerCase(); // Dependency changes if ( path.includes('package.json') || path.includes('package-lock.json') || path.includes('yarn.lock') || path.includes('requirements.txt') ) { analysis.dependencies.push(`Updated dependencies in ${file.path}`); } // Documentation changes if (path.endsWith('.md') || path.includes('docs/')) { analysis.documentation.push(`Updated documentation: ${file.path}`); } // Test changes if ( path.includes('test') || path.includes('spec') || path.endsWith('.test.ts') || path.endsWith('.spec.ts') ) { analysis.tests.push(`Test updates in ${file.path}`); } // Infrastructure/CI changes if ( path.includes('.github/') || path.includes('dockerfile') || path.includes('docker-compose') || path.includes('.yml') || path.includes('.yaml') ) { analysis.infrastructure.push(`Infrastructure update: ${file.path}`); } // Security-related files if ( path.includes('security') || path.includes('auth') || path.includes('crypto') || path.includes('permission') || path.includes('sanitiz') ) { analysis.securityUpdates.push(`Security-related change in ${file.path}`); } // Performance-related files if ( path.includes('cache') || path.includes('optimi') || path.includes('performance') || path.includes('index') || path.includes('worker') ) { analysis.performance.push(`Performance optimization in ${file.path}`); } } } /** * Enhanced commit categorization with intelligent detection */ private async categorizeCommitEnhanced( commit: CommitInfo, analysis: ReleaseAnalysis ): Promise<void> { const message = commit.message.toLowerCase(); const originalMessage = commit.message; // Conventional commit parsing with extended types const conventionalCommitRegex = /^(\w+)(\(.+\))?(!)?:\s*(.+)/; const match = originalMessage.match(conventionalCommitRegex); if (match) { const [, type, scope, breaking, description] = match; // Breaking changes if (breaking || message.includes('breaking change')) { analysis.breakingChanges.push(originalMessage); } // Categorize by type switch (type) { case 'feat': case 'feature': analysis.features.push(originalMessage); break; case 'fix': case 'bugfix': analysis.bugFixes.push(originalMessage); break; case 'perf': case 'performance': analysis.performance.push(originalMessage); break; case 'security': case 'sec': analysis.securityUpdates.push(originalMessage); break; case 'docs': case 'documentation': analysis.documentation.push(originalMessage); break; case 'test': case 'tests': analysis.tests.push(originalMessage); break; case 'build': case 'ci': case 'chore': analysis.infrastructure.push(originalMessage); break; case 'deps': case 'dependencies': analysis.dependencies.push(originalMessage); break; case 'refactor': // Refactors might be features or performance improvements if (message.includes('optimi') || message.includes('performance')) { analysis.performance.push(originalMessage); } else { analysis.features.push(originalMessage); } break; } } else { // Fallback to keyword-based detection for non-conventional commits if (message.includes('breaking')) { analysis.breakingChanges.push(originalMessage); } if (message.includes('feature') || message.includes('add') || message.includes('implement')) { analysis.features.push(originalMessage); } if (message.includes('fix') || message.includes('bug') || message.includes('issue')) { analysis.bugFixes.push(originalMessage); } if ( message.includes('security') || message.includes('vulnerability') || message.includes('cve') ) { analysis.securityUpdates.push(originalMessage); } if ( message.includes('performance') || message.includes('optimi') || message.includes('speed') ) { analysis.performance.push(originalMessage); } if (message.includes('depend') || message.includes('upgrade') || message.includes('bump')) { analysis.dependencies.push(originalMessage); } if (message.includes('doc') || message.includes('readme')) { analysis.documentation.push(originalMessage); } if (message.includes('test') || message.includes('spec')) { analysis.tests.push(originalMessage); } } } /** * Get commits with detailed information */ private getCommitsSinceTag(tag: string): CommitInfo[] { try { const gitLog = execSync( `git log ${tag}..HEAD --pretty=format:"%H|%s|%an|%ad" --date=short --numstat`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 } ); if (!gitLog.trim()) { console.log(`No commits found since ${tag}`); return []; } const commits: CommitInfo[] = []; const sections = gitLog.split('\n\n').filter(s => s.trim()); for (const section of sections) { const lines = section.split('\n'); const [commitLine] = lines; if (commitLine && commitLine.includes('|')) { const [hash, message, author, date] = commitLine.split('|'); let additions = 0; let deletions = 0; const files: string[] = []; // Parse numstat data for (let i = 1; i < lines.length; i++) { const statLine = lines[i].trim(); if (statLine) { const [add, del, file] = statLine.split(/\s+/); if (file) { additions += parseInt(add) || 0; deletions += parseInt(del) || 0; files.push(file); } } } // Extract PR number if present const prMatch = message.match(/#(\d+)/); commits.push({ hash: hash.substring(0, 7), message: message.trim(), author: author.trim(), date: date.trim(), files, additions, deletions, prNumber: prMatch ? parseInt(prMatch[1]) : undefined, }); } } console.log(`Found ${commits.length} commits since ${tag}`); return commits; } catch (error) { console.error(`Error getting commits since ${tag}:`, error); return []; } } /** * Get the latest git tag */ private getLatestTag(): string { try { return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim(); } catch { // Try to find any tag try { const tags = execSync('git tag -l --sort=-version:refname', { encoding: 'utf8' }).trim(); if (tags) { return tags.split('\n')[0]; } } catch {} return 'HEAD~10'; // Fallback to last 10 commits if no tags } } /** * Generate next version number based on change analysis */ private getNextVersion(currentTag: string): string { // First priority: Use VERSION environment variable if set (for CI/CD workflows) const envVersion = process.env.VERSION; if (envVersion) { const normalizedVersion = envVersion.startsWith('v') ? envVersion : `v${envVersion}`; console.log(`Using environment variable VERSION: ${normalizedVersion}`); return normalizedVersion; } // Second priority: Use package.json version as the target const packageVersion = this.getVersionFromPackageJson(); if (!currentTag.match(/^v?\d+\.\d+\.\d+/)) { // If no valid tag exists, get version from package.json return packageVersion; } // Check if package.json has been manually updated to a specific version const currentVersion = currentTag.replace(/^v/, ''); const targetVersion = packageVersion.replace(/^v/, ''); if (targetVersion !== currentVersion) { console.log(`Using package.json version ${packageVersion} instead of auto-generated version`); return packageVersion; } // Auto-generate version based on commits const [major, minor, patch] = currentVersion.split('.').map(Number); // Check commit types for versioning const commits = this.getCommitsSinceTag(currentTag); const hasBreaking = commits.some( c => c.message.includes('BREAKING CHANGE') || c.message.includes('!:') || c.message.toLowerCase().includes('breaking') ); const hasFeatures = commits.some( c => c.message.toLowerCase().startsWith('feat') || c.message.toLowerCase().includes('feature') ); if (hasBreaking) { return `v${major + 1}.0.0`; } else if (hasFeatures) { return `v${major}.${minor + 1}.0`; } else { return `v${major}.${minor}.${patch + 1}`; } } /** * Get version from package.json as fallback */ private getVersionFromPackageJson(): string { try { const packageJson = JSON.parse(readFileSync(join(this.projectRoot, 'package.json'), 'utf8')); return `v${packageJson.version}`; } catch { // Fallback to a more reasonable version try { const lastTag = this.getLatestTag(); if (lastTag && lastTag.match(/^v?\d+\.\d+\.\d+/)) { return lastTag; } } catch {} return 'v0.1.0'; // More reasonable fallback for new projects } } /** * Generate markdown release notes with enhanced formatting */ private async generateMarkdown(analysis: ReleaseAnalysis): Promise<string> { const template = this.getEnhancedTemplate(); // Calculate statistics const stats = { totalCommits: analysis.commits.length, filesChanged: analysis.filesChanged.length, additions: analysis.filesChanged.reduce((sum, f) => sum + f.additions, 0), deletions: analysis.filesChanged.reduce((sum, f) => sum + f.deletions, 0), contributors: analysis.contributors.length, }; let markdown = template .replace(/{{VERSION}}/g, analysis.version) .replace('{{DATE}}', new Date().toISOString().split('T')[0]) .replace('{{STATS}}', this.formatStats(stats)) .replace('{{HIGHLIGHTS}}', this.generateHighlights(analysis)) .replace('{{FEATURES}}', this.formatSection(analysis.features, '✨')) .replace('{{BUG_FIXES}}', this.formatSection(analysis.bugFixes, '🐛')) .replace('{{PERFORMANCE}}', this.formatSection(analysis.performance, '⚡')) .replace('{{SECURITY}}', this.formatSection(analysis.securityUpdates, '🔒')) .replace('{{BREAKING}}', this.formatSection(analysis.breakingChanges, '💥')) .replace('{{DEPENDENCIES}}', this.formatSection(analysis.dependencies, '📦')) .replace('{{DOCUMENTATION}}', this.formatSection(analysis.documentation, '📚')) .replace('{{TESTS}}', this.formatSection(analysis.tests, '🧪')) .replace('{{INFRASTRUCTURE}}', this.formatSection(analysis.infrastructure, '🔧')) .replace('{{CONTRIBUTORS}}', this.formatContributors(analysis.contributors)) .replace('{{COMMIT_DETAILS}}', this.formatCommitDetails(analysis.commits)); return markdown; } /** * Get enhanced release notes template */ private getEnhancedTemplate(): string { return `# Release {{VERSION}} ({{DATE}}) ## 📊 Release Statistics {{STATS}} ## 🎯 Release Highlights {{HIGHLIGHTS}} ## 🚀 What's New ### Features {{FEATURES}} ### Performance Improvements {{PERFORMANCE}} ## 🐛 Bug Fixes {{BUG_FIXES}} ## 🔒 Security Updates {{SECURITY}} ## 💥 Breaking Changes {{BREAKING}} ## 📦 Dependencies {{DEPENDENCIES}} ## 📚 Documentation {{DOCUMENTATION}} ## 🧪 Tests {{TESTS}} ## 🔧 Infrastructure & Build {{INFRASTRUCTURE}} ## 👥 Contributors {{CONTRIBUTORS}} ## 📦 Installation \`\`\`bash npm install @modelcontextprotocol/mcp-adr-analysis-server@{{VERSION}} \`\`\` ## 🔄 Upgrading If you're upgrading from a previous version, please review the breaking changes section above. --- <details> <summary>📝 Commit Details</summary> {{COMMIT_DETAILS}} </details> **Full Changelog**: https://github.com/tosin2013/mcp-adr-analysis-server/compare/v2.0.7...{{VERSION}} `; } /** * Format statistics section */ private formatStats(stats: any): string { return `- **${stats.totalCommits}** commits - **${stats.filesChanged}** files changed - **${stats.additions}** additions, **${stats.deletions}** deletions - **${stats.contributors}** contributors`; } /** * Generate highlights section using AI or heuristics */ private generateHighlights(analysis: ReleaseAnalysis): string { const highlights: string[] = []; // Major features if (analysis.features.length > 0) { highlights.push(`🎉 ${analysis.features.length} new features added`); } // Critical fixes if (analysis.securityUpdates.length > 0) { highlights.push(`🛡️ ${analysis.securityUpdates.length} security improvements`); } // Performance if (analysis.performance.length > 0) { highlights.push(`⚡ ${analysis.performance.length} performance optimizations`); } // Breaking changes warning if (analysis.breakingChanges.length > 0) { highlights.push( `⚠️ ${analysis.breakingChanges.length} breaking changes - please review before upgrading` ); } return highlights.join('\n'); } /** * Format a section with items */ private formatSection(items: string[], emoji: string): string { if (items.length === 0) { return '_No changes in this category._'; } // Group similar items and format const formatted = items .filter((item, index, self) => self.indexOf(item) === index) // Remove duplicates .map(item => { // Clean up commit messages const cleaned = item .replace(/^(feat|fix|perf|docs|test|build|ci|chore|refactor|style)(\(.+\))?(!)?:\s*/i, '') .replace(/^\w/, c => c.toUpperCase()); return `- ${emoji} ${cleaned}`; }) .sort() .join('\n'); return formatted; } /** * Format contributors list with links */ private formatContributors(contributors: string[]): string { if (contributors.length === 0) { return '_No contributors in this release._'; } return contributors .map(contributor => { // Remove email if present const name = contributor.replace(/<.*>/, '').trim(); return `- @${name}`; }) .join('\n'); } /** * Format commit details for collapsible section */ private formatCommitDetails(commits: CommitInfo[]): string { return commits .map(commit => { const prRef = commit.prNumber ? ` (#${commit.prNumber})` : ''; return `- \`${commit.hash}\` ${commit.message}${prRef} - @${commit.author} (${commit.date})`; }) .join('\n'); } /** * Enhance release notes with AI using OpenAI API */ private async enhanceWithAI(markdown: string, analysis: ReleaseAnalysis): Promise<string> { if (!this.openai) { return markdown; } try { console.log('🤖 Enhancing release notes with AI...'); const prompt = this.buildAIPrompt(analysis, markdown); const completion = await this.openai.chat.completions.create({ model: this.model, messages: [ { role: 'system', content: `You are an expert technical writer specializing in software release notes. Your task is to enhance release notes by: 1. Making them more clear, concise, and professional 2. Grouping related changes intelligently 3. Highlighting the most important changes for users 4. Adding context about the impact of changes 5. Ensuring consistent formatting and tone 6. Making technical changes understandable to various audiences 7. Identifying patterns and themes in the changes 8. Suggesting upgrade paths for breaking changes Maintain the markdown structure and all existing sections. Focus on clarity, impact, and user value. Do not invent or add information that isn't in the source data.`, }, { role: 'user', content: prompt, }, ], temperature: 0.3, max_tokens: 4000, }); const enhancedContent = completion.choices[0]?.message?.content; if (enhancedContent) { console.log('✅ AI enhancement completed'); return enhancedContent; } return markdown; } catch (error) { console.error('⚠️ AI enhancement failed, using template version:', error); return markdown; } } /** * Build AI prompt with context */ private buildAIPrompt(analysis: ReleaseAnalysis, markdown: string): string { return `Please enhance the following release notes for version ${analysis.version}. Context: - Total commits: ${analysis.commits.length} - Key areas changed: ${this.identifyKeyAreas(analysis)} - Breaking changes: ${analysis.breakingChanges.length > 0 ? 'Yes' : 'No'} - Security updates: ${analysis.securityUpdates.length > 0 ? 'Yes' : 'No'} Raw commit data summary: - Features: ${analysis.features.length} items - Bug fixes: ${analysis.bugFixes.length} items - Performance: ${analysis.performance.length} items - Dependencies: ${analysis.dependencies.length} items Current release notes to enhance: ${markdown} Please enhance these release notes by: 1. Improving the highlights section with the most impactful changes 2. Grouping related changes together 3. Adding brief context about why changes matter 4. Ensuring clear, consistent language 5. Making technical changes accessible 6. Highlighting any upgrade considerations Return the enhanced markdown maintaining the same structure.`; } /** * Identify key areas of change for AI context */ private identifyKeyAreas(analysis: ReleaseAnalysis): string { const areas: string[] = []; if (analysis.features.length > 0) areas.push('New Features'); if (analysis.bugFixes.length > 0) areas.push('Bug Fixes'); if (analysis.performance.length > 0) areas.push('Performance'); if (analysis.securityUpdates.length > 0) areas.push('Security'); if (analysis.breakingChanges.length > 0) areas.push('Breaking Changes'); if (analysis.dependencies.length > 0) areas.push('Dependencies'); if (analysis.documentation.length > 0) areas.push('Documentation'); if (analysis.tests.length > 0) areas.push('Testing'); if (analysis.infrastructure.length > 0) areas.push('Infrastructure'); return areas.join(', '); } /** * Update CHANGELOG.md with new release notes */ private updateChangelog(releaseNotes: string): void { const changelogPath = join(this.projectRoot, 'CHANGELOG.md'); let existingChangelog = ''; if (existsSync(changelogPath)) { existingChangelog = readFileSync(changelogPath, 'utf8'); } else { existingChangelog = `# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `; } // Check if this version already exists to avoid duplicates const versionMatch = releaseNotes.match(/# Release (v[\d.\-\w]+)/); if (versionMatch) { // Use regex to match exact version line (not substring) const versionPattern = new RegExp( `^# Release ${versionMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|\\(|$)`, 'm' ); if (versionPattern.test(existingChangelog)) { console.log( `📝 Version ${versionMatch[1]} already exists in CHANGELOG.md, skipping to preserve history` ); return; } } // Find where to insert the new release notes const lines = existingChangelog.split('\n'); let insertIndex = -1; let foundHeader = false; // Look for the right place to insert for (let i = 0; i < lines.length; i++) { // Skip the main "# Changelog" header if (lines[i].startsWith('# Changelog')) { foundHeader = true; continue; } // Skip description lines after main header if (foundHeader && !lines[i].startsWith('# Release ') && lines[i].trim() !== '') { continue; } // Found first release section - insert before it to maintain chronological order if (lines[i].startsWith('# Release ')) { insertIndex = i; break; } // Found empty line after header section - good place to insert if no releases yet if ( foundHeader && lines[i].trim() === '' && (i + 1 >= lines.length || !lines[i + 1].startsWith('# Release ')) ) { insertIndex = i + 1; break; } } // If no good position found, append at the end if (insertIndex === -1) { // Make sure there's a blank line before appending if (lines[lines.length - 1]?.trim() !== '') { lines.push(''); } insertIndex = lines.length; } // Insert the new release notes (newest first) lines.splice(insertIndex, 0, releaseNotes, ''); // Clean up any excessive blank lines const cleanedContent = lines.join('\n').replace(/\n{3,}/g, '\n\n'); writeFileSync(changelogPath, cleanedContent); console.log( `📝 Added version ${versionMatch?.[1] || 'unknown'} to CHANGELOG.md (preserving all previous versions)` ); } } // CLI execution if (import.meta.url === new URL(process.argv[1], 'file:').href) { const generator = new EnhancedAIReleaseNotesGenerator(); generator .generateReleaseNotes() .then(() => process.exit(0)) .catch(() => process.exit(1)); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tosin2013/mcp-adr-analysis-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server