@rheged-studio/changelog-core 1.1.1

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/changelog-contract.md +129 -0
  4. package/config.json +9 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +106 -0
  8. package/dist/commands/add-links.d.ts +8 -0
  9. package/dist/commands/add-links.d.ts.map +1 -0
  10. package/dist/commands/add-links.js +258 -0
  11. package/dist/commands/backfill-commits.d.ts +19 -0
  12. package/dist/commands/backfill-commits.d.ts.map +1 -0
  13. package/dist/commands/backfill-commits.js +236 -0
  14. package/dist/commands/check-completeness.d.ts +13 -0
  15. package/dist/commands/check-completeness.d.ts.map +1 -0
  16. package/dist/commands/check-completeness.js +164 -0
  17. package/dist/commands/enrich.d.ts +17 -0
  18. package/dist/commands/enrich.d.ts.map +1 -0
  19. package/dist/commands/enrich.js +194 -0
  20. package/dist/commands/finalise.d.ts +25 -0
  21. package/dist/commands/finalise.d.ts.map +1 -0
  22. package/dist/commands/finalise.js +249 -0
  23. package/dist/commands/set-affected-packages.d.ts +13 -0
  24. package/dist/commands/set-affected-packages.d.ts.map +1 -0
  25. package/dist/commands/set-affected-packages.js +207 -0
  26. package/dist/commands/validate.d.ts +6 -0
  27. package/dist/commands/validate.d.ts.map +1 -0
  28. package/dist/commands/validate.js +293 -0
  29. package/dist/index.d.ts +23 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +24 -0
  32. package/dist/lib/blank.d.ts +5 -0
  33. package/dist/lib/blank.d.ts.map +1 -0
  34. package/dist/lib/blank.js +6 -0
  35. package/dist/lib/changelog.d.ts +6 -0
  36. package/dist/lib/changelog.d.ts.map +1 -0
  37. package/dist/lib/changelog.js +47 -0
  38. package/dist/lib/cli-entry.d.ts +10 -0
  39. package/dist/lib/cli-entry.d.ts.map +1 -0
  40. package/dist/lib/cli-entry.js +22 -0
  41. package/dist/lib/commit-count.d.ts +16 -0
  42. package/dist/lib/commit-count.d.ts.map +1 -0
  43. package/dist/lib/commit-count.js +72 -0
  44. package/dist/lib/config.d.ts +25 -0
  45. package/dist/lib/config.d.ts.map +1 -0
  46. package/dist/lib/config.js +156 -0
  47. package/dist/lib/derive-packages.d.ts +10 -0
  48. package/dist/lib/derive-packages.d.ts.map +1 -0
  49. package/dist/lib/derive-packages.js +42 -0
  50. package/dist/lib/enrich.d.ts +29 -0
  51. package/dist/lib/enrich.d.ts.map +1 -0
  52. package/dist/lib/enrich.js +54 -0
  53. package/dist/lib/frontmatter.d.ts +11 -0
  54. package/dist/lib/frontmatter.d.ts.map +1 -0
  55. package/dist/lib/frontmatter.js +355 -0
  56. package/dist/lib/stamp.d.ts +10 -0
  57. package/dist/lib/stamp.d.ts.map +1 -0
  58. package/dist/lib/stamp.js +35 -0
  59. package/dist/lib/vendor/issue-keys.d.ts +16 -0
  60. package/dist/lib/vendor/issue-keys.d.ts.map +1 -0
  61. package/dist/lib/vendor/issue-keys.js +29 -0
  62. package/package.json +124 -0
