Skip to content
Source
Sign inSign up
Open History to see the last commit

generate-release-notes.ts

393 lines15.7 KB

1#!/usr/bin/env node
2​
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Config from "effect/Config";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import { Command, Flag } from "effect/cli";
13import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15​
16// Release notes cover web, desktop, and server. Mobile ships its own store notes.
17const MOBILE_SCOPES = new Set(["mobile", "android", "ios"]);
18const COMMIT_SCOPES = /^[a-z]+\(([^)]*)\)!?:/i;
19const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/;
20const PULL_REQUEST_BODY_LIMIT = 300;
21const SUMMARY_MODEL = "gpt-6.1-terra";
22​
23const SUMMARY_INSTRUCTIONS = `Write up to 7 bullet points for the T3 Code update popover. Cover web, desktop, and server. Mobile has its own release notes.
24​
25Give a feature a bullet only when it lets users do something they could not do before, removes a real limitation they hit in normal use, or cuts meaningful steps or wait time from a common task. Judge the outcome, regardless of the commit prefix. A new setting, a large diff, or support for one more edge case is not enough.
26​
27Lead with new capabilities. Give a bug fix its own bullet only when the source shows lost work or a core workflow that users could not complete in normal use. Leave isolated failures, cosmetic polish, and routine fixes in the count. Skip internal work, docs, tests, and dependency bumps. Group commits only when they deliver the same user outcome. Seven is a ceiling. Stop when the qualifying changes run out, even if that leaves zero bullets.
28​
29Each bullet has a short feature name and one sentence of at most 20 words. Say what the user can do or what now works. Use product names people see in the app and leave out protocol or implementation details. Describe the shipped behavior at the end of the range. Mark user-accessible Labs or beta features "(beta)". Skip dev-only features. No title, headings, introduction, example prompts, hype, PR numbers, or em dashes.
30​
31Return JSON with a "highlights" array. For each highlight, give "label" (plain text, no final period), "detail" (one sentence), and "commits" (the bracketed input numbers of all commits it covers). Use the subjects and PR descriptions as source material. The release script adds the formatting, count of remaining fixes and performance improvements, and contributor credit.`;
32​
33class EmptyReleaseRangeError extends Schema.TaggedError<EmptyReleaseRangeError>()(
34 "EmptyReleaseRangeError",
35 { range: Schema.String },
36) {}
37​
38interface ReleaseCommit {
39 readonly subject: string;
40 readonly authorName: string;
42​
43const PullRequest = Schema.Struct({
44 body: Schema.String,
45 additions: Schema.Number,
46 deletions: Schema.Number,
47 changedFiles: Schema.Number,
48 author: Schema.NullOr(
49 Schema.Struct({
50 __typename: Schema.String,
51 login: Schema.String,
52 name: Schema.optional(Schema.NullOr(Schema.String)),
53 }),
54 ),
55});
56export type PullRequest = typeof PullRequest.Type;
57​
58const PullRequestQueryResponse = Schema.Struct({
59 data: Schema.Struct({
60 repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)),
61 }),
62});
63​
64const GeneratedNotes = Schema.Struct({ body: Schema.String });
65​
66const SummaryResponse = Schema.Struct({
67 status: Schema.String,
68 output: Schema.Array(
69 Schema.Struct({
70 type: Schema.String,
71 content: Schema.optional(
72 Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })),
73 ),
74 }),
75 ),
76 usage: Schema.optional(
77 Schema.Struct({ input_tokens: Schema.Number, output_tokens: Schema.Number }),
78 ),
79});
80​
81const ReleaseHighlights = Schema.Struct({
82 highlights: Schema.Array(
83 Schema.Struct({
84 label: Schema.NonEmptyString,
85 detail: Schema.NonEmptyString,
86 commits: Schema.NonEmptyArray(Schema.Int),
87 }),
88 ),
89});
90const decodeHighlights = Schema.decodeUnknownOption(Schema.fromJsonString(ReleaseHighlights));
91​
92export function formatReleaseSummary(
93 commits: ReadonlyArray<ReleaseCommit>,
94 selected: (typeof ReleaseHighlights.Type)["highlights"],
95): Option.Option<string> {
96 const highlights = selected.slice(0, 7);
97 const covered = new Set(highlights.flatMap((highlight) => highlight.commits));
98 for (const id of covered) {
99 const commit = commits[id - 1];
100 if (!commit || !isReleaseNoteCommit(commit)) return Option.none();
101 }
102 const bullets = highlights.map(
103 ({ label, detail }) => `- **${label.replace(/\.$/, "")}.** ${detail}`,
104 );
105 const remaining = commits.flatMap(({ subject }, index) => {
106 if (covered.has(index + 1)) return [];
107 return /^(fix|perf)(?:\([^)]*\))?!?:/i.exec(subject)?.[1]?.toLowerCase() ?? [];
108 });
109 if (remaining.length === 0) return Option.some(bullets.join("\n"));
110​
111 const count = remaining.length;
112 let kind = "bug fixes and performance improvements";
113 if (!remaining.includes("perf")) kind = count === 1 ? "bug fix" : "bug fixes";
114 if (!remaining.includes("fix"))
115 kind = count === 1 ? "performance improvement" : "performance improvements";
116 // Older popovers skip headings. A footer paragraph would hide the bullets on those clients.
117 return Option.some(
118 bullets.length
119 ? `${bullets.join("\n")}\n\n### Plus ${count} more ${kind}.`
120 : `**${count} ${kind}.**`,
121 );
123​
124const pullRequestNumber = (subject: string) => {
125 const match = PULL_REQUEST_NUMBER.exec(subject);
126 return match ? Number(match[1]) : undefined;
127};
128​
129/** Drops a commit only when every scope is mobile, so `fix(web,mobile)` stays. */
130export function isReleaseNoteCommit(commit: ReleaseCommit): boolean {
131 const scopes = COMMIT_SCOPES.exec(commit.subject)?.[1]?.split(",");
132 return !scopes?.every((scope) => MOBILE_SCOPES.has(scope.trim().toLowerCase()));
134​
135/** Keeps the prose of a PR body: no images, tables, headings, HTML, or verification sections. */
136function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string {
137 const [prose = ""] = body.split(/\n#+\s*(?:Verification|Council|Test|Testing|Screenshots)\b/i);
138 const text = prose
139 .split("\n")
140 .filter((line) => line.trim() !== "" && !/^\s*[!|#<]/.test(line))
141 .join(" ")
142 .replace(/\s+/g, " ")
143 .trim();
144 return text.length > limit ? `${text.slice(0, limit)}...` : text;
146​
147function buildSummaryInput(
148 commits: ReadonlyArray<ReleaseCommit>,
149 pullRequests: ReadonlyMap<number, PullRequest>,
150): string {
151 return commits
152 .flatMap((commit, index) => {
153 if (!isReleaseNoteCommit(commit)) return [];
154 const { subject } = commit;
155 const source = `[${index + 1}] ${subject}`;
156 const number = pullRequestNumber(subject);
157 const pullRequest = number === undefined ? undefined : pullRequests.get(number);
158 if (!pullRequest) return [source];
159 const line = `${source} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`;
160 const body = trimPullRequestBody(pullRequest.body);
161 return body ? [line, ` > ${body}`] : [line];
162 })
163 .join("\n");
165​
166/** PR authors without bots, plus authors of direct pushes who have no PR in the range. */
167export function countContributors(
168 commits: ReadonlyArray<ReleaseCommit>,
169 pullRequests: ReadonlyMap<number, PullRequest>,
170): number {
171 const people = new Set<string>();
172 // A PR's squash commit carries its author's git name, which may differ from the profile name.
173 const prAuthorNames = new Set<string>();
174 const directAuthors: string[] = [];
175 for (const commit of commits) {
176 const number = pullRequestNumber(commit.subject);
177 const author = number === undefined ? undefined : pullRequests.get(number)?.author;
178 if (!author) {
179 directAuthors.push(commit.authorName);
180 continue;
181 }
182 if (author.__typename === "Bot" || author.login.endsWith("[bot]")) continue;
183 people.add(author.login);
184 prAuthorNames.add(commit.authorName);
185 if (author.name) prAuthorNames.add(author.name);
186 }
187 for (const name of directAuthors) {
188 if (!name.endsWith("[bot]") && !prAuthorNames.has(name)) people.add(name);
189 }
190 return people.size;
192​
193/** No date here: the app shows how long ago a release shipped, and GitHub shows the date. */
194function buildReleaseHeader(version: string, changes: number, contributors: number): string {
195 const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
196 return `**v${version.replace(/^v/, "")}** · ${plural(changes, "change")} from ${plural(contributors, "contributor")}`;
198​
199/** The summary leads; GitHub's full list folds under it. Clients cut the body at `<details>`. */
200function assembleReleaseNotes(header: string, summary: string, fullChangelog: string): string {
201 const folded = `<details>\n<summary>Full changelog</summary>\n\n${fullChangelog.trim()}\n\n</details>\n`;
202 return [header, summary, folded].filter(Boolean).join("\n\n");
204​
205const run = Effect.fn("generateReleaseNotes.run")(function* (
206 command: string,
207 args: ReadonlyArray<string>,
208) {
209 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
210 return yield* spawner.string(ChildProcess.make(command, args));
211});
212​
213const decodeJson = <S extends Schema.Top>(schema: S) =>
214 Schema.decodeUnknownEffect(Schema.fromJsonString(schema));
215​
216const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* (
217 previousTag: string,
218 ref: string,
219) {
220 const log = yield* run("git", [
221 "log",
222 "--reverse",
223 "--format=%s%x1f%an",
224 `${previousTag}..${ref}`,
225 ]);
226 return log
227 .split("\n")
228 .filter((line) => line !== "")
229 .map((line): ReleaseCommit => {
230 const [subject = "", authorName = ""] = line.split("\x1f");
231 return { subject, authorName };
232 });
233});
234​
235const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* (
236 repository: string,
237 numbers: ReadonlyArray<number>,
238) {
239 const [owner, name] = repository.split("/");
240 const pullRequests = new Map<number, PullRequest>();
241 for (let start = 0; start < numbers.length; start += 100) {
242 const fields = numbers
243 .slice(start, start + 100)
244 .map(
245 (number) =>
246 `pr${number}: pullRequest(number: ${number}) { body additions deletions changedFiles author { __typename login ... on User { name } } }`,
247 )
248 .join("\n");
249 const query = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`;
250 const response = yield* decodeJson(PullRequestQueryResponse)(
251 yield* run("gh", [
252 "api",
253 "graphql",
254 "-f",
255 `query=${query}`,
256 "-f",
257 `owner=${owner}`,
258 "-f",
259 `name=${name}`,
260 ]),
261 );
262 for (const [alias, pullRequest] of Object.entries(response.data.repository)) {
263 if (pullRequest) pullRequests.set(Number(alias.slice(2)), pullRequest);
264 }
265 }
266 return pullRequests;
267});
268​
269const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(function* (options: {
270 readonly repository: string;
271 readonly tag: string;
272 readonly ref: string;
273 readonly previousTag: string;
274}) {
275 const response = yield* run("gh", [
276 "api",
277 `repos/${options.repository}/releases/generate-notes`,
278 "-f",
279 `tag_name=${options.tag}`,
280 "-f",
281 `target_commitish=${options.ref}`,
282 "-f",
283 `previous_tag_name=${options.previousTag}`,
284 ]);
285 return (yield* decodeJson(GeneratedNotes)(response)).body;
286});
287​
288export function readReleaseHighlights(response: typeof SummaryResponse.Type) {
289 // An incomplete response can carry partial text; publishing it would cut the notes short.
290 if (response.status !== "completed") return Option.none();
291 const text = response.output
292 .flatMap((item) => item.content ?? [])
293 .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : []))
294 .join("")
295 .trim();
296 return Option.map(decodeHighlights(text), ({ highlights }) => highlights);
298​
299const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) {
300 const apiKey = yield* Config.Redacted("OPENAI_API_KEY");
301 const httpClient = yield* HttpClient.HttpClient;
302 const response = yield* HttpClientRequest.post("https://api.openai.com/v1/responses").pipe(
303 HttpClientRequest.bearerToken(apiKey),
304 HttpClientRequest.bodyJson({
305 model: SUMMARY_MODEL,
306 reasoning: { effort: "low" },
307 instructions: SUMMARY_INSTRUCTIONS,
308 text: { format: { type: "json_object" } },
309 input: `Choose highlights as JSON from these commits:\n${input}`,
310 }),
311 Effect.flatMap(httpClient.execute),
312 Effect.flatMap(HttpClientResponse.filterStatusOk),
313 Effect.flatMap((response) => response.json),
314 Effect.flatMap(Schema.decodeUnknownEffect(SummaryResponse)),
315 Effect.timeout("3 minutes"),
316 );
317 yield* Effect.logInfo("release summary generated").pipe(
318 Effect.annotateLogs({
319 model: SUMMARY_MODEL,
320 inputTokens: response.usage?.input_tokens,
321 outputTokens: response.usage?.output_tokens,
322 }),
323 );
324 return readReleaseHighlights(response);
325});
326​
327const command = Command.make(
328 "generate-release-notes",
329 {
330 repository: Flag.String("repository").pipe(Flag.withDescription("GitHub owner/name.")),
331 tag: Flag.String("tag").pipe(Flag.withDescription("Tag being released.")),
332 ref: Flag.String("ref").pipe(Flag.withDescription("Commit being released.")),
333 previousTag: Flag.String("previous-tag").pipe(Flag.withDescription("Previous release tag.")),
334 version: Flag.String("version").pipe(Flag.withDescription("Version shown in the header.")),
335 output: Flag.String("output").pipe(Flag.withDescription("File to write the notes to.")),
336 },
337 ({ repository, tag, ref, previousTag, version, output }) =>
338 Effect.gen(function* () {
339 const allCommits = yield* readCommits(previousTag, ref);
340 // The spawner ignores exit codes, so a failed git log shows up as an empty range.
341 if (allCommits.length === 0) {
342 return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` });
343 }
344 const numbers = [
345 ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])),
346 ];
347 const [pullRequests, fullChangelog] = yield* Effect.all(
348 [
349 readPullRequests(repository, numbers),
350 readFullChangelog({ repository, tag, ref, previousTag }),
351 ],
352 { concurrency: "unbounded" },
353 );
354 const header = buildReleaseHeader(
355 version,
356 allCommits.length,
357 countContributors(allCommits, pullRequests),
358 );
359 const input = buildSummaryInput(allCommits, pullRequests);
360 const selected = input === "" ? Option.some([]) : yield* summarize(input);
361 const summary = Option.flatMap(selected, (highlights) =>
362 formatReleaseSummary(allCommits, highlights),
363 );
364 if (Option.isNone(summary)) {
365 return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place");
366 }
367 const fs = yield* FileSystem.FileSystem;
368 // Publish uses the file when it exists, so a partial write must never land at `output`.
369 yield* fs.writeFileString(
370 `${output}.tmp`,
371 assembleReleaseNotes(header, summary.value, fullChangelog),
372 );
373 yield* fs.rename(`${output}.tmp`, output);
374 yield* Effect.logInfo("release notes written").pipe(
375 Effect.annotateLogs({ output, commits: allCommits.length }),
376 );
377 }),
378).pipe(
379 Command.withDescription("Write release notes with a model summary over GitHub's full list."),
380);
381​
382if (import.meta.main) {
383 Command.run(command, { version: "0.0.0" }).pipe(
384 Effect.provide(
385 Layer.mergeAll(
386 Logger.layer([Logger.consolePretty()]),
387 NodeServices.layer,
388 FetchHttpClient.layer,
389 ),
390 ),
391 NodeRuntime.runMain,
392 );