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.test.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.test.tsBlame295 lines · 1 contributor
a53ceabClaude1/**
2 * Block J15 — Release-notes generator. Pure helpers + route-auth smoke.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 classifyCommit,
9 groupCommits,
10 contributorsFrom,
11 renderNotesMarkdown,
12 BUCKET_ORDER,
13 __internal,
14} from "../lib/release-notes";
15
16describe("release-notes — classifyCommit", () => {
17 it("recognises feat / fix / perf / refactor / docs / chore / revert prefixes", () => {
18 expect(classifyCommit({ sha: "a", message: "feat: thing" }).bucket).toBe(
19 "features"
20 );
21 expect(classifyCommit({ sha: "b", message: "fix: thing" }).bucket).toBe(
22 "fixes"
23 );
24 expect(classifyCommit({ sha: "c", message: "perf: thing" }).bucket).toBe(
25 "perf"
26 );
27 expect(classifyCommit({ sha: "d", message: "refactor: thing" }).bucket).toBe(
28 "refactor"
29 );
30 expect(classifyCommit({ sha: "e", message: "docs: thing" }).bucket).toBe(
31 "docs"
32 );
33 expect(classifyCommit({ sha: "f", message: "chore: thing" }).bucket).toBe(
34 "chore"
35 );
36 expect(classifyCommit({ sha: "g", message: "revert: thing" }).bucket).toBe(
37 "revert"
38 );
39 });
40
41 it("treats aliases (feature, bugfix, doc) as canonical", () => {
42 expect(classifyCommit({ sha: "a", message: "feature: x" }).bucket).toBe(
43 "features"
44 );
45 expect(classifyCommit({ sha: "b", message: "bugfix: x" }).bucket).toBe(
46 "fixes"
47 );
48 expect(classifyCommit({ sha: "c", message: "doc: x" }).bucket).toBe("docs");
49 expect(classifyCommit({ sha: "d", message: "tests: x" }).bucket).toBe("test");
50 });
51
52 it("is case-insensitive on the prefix", () => {
53 expect(classifyCommit({ sha: "a", message: "Feat: x" }).bucket).toBe(
54 "features"
55 );
56 expect(classifyCommit({ sha: "b", message: "FIX: x" }).bucket).toBe("fixes");
57 });
58
59 it("extracts scope from feat(scope): ...", () => {
60 const c = classifyCommit({ sha: "a", message: "feat(api): add endpoint" });
61 expect(c.bucket).toBe("features");
62 expect(c.scope).toBe("api");
63 expect(c.subject).toBe("add endpoint");
64 });
65
66 it("flags breaking with the `!` marker", () => {
67 const c = classifyCommit({ sha: "a", message: "feat(api)!: drop v1" });
68 expect(c.isBreaking).toBe(true);
69 expect(c.scope).toBe("api");
70 });
71
72 it("flags breaking with BREAKING CHANGE in subject", () => {
73 expect(
74 classifyCommit({ sha: "a", message: "feat: drop v1 BREAKING CHANGE" })
75 .isBreaking
76 ).toBe(true);
77 });
78
79 it("captures trailing (#NNN) PR number", () => {
80 const c = classifyCommit({
81 sha: "a",
82 message: "fix: off-by-one (#123)",
83 });
84 expect(c.prNumber).toBe(123);
85 expect(c.subject).toBe("off-by-one");
86 });
87
88 it("parses Merge pull request #N commits into merges bucket", () => {
89 const c = classifyCommit({
90 sha: "a",
91 message: "Merge pull request #42 from user/branch",
92 });
93 expect(c.bucket).toBe("merges");
94 expect(c.prNumber).toBe(42);
95 });
96
97 it("parses Merge branch commits into merges bucket", () => {
98 const c = classifyCommit({
99 sha: "a",
100 message: "Merge branch 'main' into feature",
101 });
102 expect(c.bucket).toBe("merges");
103 expect(c.prNumber).toBeNull();
104 });
105
106 it("falls back to 'other' for non-conventional subjects", () => {
107 expect(
108 classifyCommit({ sha: "a", message: "fix the thing that broke" }).bucket
109 ).toBe("other");
110 expect(classifyCommit({ sha: "b", message: "WIP" }).bucket).toBe("other");
111 });
112
113 it("does not match fake prefixes embedded in words", () => {
114 // `fixed: ...` shouldn't match `fix:` — the regex needs the literal prefix+colon.
115 const c = classifyCommit({ sha: "a", message: "fixed some stuff" });
116 expect(c.bucket).toBe("other");
117 });
118
119 it("handles blank subjects gracefully", () => {
120 const c = classifyCommit({ sha: "a", message: "" });
121 expect(c.bucket).toBe("other");
122 expect(c.subject).toBe("");
123 });
124
125 it("preserves author on the classified row", () => {
126 const c = classifyCommit({
127 sha: "a",
128 message: "feat: x",
129 author: "ada",
130 });
131 expect(c.author).toBe("ada");
132 });
133});
134
135describe("release-notes — groupCommits", () => {
136 it("splits a mixed list into the right buckets", () => {
137 const groups = groupCommits([
138 { sha: "1", message: "feat: a" },
139 { sha: "2", message: "fix: b" },
140 { sha: "3", message: "chore: c" },
141 { sha: "4", message: "feat: d" },
142 { sha: "5", message: "nothing special" },
143 ]);
144 expect(groups.features.map((x) => x.sha)).toEqual(["1", "4"]);
145 expect(groups.fixes.map((x) => x.sha)).toEqual(["2"]);
146 expect(groups.chore.map((x) => x.sha)).toEqual(["3"]);
147 expect(groups.other.map((x) => x.sha)).toEqual(["5"]);
148 });
149
150 it("preserves input order within each bucket", () => {
151 const groups = groupCommits([
152 { sha: "b", message: "feat: b" },
153 { sha: "a", message: "feat: a" },
154 { sha: "c", message: "feat: c" },
155 ]);
156 expect(groups.features.map((x) => x.sha)).toEqual(["b", "a", "c"]);
157 });
158});
159
160describe("release-notes — contributorsFrom", () => {
161 it("returns unique authors sorted case-insensitively", () => {
162 expect(
163 contributorsFrom([
164 { sha: "1", message: "x", author: "Zoe" },
165 { sha: "2", message: "y", author: "ada" },
166 { sha: "3", message: "z", author: "Zoe" },
167 { sha: "4", message: "w", author: "" },
168 ])
169 ).toEqual(["ada", "Zoe"]);
170 });
171
172 it("returns [] when no authors", () => {
173 expect(contributorsFrom([{ sha: "1", message: "x" }])).toEqual([]);
174 });
175});
176
177describe("release-notes — renderNotesMarkdown", () => {
178 it("returns a placeholder for empty input", () => {
179 const md = renderNotesMarkdown([]);
180 expect(md).toContain("No commits between these refs");
181 });
182
183 it("renders bucket headings in BUCKET_ORDER", () => {
184 const md = renderNotesMarkdown([
185 { sha: "1", message: "chore: c" },
186 { sha: "2", message: "feat: a" },
187 { sha: "3", message: "fix: b" },
188 ]);
189 const featIdx = md.indexOf("## Features");
190 const fixIdx = md.indexOf("## Bug fixes");
191 const choreIdx = md.indexOf("## Chores");
192 expect(featIdx).toBeGreaterThanOrEqual(0);
193 expect(fixIdx).toBeGreaterThan(featIdx);
194 expect(choreIdx).toBeGreaterThan(fixIdx);
195 });
196
197 it("surfaces a 'Breaking changes' section at the top", () => {
198 const md = renderNotesMarkdown([
199 { sha: "1", message: "feat!: drop v1" },
200 { sha: "2", message: "fix: tidy" },
201 ]);
202 const breakIdx = md.indexOf("Breaking changes");
203 const featIdx = md.indexOf("## Features");
204 expect(breakIdx).toBeGreaterThanOrEqual(0);
205 expect(breakIdx).toBeLessThan(featIdx);
206 });
207
208 it("emits bold scope prefixes in list rows", () => {
209 const md = renderNotesMarkdown([
210 { sha: "abcdef1", message: "feat(api): add endpoint" },
211 ]);
212 expect(md).toContain("**api:**");
213 expect(md).toContain("add endpoint");
214 expect(md).toContain("abcdef1");
215 });
216
217 it("includes a Contributors section when authors present", () => {
218 const md = renderNotesMarkdown(
219 [{ sha: "1", message: "feat: a", author: "ada" }],
220 { includeContributors: true }
221 );
222 expect(md).toContain("## Contributors");
223 expect(md).toContain("@ada");
224 });
225
226 it("skips Contributors when includeContributors: false", () => {
227 const md = renderNotesMarkdown(
228 [{ sha: "1", message: "feat: a", author: "ada" }],
229 { includeContributors: false }
230 );
231 expect(md).not.toContain("## Contributors");
232 });
233
234 it("emits a compare link when owner/repo + tags provided", () => {
235 const md = renderNotesMarkdown(
236 [{ sha: "1", message: "feat: a" }],
237 { ownerRepo: "acme/widget", previousTag: "v1", newTag: "v2" }
238 );
239 expect(md).toContain("/acme/widget/compare/v1...v2");
240 });
241
242 it("produces Markdown links for PR numbers when ownerRepo provided", () => {
243 const md = renderNotesMarkdown(
244 [{ sha: "abcdef0", message: "feat: a (#7)" }],
245 { ownerRepo: "acme/widget", newTag: "v1" }
246 );
247 expect(md).toContain("/acme/widget/pulls/7");
248 expect(md).toContain("/acme/widget/commit/abcdef0");
249 });
250});
251
252describe("release-notes — BUCKET_ORDER", () => {
253 it("lists features before fixes before perf", () => {
254 expect(BUCKET_ORDER.indexOf("features")).toBeLessThan(
255 BUCKET_ORDER.indexOf("fixes")
256 );
257 expect(BUCKET_ORDER.indexOf("fixes")).toBeLessThan(
258 BUCKET_ORDER.indexOf("perf")
259 );
260 });
261
262 it("puts 'other' last", () => {
263 expect(BUCKET_ORDER[BUCKET_ORDER.length - 1]).toBe("other");
264 });
265});
266
267describe("release-notes — route smoke", () => {
268 it("POST /generate-notes requires auth (redirects or 401)", async () => {
269 const res = await app.request(
270 "/alice/nope/releases/generate-notes",
271 { method: "POST", body: "target=main" }
272 );
273 expect([302, 401, 404].includes(res.status)).toBe(true);
274 });
275
276 it("POST with invalid bearer → 401 JSON", async () => {
277 const res = await app.request(
278 "/alice/nope/releases/generate-notes",
279 {
280 method: "POST",
281 headers: { authorization: "Bearer glc_garbage" },
282 body: "target=main",
283 }
284 );
285 expect(res.status).toBe(401);
286 });
287});
288
289describe("release-notes — __internal symmetry", () => {
290 it("re-exports the same classifyCommit / groupCommits / renderNotesMarkdown", () => {
291 expect(__internal.classifyCommit).toBe(classifyCommit);
292 expect(__internal.groupCommits).toBe(groupCommits);
293 expect(__internal.renderNotesMarkdown).toBe(renderNotesMarkdown);
294 });
295});