-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgen-release-notes.ts
More file actions
136 lines (123 loc) · 4.08 KB
/
Copy pathgen-release-notes.ts
File metadata and controls
136 lines (123 loc) · 4.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
#!/usr/bin/env node
// gen-release-notes — generate release notes by walking commits between two refs.
// Spec: docs/DEVELOPMENT_PLAN.md §9 (M9 release pipeline)
//
// Usage:
// tsx scripts/gen-release-notes.ts <from-ref> <to-ref> # write to stdout
// tsx scripts/gen-release-notes.ts <from-ref> <to-ref> > NOTES.md
//
// Output buckets commits by conventional-commit type:
// feat: → ✨ New
// fix: → 🐛 Fixes
// perf: → ⚡ Performance
// refactor: → ♻️ Refactor
// docs: → 📝 Docs
// test: → 🧪 Tests
// chore: → 🔧 Chore
// anything else → 📦 Other
import { spawnSync } from 'node:child_process';
interface Commit {
hash: string;
subject: string;
body: string;
}
interface Bucket {
label: string;
emoji: string;
commits: Commit[];
}
const BUCKETS: Record<string, { label: string; emoji: string }> = {
feat: { label: 'New', emoji: '✨' },
fix: { label: 'Fixes', emoji: '🐛' },
perf: { label: 'Performance', emoji: '⚡' },
refactor: { label: 'Refactor', emoji: '♻️' },
docs: { label: 'Docs', emoji: '📝' },
test: { label: 'Tests', emoji: '🧪' },
chore: { label: 'Chore', emoji: '🔧' },
other: { label: 'Other', emoji: '📦' },
};
const BUCKET_ORDER = ['feat', 'fix', 'perf', 'refactor', 'docs', 'test', 'chore', 'other'];
function gitLog(fromRef: string, toRef: string): Commit[] {
// %H = full hash, %s = subject, %b = body — separated by NUL for safety
const sep = '__DEEPCODE_SEP__';
const recordSep = '__DEEPCODE_RECORD__';
const fmt = `%H${sep}%s${sep}%b${recordSep}`;
const r = spawnSync('git', ['log', `--pretty=format:${fmt}`, `${fromRef}..${toRef}`], {
encoding: 'utf8',
});
if (r.status !== 0) {
process.stderr.write(`git log failed: ${r.stderr}\n`);
process.exit(2);
}
return r.stdout
.split(recordSep)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const [hash = '', subject = '', body = ''] = s.split(sep);
return { hash, subject, body };
});
}
function classify(commit: Commit): string {
const m = /^([a-z]+)(?:\([^)]+\))?(?:!)?:/.exec(commit.subject);
if (!m) return 'other';
const type = m[1]!.toLowerCase();
return BUCKETS[type] ? type : 'other';
}
function bucketCommits(commits: Commit[]): Record<string, Bucket> {
const out: Record<string, Bucket> = {};
for (const key of BUCKET_ORDER) {
out[key] = { label: BUCKETS[key]!.label, emoji: BUCKETS[key]!.emoji, commits: [] };
}
for (const c of commits) {
out[classify(c)]!.commits.push(c);
}
return out;
}
function strip(s: string): string {
// Drop the "type(scope): " prefix and any Co-Authored-By trailer
return s
.replace(/^[a-z]+(?:\([^)]+\))?!?:\s*/, '')
.split('\n')
.filter((l) => !/^Co-Authored-By:/.test(l))
.join('\n')
.trim();
}
function renderMarkdown(fromRef: string, toRef: string, buckets: Record<string, Bucket>): string {
const lines: string[] = [];
lines.push(`# Release notes (${fromRef}…${toRef})`);
lines.push('');
for (const key of BUCKET_ORDER) {
const b = buckets[key]!;
if (b.commits.length === 0) continue;
lines.push(`## ${b.emoji} ${b.label}`);
lines.push('');
for (const c of b.commits) {
const subject = strip(c.subject);
const short = c.hash.slice(0, 7);
lines.push(`- ${subject} (${short})`);
}
lines.push('');
}
const total = Object.values(buckets).reduce((n, b) => n + b.commits.length, 0);
lines.push(`---`);
lines.push(`${total} commits.`);
return lines.join('\n');
}
function main(): void {
const [from, to] = process.argv.slice(2);
if (!from || !to) {
process.stderr.write('Usage: gen-release-notes <from-ref> <to-ref>\n');
process.exit(2);
}
const commits = gitLog(from, to);
const buckets = bucketCommits(commits);
process.stdout.write(renderMarkdown(from, to, buckets) + '\n');
}
// Expose for tests
export { gitLog, bucketCommits, classify, renderMarkdown, strip };
// CLI entry — only run if invoked directly
const invoked = process.argv[1] ?? '';
if (invoked.includes('gen-release-notes')) {
main();
}