CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| a53ceab | 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 | ||
| 15 | export 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 | ||
| 30 | export 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 | ||
| 38 | export 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. */ | |
| 49 | export 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 | ||
| 65 | const 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 | ||
| 81 | const 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)!: ...` | |
| 104 | const 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`. */ | |
| 108 | const MERGE_RE = /^Merge (pull request #(\d+)|branch|commit)/i; | |
| 109 | ||
| 110 | /** Trailing `(#123)` PR reference, as appended by squash merges. */ | |
| 111 | const TRAILING_PR_RE = /\(#(\d+)\)\s*$/; | |
| 112 | ||
| 113 | /** Pure: classify a single commit message subject. */ | |
| 114 | export 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. */ | |
| 184 | export 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. */ | |
| 210 | export 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 | ||
| 219 | function 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 | ||
| 224 | function 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 | ||
| 250 | export 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. */ | |
| 261 | export 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 | ||
| 317 | export const __internal = { | |
| 318 | classifyCommit, | |
| 319 | groupCommits, | |
| 320 | contributorsFrom, | |
| 321 | renderNotesMarkdown, | |
| 322 | BUCKET_ORDER, | |
| 323 | BUCKET_HEADINGS, | |
| 324 | PREFIX_TO_BUCKET, | |
| 325 | }; |