@@ -0,0 +1,236 @@
1
+ // One-off backfill of `stats.commits` across the dated changelog backlog (A-560).
2
+ //
3
+ // The `commits` stat joins the existing post-merge stats rail (files_changed /
4
+ // loc_added / loc_removed), but those were only ever filled going forward by the
5
+ // release-time enrichment. This walks the existing `changelog/*.md` entries and
6
+ // stamps `stats.commits` for each one whose merged PR can be resolved, so the
7
+ // backlog carries the same field as new entries will.
8
+ //
9
+ // Deliberately narrow: it sets ONLY `stats.commits` and touches no other field —
10
+ // unlike `finalise`, which also stamps `version` (owned by release-please) and
11
+ // the other post-merge fields. Running finalise to backfill would mis-stamp the
12
+ // not-yet-finalised entries; this stays in its lane.
13
+ //
14
+ // Idempotent: re-running is a no-op once `stats.commits` matches the resolved
15
+ // count. Best-effort per entry — a gh/network failure on one entry warns and
16
+ // skips it rather than aborting the run.
17
+ //
18
+ // The new line is spliced into the raw `stats:` block textually rather than via
19
+ // a parse → re-serialise round-trip. Re-serialising would re-canonicalise the
20
+ // whole frontmatter (quote styles, inline-vs-block arrays) on older,
21
+ // differently-formatted entries — a noisy diff that buries the one line that
22
+ // actually changed. Textual insertion keeps each entry's diff to a single added
23
+ // line. (Release-time enrichment can re-serialise freely because it only runs on
24
+ // freshly-authored, already-canonical entries.)
25
+ //
26
+ // Zero-dep: composes the package's own lib modules and the frontmatter parser,
27
+ // so it runs under bare `node`.
28
+ import { isCliEntry } from "../lib/cli-entry.js";
29
+ import { nonMergeCommitCount, realRunner } from "../lib/commit-count.js";
30
+ import { loadConfig } from "../lib/config.js";
31
+ import { parseFrontmatter } from "../lib/frontmatter.js";
32
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { argv } from "node:process";
35
+ /**
36
+ * Resolve the merged PR number for an entry: prefer the recorded `pr` field,
37
+ * else look it up from the `branch`. Returns the number, or null when there's no
38
+ * resolvable merged PR (e.g. an entry whose PR never merged or predates GitHub).
39
+ * Throws (rather than returning null) if the gh output isn't valid JSON —
40
+ * main()'s per-entry try/catch owns that, so a malformed response skips the
41
+ * entry loudly instead of being mistaken for "no PR".
42
+ */
43
+ export function resolvePrNumber(run, fm) {
44
+ if (typeof fm.pr === "number") {
45
+ return fm.pr;
46
+ }
47
+ const branch = typeof fm.branch === "string" ? fm.branch : "";
48
+ if (!branch) {
49
+ return null;
50
+ }
51
+ const list = JSON.parse(run("gh", [
52
+ "pr",
53
+ "list",
54
+ "--head",
55
+ branch,
56
+ "--state",
57
+ "merged",
58
+ "--limit",
59
+ "1",
60
+ "--json",
61
+ "number",
62
+ ]));
63
+ if (!Array.isArray(list) || list.length === 0) {
64
+ return null;
65
+ }
66
+ const first = list[0];
67
+ return first.number ?? null;
68
+ }
69
+ /**
70
+ * Splice a `commits: <n>` line into an entry's raw `stats:` block — replacing an
71
+ * existing `commits:` line, else appending after the block's last child. When the
72
+ * entry omits `stats` entirely (the contract allows it), synthesise a minimal
73
+ * `stats:` block carrying just the commits count. Leaves every other byte of the
74
+ * entry untouched.
75
+ */
76
+ function setStatsCommits(raw, commits) {
77
+ const lines = raw.split("\n");
78
+ if (lines[0]?.trim() !== "---") {
79
+ throw new Error("entry has no frontmatter fence");
80
+ }
81
+ let fmEnd = -1;
82
+ for (let index = 1; index < lines.length; index++) {
83
+ if (lines[index].trim() === "---") {
84
+ fmEnd = index;
85
+ break;
86
+ }
87
+ }
88
+ if (fmEnd === -1) {
89
+ throw new Error("unterminated frontmatter");
90
+ }
91
+ let statsIndex = -1;
92
+ let inlineEmptyStats = false;
93
+ for (let index = 1; index < fmEnd; index++) {
94
+ const line = lines[index];
95
+ if (/^stats:\s*$/.test(line)) {
96
+ statsIndex = index;
97
+ break;
98
+ }
99
+ // Serialiser can emit `stats: {}` when the mapping has no children; treat
100
+ // that as an empty block to expand rather than synthesising a second key.
101
+ if (/^stats:\s*\{\}\s*$/.test(line)) {
102
+ statsIndex = index;
103
+ inlineEmptyStats = true;
104
+ break;
105
+ }
106
+ }
107
+ if (statsIndex === -1) {
108
+ // The contract lets an entry omit `stats` entirely (A-613). Rather than
109
+ // throwing, synthesise a minimal block carrying just the commits count,
110
+ // appended as the last frontmatter field (its canonical position) so no
111
+ // existing line shifts. The other stats children stay absent — this script
112
+ // sets only stats.commits.
113
+ lines.splice(fmEnd, 0, "stats:", ` commits: ${commits}`);
114
+ return lines.join("\n");
115
+ }
116
+ if (inlineEmptyStats) {
117
+ lines[statsIndex] = "stats:";
118
+ lines.splice(statsIndex + 1, 0, ` commits: ${commits}`);
119
+ return lines.join("\n");
120
+ }
121
+ // Walk the indented children of the stats block. The first dedented (or
122
+ // closing-fence) line ends it. Track the last child to append after, any
123
+ // existing commits line to replace in place, and the child indent so an
124
+ // inserted line nests under stats: at the same depth (don't hard-code two
125
+ // spaces — a 4-space block would otherwise be mis-nested; A-581).
126
+ let lastChild = statsIndex;
127
+ let commitsIndex = -1;
128
+ let childIndent = null;
129
+ for (let index = statsIndex + 1; index < fmEnd; index++) {
130
+ const line = lines[index];
131
+ if (line.trim() === "") {
132
+ continue;
133
+ }
134
+ const indentMatch = /^(\s+)/.exec(line);
135
+ if (!indentMatch) {
136
+ break;
137
+ }
138
+ if (childIndent === null) {
139
+ childIndent = indentMatch[1];
140
+ }
141
+ lastChild = index;
142
+ if (/^\s+commits:/.test(line)) {
143
+ commitsIndex = index;
144
+ }
145
+ }
146
+ // Fall back to two spaces only when the stats block has no children to mirror.
147
+ const indent = childIndent ?? " ";
148
+ const newLine = `${indent}commits: ${commits}`;
149
+ if (commitsIndex === -1) {
150
+ lines.splice(lastChild + 1, 0, newLine);
151
+ }
152
+ else {
153
+ lines[commitsIndex] = newLine;
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+ /**
158
+ * Pure backfill of a single entry's raw markdown. Returns the rewritten markdown
159
+ * with `stats.commits` set, or null when nothing changed (unresolvable PR, or
160
+ * the count already matches).
161
+ */
162
+ export function backfillEntry(raw, run) {
163
+ const { data } = parseFrontmatter(raw);
164
+ const prNumber = resolvePrNumber(run, data);
165
+ if (prNumber === null) {
166
+ return null;
167
+ }
168
+ const count = nonMergeCommitCount(run, prNumber);
169
+ if (count === null) {
170
+ return null;
171
+ }
172
+ const commits = Number.parseInt(count, 10);
173
+ const existing = typeof data.stats === "object" &&
174
+ data.stats !== null &&
175
+ !Array.isArray(data.stats)
176
+ ? data.stats.commits
177
+ : undefined;
178
+ if (existing === commits) {
179
+ return null; // already backfilled with the same count
180
+ }
181
+ return setStatsCommits(raw, commits);
182
+ }
183
+ const USAGE = `backfill-commits — one-off backfill of stats.commits across the changelog backlog
184
+
185
+ Walks the dated changelog/ entries and stamps stats.commits for each one whose
186
+ merged PR resolves (via gh). Sets ONLY stats.commits; idempotent; best-effort per
187
+ entry. WRITES to changelog/ files unless --dry-run.
188
+
189
+ Usage:
190
+ changelog-core backfill-commits Backfill every resolvable entry (writes; needs gh)
191
+ changelog-core backfill-commits --dry-run Report what would change without writing
192
+ changelog-core backfill-commits --help Show this message (alias: -h)`;
193
+ export function main() {
194
+ const args = argv.slice(2);
195
+ if (args.includes("--help") || args.includes("-h")) {
196
+ console.log(USAGE);
197
+ return;
198
+ }
199
+ const dryRun = args.includes("--dry-run");
200
+ const config = loadConfig();
201
+ const run = realRunner;
202
+ const files = readdirSync(config.changelogDir)
203
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
204
+ .map((name) => join(config.changelogDir, name));
205
+ let changed = 0;
206
+ let skipped = 0;
207
+ for (const file of files) {
208
+ let next = null;
209
+ try {
210
+ next = backfillEntry(readFileSync(file, "utf8"), run);
211
+ }
212
+ catch (error) {
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ console.warn(`⚠️ Skipped ${file}: ${message}`);
215
+ skipped++;
216
+ continue;
217
+ }
218
+ if (next === null) {
219
+ continue;
220
+ }
221
+ if (dryRun) {
222
+ console.log(`would backfill: ${file}`);
223
+ }
224
+ else {
225
+ writeFileSync(file, next);
226
+ console.log(`backfilled: ${file}`);
227
+ }
228
+ changed++;
229
+ }
230
+ console.log(`\n${dryRun ? "Would backfill" : "Backfilled"} ${changed} entr${changed === 1 ? "y" : "ies"}${skipped ? `, skipped ${skipped} on error` : ""}.`);
231
+ }
232
+ // Only run the filesystem pass when invoked as a CLI, not when imported by
233
+ // tests.
234
+ if (isCliEntry(import.meta.filename)) {
235
+ main();
236
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Build the path regex for dated entries under a configured changelog directory.
3
+ */
4
+ export declare function changelogEntryPattern(changelogDirectory: string): RegExp;
5
+ export declare function isReleaseTriggering(prTitle: string): boolean;
6
+ export declare function hasChangelogEntry(changedFiles: string[], changelogDirectory?: string): boolean;
7
+ export type CompletenessResult = {
8
+ ok: boolean;
9
+ reason: string;
10
+ };
11
+ export declare function checkCompleteness(prTitle: string, changedFiles: string[], changelogDirectory?: string): CompletenessResult;
12
+ export declare function main(): void;
13
+ //# sourceMappingURL=check-completeness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-completeness.d.ts","sourceRoot":"","sources":["../../src/commands/check-completeness.ts"],"names":[],"mappings":"AA6BA;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,CAGxE;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,EAAE,EACtB,kBAAkB,GAAE,MAA8B,GACjD,OAAO,CAKT;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,EAAE,EACtB,kBAAkB,GAAE,MAA8B,GACjD,kBAAkB,CAmBpB;AAyFD,wBAAgB,IAAI,IAAI,IAAI,CAgC3B"}
@@ -0,0 +1,164 @@
1
+ // Changelog-completeness gate (A-380). A release-triggering PR title
2
+ // (`feat`/`fix`/breaking) MUST carry a dated changelog entry. This restores
3
+ // the coupling Changesets gave for free — no changeset → no release — now that
4
+ // release-please infers the bump from the Conventional-Commit PR title rather
5
+ // than an explicit file.
6
+ //
7
+ // "Release-triggering" mirrors release-please's default node bump table:
8
+ // `feat` (minor), `fix`/`perf`/`revert` (patch), and a `!` breaking marker
9
+ // (major) cut a release; `docs`/`chore`/`ci`/`refactor`/`test`/`build`/`style`
10
+ // do not.
11
+ //
12
+ // Inputs (env, set by the workflow):
13
+ // PR_TITLE — the pull request title (github.event.pull_request.title)
14
+ // BASE_REF — the base branch name (github.base_ref); defaults to "main"
15
+ // Reads changed files from `git diff --name-only origin/<BASE_REF>...HEAD`.
16
+ // Pure functions live exported for vitest.
17
+ //
18
+ // Zero-dep: Node built-ins only — no tsx, so CI runs it under bare `node`.
19
+ import { isCliEntry } from "../lib/cli-entry.js";
20
+ import { loadConfig } from "../lib/config.js";
21
+ import { escapeRegex } from "../lib/vendor/issue-keys.js";
22
+ import { execFileSync } from "node:child_process";
23
+ import { argv } from "node:process";
24
+ const RELEASE_TRIGGERING_TYPE = /^(feat|fix|perf|revert)(\([^)]+\))?:/;
25
+ const BREAKING_SUBJECT = /^[a-z]+(\([^)]+\))?!:/;
26
+ const DEFAULT_CHANGELOG_DIR = "changelog";
27
+ /**
28
+ * Build the path regex for dated entries under a configured changelog directory.
29
+ */
30
+ export function changelogEntryPattern(changelogDirectory) {
31
+ const directory = changelogDirectory.replace(/\/+$/, "");
32
+ return new RegExp(`^${escapeRegex(directory)}/.+\\.md$`);
33
+ }
34
+ function normalisedChangelogDirectory(changelogDirectory) {
35
+ return changelogDirectory.replace(/\/+$/, "");
36
+ }
37
+ export function isReleaseTriggering(prTitle) {
38
+ const title = prTitle.trim();
39
+ return BREAKING_SUBJECT.test(title) || RELEASE_TRIGGERING_TYPE.test(title);
40
+ }
41
+ export function hasChangelogEntry(changedFiles, changelogDirectory = DEFAULT_CHANGELOG_DIR) {
42
+ const directory = normalisedChangelogDirectory(changelogDirectory);
43
+ const pattern = changelogEntryPattern(directory);
44
+ const readme = `${directory}/README.md`;
45
+ return changedFiles.some((file) => pattern.test(file) && file !== readme);
46
+ }
47
+ export function checkCompleteness(prTitle, changedFiles, changelogDirectory = DEFAULT_CHANGELOG_DIR) {
48
+ if (!isReleaseTriggering(prTitle)) {
49
+ return {
50
+ ok: true,
51
+ reason: `PR title "${prTitle}" is not release-triggering — no changelog entry required.`,
52
+ };
53
+ }
54
+ if (hasChangelogEntry(changedFiles, changelogDirectory)) {
55
+ return {
56
+ ok: true,
57
+ reason: `Release-triggering PR title with a ${normalisedChangelogDirectory(changelogDirectory)}/ entry present.`,
58
+ };
59
+ }
60
+ return {
61
+ ok: false,
62
+ reason: `PR title "${prTitle}" triggers a release (feat/fix/breaking) but no ${normalisedChangelogDirectory(changelogDirectory)}/*.md entry is present in the diff vs the base branch. Run /send-it (or add a dated changelog entry) so the release carries notes.`,
63
+ };
64
+ }
65
+ function readChangedFiles(baseRef) {
66
+ // execFileSync (argv array, no shell) rather than execSync — consistent with
67
+ // finalise and keeps `baseRef` (from env) out of a shell string.
68
+ const out = execFileSync("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`], { encoding: "utf8" });
69
+ return out
70
+ .split("\n")
71
+ .map((line) => line.trim())
72
+ .filter(Boolean);
73
+ }
74
+ const USAGE = `check-completeness — gate a release-triggering PR on a dated changelog entry
75
+
76
+ Reads PR_TITLE and BASE_REF (default "main") from the environment and the changed
77
+ files from \`git diff --name-only origin/<BASE_REF>...HEAD\`. A feat/fix/breaking
78
+ PR title with no dated entry under the configured changelogDir fails the gate
79
+ (exit 1).
80
+
81
+ Usage:
82
+ PR_TITLE=… changelog-core check-completeness Run the gate
83
+ changelog-core check-completeness --self-test Run the built-in offline smoke test
84
+ changelog-core check-completeness --help Show this message (alias: -h)`;
85
+ // Offline smoke test: exercise the pure checkCompleteness / isReleaseTriggering
86
+ // over representative inputs — no env, no git.
87
+ function selfTest() {
88
+ const cases = [
89
+ {
90
+ name: "feat: title is release-triggering",
91
+ ok: isReleaseTriggering("feat: add a thing") === true,
92
+ },
93
+ {
94
+ name: "docs: title is not release-triggering",
95
+ ok: isReleaseTriggering("docs: tidy the readme") === false,
96
+ },
97
+ {
98
+ name: "breaking ! marker is release-triggering",
99
+ ok: isReleaseTriggering("feat(api)!: drop the old endpoint") === true,
100
+ },
101
+ {
102
+ name: "feat with a changelog entry passes the gate",
103
+ ok: checkCompleteness("feat: x", ["changelog/20260101-000000-a-1-x.md"])
104
+ .ok === true,
105
+ },
106
+ {
107
+ name: "feat with no changelog entry fails the gate",
108
+ ok: checkCompleteness("feat: x", ["skills/changelog/SKILL.md"]).ok ===
109
+ false,
110
+ },
111
+ {
112
+ name: "changelog/README.md does not satisfy the gate",
113
+ ok: checkCompleteness("fix: y", ["changelog/README.md"]).ok === false,
114
+ },
115
+ {
116
+ name: "custom changelogDir is honoured",
117
+ ok: checkCompleteness("feat: x", ["changelogs/20260101-000000-a-1-x.md"], "changelogs").ok === true,
118
+ },
119
+ {
120
+ name: "non-release-triggering title passes with no entry",
121
+ ok: checkCompleteness("chore: z", []).ok === true,
122
+ },
123
+ ];
124
+ let failed = 0;
125
+ for (const { name, ok } of cases) {
126
+ if (ok) {
127
+ console.log(` ok ${name}`);
128
+ }
129
+ else {
130
+ failed += 1;
131
+ console.log(` FAIL ${name}`);
132
+ }
133
+ }
134
+ console.log(`\n${cases.length - failed}/${cases.length} passed`);
135
+ process.exit(failed === 0 ? 0 : 1);
136
+ }
137
+ export function main() {
138
+ const args = argv.slice(2);
139
+ if (args.includes("--help") || args.includes("-h")) {
140
+ console.log(USAGE);
141
+ return;
142
+ }
143
+ if (args.includes("--self-test")) {
144
+ selfTest();
145
+ return;
146
+ }
147
+ const prTitle = process.env.PR_TITLE ?? "";
148
+ const baseRef = process.env.BASE_REF || "main";
149
+ if (!prTitle) {
150
+ console.error("PR_TITLE is not set — cannot run the changelog-completeness gate.");
151
+ process.exit(1);
152
+ }
153
+ const { changelogDir: changelogDirectory } = loadConfig();
154
+ const result = checkCompleteness(prTitle, readChangedFiles(baseRef), changelogDirectory);
155
+ console.log(result.reason);
156
+ if (!result.ok) {
157
+ process.exit(1);
158
+ }
159
+ }
160
+ // Only run when invoked as a CLI, not when imported (e.g. by unit tests
161
+ // exercising the pure functions).
162
+ if (isCliEntry(import.meta.filename)) {
163
+ main();
164
+ }
@@ -0,0 +1,17 @@
1
+ import type { EnrichInput } from "../lib/enrich.js";
2
+ /**
3
+ * Map the orchestrator's env-var interface into an EnrichInput. Absent or empty
4
+ * vars become null so enrichFrontmatter's `blank()` guards fire — and so
5
+ * `Number.parseInt` is never handed `""` (which is NaN, and the validator rejects
6
+ * it).
7
+ */
8
+ export declare function readEnvironmentInput(environment: Record<string, string | undefined>): EnrichInput;
9
+ /**
10
+ * Enrich one entry's raw markdown, returning the rewritten string — or null when
11
+ * enrichment changes nothing (an already-filled entry, since enrichFrontmatter's
12
+ * fill-once guards make a re-run a no-op). Lets callers detect no-ops without
13
+ * diffing frontmatter.
14
+ */
15
+ export declare function enrichEntry(raw: string, input: EnrichInput): null | string;
16
+ export declare function main(): void;
17
+ //# sourceMappingURL=enrich.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enrich.d.ts","sourceRoot":"","sources":["../../src/commands/enrich.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAKpD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAC9C,WAAW,CAeb;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,GAAG,MAAM,CAG1E;AA0ED,wBAAgB,IAAI,IAAI,IAAI,CAuE3B"}
@@ -0,0 +1,194 @@
1
+ // Post-merge enrichment of a single changelog entry — the DEPLOY-TARGET entry
2
+ // point the release orchestrator's `enrich-changelogs.yml` cron invokes on a
3
+ // checked-out target, one run per discovered entry.
4
+ //
5
+ // Deploy targets are never checked out during the release flow, so — unlike npm
6
+ // targets, which enrich at release time via `finalise` — their entries can only
7
+ // be filled afterwards, from the cron. This is the writer that cron had no
8
+ // implementation for: it reads the per-PR data from the env-var interface below,
9
+ // finds the entry by its `branch:` field, fills the post-merge fields, and writes
10
+ // back.
11
+ //
12
+ // Env-var interface (all supplied by enrich-changelogs.yml):
13
+ // BRANCH_NAME the entry's branch — the lookup key (required)
14
+ // MERGED_AT PR merged_at (ISO 8601 UTC) (required)
15
+ // MERGE_SHA merge commit SHA (only first 7 stored) (required)
16
+ // PR_NUMBER merged PR number (optional)
17
+ // ADDITIONS lines added -> stats.loc_added (optional)
18
+ // DELETIONS lines removed -> stats.loc_removed (optional)
19
+ // CHANGED_FILES files changed -> stats.files_changed (optional)
20
+ // The cron passes no commit count, so `stats.commits` is left to the release-time
21
+ // path; the pure transform needs no `GH_TOKEN` (the cron empties it).
22
+ //
23
+ // Thin re-serialising wrapper: it delegates the actual field logic to
24
+ // `lib/enrich#enrichFrontmatter`, which re-serialises the frontmatter. Enrichment
25
+ // is fill-once via enrichFrontmatter's guards, so re-runs are idempotent.
26
+ //
27
+ // Zero-dep: composes the package's own lib modules, so it runs under bare `node`.
28
+ import { findEntryByBranch } from "../lib/changelog.js";
29
+ import { isCliEntry } from "../lib/cli-entry.js";
30
+ import { loadConfig } from "../lib/config.js";
31
+ import { enrichFrontmatter } from "../lib/enrich.js";
32
+ import { parseFrontmatter } from "../lib/frontmatter.js";
33
+ import { readFileSync, writeFileSync } from "node:fs";
34
+ import { argv, env, exit } from "node:process";
35
+ /**
36
+ * Map the orchestrator's env-var interface into an EnrichInput. Absent or empty
37
+ * vars become null so enrichFrontmatter's `blank()` guards fire — and so
38
+ * `Number.parseInt` is never handed `""` (which is NaN, and the validator rejects
39
+ * it).
40
+ */
41
+ export function readEnvironmentInput(environment) {
42
+ function read(name) {
43
+ const value = environment[name];
44
+ return value === undefined || value === "" ? null : value;
45
+ }
46
+ return {
47
+ additions: read("ADDITIONS"),
48
+ branch: read("BRANCH_NAME") ?? "",
49
+ changedFiles: read("CHANGED_FILES"),
50
+ deletions: read("DELETIONS"),
51
+ mergedAt: read("MERGED_AT") ?? "",
52
+ mergeSha: read("MERGE_SHA") ?? "",
53
+ prNumber: read("PR_NUMBER"),
54
+ };
55
+ }
56
+ /**
57
+ * Enrich one entry's raw markdown, returning the rewritten string — or null when
58
+ * enrichment changes nothing (an already-filled entry, since enrichFrontmatter's
59
+ * fill-once guards make a re-run a no-op). Lets callers detect no-ops without
60
+ * diffing frontmatter.
61
+ */
62
+ export function enrichEntry(raw, input) {
63
+ const next = enrichFrontmatter(raw, input);
64
+ return next === raw ? null : next;
65
+ }
66
+ const USAGE = `enrich — fill the post-merge fields of one changelog entry
67
+
68
+ The deploy-target post-merge enricher: reads a merged PR's data from env vars,
69
+ finds the entry by its branch, fills merged_at / commit / pr / stats via the
70
+ shared enrich lib, and writes back. Fill-once and idempotent.
71
+
72
+ Env vars: BRANCH_NAME, MERGED_AT, MERGE_SHA (required); PR_NUMBER, ADDITIONS,
73
+ DELETIONS, CHANGED_FILES (optional).
74
+
75
+ Usage:
76
+ changelog-core enrich Enrich the entry for $BRANCH_NAME (writes)
77
+ changelog-core enrich --check Exit 1 if the entry still needs enriching
78
+ changelog-core enrich --dry-run Report what would change without writing
79
+ changelog-core enrich --self-test Offline smoke test of the pure logic
80
+ changelog-core enrich --help Show this message (alias: -h)`;
81
+ function assert(condition, message) {
82
+ if (!condition) {
83
+ throw new Error(message);
84
+ }
85
+ }
86
+ /**
87
+ * Offline smoke test of the pure logic — no filesystem, no network. Enriches an
88
+ * in-memory placeholder and asserts the fields land and a re-run is a no-op.
89
+ */
90
+ function selfTest() {
91
+ const entry = [
92
+ "---",
93
+ 'title: "Fix a thing"',
94
+ 'created_at: "2026-05-23T14:55:37Z"',
95
+ 'branch: "a-1-fix-a-thing"',
96
+ "merged_at:",
97
+ "pr:",
98
+ "commit:",
99
+ "category: fix",
100
+ "breaking: false",
101
+ "---",
102
+ "",
103
+ "## Fixed",
104
+ "",
105
+ "- A thing",
106
+ "",
107
+ ].join("\n");
108
+ const input = {
109
+ additions: "10",
110
+ branch: "a-1-fix-a-thing",
111
+ changedFiles: "3",
112
+ deletions: "2",
113
+ mergedAt: "2026-05-24T09:00:00Z",
114
+ mergeSha: "abc1234def5678",
115
+ prNumber: "7",
116
+ };
117
+ const out = enrichEntry(entry, input);
118
+ assert(out !== null, "expected enrichment to change the placeholder entry");
119
+ const { data } = parseFrontmatter(out);
120
+ assert(data.merged_at === "2026-05-24T09:00:00Z", "merged_at not filled");
121
+ assert(data.commit === "abc1234", "commit not filled to 7 chars");
122
+ assert(data.pr === 7, "blank pr must be resolved from prNumber");
123
+ assert(typeof data.stats === "object" &&
124
+ data.stats !== null &&
125
+ !Array.isArray(data.stats) &&
126
+ data.stats.loc_added === 10 &&
127
+ data.stats.loc_removed === 2 &&
128
+ data.stats.files_changed === 3, "stats not overwritten authoritatively");
129
+ assert(enrichEntry(out, input) === null, "re-run should be a no-op");
130
+ }
131
+ export function main() {
132
+ const args = argv.slice(2);
133
+ if (args.includes("--help") || args.includes("-h")) {
134
+ console.log(USAGE);
135
+ return;
136
+ }
137
+ if (args.includes("--self-test")) {
138
+ try {
139
+ selfTest();
140
+ console.log("✓ enrich self-test passed");
141
+ }
142
+ catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ console.error(`✗ enrich self-test failed: ${message}`);
145
+ exit(1);
146
+ }
147
+ return;
148
+ }
149
+ const check = args.includes("--check");
150
+ const dryRun = args.includes("--dry-run");
151
+ const input = readEnvironmentInput(env);
152
+ // The cron always supplies these three; without them there is nothing to look
153
+ // up or fill, so this is a misinvocation rather than a no-op.
154
+ if (!input.branch || !input.mergedAt || !input.mergeSha) {
155
+ console.error("enrich: BRANCH_NAME, MERGED_AT and MERGE_SHA are required.\n");
156
+ console.error(USAGE);
157
+ exit(2);
158
+ }
159
+ const path = findEntryByBranch(input.branch, loadConfig().changelogDir);
160
+ if (path === null) {
161
+ // Discovery upstream may race with the entry's own merge; a missing entry is
162
+ // not the writer's failure to own — warn and succeed.
163
+ console.warn(`enrich: no entry for branch '${input.branch}' — nothing to enrich.`);
164
+ return;
165
+ }
166
+ let next = null;
167
+ try {
168
+ next = enrichEntry(readFileSync(path, "utf8"), input);
169
+ }
170
+ catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ console.error(`enrich: ${path}: ${message}`);
173
+ exit(1);
174
+ }
175
+ if (next === null) {
176
+ console.log(`already enriched: ${path}`);
177
+ return;
178
+ }
179
+ if (check) {
180
+ // Completeness gate: the entry still needs enriching.
181
+ console.log(`would enrich: ${path}`);
182
+ exit(1);
183
+ }
184
+ if (dryRun) {
185
+ console.log(`would enrich: ${path}`);
186
+ return;
187
+ }
188
+ writeFileSync(path, next);
189
+ console.log(`enriched: ${path}`);
190
+ }
191
+ // Only run the filesystem pass when invoked as a CLI, not when imported by tests.
192
+ if (isCliEntry(import.meta.filename)) {
193
+ main();
194
+ }
@@ -0,0 +1,25 @@
1
+ import type { Runner } from "../lib/commit-count.js";
2
+ export type ResolvedPr = {
3
+ additions: null | string;
4
+ changedFiles: null | string;
5
+ commits: null | string;
6
+ deletions: null | string;
7
+ mergedAt: string;
8
+ mergeSha: string;
9
+ prNumber: string;
10
+ };
11
+ /**
12
+ * Resolve the merged PR for a branch, or null when none is found.
13
+ */
14
+ export type PrResolver = (branch: string) => null | ResolvedPr;
15
+ /**
16
+ * Finalise one entry's raw markdown for release. Returns the rewritten markdown,
17
+ * or null when nothing changed (already finalised).
18
+ */
19
+ export declare function finaliseEntry(raw: string, version: string, resolvePr: PrResolver): null | string;
20
+ /**
21
+ * Build a PR resolver backed by `gh` (injectable runner for tests).
22
+ */
23
+ export declare function makeResolver(run: Runner): PrResolver;
24
+ export declare function main(): void;
25
+ //# sourceMappingURL=finalise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finalise.d.ts","sourceRoot":"","sources":["../../src/commands/finalise.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAUrD,MAAM,MAAM,UAAU,GAAG;IACvB,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,YAAY,EAAE,IAAI,GAAG,MAAM,CAAC;IAC5B,OAAO,EAAE,IAAI,GAAG,MAAM,CAAC;IACvB,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,UAAU,CAAC;AAE/D;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,UAAU,GACpB,IAAI,GAAG,MAAM,CAmDf;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAwEpD;AAmFD,wBAAgB,IAAI,IAAI,IAAI,CAiC3B"}