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
JavaScript
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;
;