From 5d653beb96190a7a8be87e56ebc540af40e65599 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 14:59:57 +0100 Subject: [PATCH] Add `parseChangelog` and `renderChangelog` --- pr-checks/changelog.test.ts | 12 ++++++ pr-checks/changelog.ts | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index 3eddc1459..36a5c29a0 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -4,15 +4,19 @@ * Tests for `changelog.ts`. */ +import * as fs from "node:fs"; import * as assert from "node:assert/strict"; import { describe, it } from "node:test"; import { EMPTY_CHANGELOG, getReleaseDateString, + parseChangelog, processChangelogForBackports, + renderChangelog, setVersionAndDate, } from "./changelog"; +import { CHANGELOG_FILE } from "./config"; const testDate = new Date(2026, 7, 14); @@ -37,6 +41,14 @@ describe("setVersionAndDate", async () => { }); }); +describe("parseChangelog + renderChangelog", async () => { + await it("renderChangelog(parseChangelog(c)) == c", async () => { + const actualChangelog = fs.readFileSync(CHANGELOG_FILE, "utf-8"); + const roundtrip = renderChangelog(parseChangelog(actualChangelog)); + assert.deepEqual(roundtrip.split("\n"), actualChangelog.split("\n")); + }); +}); + const testChangelog = `# CodeQL Action Changelog ## 4.12.3 - 14 Aug 2026 diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 3fe03a399..db3a99b15 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -15,6 +15,22 @@ export const EMPTY_CHANGELOG = `# CodeQL Action Changelog ${NO_CHANGES_STR}`; +/** + * Represents sections in a changelog. + */ +export interface ChangelogSection { + headerLine: string; + bodyLines: string[]; +} + +/** + * Represents a changelog. + */ +export interface Changelog { + preamble: string[]; + sections: ChangelogSection[]; +} + /** Returns `date` formatted as `DD Mon YYYY`. */ export function getReleaseDateString(today: Date = new Date()): string { return today.toLocaleDateString("en-GB", { @@ -60,6 +76,75 @@ export function setVersionAndDate( return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); } +/** + * Parses `content` into a structured representation of a changelog. + * + * @param content The contents of the changelog file. + */ +export function parseChangelog(content: string): Changelog { + const lines = content.split("\n"); + let i = 0; + + const preamble: string[] = []; + const sections: ChangelogSection[] = []; + let currentSection: ChangelogSection | undefined = undefined; + + // Process all lines of the input file. + while (i < lines.length) { + const line = lines[i]; + + // Sections of the changelog start with `## `. + if (line.startsWith("## ")) { + // 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 + // in the changelog. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + // Initialise the new section. + currentSection = { headerLine: line, bodyLines: [] }; + } else if (currentSection !== undefined) { + // Add lines between the section header and the next to the current section. + currentSection.bodyLines.push(line); + } else { + // This is neither a section header nor are we in a section already, + // so this line is part of the preamble. + preamble.push(line); + } + + i++; + } + + // Push the current section to the array of completed sections, if there is + // still one unfinished. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + return { preamble, sections }; +} + +/** + * Combines an array of lines into a single string by adding line breaks. + */ +export function unlines(lines: string[]): string { + return `${lines.join("\n")}`; +} + +/** + * Renders a given changelog to a string. + */ +export function renderChangelog(changelog: Changelog): string { + let result = unlines(changelog.preamble); + + for (const section of changelog.sections) { + result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; + } + + return result; +} + /** * Processes changelog entries for a backport, converting version references * from the source major version to the target major version and filtering