Apply suggestion from @mbg

Co-authored-by: Michael B. Gale <mbg@github.com>
This commit is contained in:
Mario Campos
2026-09-21 16:28:52 -05:00
committed by GitHub
parent 771560691a
commit 598cda36cf

View File

@@ -1,253 +1,253 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import { CHANGELOG_FILE, DryRunOption } from "./config"; import { CHANGELOG_FILE, DryRunOption } from "./config";
/** The placeholder in the header for unreleased changes. */ /** The placeholder in the header for unreleased changes. */
export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]";
/** The default contents for a section in the changelog. */ /** The default contents for a section in the changelog. */
export const NO_CHANGES_STR = "No user facing changes."; export const NO_CHANGES_STR = "No user facing changes.";
/** Placeholder changelog content for a new release. */ /** Placeholder changelog content for a new release. */
export const EMPTY_CHANGELOG = `# CodeQL Action Changelog export const EMPTY_CHANGELOG = `# CodeQL Action Changelog
## ${UNRELEASED_PLACEHOLDER} ## ${UNRELEASED_PLACEHOLDER}
${NO_CHANGES_STR} ${NO_CHANGES_STR}
`; `;
/** /**
* Represents sections in a changelog. * Represents sections in a changelog.
*/ */
export interface ChangelogSection { export interface ChangelogSection {
headerLine: string; headerLine: string;
bodyLines: string[]; bodyLines: string[];
} }
/** /**
* Represents a changelog. * Represents a changelog.
*/ */
export interface Changelog { export interface Changelog {
preamble: string[]; preamble: string[];
sections: ChangelogSection[]; sections: ChangelogSection[];
} }
/** /**
* Returns the text of the header (without the '## ' prefix) of the given section. * Returns the text of the header (without the '## ' prefix) of the given section.
* */ * */
export function getHeader(section: ChangelogSection): string { export function getHeader(section: ChangelogSection): string {
return section.headerLine.replace(/^#+\s+/, "").trimEnd(); return section.headerLine.replace(/^#+\s+/, "").trimEnd();
} }
/** Returns `date` formatted as `DD Mon YYYY`. */ /** Returns `date` formatted as `DD Mon YYYY`. */
export function getReleaseDateString(today: Date = new Date()): string { export function getReleaseDateString(today: Date = new Date()): string {
return today.toLocaleDateString("en-GB", { return today.toLocaleDateString("en-GB", {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}); });
} }
export interface OpenChangelogOptions { export interface OpenChangelogOptions {
initChangelog?: boolean; initChangelog?: boolean;
} }
export function withChangelog( export function withChangelog(
transformer: (contents: string) => string, transformer: (contents: string) => string,
options: DryRunOption & OpenChangelogOptions, options: DryRunOption & OpenChangelogOptions,
): void { ): void {
let content: string; let content: string;
if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) {
content = EMPTY_CHANGELOG; content = EMPTY_CHANGELOG;
} else { } else {
content = fs.readFileSync(CHANGELOG_FILE, "utf8"); content = fs.readFileSync(CHANGELOG_FILE, "utf8");
} }
if (!options.dryRun) { if (!options.dryRun) {
fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8");
} else { } else {
console.info(`[DRY RUN] Would have written updated changelog.`); console.info(`[DRY RUN] Would have written updated changelog.`);
} }
} }
/** /**
* Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version
* and today's date. * and today's date.
*/ */
export function setVersionAndDate( export function setVersionAndDate(
version: string, version: string,
content: string, content: string,
date: Date = new Date(), date: Date = new Date(),
): string { ): string {
const versionAndDate = `${version} - ${getReleaseDateString(date)}`; const versionAndDate = `${version} - ${getReleaseDateString(date)}`;
return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate);
} }
/** /**
* Parses `content` into a structured representation of a changelog. * Parses `content` into a structured representation of a changelog.
* *
* @param content The contents of the changelog file. * @param content The contents of the changelog file.
*/ */
export function parseChangelog(content: string): Changelog { export function parseChangelog(content: string): Changelog {
const lines = content.split("\n"); const lines = content.split("\n");
let i = 0; let i = 0;
const preamble: string[] = []; const preamble: string[] = [];
const sections: ChangelogSection[] = []; const sections: ChangelogSection[] = [];
let currentSection: ChangelogSection | undefined = undefined; let currentSection: ChangelogSection | undefined = undefined;
// Process all lines of the input file. // Process all lines of the input file.
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
// Sections of the changelog start with `## `. // Sections of the changelog start with `## `.
if (line.startsWith("## ")) { if (line.startsWith("## ")) {
// We have discovered a new section. If `currentSection` is already defined, // We have discovered a new section. If `currentSection` is already defined,
// then this marks the end of that section. Push it to the array of sections // then this marks the end of that section. Push it to the array of sections
// in the changelog. // in the changelog.
if (currentSection !== undefined) { if (currentSection !== undefined) {
sections.push(currentSection); sections.push(currentSection);
} }
// Initialise the new section. // Initialise the new section.
currentSection = { headerLine: line, bodyLines: [] }; currentSection = { headerLine: line, bodyLines: [] };
} else if (currentSection !== undefined) { } else if (currentSection !== undefined) {
// Add lines between the section header and the next to the current section. // Add lines between the section header and the next to the current section.
currentSection.bodyLines.push(line); currentSection.bodyLines.push(line);
} else { } else {
// This is neither a section header nor are we in a section already, // This is neither a section header nor are we in a section already,
// so this line is part of the preamble. // so this line is part of the preamble.
preamble.push(line); preamble.push(line);
} }
i++; i++;
} }
// Push the current section to the array of completed sections, if there is // Push the current section to the array of completed sections, if there is
// still one unfinished. // still one unfinished.
if (currentSection !== undefined) { if (currentSection !== undefined) {
sections.push(currentSection); sections.push(currentSection);
} }
return { preamble, sections }; return { preamble, sections };
} }
/** /**
* Inserts the changenotes `lines` in the `[UNRELEASED]` section of `changelog`. * Inserts the changenotes `lines` in the `[UNRELEASED]` section of `changelog`.
* If the section contains the stock message {@link NO_CHANGES_STR}, then * If the section contains the stock message {@link NO_CHANGES_STR}, then
* `lines` will be inserted in place and the stock message will be deleted. * `lines` will be inserted in place and the stock message will be deleted.
* *
* This function will throw an exception if `[UNRELEASED]` does not exist. * This function will throw an exception if `[UNRELEASED]` does not exist.
* *
* @param changelog The CHANGELOG object to modify. * @param changelog The CHANGELOG object to modify.
* @param lines The changenotes to insert. * @param lines The changenotes to insert.
*/ */
export function addBodyLinesToUnreleasedSection( export function addBodyLinesToUnreleasedSection(
changelog: Changelog, changelog: Changelog,
lines: string[], lines: string[],
) { ) {
// Do nothing if there is nothing to insert. // Do nothing if there is nothing to insert.
if (lines.length === 0) return; if (lines.length === 0) return;
const unreleasedSection = changelog.sections[0]; const unreleasedSection = changelog.sections[0];
if (getHeader(unreleasedSection) !== UNRELEASED_PLACEHOLDER) { if (getHeader(unreleasedSection) !== UNRELEASED_PLACEHOLDER) {
throw Error("'[UNRELEASED]' is not the first section of 'CHANGELOG.md'"); throw Error(`'${UNRELEASED_PLACEHOLDER}' is not the first section of 'CHANGELOG.md'`);
} }
if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) { if (unreleasedSection.bodyLines.includes(NO_CHANGES_STR)) {
unreleasedSection.bodyLines = ["", ...lines, ""]; unreleasedSection.bodyLines = ["", ...lines, ""];
return; return;
} }
unreleasedSection.bodyLines.pop(); // Remove the last empty line. unreleasedSection.bodyLines.pop(); // Remove the last empty line.
unreleasedSection.bodyLines.push(...lines); unreleasedSection.bodyLines.push(...lines);
unreleasedSection.bodyLines.push(""); unreleasedSection.bodyLines.push("");
} }
/** /**
* Combines an array of lines into a single string by adding line breaks. * Combines an array of lines into a single string by adding line breaks.
*/ */
export function unlines(lines: string[]): string { export function unlines(lines: string[]): string {
return `${lines.join("\n")}`; return `${lines.join("\n")}`;
} }
/** /**
* Renders a given changelog to a string. * Renders a given changelog to a string.
*/ */
export function renderChangelog(changelog: Changelog): string { export function renderChangelog(changelog: Changelog): string {
let result = unlines(changelog.preamble); let result = unlines(changelog.preamble);
for (const section of changelog.sections) { for (const section of changelog.sections) {
result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`;
} }
return result; return result;
} }
/** /**
* Processes changelog entries for a backport, converting version references * Processes changelog entries for a backport, converting version references
* from the source major version to the target major version and filtering * from the source major version to the target major version and filtering
* entries that only apply to newer versions. * entries that only apply to newer versions.
*/ */
export function processChangelogForBackports( export function processChangelogForBackports(
sourceBranchMajorVersion: string, sourceBranchMajorVersion: string,
targetBranchMajorVersion: string, targetBranchMajorVersion: string,
content: string, content: string,
): string { ): string {
// Changelog entries can use the following format to indicate // Changelog entries can use the following format to indicate
// that they only apply to newer versions // that they only apply to newer versions
const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/;
// Parse the changelog. // Parse the changelog.
const changelog = parseChangelog(content); const changelog = parseChangelog(content);
if (changelog.sections.length === 0) { if (changelog.sections.length === 0) {
throw new Error("Could not find any change sections in CHANGELOG.md"); throw new Error("Could not find any change sections in CHANGELOG.md");
} }
// Filter out changelog entries that only apply to newer versions and // Filter out changelog entries that only apply to newer versions and
// update the section headings with the backport major version for // update the section headings with the backport major version for
// sections we keep. // sections we keep.
for (const section of changelog.sections) { for (const section of changelog.sections) {
// Update the section headings with the backport major version. // Update the section headings with the backport major version.
section.headerLine = section.headerLine.replace( section.headerLine = section.headerLine.replace(
`## ${sourceBranchMajorVersion}`, `## ${sourceBranchMajorVersion}`,
`## ${targetBranchMajorVersion}`, `## ${targetBranchMajorVersion}`,
); );
const filteredEntries: string[] = []; const filteredEntries: string[] = [];
let foundContent = false; let foundContent = false;
for (const line of section.bodyLines) { for (const line of section.bodyLines) {
// Skip the entry if `someVersionsOnlyRegex` matches and the major version // Skip the entry if `someVersionsOnlyRegex` matches and the major version
// of the target branch is smaller than the required version. // of the target branch is smaller than the required version.
const match = someVersionsOnlyRegex.exec(line); const match = someVersionsOnlyRegex.exec(line);
if ( if (
match && match &&
Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1])
) { ) {
continue; continue;
} }
// Keep the line. // Keep the line.
filteredEntries.push(line); filteredEntries.push(line);
// Set `foundContent` to `true` if the line is not empty. // Set `foundContent` to `true` if the line is not empty.
if (line.trim() !== "") { if (line.trim() !== "") {
foundContent = true; foundContent = true;
} }
} }
// Update the section with the retained entries. // Update the section with the retained entries.
section.bodyLines = filteredEntries; section.bodyLines = filteredEntries;
// Add an entry if we didn't keep any. // Add an entry if we didn't keep any.
if (!foundContent) { if (!foundContent) {
section.bodyLines.push(NO_CHANGES_STR); section.bodyLines.push(NO_CHANGES_STR);
} }
} }
return renderChangelog(changelog); return renderChangelog(changelog);
} }