-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathrelease-notes.ts
More file actions
722 lines (639 loc) · 22.8 KB
/
release-notes.ts
File metadata and controls
722 lines (639 loc) · 22.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
/**
* Release Notes Parser & Aggregation
*
* Extracts user-facing changelog entries from GitHub Release bodies (stable)
* or conventional commit messages (nightly). Uses `marked.lexer()` for
* AST-based section extraction and produces structured data that can be
* re-serialized as filtered markdown for rendering via `renderMarkdown()`.
*
* Only three categories are kept — everything else is filtered out:
* - **New Features** (✨) — from `### New Features` sections or `feat:` commits
* - **Bug Fixes** (🐛) — from `### Bug Fixes` sections or `fix:` commits
* - **Performance** (⚡) — from `### Performance` sections or `perf:` commits
*/
import { marked, type Token, type Tokens } from "marked";
import {
compareVersions,
GITHUB_RELEASES_URL,
getGitHubHeaders,
} from "./binary.js";
import { customFetch } from "./custom-ca.js";
import type { GitHubRelease } from "./delta-upgrade.js";
import { logger } from "./logger.js";
const log = logger.withTag("release-notes");
// ────────────────────────────── Constants ──────────────────────────────────
/**
* Strips emoji and symbols from heading text for category matching.
* Covers Dingbats (✨ U+2728), Misc Symbols (⚡ U+26A1), variation
* selectors, and Supplemental Pictographics (🐛, 🔧, 📚).
*/
// biome-ignore lint/suspicious/noMisleadingCharacterClass: intentional — stripping emoji ranges that include combining marks
const EMOJI_RE = /[\u{2000}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FFFF}]/gu;
/** Strips ` by @author in [#123](url)` or ` by @author in #123` suffixes */
const AUTHOR_SUFFIX_RE = /\s+by\s+@\S+\s+in\s+\[?#\d+\]?(?:\([^)]*\))?\s*$/;
/** Matches nightly version format: X.Y.Z-dev.<unix-seconds> */
const NIGHTLY_VERSION_RE = /^[\d.]+(?:-\w+)?-dev\.(\d+)$/;
/** Matches conventional commit format: type(scope): description */
const CONVENTIONAL_COMMIT_RE = /^(\w+)(?:\([^)]*\))?:\s*(.+)$/;
/** Strips leading `v` prefix from version tags */
const VERSION_PREFIX_RE = /^v/;
// ────────────────────────────────── Types ──────────────────────────────────
/** Categories of changes shown to users */
export type ChangeCategory = "features" | "fixes" | "performance";
/**
* A section from a release body grouped by category.
*
* Contains the raw markdown for this section — subheadings (scope groups
* like "Dashboard"), list items, and any other block content. Keeping
* markdown enables rendering via `renderMarkdown()`.
*/
export type ChangeSection = {
/** Category this section belongs to */
category: ChangeCategory;
/** Raw markdown source for this section */
markdown: string;
};
/**
* Aggregated changelog across a version range.
*
* Represents the flat delta between two versions — all user-facing changes
* merged by category regardless of which specific release introduced them.
*/
export type ChangelogSummary = {
/** Version before upgrade */
fromVersion: string;
/** Version after upgrade */
toVersion: string;
/** Sections grouped by category, flattened across all releases */
sections: ChangeSection[];
/** Total number of list items across all sections */
totalItems: number;
/** Whether items were truncated to fit terminal constraints */
truncated: boolean;
/** Original item count before truncation */
originalCount: number;
};
// ──────────────────────────── Section Extraction ───────────────────────────
/**
* Map of heading text patterns (lowercased, emoji-stripped) to categories.
*
* The release body format is machine-generated by Craft and uses `###`
* headers: `### New Features ✨`, `### Bug Fixes 🐛`, etc.
*/
const HEADING_TO_CATEGORY: ReadonlyMap<string, ChangeCategory> = new Map([
["new features", "features"],
["bug fixes", "fixes"],
["performance", "performance"],
]);
/** Category display order */
const CATEGORY_ORDER: readonly ChangeCategory[] = [
"features",
"fixes",
"performance",
];
/**
* Normalize a heading text for category lookup.
*
* Strips emoji and extra whitespace, lowercases the result.
*/
function normalizeHeading(text: string): string {
return text.replace(EMOJI_RE, "").trim().toLowerCase();
}
/**
* Extract user-facing sections from a GitHub Release body.
*
* Parses the body with `marked.lexer()` and walks the AST to find `###`
* headings that match known categories (features, fixes, performance).
* Collects all tokens between matched headings — including `####`
* subheadings (scope groups) and list tokens (entries).
*
* @param body - Markdown release notes body
* @returns Array of sections with category and markdown content
*/
export function extractSections(body: string): ChangeSection[] {
if (!body?.trim()) {
return [];
}
const tokens = marked.lexer(body);
const sections: ChangeSection[] = [];
let currentCategory: ChangeCategory | null = null;
let currentTokens: Token[] = [];
for (const token of tokens) {
// Only split on ### (depth 3) headers — the top-level sections
if (token.type === "heading" && (token as Tokens.Heading).depth === 3) {
// Flush previous section if it was a kept category
if (currentCategory && currentTokens.length > 0) {
sections.push({
category: currentCategory,
markdown: tokensToMarkdown(currentTokens),
});
}
// Check if this heading matches a kept category
const normalized = normalizeHeading((token as Tokens.Heading).text);
currentCategory = HEADING_TO_CATEGORY.get(normalized) ?? null;
currentTokens = [];
continue;
}
// Accumulate tokens under the current section
if (currentCategory) {
currentTokens.push(token);
}
}
// Flush final section
if (currentCategory && currentTokens.length > 0) {
sections.push({
category: currentCategory,
markdown: tokensToMarkdown(currentTokens),
});
}
return sections;
}
// ─────────────────────────── Token Serialization ───────────────────────────
/**
* Reconstruct markdown source from AST tokens.
*
* Uses the `raw` property of each token which `marked` preserves as the
* original markdown source text. This gives us lossless round-trip: the
* re-serialized markdown can be passed back through `renderMarkdown()` for
* ANSI-styled terminal output.
*/
function tokensToMarkdown(tokens: Token[]): string {
return tokens.map((t) => (t as { raw?: string }).raw ?? "").join("");
}
// ─────────────────────────── List Item Counting ────────────────────────────
/**
* Count top-level list items across all list tokens in an array.
*
* Counts only direct children of each list — not nested sublists.
* This matches the granularity of `truncateSectionMarkdown`, which
* tracks and slices by `list.items.length` (top-level items only).
*/
export function countListItems(tokens: Token[]): number {
let count = 0;
for (const token of tokens) {
if (token.type === "list") {
count += (token as Tokens.List).items.length;
}
}
return count;
}
/**
* Count list items in a markdown string by parsing it first.
*/
function countMarkdownListItems(md: string): number {
if (!md) {
return 0;
}
return countListItems(marked.lexer(md));
}
// ──────────────────────────── Author Stripping ─────────────────────────────
/**
* Strip author/PR attributions from a section's markdown content.
*
* Operates line-by-line: only strips from lines starting with `- ` (list items).
* Release notes include ` by @author in [#123](url)` suffixes that are noise
* in the upgrade output.
*/
function stripAttributions(md: string): string {
return md
.split("\n")
.map((line) => {
if (line.trimStart().startsWith("- ")) {
return line.replace(AUTHOR_SUFFIX_RE, "");
}
return line;
})
.join("\n");
}
// ──────────────────────────── Section Truncation ───────────────────────────
/**
* Truncate list items within a markdown section to a maximum count.
*
* Parses the markdown, walks list tokens, and removes excess items.
* Re-serializes to markdown afterward.
*
* @returns Truncated markdown and the number of items removed
*/
function truncateSectionMarkdown(
md: string,
maxItems: number
): { markdown: string; removed: number } {
const tokens = marked.lexer(md);
let remaining = maxItems;
let totalRemoved = 0;
const truncated = tokens.map((token) => {
if (token.type !== "list") {
return (token as { raw?: string }).raw ?? "";
}
if (remaining <= 0) {
totalRemoved += (token as Tokens.List).items.length;
return "";
}
const list = token as Tokens.List;
if (list.items.length <= remaining) {
remaining -= list.items.length;
return list.raw;
}
// Truncate this list — keep items using their raw markdown which
// preserves any nested content (sublists, inline formatting).
const kept = list.items.slice(0, remaining);
totalRemoved += list.items.length - remaining;
remaining = 0;
return kept.map((item) => item.raw).join("");
});
return {
markdown: truncated.join(""),
removed: totalRemoved,
};
}
/**
* Apply truncation across sections, distributing budget proportionally.
*
* Mutates the sections array in place, replacing markdown with truncated
* versions when the total item count exceeds `maxItems`.
*/
function applySectionTruncation(
sections: ChangeSection[],
maxItems: number,
originalCount: number
): { totalItems: number; truncated: boolean } {
let budget = maxItems;
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (!section) {
continue;
}
const sectionItems = countMarkdownListItems(section.markdown);
// Last section gets remaining budget; non-last sections use Math.floor
// to avoid rounding errors that could exhaust the budget prematurely.
// Cap by remaining budget to prevent over-allocation when maxItems is
// very small relative to the number of sections.
const sectionBudget =
i === sections.length - 1
? Math.max(0, budget)
: Math.min(
Math.max(0, budget),
Math.max(1, Math.floor((sectionItems / originalCount) * maxItems))
);
if (sectionItems > sectionBudget) {
const result = truncateSectionMarkdown(section.markdown, sectionBudget);
sections[i] = { ...section, markdown: result.markdown };
budget -= sectionBudget;
} else {
budget -= sectionItems;
}
}
const totalItems = sections.reduce(
(sum, s) => sum + countMarkdownListItems(s.markdown),
0
);
return { totalItems, truncated: totalItems < originalCount };
}
// ──────────────────────────── Changelog Building ───────────────────────────
/**
* Assemble a `ChangelogSummary` from pre-built sections.
*
* Shared by both the stable (GitHub Releases) and nightly (commit log)
* paths. Handles counting, optional truncation, and summary construction.
*
* @param sections - Sections grouped by category
* @param fromVersion - Current version
* @param toVersion - Target version
* @param maxItems - Maximum total list items across all sections
* @returns Changelog summary, or null if sections are empty
*/
function buildSummaryFromSections(
sections: ChangeSection[],
fromVersion: string,
toVersion: string,
maxItems?: number
): ChangelogSummary | null {
if (sections.length === 0) {
return null;
}
const originalCount = sections.reduce(
(sum, s) => sum + countMarkdownListItems(s.markdown),
0
);
let totalItems = originalCount;
let truncated = false;
if (maxItems !== undefined && originalCount > maxItems) {
const result = applySectionTruncation(sections, maxItems, originalCount);
totalItems = result.totalItems;
truncated = result.truncated;
}
return {
fromVersion,
toVersion,
sections,
totalItems,
truncated,
originalCount,
};
}
/**
* Merge extracted sections by category across multiple releases.
*
* Concatenates markdown from the same category and strips author attributions.
*/
function mergeSectionsByCategory(releases: GitHubRelease[]): ChangeSection[] {
const sectionsByCategory = new Map<ChangeCategory, string[]>();
for (const release of releases) {
if (!release.body) {
continue;
}
const sections = extractSections(release.body);
for (const section of sections) {
const stripped = stripAttributions(section.markdown);
const existing = sectionsByCategory.get(section.category) ?? [];
existing.push(stripped);
sectionsByCategory.set(section.category, existing);
}
}
// Build merged sections in display order
const merged: ChangeSection[] = [];
for (const category of CATEGORY_ORDER) {
const markdowns = sectionsByCategory.get(category);
if (markdowns && markdowns.length > 0) {
merged.push({ category, markdown: markdowns.join("\n") });
}
}
return merged;
}
/**
* Build a changelog summary from a list of GitHub releases.
*
* Filters releases within the version range (exclusive `fromVersion`,
* inclusive `toVersion`), extracts and merges sections by category, and
* optionally truncates to fit terminal constraints.
*
* @param releases - GitHub releases (newest first)
* @param fromVersion - Current version (exclusive lower bound)
* @param toVersion - Target version (inclusive upper bound)
* @param maxItems - Maximum total list items across all sections
* @returns Changelog summary, or null if no relevant changes found
*/
export function buildChangelogSummary(
releases: GitHubRelease[],
fromVersion: string,
toVersion: string,
maxItems?: number
): ChangelogSummary | null {
const inRange = releases.filter((r) => {
const version = r.tag_name.replace(VERSION_PREFIX_RE, "");
return (
compareVersions(version, fromVersion) === 1 &&
compareVersions(version, toVersion) <= 0
);
});
if (inRange.length === 0) {
return null;
}
const mergedSections = mergeSectionsByCategory(inRange);
return buildSummaryFromSections(
mergedSections,
fromVersion,
toVersion,
maxItems
);
}
// ────────────────────────── Nightly Commit Parsing ─────────────────────────
/** Conventional commit prefix → category mapping */
const COMMIT_PREFIX_TO_CATEGORY: ReadonlyMap<string, ChangeCategory> = new Map([
["feat", "features"],
["fix", "fixes"],
["perf", "performance"],
]);
/**
* Extract the unix timestamp from a nightly version string.
*
* Nightly versions use the format `X.Y.Z-dev.<unix-seconds>`.
*
* @returns Unix timestamp in seconds, or null if not a nightly version
*/
export function extractNightlyTimestamp(version: string): number | null {
const match = NIGHTLY_VERSION_RE.exec(version);
if (!match?.[1]) {
return null;
}
const ts = Number.parseInt(match[1], 10);
return Number.isNaN(ts) ? null : ts;
}
/** GitHub Commits API response entry (subset) */
type GitHubCommit = {
commit: {
message: string;
};
};
/**
* Parse conventional commit messages into changelog sections.
*
* Only `feat:`, `fix:`, and `perf:` prefixes pass through.
* Commits with `#skip-changelog` in the body are filtered out.
* Only the first line of multi-line messages is used.
*
* @param commits - Array of GitHub commit objects
* @returns Sections grouped by category
*/
export function parseCommitMessages(commits: GitHubCommit[]): ChangeSection[] {
const items = new Map<ChangeCategory, string[]>();
for (const { commit } of commits) {
const message = commit.message;
if (message.includes("#skip-changelog")) {
continue;
}
const firstLine = message.split("\n")[0]?.trim();
if (!firstLine) {
continue;
}
const ccMatch = CONVENTIONAL_COMMIT_RE.exec(firstLine);
if (!(ccMatch?.[1] && ccMatch[2])) {
continue;
}
const category = COMMIT_PREFIX_TO_CATEGORY.get(ccMatch[1]);
if (!category) {
continue;
}
const description = ccMatch[2].trim();
const existing = items.get(category) ?? [];
existing.push(description);
items.set(category, existing);
}
const sections: ChangeSection[] = [];
for (const category of CATEGORY_ORDER) {
const descriptions = items.get(category);
if (descriptions && descriptions.length > 0) {
const markdown = `${descriptions.map((d) => `- ${d}`).join("\n")}\n`;
sections.push({ category, markdown });
}
}
return sections;
}
// ────────────────────────────── Fetch Functions ────────────────────────────
/**
* Max releases to fetch for changelog purposes.
*
* Higher than the delta-upgrade cap (12) to cover larger version jumps.
* GitHub API max per_page is 100; 30 covers ~6+ months of weekly releases.
*/
const CHANGELOG_MAX_RELEASES = 30;
/**
* Fetch recent releases from GitHub for changelog building.
*
* Uses a higher `per_page` than `fetchRecentReleases()` in delta-upgrade
* (which is capped at 12 for patch chain resolution) to cover larger
* version jumps without silent truncation.
*
* @returns Array of releases (newest first), or empty array on failure
*/
async function fetchReleasesForChangelog(): Promise<GitHubRelease[]> {
let response: Response;
try {
response = await customFetch(
`${GITHUB_RELEASES_URL}?per_page=${CHANGELOG_MAX_RELEASES}`,
{ headers: getGitHubHeaders() }
);
} catch (error) {
log.debug("Failed to fetch releases for changelog", error);
return [];
}
if (!response.ok) {
return [];
}
return (await response.json()) as GitHubRelease[];
}
/**
* Fetch changelog for a stable release upgrade.
*
* Accepts optional pre-fetched releases to avoid a duplicate API call
* when the delta-upgrade flow has already fetched recent releases. Falls
* back to fetching with a higher per_page than the delta-upgrade path
* to cover larger version jumps.
*
* @param fromVersion - Current version
* @param toVersion - Target version
* @param maxItems - Maximum list items to include
* @param prefetchedReleases - Optional releases already fetched by the caller
* @returns Changelog summary, or null on failure
*/
async function fetchStableChangelog(
fromVersion: string,
toVersion: string,
maxItems?: number,
prefetchedReleases?: GitHubRelease[]
): Promise<ChangelogSummary | null> {
const releases = prefetchedReleases ?? (await fetchReleasesForChangelog());
if (releases.length === 0) {
return null;
}
return buildChangelogSummary(releases, fromVersion, toVersion, maxItems);
}
/**
* Build a changelog summary from nightly commit data.
*
* Separated from the fetch logic for testability.
*/
function buildNightlyChangelogSummary(
commits: GitHubCommit[],
fromVersion: string,
toVersion: string,
maxItems?: number
): ChangelogSummary | null {
const sections = parseCommitMessages(commits);
return buildSummaryFromSections(sections, fromVersion, toVersion, maxItems);
}
/**
* Fetch changelog for a nightly upgrade using the GitHub Commits API.
*
* Nightly versions encode the commit timestamp as unix seconds in the
* pre-release identifier (`X.Y.Z-dev.<unix-seconds>`). This enables
* timestamp-based commit listing without git tags.
*
* @param fromVersion - Current nightly version
* @param toVersion - Target nightly version
* @param maxItems - Maximum list items to include
* @returns Changelog summary, or null on failure or invalid versions
*/
async function fetchNightlyChangelog(
fromVersion: string,
toVersion: string,
maxItems?: number
): Promise<ChangelogSummary | null> {
const fromTs = extractNightlyTimestamp(fromVersion);
const toTs = extractNightlyTimestamp(toVersion);
if (fromTs === null || toTs === null) {
log.debug("Cannot extract timestamps from nightly versions");
return null;
}
// GitHub's `since` is inclusive — offset by +1s to exclude the current
// version's commit (the user already has it).
// GitHub's `until` is exclusive — offset by +1s to include the target
// version's commit (the commit the user is upgrading to).
const sinceDate = new Date((fromTs + 1) * 1000).toISOString();
const untilDate = new Date((toTs + 1) * 1000).toISOString();
const url = `https://api.github.com/repos/getsentry/cli/commits?sha=main&since=${sinceDate}&until=${untilDate}&per_page=100`;
let response: Response;
try {
response = await customFetch(url, { headers: getGitHubHeaders() });
} catch {
log.debug("Failed to fetch nightly commits");
return null;
}
if (!response.ok) {
log.debug(`Nightly commits API returned ${response.status}`);
return null;
}
const commits = (await response.json()) as GitHubCommit[];
if (commits.length === 0) {
return null;
}
return buildNightlyChangelogSummary(
commits,
fromVersion,
toVersion,
maxItems
);
}
/** Options for {@link fetchChangelog} */
export type FetchChangelogOptions = {
/** Release channel */
channel: "stable" | "nightly";
/** Current version */
fromVersion: string;
/** Target version */
toVersion: string;
/** Maximum list items to include */
maxItems?: number;
/** Pre-fetched releases to avoid redundant API call (stable channel only) */
prefetchedReleases?: GitHubRelease[];
};
/**
* Fetch changelog for an upgrade, dispatching to stable or nightly.
*
* Best-effort: returns null on any failure. Designed to be called in
* parallel with the binary download so it adds zero latency.
*
* For stable upgrades, accepts optional pre-fetched releases to avoid
* a redundant GitHub Releases API call when the delta-upgrade flow has
* already fetched the same data.
*/
export async function fetchChangelog(
opts: FetchChangelogOptions
): Promise<ChangelogSummary | null> {
try {
const { channel, fromVersion, toVersion, maxItems, prefetchedReleases } =
opts;
if (channel === "nightly") {
return await fetchNightlyChangelog(fromVersion, toVersion, maxItems);
}
return await fetchStableChangelog(
fromVersion,
toVersion,
maxItems,
prefetchedReleases
);
} catch (error) {
log.debug("Changelog fetch failed:", error);
return null;
}
}