mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
feat(release): reconcile-changelog tool + version-anchored fragment aggregation (#12987)
* feat(release): reconcile-changelog tool + version-anchored fragment aggregation `npm run release:reconcile` (scripts/release/reconcile-changelog.mjs) turns the v3.8.51 reconciliation pass (#12971) into a repeatable Phase 0a step: - folds `changelog.d/` fragments under `## [<version>]` — never under the first matching heading — and credits each one with the PR of the commit that ADDED it (`git log --diff-filter=A`), because the filename prefix is not reliable (issue numbers, closed/recreated PRs, literal `#PR_NUMBER`); `--carrier N` marks a PR that only back-filled fragments for other people's PRs - drops fragments whose text already ships in another version section (phantom fragments re-added by branches cut before the previous aggregation) - covers a commit only when its OWN PR is a primary ref of a bullet (a `/pull/N` link, the trailing `(#N)`, or an explicit `(#N …)` group) so an incidental mention cannot hide a PR's own bullet; generates `**type(scope):** subject (#PR) — thanks @author` for the rest, rolls Dependabot bumps into one line, documents direct pushes by hash - `--credit N=handle` carries the closed-PR / co-author / deleted-PR audit - keeps pre-existing section bullets verbatim (changelog-integrity compares bullet lines), never touches `[Unreleased]` or older sections - opens the section with "📊 Release by the numbers" + "🏆 Top 25" (mailmap + merged-PR login), the v3.8.50 format `aggregate-changelog.mjs` gains the same anchoring: `insertBullets(text, bullets, version)` searches the heading inside `## [version]` only (with `[Unreleased]` still carrying `### ✨ New Features`, every feature fragment was landing there); `aggregate()` reads the version from package.json. Tests: tests/unit/reconcile-changelog.test.ts (helpers + an end-to-end reconcile fixture) and two new cases in tests/unit/changelog-fragments.test.ts. * fix(release): escape every regex metacharacter before building the mention regex CodeQL js/incomplete-sanitization on reconcile-changelog.mjs: the handle was only escaping '-' before being interpolated into a RegExp. Use a full escapeRegExp helper instead; handles are [A-Za-z0-9_-] in practice, so behaviour is unchanged for real input and the addCredit tests still pass.
This commit is contained in:
committed by
GitHub
parent
10fef01d20
commit
86b1cb84fe
@@ -277,6 +277,7 @@
|
||||
"postbuild": "node scripts/build/colocate-standalone.mjs",
|
||||
"release:contributors": "node scripts/release/gen-contributors.mjs",
|
||||
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
|
||||
"release:reconcile": "node scripts/release/reconcile-changelog.mjs",
|
||||
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"",
|
||||
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"
|
||||
|
||||
@@ -44,6 +44,15 @@ export const SECTIONS = Object.freeze({
|
||||
|
||||
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
|
||||
|
||||
/** The living cycle version = package.json `version` (null when unreadable → legacy first-heading mode). */
|
||||
export function readVersion(root = ROOT) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one fragment's text. Returns null when OK, or a human-readable error.
|
||||
* Pure — unit-tested.
|
||||
@@ -89,17 +98,32 @@ export function collectFragments(root) {
|
||||
|
||||
/**
|
||||
* Append bullets at the END of a living-section heading's bullet block (before the
|
||||
* next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this
|
||||
* repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested.
|
||||
* Throws when a needed heading is missing (the release captain adds the heading; the
|
||||
* script never invents structure).
|
||||
* next "##"/"###" heading). When `version` is given the heading is searched INSIDE the
|
||||
* `## [version]` block only — `[Unreleased]` still carries a `### ✨ New Features`
|
||||
* heading, so the first occurrence in the file is the wrong one (v3.8.51: every feature
|
||||
* fragment was landing under `[Unreleased]`, #12971). Without `version` the FIRST
|
||||
* occurrence is used (legacy behaviour). Pure — unit-tested. Throws when a needed heading
|
||||
* is missing (the release captain adds the heading; the script never invents structure).
|
||||
*/
|
||||
export function insertBullets(changelogText, bulletsBySection) {
|
||||
export function insertBullets(changelogText, bulletsBySection, version = null) {
|
||||
let lines = changelogText.split("\n");
|
||||
for (const [section, heading] of Object.entries(SECTIONS)) {
|
||||
const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b);
|
||||
if (bullets.length === 0) continue;
|
||||
const headIdx = lines.findIndex((l) => l.trim() === heading);
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
if (version) {
|
||||
from = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
||||
if (from === -1) {
|
||||
throw new Error(
|
||||
`section "## [${version}]" not found in CHANGELOG.md — fragments must land in the living version section`
|
||||
);
|
||||
}
|
||||
to = lines.findIndex((l, i) => i > from && l.startsWith("## ["));
|
||||
if (to === -1) to = lines.length;
|
||||
}
|
||||
const rel = lines.slice(from, to).findIndex((l) => l.trim() === heading);
|
||||
const headIdx = rel === -1 ? -1 : from + rel;
|
||||
if (headIdx === -1) {
|
||||
throw new Error(
|
||||
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
|
||||
@@ -125,7 +149,7 @@ export function insertBullets(changelogText, bulletsBySection) {
|
||||
* Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is
|
||||
* true nothing is written or deleted.
|
||||
*/
|
||||
export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
export function aggregate({ root = ROOT, dryRun = false, version = readVersion(root) } = {}) {
|
||||
const collected = collectFragments(root);
|
||||
if (collected.invalid.length > 0) {
|
||||
const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n");
|
||||
@@ -134,7 +158,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
const total = collected.features.length + collected.fixes.length + collected.maintenance.length;
|
||||
const changelogPath = join(root, "CHANGELOG.md");
|
||||
const before = readFileSync(changelogPath, "utf8");
|
||||
const after = total === 0 ? before : insertBullets(before, collected);
|
||||
const after = total === 0 ? before : insertBullets(before, collected, version);
|
||||
if (!dryRun && total > 0) {
|
||||
writeFileSync(changelogPath, after);
|
||||
for (const section of Object.keys(SECTIONS)) {
|
||||
|
||||
831
scripts/release/reconcile-changelog.mjs
Normal file
831
scripts/release/reconcile-changelog.mjs
Normal file
@@ -0,0 +1,831 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/release/reconcile-changelog.mjs
|
||||
//
|
||||
// Reconcile the living `## [<version>]` CHANGELOG section against the FULL development cycle
|
||||
// (Phase 0a.1–0a.3a of /generate-release), so that at the moment a release is cut:
|
||||
// • every cycle commit is represented by a bullet whose PRIMARY reference is that commit's PR,
|
||||
// • every bullet carries the merged PR link and `— thanks @author` (Hard Rule #16),
|
||||
// • fragments are folded in under the RIGHT version section (not the first heading that
|
||||
// matches — `[Unreleased]` still carries a `### ✨ New Features` heading),
|
||||
// • fragments that duplicate bullets already shipped in a previous version are dropped,
|
||||
// • the section opens with "📊 Release by the numbers" + "🏆 Top 25 contributors" (v3.8.50 format).
|
||||
//
|
||||
// It never touches bullets that already exist in the section (the changelog-integrity gate
|
||||
// compares bullet lines against the base), never touches `[Unreleased]`, and never edits any
|
||||
// other version section. Run `npm run release:contributors -- <version> --inject` afterwards to
|
||||
// (re)build the `### 🙌 Contributors` table, then `release:sync-changelog-i18n`.
|
||||
//
|
||||
// Lessons baked in (v3.8.51 reconciliation, 2026-09-07 — PR #12971):
|
||||
// • prefix of a fragment filename is NOT a reliable PR number (issue numbers, closed/recreated
|
||||
// PRs, literal `#PR_NUMBER`); the commit that ADDED the fragment (`git log --diff-filter=A`)
|
||||
// is the definitive origin — unless that commit is a "carrier" PR that only back-filled
|
||||
// fragments for other people's PRs (`--carrier N`), then the prefix wins;
|
||||
// • a commit is covered only when its OWN PR is a primary ref (a `/pull/N` link or the last
|
||||
// `#N` on the bullet line) — an incidental mention ("Opper #11629 + 1min.ai #11631") must not
|
||||
// hide a PR's own bullet;
|
||||
// • gen-contributors only reads lines that start with "- ", so fragment bullets are collapsed
|
||||
// to a single line (pre-existing section bullets are left verbatim).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release/reconcile-changelog.mjs [--version 3.8.51] [--base <ref>] [--head <ref>]
|
||||
// [--release-branch release/v3.8.51] [--credit 12255=backryun] [--carrier 11938]
|
||||
// [--drop-fragment changelog.d/fixes/x.md] [--fragment-pr changelog.d/fixes/y.md=11845]
|
||||
// [--prs <cached gh json>] [--dry-run] [--report <path.json>]
|
||||
//
|
||||
// Defaults: version = package.json; release branch = release/v<version>; head = HEAD;
|
||||
// base = parent of the commit that opened the cycle (first commit carrying the version string,
|
||||
// see resolveCycleBase in list-uncovered-commits.mjs) — pass `--base origin/release/v<prev>` to
|
||||
// use the previous release tip explicitly. Exit 0 always (advisory: the captain reviews the diff).
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveCycleBase } from "./list-uncovered-commits.mjs";
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
export const MAINTAINER = "diegosouzapw";
|
||||
export const SECTION_HEADINGS = Object.freeze({
|
||||
features: "### ✨ New Features",
|
||||
fixes: "### 🐛 Bug Fixes",
|
||||
maintenance: "### 📝 Maintenance",
|
||||
});
|
||||
const TYPE_LABEL = {
|
||||
fix: "🐛 Fixes",
|
||||
feat: "✨ Features",
|
||||
docs: "📚 Docs",
|
||||
chore: "🧹 Chore",
|
||||
test: "🧪 Tests",
|
||||
refactor: "♻️ Refactor",
|
||||
perf: "⚡ Performance",
|
||||
security: "🔒 Security",
|
||||
ci: "⚙️ CI",
|
||||
deps: "📦 Dependencies",
|
||||
build: "🏗️ Build",
|
||||
revert: "⏪ Reverts",
|
||||
other: "🔀 Other",
|
||||
};
|
||||
const BOT_RE = /dependabot|\[bot\]|^app\//i;
|
||||
|
||||
// ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
|
||||
|
||||
/** `[#N](url)` → `#N`, `[@h](url)` → `@h` so refs/handles can be scanned uniformly. */
|
||||
export const normalizeLinks = (s) =>
|
||||
s.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1").replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
|
||||
export const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||||
export const prLink = (repo, n) => `[#${n}](https://github.com/${repo}/pull/${n})`;
|
||||
|
||||
/**
|
||||
* Primary refs of a bullet: every `/pull/N` link, the LAST `#N` on its first line (the
|
||||
* conventional trailing `(#N)` back-reference) and every explicit `(#N …)` group — a group
|
||||
* that OPENS with the ref, e.g. `(#11436)` or `(#11436 — thanks @x)`. An incidental mention
|
||||
* inside prose (`(Opper #11629 + 1min.ai #11631)`) does not count.
|
||||
*/
|
||||
export function primaryRefs(bullet) {
|
||||
const flat = normalizeLinks(bullet.replace(/\n\s+/g, " "));
|
||||
const out = new Set([...bullet.matchAll(/\/pull\/(\d+)\)/g)].map((m) => Number(m[1])));
|
||||
const refs = refsIn(flat);
|
||||
if (refs.length) out.add(refs[refs.length - 1]);
|
||||
for (const m of flat.matchAll(/\(#(\d+)(?=[\s,)—-])/g)) out.add(Number(m[1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Commit hashes a bullet documents explicitly as `(direct commit \`<hash>\`)`. */
|
||||
export const directHashes = (bullet) =>
|
||||
[...bullet.matchAll(/direct commit `([0-9a-f]{7,40})`/g)].map((m) => m[1]);
|
||||
|
||||
/**
|
||||
* Split a markdown text into bullet blocks per section heading. A block is a `- ` line plus its
|
||||
* indented continuation lines. `fixedSection` forces every bullet into one section (fragments).
|
||||
* `collapse` joins continuation lines into the first line (fragments only — never pre-existing
|
||||
* section bullets, which the changelog-integrity gate compares line by line).
|
||||
*/
|
||||
export function parseBlocks(text, { fixedSection = null, collapse = false } = {}) {
|
||||
const out = { features: [], fixes: [], maintenance: [] };
|
||||
let cur = fixedSection;
|
||||
let block = null;
|
||||
const flush = () => {
|
||||
if (block && cur) {
|
||||
out[cur].push(
|
||||
collapse
|
||||
? block.map((l, i) => (i ? l.trim() : l.replace(/\s+$/, ""))).join(" ")
|
||||
: block.join("\n")
|
||||
);
|
||||
}
|
||||
block = null;
|
||||
};
|
||||
for (const line of text.split("\n")) {
|
||||
if (!fixedSection && line.startsWith("### ")) {
|
||||
flush();
|
||||
cur = Object.keys(SECTION_HEADINGS).find((k) => SECTION_HEADINGS[k] === line.trim()) || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("- ")) {
|
||||
flush();
|
||||
if (cur) block = [line];
|
||||
continue;
|
||||
}
|
||||
if (block && /^\s+\S/.test(line)) {
|
||||
block.push(line);
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Escape every regex metacharacter (CodeQL js/incomplete-sanitization: never escape just one). */
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mentions = (b, h) => new RegExp(`@${escapeRegExp(h)}(?![A-Za-z0-9_-])`, "i").test(b);
|
||||
|
||||
/** Append `— thanks @a / @b` (or extend an existing trailing thanks group) for handles not yet mentioned. */
|
||||
export function addCredit(bullet, handles, maintainer = MAINTAINER) {
|
||||
const hs = [...new Set(handles.filter((h) => h && h !== maintainer && !mentions(bullet, h)))];
|
||||
if (!hs.length) return bullet;
|
||||
const lines = bullet.split("\n");
|
||||
let last = lines[lines.length - 1];
|
||||
const add = hs.map((h) => `@${h}`).join(" / ");
|
||||
if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\s*$/.test(last)) {
|
||||
last = `${last.replace(/\s*$/, "")} / ${add}`;
|
||||
} else if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\)\s*$/.test(last)) {
|
||||
last = `${last.replace(/\)\s*$/, "")} / ${add})`;
|
||||
} else {
|
||||
last = `${last.replace(/\s*$/, "")} — thanks ${add}`;
|
||||
}
|
||||
lines[lines.length - 1] = last;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Append a `([#N](…))` link to the last line of a bullet, before any trailing thanks group. */
|
||||
export function appendLink(bullet, repo, n) {
|
||||
if (refsIn(normalizeLinks(bullet)).includes(n)) return bullet;
|
||||
const lines = bullet.split("\n");
|
||||
let last = lines[lines.length - 1];
|
||||
const th = last.match(/\s*—\s*thanks\s+@[^\n]*$/);
|
||||
last = th
|
||||
? `${last.slice(0, th.index).replace(/\s*$/, "")} (${prLink(repo, n)})${th[0]}`
|
||||
: `${last.replace(/\s*$/, "")} (${prLink(repo, n)})`;
|
||||
lines[lines.length - 1] = last;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Conventional-commit subject → { type, text } with the type prefix bolded (repo format). */
|
||||
export function bulletFromSubject(subject, special = {}) {
|
||||
let s = subject
|
||||
.replace(/\s*\(#\d+\)\s*$/, "")
|
||||
.replace(/^\[?URGENT\]?\s*/i, "")
|
||||
.trim();
|
||||
if (special[s]) s = special[s];
|
||||
const m = s.match(/^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i);
|
||||
if (!m) return { type: "other", text: s };
|
||||
return { type: m[1].toLowerCase(), text: `**${m[1].toLowerCase()}${m[2] || ""}:** ${m[4]}` };
|
||||
}
|
||||
|
||||
export const sectionForType = (type) =>
|
||||
type === "feat"
|
||||
? "features"
|
||||
: ["fix", "perf", "security", "revert"].includes(type)
|
||||
? "fixes"
|
||||
: "maintenance";
|
||||
|
||||
/** Type of an existing bullet (`- **fix(x):** …` or `- fix(x): …`), else "other". */
|
||||
export function typeOfBullet(bullet) {
|
||||
const m = normalizeLinks(bullet).match(/^- \*{0,2}([a-z]+)(?:\([^)]*\))?!?:\*{0,2}/i);
|
||||
return m ? m[1].toLowerCase() : "other";
|
||||
}
|
||||
|
||||
/** Text key used to spot twins (two fragments for one PR, a direct-commit twin of a synced PR). */
|
||||
export const dedupKey = (b) =>
|
||||
normalizeLinks(b)
|
||||
.split("\n")[0]
|
||||
.replace(/\s*\((?:#\d+[^)]*|direct commit[^)]*)\)\s*/g, " ")
|
||||
.replace(/\s*—\s*thanks.*$/, "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
|
||||
/**
|
||||
* Keep one bullet per dedupKey (the best-linked one) and MERGE every `([#N](…))` /
|
||||
* `(direct commit …)` group the dropped twins carried into the survivor.
|
||||
*/
|
||||
export function dedupeBullets(list) {
|
||||
const best = new Map();
|
||||
const score = (b) => (b.match(/\/pull\//g) || []).length * 10 + b.length / 1000;
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (!best.has(k) || score(b) > best.get(k).score) best.set(k, { b, score: score(b) });
|
||||
}
|
||||
const extras = new Map();
|
||||
const dropped = [];
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (best.get(k).b === b) continue;
|
||||
dropped.push(b);
|
||||
if (!extras.has(k)) extras.set(k, []);
|
||||
for (const m of b.matchAll(/\(\[#\d+\]\([^)]*\)\)|\(direct commit `[0-9a-f]+`\)/g)) {
|
||||
extras.get(k).push(m[0]);
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
const kept = [];
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (best.get(k).b !== b || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
let out = b;
|
||||
for (const g of extras.get(k) || []) {
|
||||
if (out.includes(g)) continue;
|
||||
const th = out.match(/\s*—\s*thanks[^\n]*$/);
|
||||
out = th ? `${out.slice(0, th.index)} ${g}${th[0]}` : `${out} ${g}`;
|
||||
}
|
||||
kept.push(out);
|
||||
}
|
||||
return { kept, dropped };
|
||||
}
|
||||
|
||||
/** Extract `## [version]` … up to the next `## [` (exclusive). */
|
||||
export function versionSectionRange(changelog, version) {
|
||||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const m = changelog.match(new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"));
|
||||
if (!m) return null;
|
||||
const bodyStart = m.index + m[0].length;
|
||||
const rest = changelog.slice(bodyStart);
|
||||
const next = rest.search(/\n## \[/);
|
||||
return { start: m.index, bodyStart, end: next === -1 ? changelog.length : bodyStart + next };
|
||||
}
|
||||
|
||||
/** Everything under every `## [` heading OTHER than `version` (used to spot already-shipped text). */
|
||||
export function otherSectionsText(changelog, version) {
|
||||
const r = versionSectionRange(changelog, version);
|
||||
if (!r) return changelog;
|
||||
return changelog.slice(0, r.start) + changelog.slice(r.end);
|
||||
}
|
||||
|
||||
/** Commits covered by the bullets: own PR is a primary ref (or closes a cited issue); no-PR commits by any ref. */
|
||||
export function computeCoverage(
|
||||
rows,
|
||||
bullets,
|
||||
{ closingPrs = new Set(), originHashes = new Set(), skipHashes = new Set() } = {}
|
||||
) {
|
||||
const cited = new Set();
|
||||
const hashes = [];
|
||||
for (const b of bullets) {
|
||||
for (const n of primaryRefs(b)) cited.add(n);
|
||||
hashes.push(...directHashes(b));
|
||||
}
|
||||
const documentedHash = (full) =>
|
||||
hashes.some((h) => full.startsWith(h) || h.startsWith(full.slice(0, 9)));
|
||||
const uncovered = rows.filter((r) => {
|
||||
const h = r.hash.slice(0, 9);
|
||||
if (skipHashes.has(h) || originHashes.has(h)) return false;
|
||||
if (r.pr) return !(cited.has(r.pr) || closingPrs.has(r.pr));
|
||||
return !(documentedHash(r.hash) || r.refs.some((x) => cited.has(x)));
|
||||
});
|
||||
return { cited, uncovered };
|
||||
}
|
||||
|
||||
/** Rank authors by commits; key = GitHub login of the merged PR when known, else the mailmap name. */
|
||||
export function rankAuthors(rows, limit = 25) {
|
||||
const counts = {};
|
||||
const names = {};
|
||||
for (const r of rows) {
|
||||
if (BOT_RE.test(r.authorName)) continue;
|
||||
const k = r.prAuthor || r.authorName;
|
||||
counts[k] = (counts[k] || 0) + 1;
|
||||
names[k] ??= {};
|
||||
names[k][r.authorName] = (names[k][r.authorName] || 0) + 1;
|
||||
}
|
||||
const display = (k) => Object.entries(names[k]).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return {
|
||||
counts,
|
||||
top: Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([k, c]) => [display(k) === k ? k : `${display(k)} (@${k})`, c]),
|
||||
};
|
||||
}
|
||||
|
||||
const fmt = (n) => n.toLocaleString("en-US");
|
||||
const medal = (i) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : String(i + 1));
|
||||
|
||||
/** Render the whole `## [version]` section (header → stats → ranking → three sections). */
|
||||
export function renderSection({
|
||||
version,
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
rows,
|
||||
all,
|
||||
ranking,
|
||||
prNumbers,
|
||||
dateSuffix = "TBD",
|
||||
}) {
|
||||
const bullets = Object.values(all)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- "));
|
||||
const byType = {};
|
||||
const SEC_DEFAULT = { features: "feat", fixes: "fix", maintenance: "chore" };
|
||||
for (const k of Object.keys(all)) {
|
||||
for (const b of all[k].filter((x) => x.startsWith("- "))) {
|
||||
let t = typeOfBullet(b);
|
||||
if (!TYPE_LABEL[t] || t === "other") t = SEC_DEFAULT[k];
|
||||
byType[TYPE_LABEL[t]] = (byType[TYPE_LABEL[t]] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const prRefs = new Set();
|
||||
const handles = new Set();
|
||||
for (const b of bullets) {
|
||||
for (const n of refsIn(normalizeLinks(b))) if (prNumbers.has(n)) prRefs.add(n);
|
||||
for (const m of normalizeLinks(b).matchAll(/@([A-Za-z0-9_-]+)/g)) handles.add(m[1]);
|
||||
}
|
||||
const humans = rows.filter((r) => !BOT_RE.test(r.authorName));
|
||||
const people = new Set([...Object.keys(ranking.counts), ...handles].map((x) => x.toLowerCase()));
|
||||
const lines = [
|
||||
`## [${version}] — ${dateSuffix}`,
|
||||
``,
|
||||
`_Living section — reconciled ${today} from all cycle commits (\`${baseTip}\` → \`${headTip}\`, ${fmt(rows.length)} non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each \`/generate-release\` phase._`,
|
||||
``,
|
||||
`### 📊 Release by the numbers`,
|
||||
``,
|
||||
`| | |`,
|
||||
`| --- | ---: |`,
|
||||
`| 👥 People who contributed | **${fmt(people.size)}** |`,
|
||||
`| 📝 Commits in the cycle | **${fmt(rows.length)}** |`,
|
||||
`| 🔀 Pull requests referenced | **${fmt(prRefs.size)}** |`,
|
||||
`| 📋 Changelog entries | **${fmt(bullets.length)}** |`,
|
||||
`| 🙌 Contributors credited in entries | **${fmt(handles.size)}** |`,
|
||||
`| 🤖 Automated dependency commits | ${rows.length - humans.length} |`,
|
||||
``,
|
||||
`**Entries by type**`,
|
||||
``,
|
||||
`| Type | Count |`,
|
||||
`| --- | ---: |`,
|
||||
...Object.entries(byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([t, c]) => `| ${t} | ${c} |`),
|
||||
``,
|
||||
`### 🏆 Top 25 contributors this cycle`,
|
||||
``,
|
||||
`_By commits in \`${baseTip}..${headTip}\`, author identities consolidated via \`.mailmap\` and the merged PR's GitHub login. Bots excluded._`,
|
||||
``,
|
||||
`| # | Contributor | Commits |`,
|
||||
`| ---: | --- | ---: |`,
|
||||
...ranking.top.map(([n, c], i) => `| ${medal(i)} | ${n} | ${c} |`),
|
||||
``,
|
||||
];
|
||||
for (const k of ["features", "fixes", "maintenance"]) {
|
||||
lines.push(SECTION_HEADINGS[k], "", ...all[k], "");
|
||||
}
|
||||
return `${lines.join("\n")}\n---\n`;
|
||||
}
|
||||
|
||||
// ───────────────────────────── data acquisition (git + gh) ─────────────────────────────
|
||||
|
||||
const git = (args, cwd = ROOT) =>
|
||||
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }).trim();
|
||||
const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 28 });
|
||||
|
||||
export function repoSlug(cwd = ROOT) {
|
||||
const url = git(["remote", "get-url", "origin"], cwd);
|
||||
const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
||||
return m ? m[1] : "diegosouzapw/OmniRoute";
|
||||
}
|
||||
|
||||
/** Non-merge commits in `base..head` with mailmap identities, PR number and co-author trailers. */
|
||||
export function readCommits(base, head, cwd = ROOT) {
|
||||
const raw = git(
|
||||
[
|
||||
"log",
|
||||
"--no-merges",
|
||||
"--use-mailmap",
|
||||
"--date=short",
|
||||
"--format=%H%x1f%ad%x1f%aN%x1f%aE%x1f%s%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1e)%x1e%x1e",
|
||||
`${base}..${head}`,
|
||||
],
|
||||
cwd
|
||||
);
|
||||
return raw
|
||||
.split("\x1e\x1e")
|
||||
.map((s) => s.replace(/^\n/, ""))
|
||||
.filter((s) => s.trim())
|
||||
.map((r) => {
|
||||
const [hash, date, authorName, authorEmail, subject, coa] = r.split("\x1f");
|
||||
const refs = refsIn(subject);
|
||||
const prMatch = subject.match(/\(#(\d+)\)\s*$/);
|
||||
return {
|
||||
hash,
|
||||
date,
|
||||
authorName,
|
||||
authorEmail,
|
||||
subject,
|
||||
refs,
|
||||
pr: prMatch ? Number(prMatch[1]) : null,
|
||||
prAuthor: null,
|
||||
coauthors: (coa || "")
|
||||
.split("\x1e")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMergedPrs(repo, releaseBranch) {
|
||||
return JSON.parse(
|
||||
gh([
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--state",
|
||||
"merged",
|
||||
"--base",
|
||||
releaseBranch,
|
||||
"--limit",
|
||||
"1000",
|
||||
"--json",
|
||||
"number,title,author,body,closingIssuesReferences,mergedAt",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchPr(repo, n) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
String(n),
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"number,title,author,body,closingIssuesReferences",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragment files at `ref` + the commit that ADDED each one (definitive origin of the credit). */
|
||||
export function readFragments(ref = "HEAD", cwd = ROOT) {
|
||||
const out = [];
|
||||
for (const dir of Object.keys(SECTION_HEADINGS)) {
|
||||
let files = "";
|
||||
try {
|
||||
files = git(["ls-tree", "--name-only", ref, `changelog.d/${dir}/`], cwd);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files
|
||||
.split("\n")
|
||||
.filter((x) => x && !/README\.md$|\.gitkeep$/.test(x))
|
||||
.sort()) {
|
||||
const text = git(["show", `${ref}:${f}`], cwd);
|
||||
const origin =
|
||||
git(["log", "--diff-filter=A", "--format=%h%x09%s", "--", f], cwd)
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.pop() || "";
|
||||
const [ohash, osubject = ""] = origin.split("\t");
|
||||
const om = osubject.match(/\(#(\d+)\)\s*$/);
|
||||
const pm = f.match(/\/(\d{4,6})-/);
|
||||
out.push({
|
||||
path: f,
|
||||
section: dir,
|
||||
text,
|
||||
originHash: ohash ? ohash.slice(0, 9) : null,
|
||||
originPr: om ? Number(om[1]) : null,
|
||||
prefixPr: pm ? Number(pm[1]) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────────────────────────── the reconciliation itself ─────────────────────────────
|
||||
|
||||
export function reconcile({
|
||||
changelog,
|
||||
version,
|
||||
repo,
|
||||
rows,
|
||||
prs,
|
||||
fragments,
|
||||
extraPrs = new Map(),
|
||||
credits = {},
|
||||
carriers = new Set(),
|
||||
dropFragments = new Set(),
|
||||
fragmentPr = {},
|
||||
fragmentCredit = {},
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
skipHashes = new Set(),
|
||||
special = {},
|
||||
}) {
|
||||
const prBy = new Map(prs.map((p) => [p.number, p]));
|
||||
for (const [n, p] of extraPrs) if (!prBy.has(n)) prBy.set(n, p);
|
||||
const prAuthor = (n) => prBy.get(n)?.author?.login || null;
|
||||
for (const r of rows) r.prAuthor = r.pr ? prAuthor(r.pr) : null;
|
||||
const issueToPrs = new Map();
|
||||
for (const p of prs)
|
||||
for (const ci of p.closingIssuesReferences || []) {
|
||||
if (!issueToPrs.has(ci.number)) issueToPrs.set(ci.number, []);
|
||||
issueToPrs.get(ci.number).push(p.number);
|
||||
}
|
||||
|
||||
const range = versionSectionRange(changelog, version);
|
||||
if (!range) throw new Error(`CHANGELOG.md has no "## [${version}]" section`);
|
||||
const existing = parseBlocks(changelog.slice(range.bodyStart, range.end));
|
||||
const shippedElsewhere = normalizeLinks(otherSectionsText(changelog, version));
|
||||
|
||||
const blocks = {
|
||||
features: [...existing.features],
|
||||
fixes: [...existing.fixes],
|
||||
maintenance: [...existing.maintenance],
|
||||
};
|
||||
const srcOf = new Map();
|
||||
for (const f of fragments) {
|
||||
const parsed = parseBlocks(f.text, { fixedSection: f.section, collapse: true });
|
||||
for (const b of parsed[f.section]) {
|
||||
blocks[f.section].push(b);
|
||||
srcOf.set(b, f);
|
||||
}
|
||||
}
|
||||
|
||||
const dropped = [];
|
||||
const patched = [];
|
||||
const mismatches = [];
|
||||
const originHashes = new Set();
|
||||
for (const k of Object.keys(blocks)) {
|
||||
const out = [];
|
||||
for (let b of blocks[k]) {
|
||||
const frag = srcOf.get(b);
|
||||
const first = b.split("\n")[0].trim();
|
||||
if (frag && dropFragments.has(frag.path)) {
|
||||
dropped.push({ why: "dropped by --drop-fragment", first, src: frag.path });
|
||||
continue;
|
||||
}
|
||||
const probe = normalizeLinks(first)
|
||||
.replace(/\s*\(#\d+.*$/, "")
|
||||
.slice(0, 90);
|
||||
if (frag && probe.length > 40 && shippedElsewhere.includes(probe)) {
|
||||
dropped.push({
|
||||
why: "text already shipped in another version section",
|
||||
first,
|
||||
src: frag.path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (frag) {
|
||||
const refs0 = refsIn(normalizeLinks(b));
|
||||
const override = fragmentPr[frag.path];
|
||||
const oPr = frag.originPr;
|
||||
const fPr = frag.prefixPr;
|
||||
let defPrs = override
|
||||
? [override]
|
||||
: oPr && prBy.has(oPr) && !carriers.has(oPr)
|
||||
? [oPr]
|
||||
: fPr && prBy.has(fPr)
|
||||
? [fPr]
|
||||
: oPr && prBy.has(oPr)
|
||||
? [oPr]
|
||||
: [];
|
||||
if (!defPrs.length)
|
||||
for (const n of refs0) for (const pr of issueToPrs.get(n) || []) defPrs.push(pr);
|
||||
defPrs = [...new Set(defPrs)];
|
||||
if (oPr && fPr && prBy.has(fPr) && oPr !== fPr) {
|
||||
mismatches.push(
|
||||
`${frag.path}: prefix #${fPr} (${prAuthor(fPr)}) vs origin #${oPr} (${prAuthor(oPr)}) → used #${defPrs.join("/")}`
|
||||
);
|
||||
}
|
||||
if (b.includes("#PR_NUMBER") && defPrs.length) {
|
||||
b = b
|
||||
.replace(/\[#PR_NUMBER\]\([^)]*\)/g, prLink(repo, defPrs[0]))
|
||||
.replace(/#PR_NUMBER/g, `#${defPrs[0]}`);
|
||||
}
|
||||
for (const h of fragmentCredit[frag.path] || []) b = addCredit(b, [h]);
|
||||
for (const n of defPrs) {
|
||||
const before = b;
|
||||
b = appendLink(b, repo, n);
|
||||
b = addCredit(b, [prAuthor(n), ...(credits[n] || [])]);
|
||||
if (b !== before) patched.push(`${frag.path} → #${n} @${prAuthor(n)}`);
|
||||
}
|
||||
if (!override && !(oPr && carriers.has(oPr)) && frag.originHash)
|
||||
originHashes.add(frag.originHash);
|
||||
}
|
||||
out.push(b);
|
||||
}
|
||||
blocks[k] = out;
|
||||
}
|
||||
|
||||
const allExisting = Object.values(blocks)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- "));
|
||||
const closingPrs = new Set();
|
||||
const citedIssues = new Set(allExisting.flatMap((b) => refsIn(normalizeLinks(b))));
|
||||
for (const p of prs)
|
||||
for (const ci of p.closingIssuesReferences || [])
|
||||
if (citedIssues.has(ci.number)) closingPrs.add(p.number);
|
||||
const { uncovered } = computeCoverage(rows, allExisting, {
|
||||
closingPrs,
|
||||
originHashes,
|
||||
skipHashes,
|
||||
});
|
||||
|
||||
const gen = { features: [], fixes: [], maintenance: [] };
|
||||
const deps = [];
|
||||
for (const r of [...uncovered].sort(
|
||||
(a, b) => (a.pr || 0) - (b.pr || 0) || a.date.localeCompare(b.date)
|
||||
)) {
|
||||
if (BOT_RE.test(r.authorName)) {
|
||||
deps.push(r);
|
||||
continue;
|
||||
}
|
||||
const c = bulletFromSubject(r.subject, special);
|
||||
let bullet = `- ${c.text}`;
|
||||
bullet += r.pr ? ` (${prLink(repo, r.pr)})` : ` (direct commit \`${r.hash.slice(0, 10)}\`)`;
|
||||
bullet = addCredit(bullet, [
|
||||
r.pr ? prAuthor(r.pr) : null,
|
||||
...(r.pr ? credits[r.pr] || [] : []),
|
||||
]);
|
||||
gen[sectionForType(c.type)].push(bullet);
|
||||
}
|
||||
if (deps.length) {
|
||||
gen.maintenance.push(
|
||||
`- **deps:** ${deps.length} Dependabot bumps — ${deps
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.subject.replace(/^deps(\([^)]*\))?:\s*/, "").replace(/\s*\(#\d+\)\s*$/, "")} (${prLink(repo, r.pr)})`
|
||||
)
|
||||
.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const all = {};
|
||||
const dedupDropped = [];
|
||||
for (const k of Object.keys(blocks)) {
|
||||
const existingSet = new Set(existing[k]);
|
||||
const merged = [...blocks[k], ...gen[k]];
|
||||
const kept = merged.filter((b) => existingSet.has(b));
|
||||
const { kept: rest, dropped: dd } = dedupeBullets(merged.filter((b) => !existingSet.has(b)));
|
||||
dedupDropped.push(...dd);
|
||||
all[k] = [...kept, ...rest];
|
||||
}
|
||||
|
||||
const ranking = rankAuthors(rows);
|
||||
const section = renderSection({
|
||||
version,
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
rows,
|
||||
all,
|
||||
ranking,
|
||||
prNumbers: new Set(prBy.keys()),
|
||||
});
|
||||
const next = `${changelog.slice(0, range.start)}${section}${changelog.slice(range.end + 1)}`;
|
||||
const report = {
|
||||
version,
|
||||
baseTip,
|
||||
headTip,
|
||||
commits: rows.length,
|
||||
existing: Object.fromEntries(Object.entries(existing).map(([k, v]) => [k, v.length])),
|
||||
fragments: fragments.length,
|
||||
generated: Object.fromEntries(Object.entries(gen).map(([k, v]) => [k, v.length])),
|
||||
bullets: Object.values(all)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- ")).length,
|
||||
dropped,
|
||||
patched: patched.length,
|
||||
mismatches,
|
||||
dedupDropped,
|
||||
uncovered: uncovered.length,
|
||||
depsRolled: deps.length,
|
||||
ranking: ranking.top,
|
||||
};
|
||||
return { changelog: next, report };
|
||||
}
|
||||
|
||||
// ───────────────────────────── CLI ─────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = {
|
||||
credit: {},
|
||||
carriers: new Set(),
|
||||
dropFragments: new Set(),
|
||||
fragmentPr: {},
|
||||
dryRun: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const v = () => argv[++i];
|
||||
if (a === "--version") o.version = v();
|
||||
else if (a === "--base") o.base = v();
|
||||
else if (a === "--head") o.head = v();
|
||||
else if (a === "--release-branch") o.releaseBranch = v();
|
||||
else if (a === "--prs") o.prs = v();
|
||||
else if (a === "--report") o.report = v();
|
||||
else if (a === "--dry-run") o.dryRun = true;
|
||||
else if (a === "--carrier") o.carriers.add(Number(v()));
|
||||
else if (a === "--drop-fragment") o.dropFragments.add(v());
|
||||
else if (a === "--credit") {
|
||||
const [n, hs] = v().split("=");
|
||||
o.credit[Number(n)] = hs.split(",").map((h) => h.replace(/^@/, ""));
|
||||
} else if (a === "--fragment-pr") {
|
||||
const [p, n] = v().split("=");
|
||||
o.fragmentPr[p] = Number(n);
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const o = parseArgs(argv);
|
||||
const version =
|
||||
o.version || JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||
const releaseBranch = o.releaseBranch || `release/v${version}`;
|
||||
const head = o.head || "HEAD";
|
||||
let base = o.base;
|
||||
if (!base) {
|
||||
const { base: open, source } = resolveCycleBase(version);
|
||||
base = `${open}^`;
|
||||
console.log(`[reconcile-changelog] base = parent of the ${source} commit ${open.slice(0, 10)}`);
|
||||
}
|
||||
const repo = repoSlug();
|
||||
const rows = readCommits(base, head);
|
||||
const prs = o.prs
|
||||
? JSON.parse(fs.readFileSync(o.prs, "utf8"))
|
||||
: fetchMergedPrs(repo, releaseBranch);
|
||||
const known = new Set(prs.map((p) => p.number));
|
||||
const extraPrs = new Map();
|
||||
for (const n of new Set(rows.filter((r) => r.pr && !known.has(r.pr)).map((r) => r.pr))) {
|
||||
const p = fetchPr(repo, n);
|
||||
if (p) extraPrs.set(n, p);
|
||||
}
|
||||
const fragments = readFragments(head);
|
||||
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
|
||||
const baseTip = git(["rev-parse", "--short=10", base]);
|
||||
const headTip = git(["rev-parse", "--short=10", head]);
|
||||
// the cycle-open bump and the living-section restore are the only legitimate non-bullet commits
|
||||
const skipHashes = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(r) =>
|
||||
/^chore\(release\): (open v[\d.]+ development cycle|restore the living)/.test(
|
||||
r.subject
|
||||
) || /^Release v[\d.]+$/.test(r.subject)
|
||||
)
|
||||
.map((r) => r.hash.slice(0, 9))
|
||||
);
|
||||
const { changelog: next, report } = reconcile({
|
||||
changelog,
|
||||
version,
|
||||
repo,
|
||||
rows,
|
||||
prs,
|
||||
fragments,
|
||||
extraPrs,
|
||||
credits: o.credit,
|
||||
carriers: o.carriers,
|
||||
dropFragments: o.dropFragments,
|
||||
fragmentPr: o.fragmentPr,
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
baseTip,
|
||||
headTip,
|
||||
skipHashes,
|
||||
});
|
||||
if (!o.dryRun) {
|
||||
fs.writeFileSync(path.join(ROOT, "CHANGELOG.md"), next);
|
||||
for (const f of fragments) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(ROOT, f.path));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (o.report) fs.writeFileSync(o.report, JSON.stringify(report, null, 1));
|
||||
const { dropped, mismatches, dedupDropped, ranking, ...summary } = report;
|
||||
console.log(`[reconcile-changelog] ${o.dryRun ? "(dry-run) " : ""}${JSON.stringify(summary)}`);
|
||||
for (const d of dropped) console.log(` dropped: ${d.why} — ${d.first.slice(0, 90)}`);
|
||||
for (const m of mismatches) console.log(` review: ${m}`);
|
||||
for (const d of dedupDropped) console.log(` deduped: ${d.slice(0, 90)}`);
|
||||
console.log(
|
||||
`[reconcile-changelog] next: npm run release:contributors -- ${version} --inject && npx prettier --write CHANGELOG.md && npm run release:sync-changelog-i18n -- ${version} <prev> && npm run check:changelog-integrity`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -189,3 +189,63 @@ test("SECTIONS maps every dir to a real living-section heading in the fixture",
|
||||
assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("insertBullets with a version anchors on THAT section even when [Unreleased] has the same heading", () => {
|
||||
const withUnreleasedHeading = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **unreleased**: leftover from an older cycle (#9)
|
||||
|
||||
## [3.8.47] — TBD
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **existing feature**: already here (#1 — thanks @a)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(x):** existing fix (#2 — thanks @b)
|
||||
|
||||
## [3.8.46] - 2026-07-04
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **old feature**: shipped (#0)
|
||||
`;
|
||||
const out = insertBullets(
|
||||
withUnreleasedHeading,
|
||||
{ features: [{ text: "- **new**: landed (#4)" }] },
|
||||
"3.8.47"
|
||||
);
|
||||
const unreleased = out.slice(out.indexOf("## [Unreleased]"), out.indexOf("## [3.8.47]"));
|
||||
const living = out.slice(out.indexOf("## [3.8.47]"), out.indexOf("## [3.8.46]"));
|
||||
assert.ok(!unreleased.includes("landed (#4)"), "must not land under [Unreleased]");
|
||||
assert.ok(
|
||||
living.includes("- **existing feature**: already here (#1 — thanks @a)\n- **new**: landed (#4)")
|
||||
);
|
||||
assert.throws(
|
||||
() => insertBullets(withUnreleasedHeading, { features: [{ text: "- x" }] }, "9.9.9"),
|
||||
/section "## \[9\.9\.9\]" not found/
|
||||
);
|
||||
});
|
||||
|
||||
test("aggregate reads the living version from package.json and lands fragments under it", () => {
|
||||
const root = makeRoot({
|
||||
fragments: { "features/9-new.md": "- **new**: from a fragment (#9)\n" },
|
||||
});
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ version: "3.8.47" }));
|
||||
const before = readFileSync(join(root, "CHANGELOG.md"), "utf8");
|
||||
const withHeading = before.replace(
|
||||
"## [Unreleased]\n",
|
||||
"## [Unreleased]\n\n### ✨ New Features\n\n- stale (#8)\n"
|
||||
);
|
||||
writeFileSync(join(root, "CHANGELOG.md"), withHeading);
|
||||
aggregate({ root });
|
||||
const after = readFileSync(join(root, "CHANGELOG.md"), "utf8");
|
||||
const unreleased = after.slice(after.indexOf("## [Unreleased]"), after.indexOf("## [3.8.47]"));
|
||||
assert.ok(!unreleased.includes("from a fragment (#9)"));
|
||||
assert.ok(after.slice(after.indexOf("## [3.8.47]")).includes("from a fragment (#9)"));
|
||||
});
|
||||
|
||||
431
tests/unit/reconcile-changelog.test.ts
Normal file
431
tests/unit/reconcile-changelog.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../scripts/release/reconcile-changelog.mjs");
|
||||
const {
|
||||
primaryRefs,
|
||||
parseBlocks,
|
||||
addCredit,
|
||||
appendLink,
|
||||
bulletFromSubject,
|
||||
sectionForType,
|
||||
dedupeBullets,
|
||||
computeCoverage,
|
||||
versionSectionRange,
|
||||
otherSectionsText,
|
||||
rankAuthors,
|
||||
reconcile,
|
||||
} = mod;
|
||||
|
||||
const REPO = "diegosouzapw/OmniRoute";
|
||||
const link = (n: number) => `[#${n}](https://github.com/${REPO}/pull/${n})`;
|
||||
|
||||
test("primaryRefs: /pull links and the LAST #N count, incidental mentions do not", () => {
|
||||
const b = `- **test:** counts after additions — Opper #11629 + 1min.ai #11631 (${link(11674)}) — thanks @x`;
|
||||
assert.deepEqual([...primaryRefs(b)].sort(), [11674]);
|
||||
assert.deepEqual(
|
||||
[...primaryRefs("- fix(x): thing (#11766) (#11794)")].sort(),
|
||||
[11766, 11794],
|
||||
"explicit (#N) groups count"
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...primaryRefs("- **security:** verbatim,\n across two lines (#12457)")],
|
||||
[12457],
|
||||
"last ref of a multi-line block"
|
||||
);
|
||||
assert.deepEqual([...primaryRefs("- plain sentence, no refs")], []);
|
||||
});
|
||||
|
||||
test("parseBlocks: sections by heading, continuation lines kept; fragments collapse to one line", () => {
|
||||
const text = `### ✨ New Features
|
||||
|
||||
- **feat(a):** one
|
||||
continued here
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(b):** two
|
||||
- **fix(c):** three
|
||||
`;
|
||||
const p = parseBlocks(text);
|
||||
assert.equal(p.features.length, 1);
|
||||
assert.equal(p.features[0], "- **feat(a):** one\n continued here");
|
||||
assert.deepEqual(p.fixes, ["- **fix(b):** two", "- **fix(c):** three"]);
|
||||
const frag = parseBlocks("- **fix(d):** wrapped\n tail line\n", {
|
||||
fixedSection: "fixes",
|
||||
collapse: true,
|
||||
});
|
||||
assert.deepEqual(frag.fixes, ["- **fix(d):** wrapped tail line"]);
|
||||
});
|
||||
|
||||
test("addCredit: appends, extends a trailing thanks group, skips maintainer and already-mentioned handles", () => {
|
||||
assert.equal(addCredit("- x (#1)", ["alice"]), "- x (#1) — thanks @alice");
|
||||
assert.equal(addCredit("- x (#1) — thanks @alice", ["bob"]), "- x (#1) — thanks @alice / @bob");
|
||||
assert.equal(addCredit("- x (#1 — thanks @alice)", ["bob"]), "- x (#1 — thanks @alice / @bob)");
|
||||
assert.equal(
|
||||
addCredit("- x (#1) — thanks @alice", ["alice", "diegosouzapw"]),
|
||||
"- x (#1) — thanks @alice"
|
||||
);
|
||||
assert.equal(
|
||||
addCredit("- x by @alice-dev", ["alice"]),
|
||||
"- x by @alice-dev — thanks @alice",
|
||||
"handle boundary is exact"
|
||||
);
|
||||
});
|
||||
|
||||
test("appendLink: adds the PR link before a trailing thanks group and never twice", () => {
|
||||
const once = appendLink("- x — thanks @alice", REPO, 12);
|
||||
assert.equal(once, `- x (${link(12)}) — thanks @alice`);
|
||||
assert.equal(appendLink(once, REPO, 12), once);
|
||||
assert.equal(appendLink("- y", REPO, 3), `- y (${link(3)})`);
|
||||
});
|
||||
|
||||
test("bulletFromSubject: bolds the conventional prefix, strips (#N) and URGENT, maps specials", () => {
|
||||
assert.deepEqual(bulletFromSubject("fix(cli): thing (#11794)"), {
|
||||
type: "fix",
|
||||
text: "**fix(cli):** thing",
|
||||
});
|
||||
assert.deepEqual(bulletFromSubject("[URGENT] fix(dev): hot (#1)"), {
|
||||
type: "fix",
|
||||
text: "**fix(dev):** hot",
|
||||
});
|
||||
assert.deepEqual(bulletFromSubject("Update README.md (#5)"), {
|
||||
type: "other",
|
||||
text: "Update README.md",
|
||||
});
|
||||
assert.deepEqual(
|
||||
bulletFromSubject("Update README.md (#5)", { "Update README.md": "docs(readme): touch-ups" }),
|
||||
{
|
||||
type: "docs",
|
||||
text: "**docs(readme):** touch-ups",
|
||||
}
|
||||
);
|
||||
assert.equal(sectionForType("feat"), "features");
|
||||
assert.equal(sectionForType("perf"), "fixes");
|
||||
assert.equal(sectionForType("docs"), "maintenance");
|
||||
});
|
||||
|
||||
test("dedupeBullets: keeps the best-linked twin and merges the other twin's refs", () => {
|
||||
const a = `- **fix(glm):** drop the extra arg (${link(12770)})`;
|
||||
const b = "- **fix(glm):** drop the extra arg";
|
||||
const c = `- **test(check):** escape the fixture (direct commit \`fb7445eaa1\`)`;
|
||||
const d = `- **test(check):** escape the fixture (${link(11942)})`;
|
||||
const { kept, dropped } = dedupeBullets([b, a, c, d]);
|
||||
assert.deepEqual(dropped, [b, c]);
|
||||
assert.equal(kept.length, 2);
|
||||
assert.equal(kept[0], a);
|
||||
assert.ok(
|
||||
kept[1].includes(link(11942)) && kept[1].includes("direct commit `fb7445eaa1`"),
|
||||
"merged the twin's hash"
|
||||
);
|
||||
});
|
||||
|
||||
test("computeCoverage: own PR must be a primary ref; incidental mention leaves the commit uncovered", () => {
|
||||
const rows = [
|
||||
{ hash: "aaaaaaaaa1", pr: 11631, refs: [11631], subject: "feat: 1min (#11631)" },
|
||||
{ hash: "bbbbbbbbb2", pr: 11674, refs: [11674], subject: "test: counts (#11674)" },
|
||||
{ hash: "ccccccccc3", pr: null, refs: [11449], subject: "fix: direct (#11449)" },
|
||||
{ hash: "ddddddddd4", pr: 12000, refs: [12000], subject: "fix: closes issue (#12000)" },
|
||||
{ hash: "eeeeeeeee5", pr: null, refs: [], subject: "chore(release): open cycle" },
|
||||
{ hash: "f0f0f0f0f0abcdef", pr: null, refs: [], subject: "fix(ui): direct push" },
|
||||
];
|
||||
const bullets = [
|
||||
`- **test:** counts — 1min.ai #11631 (${link(11674)})`,
|
||||
"- **fix:** direct push fixing #11449",
|
||||
"- **fix:** the issue bullet cites #7000 only",
|
||||
"- **fix(ui):** direct push (direct commit `f0f0f0f0f0`)",
|
||||
];
|
||||
const { uncovered } = computeCoverage(rows, bullets, {
|
||||
closingPrs: new Set([12000]), // PR 12000 closes issue 7000, which is cited
|
||||
skipHashes: new Set(["eeeeeeeee"]),
|
||||
});
|
||||
assert.deepEqual(
|
||||
uncovered.map((r) => r.hash),
|
||||
["aaaaaaaaa1"],
|
||||
"only the incidentally-mentioned PR is uncovered"
|
||||
);
|
||||
});
|
||||
|
||||
test("versionSectionRange / otherSectionsText isolate exactly one version block", () => {
|
||||
const cl = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- old feature (#1)
|
||||
|
||||
## [3.8.51] — TBD
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix:** in section (#2)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.50] — 2026-08-25
|
||||
|
||||
- shipped (#3)
|
||||
`;
|
||||
const r = versionSectionRange(cl, "3.8.51");
|
||||
assert.ok(r && cl.slice(r.start, r.end).includes("in section (#2)"));
|
||||
assert.ok(!cl.slice(r.start, r.end).includes("shipped (#3)"));
|
||||
const other = otherSectionsText(cl, "3.8.51");
|
||||
assert.ok(
|
||||
other.includes("old feature (#1)") &&
|
||||
other.includes("shipped (#3)") &&
|
||||
!other.includes("in section (#2)")
|
||||
);
|
||||
assert.equal(versionSectionRange(cl, "9.9.9"), null);
|
||||
});
|
||||
|
||||
test("rankAuthors: consolidates display-name drift by PR login, excludes bots", () => {
|
||||
const rows = [
|
||||
{ authorName: "Webman", prAuthor: "jonlwheat2-gif" },
|
||||
{ authorName: "Webman", prAuthor: "jonlwheat2-gif" },
|
||||
{ authorName: "WebPerson", prAuthor: "jonlwheat2-gif" },
|
||||
{ authorName: "solo", prAuthor: null },
|
||||
{ authorName: "dependabot[bot]", prAuthor: null },
|
||||
];
|
||||
const { top, counts } = rankAuthors(rows);
|
||||
assert.deepEqual(top[0], ["Webman (@jonlwheat2-gif)", 3]);
|
||||
assert.deepEqual(top[1], ["solo", 1]);
|
||||
assert.equal("dependabot[bot]" in counts, false);
|
||||
});
|
||||
|
||||
test("reconcile: folds fragments under the version section, credits by fragment origin, drops shipped twins, generates the rest", () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(old):** shipped last cycle but never moved (#9000)
|
||||
|
||||
## [3.8.51] — TBD
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security(streaming):** pre-existing bullet stays verbatim,
|
||||
across two lines (#12457)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
---
|
||||
|
||||
## [3.8.50] — 2026-08-25
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(cli):** \`omniroute update\` now finds npm on Windows. It called execFile with no shell (#11335)
|
||||
|
||||
---
|
||||
`;
|
||||
const rows = [
|
||||
{
|
||||
hash: "a0ceccc6f0aaaa",
|
||||
date: "2026-08-25",
|
||||
authorName: "Burak",
|
||||
authorEmail: "b@x",
|
||||
subject: "feat(search): add Xquik (#11370)",
|
||||
refs: [11370],
|
||||
pr: 11370,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "b1b1b1b1b1bbbb",
|
||||
date: "2026-08-26",
|
||||
authorName: "Zero",
|
||||
authorEmail: "z@x",
|
||||
subject: "feat(providers): add 1min.ai provider (#11631)",
|
||||
refs: [11631],
|
||||
pr: 11631,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "c2c2c2c2c2cccc",
|
||||
date: "2026-08-26",
|
||||
authorName: "diegosouzapw",
|
||||
authorEmail: "d@x",
|
||||
subject: "feat(models): recreated (#11887)",
|
||||
refs: [11887],
|
||||
pr: 11887,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "d3d3d3d3d3dddd",
|
||||
date: "2026-08-27",
|
||||
authorName: "Markus Hartung",
|
||||
authorEmail: "m@x",
|
||||
subject: "fix(x): phantom fragment carrier (#11434)",
|
||||
refs: [11434],
|
||||
pr: 11434,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "e4e4e4e4e4eeee",
|
||||
date: "2026-08-27",
|
||||
authorName: "dependabot[bot]",
|
||||
authorEmail: "dep@x",
|
||||
subject: "deps: bump a from 1 to 2 (#11500)",
|
||||
refs: [11500],
|
||||
pr: 11500,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "f5f5f5f5f5ffff",
|
||||
date: "2026-08-28",
|
||||
authorName: "diegosouzapw",
|
||||
authorEmail: "d@x",
|
||||
subject: "chore(release): open v3.8.51 development cycle",
|
||||
refs: [],
|
||||
pr: null,
|
||||
coauthors: [],
|
||||
},
|
||||
{
|
||||
hash: "0606060606aaaa",
|
||||
date: "2026-08-29",
|
||||
authorName: "diegosouzapw",
|
||||
authorEmail: "d@x",
|
||||
subject: "fix(streaming): sanitize (#12457)",
|
||||
refs: [12457],
|
||||
pr: 12457,
|
||||
coauthors: [],
|
||||
},
|
||||
];
|
||||
const prs = [
|
||||
{
|
||||
number: 11631,
|
||||
title: "1min",
|
||||
author: { login: "zero-executioner" },
|
||||
body: "",
|
||||
closingIssuesReferences: [],
|
||||
},
|
||||
{
|
||||
number: 11887,
|
||||
title: "recreated",
|
||||
author: { login: "diegosouzapw" },
|
||||
body: "",
|
||||
closingIssuesReferences: [],
|
||||
},
|
||||
{
|
||||
number: 11434,
|
||||
title: "carrier",
|
||||
author: { login: "hartmark" },
|
||||
body: "",
|
||||
closingIssuesReferences: [],
|
||||
},
|
||||
{
|
||||
number: 11500,
|
||||
title: "bump",
|
||||
author: { login: "app/dependabot" },
|
||||
body: "",
|
||||
closingIssuesReferences: [],
|
||||
},
|
||||
{
|
||||
number: 12457,
|
||||
title: "sanitize",
|
||||
author: { login: "diegosouzapw" },
|
||||
body: "",
|
||||
closingIssuesReferences: [],
|
||||
},
|
||||
];
|
||||
const fragments = [
|
||||
// no-ref fragment whose origin commit is the contributor's own PR → link + credit from origin
|
||||
{
|
||||
path: "changelog.d/features/xquik.md",
|
||||
section: "features",
|
||||
text: "- **feat(search):** Add Xquik X search\n with typed results\n",
|
||||
originHash: "a0ceccc6f",
|
||||
originPr: 11370,
|
||||
prefixPr: null,
|
||||
},
|
||||
// fragment named after the CLOSED PR (11685) but landed by the recreated PR 11887 → origin wins, extra credit applies
|
||||
{
|
||||
path: "changelog.d/features/11685-antigravity.md",
|
||||
section: "features",
|
||||
text: "- Default Antigravity connections to auto-sync\n",
|
||||
originHash: "c2c2c2c2c",
|
||||
originPr: 11887,
|
||||
prefixPr: 11685,
|
||||
},
|
||||
// phantom fragment: text already shipped in [3.8.50] → dropped
|
||||
{
|
||||
path: "changelog.d/fixes/cli-update-npm-win32.md",
|
||||
section: "fixes",
|
||||
text: "- **fix(cli):** `omniroute update` now finds npm on Windows. It called execFile with no shell\n",
|
||||
originHash: "d3d3d3d3d",
|
||||
originPr: 11434,
|
||||
prefixPr: null,
|
||||
},
|
||||
];
|
||||
const { changelog: next, report } = reconcile({
|
||||
changelog,
|
||||
version: "3.8.51",
|
||||
repo: REPO,
|
||||
rows,
|
||||
prs,
|
||||
fragments,
|
||||
credits: { 11887: ["MumuTW"] },
|
||||
fragmentCredit: { "changelog.d/features/xquik.md": ["kriptoburak"] },
|
||||
today: "2026-09-07",
|
||||
baseTip: "0915890",
|
||||
headTip: "d6f3150",
|
||||
skipHashes: new Set(["f5f5f5f5f"]),
|
||||
});
|
||||
const section = next.slice(next.indexOf("## [3.8.51]"), next.indexOf("## [3.8.50]"));
|
||||
// structure
|
||||
assert.ok(
|
||||
section.includes("### 📊 Release by the numbers") &&
|
||||
section.includes("### 🏆 Top 25 contributors this cycle")
|
||||
);
|
||||
assert.ok(section.indexOf("### ✨ New Features") < section.indexOf("### 🐛 Bug Fixes"));
|
||||
// [Unreleased] and [3.8.50] untouched
|
||||
assert.ok(next.includes("- **feat(old):** shipped last cycle but never moved (#9000)"));
|
||||
assert.equal(
|
||||
(next.match(/now finds npm on Windows/g) || []).length,
|
||||
1,
|
||||
"phantom fragment dropped, original kept"
|
||||
);
|
||||
// pre-existing bullet verbatim (two lines)
|
||||
assert.ok(
|
||||
section.includes(
|
||||
"- **security(streaming):** pre-existing bullet stays verbatim,\n across two lines (#12457)"
|
||||
)
|
||||
);
|
||||
// fragment credited by origin + override, collapsed to one line
|
||||
assert.ok(
|
||||
section.includes(
|
||||
`- **feat(search):** Add Xquik X search with typed results — thanks @kriptoburak`
|
||||
),
|
||||
section
|
||||
);
|
||||
// recreated PR: link to the landing PR, credit to the original author
|
||||
assert.ok(
|
||||
section.includes(
|
||||
`- Default Antigravity connections to auto-sync (${link(11887)}) — thanks @MumuTW`
|
||||
),
|
||||
section
|
||||
);
|
||||
// uncovered commit generated with author credit; dependabot rolled up; cycle-open skipped
|
||||
assert.ok(
|
||||
section.includes(
|
||||
`- **feat(providers):** add 1min.ai provider (${link(11631)}) — thanks @zero-executioner`
|
||||
)
|
||||
);
|
||||
assert.ok(section.includes("- **deps:** 1 Dependabot bumps — bump a from 1 to 2"));
|
||||
assert.ok(!section.includes("open v3.8.51 development cycle"));
|
||||
// the carrier commit of the dropped phantom fragment is NOT silently covered → it gets its own bullet
|
||||
assert.ok(
|
||||
section.includes(`- **fix(x):** phantom fragment carrier (${link(11434)}) — thanks @hartmark`)
|
||||
);
|
||||
assert.equal(report.dropped.length, 1);
|
||||
assert.equal(report.generated.features, 1);
|
||||
assert.equal(
|
||||
report.generated.fixes,
|
||||
1,
|
||||
"12457's own commit is covered by the pre-existing multi-line bullet; only 11434 is generated"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user