UNPKG

ai-context-commit-tools

Version:

AI context builder with automated commit message generation and changelog maintenance for enhanced AI-assisted development

408 lines (403 loc) â€ĸ 16 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ChangelogGenerator = void 0; const tslib_1 = require("tslib"); const child_process_1 = require("child_process"); const fs = tslib_1.__importStar(require("fs-extra")); const path = tslib_1.__importStar(require("path")); class ChangelogGenerator { constructor(projectRoot = process.cwd(), debugMode = false) { this.typeTitles = { feat: 'Added', fix: 'Fixed', perf: 'Performance', security: 'Security', refactor: 'Changed', docs: 'Documentation', test: 'Testing', chore: 'Maintenance', ci: 'CI/CD', build: 'Build', style: 'Code Style', }; this.projectRoot = projectRoot; this.changelogPath = path.join(projectRoot, 'CHANGELOG.md'); this.debugMode = debugMode; } async generateChangelog(previewMode = false) { try { this.log('🔍 Analyzing commits since last CI run...'); const commits = await this.getCommitsSinceLastCI(); if (commits.length === 0) { this.log('â„šī¸ No new commits to process'); return null; } this.log(`📝 Processing ${commits.length} commits...`); const entries = this.parseCommits(commits); const sections = this.groupEntriesByType(entries); const newContent = this.generateSectionContent(sections); if (!newContent.trim()) { this.log('â„šī¸ No significant changes to document'); return null; } if (previewMode) { this.log('📋 Changelog Preview:'); console.log('─'.repeat(50)); console.log(newContent); console.log('─'.repeat(50)); return newContent; } const existingContent = await this.readExistingChangelog(); const updatedContent = this.mergeWithExistingChangelog(existingContent, newContent); const latestCommit = await this.getLatestCommitHash(); const finalContent = this.addMetadata(updatedContent, latestCommit); await fs.writeFile(this.changelogPath, finalContent); this.log('✅ Changelog updated successfully'); return finalContent; } catch (error) { throw new Error(`Failed to generate changelog: ${error}`); } } async getCommitsSinceLastCI() { try { let lastProcessedCommit = null; if (await fs.pathExists(this.changelogPath)) { const changelogContent = await fs.readFile(this.changelogPath, 'utf8'); const match = changelogContent.match(/<!-- CI-LAST-PROCESSED: ([a-f0-9]+) -->/); if (match && match[1]) { lastProcessedCommit = match[1]; this.log(`Found last processed commit: ${lastProcessedCommit}`); } } let gitLogCmd; if (lastProcessedCommit) { gitLogCmd = `git log ${lastProcessedCommit}..HEAD --oneline --no-merges --reverse`; } else { this.log('No previous run found, getting recent commits'); try { gitLogCmd = 'git log HEAD~50..HEAD --oneline --no-merges --reverse'; (0, child_process_1.execSync)(gitLogCmd, { encoding: 'utf8', stdio: 'ignore' }); } catch (error) { this.log('Repository has fewer than 50 commits, getting all commits'); gitLogCmd = 'git log --oneline --no-merges --reverse'; } } const gitLog = (0, child_process_1.execSync)(gitLogCmd, { encoding: 'utf8' }).trim(); if (!gitLog) { return []; } const commits = gitLog .split('\n') .map(line => { const [hash, ...messageParts] = line.split(' '); return { hash: hash || '', message: messageParts.join(' '), }; }) .filter(commit => commit.hash && commit.message); this.log(`Found ${commits.length} new commits`); return commits; } catch (error) { throw new Error(`Failed to get commits: ${error}`); } } parseCommits(commits) { const entries = commits.map(commit => { const parsed = this.parseConventionalCommit(commit.message); return { type: parsed.type, scope: parsed.scope, description: parsed.description, hash: commit.hash, author: commit.author, date: commit.date, }; }); const filtered = entries.filter(entry => this.isChangelogWorthy(entry)); return this.deduplicateEntries(filtered); } parseConventionalCommit(message) { const conventionalRegex = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|security)(?:\(([^)]+)\))?: (.+)$/; const match = message.match(conventionalRegex); if (match) { return { type: match[1], scope: match[2] || undefined, description: match[3] || '', }; } const inferredType = this.inferCommitType(message); return { type: inferredType, scope: undefined, description: message, }; } inferCommitType(message) { const lower = message.toLowerCase(); if (lower.includes('fix') || lower.includes('bug') || lower.includes('error')) { return 'fix'; } if (lower.includes('test') || lower.includes('spec')) { return 'test'; } if (lower.includes('doc') || lower.includes('readme')) { return 'docs'; } if (lower.includes('refactor') || lower.includes('cleanup')) { return 'refactor'; } if (lower.includes('performance') || lower.includes('optimize')) { return 'perf'; } if (lower.includes('security') || lower.includes('vulnerability')) { return 'security'; } if (lower.includes('ci') || lower.includes('pipeline') || lower.includes('workflow')) { return 'ci'; } if (lower.includes('dependency') || lower.includes('deps') || lower.includes('version')) { return 'chore'; } return 'feat'; } isChangelogWorthy(entry) { const description = entry.description.toLowerCase().trim(); const excludePatterns = [ /^merge /, /found \d+ staged files/, /generating ai commit message/, /ai message generated/, /^wip$/, /^temp$/, /^tmp$/, /^\w+\.\w+$/, /^(update|fix|add|remove|change|delete|create|test|debug)$/, ]; for (const pattern of excludePatterns) { if (pattern.test(description)) { return false; } } return true; } deduplicateEntries(entries) { const seen = new Map(); for (const entry of entries) { const normalizedDesc = this.normalizeDescription(entry.description); const key = `${entry.type}:${entry.scope || ''}:${normalizedDesc}`; if (!seen.has(key)) { seen.set(key, entry); } else { const existing = seen.get(key); if (entry.description.length > existing.description.length) { seen.set(key, entry); } } } return Array.from(seen.values()); } normalizeDescription(description) { return description .toLowerCase() .replace(/\s+/g, ' ') .trim(); } groupEntriesByType(entries) { const grouped = {}; entries.forEach(entry => { const title = this.typeTitles[entry.type]; if (!grouped[title]) { grouped[title] = []; } grouped[title].push(entry); }); const sectionOrder = Object.values(this.typeTitles); return sectionOrder .filter(title => grouped[title] && grouped[title].length > 0) .map(title => ({ title, entries: grouped[title] || [], })); } generateSectionContent(sections) { return sections .map(section => { const entries = section.entries .map(entry => { const scopeDisplay = entry.scope ? `**${entry.scope}**` : ''; const prefix = scopeDisplay ? `${scopeDisplay}: ` : ''; let description = entry.description; description = description.replace(/^(add|fix|update|remove)\s+/i, ''); description = description.replace(/\b[\w-]+\.(ts|js|tsx|jsx|json|yaml|yml|md|txt)\b(?!\s+\w)/gi, ''); description = description.replace(/\s+/g, ' ').trim(); description = description.replace(/^[,\-\s]+|[,\-\s]+$/g, ''); if (description.length > 0 && !/^[A-Z]{2,}/.test(description)) { description = description.charAt(0).toLowerCase() + description.slice(1); } if (description.length < 10) { return null; } if (entry.scope) { return `${prefix}${description}`; } else { return `- ${description}`; } }) .filter(entry => entry !== null); if (entries.length === 0) { return null; } return `### ${section.title}\n\n${entries.join('\n')}`; }) .filter(section => section !== null) .join('\n\n'); } async readExistingChangelog() { if (await fs.pathExists(this.changelogPath)) { return await fs.readFile(this.changelogPath, 'utf8'); } return this.createInitialChangelog(); } createInitialChangelog() { return `# 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). ## [Unreleased] `; } mergeWithExistingChangelog(existingContent, newSection) { const unreleasedRegex = /## \[Unreleased\]\s*([\s\S]*?)(?=\n## |\n<!-- Generated:|$)/; const match = existingContent.match(unreleasedRegex); if (match && match[1]) { const existingUnreleased = match[1].trim(); if (existingUnreleased && !existingUnreleased.includes('<!-- Generated:')) { this.log('Merging with existing unreleased content'); const mergedSection = this.intelligentMerge(existingUnreleased, newSection); return existingContent.replace(unreleasedRegex, `## [Unreleased]\n\n${mergedSection}\n\n`); } else { return existingContent.replace(unreleasedRegex, `## [Unreleased]\n\n${newSection}\n\n`); } } else { const headerEndIndex = existingContent.indexOf('\n## '); if (headerEndIndex !== -1) { return `${existingContent.substring(0, headerEndIndex)}\n## [Unreleased]\n\n${newSection}\n\n${existingContent.substring(headerEndIndex)}`; } else { return `${existingContent}\n## [Unreleased]\n\n${newSection}\n\n`; } } } intelligentMerge(existingSection, newSection) { const existingEntries = this.parseExistingChangelogEntries(existingSection); const newEntries = this.parseExistingChangelogEntries(newSection); const allEntries = [...existingEntries, ...newEntries]; const deduplicatedEntries = this.deduplicateChangelogEntries(allEntries); const grouped = {}; deduplicatedEntries.forEach(entry => { if (!grouped[entry.section]) { grouped[entry.section] = []; } grouped[entry.section].push(entry.content); }); const sectionOrder = Object.values(this.typeTitles); const sections = sectionOrder .filter(section => grouped[section] && grouped[section].length > 0) .map(section => `### ${section}\n\n${grouped[section].join('\n')}`) .join('\n\n'); return sections; } parseExistingChangelogEntries(content) { const entries = []; const sections = content.split(/###\s+(.+)/); for (let i = 1; i < sections.length; i += 2) { const sectionTitle = sections[i]?.trim(); if (!sectionTitle) continue; const sectionContent = sections[i + 1] || ''; const entryLines = sectionContent .split('\n') .filter(line => line.trim().startsWith('-')) .map(line => line.trim()); entryLines.forEach(line => { entries.push({ section: sectionTitle, content: line, }); }); } return entries; } deduplicateChangelogEntries(entries) { const seen = new Map(); for (const entry of entries) { const normalized = this.normalizeChangelogEntry(entry.content); const key = `${entry.section}:${normalized}`; if (!seen.has(key)) { seen.set(key, entry); } else { const existing = seen.get(key); if (entry.content.length > existing.content.length) { seen.set(key, entry); } } } return Array.from(seen.values()); } normalizeChangelogEntry(content) { return content .toLowerCase() .replace(/^-\s*/, '') .replace(/\*\*[^*]+\*\*:\s*/, '') .replace(/task\s*(id|Id)?\s*:\s*/, '') .replace(/dev-\d+[,\s]*/, '') .replace(/\s+/g, ' ') .trim(); } addMetadata(content, latestCommit) { const timestamp = new Date().toISOString(); const metadata = `<!-- Generated: ${timestamp} Commit: ${latestCommit} --> <!-- CI-LAST-PROCESSED: ${latestCommit} -->`; const cleanedContent = this.removeExistingMetadata(content); const unreleasedEndRegex = /(## \[Unreleased\][\s\S]*?)(\n## |$)/; const match = cleanedContent.match(unreleasedEndRegex); if (match) { return cleanedContent.replace(unreleasedEndRegex, `${match[1]}\n${metadata}\n${match[2] || ''}`); } else { return `${cleanedContent}\n${metadata}\n`; } } removeExistingMetadata(content) { return (content .replace(/<!-- Generated:.*?-->\n?/g, '') .replace(/<!-- CI-LAST-PROCESSED:.*?-->\n?/g, '') .replace(/\n{3,}/g, '\n\n') .trim()); } async getLatestCommitHash() { try { return (0, child_process_1.execSync)('git rev-parse HEAD', { encoding: 'utf8' }).trim(); } catch (error) { return 'unknown'; } } log(message) { if (this.debugMode || process.env.NODE_ENV !== 'test') { console.log(message); } } } exports.ChangelogGenerator = ChangelogGenerator;