From ab1d2a19610173d3fd8c601c88b5fb86ad094960 Mon Sep 17 00:00:00 2001 From: Lucas Laughlin Date: Thu, 2 Apr 2026 03:15:31 -0600 Subject: [PATCH] feat: add release notes drafter script TypeScript/Bun script that parses git log between version tags, groups commits by conventional-commit category (features, fixes, docs, maintenance), extracts Linear ticket IDs and PR numbers, and outputs formatted Markdown. Runnable via `bun run release-notes` or `make release-notes`. Includes sample output for v0.3.4 as validation. Nightshift-Task: release-notes Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 7 +- docs/release-notes/v0.3.4.md | 36 +++++ package.json | 6 + scripts/release-notes/draft.ts | 238 +++++++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 docs/release-notes/v0.3.4.md create mode 100644 package.json create mode 100644 scripts/release-notes/draft.ts diff --git a/Makefile b/Makefile index 088be01..ece2e9a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help +.PHONY: build test test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks release-notes help # Binary name BINARY=nightshift @@ -75,9 +75,14 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" + @echo " release-notes - Draft release notes from git tags" @echo " install-hooks - Install git pre-commit hook" @echo " help - Show this help" +# Draft release notes from git tags +release-notes: + bun run scripts/release-notes/draft.ts + # Install git pre-commit hook install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit diff --git a/docs/release-notes/v0.3.4.md b/docs/release-notes/v0.3.4.md new file mode 100644 index 0000000..4c0a913 --- /dev/null +++ b/docs/release-notes/v0.3.4.md @@ -0,0 +1,36 @@ +# Release Notes — v0.3.4 + +**Date:** 2026-02-28 +**Tag range:** `v0.3.3..v0.3.4` +**Commits:** 11 + +## 🚀 Features + +- **tasks:** add detailed agent instructions for PII Exposure Scanner ([#34](../../pulls/34)) (`073c05a`) +- add --timeout flag to run and daemon commands ([#27](../../pulls/27)) (`4c0a915`) + +## 🐛 Bug Fixes + +- serialize provider config with correct YAML key names (fixes #20) ([#43](../../pulls/43)) (`519821c`) +- **#19:** config max_projects, budget day-boundary, codex fallback flags ([#42](../../pulls/42)) (`615e2b9`) +- resolve lint warnings in copilot provider and helpers ([#38](../../pulls/38)) (`c703999`) +- capture partial output on timeout and kill process groups ([#33](../../pulls/33)) (`b13164a`) + +## 📚 Documentation + +- remove auto-generated implementation docs ([#40](../../pulls/40)) (`ebbee00`) +- add comprehensive task reference page for all 59 built-in tasks ([#30](../../pulls/30)) (`939c50f`) + +## 🔧 Maintenance + +- replace WriteString(fmt.Sprintf) with fmt.Fprintf ([#41](../../pulls/41)) (`6d34c71`) + +## Other Changes + +- Bump version to v0.3.4 (`2ebd805`) +- Fix copilot cli integration ([#39](../../pulls/39)) (`3337893`) + +--- + +*Generated by release-notes drafter — 11 commits across 5 categories.* + diff --git a/package.json b/package.json new file mode 100644 index 0000000..09f17d8 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "scripts": { + "release-notes": "bun run scripts/release-notes/draft.ts" + } +} diff --git a/scripts/release-notes/draft.ts b/scripts/release-notes/draft.ts new file mode 100644 index 0000000..b5efa95 --- /dev/null +++ b/scripts/release-notes/draft.ts @@ -0,0 +1,238 @@ +#!/usr/bin/env bun +/** + * Release Notes Drafter + * + * Parses git log between version tags, groups commits by conventional-commit + * category, extracts Linear ticket IDs and PR numbers, and outputs Markdown. + * + * Usage: + * bun run scripts/release-notes/draft.ts [from-tag] [to-tag] + * bun run scripts/release-notes/draft.ts # latest two tags + * bun run scripts/release-notes/draft.ts v0.3.3 v0.3.4 + */ + +import { execSync } from "child_process"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface Commit { + hash: string; + subject: string; + type: string; + scope: string | null; + description: string; + prNumber: string | null; + linearIds: string[]; +} + +type Category = { + title: string; + emoji: string; + types: string[]; +}; + +// ── Config ────────────────────────────────────────────────────────────────── + +const CATEGORIES: Category[] = [ + { title: "Features", emoji: "🚀", types: ["feat"] }, + { title: "Bug Fixes", emoji: "🐛", types: ["fix"] }, + { title: "Documentation", emoji: "📚", types: ["docs"] }, + { title: "Maintenance", emoji: "🔧", types: ["chore", "refactor", "build", "ci", "style", "perf", "test"] }, +]; + +const CONVENTIONAL_RE = + /^(?[a-z]+)(?:\((?[^)]+)\))?(?:!)?:\s*(?.+)$/i; + +const PR_RE = /\(#(?\d+)\)\s*$/; +const LINEAR_RE = /\b([A-Z]{2,10}-\d+)\b/g; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function git(cmd: string): string { + return execSync(`git ${cmd}`, { encoding: "utf-8" }).trim(); +} + +function getTags(): string[] { + try { + const raw = git("tag --sort=-version:refname"); + return raw.split("\n").filter(Boolean); + } catch { + return []; + } +} + +function getCommits(from: string, to: string): Commit[] { + const SEP = "---COMMIT---"; + const raw = git( + `log ${from}..${to} --pretty=format:"%H %s${SEP}" --no-merges` + ); + if (!raw) return []; + + return raw + .split(SEP) + .map((entry) => entry.trim()) + .filter(Boolean) + .map(parseCommit); +} + +function parseCommit(line: string): Commit { + const hash = line.slice(0, 40); + const subject = line.slice(41); + + const prMatch = PR_RE.exec(subject); + const prNumber = prMatch?.groups?.pr ?? null; + + // Strip PR suffix for cleaner display + const cleanSubject = subject.replace(PR_RE, "").trim(); + + const convMatch = CONVENTIONAL_RE.exec(cleanSubject); + + const linearIds: string[] = []; + let m: RegExpExecArray | null; + const linearRe = new RegExp(LINEAR_RE.source, LINEAR_RE.flags); + while ((m = linearRe.exec(subject)) !== null) { + linearIds.push(m[1]); + } + + if (convMatch?.groups) { + return { + hash, + subject, + type: convMatch.groups.type.toLowerCase(), + scope: convMatch.groups.scope ?? null, + description: convMatch.groups.desc, + prNumber, + linearIds, + }; + } + + return { + hash, + subject, + type: "other", + scope: null, + description: cleanSubject, + prNumber, + linearIds, + }; +} + +function formatCommit(c: Commit): string { + let line = `- ${c.description}`; + if (c.scope) line = `- **${c.scope}:** ${c.description}`; + if (c.prNumber) line += ` ([#${c.prNumber}](../../pulls/${c.prNumber}))`; + if (c.linearIds.length) line += ` [${c.linearIds.join(", ")}]`; + line += ` (\`${c.hash.slice(0, 7)}\`)`; + return line; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + let fromTag: string; + let toTag: string; + + if (args.length >= 2) { + fromTag = args[0]; + toTag = args[1]; + } else { + const tags = getTags(); + if (tags.length < 2) { + console.error("Error: need at least two version tags."); + process.exit(1); + } + toTag = tags[0]; + fromTag = tags[1]; + } + + const commits = getCommits(fromTag, toTag); + if (commits.length === 0) { + console.error(`No commits found between ${fromTag} and ${toTag}.`); + process.exit(1); + } + + const tagDate = (() => { + try { + return git(`log -1 --format=%cs ${toTag}`); + } catch { + return new Date().toISOString().slice(0, 10); + } + })(); + + // Group commits into categories + const grouped = new Map(); + const uncategorized: Commit[] = []; + + for (const commit of commits) { + let placed = false; + for (const cat of CATEGORIES) { + if (cat.types.includes(commit.type)) { + const existing = grouped.get(cat.title) ?? []; + existing.push(commit); + grouped.set(cat.title, existing); + placed = true; + break; + } + } + if (!placed) { + uncategorized.push(commit); + } + } + + // Build markdown + const lines: string[] = []; + lines.push(`# Release Notes — ${toTag}`); + lines.push(""); + lines.push(`**Date:** ${tagDate} `); + lines.push(`**Tag range:** \`${fromTag}..${toTag}\` `); + lines.push(`**Commits:** ${commits.length}`); + lines.push(""); + + for (const cat of CATEGORIES) { + const catCommits = grouped.get(cat.title); + if (!catCommits?.length) continue; + lines.push(`## ${cat.emoji} ${cat.title}`); + lines.push(""); + for (const c of catCommits) { + lines.push(formatCommit(c)); + } + lines.push(""); + } + + if (uncategorized.length) { + lines.push("## Other Changes"); + lines.push(""); + for (const c of uncategorized) { + lines.push(formatCommit(c)); + } + lines.push(""); + } + + // Stats + const typeCount = new Map(); + for (const c of commits) { + typeCount.set(c.type, (typeCount.get(c.type) ?? 0) + 1); + } + lines.push("---"); + lines.push(""); + lines.push( + `*Generated by release-notes drafter — ${commits.length} commits across ${typeCount.size} categories.*` + ); + lines.push(""); + + const output = lines.join("\n"); + console.log(output); + return output; +} + +const output = main(); + +// If --save flag or output file arg provided, write to file +const saveIdx = process.argv.indexOf("--save"); +if (saveIdx !== -1) { + const toTag = process.argv.length >= 4 ? process.argv[3] : getTags()[0]; + const outPath = process.argv[saveIdx + 1] ?? `docs/release-notes/${toTag}.md`; + await Bun.write(outPath, output); + console.error(`\nSaved to ${outPath}`); +}