@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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/changelog-contract.md +129 -0
- package/config.json +9 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +106 -0
- package/dist/commands/add-links.d.ts +8 -0
- package/dist/commands/add-links.d.ts.map +1 -0
- package/dist/commands/add-links.js +258 -0
- package/dist/commands/backfill-commits.d.ts +19 -0
- package/dist/commands/backfill-commits.d.ts.map +1 -0
- package/dist/commands/backfill-commits.js +236 -0
- package/dist/commands/check-completeness.d.ts +13 -0
- package/dist/commands/check-completeness.d.ts.map +1 -0
- package/dist/commands/check-completeness.js +164 -0
- package/dist/commands/enrich.d.ts +17 -0
- package/dist/commands/enrich.d.ts.map +1 -0
- package/dist/commands/enrich.js +194 -0
- package/dist/commands/finalise.d.ts +25 -0
- package/dist/commands/finalise.d.ts.map +1 -0
- package/dist/commands/finalise.js +249 -0
- package/dist/commands/set-affected-packages.d.ts +13 -0
- package/dist/commands/set-affected-packages.d.ts.map +1 -0
- package/dist/commands/set-affected-packages.js +207 -0
- package/dist/commands/validate.d.ts +6 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +293 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/lib/blank.d.ts +5 -0
- package/dist/lib/blank.d.ts.map +1 -0
- package/dist/lib/blank.js +6 -0
- package/dist/lib/changelog.d.ts +6 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +47 -0
- package/dist/lib/cli-entry.d.ts +10 -0
- package/dist/lib/cli-entry.d.ts.map +1 -0
- package/dist/lib/cli-entry.js +22 -0
- package/dist/lib/commit-count.d.ts +16 -0
- package/dist/lib/commit-count.d.ts.map +1 -0
- package/dist/lib/commit-count.js +72 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +156 -0
- package/dist/lib/derive-packages.d.ts +10 -0
- package/dist/lib/derive-packages.d.ts.map +1 -0
- package/dist/lib/derive-packages.js +42 -0
- package/dist/lib/enrich.d.ts +29 -0
- package/dist/lib/enrich.d.ts.map +1 -0
- package/dist/lib/enrich.js +54 -0
- package/dist/lib/frontmatter.d.ts +11 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +355 -0
- package/dist/lib/stamp.d.ts +10 -0
- package/dist/lib/stamp.d.ts.map +1 -0
- package/dist/lib/stamp.js +35 -0
- package/dist/lib/vendor/issue-keys.d.ts +16 -0
- package/dist/lib/vendor/issue-keys.d.ts.map +1 -0
- package/dist/lib/vendor/issue-keys.js +29 -0
- 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"}
|