import { type Release, type Issue, githubFetchJson, githubFetchJsonPaginated, } from "./github_api.mts"; type Changelog = Record; const repository = "explosivegaming/ExpCluster"; const branch = "main"; const changelogSections = [ "Major Features", "Features", "Fixes", "Changes", "Breaking Changes", "Meta", ]; // Generic result codes const success = 0; const failure = 1; type ParserState = { pos: number, lines: string[], }; function createParser(lines: string[]): ParserState { return { pos: 0, lines }; } function atEnd(parser: ParserState) { return parser.pos >= parser.lines.length; } function currentLine(parser: ParserState) { if (atEnd(parser)) { throw new Error("Attempt to access line past end"); } return parser.lines[parser.pos]; } function skipToLine( parser: ParserState, pattern: RegExp ) { for (; parser.pos < parser.lines.length; parser.pos++) { if (pattern.test(currentLine(parser))) { return success; } } return failure; } function skipEmptyLines(parser: ParserState) { while (parser.pos < parser.lines.length && /^ *$/.test(currentLine(parser))) { parser.pos += 1; } } function extractPullRequestChangelog( parser: ParserState, ): | [typeof success, string[]] | [typeof failure, undefined, string] { const headerFail = skipToLine(parser, /^###? Changelog *$/); if (headerFail) { return [failure, , "Header not found"]; } parser.pos += 1; skipEmptyLines(parser); if (!atEnd(parser) && /^None/i.test(currentLine(parser))) { parser.pos += 1; return [success, []]; } const blockStartFail = skipToLine(parser, /```/); if (blockStartFail) { return [failure, , "Code block not found after header"]; } parser.pos += 1 const blockStartPos = parser.pos; const blockEndFail = skipToLine(parser, /```/); if (blockEndFail) { return [failure, , "End of code block not found after header"]; } return [success, parser.lines.slice(blockStartPos, parser.pos)]; } function parseChangelog(parser: ParserState, pr: Issue, issues: Issue[]): [Changelog, string[]] { let section: string | undefined; let changelog: Changelog = Object.create(null); let warnings: string[] = []; while (!atEnd(parser)) { const line = currentLine(parser); parser.pos += 1; if (line.trim() === "") { continue; } if (line.startsWith("###")) { section = line.slice(4).trim(); if (!changelogSections.includes(section)) { warnings.push( `Changelog section ${section} on line ${parser.pos} is not one of ${changelogSections.join(", ")}` ); } changelog[section] = changelog[section] ?? []; continue; } if (!section) { warnings.push(`Unexpected content "${line}" outside of a changelog section on line ${parser.pos}`); continue; } if (line.startsWith("- ")) { const entry = line .slice(2) .trim() .replace(new RegExp(`\\[#(\\d+)\\]\\(https://github.com/${repository}/(issues|pull)/\\d+\\)`, "g"), "#$1"); let hasRef = false for (const ref of entry.matchAll(/#(\d+)/g)) { hasRef = true; const id = Number.parseInt(ref[1], 10); const refIssue = issues.find(issue => issue.number === id); if (!refIssue) { warnings.push( `Issue reference ${ref[0]} on line ${parser.pos} does not exist ` + "or has not been updated since the last release" ); } else if (refIssue.pull_request && refIssue.number !== pr.number) { warnings.push( `Pull request reference ${ref[0]} on line ${parser.pos} is to a ` + "different Pull request than this one." ); } } if (!hasRef) { warnings.push(`Changelog entry on line ${parser.pos} does not reference an issue`); } changelog[section].push(entry); continue; } warnings.push(`Unexpected content "${line}" on line ${parser.pos}`); } return [changelog, warnings]; } function changelogFromPullRequests(pullRequests: Issue[], issues: Issue[]) { const mergedChangelog: Changelog = Object.create(null); for (const pr of pullRequests) { if (!pr.body) { console.error(`Failed to extract changelog in ${pr.html_url}: No body`) console.log(pr.title) continue; } const [ changelogFail, changelogLines, changelogFailReason ] = extractPullRequestChangelog(createParser(pr.body!.split(/\r?\n/))); if (changelogFail) { console.error(`Failed to extract changelog in ${pr.html_url}: ${changelogFailReason}`) console.log(pr.title) console.log(pr.body) continue; } const [changelog, warnings] = parseChangelog(createParser(changelogLines), pr, issues); for (const warning of warnings) { console.warn(`Warning in changelog for ${pr.html_url}: ${warning}`) } for (const [section, items] of Object.entries(changelog)) { mergedChangelog[section] = [...mergedChangelog[section] ?? [], ...items]; } } return mergedChangelog; } async function fetchLastRelease() { // Assumes releases are ordered by their creation or tag time in descending order. const releases = await githubFetchJson(`/repos/${repository}/releases`); const latest = releases[0] if (!latest) { throw new Error(`Unable to find latest release on ${branch}`); } return latest; } async function fetchIssuesUpdatedSince(since: string) { return await githubFetchJsonPaginated( `/repos/${repository}/issues`, { state: "all", since, sort: "updated", direction: "asc" }, ); } function printMarkdown( changelog: Changelog, issues: Issue[], users: Issue["user"][], refText: (issue: Issue) => string, userText: (user: Issue["user"]) => string, ) { const sections = new Set([...changelogSections, ...Object.keys(changelog)]); for (const section of sections) { if (section in changelog) { console.log(`### ${section}\n`); const entries = changelog[section]; console.log(entries.map(text => ( `- ${text.replaceAll(/#(\d+)/g, (ref, idAsText) => { const id = Number.parseInt(idAsText, 10); if (!Number.isFinite(id)) { return ref; } const issue = issues.find(issue => issue.number === id); if (!issue) { return ref; } return refText(issue); })}` )).join("\n")); console.log(); } } console.log(`Many thanks to the following for contributing to this release:`); console.log(users.map(userText).join("\n")) } async function main() { const lastRelease = await fetchLastRelease(); const issues = await fetchIssuesUpdatedSince(lastRelease.created_at); const pullRequests = issues.filter(issue => ( issue.pull_request && issue.pull_request.merged_at && issue.pull_request.merged_at > lastRelease.created_at )); const changelog = changelogFromPullRequests(pullRequests, issues); const users = [...new Map(pullRequests.map(issue => [issue.user.login, issue.user])).values()] console.log(); console.log("=== Github Release Markdown ==="); printMarkdown(changelog, issues, users, issue => `${repository}#${issue.number}`, user => `@${user.login}`, ); console.log(); console.log("=== Discord / Changelog Markdown ==="); printMarkdown(changelog, issues, users, issue => `[#${issue.number}](<${issue.html_url}>)`, user => `[@${user.login}](<${user.html_url}>)` ); } if (import.meta.main) { main().catch(console.error); }