945ccf4bb0
Recording fingerprints for bullets the model preserved verbatim made the next runChangelogFixer pass (release.ts step 5) treat the still-valid Unreleased bullet as already consumed and drop it from the release notes. Record after applyRewrite and skip items whose exact text survives in the rewritten section; add a verbatim-keep regression.
380 lines
10 KiB
TypeScript
380 lines
10 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,
|
|
parseChangelog,
|
|
parseItems,
|
|
recordSummarizedItemFingerprints,
|
|
renderChangelog,
|
|
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("drops stale source bullets after summarization changes their text", () => {
|
|
const sourceItem =
|
|
"- Fixed isolated `task` subagents mutating the parent checkout and stacking parallel task branches ([#6003](https://github.com/can1357/oh-my-pi/issues/6003)).";
|
|
const document = parseChangelog(
|
|
["# Changelog", "", "## [Unreleased]", "", "### Fixed", "", sourceItem, ""].join("\n"),
|
|
);
|
|
const unreleased = document.sections.find(section => section.title === "Unreleased");
|
|
if (!unreleased) throw new Error("fixture is missing Unreleased");
|
|
|
|
const sourceItems = unreleased.subsections.flatMap(sub => parseItems(sub.lines));
|
|
const summarizedItem =
|
|
"- Fixed isolated `task` subagents mutating the parent checkout by detaching the git directory.";
|
|
unreleased.subsections = [{ title: "Fixed", lines: [{ text: summarizedItem, lineNumber: 0 }] }];
|
|
recordSummarizedItemFingerprints(document, sourceItems, unreleased);
|
|
const staleMerge = renderChangelog(document).replace(summarizedItem, `${summarizedItem}\n${sourceItem}`);
|
|
|
|
const result = fixChangelogContent(staleMerge, new Set());
|
|
|
|
expect(result.droppedReleasedDuplicates).toBe(1);
|
|
expect(result.content).toContain(summarizedItem);
|
|
expect(result.content).not.toContain(sourceItem);
|
|
});
|
|
|
|
it("keeps bullets the rewrite preserved verbatim instead of dropping them as consumed", () => {
|
|
const keptItem = "- Fixed crash when opening the empty settings panel ([#1234](https://github.com/can1357/oh-my-pi/issues/1234)).";
|
|
const document = parseChangelog(
|
|
["# Changelog", "", "## [Unreleased]", "", "### Fixed", "", keptItem, ""].join("\n"),
|
|
);
|
|
const unreleased = document.sections.find(section => section.title === "Unreleased");
|
|
if (!unreleased) throw new Error("fixture is missing Unreleased");
|
|
|
|
const sourceItems = unreleased.subsections.flatMap(sub => parseItems(sub.lines));
|
|
// The model returned this bullet unchanged; applyRewrite writes it back.
|
|
unreleased.subsections = [{ title: "Fixed", lines: [{ text: keptItem, lineNumber: 0 }] }];
|
|
recordSummarizedItemFingerprints(document, sourceItems, unreleased);
|
|
const afterRewrite = renderChangelog(document);
|
|
expect(afterRewrite).not.toContain("changelog-source-item");
|
|
|
|
const result = fixChangelogContent(afterRewrite, new Set());
|
|
|
|
expect(result.droppedReleasedDuplicates).toBe(0);
|
|
expect(result.content).toContain(keptItem);
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|
|
});
|