write-minor-changelog-section.ts
write-minor-changelog-section.tsBrowse 3 files
1,301 tokens
5,146 bytes
Token encoding: o200k_base
Snapshot bf83086
← Back to SKILL.md
1import { readFile, writeFile } from 'node:fs/promises';2import { createRequire } from 'node:module';3import { extname } from 'node:path';4import { fileURLToPath } from 'node:url';5 6import { Command } from 'commander';7import picocolors from 'picocolors';8import semver from 'semver';9 10import { getMinorChangelogSummary } from './get-minor-changelog-summary.ts';11 12const CHANGELOG_PATH = fileURLToPath(new URL('../../../CHANGELOG.md', import.meta.url));13 14const program = new Command();15 16program17 .name('write-minor-changelog-section')18 .description(19 'Writes a minor/major release section to CHANGELOG.md. Reads the highlights text (tagline + intro + bullet points) from stdin, collects the full entry list automatically, and assembles + writes the complete section.'20 )21 .arguments('[version]')22 .option('--verbose', 'Enable verbose logging', false)23 .option('-d, --dry-run', 'Print the composed section instead of writing to CHANGELOG.md', false);24 25/** Read all of stdin, or return empty string if stdin is a TTY (interactive). */26async function readStdin(): Promise<string> {27 if (process.stdin.isTTY) {28 return '';29 }30 const chunks: Array<string> = [];31 for await (const chunk of process.stdin) {32 if (typeof chunk === 'string' || Buffer.isBuffer(chunk)) {33 chunks.push(chunk.toString());34 }35 }36 return chunks.join('').trim();37}38 39/**40 * Insert or replace the X.Y.0 section in the changelog content.41 * - If a `## X.Y.0` heading already exists, the entire section is replaced.42 * - Otherwise the new section is prepended to the top of the file.43 */44function insertOrReplaceSection(content: string, version: string, section: string): string {45 const heading = `## ${version}`;46 47 // Section may be at the very start of the file, or after a newline48 let startIdx: number;49 if (content.startsWith(heading + '\n')) {50 startIdx = 0;51 } else {52 const idx = content.indexOf('\n' + heading + '\n');53 startIdx = idx === -1 ? -1 : idx + 1; // +1 to skip the leading \n54 }55 56 if (startIdx !== -1) {57 // Find where this section ends (the next `## ` heading)58 const afterHeading = startIdx + heading.length + 1;59 const nextSectionIdx = content.indexOf('\n## ', afterHeading);60 61 if (nextSectionIdx !== -1) {62 return content.slice(0, startIdx) + section + '\n' + content.slice(nextSectionIdx + 1);63 } else {64 return content.slice(0, startIdx) + section;65 }66 } else {67 // No existing section — prepend to the top68 return section + '\n' + content;69 }70}71 72function esMain(url: string): boolean {73 if (!url || !process.argv[1]) {74 return false;75 }76 const require = createRequire(url);77 const scriptPath = require.resolve(process.argv[1]);78 const modulePath = fileURLToPath(url);79 const extension = extname(scriptPath);80 return extension81 ? modulePath === scriptPath82 : modulePath.slice(0, -extname(modulePath).length) === scriptPath;83}84 85if (esMain(import.meta.url)) {86 const opts = program.parse();87 const [version] = opts.args;88 const { verbose, dryRun } = opts.opts();89 90 const log = (...msg: unknown[]) => {91 if (verbose) {92 console.error(...msg);93 }94 };95 96 if (version && !semver.valid(version)) {97 console.error(98 `🚨 Invalid argument, expected a semver version e.g. ${picocolors.green('10.4.0')}, got: ${picocolors.red(version)}`99 );100 process.exit(1);101 }102 103 (async () => {104 // 1. Read highlights from stdin105 const highlights = await readStdin();106 if (!highlights) {107 const invokedScriptPath =108 process.argv[1] ?? '.agents/skills/minor-release/write-minor-changelog-section.ts';109 console.error(110 `🚨 No highlights provided via stdin.\n\nUsage:\n echo "highlights text" | node ${invokedScriptPath} [version]\n\nThe highlights text should contain the tagline, intro sentence, and bullet points — everything except the ## heading and the full entry list.`111 );112 process.exit(1);113 }114 115 // 2. Collect the full entry list (also resolves the version if not provided)116 const { text: entries, resolvedVersion } = await getMinorChangelogSummary({117 version,118 verbose,119 });120 121 log(`\n📝 Composing changelog section for ${picocolors.blue(resolvedVersion)}...`);122 123 // 3. Assemble the full section124 const section = [125 `## ${resolvedVersion}`,126 '',127 highlights,128 '',129 '<details>',130 '<summary>List of all updates</summary>',131 '',132 entries,133 '',134 '</details>',135 '',136 ].join('\n');137 138 if (dryRun) {139 console.log(section);140 return;141 }142 143 // 4. Read CHANGELOG.md, insert/replace the section, write back144 const existing = await readFile(CHANGELOG_PATH, 'utf-8');145 const updated = insertOrReplaceSection(existing, resolvedVersion, section);146 147 await writeFile(CHANGELOG_PATH, updated, 'utf-8');148 log(`✅ Written to ${picocolors.green(CHANGELOG_PATH)}`);149 console.error(150 `✅ CHANGELOG.md updated for ${picocolors.blue(resolvedVersion)} (${picocolors.yellow(entries.split('\n').length)} entries)`151 );152 })().catch((err) => {153 console.error(err);154 process.exit(1);155 });156}157 