Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

82 lines · 3.7 KBRaw History
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 */
7import { parse } from "yaml";
8
9export interface NotesPull {
10 number: number;
11 title: string;
12 author: string;
13 labels: string[];
14 url: string;
15}
16
17export interface NotesCategory {
18 title: string;
19 labels: string[];
20 exclude?: { labels?: string[]; authors?: string[] };
21}
22
23export interface NotesConfig {
24 exclude?: { labels?: string[]; authors?: string[] };
25 categories?: NotesCategory[];
26}
27
28const 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. */
31export 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
54const hasAny = (labels: string[], wanted: string[] | undefined) => !!wanted?.length && (wanted.includes("*") || labels.some((l) => wanted.includes(l)));
55const byAuthor = (author: string, authors: string[] | undefined) => !!authors?.some((a) => a === "*" || a.toLowerCase() === author.toLowerCase());
56
57const line = (p: NotesPull) => `* ${p.title} by @${p.author} in ${p.url}`;
58
59export 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}