Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pr-checks/bundle-changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ ${NO_CHANGES_STR}`;
describe("updateChangelog", async () => {
await it("removes `NO_CHANGES_STR` if present in [UNRELEASED] section", async () => {
const result = updateChangelog(EMPTY_CHANGELOG, "");
assert.ok(!result.includes(NO_CHANGES_STR.trim()));
assert.ok(!result.includes(NO_CHANGES_STR));
});

await it("doesn't remove `NO_CHANGES_STR` if present in versioned section", async () => {
const result = updateChangelog(
EMPTY_CHANGELOG.replace(UNRELEASED_PLACEHOLDER, "1.2.3"),
"",
);
assert.ok(result.includes(NO_CHANGES_STR.trim()));
assert.ok(result.includes(NO_CHANGES_STR));
});

await it("throws if there are no sections", async () => {
Expand Down
99 changes: 99 additions & 0 deletions pr-checks/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,46 @@ import * as fs from "node:fs";
import { describe, it } from "node:test";

import {
addBodyLinesToUnreleasedSection,
ChangelogSection,
EMPTY_CHANGELOG,
getHeader,
getReleaseDateString,
NO_CHANGES_STR,
parseChangelog,
processChangelogForBackports,
renderChangelog,
setVersionAndDate,
UNRELEASED_PLACEHOLDER,
} from "./changelog";
import { CHANGELOG_FILE } from "./config";

const testDate = new Date(2026, 7, 14);

describe("getHeader", async () => {
function Section(headerLine: string): ChangelogSection {
return {
headerLine,
bodyLines: [],
};
}
await it("returns non-headers unchanged", () => {
assert.equal("foo", getHeader(Section("foo")));
assert.equal("- bar", getHeader(Section("- bar")));
});
await it("strips octothorpes", async () => {
assert.equal("foo", getHeader(Section("# foo")));
assert.equal("foo", getHeader(Section("## foo")));
assert.equal("foo", getHeader(Section("### foo")));
assert.equal("foo", getHeader(Section("#### foo")));
assert.equal("foo", getHeader(Section("##### foo")));
assert.equal("foo", getHeader(Section("###### foo")));
});
await it("strips whitespace", async () => {
assert.equal("foo", getHeader(Section("# foo ")));
});
});

describe("getReleaseDateString", async () => {
await it("formats dates as expected", async () => {
assert.equal(getReleaseDateString(testDate), "14 Aug 2026");
Expand Down Expand Up @@ -70,3 +99,73 @@ describe("processChangelogForBackports", async () => {
assert.deepEqual(result.split("\n"), testChangelogResult.split("\n"));
});
});

describe("addBodyLinesToUnreleasedSection", async () => {
function newChangelogWithSections(sections: ChangelogSection[]) {
return {
preamble: [],
sections,
};
}

await it("throws error if '[UNRELEASED]' section is not first", async () => {
const invalidChangelog = newChangelogWithSections([
{
headerLine: "## Release 1.0.0",
bodyLines: [],
},
{
headerLine: `## ${UNRELEASED_PLACEHOLDER}`,
bodyLines: [],
},
]);
assert.throws(() =>
addBodyLinesToUnreleasedSection(invalidChangelog, ["foo"]),
);
});

await it("overwrites 'No user facing changes.'", async () => {
const changelog = newChangelogWithSections([
{
headerLine: `## ${UNRELEASED_PLACEHOLDER}`,
bodyLines: ["", NO_CHANGES_STR, ""],
},
]);

addBodyLinesToUnreleasedSection(changelog, ["- foo"]);

assert.equal(changelog.sections[0].bodyLines.length, 3);
assert.deepEqual(changelog.sections[0].bodyLines, ["", "- foo", ""]);
});

await it("does nothing if lines is empty", async () => {
const changelog = newChangelogWithSections([
{
headerLine: `## ${UNRELEASED_PLACEHOLDER}`,
bodyLines: ["", NO_CHANGES_STR, ""],
},
]);
const changelogClone = structuredClone(changelog);

addBodyLinesToUnreleasedSection(changelog, []);

assert.deepEqual(changelog, changelogClone);
});

await it("inserts a line", async () => {
const changelog = newChangelogWithSections([
{
headerLine: `## ${UNRELEASED_PLACEHOLDER}`,
bodyLines: ["", "- Added a new dependency.", ""],
},
]);
const lineToInsert = "- foo";

addBodyLinesToUnreleasedSection(changelog, [lineToInsert]);

assert.equal(changelog.sections[0].bodyLines.length, 4);
assert.ok(
changelog.sections[0].bodyLines.some((line) => line === lineToInsert),
);
});
});
47 changes: 44 additions & 3 deletions pr-checks/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { CHANGELOG_FILE, DryRunOption } from "./config";
export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]";

/** The default contents for a section in the changelog. */
export const NO_CHANGES_STR = "No user facing changes.\n\n";
export const NO_CHANGES_STR = "No user facing changes.";

