From 0f4671afd08b765eb3d1a27b9e8599f3a8be05db Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:28:48 +0000 Subject: [PATCH] Add contributors title and statistics to the changelog script Puts the contributors under a "Contributors" title on a single comma separated line, and prints commits, merged pull requests, files changed and line additions and deletions since the last release after them. The compare API lists at most 300 files, so the file and line counts come from the diff media type of the same comparison instead. To fetch it, githubFetch now lets the caller override the Accept header and checks the Content-Type against it. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/github_api.mts | 19 ++++++++++-- scripts/pr_to_changelog.mts | 60 +++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/scripts/github_api.mts b/scripts/github_api.mts index 1f03a51c..12b7d7a6 100644 --- a/scripts/github_api.mts +++ b/scripts/github_api.mts @@ -11,8 +11,8 @@ export async function githubFetch( init = { ...init, headers: { - ...init.headers as Record ?? {}, "Accept": "application/vnd.github+json", + ...init.headers as Record ?? {}, } }; if (process.env.GH_TOKEN) { @@ -24,7 +24,9 @@ export async function githubFetch( 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")) { + const accept = init.headers!["Accept"]; + const contentType = accept === "application/vnd.github+json" ? "application/json" : accept; + if (!response.headers.get("Content-Type")?.startsWith(contentType)) { throw new Error( `GitHub replied with ${response.status} ${response.statusText}: ${response.headers.get("Content-Type")}` ); @@ -41,6 +43,15 @@ export async function githubFetchJson( return await response.json(); } +export async function githubFetchText( + path: string, + query: Record = {}, + init: RequestInit & { headers?: Record } = {}, +): Promise { + const response = await githubFetch(path, query, init); + return await response.text(); +} + export async function githubFetchJsonPaginated( path: string, query: Record = {}, @@ -117,6 +128,10 @@ export interface Release { updated_at?: string | null, } +export interface Comparison { + total_commits: number, +} + export interface Commit { sha: string, commit: { diff --git a/scripts/pr_to_changelog.mts b/scripts/pr_to_changelog.mts index a5bbb7bf..7a6324a4 100644 --- a/scripts/pr_to_changelog.mts +++ b/scripts/pr_to_changelog.mts @@ -1,12 +1,22 @@ import { + type Comparison, type Release, type Issue, githubFetchJson, githubFetchJsonPaginated, + githubFetchText, } from "./github_api.mts"; type Changelog = Record; +type Statistics = { + commits: number, + pullRequests: number, + filesChanged: number, + lineAdditions: number, + lineDeletions: number, +}; + const repository = "clusterio/clusterio"; const branch = "master"; @@ -187,10 +197,41 @@ async function fetchIssuesUpdatedSince(since: string) { ); } +async function fetchStatistics(lastRelease: Release, pullRequests: Issue[]): Promise { + const path = `/repos/${repository}/compare/${lastRelease.tag_name}...${branch}`; + const comparison = await githubFetchJson(path, { per_page: "1" }); + // The JSON response lists at most 300 files, the diff has all of them. + const diff = await githubFetchText(path, {}, { headers: { "Accept": "application/vnd.github.diff" } }); + let filesChanged = 0; + let lineAdditions = 0; + let lineDeletions = 0; + let inHunk = false; // Skips the ---/+++ file headers + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git ")) { + filesChanged += 1; + inHunk = false; + } else if (line.startsWith("@@")) { + inHunk = true; + } else if (inHunk && line.startsWith("+")) { + lineAdditions += 1; + } else if (inHunk && line.startsWith("-")) { + lineDeletions += 1; + } + } + return { + commits: comparison.total_commits, + pullRequests: pullRequests.length, + filesChanged, + lineAdditions, + lineDeletions, + }; +} + function printMarkdown( changelog: Changelog, issues: Issue[], users: Issue["user"][], + statistics: Statistics, refText: (issue: Issue) => string, userText: (user: Issue["user"]) => string, ) { @@ -216,8 +257,20 @@ function printMarkdown( } } + console.log("Contributors"); + console.log(); console.log(`Many thanks to the following for contributing to this release:`); - console.log(users.map(userText).join("\n")) + console.log(users.map(userText).join(", ")); + + const format = (value: number) => value.toLocaleString("en-US"); + console.log(); + console.log("Statistics"); + console.log(); + console.log(`Commits: ${format(statistics.commits)}`); + console.log(`Pull Requests: ${format(statistics.pullRequests)}`); + console.log(`Files Changed: ${format(statistics.filesChanged)}`); + console.log(`Line Additions: ${format(statistics.lineAdditions)}`); + console.log(`Line Deletions: ${format(statistics.lineDeletions)}`); } async function main() { @@ -232,17 +285,18 @@ async function main() { const changelog = changelogFromPullRequests(pullRequests, issues); const users = [...new Map(pullRequests.map(issue => [issue.user.login, issue.user])).values()] + const statistics = await fetchStatistics(lastRelease, pullRequests); console.log(); console.log("=== Github Release Markdown ==="); - printMarkdown(changelog, issues, users, + printMarkdown(changelog, issues, users, statistics, issue => `${repository}#${issue.number}`, user => `@${user.login}`, ); console.log(); console.log("=== Discord / Changelog Markdown ==="); - printMarkdown(changelog, issues, users, + printMarkdown(changelog, issues, users, statistics, issue => `[#${issue.number}](<${issue.html_url}>)`, user => `[@${user.login}](<${user.html_url}>)` );