| 1 | /** |
| 2 | * Release notes like GitHub's "Generate release notes": merged pull requests |
| 3 | * between two tags, grouped by the categories in `.github/release.yml`, then |
| 4 | * first-time contributors and a full changelog link. Pure, so it is unit-tested. |
| 5 | * https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes |
| 6 | */ |
| 7 | import { parse } from "yaml"; |
| 8 | |
| 9 | export interface NotesPull { |
| 10 | number: number; |
| 11 | title: string; |
| 12 | author: string; |
| 13 | labels: string[]; |
| 14 | url: string; |
| 15 | } |
| 16 | |
| 17 | export interface NotesCategory { |
| 18 | title: string; |
| 19 | labels: string[]; |
| 20 | exclude?: { labels?: string[]; authors?: string[] }; |
| 21 | } |
| 22 | |
| 23 | export interface NotesConfig { |
| 24 | exclude?: { labels?: string[]; authors?: string[] }; |
| 25 | categories?: NotesCategory[]; |
| 26 | } |
| 27 | |
| 28 | const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : typeof v === "string" ? [v] : undefined); |
| 29 | |
| 30 | /** Parse `.github/release.yml`; returns an empty config for anything malformed. */ |
| 31 | export function parseReleaseConfig(text: string): NotesConfig { |
| 32 | let doc: unknown; |
| 33 | try { |
| 34 | doc = parse(text); |
| 35 | } catch { |
| 36 | return {}; |
| 37 | } |
| 38 | const changelog = (doc as { changelog?: Record<string, unknown> } | null)?.changelog; |
| 39 | if (!changelog || typeof changelog !== "object") return {}; |
| 40 | const ex = changelog.exclude as Record<string, unknown> | undefined; |
| 41 | const out: NotesConfig = {}; |
| 42 | if (ex && typeof ex === "object") out.exclude = { labels: strings(ex.labels), authors: strings(ex.authors) }; |
| 43 | if (Array.isArray(changelog.categories)) { |
| 44 | out.categories = []; |
| 45 | for (const raw of changelog.categories as Array<Record<string, unknown>>) { |
| 46 | if (!raw || typeof raw !== "object" || typeof raw.title !== "string") continue; |
| 47 | const cex = raw.exclude as Record<string, unknown> | undefined; |
| 48 | out.categories.push({ title: raw.title, labels: strings(raw.labels) ?? [], exclude: cex && typeof cex === "object" ? { labels: strings(cex.labels), authors: strings(cex.authors) } : undefined }); |
| 49 | } |
| 50 | } |
| 51 | return out; |
| 52 | } |
| 53 | |
| 54 | const hasAny = (labels: string[], wanted: string[] | undefined) => !!wanted?.length && (wanted.includes("*") || labels.some((l) => wanted.includes(l))); |
| 55 | const byAuthor = (author: string, authors: string[] | undefined) => !!authors?.some((a) => a === "*" || a.toLowerCase() === author.toLowerCase()); |
| 56 | |
| 57 | const line = (p: NotesPull) => `* ${p.title} by @${p.author} in ${p.url}`; |
| 58 | |
| 59 | export function renderReleaseNotes(input: { pulls: NotesPull[]; newContributors: NotesPull[]; changelogUrl: string; config?: NotesConfig }): string { |
| 60 | const cfg = input.config ?? {}; |
| 61 | const pulls = input.pulls.filter((p) => !hasAny(p.labels, cfg.exclude?.labels) && !byAuthor(p.author, cfg.exclude?.authors)); |
| 62 | const parts: string[] = []; |
| 63 | if (pulls.length) { |
| 64 | const sections: string[] = []; |
| 65 | if (cfg.categories?.length) { |
| 66 | const placed = new Set<number>(); |
| 67 | for (const cat of cfg.categories) { |
| 68 | const items = pulls.filter((p) => !placed.has(p.number) && hasAny(p.labels, cat.labels) && !hasAny(p.labels, cat.exclude?.labels) && !byAuthor(p.author, cat.exclude?.authors)); |
| 69 | for (const p of items) placed.add(p.number); |
| 70 | if (items.length) sections.push(`### ${cat.title}\n${items.map(line).join("\n")}`); |
| 71 | } |
| 72 | const other = pulls.filter((p) => !placed.has(p.number)); |
| 73 | if (other.length) sections.push(`### Other Changes\n${other.map(line).join("\n")}`); |
| 74 | } else sections.push(pulls.map(line).join("\n")); |
| 75 | parts.push(`## What's Changed\n${sections.join("\n\n")}`); |
| 76 | } |
| 77 | if (input.newContributors.length) { |
| 78 | parts.push(`## New Contributors\n${input.newContributors.map((p) => `* @${p.author} made their first contribution in ${p.url}`).join("\n")}`); |
| 79 | } |
| 80 | parts.push(`**Full Changelog**: ${input.changelogUrl}`); |
| 81 | return parts.join("\n\n"); |
| 82 | } |