/** Placeholder changelog content for a new release. */
export const EMPTY_CHANGELOG = `# CodeQL Action Changelog

## ${UNRELEASED_PLACEHOLDER}

${NO_CHANGES_STR}`;
${NO_CHANGES_STR}

`;

/**
* Represents sections in a changelog.
Expand All @@ -31,6 +33,13 @@ export interface Changelog {
sections: ChangelogSection[];
}

/**
* Returns the text of the header (without the '## ' prefix) of the given section.
* */
export function getHeader(section: ChangelogSection): string {
return section.headerLine.replace(/^#+\s+/, "").trimEnd();
}

/** Returns `date` formatted as `DD Mon YYYY`. */
export function getReleaseDateString(today: Date = new Date()): string {
return today.toLocaleDateString("en-GB", {
Expand Down Expand Up @@ -125,6 +134,38 @@ export function parseChangelog(content: string): Changelog {
return { preamble, sections };
}

/**
* Inserts the changenotes `notes` under the `[UNRELEASED]` section of `changelog`.
* If the section contains the stock message {@link NO_CHANGES_STR}, then
* `notes` will be inserted in place and the stock message will be deleted.
*
* This function will throw an exception if `[UNRELEASED]` does not exist.
*
* @param changelog The CHANGELOG object to modify.
* @param lines The changenotes to insert.
*/
export function addBodyLinesToUnreleasedSection(
changelog: Changelog,
lines: string[],
) {
// Do nothing if there is nothing to insert.
if (lines.length === 0) return;

const unreleasedSection = changelog.sections[0];
if (getHeader(unreleasedSection) !== UNRELEASED_PLACEHOLDER) {
throw Error("'[UNRELEASED]' is not the first section of 'CHANGELOG.md'");
}

if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) {
unreleasedSection.bodyLines = ["", ...lines, ""];
return;
}

unreleasedSection.bodyLines.pop(); // Remove the last empty line.
unreleasedSection.bodyLines.push(...lines);
unreleasedSection.bodyLines.push("");
}

/**
* Combines an array of lines into a single string by adding line breaks.
*/
Expand Down Expand Up @@ -204,7 +245,7 @@ export function processChangelogForBackports(

// Add an entry if we didn't keep any.
if (!foundContent) {
section.bodyLines.push(NO_CHANGES_STR.trim());
section.bodyLines.push(NO_CHANGES_STR);
}
}

Expand Down
11 changes: 0 additions & 11 deletions pr-checks/changelog/validate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,3 @@ export function isValidChangenoteFile(filename: string): boolean {

return isValid;
}

/**
* Validates the change-note files of the given list of file paths, ignoring ".gitkeep".
* @param filepaths A list of filepaths to validate
* @returns True if all the paths are valid, false otherwise.
*/
export function isValidAllChangenoteFiles(filepaths: string[]): boolean {
return filepaths
.filter((f) => f !== ".gitkeep")
.reduce((r, filePath) => r && isValidChangenoteFile(filePath), true);
}
41 changes: 1 addition & 40 deletions pr-checks/changelog/validate.test.mts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, it } from "node:test";

import { withTmpDir, withTmpFile } from "../../src/util";
import { withTmpFile } from "../../src/util";

import {
hasValidChangenoteCategory,
isValidAllChangenoteFiles,
isValidChangenoteContent,
isValidChangenoteFile,
isValidChangenoteFilename,
Expand Down Expand Up @@ -187,39 +184,3 @@ await describe("isValidChangenoteFile", async () => {
);
});
});

await describe("isValidAllChangenoteFiles", async () => {
await it("accepts list of file paths of valid change-notes", async () => {
await withTmpDir(async (tmpDir) => {
const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md");
const fileName2 = path.join(tmpDir, "2026-01-02-add-feature.md");
fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n");
fs.writeFileSync(
fileName2,
"---\ncategory: feature\n---\n- Added a feature\n",
);
assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), true);
});
});

await it("accepts the empty list", async () => {
assert.equal(isValidAllChangenoteFiles([]), true);
});

await it("accepts list of .gitkeep", async () => {
assert.equal(isValidAllChangenoteFiles([".gitkeep"]), true);
});

await it("rejects list containing a file path to an invalid change-note", async () => {
await withTmpDir(async (tmpDir) => {
const fileName1 = path.join(tmpDir, "2026-01-01-fix-bug.md");
const fileName2 = path.join(tmpDir, "2026-01-02-wrong-category.md");
fs.writeFileSync(fileName1, "---\ncategory: fix\n---\n- Fixed a bug\n");
fs.writeFileSync(
fileName2,
"---\ncategory: foobar\n---\n- Added a feature\n",
);
assert.equal(isValidAllChangenoteFiles([fileName1, fileName2]), false);
});
});
});
Loading
Loading