From 86b1cb84feb495e4c040dcb9526304a00904fd3e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:11:13 -0300 Subject: [PATCH] feat(release): reconcile-changelog tool + version-anchored fragment aggregation (#12987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `## []` โ€” 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. --- package.json | 1 + scripts/release/aggregate-changelog.mjs | 40 +- scripts/release/reconcile-changelog.mjs | 831 ++++++++++++++++++++++++ tests/unit/changelog-fragments.test.ts | 60 ++ tests/unit/reconcile-changelog.test.ts | 431 ++++++++++++ 5 files changed, 1355 insertions(+), 8 deletions(-) create mode 100644 scripts/release/reconcile-changelog.mjs create mode 100644 tests/unit/reconcile-changelog.test.ts diff --git a/package.json b/package.json index f984ef4314..5cbcf71b42 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs index 7d490045f6..efc65d0ba3 100644 --- a/scripts/release/aggregate-changelog.mjs +++ b/scripts/release/aggregate-changelog.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)) { diff --git a/scripts/release/reconcile-changelog.mjs b/scripts/release/reconcile-changelog.mjs new file mode 100644 index 0000000000..a6e2d28739 --- /dev/null +++ b/scripts/release/reconcile-changelog.mjs @@ -0,0 +1,831 @@ +#!/usr/bin/env node +// scripts/release/reconcile-changelog.mjs +// +// Reconcile the living `## []` 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 -- --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 ] [--head ] +// [--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 ] [--dry-run] [--report ] +// +// Defaults: version = package.json; release branch = release/v; 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` 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 \`\`)`. */ +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} && npm run check:changelog-integrity` + ); + return 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts index 2dddf03613..7c503fa16d 100644 --- a/tests/unit/changelog-fragments.test.ts +++ b/tests/unit/changelog-fragments.test.ts @@ -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)")); +}); diff --git a/tests/unit/reconcile-changelog.test.ts b/tests/unit/reconcile-changelog.test.ts new file mode 100644 index 0000000000..83db5c7c24 --- /dev/null +++ b/tests/unit/reconcile-changelog.test.ts @@ -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" + ); +});