Skip to content

Commit 10ef385

Browse files
committed
feat(cli): add release notes generator script
Adds a script to generate release notes from git commit history using conventional commits. Parses commits between two references, categorizes by type (feat, fix, chore, etc.), and outputs formatted markdown or JSON. Usage: bun run release-notes --version 0.14.0 --since <commit>
1 parent f33fe68 commit 10ef385

File tree

2 files changed

+388
-1
lines changed

2 files changed

+388
-1
lines changed

cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"test": "bun run embed-all && bun test",
3737
"test:fast": "bun run embed-all && bun test --coverage=false",
3838
"test:coverage": "bun run embed-all && bun test --coverage && echo 'Coverage report: cli/coverage/lcov-report/index.html'",
39-
"typecheck": "bun run embed-all && bunx tsc --noEmit"
39+
"typecheck": "bun run embed-all && bunx tsc --noEmit",
40+
"release-notes": "bun run scripts/generate-release-notes.ts"
4041
},
4142
"dependencies": {
4243
"@modelcontextprotocol/sdk": "^1.20.2",
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Release Notes Generator
4+
*
5+
* Generates release notes from git commit history using conventional commits.
6+
* Analyzes commits between two references (tags, commits, or branches).
7+
*
8+
* Usage:
9+
* bun run scripts/generate-release-notes.ts [options]
10+
*
11+
* Options:
12+
* --since <ref> Start reference (tag, commit, branch). Default: auto-detect last tag
13+
* --until <ref> End reference. Default: HEAD
14+
* --version <ver> Version string for the release (e.g., "0.14.0")
15+
* --format <fmt> Output format: markdown (default), json
16+
* --output <file> Write to file instead of stdout
17+
*/
18+
19+
import * as childProcess from "child_process";
20+
import * as fs from "fs";
21+
22+
// Conventional commit types and their display names
23+
const COMMIT_TYPES: Record<string, { title: string; emoji: string; priority: number }> = {
24+
feat: { title: "New Features", emoji: "🚀", priority: 1 },
25+
fix: { title: "Bug Fixes", emoji: "🐛", priority: 2 },
26+
perf: { title: "Performance Improvements", emoji: "⚡", priority: 3 },
27+
refactor: { title: "Refactoring", emoji: "♻️", priority: 4 },
28+
docs: { title: "Documentation", emoji: "📚", priority: 5 },
29+
chore: { title: "Maintenance", emoji: "🔧", priority: 6 },
30+
test: { title: "Testing", emoji: "🧪", priority: 7 },
31+
ci: { title: "CI/CD", emoji: "🔄", priority: 8 },
32+
build: { title: "Build System", emoji: "📦", priority: 9 },
33+
style: { title: "Code Style", emoji: "💅", priority: 10 },
34+
};
35+
36+
// Scopes to highlight (CLI, monitoring, etc.)
37+
const KNOWN_SCOPES = ["cli", "monitoring", "reporter", "grafana", "mcp", "prepare-db", "checkup", "deps", "ci", "formula", "pgai", "dashboards"];
38+
39+
interface ParsedCommit {
40+
hash: string;
41+
shortHash: string;
42+
type: string;
43+
scope: string | null;
44+
subject: string;
45+
body: string;
46+
breaking: boolean;
47+
date: string;
48+
author: string;
49+
}
50+
51+
interface ReleaseNotes {
52+
version: string;
53+
date: string;
54+
sinceRef: string;
55+
untilRef: string;
56+
commits: ParsedCommit[];
57+
categories: Record<string, ParsedCommit[]>;
58+
breaking: ParsedCommit[];
59+
stats: {
60+
total: number;
61+
features: number;
62+
fixes: number;
63+
contributors: string[];
64+
};
65+
}
66+
67+
function execSync(command: string): string {
68+
try {
69+
const result = childProcess.execSync(command, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
70+
return result.trim();
71+
} catch (err) {
72+
return "";
73+
}
74+
}
75+
76+
function parseArgs(): { since: string; until: string; version: string; format: string; output: string | null } {
77+
const args = process.argv.slice(2);
78+
const result = { since: "", until: "HEAD", version: "", format: "markdown", output: null as string | null };
79+
80+
for (let i = 0; i < args.length; i++) {
81+
const arg = args[i];
82+
const next = args[i + 1];
83+
switch (arg) {
84+
case "--since":
85+
result.since = next || "";
86+
i++;
87+
break;
88+
case "--until":
89+
result.until = next || "HEAD";
90+
i++;
91+
break;
92+
case "--version":
93+
result.version = next || "";
94+
i++;
95+
break;
96+
case "--format":
97+
result.format = next || "markdown";
98+
i++;
99+
break;
100+
case "--output":
101+
result.output = next || null;
102+
i++;
103+
break;
104+
case "--help":
105+
console.log(`
106+
Release Notes Generator
107+
108+
Usage: bun run scripts/generate-release-notes.ts [options]
109+
110+
Options:
111+
--since <ref> Start reference (tag, commit, or branch)
112+
Default: auto-detect last release tag
113+
--until <ref> End reference (tag, commit, or branch)
114+
Default: HEAD
115+
--version <ver> Version string for the release header
116+
Default: derived from --until or current date
117+
--format <fmt> Output format: markdown (default) or json
118+
--output <file> Write to file instead of stdout
119+
120+
Examples:
121+
# Generate notes for upcoming 0.14.0 release
122+
bun run scripts/generate-release-notes.ts --version 0.14.0
123+
124+
# Generate notes between two commits
125+
bun run scripts/generate-release-notes.ts --since abc123 --until def456
126+
127+
# Output as JSON
128+
bun run scripts/generate-release-notes.ts --format json
129+
`);
130+
process.exit(0);
131+
}
132+
}
133+
return result;
134+
}
135+
136+
function detectLastTag(): string {
137+
// Try to find the last version tag
138+
const tags = execSync("git tag --sort=-version:refname 2>/dev/null").split("\n").filter(Boolean);
139+
140+
// Look for semantic version tags
141+
for (const tag of tags) {
142+
if (/^v?\d+\.\d+/.test(tag)) {
143+
return tag;
144+
}
145+
}
146+
147+
// Fallback: find a meaningful starting point from commit messages
148+
const versionCommit = execSync("git log --oneline --grep='prepare-for-0.14\\|0.13\\|release' --format='%H' | head -1");
149+
if (versionCommit) {
150+
return versionCommit;
151+
}
152+
153+
// Last resort: 100 commits back
154+
return "HEAD~100";
155+
}
156+
157+
function getCommitsBetween(since: string, until: string): string[] {
158+
// Get commit hashes between the two refs
159+
const range = since ? `${since}..${until}` : until;
160+
const output = execSync(`git log ${range} --format='%H' --no-merges 2>/dev/null`);
161+
return output.split("\n").filter(Boolean);
162+
}
163+
164+
function parseCommit(hash: string): ParsedCommit | null {
165+
const format = "%H%n%h%n%s%n%b%n%ad%n%an%n---END---";
166+
const output = execSync(`git log -1 --format='${format}' --date=short ${hash}`);
167+
168+
if (!output) return null;
169+
170+
const parts = output.split("\n---END---")[0]?.split("\n") || [];
171+
const [fullHash, shortHash, subject, ...rest] = parts;
172+
const author = rest.pop() || "";
173+
const date = rest.pop() || "";
174+
const body = rest.join("\n").trim();
175+
176+
if (!subject) return null;
177+
178+
// Parse conventional commit format: type(scope): subject
179+
// Also handle: type: subject, type!: subject (breaking)
180+
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
181+
182+
let type = "other";
183+
let scope: string | null = null;
184+
let breaking = false;
185+
let cleanSubject = subject;
186+
187+
if (match) {
188+
type = match[1]?.toLowerCase() || "other";
189+
scope = match[2] || null;
190+
breaking = !!match[3] || body.includes("BREAKING CHANGE");
191+
cleanSubject = match[4] || subject;
192+
}
193+
194+
// Normalize type aliases
195+
if (type === "feature") type = "feat";
196+
if (type === "bugfix") type = "fix";
197+
198+
return {
199+
hash: fullHash || hash,
200+
shortHash: shortHash || hash.slice(0, 7),
201+
type,
202+
scope,
203+
subject: cleanSubject,
204+
body,
205+
breaking,
206+
date: date || new Date().toISOString().split("T")[0] || "",
207+
author,
208+
};
209+
}
210+
211+
function categorizeCommits(commits: ParsedCommit[]): Record<string, ParsedCommit[]> {
212+
const categories: Record<string, ParsedCommit[]> = {};
213+
214+
for (const commit of commits) {
215+
const type = COMMIT_TYPES[commit.type] ? commit.type : "other";
216+
if (!categories[type]) {
217+
categories[type] = [];
218+
}
219+
categories[type].push(commit);
220+
}
221+
222+
return categories;
223+
}
224+
225+
function generateMarkdown(notes: ReleaseNotes): string {
226+
const lines: string[] = [];
227+
228+
// Header
229+
const dateStr = new Date().toISOString().split("T")[0];
230+
lines.push(`# Release ${notes.version || "Notes"}`);
231+
lines.push("");
232+
lines.push(`**Release Date:** ${dateStr}`);
233+
lines.push("");
234+
235+
// Stats summary
236+
lines.push("## Summary");
237+
lines.push("");
238+
lines.push(`This release includes **${notes.stats.total}** changes:`);
239+
lines.push(`- ${notes.stats.features} new features`);
240+
lines.push(`- ${notes.stats.fixes} bug fixes`);
241+
if (notes.stats.contributors.length > 0) {
242+
lines.push(`- ${notes.stats.contributors.length} contributors`);
243+
}
244+
lines.push("");
245+
246+
// Breaking changes (if any)
247+
if (notes.breaking.length > 0) {
248+
lines.push("## Breaking Changes");
249+
lines.push("");
250+
for (const commit of notes.breaking) {
251+
const scopeStr = commit.scope ? `**${commit.scope}:** ` : "";
252+
lines.push(`- ${scopeStr}${commit.subject}`);
253+
}
254+
lines.push("");
255+
}
256+
257+
// Categories sorted by priority
258+
const sortedTypes = Object.keys(notes.categories).sort((a, b) => {
259+
const pa = COMMIT_TYPES[a]?.priority ?? 99;
260+
const pb = COMMIT_TYPES[b]?.priority ?? 99;
261+
return pa - pb;
262+
});
263+
264+
for (const type of sortedTypes) {
265+
const commits = notes.categories[type];
266+
if (!commits || commits.length === 0) continue;
267+
268+
const typeInfo = COMMIT_TYPES[type] || { title: "Other Changes", emoji: "📝", priority: 99 };
269+
lines.push(`## ${typeInfo.emoji} ${typeInfo.title}`);
270+
lines.push("");
271+
272+
// Group by scope within each type
273+
const byScope: Record<string, ParsedCommit[]> = {};
274+
for (const commit of commits) {
275+
const scope = commit.scope || "_general";
276+
if (!byScope[scope]) byScope[scope] = [];
277+
byScope[scope].push(commit);
278+
}
279+
280+
// Sort scopes: known scopes first, then alphabetically
281+
const scopes = Object.keys(byScope).sort((a, b) => {
282+
if (a === "_general") return 1;
283+
if (b === "_general") return -1;
284+
const aKnown = KNOWN_SCOPES.includes(a);
285+
const bKnown = KNOWN_SCOPES.includes(b);
286+
if (aKnown && !bKnown) return -1;
287+
if (!aKnown && bKnown) return 1;
288+
return a.localeCompare(b);
289+
});
290+
291+
for (const scope of scopes) {
292+
const scopeCommits = byScope[scope] || [];
293+
if (scope !== "_general" && scopeCommits.length > 0) {
294+
lines.push(`### ${scope}`);
295+
lines.push("");
296+
}
297+
for (const commit of scopeCommits) {
298+
lines.push(`- ${commit.subject} (\`${commit.shortHash}\`)`);
299+
}
300+
lines.push("");
301+
}
302+
}
303+
304+
// Contributors
305+
if (notes.stats.contributors.length > 0) {
306+
lines.push("## Contributors");
307+
lines.push("");
308+
lines.push("Thank you to all contributors:");
309+
lines.push("");
310+
for (const contributor of notes.stats.contributors.sort()) {
311+
lines.push(`- ${contributor}`);
312+
}
313+
lines.push("");
314+
}
315+
316+
return lines.join("\n");
317+
}
318+
319+
function generateJson(notes: ReleaseNotes): string {
320+
return JSON.stringify(notes, null, 2);
321+
}
322+
323+
async function main() {
324+
const args = parseArgs();
325+
326+
// Determine the range
327+
const since = args.since || detectLastTag();
328+
const until = args.until;
329+
330+
const log = (msg: string) => process.stderr.write(msg + "\n");
331+
log(`Analyzing commits from ${since} to ${until}...`);
332+
333+
// Get and parse commits
334+
const hashes = getCommitsBetween(since, until);
335+
log(`Found ${hashes.length} commits to analyze`);
336+
337+
const commits: ParsedCommit[] = [];
338+
for (const hash of hashes) {
339+
const parsed = parseCommit(hash);
340+
if (parsed) {
341+
commits.push(parsed);
342+
}
343+
}
344+
345+
// Build release notes structure
346+
const categories = categorizeCommits(commits);
347+
const breaking = commits.filter((c) => c.breaking);
348+
const contributors = [...new Set(commits.map((c) => c.author))];
349+
350+
const notes: ReleaseNotes = {
351+
version: args.version || "",
352+
date: new Date().toISOString().split("T")[0] || "",
353+
sinceRef: since,
354+
untilRef: until,
355+
commits,
356+
categories,
357+
breaking,
358+
stats: {
359+
total: commits.length,
360+
features: categories["feat"]?.length || 0,
361+
fixes: categories["fix"]?.length || 0,
362+
contributors,
363+
},
364+
};
365+
366+
// Generate output
367+
let output: string;
368+
if (args.format === "json") {
369+
output = generateJson(notes);
370+
} else {
371+
output = generateMarkdown(notes);
372+
}
373+
374+
// Write output
375+
if (args.output) {
376+
fs.writeFileSync(args.output, output, "utf8");
377+
log(`Release notes written to: ${args.output}`);
378+
} else {
379+
console.log(output);
380+
}
381+
}
382+
383+
main().catch((err) => {
384+
console.error("Error generating release notes:", err);
385+
process.exit(1);
386+
});

0 commit comments

Comments
 (0)