328 lines
7.7 KiB
TypeScript
328 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { $ } from "bun";
|
|
import { collectPromotableAddedItemLines, fixChangelogContent, runChangelogFixer } from "./fix-changelogs";
|
|
|
|
describe("collectPromotableAddedItemLines", () => {
|
|
it("keeps new changelog item additions while ignoring moves and edits", () => {
|
|
const diff = [
|
|
"diff --git a/packages/example/CHANGELOG.md b/packages/example/CHANGELOG.md",
|
|
"--- a/packages/example/CHANGELOG.md",
|
|
"+++ b/packages/example/CHANGELOG.md",
|
|
"@@ -10,0 +11,2 @@",
|
|
"+",
|
|
"+- Added after the latest tag in a released section.",
|
|
"@@ -20 +22 @@",
|
|
"-- Moved historical entry.",
|
|
"+- Moved historical entry.",
|
|
"@@ -30 +32,2 @@",
|
|
"-- Historical entry with old wording.",
|
|
"+- Historical entry with new wording.",
|
|
"+- Another brand-new item in the same hunk.",
|
|
].join("\n");
|
|
|
|
const lines = collectPromotableAddedItemLines(diff);
|
|
|
|
expect(lines.get("packages/example/CHANGELOG.md")).toEqual(new Set([12, 33]));
|
|
});
|
|
|
|
it("does not promote items from newly added release sections", () => {
|
|
const diff = [
|
|
"diff --git a/packages/example/CHANGELOG.md b/packages/example/CHANGELOG.md",
|
|
"--- a/packages/example/CHANGELOG.md",
|
|
"+++ b/packages/example/CHANGELOG.md",
|
|
"@@ -1,0 +1,8 @@",
|
|
"+# Changelog",
|
|
"+",
|
|
"+## [1.0.0] - 2026-01-01",
|
|
"+",
|
|
"+### Fixed",
|
|
"+",
|
|
"+- Released fix.",
|
|
"+- Another released fix.",
|
|
].join("\n");
|
|
|
|
const lines = collectPromotableAddedItemLines(diff);
|
|
|
|
expect(lines.get("packages/example/CHANGELOG.md")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("fixChangelogContent", () => {
|
|
it("moves added released-section items to Unreleased and merges duplicate category headings", () => {
|
|
const content = [
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"### Fixed",
|
|
"",
|
|
"- Existing fix.",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Second fix.",
|
|
"",
|
|
"## [1.0.0] - 2026-01-01",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Historical addition.",
|
|
"- New addition in released section.",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Historical fix.",
|
|
"- New fix in released section.",
|
|
"",
|
|
].join("\n");
|
|
|
|
const result = fixChangelogContent(content, new Set([17, 22]));
|
|
|
|
expect(result.promotedItems).toBe(2);
|
|
expect(result.mergedDuplicateHeadings).toBe(1);
|
|
expect(result.content).toBe(
|
|
[
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- New addition in released section.",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Existing fix.",
|
|
"- Second fix.",
|
|
"- New fix in released section.",
|
|
"",
|
|
"## [1.0.0] - 2026-01-01",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Historical addition.",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Historical fix.",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("drops Unreleased items that already appear verbatim in a released section", () => {
|
|
const content = [
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Brand-new unreleased feature.",
|
|
"- Added fullscreen settings mouse-event handling.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Added fullscreen settings mouse-event handling.",
|
|
"",
|
|
].join("\n");
|
|
|
|
const result = fixChangelogContent(content, new Set());
|
|
|
|
expect(result.droppedReleasedDuplicates).toBe(1);
|
|
expect(result.promotedItems).toBe(0);
|
|
expect(result.content).toBe(
|
|
[
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Brand-new unreleased feature.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Added fullscreen settings mouse-event handling.",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("can recover Unreleased by dropping bullets known to be historically released", () => {
|
|
const content = [
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Historical fix still stranded in Unreleased.",
|
|
"- Brand-new fix.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
].join("\n");
|
|
|
|
const result = fixChangelogContent(
|
|
content,
|
|
new Set(),
|
|
new Set(["- Historical fix still stranded in Unreleased."]),
|
|
);
|
|
|
|
expect(result.droppedReleasedDuplicates).toBe(1);
|
|
expect(result.content).toBe(
|
|
[
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Brand-new fix.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("compacts blank separators between adjacent bullet items", () => {
|
|
const content = [
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- First fix.",
|
|
"",
|
|
"- Second fix.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
].join("\n");
|
|
|
|
const result = fixChangelogContent(content, new Set());
|
|
|
|
expect(result.content).toBe(
|
|
[
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- First fix.",
|
|
"- Second fix.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("leaves a clean changelog untouched", () => {
|
|
const content = [
|
|
"# Changelog",
|
|
"",
|
|
"## [Unreleased]",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Only an unreleased feature.",
|
|
"",
|
|
"## [1.1.0] - 2026-02-01",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- A released feature.",
|
|
"",
|
|
].join("\n");
|
|
|
|
const result = fixChangelogContent(content, new Set());
|
|
|
|
expect(result.droppedReleasedDuplicates).toBe(0);
|
|
expect(result.content).toBe(content);
|
|
});
|
|
});
|
|
|
|
const RELEASED_ONLY = `# Changelog
|
|
|
|
## [Unreleased]
|
|
|
|
## [1.0.0] - 2025-01-01
|
|
|
|
### Fixed
|
|
|
|
- Old released bullet.
|
|
`;
|
|
|
|
const RELEASED_PLUS_RECOVERED = `# Changelog
|
|
|
|
## [Unreleased]
|
|
|
|
## [1.0.0] - 2025-01-01
|
|
|
|
### Fixed
|
|
|
|
- Old released bullet.
|
|
- Recovered bullet.
|
|
`;
|
|
|
|
describe("runChangelogFixer baseline pin", () => {
|
|
it("uses the clog baseline ref as the diff floor so a recovered released bullet is not re-promoted", async () => {
|
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clog-fix-"));
|
|
const git = (...args: string[]) =>
|
|
$`git ${args}`
|
|
.cwd(repoRoot)
|
|
.quiet()
|
|
.env({
|
|
...process.env,
|
|
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
GIT_AUTHOR_NAME: "t",
|
|
GIT_AUTHOR_EMAIL: "t@t",
|
|
GIT_COMMITTER_NAME: "t",
|
|
GIT_COMMITTER_EMAIL: "t@t",
|
|
});
|
|
try {
|
|
const changelogPath = path.join(repoRoot, "packages/foo/CHANGELOG.md");
|
|
await git("init", "-b", "main");
|
|
await Bun.write(changelogPath, RELEASED_ONLY);
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "release 1.0.0");
|
|
await git("tag", "v1.0.0");
|
|
|
|
// Simulate a `--recover` restoring a historically released bullet that the
|
|
// v1.0.0 snapshot no longer carries.
|
|
await Bun.write(changelogPath, RELEASED_PLUS_RECOVERED);
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "recover dropped bullet");
|
|
|
|
// No baseline tag: the floor is the latest version tag, which predates the
|
|
// recovery, so the restored released bullet reads as added-in-a-released
|
|
// section and is wrongly promoted back into [Unreleased].
|
|
const withoutPin = await runChangelogFixer({ repoRoot, write: false });
|
|
expect(withoutPin.since).toBe("v1.0.0");
|
|
const promoted = withoutPin.changedFiles.find(file => file.path === "packages/foo/CHANGELOG.md");
|
|
expect(promoted?.promotedItems).toBe(1);
|
|
|
|
// Pin `clog` (a custom ref, not a tag — see resolveSince) to the recovery
|
|
// commit: the plain run now diffs against it and leaves the bullet untouched.
|
|
await git("update-ref", "refs/clog", "HEAD");
|
|
const withPin = await runChangelogFixer({ repoRoot, write: false });
|
|
expect(withPin.since).toBe("refs/clog");
|
|
expect(withPin.changedFiles).toHaveLength(0);
|
|
} finally {
|
|
await fs.rm(repoRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|