Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita53ceabunknown_key

feat(BLOCK-J): J15 deterministic release-notes generator

feat(BLOCK-J): J15 deterministic release-notes generator

- src/lib/release-notes.ts: pure classifier / grouper / Markdown
  renderer with conventional-commit prefix matching (feat/fix/perf/
  refactor/docs/chore/revert + aliases), scope capture, `!` breaking
  marker, trailing `(#N)` PR capture, and Merge-commit detection
- src/routes/releases.tsx: "Generate from commits" button on the
  new-release form re-renders with notes prefilled while preserving
  tag/name/target/draft/prerelease fields. AI-disabled repos now fall
  through to the deterministic renderer on publish
- 30 new tests covering classifier edge cases, bucket ordering,
  breaking surfacing, contributors rollup, compare-link emission
- Total suite 897/897
Claude committed on April 15, 2026Parent: bed6b57
4 files changed+82319a53ceab429b4ffe30b698515382a6420e6c80179
4 changed files+823−19
ModifiedBUILD_BIBLE.md+3−0View fileUnifiedSplit
137137| Community profile (health standards) | ✅ | J12 — `GET /:owner/:repo/community` scores the repo on 8 items (description, README, LICENSE — required; CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics — recommended). Pure `checklistFromInputs` + `buildReport`, git-layer `computeHealth`. One-click "Add <path>" links route to the web editor. `src/lib/community.ts` + `src/routes/community.tsx`. |
138138| Pinned repositories on profile | ✅ | J13 — users pin up to 6 repos ordered explicitly; `drizzle/0035_pinned_repos.sql` adds `pinned_repositories`. Manage at `/settings/pins`. Profile page renders "Pinned" grid above the repo list with viewer-aware private filtering. `src/lib/pinned-repos.ts` + `src/routes/pinned-repos.tsx`. |
139139| Issue dependencies (blocked-by / blocks) | ✅ | J14 — `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (CHECK no-self, unique on pair, both-side indexes). Pure `wouldCreateCycle` BFS + `summariseBlockers`. `addDependency` enforces same-repo, no-self, no-dup, no-cycle with `{ok, reason}` error taxonomy. Issue detail page gets a "Dependencies" panel with "Blocked by" / "Blocks" lists, state pills, `#number` add form + per-row dismiss. `src/lib/issue-dependencies.ts` + routes in `src/routes/issues.tsx`. |
140| Deterministic release-notes generator | ✅ | J15 — `src/lib/release-notes.ts` classifies commits by conventional-commit prefix (feat/fix/perf/refactor/docs/chore/revert/style/build/ci/test + aliases + `!` breaking marker + trailing `(#N)` capture) into 13 ordered buckets and renders Markdown with a Breaking-changes section, per-bucket headings, Contributors list, and Full-Changelog compare link. "Generate from commits" button on the new-release form prefills the notes textarea without losing other field state; AI-disabled repos now fall through to the deterministic path instead of publishing blank notes. `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes`. |
140141| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
141142| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
142143| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
299300- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
300301- **J13** — Pinned repositories on user profile → ✅ shipped. `drizzle/0035_pinned_repos.sql` adds `pinned_repositories` (unique on `(user_id, repository_id)`, `position` int for explicit ordering). `src/lib/pinned-repos.ts` exposes `MAX_PINS=6`, pure `sanitisePinIds` (de-dup + clamp + trim), `listPinnedForUser` (ordered with owner username joined), `setPinsForUser` (delete-then-insert, filters out private repos the viewer doesn't own), `listPinCandidates`. `src/routes/pinned-repos.tsx` serves `GET/POST /settings/pins` (requireAuth) with a checkbox grid preview. The profile page in `src/routes/web.tsx` now renders a "Pinned" section above the repo grid when the user has any pinned repos; private pins hidden from other viewers. 9 new tests. Total suite 853/853.
301302- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
303- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
302304- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
303305- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
304306- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
496498- `src/routes/community.tsx` (Block J12) — serves `/:owner/:repo/community`. softAuth. Renders progress bar (green ≥80%, yellow ≥50%, else red) + per-item row with required badge + "Add <path>" or "Edit settings" CTA.
497499- `src/lib/review-requests.ts` (Block J11) — PR review-request lifecycle helpers. Pure: `isValidSource`, `isValidState`, `nextState` (state machine; `dismissed` terminal, `commented` is no-op), `sanitiseCandidates` (de-dup + drop author). DB: `requestReviewers` (idempotent, skips existing (pr, reviewer) rows), `listForPr` (joins `users` for username), `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners` (diff paths → CODEOWNERS → user IDs → review requests + `review_requested` notifications), `countPendingForUser` (for inbox badges). Every DB helper swallows errors and returns safe defaults — never throws.
498500- `src/lib/issue-dependencies.ts` (Block J14) — issue "blocker blocks blocked" dependency helpers. Pure: `wouldCreateCycle` (BFS following forward blocks edges; self-refs return true), `summariseBlockers` (counts {open, closed, total}). DB: `addDependency` (rejects with `{ok:false, reason: 'self'|'cross_repo'|'exists'|'cycle'|'not_found'|'error'}`), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Same-repo enforcement at app layer. `__internal` re-exports for tests.
501- `src/lib/release-notes.ts` (Block J15) — pure release-notes generator. Exports `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` capture; handles `feature`/`bugfix`/`doc`/`tests` aliases; `Merge pull request #N` + `Merge branch ...` detection), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). Zero-IO, never throws. `__internal` re-exports for tests.
499502
500503### 4.7 Views (locked contracts)
501504- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/release-notes.test.ts+295−0View fileUnifiedSplit
1/**
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});
Addedsrc/lib/release-notes.ts+325−0View fileUnifiedSplit
1/**
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};
Modifiedsrc/routes/releases.tsx+200−19View fileUnifiedSplit
3434 getDefaultBranch,
3535} from "../git/repository";
3636import { generateChangelog } from "../lib/ai-generators";
37import { renderNotesMarkdown } from "../lib/release-notes";
3738import { notifyMany, audit } from "../lib/notify";
3839import { renderMarkdown } from "../lib/markdown";
3940import { getUnreadCount } from "../lib/unread";
179180 );
180181});
181182
182releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
183 const user = c.get("user")!;
184 const { owner, repo } = c.req.param();
185 const repoRow = await loadRepo(owner, repo);
186 if (!repoRow) return c.notFound();
187 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
188
183// Shared form renderer — used by GET /new and POST /generate-notes.
184async function renderNewReleaseForm(
185 c: any,
186 user: any,
187 owner: string,
188 repo: string,
189 repoRow: any,
190 prefill: {
191 tag?: string;
192 target?: string;
193 name?: string;
194 previousTag?: string;
195 body?: string;
196 isPrerelease?: boolean;
197 isDraft?: boolean;
198 notice?: string | null;
199 } = {}
200) {
189201 const branches = await listBranches(owner, repo);
190202 const tags = await listTags(owner, repo);
191203 const unread = await getUnreadCount(user.id);
207219 <RepoNav owner={owner} repo={repo} active="releases" />
208220 <h3>Draft a new release</h3>
209221 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
222 {prefill.notice && (
223 <div
224 style="padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; font-size: 13px"
225 >
226 {prefill.notice}
227 </div>
228 )}
210229 <form
211230 method="POST"
212231 action={`/${owner}/${repo}/releases`}
220239 required
221240 placeholder="v1.0.0"
222241 pattern="[A-Za-z0-9._\\-]+"
242 value={prefill.tag || ""}
223243 />
224244 </div>
225245 <div class="form-group">
226246 <label>Target branch / commit</label>
227247 <select name="target">
228248 {branches.map((b) => (
229 <option value={b} selected={b === repoRow.defaultBranch}>
249 <option
250 value={b}
251 selected={
252 prefill.target
253 ? b === prefill.target
254 : b === repoRow.defaultBranch
255 }
256 >
230257 {b}
231258 </option>
232259 ))}
234261 </div>
235262 <div class="form-group">
236263 <label>Release name</label>
237 <input type="text" name="name" required placeholder="v1.0.0 — the big one" />
264 <input
265 type="text"
266 name="name"
267 required
268 placeholder="v1.0.0 — the big one"
269 value={prefill.name || ""}
270 />
238271 </div>
239272 <div class="form-group">
240 <label>Previous tag (for AI changelog)</label>
273 <label>Previous tag (for changelog)</label>
241274 <select name="previousTag">
242 <option value="">(auto — last tag)</option>
275 <option value="" selected={!prefill.previousTag}>
276 (auto — last tag)
277 </option>
243278 {tags.map((t) => (
244 <option value={t.name}>{t.name}</option>
279 <option value={t.name} selected={t.name === prefill.previousTag}>
280 {t.name}
281 </option>
245282 ))}
246283 </select>
247284 </div>
248285 <div class="form-group">
249286 <label>Notes (leave blank for AI-generated)</label>
250 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
287 <textarea
288 name="body"
289 rows={14}
290 placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits, or click 'Generate from commits' to prefill a deterministic conventional-commit grouping."
291 >
292 {prefill.body || ""}
293 </textarea>
251294 </div>
252 <div style="display: flex; gap: 12px">
295 <div style="display: flex; gap: 12px; align-items: center">
253296 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
254 <input type="checkbox" name="isPrerelease" value="1" />
297 <input
298 type="checkbox"
299 name="isPrerelease"
300 value="1"
301 checked={!!prefill.isPrerelease}
302 />
255303 Pre-release
256304 </label>
257305 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
258 <input type="checkbox" name="isDraft" value="1" />
306 <input
307 type="checkbox"
308 name="isDraft"
309 value="1"
310 checked={!!prefill.isDraft}
311 />
259312 Save as draft
260313 </label>
261314 </div>
262 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
263 Publish release
264 </button>
315 <div style="display: flex; gap: 8px; margin-top: 16px">
316 <button type="submit" class="btn btn-primary">
317 Publish release
318 </button>
319 <button
320 type="submit"
321 class="btn"
322 formaction={`/${owner}/${repo}/releases/generate-notes`}
323 formnovalidate
324 title="Classify commits by conventional-commit prefix and prefill notes"
325 >
326 Generate from commits
327 </button>
328 </div>
265329 </form>
266330 </Layout>
267331 );
332}
333
334releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
335 const user = c.get("user")!;
336 const { owner, repo } = c.req.param();
337 const repoRow = await loadRepo(owner, repo);
338 if (!repoRow) return c.notFound();
339 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
340 return renderNewReleaseForm(c, user, owner, repo, repoRow);
268341});
269342
343// J15 — deterministic notes prefill from conventional commits.
344releasesRoute.post(
345 "/:owner/:repo/releases/generate-notes",
346 requireAuth,
347 async (c) => {
348 const user = c.get("user")!;
349 const { owner, repo } = c.req.param();
350 const repoRow = await loadRepo(owner, repo);
351 if (!repoRow) return c.notFound();
352 if (repoRow.ownerId !== user.id) {
353 return c.redirect(`/${owner}/${repo}/releases`);
354 }
355
356 const body = await c.req.parseBody();
357 const tag = String(body.tag || "").trim();
358 const name = String(body.name || "").trim();
359 const target = String(body.target || repoRow.defaultBranch).trim();
360 const previousTag = String(body.previousTag || "").trim();
361 const existingBody = String(body.body || "");
362 const isDraft = !!body.isDraft;
363 const isPrerelease = !!body.isPrerelease;
364
365 let autoPrev = previousTag;
366 if (!autoPrev) {
367 try {
368 const tags = await listTags(owner, repo);
369 autoPrev = tags[0]?.name || "";
370 } catch {
371 autoPrev = "";
372 }
373 }
374
375 let notice = "Prefilled notes from conventional-commit grouping.";
376 let generated = "";
377 try {
378 const sha = await resolveRef(owner, repo, target);
379 if (!sha) {
380 notice = `Could not resolve target "${target}". Notes left unchanged.`;
381 return renderNewReleaseForm(c, user, owner, repo, repoRow, {
382 tag,
383 target,
384 name,
385 previousTag: autoPrev,
386 body: existingBody,
387 isDraft,
388 isPrerelease,
389 notice,
390 });
391 }
392 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
393 generated = renderNotesMarkdown(
394 commits.map((cmt) => ({
395 sha: cmt.sha,
396 message: cmt.message,
397 author: cmt.author,
398 })),
399 {
400 ownerRepo: `${owner}/${repo}`,
401 previousTag: autoPrev || null,
402 newTag: tag || "HEAD",
403 }
404 );
405 if (commits.length === 0) {
406 notice = `No commits between ${autoPrev || "<root>"} and ${target}.`;
407 }
408 } catch (err) {
409 console.error("[releases] generate-notes failed:", err);
410 notice = "Failed to read commits — please try again.";
411 }
412
413 // Preserve whatever the owner had typed; append generated draft below.
414 const combined =
415 existingBody.trim().length === 0
416 ? generated
417 : `${existingBody.trimEnd()}\n\n${generated}`;
418
419 return renderNewReleaseForm(c, user, owner, repo, repoRow, {
420 tag,
421 target,
422 name,
423 previousTag: autoPrev,
424 body: combined,
425 isDraft,
426 isPrerelease,
427 notice,
428 });
429 }
430);
431
270432releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
271433 const user = c.get("user")!;
272434 const { owner, repo } = c.req.param();
315477 if (!finalBody && aiEnabled) {
316478 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
317479 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
480 } else if (!finalBody) {
481 // J15 — AI disabled: deterministic grouping from conventional commits.
482 try {
483 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
484 finalBody = renderNotesMarkdown(
485 commits.map((cmt) => ({
486 sha: cmt.sha,
487 message: cmt.message,
488 author: cmt.author,
489 })),
490 {
491 ownerRepo: `${owner}/${repo}`,
492 previousTag: autoPrev || null,
493 newTag: tag,
494 }
495 );
496 } catch {
497 finalBody = "";
498 }
318499 }
319500
320501 // Create the git tag (best-effort — if it already exists we reuse)
321502