From a07be7b7ed3ec59ebe30914355900d1589fd9e50 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:50:00 +0100 Subject: [PATCH] Bump version in package jsons --- exp_commands/package.json | 2 +- exp_groups/package.json | 2 +- exp_gui/package.json | 2 +- exp_legacy/package.json | 3 +- exp_scenario/package.json | 3 +- exp_server_ups/package.json | 6 +- exp_util/package.json | 2 +- scripts/github_api.mts | 141 +++++++++++++++++++ scripts/pr_to_changelog.mts | 260 ++++++++++++++++++++++++++++++++++++ 9 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 scripts/github_api.mts create mode 100644 scripts/pr_to_changelog.mts diff --git a/exp_commands/package.json b/exp_commands/package.json index bd7e0a20..8c9f8da9 100644 --- a/exp_commands/package.json +++ b/exp_commands/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/lib_commands", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin providing a Lua command processing library.", "author": "Cooldude2606 ", "license": "MIT", diff --git a/exp_groups/package.json b/exp_groups/package.json index 05bd9579..d54c67a8 100644 --- a/exp_groups/package.json +++ b/exp_groups/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/permission-groups", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin providing syncing of permission groups", "author": "Cooldude2606 ", "license": "MIT", diff --git a/exp_gui/package.json b/exp_gui/package.json index 441f7dff..7c1988c9 100644 --- a/exp_gui/package.json +++ b/exp_gui/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/lib_gui", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin providing a Lua GUI definition library.", "author": "Cooldude2606 ", "license": "MIT", diff --git a/exp_legacy/package.json b/exp_legacy/package.json index 70d90b2e..77a7f964 100644 --- a/exp_legacy/package.json +++ b/exp_legacy/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/legacy", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin implementing the legacy v6 scenario updated for factorio 2.0", "author": "Cooldude2606 ", "license": "MIT", @@ -26,6 +26,7 @@ "dependencies": { "@expcluster/lib_commands": "workspace:^", "@expcluster/lib_util": "workspace:^", + "@expcluster/lib_gui": "workspace:^", "@sinclair/typebox": "catalog:" }, "publishConfig": { diff --git a/exp_scenario/package.json b/exp_scenario/package.json index a7abd8c2..cd8b8b26 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/scenario", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin implementing the Explosive Gaming scenario.", "author": "Cooldude2606 ", "license": "MIT", @@ -31,6 +31,7 @@ "dependencies": { "@expcluster/lib_commands": "workspace:^", "@expcluster/lib_util": "workspace:^", + "@expcluster/lib_gui": "workspace:^", "@sinclair/typebox": "catalog:" }, "publishConfig": { diff --git a/exp_server_ups/package.json b/exp_server_ups/package.json index 3cd9a76a..f48b165c 100644 --- a/exp_server_ups/package.json +++ b/exp_server_ups/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/server-ups", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin providing in game server ups counter", "author": "Cooldude2606 ", "license": "MIT", @@ -25,9 +25,9 @@ }, "dependencies": { "@sinclair/typebox": "catalog:", + "@expcluster/lib_commands": "workspace:^", "@expcluster/lib_util": "workspace:^", - "@expcluster/lib_gui": "workspace:^", - "@expcluster/lib_commands": "workspace:^" + "@expcluster/lib_gui": "workspace:^" }, "publishConfig": { "access": "public" diff --git a/exp_util/package.json b/exp_util/package.json index ac95386f..483b3a84 100644 --- a/exp_util/package.json +++ b/exp_util/package.json @@ -1,6 +1,6 @@ { "name": "@expcluster/lib_util", - "version": "0.1.0", + "version": "6.5.0", "description": "Clusterio plugin providing Lua libraries and other utilities.", "author": "Cooldude2606 ", "license": "MIT", diff --git a/scripts/github_api.mts b/scripts/github_api.mts new file mode 100644 index 00000000..1f03a51c --- /dev/null +++ b/scripts/github_api.mts @@ -0,0 +1,141 @@ +export async function githubFetch( + path: string, + query: Record= {}, + init: RequestInit & { headers?: Record } = {}, +) { + const url = new URL("https://api.github.com/"); + url.pathname = path; + for (const [name, value] of Object.entries(query)) { + url.searchParams.set(name, value); + } + init = { + ...init, + headers: { + ...init.headers as Record ?? {}, + "Accept": "application/vnd.github+json", + } + }; + if (process.env.GH_TOKEN) { + init.headers!["Authorization"] = `Bearer ${process.env.GH_TOKEN}`; + } + console.log("Fetching", url.href); + const response = await fetch(url, init); + if (!response.ok) { + console.log(response.headers); + throw new Error(`GitHub replied with ${response.status} ${response.statusText}: ${await response.text()}`); + } + if (!response.headers.get("Content-Type")?.startsWith("application/json")) { + throw new Error( + `GitHub replied with ${response.status} ${response.statusText}: ${response.headers.get("Content-Type")}` + ); + } + return response; +} + +export async function githubFetchJson( + path: string, + query: Record = {}, + init: RequestInit & { headers?: Record } = {}, +): Promise { + const response = await githubFetch(path, query, init); + return await response.json(); +} + +export async function githubFetchJsonPaginated( + path: string, + query: Record = {}, + init: RequestInit & { headers?: Record } = {}, + shouldContinue: (result: T[]) => boolean = () => true, +): Promise { + const data: T[] = []; + let pagesRemaining = true; + query = { + ...query, + per_page: "100", + }; + while (pagesRemaining) { + const response = await githubFetch(path, query, init); + const responseItems = await response.json(); + data.push(...responseItems); + const linkHeader = response.headers.get("Link"); + pagesRemaining = linkHeader?.includes(`rel="next"`) ?? false; + if (pagesRemaining) { + pagesRemaining = shouldContinue(responseItems); + } + if (pagesRemaining) { + query = { + ...query, + page: String((query.page ? (Number(query.page)) : 1) + 1), + } + } + } + return data; +} + +// Minimal types that include only what is used and useful for our scripts. See +// https://docs.github.com/en/rest for complete reference. +export interface PullRequest { + number: number, + url: string, + html_url: string, + state: string, + title: string, + body: string | null, + created_at: string, + updated_at: string, + closed_at: string | null, + merged_at: string | null, + merge_commit_sha: string | null, +} + +export interface Issue { + number: number, + title: string, + url: string, + html_url: string, + state: string, + body?: string | null, + user: { + login: string, + url: string, + html_url: string, + } + pull_request?: { + url: string, + html_url: string, + merged_at?: string | null, + }, +} + +export interface Release { + tag_name: string, + target_commitish: string, + name: string | null, + body: string | null, + created_at: string, + published_at: string | null, + updated_at?: string | null, +} + +export interface Commit { + sha: string, + commit: { + message: string, + }, + author: null | {} | { + name?: null | string, + login: string, + type: string, + }, + parents: { + sha: string, + }[], +} + +export interface Reference { + ref: string, + object: { + type: string, + sha: string, + }, +} diff --git a/scripts/pr_to_changelog.mts b/scripts/pr_to_changelog.mts new file mode 100644 index 00000000..7b80b7dc --- /dev/null +++ b/scripts/pr_to_changelog.mts @@ -0,0 +1,260 @@ +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); +}