Add script to format changelog

This commit is contained in:
ζeh Matt 2023-12-20 18:21:58 +02:00
parent 5fa955e1b3
commit 8d7db5e987
No known key found for this signature in database
GPG Key ID: 18CE582C71A225B0
1 changed files with 304 additions and 0 deletions

304
scripts/format-changelog.js Normal file
View File

@ -0,0 +1,304 @@
const fs = require('fs');
// This is also the order expected for each version.
const ChangeTypes = ["Headline feature", "Feature", "Improved", "Change", "Removed", "Fix"];
// A list of change types that were previously used but aren't accepted anymore, will be replaced with the value in the map.
const ChangeTypeSubstitutions = {
"Improve": "Improved",
};
const HeaderSeperator = "------------------------------------------------------------------------";
function reportError(ctx, message) {
console.error(`Error on line ${ctx.lineIndex}: ${message}`);
process.exit(-1);
}
function reportLineError(line, message) {
console.error(`Error on line ${line.index + 1}: ${message}`);
process.exit(-1);
}
function consumeLine(ctx) {
if (ctx.lineIndex >= ctx.lines.length) {
reportError(ctx, "Unexpected end of file");
}
const line = ctx.lines[ctx.lineIndex];
ctx.lineIndex++;
return line;
}
function peekLine(ctx) {
if (ctx.lineIndex >= ctx.lines.length) {
return null;
}
return ctx.lines[ctx.lineIndex];
}
function readVersionHeader(ctx) {
// Extract the version number.
const versionLine = consumeLine(ctx);
const versionMatch = versionLine.text.match(/^((\d+\.\d+\.\d+(\.\d+)?(-\w+)?) \((\d{4}-\d{2}-\d{2}|in development)\))$/);
if (versionMatch == null) {
reportLineError(versionLine, "Expected a version number");
}
// Check the version seperator
const versionSeperator = consumeLine(ctx);
if (versionSeperator.text != HeaderSeperator) {
reportLineError(versionSeperator, "Expected version seperator");
}
// Check for optional headline, this means the there is text not starting with - and an empty line before
// the first entry.
var headline = peekLine(ctx);
if (headline != null && headline.text != "" && !headline.text.startsWith("-")) {
consumeLine(ctx);
// Check for empty line
const emptyLine = peekLine(ctx);
if (emptyLine == null || emptyLine.text != "") {
reportError(ctx, "Expected empty line after headline");
}
consumeLine(ctx);
headline = headline.text;
} else {
headline = "";
}
return {
version: versionMatch[2],
date: versionMatch[5],
headline: headline,
};
}
function isValidReference(ref) {
const regex = /^\w*#\d+( \(partial\))?$/;
return regex.test(ref);
}
function readVersionEntry(ctx, rawEntry, versionInfo) {
// Check for the '- ' prefix
var text = rawEntry.text;
if (!text.startsWith("- ")) {
reportLineError(rawEntry, "Expected entry prefix '- '");
}
text = text.substr(2);
// Extract the change type
const changeTypeIdx = text.indexOf(":");
if (changeTypeIdx == -1) {
reportLineError(rawEntry, "Expected change type");
}
var changeType = text.substring(0, changeTypeIdx).trim();
text = text.substring(changeTypeIdx + 1).trim();
// Check if changeType is valid
if (!ChangeTypes.includes(changeType)) {
// Check for substitution
if (changeType in ChangeTypeSubstitutions) {
changeType = ChangeTypeSubstitutions[changeType];
} else {
reportLineError(rawEntry, `Invalid change type '${changeType}', types can be ${ChangeTypes.join(", ")}`);
}
}
// Extract the references.
var refs = [];
if (text.startsWith("[")) {
const refsEndIdx = text.indexOf("]");
if (refsEndIdx == -1) {
reportLineError(rawEntry, "Open reference bracket '[' without closing bracket ']'");
}
refs = text.substring(1, refsEndIdx).split(",").map(ref => ref.trim());
text = text.substring(refsEndIdx + 1).trim();
} else {
// Text must start with '[', allow having this missing in older versions.
if (versionInfo.date == "in development") {
reportLineError(rawEntry, "Expected reference [#REF]");
}
}
// Check if reference is correct, can be '#123' or 'project#123'
for (const ref of refs) {
if(!isValidReference(ref)) {
reportLineError(rawEntry, `Invalid reference '${ref}', must be '#123' or 'project#123'`);
}
}
const result = {
changeType: changeType,
refs: refs,
description: text.trim(),
};
return result;
}
function readVersionEntries(ctx, versionInfo) {
var entries = [];
while (ctx.lineIndex < ctx.lines.length) {
const rawEntry = consumeLine(ctx);
// Empty line means end of version block.
if (rawEntry.text == "") {
break;
}
const entry = readVersionEntry(ctx, rawEntry, versionInfo);
entries.push(entry);
}
return entries;
}
function readVersionBlock(ctx) {
const versionInfo = readVersionHeader(ctx);
const entries = readVersionEntries(ctx, versionInfo);
return {
version: versionInfo.version,
date: versionInfo.date,
headline: versionInfo.headline,
entries: entries
};
}
function parseChangelog(changelog) {
// Split the changelog into lines but also keep the line numbers.
const lines = changelog.split('\n').map((text, index) => ({ text, index }));
// Remove duplicate empty lines.
for (var i = 0; i < lines.length; i++) {
if (lines[i].text == "" && (i == 0 || lines[i - 1].text == "")) {
lines.splice(i, 1);
i--;
}
}
var ctx = {
lines,
lineIndex: 0,
versions: []
};
while (ctx.lineIndex < ctx.lines.length) {
const versionBlock = readVersionBlock(ctx);
ctx.versions.push(versionBlock);
}
return ctx.versions;
}
function referenceSort(a, b) {
// Extract the number from the reference
const numA = parseInt(a.replace(/\D/g, ''), 10);
const numB = parseInt(b.replace(/\D/g, ''), 10);
// Check if both are direct references or subproject references
const isDirectA = a.startsWith('#');
const isDirectB = b.startsWith('#');
if (isDirectA && isDirectB) {
// If both are direct references, sort by the number
return numA - numB;
} else if (!isDirectA && !isDirectB) {
// If both are subproject references, sort by the name and then the number
const nameA = a.split('#')[0];
const nameB = b.split('#')[0];
if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
return numA - numB;
} else {
// If one is a direct reference and the other is a subproject reference,
// the direct reference should come first
return isDirectA ? -1 : 1;
}
}
function reorderGroups(a, b) {
const changeTypeDiff = ChangeTypes.indexOf(a.changeType) - ChangeTypes.indexOf(b.changeType);
if (changeTypeDiff != 0) {
return changeTypeDiff;
}
// If changeType is identical, sort based on reference
const hasMainRefA = a.refs.length > 0 && a.refs[0].startsWith("#");
const hasMainRefB = b.refs.length > 0 && b.refs[0].startsWith("#");
// Sort by main reference number.
if (hasMainRefA && hasMainRefB) {
const numA = parseInt(a.refs[0].replace(/\D/g, ''), 10);
const numB = parseInt(b.refs[0].replace(/\D/g, ''), 10);
return numA - numB;
} else if (hasMainRefA && !hasMainRefB) {
return -1;
} else if (!hasMainRefA && hasMainRefB) {
return 1;
} else {
// Sort by reference name.
const nameA = a.refs.length > 0 ? a.refs[0].split('#')[0] : "";
const nameB = b.refs.length > 0 ? b.refs[0].split('#')[0] : "";
if (nameA.toLowerCase() < nameB.toLowerCase()) return -1;
if (nameA.toLowerCase() > nameB.toLowerCase()) return 1;
return 0;
}
}
function cleanupVersionBlock(version) {
// Sort the references first, numbers come before sub project refs.
version.entries.forEach(entry => entry.refs.sort((a, b) => referenceSort(a, b)));
// Sort the entries by change type.
version.entries.sort((a, b) => reorderGroups(a, b));
// Ensure entries end with a dot.
version.entries.forEach(entry => {
if (!entry.description.endsWith(".")) {
entry.description += ".";
}
});
}
function cleanupVersions(versions) {
for (const version of versions) {
cleanupVersionBlock(version);
}
}
function printChangelog(versions) {
for (const version of versions) {
console.log(`${version.version} (${version.date})`);
console.log(HeaderSeperator);
if (version.headline != "") {
console.log(version.headline);
console.log();
}
for (const entry of version.entries) {
var line = `- ${entry.changeType}: `;
if (entry.refs.length > 0) {
line += `[${entry.refs.join(", ")}] `;
}
line += `${entry.description}`;
console.log(line);
}
console.log();
}
}
function checkLineEndings(changelog) {
if (changelog.indexOf("\r\n") != -1) {
console.error("Changelog contains Windows line endings, please convert to Unix line endings.");
process.exit(-1);
}
}
const filePath = process.argv[2];
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('Error reading the file:', err);
process.exit(-1);
}
checkLineEndings(data);
var versions = parseChangelog(data);
cleanupVersions(versions);
printChangelog(versions);
});