get-minor-changelog-summary.ts
get-minor-changelog-summary.tsBrowse 3 files
1,814 tokens
7,066 bytes
Token encoding: o200k_base
Snapshot bf83086
← Back to SKILL.md
1import { readFile } 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 10const PRERELEASE_CHANGELOG_PATH = fileURLToPath(11 new URL('../../../CHANGELOG.prerelease.md', import.meta.url)12);13const STABLE_CHANGELOG_PATH = fileURLToPath(new URL('../../../CHANGELOG.md', import.meta.url));14 15const program = new Command();16 17program18 .name('get-minor-changelog-summary')19 .description(20 'Collects all changelog entries from prereleases of a minor/major version, filtering out changes that were already shipped in patch releases of the previous minor. If no version is given, the stable version of the most recent prerelease is used.'21 )22 .arguments('[version]')23 .option('-V, --verbose', 'Enable verbose logging', false);24 25/**26 * Parse a markdown changelog file into a map of version string -> section body.27 * Sections are delineated by `## <version>` headings.28 */29function parseChangelogSections(content: string): Map<string, string> {30 const sections = new Map<string, string>();31 const parts = content.split(/^## /m);32 for (const part of parts) {33 if (!part.trim()) {34 continue;35 }36 const newlineIdx = part.indexOf('\n');37 const version = newlineIdx === -1 ? part.trim() : part.slice(0, newlineIdx).trim();38 const body = newlineIdx === -1 ? '' : part.slice(newlineIdx + 1);39 sections.set(version, body);40 }41 return sections;42}43 44/** Extract all PR numbers referenced in a changelog section body. */45function extractPRNumbers(content: string): Set<number> {46 const prNumbers = new Set<number>();47 const prRegex = /\[#(\d+)\]\(https:\/\/github\.com\//g;48 let match;49 while ((match = prRegex.exec(content)) !== null) {50 prNumbers.add(parseInt(match[1], 10));51 }52 return prNumbers;53}54 55/** Extract individual changelog list items (lines starting with `- `) from a section body. */56function extractEntries(content: string): string[] {57 return content58 .split('\n')59 .filter((line) => line.startsWith('- '))60 .map((line) => line.trim());61}62 63export const getMinorChangelogSummary = async (args: { version?: string; verbose?: boolean }) => {64 const { verbose } = args;65 66 const log = (...msg: unknown[]) => {67 if (verbose) {68 console.error(...msg);69 }70 };71 72 const [prereleaseChangelog, stableChangelog] = await Promise.all([73 readFile(PRERELEASE_CHANGELOG_PATH, 'utf-8'),74 readFile(STABLE_CHANGELOG_PATH, 'utf-8'),75 ]);76 77 const preSections = parseChangelogSections(prereleaseChangelog);78 const stableSections = parseChangelogSections(stableChangelog);79 80 let version = args.version;81 if (!version) {82 const firstPrerelease = [...preSections.keys()][0];83 if (!firstPrerelease) {84 throw new Error(85 `No prerelease entries found in ${picocolors.green(PRERELEASE_CHANGELOG_PATH)}`86 );87 }88 const p = semver.parse(firstPrerelease);89 if (!p) {90 throw new Error(91 `Could not parse version from first prerelease entry: ${picocolors.red(firstPrerelease)}`92 );93 }94 version = `${p.major}.${p.minor}.0`;95 log(96 `🔍 No version specified, inferred ${picocolors.blue(version)} from most recent prerelease ${picocolors.green(firstPrerelease)}`97 );98 }99 100 const parsed = semver.parse(version);101 if (!parsed || parsed.patch !== 0 || parsed.prerelease.length > 0) {102 throw new Error(103 `Version must be a stable minor or major release (e.g. 10.4.0), got: ${picocolors.red(version)}`104 );105 }106 107 const { major, minor } = parsed;108 109 // Collect all prerelease versions for the target (e.g. 10.4.0-alpha.*, 10.4.0-beta.*, 10.4.0-rc.*)110 const prereleaseVersionPrefix = `${major}.${minor}.0-`;111 const prereleaseVersions = [...preSections.keys()]112 .filter((v) => v.startsWith(prereleaseVersionPrefix))113 .sort((a, b) => semver.compare(a, b));114 115 if (prereleaseVersions.length === 0) {116 throw new Error(117 `No prerelease versions found for ${picocolors.blue(version)} in ${picocolors.green(PRERELEASE_CHANGELOG_PATH)}`118 );119 }120 121 log(122 `🔍 Found ${picocolors.blue(prereleaseVersions.length)} prerelease version(s): ${prereleaseVersions.join(', ')}`123 );124 125 // Collect PR numbers that already shipped in patch releases of the previous minor (e.g. 10.3.1+)126 const patchPRNumbers = new Set<number>();127 128 if (minor > 0) {129 const prevMinor = minor - 1;130 const patchVersions = [...stableSections.keys()].filter((v) => {131 const p = semver.parse(v);132 return p && p.major === major && p.minor === prevMinor && p.patch > 0;133 });134 135 log(136 `🔍 Patch versions to filter out: ${picocolors.yellow(patchVersions.join(', ') || 'none')}`137 );138 139 for (const pv of patchVersions) {140 const body = stableSections.get(pv)!;141 for (const pr of extractPRNumbers(body)) {142 patchPRNumbers.add(pr);143 }144 }145 146 log(147 `🔍 Found ${picocolors.yellow(patchPRNumbers.size)} PR(s) already shipped in patch releases — these will be filtered out`148 );149 } else {150 log(`ℹ️ Version ${picocolors.blue(version)} is a major release, no patches to filter`);151 }152 153 // Aggregate changelog entries from all prereleases, deduplicating by PR number154 const entriesByPR = new Map<number, string>();155 const directCommitEntries: string[] = [];156 157 for (const preVersion of prereleaseVersions) {158 const body = preSections.get(preVersion)!;159 const entries = extractEntries(body);160 161 for (const entry of entries) {162 const prMatch = entry.match(/\[#(\d+)\]/);163 if (prMatch) {164 const prNum = parseInt(prMatch[1], 10);165 if (!patchPRNumbers.has(prNum) && !entriesByPR.has(prNum)) {166 entriesByPR.set(prNum, entry);167 }168 } else if (!directCommitEntries.includes(entry)) {169 directCommitEntries.push(entry);170 }171 }172 }173 174 const allEntries = [...entriesByPR.values(), ...directCommitEntries].sort();175 const text = allEntries.join('\n');176 177 log(`\n✅ Generated changelog summary (${picocolors.blue(allEntries.length)} entries):\n`);178 179 return { text, entryCount: allEntries.length, resolvedVersion: version };180};181 182function esMain(url: string): boolean {183 if (!url || !process.argv[1]) {184 return false;185 }186 const require = createRequire(url);187 const scriptPath = require.resolve(process.argv[1]);188 const modulePath = fileURLToPath(url);189 const extension = extname(scriptPath);190 return extension191 ? modulePath === scriptPath192 : modulePath.slice(0, -extname(modulePath).length) === scriptPath;193}194 195if (esMain(import.meta.url)) {196 const parsed = program.parse();197 const [version] = parsed.args;198 if (version && !semver.valid(version)) {199 console.error(200 `🚨 Invalid argument, expected a semver version e.g. ${picocolors.green('10.4.0')}, got: ${picocolors.red(version)}`201 );202 process.exit(1);203 }204 const { text } = await getMinorChangelogSummary({ version, verbose: parsed.opts().verbose });205 console.log(text);206}207 