CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| a53ceab | 1 | /** |
| 2 | * Block J15 — Release-notes generator. Pure helpers + route-auth smoke. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { | |
| 8 | classifyCommit, | |
| 9 | groupCommits, | |
| 10 | contributorsFrom, | |
| 11 | renderNotesMarkdown, | |
| 12 | BUCKET_ORDER, | |
| 13 | __internal, | |
| 14 | } from "../lib/release-notes"; | |
| 15 | ||
| 16 | describe("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 | ||
| 135 | describe("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 | ||
| 160 | describe("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 | ||
| 177 | describe("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 | ||
| 252 | describe("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 | ||
| 267 | describe("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 | ||
| 289 | describe("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 | }); |