aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
554 lines (479 loc) • 15.3 kB
JavaScript
/**
* Changelog Generator
* Story 11.2 - Enhanced Capabilities
*
* Auto-generates changelogs from completed stories and git commits.
* Follows Keep a Changelog format (https://keepachangelog.com)
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
class ChangelogGenerator {
constructor(config = {}) {
// Root path
this.rootPath = config.rootPath || process.cwd();
// Category mapping (conventional commits → changelog)
this.categories = {
feat: 'Added',
feature: 'Added',
add: 'Added',
fix: 'Fixed',
bugfix: 'Fixed',
perf: 'Performance',
performance: 'Performance',
refactor: 'Changed',
change: 'Changed',
update: 'Changed',
docs: 'Documentation',
doc: 'Documentation',
breaking: 'Breaking Changes',
deprecated: 'Deprecated',
deprecate: 'Deprecated',
removed: 'Removed',
remove: 'Removed',
security: 'Security',
chore: null, // Don't include chores
test: null, // Don't include tests
ci: null, // Don't include CI
};
// Changelog order
this.categoryOrder = [
'Breaking Changes',
'Added',
'Changed',
'Deprecated',
'Removed',
'Fixed',
'Security',
'Performance',
'Documentation',
];
// Story patterns
this.storyPatterns = [
/\[Story\s+(\d+\.?\d*)\]/i,
/\(Story\s+(\d+\.?\d*)\)/i,
/Story[\s-](\d+\.?\d*)/i,
/#(\d+)/,
];
// Output paths
this.changelogPath = config.changelogPath || path.join(this.rootPath, 'docs', 'CHANGELOG.md');
this.jsonPath = config.jsonPath || path.join(this.rootPath, '.aios', 'changelog.json');
}
/**
* Generate changelog
* @param {Object} options - Generation options
* @returns {Promise<Object>} - Generated changelog
*/
async generate(options = {}) {
const since = options.since || (await this.getLastReleaseTag());
const until = options.until || 'HEAD';
const version = options.version || 'Unreleased';
// Get commits
const commits = await this.getCommits(since, until);
// Get completed stories (if available)
const stories = await this.getCompletedStories(since);
// Categorize
const categorized = this.categorize(commits, stories);
// Format
const changelog = this.format(categorized, version);
// Save if requested
if (options.save) {
await this.save(changelog, categorized, version);
}
return {
version,
since,
until,
commitCount: commits.length,
storyCount: stories.length,
changelog,
categorized,
};
}
/**
* Get the last release tag
* @returns {Promise<string>} - Last tag or first commit
*/
async getLastReleaseTag() {
try {
const tag = execSync('git describe --tags --abbrev=0 2>/dev/null', {
cwd: this.rootPath,
encoding: 'utf8',
}).trim();
return tag;
} catch {
// No tags, get first commit
try {
return execSync('git rev-list --max-parents=0 HEAD', {
cwd: this.rootPath,
encoding: 'utf8',
}).trim();
} catch {
return 'HEAD~100'; // Fallback to last 100 commits
}
}
}
/**
* Get commits between two refs
* @param {string} since - Start ref
* @param {string} until - End ref
* @returns {Promise<Array>} - Commits
*/
async getCommits(since, until) {
try {
// Use unique separator to avoid conflicts with message content
const separator = '|||CHANGELOG_SEP|||';
const format = `%H${separator}%s${separator}%an${separator}%aI`;
const log = execSync(`git log ${since}..${until} --format="${format}" --no-merges`, {
cwd: this.rootPath,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
const commits = [];
const entries = log.split('\n').filter((l) => l.trim());
for (const entry of entries) {
const parts = entry.split(separator);
if (parts.length < 4) continue; // Skip malformed entries
const [hash, subject, author, date] = parts;
commits.push({
hash: hash?.substring(0, 8),
message: subject,
body: '', // Body removed to avoid parsing issues
author,
date,
});
}
return commits;
} catch (error) {
console.warn('Failed to get commits:', error.message);
return [];
}
}
/**
* Get completed stories since a date
* @param {string} since - Since ref/date
* @returns {Promise<Array>} - Completed stories
*/
async getCompletedStories(since) {
const stories = [];
const storiesDir = path.join(this.rootPath, 'docs', 'stories');
if (!fs.existsSync(storiesDir)) {
return stories;
}
// Get since date
let sinceDate = new Date(0);
try {
const dateStr = execSync(`git log -1 --format=%aI ${since}`, {
cwd: this.rootPath,
encoding: 'utf8',
}).trim();
sinceDate = new Date(dateStr);
} catch {
// Ignore
}
// Walk stories directories
const walkDir = (dir) => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.yaml')) {
try {
const content = fs.readFileSync(fullPath, 'utf8');
const stat = fs.statSync(fullPath);
// Check if modified after since date
if (stat.mtime > sinceDate) {
// Check if completed
if (
content.includes('Status: Done') ||
content.includes('Status: Complete') ||
content.includes('status: done')
) {
const story = this.parseStory(content, entry.name);
if (story) {
stories.push(story);
}
}
}
} catch {
// Ignore parse errors
}
}
}
} catch {
// Ignore directory errors
}
};
walkDir(storiesDir);
return stories;
}
/**
* Parse a story file
* @param {string} content - Story content
* @param {string} filename - Filename
* @returns {Object|null} - Parsed story
*/
parseStory(content, filename) {
// Extract title
const titleMatch = content.match(/^#\s+(.+)$/m) || content.match(/title:\s*(.+)/i);
const title = titleMatch ? titleMatch[1].trim() : filename.replace(/\.(md|yaml)$/, '');
// Extract ID
const idMatch = filename.match(/story[_-]?(\d+\.?\d*)/i) || content.match(/id:\s*(\d+\.?\d*)/i);
const id = idMatch ? idMatch[1] : null;
// Extract type
let type = 'feature';
if (content.includes('bug') || content.includes('fix')) {
type = 'fix';
} else if (content.includes('refactor')) {
type = 'refactor';
} else if (content.includes('docs') || content.includes('documentation')) {
type = 'docs';
}
// Extract user story if present
const userStoryMatch = content.match(
/\*\*As a\*\*\s+(.+),\s*\*\*I want\*\*\s+(.+),\s*\*\*so that\*\*\s+(.+)/i
);
return {
id,
title: title.replace(/^Story:?\s*/i, ''),
type,
userStory: userStoryMatch
? {
role: userStoryMatch[1],
action: userStoryMatch[2],
benefit: userStoryMatch[3],
}
: null,
};
}
/**
* Categorize commits and stories
* @param {Array} commits - Commits
* @param {Array} stories - Stories
* @returns {Object} - Categorized entries
*/
categorize(commits, stories) {
const result = {};
// Initialize categories
for (const category of this.categoryOrder) {
result[category] = [];
}
// Process commits
for (const commit of commits) {
// Skip invalid commits
if (!commit || !commit.message) continue;
const type = this.extractCommitType(commit.message);
const category = this.categories[type];
if (!category) continue; // Skip chores, tests, etc.
// Check for breaking changes
const isBreaking =
commit.message.includes('BREAKING') ||
commit.message.includes('!:') ||
commit.body?.includes('BREAKING');
if (isBreaking) {
result['Breaking Changes'].push(this.formatCommit(commit));
} else {
if (!result[category]) {
result[category] = [];
}
result[category].push(this.formatCommit(commit));
}
}
// Process stories (for richer descriptions)
for (const story of stories) {
const category = this.storyToCategory(story);
if (!result[category]) {
result[category] = [];
}
// Only add if not already present from commits
const formatted = this.formatStory(story);
if (!result[category].some((e) => e.includes(story.title))) {
result[category].push(formatted);
}
}
return result;
}
/**
* Extract commit type from message
* @param {string} message - Commit message
* @returns {string} - Commit type
*/
extractCommitType(message) {
// Guard against undefined/null message
if (!message) {
return 'change';
}
// Conventional commit format: type(scope): description
const match = message.match(/^(\w+)(?:\([^)]+\))?!?:\s*/);
if (match) {
return match[1].toLowerCase();
}
// Fallback: look for keywords
const lower = message.toLowerCase();
if (lower.startsWith('fix') || lower.includes('bug')) return 'fix';
if (lower.startsWith('add') || lower.startsWith('feat')) return 'feat';
if (lower.startsWith('remove') || lower.startsWith('delete')) return 'removed';
if (lower.startsWith('deprecate')) return 'deprecated';
if (lower.startsWith('doc')) return 'docs';
return 'change'; // Default to Changed
}
/**
* Map story type to changelog category
* @param {Object} story - Story
* @returns {string} - Category
*/
storyToCategory(story) {
const typeMapping = {
feature: 'Added',
feat: 'Added',
fix: 'Fixed',
bugfix: 'Fixed',
refactor: 'Changed',
docs: 'Documentation',
breaking: 'Breaking Changes',
};
return typeMapping[story.type] || 'Added';
}
/**
* Format a commit for changelog
* @param {Object} commit - Commit
* @returns {string} - Formatted entry
*/
formatCommit(commit) {
// Guard against undefined message
if (!commit.message) {
return commit.hash || 'Unknown commit';
}
// Remove type prefix
let message = commit.message.replace(/^(\w+)(?:\([^)]+\))?!?:\s*/, '').trim();
// Capitalize first letter
message = message.charAt(0).toUpperCase() + message.slice(1);
// Extract story reference
const storyRef = this.extractStoryRef(commit.message);
if (storyRef) {
message += ` [${storyRef}]`;
}
return message;
}
/**
* Format a story for changelog
* @param {Object} story - Story
* @returns {string} - Formatted entry
*/
formatStory(story) {
let entry = story.title;
if (story.id) {
entry += ` [Story ${story.id}]`;
}
return entry;
}
/**
* Extract story reference from commit message
* @param {string} message - Commit message
* @returns {string|null} - Story reference
*/
extractStoryRef(message) {
for (const pattern of this.storyPatterns) {
const match = message.match(pattern);
if (match) {
return `Story ${match[1]}`;
}
}
return null;
}
/**
* Format categorized entries as changelog markdown
* @param {Object} categorized - Categorized entries
* @param {string} version - Version string
* @returns {string} - Formatted changelog
*/
format(categorized, version) {
const date = new Date().toISOString().split('T')[0];
let output = `## [${version}] - ${date}\n\n`;
for (const category of this.categoryOrder) {
const items = categorized[category];
if (!items || items.length === 0) continue;
output += `### ${category}\n\n`;
for (const item of items) {
output += `- ${item}\n`;
}
output += '\n';
}
return output;
}
/**
* Save changelog to files
* @param {string} changelog - Formatted changelog
* @param {Object} categorized - Categorized entries
* @param {string} version - Version
*/
async save(changelog, categorized, version) {
// Ensure directories exist
const changelogDir = path.dirname(this.changelogPath);
const jsonDir = path.dirname(this.jsonPath);
if (!fs.existsSync(changelogDir)) {
fs.mkdirSync(changelogDir, { recursive: true });
}
if (!fs.existsSync(jsonDir)) {
fs.mkdirSync(jsonDir, { recursive: true });
}
// Append to existing changelog or create new
if (fs.existsSync(this.changelogPath)) {
const existing = fs.readFileSync(this.changelogPath, 'utf8');
// Insert after header
const headerEnd = existing.indexOf('\n## ');
if (headerEnd > 0) {
const header = existing.substring(0, headerEnd + 1);
const rest = existing.substring(headerEnd + 1);
fs.writeFileSync(this.changelogPath, header + changelog + rest);
} else {
// Append at end
fs.writeFileSync(this.changelogPath, existing + '\n' + changelog);
}
} else {
// Create new changelog
const header = `# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n`;
fs.writeFileSync(this.changelogPath, header + changelog);
}
// Save JSON
const jsonData = {
generatedAt: new Date().toISOString(),
version,
categories: categorized,
};
fs.writeFileSync(this.jsonPath, JSON.stringify(jsonData, null, 2));
}
/**
* Preview changelog without saving
* @param {Object} options - Options
* @returns {Promise<string>} - Preview
*/
async preview(options = {}) {
const result = await this.generate({ ...options, save: false });
return result.changelog;
}
}
// CLI interface
if (require.main === module) {
const args = process.argv.slice(2);
const generator = new ChangelogGenerator();
const options = {
save: args.includes('--save'),
version: args.find((a) => a.startsWith('--version='))?.split('=')[1],
since: args.find((a) => a.startsWith('--since='))?.split('=')[1],
};
generator
.generate(options)
.then((result) => {
console.log(result.changelog);
console.log(`\n---\nCommits: ${result.commitCount}, Stories: ${result.storyCount}`);
})
.catch((error) => {
console.error('Error:', error.message);
process.exit(1);
});
}
module.exports = ChangelogGenerator;
module.exports.ChangelogGenerator = ChangelogGenerator;