Something went wrong. Try again.
A self-hosted household ledger.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104// Generate CHANGELOG.md sections from git history, grouped by the `area:` prefix// in commit subjects (e.g. "csv import: style the file input"). The ChronVer// version is the section heading.//// deno task changelog # preview the pending section (commits since last release)//// Release boundaries are the commits that changed package.json's `version`// field — no git tags required, so the jj workflow stays friction-free.// `deno task release` calls renderSection() to prepend a new section.import { nextVersion } from "./version.ts";
async function git(args: string[]): Promise<string> { const { code, stdout, stderr } = await new Deno.Command("git", { args, stdout: "piped", stderr: "piped", }).output(); if (code !== 0) { throw new Error( `git ${args.join(" ")}: ${new TextDecoder().decode(stderr)}`, ); } return new TextDecoder().decode(stdout).trim();}
/** * The most recent commit that changed package.json's version — i.e. the last * release. Empty string if the version line was never touched. `-G` matches * commits whose diff adds or removes a line containing `"version"`. */export async function lastReleaseCommit(): Promise<string> { return await git([ "log", "-1", "--format=%H", "-G", '"version"', "--", "package.json", ]);}
// Skip the release commits themselves (version/changelog bumps) so they don't// show up as entries in the next section.const RELEASE_SUBJECT = /^(release\b|\d{4}\.\d{2}\.\d{2}\b)/i;
/** Commit subjects for the pending release, oldest first. */export async function pendingCommits(): Promise<string[]> { const since = await lastReleaseCommit(); const range = since ? [`${since}..HEAD`] : ["HEAD"]; const out = await git([ "log", ...range, "--reverse", "--no-merges", "--format=%s", ]); if (!out) return []; return out .split("\n") .map((s) => s.trim()) .filter((s) => s && !RELEASE_SUBJECT.test(s));}
interface Entry { area: string; summary: string;}
function parse(subject: string): Entry { const m = subject.match(/^([a-z][a-z0-9 +/_-]*?):\s+(.*)$/i); if (m) return { area: m[1].trim(), summary: m[2].trim() }; return { area: "other", summary: subject };}
/** Render a Keep-a-Changelog section, grouping entries by their area prefix. */export function renderSection(version: string, subjects: string[]): string { const groups = new Map<string, string[]>(); for (const s of subjects) { const { area, summary } = parse(s); const bucket = groups.get(area) ?? []; bucket.push(summary); groups.set(area, bucket); }
const lines = [`## ${version}`, ""]; if (groups.size === 0) { lines.push("_No user-facing changes._", ""); return lines.join("\n"); } for (const [area, summaries] of groups) { lines.push(`### ${area}`, ""); for (const s of summaries) lines.push(`- ${s}`); lines.push(""); } return lines.join("\n");}
if (import.meta.main) { const version = await nextVersion(); const subjects = await pendingCommits(); console.log(renderSection(version, subjects));}