@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +1104 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,171 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ import { collectReleaseNotesInput, renderReleaseNotes } from './notes.js';
6
+ import type { ReleaseNotesCollectOptions } from './notes.js';
7
+
8
+ const RELEASE_BRANCH = 'changeset-release/main';
9
+
10
+ type CliOptions = Omit<ReleaseNotesCollectOptions, 'mode'>;
11
+
12
+ const runText = async (cmd: readonly string[]): Promise<string> => {
13
+ const proc = Bun.spawn([...cmd], { stderr: 'pipe', stdout: 'pipe' });
14
+ const [stdout, stderr, exitCode] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ proc.exited,
18
+ ]);
19
+ if (exitCode !== 0) {
20
+ throw new Error(
21
+ `Command failed (${exitCode}): ${cmd.join(' ')}\n${stderr.trim()}`
22
+ );
23
+ }
24
+ return stdout.trim();
25
+ };
26
+
27
+ const readRepository = async (): Promise<string> =>
28
+ process.env['GITHUB_REPOSITORY'] ??
29
+ (await runText([
30
+ 'gh',
31
+ 'repo',
32
+ 'view',
33
+ '--json',
34
+ 'nameWithOwner',
35
+ '--jq',
36
+ '.nameWithOwner',
37
+ ]));
38
+
39
+ const findReleasePullRequestNumber = async (
40
+ repo: string
41
+ ): Promise<string | undefined> => {
42
+ const output = await runText([
43
+ 'gh',
44
+ 'pr',
45
+ 'list',
46
+ '--repo',
47
+ repo,
48
+ '--head',
49
+ RELEASE_BRANCH,
50
+ '--base',
51
+ 'main',
52
+ '--state',
53
+ 'open',
54
+ '--json',
55
+ 'number',
56
+ '--jq',
57
+ '.[0].number // empty',
58
+ ]);
59
+ return output || undefined;
60
+ };
61
+
62
+ const parseArgs = (args: readonly string[]): CliOptions => {
63
+ let distTag: string | undefined;
64
+ let repo: string | undefined;
65
+ let repoRoot = resolve(process.cwd());
66
+ let version: string | undefined;
67
+
68
+ for (let index = 0; index < args.length; index += 1) {
69
+ const arg = args[index];
70
+ if (arg === '--dist-tag') {
71
+ index += 1;
72
+ distTag = args[index];
73
+ continue;
74
+ }
75
+ if (arg === '--repo') {
76
+ index += 1;
77
+ repo = args[index];
78
+ continue;
79
+ }
80
+ if (arg === '--repo-root') {
81
+ index += 1;
82
+ repoRoot = resolve(args[index] ?? repoRoot);
83
+ continue;
84
+ }
85
+ if (arg === '--version') {
86
+ index += 1;
87
+ version = args[index];
88
+ continue;
89
+ }
90
+ throw new Error(`Unknown release-notes option: ${arg}`);
91
+ }
92
+
93
+ return {
94
+ ...(distTag === undefined ? {} : { distTag }),
95
+ ...(repo === undefined ? {} : { repo }),
96
+ repoRoot,
97
+ ...(version === undefined ? {} : { version }),
98
+ };
99
+ };
100
+
101
+ const commandGithubRelease = async (args: readonly string[]): Promise<void> => {
102
+ const input = await collectReleaseNotesInput({
103
+ ...parseArgs(args),
104
+ mode: 'github-release',
105
+ });
106
+ process.stdout.write(renderReleaseNotes(input));
107
+ };
108
+
109
+ const commandReleasePr = async (args: readonly string[]): Promise<void> => {
110
+ const input = await collectReleaseNotesInput({
111
+ ...parseArgs(args),
112
+ mode: 'release-pr',
113
+ });
114
+ process.stdout.write(renderReleaseNotes(input));
115
+ };
116
+
117
+ const commandUpdateReleasePr = async (
118
+ args: readonly string[]
119
+ ): Promise<void> => {
120
+ const options = parseArgs(args);
121
+ const repo = options.repo ?? (await readRepository());
122
+ const number = await findReleasePullRequestNumber(repo);
123
+ if (!number) {
124
+ throw new Error(`No open ${RELEASE_BRANCH} pull request found`);
125
+ }
126
+ const input = await collectReleaseNotesInput({
127
+ ...options,
128
+ mode: 'release-pr',
129
+ repo,
130
+ });
131
+ const dir = await mkdtemp(join(tmpdir(), 'trails-release-notes-'));
132
+ const bodyPath = join(dir, 'body.md');
133
+ await writeFile(bodyPath, renderReleaseNotes(input));
134
+ await runText([
135
+ 'gh',
136
+ 'pr',
137
+ 'edit',
138
+ number,
139
+ '--repo',
140
+ repo,
141
+ '--body-file',
142
+ bodyPath,
143
+ ]);
144
+ console.error(`trails: updated release PR #${number} body`);
145
+ };
146
+
147
+ export const runReleaseNotesCli = async (
148
+ args: readonly string[] = process.argv.slice(2)
149
+ ): Promise<number> => {
150
+ try {
151
+ const [command, ...rest] = args;
152
+ if (command === 'github-release') {
153
+ await commandGithubRelease(rest);
154
+ return 0;
155
+ }
156
+ if (command === 'release-pr') {
157
+ await commandReleasePr(rest);
158
+ return 0;
159
+ }
160
+ if (command === 'update-release-pr') {
161
+ await commandUpdateReleasePr(rest);
162
+ return 0;
163
+ }
164
+ throw new Error(
165
+ 'Usage: bun scripts/release-notes.ts <github-release|release-pr|update-release-pr> [--version <version>] [--dist-tag <tag>] [--repo <owner/name>] [--repo-root <path>]'
166
+ );
167
+ } catch (error) {
168
+ console.error(error instanceof Error ? error.message : String(error));
169
+ return 1;
170
+ }
171
+ };
@@ -0,0 +1,390 @@
1
+ /* oxlint-disable max-lines-per-function, max-statements -- release-note parsing keeps source extraction explicit. */
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ const DEFAULT_REPO = 'outfitter-dev/trails';
6
+
7
+ interface PackageJson {
8
+ readonly name?: string;
9
+ readonly private?: boolean;
10
+ readonly version?: string;
11
+ }
12
+
13
+ interface ReleaseNotesWorkspace {
14
+ readonly isPrivate: boolean;
15
+ readonly name: string;
16
+ readonly path: string;
17
+ readonly version: string;
18
+ }
19
+
20
+ export interface ReleaseNotesChange {
21
+ readonly commit?: string | undefined;
22
+ readonly packages: readonly string[];
23
+ readonly summary: string;
24
+ readonly url?: string | undefined;
25
+ }
26
+
27
+ export interface ReleaseNotesPackageVersion {
28
+ readonly name: string;
29
+ readonly version: string;
30
+ }
31
+
32
+ export interface ReleaseNotesInput {
33
+ readonly changes: readonly ReleaseNotesChange[];
34
+ readonly distTag: string;
35
+ readonly mode: 'github-release' | 'release-pr';
36
+ readonly packageVersions: readonly ReleaseNotesPackageVersion[];
37
+ readonly repo?: string | undefined;
38
+ readonly version: string;
39
+ }
40
+
41
+ interface ChangelogEntryInput {
42
+ readonly changelog: string;
43
+ readonly packageName: string;
44
+ readonly version: string;
45
+ }
46
+
47
+ export interface ReleaseNotesParsedChange {
48
+ readonly commit?: string | undefined;
49
+ readonly packageName: string;
50
+ readonly summary: string;
51
+ readonly url?: string | undefined;
52
+ }
53
+
54
+ export interface ReleaseNotesCollectOptions {
55
+ readonly distTag?: string | undefined;
56
+ readonly mode: ReleaseNotesInput['mode'];
57
+ readonly repo?: string | undefined;
58
+ readonly repoRoot: string;
59
+ readonly version?: string | undefined;
60
+ }
61
+
62
+ interface CurrentParsedChange {
63
+ readonly commit?: string | undefined;
64
+ readonly packageName: string;
65
+ readonly summaryLines: string[];
66
+ readonly url?: string | undefined;
67
+ }
68
+
69
+ const readJson = async <T>(path: string): Promise<T> =>
70
+ (await Bun.file(path).json()) as T;
71
+
72
+ const discoverWorkspaceDirs = async (
73
+ repoRoot: string,
74
+ patterns: readonly string[]
75
+ ): Promise<string[]> => {
76
+ const dirs: string[] = [];
77
+
78
+ for (const pattern of patterns) {
79
+ if (pattern.endsWith('/*')) {
80
+ const parent = join(repoRoot, pattern.slice(0, -2));
81
+ const entries = await readdir(parent, { withFileTypes: true });
82
+ for (const entry of entries) {
83
+ if (!entry.isDirectory()) {
84
+ continue;
85
+ }
86
+ const dir = join(parent, entry.name);
87
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
88
+ dirs.push(dir);
89
+ }
90
+ }
91
+ continue;
92
+ }
93
+
94
+ const dir = join(repoRoot, pattern);
95
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
96
+ dirs.push(dir);
97
+ }
98
+ }
99
+
100
+ return dirs;
101
+ };
102
+
103
+ const discoverReleaseNotesWorkspaces = async (
104
+ repoRoot: string,
105
+ { includePrivate }: { readonly includePrivate: boolean }
106
+ ): Promise<ReleaseNotesWorkspace[]> => {
107
+ const root = await readJson<{ workspaces?: string[] }>(
108
+ join(repoRoot, 'package.json')
109
+ );
110
+ const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces ?? []);
111
+ const workspaces: ReleaseNotesWorkspace[] = [];
112
+
113
+ for (const dir of dirs) {
114
+ const pkg = await readJson<PackageJson>(join(dir, 'package.json'));
115
+ if (
116
+ typeof pkg.name !== 'string' ||
117
+ !pkg.name.startsWith('@ontrails/') ||
118
+ typeof pkg.version !== 'string' ||
119
+ (pkg.private === true && !includePrivate)
120
+ ) {
121
+ continue;
122
+ }
123
+ workspaces.push({
124
+ isPrivate: pkg.private === true,
125
+ name: pkg.name,
126
+ path: dir.slice(repoRoot.length + 1),
127
+ version: pkg.version,
128
+ });
129
+ }
130
+
131
+ return workspaces.toSorted((a, b) => a.name.localeCompare(b.name));
132
+ };
133
+
134
+ const readTrailsVersion = async (repoRoot: string): Promise<string> => {
135
+ const pkg = await readJson<PackageJson>(
136
+ join(repoRoot, 'apps', 'trails', 'package.json')
137
+ );
138
+ if (!pkg.version) {
139
+ throw new Error('Missing version in apps/trails/package.json');
140
+ }
141
+ return pkg.version;
142
+ };
143
+
144
+ const distTagForVersion = (version: string): string =>
145
+ version.includes('-')
146
+ ? (version.split('-')[1]?.split('.')[0] ?? 'beta')
147
+ : 'latest';
148
+
149
+ export const extractChangelogEntry = (
150
+ changelog: string,
151
+ version: string
152
+ ): string | undefined => {
153
+ const lines = changelog.split('\n');
154
+ const heading = `## ${version}`;
155
+ const start = lines.findIndex((line) => line.trim() === heading);
156
+ if (start === -1) {
157
+ return undefined;
158
+ }
159
+
160
+ let end = lines.length;
161
+ for (let index = start + 1; index < lines.length; index += 1) {
162
+ if (/^##\s+\S/u.test(lines[index] ?? '')) {
163
+ end = index;
164
+ break;
165
+ }
166
+ }
167
+
168
+ return lines
169
+ .slice(start + 1, end)
170
+ .join('\n')
171
+ .trim();
172
+ };
173
+
174
+ const normalizeSummary = (lines: readonly string[]): string =>
175
+ lines.join(' ').replaceAll(/\s+/gu, ' ').trim();
176
+
177
+ const parseChangeStart = (
178
+ line: string
179
+ ): Omit<ReleaseNotesParsedChange, 'packageName'> | undefined => {
180
+ if (
181
+ !line.startsWith('- ') ||
182
+ line.startsWith('- Updated dependencies') ||
183
+ line.startsWith('- @ontrails/')
184
+ ) {
185
+ return undefined;
186
+ }
187
+
188
+ const linked = line.match(/^- \[`?([0-9a-f]{7,40})`?\]\(([^)]+)\):\s*(.*)$/u);
189
+ if (linked) {
190
+ return {
191
+ commit: linked[1],
192
+ summary: linked[3] ?? '',
193
+ url: linked[2],
194
+ };
195
+ }
196
+
197
+ const raw = line.match(/^- ([0-9a-f]{7,40}):\s*(.*)$/u);
198
+ if (raw) {
199
+ return {
200
+ commit: raw[1],
201
+ summary: raw[2] ?? '',
202
+ };
203
+ }
204
+
205
+ return { summary: line.slice(2) };
206
+ };
207
+
208
+ const parseChangelogEntryChanges = ({
209
+ changelog,
210
+ packageName,
211
+ version,
212
+ }: ChangelogEntryInput): ReleaseNotesParsedChange[] => {
213
+ const entry = extractChangelogEntry(changelog, version);
214
+ if (!entry) {
215
+ return [];
216
+ }
217
+
218
+ const changes: ReleaseNotesParsedChange[] = [];
219
+ let current: CurrentParsedChange | undefined;
220
+
221
+ const flush = () => {
222
+ if (!current) {
223
+ return;
224
+ }
225
+ const summary = normalizeSummary(current.summaryLines);
226
+ if (summary) {
227
+ changes.push({
228
+ ...(current.commit === undefined ? {} : { commit: current.commit }),
229
+ packageName: current.packageName,
230
+ summary,
231
+ ...(current.url === undefined ? {} : { url: current.url }),
232
+ });
233
+ }
234
+ };
235
+
236
+ for (const line of entry.split('\n')) {
237
+ const start = parseChangeStart(line);
238
+ if (start) {
239
+ flush();
240
+ current = {
241
+ ...(start.commit === undefined ? {} : { commit: start.commit }),
242
+ packageName,
243
+ summaryLines: [start.summary],
244
+ ...(start.url === undefined ? {} : { url: start.url }),
245
+ };
246
+ continue;
247
+ }
248
+
249
+ if (current && line.startsWith(' ') && line.trim()) {
250
+ current.summaryLines.push(line.trim());
251
+ continue;
252
+ }
253
+
254
+ if (line.startsWith('- ')) {
255
+ flush();
256
+ current = undefined;
257
+ }
258
+ }
259
+
260
+ flush();
261
+ return changes;
262
+ };
263
+
264
+ export const dedupeReleaseChanges = (
265
+ changes: readonly ReleaseNotesParsedChange[]
266
+ ): ReleaseNotesChange[] => {
267
+ const byKey = new Map<string, ReleaseNotesChange>();
268
+
269
+ for (const change of changes) {
270
+ const key = `${change.commit ?? ''}\n${change.summary}`;
271
+ const existing = byKey.get(key);
272
+ if (existing) {
273
+ byKey.set(key, {
274
+ ...existing,
275
+ packages: [
276
+ ...new Set([...existing.packages, change.packageName]),
277
+ ].toSorted(),
278
+ });
279
+ continue;
280
+ }
281
+
282
+ byKey.set(key, {
283
+ ...(change.commit === undefined ? {} : { commit: change.commit }),
284
+ packages: [change.packageName],
285
+ summary: change.summary,
286
+ ...(change.url === undefined ? {} : { url: change.url }),
287
+ });
288
+ }
289
+
290
+ return [...byKey.values()];
291
+ };
292
+
293
+ const renderCommitLink = (change: ReleaseNotesChange, repo: string): string => {
294
+ if (!change.commit) {
295
+ return '';
296
+ }
297
+ const short = change.commit.slice(0, 7);
298
+ const url =
299
+ change.url ?? `https://github.com/${repo}/commit/${change.commit}`;
300
+ return `[\`${short}\`](${url}): `;
301
+ };
302
+
303
+ const renderPackages = (packages: readonly string[]): string =>
304
+ packages.map((name) => `\`${name}\``).join(', ');
305
+
306
+ export const renderReleaseNotes = ({
307
+ changes,
308
+ distTag,
309
+ mode,
310
+ packageVersions,
311
+ repo = DEFAULT_REPO,
312
+ version,
313
+ }: ReleaseNotesInput): string => {
314
+ const intro =
315
+ mode === 'release-pr'
316
+ ? `${packageVersions.length} \`@ontrails/*\` packages will be bumped to \`${version}\`.`
317
+ : `Published the publishable \`@ontrails/*\` package set at \`${version}\` on the \`${distTag}\` dist-tag.`;
318
+ const highlights = changes.slice(0, 5);
319
+ const lines = [`# Release ${version}`, '', intro, '', '## Highlights', ''];
320
+
321
+ if (highlights.length === 0) {
322
+ lines.push('- No user-facing changes were detected in package changelogs.');
323
+ } else {
324
+ lines.push(...highlights.map((change) => `- ${change.summary}`));
325
+ }
326
+
327
+ lines.push('', '## Changes', '');
328
+ if (changes.length === 0) {
329
+ lines.push('- No user-facing changes were detected in package changelogs.');
330
+ } else {
331
+ lines.push(
332
+ ...changes.map(
333
+ (change) =>
334
+ `- ${renderCommitLink(change, repo)}${change.summary} Packages: ${renderPackages(change.packages)}`
335
+ )
336
+ );
337
+ }
338
+
339
+ lines.push(
340
+ '',
341
+ '<details>',
342
+ '<summary>Package Versions</summary>',
343
+ '',
344
+ ...packageVersions.map((pkg) => `- \`${pkg.name}@${pkg.version}\``),
345
+ '',
346
+ '</details>',
347
+ ''
348
+ );
349
+
350
+ return lines.join('\n');
351
+ };
352
+
353
+ export const collectReleaseNotesInput = async ({
354
+ distTag,
355
+ mode,
356
+ repo,
357
+ repoRoot,
358
+ version,
359
+ }: ReleaseNotesCollectOptions): Promise<ReleaseNotesInput> => {
360
+ const resolvedVersion = version ?? (await readTrailsVersion(repoRoot));
361
+ const resolvedDistTag = distTag ?? distTagForVersion(resolvedVersion);
362
+ const workspaces = await discoverReleaseNotesWorkspaces(repoRoot, {
363
+ includePrivate: mode === 'release-pr',
364
+ });
365
+ const packageVersions = workspaces.map((workspace) => ({
366
+ name: workspace.name,
367
+ version: workspace.version,
368
+ }));
369
+ const parsedChangesByPackage = await Promise.all(
370
+ workspaces.map(async (workspace: ReleaseNotesWorkspace) =>
371
+ parseChangelogEntryChanges({
372
+ changelog: await Bun.file(
373
+ join(repoRoot, workspace.path, 'CHANGELOG.md')
374
+ ).text(),
375
+ packageName: workspace.name,
376
+ version: resolvedVersion,
377
+ })
378
+ )
379
+ );
380
+ const parsedChanges = parsedChangesByPackage.flat();
381
+
382
+ return {
383
+ changes: dedupeReleaseChanges(parsedChanges),
384
+ distTag: resolvedDistTag,
385
+ mode,
386
+ packageVersions,
387
+ ...(repo === undefined ? {} : { repo }),
388
+ version: resolvedVersion,
389
+ };
390
+ };