Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

release-notes.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

release-notes.tsBlame325 lines · 1 contributor
a53ceabClaude1/**
2 * Block J15 — Release notes auto-generator.
3 *
4 * Deterministically classifies a list of commits by conventional-commit
5 * prefix (feat / fix / perf / refactor / docs / chore / revert / style /
6 * build / ci / test) and renders a grouped Markdown changelog. The whole
7 * module is pure: unit tests drive it with fake commit lists. The route
8 * layer calls `git log --format=...` then hands the rows in.
9 *
10 * If no commits carry conventional prefixes we still produce sensible
11 * "Other" + "Merges" sections — we never throw and never produce an
12 * empty string for a non-empty input.
13 */
14
15export type ReleaseBucket =
16 | "features"
17 | "fixes"
18 | "perf"
19 | "refactor"
20 | "docs"
21 | "chore"
22 | "revert"
23 | "style"
24 | "build"
25 | "ci"
26 | "test"
27 | "merges"
28 | "other";
29
30export interface CommitLike {
31 sha: string;
32 /** Subject line only (first line of the commit message). */
33 message: string;
34 /** Display name of the author (optional — used for contributor rollup). */
35 author?: string;
36}
37
38export interface ClassifiedCommit {
39 sha: string;
40 bucket: ReleaseBucket;
41 scope: string | null;
42 subject: string;
43 isBreaking: boolean;
44 prNumber: number | null;
45 author: string | null;
46}
47
48/** Ordered bucket list used when rendering so sections appear predictably. */
49export const BUCKET_ORDER: ReleaseBucket[] = [
50 "features",
51 "fixes",
52 "perf",
53 "refactor",
54 "docs",
55 "test",
56 "build",
57 "ci",
58 "style",
59 "chore",
60 "revert",
61 "merges",
62 "other",
63];
64
65const BUCKET_HEADINGS: Record<ReleaseBucket, string> = {
66 features: "Features",
67 fixes: "Bug fixes",
68 perf: "Performance",
69 refactor: "Refactors",
70 docs: "Documentation",
71 test: "Tests",
72 build: "Build",
73 ci: "CI",
74 style: "Style",
75 chore: "Chores",
76 revert: "Reverts",
77 merges: "Merges",
78 other: "Other changes",
79};
80
81const PREFIX_TO_BUCKET: Record<string, ReleaseBucket> = {
82 feat: "features",
83 feature: "features",
84 features: "features",
85 fix: "fixes",
86 bugfix: "fixes",
87 hotfix: "fixes",
88 perf: "perf",
89 performance: "perf",
90 refactor: "refactor",
91 docs: "docs",
92 doc: "docs",
93 documentation: "docs",
94 chore: "chore",
95 revert: "revert",
96 style: "style",
97 build: "build",
98 ci: "ci",
99 test: "test",
100 tests: "test",
101};
102
103// `feat(scope)!: subject` or `fix: subject` or `perf(api)!: ...`
104const CONVENTIONAL_RE =
105 /^(?<prefix>[a-zA-Z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?\s*:\s*(?<subject>.+)$/;
106
107/** `Merge pull request #123 from foo/bar` or `Merge branch 'x' into y`. */
108const MERGE_RE = /^Merge (pull request #(\d+)|branch|commit)/i;
109
110/** Trailing `(#123)` PR reference, as appended by squash merges. */
111const TRAILING_PR_RE = /\(#(\d+)\)\s*$/;
112
113/** Pure: classify a single commit message subject. */
114export function classifyCommit(commit: CommitLike): ClassifiedCommit {
115 const rawSubject = (commit.message || "").trim();
116 if (!rawSubject) {
117 return {
118 sha: commit.sha,
119 bucket: "other",
120 scope: null,
121 subject: "",
122 isBreaking: false,
123 prNumber: null,
124 author: commit.author || null,
125 };
126 }
127
128 // Merge commits
129 const mergeMatch = rawSubject.match(MERGE_RE);
130 if (mergeMatch) {
131 const prNum = mergeMatch[2] ? parseInt(mergeMatch[2], 10) : null;
132 return {
133 sha: commit.sha,
134 bucket: "merges",
135 scope: null,
136 subject: rawSubject,
137 isBreaking: false,
138 prNumber: Number.isFinite(prNum as number) ? (prNum as number) : null,
139 author: commit.author || null,
140 };
141 }
142
143 // Trailing `(#N)` — pull it off the subject but preserve it in metadata.
144 let subject = rawSubject;
145 let prNumber: number | null = null;
146 const trailingPr = subject.match(TRAILING_PR_RE);
147 if (trailingPr) {
148 const n = parseInt(trailingPr[1], 10);
149 if (Number.isFinite(n) && n > 0) prNumber = n;
150 subject = subject.replace(TRAILING_PR_RE, "").trim();
151 }
152
153 // Conventional prefix?
154 const m = subject.match(CONVENTIONAL_RE);
155 if (m && m.groups) {
156 const prefixRaw = m.groups.prefix.toLowerCase();
157 const bucket = PREFIX_TO_BUCKET[prefixRaw];
158 if (bucket) {
159 const scope = m.groups.scope?.trim() || null;
160 return {
161 sha: commit.sha,
162 bucket,
163 scope,
164 subject: m.groups.subject.trim(),
165 isBreaking: !!m.groups.bang || /BREAKING CHANGE/i.test(rawSubject),
166 prNumber,
167 author: commit.author || null,
168 };
169 }
170 }
171
172 return {
173 sha: commit.sha,
174 bucket: "other",
175 scope: null,
176 subject,
177 isBreaking: /BREAKING CHANGE/i.test(rawSubject),
178 prNumber,
179 author: commit.author || null,
180 };
181}
182
183/** Pure: group commits by bucket preserving original order within each bucket. */
184export function groupCommits(
185 commits: CommitLike[]
186): Record<ReleaseBucket, ClassifiedCommit[]> {
187 const out: Record<ReleaseBucket, ClassifiedCommit[]> = {
188 features: [],
189 fixes: [],
190 perf: [],
191 refactor: [],
192 docs: [],
193 test: [],
194 build: [],
195 ci: [],
196 style: [],
197 chore: [],
198 revert: [],
199 merges: [],
200 other: [],
201 };
202 for (const c of commits) {
203 const cls = classifyCommit(c);
204 out[cls.bucket].push(cls);
205 }
206 return out;
207}
208
209/** Pure: unique authors sorted case-insensitively. */
210export function contributorsFrom(commits: CommitLike[]): string[] {
211 const seen = new Set<string>();
212 for (const c of commits) {
213 const a = (c.author || "").trim();
214 if (a) seen.add(a);
215 }
216 return [...seen].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
217}
218
219function escapeMdInline(s: string): string {
220 // Keep it simple — don't escape backticks; they're meaningful in commit subjects.
221 return s.replace(/\r?\n/g, " ").trim();
222}
223
224function formatRow(cls: ClassifiedCommit, ownerRepo?: string): string {
225 const parts: string[] = [];
226 if (cls.isBreaking) parts.push("**BREAKING**");
227 if (cls.scope) parts.push(`**${escapeMdInline(cls.scope)}:**`);
228 parts.push(escapeMdInline(cls.subject));
229 let line = "- " + parts.filter(Boolean).join(" ");
230 const shortSha = cls.sha.slice(0, 7);
231 const suffixBits: string[] = [];
232 if (cls.prNumber) {
233 suffixBits.push(
234 ownerRepo
235 ? `[#${cls.prNumber}](/${ownerRepo}/pulls/${cls.prNumber})`
236 : `#${cls.prNumber}`
237 );
238 }
239 if (shortSha) {
240 suffixBits.push(
241 ownerRepo
242 ? `[\`${shortSha}\`](/${ownerRepo}/commit/${cls.sha})`
243 : `\`${shortSha}\``
244 );
245 }
246 if (suffixBits.length) line += " (" + suffixBits.join(", ") + ")";
247 return line;
248}
249
250export interface RenderOpts {
251 ownerRepo?: string;
252 previousTag?: string | null;
253 newTag?: string;
254 /** Include a "Contributors" section with a thanks list. Default true. */
255 includeContributors?: boolean;
256 /** Include a "Full Changelog" compare link at the bottom. Default true. */
257 includeCompareLink?: boolean;
258}
259
260/** Pure: render the full Markdown changelog body. */
261export function renderNotesMarkdown(
262 commits: CommitLike[],
263 opts: RenderOpts = {}
264): string {
265 const {
266 ownerRepo,
267 previousTag,
268 newTag,
269 includeContributors = true,
270 includeCompareLink = true,
271 } = opts;
272
273 if (commits.length === 0) {
274 return "_No commits between these refs._\n";
275 }
276
277 const groups = groupCommits(commits);
278 const lines: string[] = [];
279
280 // Surface any breaking changes up top.
281 const breaking: ClassifiedCommit[] = [];
282 for (const bucket of BUCKET_ORDER) {
283 for (const c of groups[bucket]) if (c.isBreaking) breaking.push(c);
284 }
285 if (breaking.length) {
286 lines.push("## \u26A0\uFE0F Breaking changes", "");
287 for (const c of breaking) lines.push(formatRow(c, ownerRepo));
288 lines.push("");
289 }
290
291 for (const bucket of BUCKET_ORDER) {
292 const rows = groups[bucket];
293 if (!rows.length) continue;
294 lines.push(`## ${BUCKET_HEADINGS[bucket]}`, "");
295 for (const c of rows) lines.push(formatRow(c, ownerRepo));
296 lines.push("");
297 }
298
299 if (includeContributors) {
300 const contribs = contributorsFrom(commits);
301 if (contribs.length) {
302 lines.push("## Contributors", "");
303 lines.push(contribs.map((c) => `@${c}`).join(", "));
304 lines.push("");
305 }
306 }
307
308 if (includeCompareLink && ownerRepo && previousTag && newTag) {
309 lines.push(
310 `**Full Changelog:** [\`${previousTag}...${newTag}\`](/${ownerRepo}/compare/${previousTag}...${newTag})`
311 );
312 }
313
314 return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
315}
316
317export const __internal = {
318 classifyCommit,
319 groupCommits,
320 contributorsFrom,
321 renderNotesMarkdown,
322 BUCKET_ORDER,
323 BUCKET_HEADINGS,
324 PREFIX_TO_BUCKET,
325};