automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
247 lines (217 loc) • 7.87 kB
JavaScript
/**
* CHANGELOG Manager (Node port)
*
* Modes:
* - rc <version>: move [Unreleased] entries into a new version section
* - stable <version>: promote RC section to stable version
* - default: ensure [Unreleased] section exists (generate from commits if missing)
*/
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const ACTION = process.argv[2]; // rc|stable|undefined
const VERSION = process.argv[3];
const REPO_ROOT = path.join(__dirname, '..', '..');
const CHANGELOG_PATH = path.join(REPO_ROOT, 'CHANGELOG.md');
function log(color, emoji, message) {
const colors = {
reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m'
};
console.log(`${colors[color] || ''}${emoji} ${message}${colors.reset}`);
}
function todayISO() {
const d = new Date();
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
.toISOString().slice(0, 10);
}
function readLines(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
return text.split(/\r?\n/);
}
function writeLines(filePath, lines) {
let content = lines.join('\n');
if (!content.endsWith('\n')) content += '\n';
fs.writeFileSync(filePath, content, 'utf8');
}
function indexOfNextVersionHeader(lines, startIdx) {
for (let i = startIdx; i < lines.length; i++) {
if (lines[i].startsWith('## [')) return i;
}
return -1;
}
function ensureChangelogFile() {
if (!fs.existsSync(CHANGELOG_PATH)) {
throw new Error(`CHANGELOG not found at ${CHANGELOG_PATH}`);
}
}
function stageChangelog() {
try {
execSync(`git add ${JSON.stringify(CHANGELOG_PATH)}`);
} catch {}
}
// Default mode: generate [Unreleased] section if missing
function getLastTag() {
try {
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
return null;
}
}
function getCommitsSince(tag) {
try {
const range = tag ? `${tag}..HEAD` : 'HEAD';
const out = execSync(`git log ${range} --oneline --no-merges`, { encoding: 'utf8' }).trim();
return out ? out.split('\n') : [];
} catch {
return [];
}
}
function parseCommitLine(line) {
// abcd123 feat: message
const m = line.match(/^([a-f0-9]+)\s+(.+)$/);
if (!m) return null;
const hash = m[1];
const msg = m[2];
const t = msg.match(/^(feat|fix|refactor|docs|chore|test|perf):\s+(.+)$/);
if (t) return { hash, type: t[1], message: t[2] };
return { hash, type: 'other', message: msg };
}
function generateUnreleasedSection(commits) {
const groups = { feat: [], fix: [], refactor: [], docs: [], test: [], perf: [], chore: [], other: [] };
commits.forEach((line) => {
const p = parseCommitLine(line);
if (!p) return;
groups[p.type] = groups[p.type] || [];
groups[p.type].push(p);
});
const typeHeaders = {
feat: '### Features',
fix: '### Fixes',
refactor: '### Refactor',
docs: '### Documentation',
test: '### Tests',
perf: '### Performance',
chore: '### Chore',
other: '### Other',
};
const lines = ['## [Unreleased]', ''];
for (const key of ['feat', 'fix', 'refactor', 'docs', 'test', 'perf', 'chore', 'other']) {
const items = groups[key];
if (!items || items.length === 0) continue;
lines.push(typeHeaders[key]);
items.forEach((c) => lines.push(`- ${c.message} (${c.hash})`));
lines.push('');
}
return lines.join('\n');
}
function ensureUnreleased() {
ensureChangelogFile();
const existing = fs.readFileSync(CHANGELOG_PATH, 'utf8');
if (existing.includes('[Unreleased]')) {
// Already exists - this is the desired state, no warning needed
return 0;
}
const last = getLastTag();
if (last) console.log(` Last tag: ${last}`);
const commits = getCommitsSince(last);
if (commits.length === 0) {
console.log(' No new commits since last tag');
console.log('✅ CHANGELOG up to date');
return 0;
}
console.log(` Found ${commits.length} commits`);
const newSection = generateUnreleasedSection(commits);
// Insert after header (first blank line after first line)
let headerEnd = existing.indexOf('\n\n');
if (headerEnd === -1) headerEnd = existing.length;
const updated = existing.slice(0, headerEnd + 2) + newSection + '\n' + existing.slice(headerEnd + 2);
fs.writeFileSync(CHANGELOG_PATH, updated, 'utf8');
stageChangelog();
console.log('✅ CHANGELOG updated and staged');
return 0;
}
function prepareRC(version) {
ensureChangelogFile();
const lines = readLines(CHANGELOG_PATH);
const unreleasedIdx = lines.findIndex((l) => l.trim() === '## [Unreleased]');
if (unreleasedIdx === -1) {
console.error('❌ Missing "## [Unreleased]" section. Aborting.');
return 1;
}
const contentStart = unreleasedIdx + 1;
const macroIdx = lines.findIndex((l, i) => i >= contentStart && (l.startsWith('**Current Version:**') || l.startsWith('**Generated:**')));
let contentEnd = indexOfNextVersionHeader(lines, contentStart);
if (macroIdx !== -1) contentEnd = macroIdx;
if (contentEnd === -1) contentEnd = lines.length;
const extracted = lines.slice(contentStart, contentEnd).filter((l) => {
if (!l.trim()) return false;
if (l.startsWith('All notable changes')) return false;
if (l.trim() === '---') return false;
return true;
});
const movedCount = extracted.length;
const keepHead = lines.slice(0, contentStart);
const keepTail = lines.slice(contentEnd);
const beforeInsert = keepHead.concat(keepTail);
const insertAt = beforeInsert.findIndex((l, i) => i > unreleasedIdx && l.startsWith('## ['));
if (insertAt === -1) {
console.error('❌ Could not find first version section to anchor insertion.');
return 1;
}
const header = `## [${version}] - ${todayISO()}`;
const section = [header, ''];
if (movedCount > 0) section.push(...extracted); else section.push('No changelog entries (packaging-only RC).');
section.push('');
const updated = beforeInsert.slice(0, insertAt).concat(section).concat(beforeInsert.slice(insertAt));
writeLines(CHANGELOG_PATH, updated);
stageChangelog();
log('green', '✅', `CHANGELOG updated for RC ${version} (moved ${movedCount} lines)`);
return 0;
}
function promoteStable(version) {
ensureChangelogFile();
const lines = readLines(CHANGELOG_PATH);
const base = version;
let rcIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('## [') && lines[i].includes(`${base}-rc.`)) { rcIdx = i; break; }
}
if (rcIdx !== -1) {
lines[rcIdx] = `## [${base}] - ${todayISO()}`;
writeLines(CHANGELOG_PATH, lines);
stageChangelog();
log('green', '✅', `Promoted RC section to stable ${base}`);
return 0;
}
// Insert minimal section near top (after '---' if present, else before first version)
const sepIdx = lines.findIndex((l) => l.trim() === '---');
const firstVerIdx = indexOfNextVersionHeader(lines, 0);
const anchor = sepIdx !== -1 ? sepIdx + 1 : (firstVerIdx !== -1 ? firstVerIdx : lines.length);
const header = `## [${base}] - ${todayISO()}`;
const section = [header, '', 'No changes since last RC.', ''];
const updated = lines.slice(0, anchor).concat(section).concat(lines.slice(anchor));
writeLines(CHANGELOG_PATH, updated);
stageChangelog();
log('green', '✅', `Inserted stable section ${base}`);
return 0;
}
function main() {
if (ACTION === 'rc') {
if (!VERSION) {
console.error('Usage: update-changelog.js rc <version>');
process.exit(1);
}
process.exit(prepareRC(VERSION));
}
if (ACTION === 'stable') {
if (!VERSION) {
console.error('Usage: update-changelog.js stable <version>');
process.exit(1);
}
process.exit(promoteStable(VERSION));
}
// default: ensure [Unreleased]
process.exit(ensureUnreleased());
}
main();