From f9c2c0ac6eee462143345f2e41787a9fd824d49a Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Mon, 24 Aug 2026 13:14:24 +0200 Subject: [PATCH 1/3] chore: update changelog format --- .changeset/changelog-generator.ts | 79 +++++++++++++++++++++++++++++-- .changeset/tsconfig.json | 7 +++ 2 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .changeset/tsconfig.json diff --git a/.changeset/changelog-generator.ts b/.changeset/changelog-generator.ts index a9380ced65..045a972fc3 100644 --- a/.changeset/changelog-generator.ts +++ b/.changeset/changelog-generator.ts @@ -1,6 +1,43 @@ +import process from 'node:process'; import type { ChangelogFunctions } from '@changesets/types'; import { getCommitInfo, getPullRequestInfo } from '@changesets/get-github-info'; +const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com'; +const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com'; + +const firstContributionCache = new Map>(); + +/** + * Checks if this is the author's first merged PR in the repo, by counting + * their merged PRs via the GitHub search API. At changelog-generation time the + * released PR is already merged, so a count of 1 means it was their first. + * Fails open to `false` so a missing token or API hiccup never breaks a release. + */ +const isFirstContribution = (repo: string, login: string): Promise => { + let result = firstContributionCache.get(login); + if (!result) { + result = (async () => { + if (login.endsWith('[bot]')) return false; + + const query = encodeURIComponent(`repo:${repo} type:pr is:merged author:${login}`); + const response = await fetch(`${GITHUB_API_URL}/search/issues?q=${query}&per_page=1`, { + headers: { + Accept: 'application/vnd.github+json', + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Token ${process.env.GITHUB_TOKEN}` } + : {}) + } + }); + if (!response.ok) return false; + + const data = (await response.json()) as { total_count?: number }; + return (data.total_count ?? 0) <= 1; + })().catch(() => false); + firstContributionCache.set(login, result); + } + return result; +}; + const changelogFunctions: ChangelogFunctions = { getDependencyReleaseLine: async (changesets, dependenciesUpdated, options) => { if (dependenciesUpdated.length === 0) return ''; @@ -31,6 +68,7 @@ const changelogFunctions: ChangelogFunctions = { const repo = options!.repo; let prFromSummary: number | undefined; let commitFromSummary: string | undefined; + const usersFromSummary: string[] = []; const replacedChangelog = changeset.summary .replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => { @@ -42,7 +80,10 @@ const changelogFunctions: ChangelogFunctions = { commitFromSummary = commit; return ''; }) - .replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, '') + .replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => { + usersFromSummary.push(user); + return ''; + }) .trim(); // add links to issue hints (fix #123) => (fix [#123](https://....)) @@ -67,7 +108,7 @@ const changelogFunctions: ChangelogFunctions = { 7 )}\`](https://github.com/${repo}/commit/${commitFromSummary})`; } - return { pull: info?.pull.markdownLink, commit }; + return { pull: info?.pull.markdownLink, commit, author: info?.author }; } const commitToFetchFrom = commitFromSummary || changeset.commit; if (commitToFetchFrom) { @@ -75,18 +116,46 @@ const changelogFunctions: ChangelogFunctions = { repo, commit: commitToFetchFrom }); - return { pull: info?.pull?.markdownLink, commit: info?.commit.markdownLink }; + return { + pull: info?.pull?.markdownLink, + commit: info?.commit.markdownLink, + author: info?.author + }; } return { commit: undefined, - pull: undefined + pull: undefined, + author: undefined }; })(); + // `author:`/`user:` hints in the changeset summary win over the PR/commit author + const authors = usersFromSummary.length + ? usersFromSummary.map((login) => ({ + login, + markdownLink: `[@${login}](${GITHUB_SERVER_URL}/${login})` + })) + : links.author + ? [links.author] + : []; + // only link PR or merge commit not both const suffix = links.pull ? ` (${links.pull})` : links.commit ? ` (${links.commit})` : ''; + const authorSuffix = authors.length + ? ` by ${authors.map((author) => author.markdownLink).join(', ')}` + : ''; + + const thanksLines = ( + await Promise.all( + authors.map(async (author) => + (await isFirstContribution(repo, author.login)) + ? `\n- 🎉 Thanks ${author.markdownLink} for their first contribution! 🎉` + : '' + ) + ) + ).join(''); - return `\n- ${firstLine}${suffix}\n${futureLines.map((l) => ` ${l}`).join('\n')}`; + return `${thanksLines}\n- ${firstLine}${suffix}${authorSuffix}\n${futureLines.map((l) => ` ${l}`).join('\n')}`; } }; diff --git a/.changeset/tsconfig.json b/.changeset/tsconfig.json new file mode 100644 index 0000000000..796599073f --- /dev/null +++ b/.changeset/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["*.ts"] +} From 8f14ffcabed0a2dfb4521a3f6aa01728671a75f7 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Tue, 25 Aug 2026 14:18:54 +0200 Subject: [PATCH 2/3] add skip for array of authors --- .changeset/changelog-generator.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.changeset/changelog-generator.ts b/.changeset/changelog-generator.ts index 045a972fc3..b3d94def99 100644 --- a/.changeset/changelog-generator.ts +++ b/.changeset/changelog-generator.ts @@ -5,6 +5,16 @@ import { getCommitInfo, getPullRequestInfo } from '@changesets/get-github-info'; const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com'; const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com'; +/** + * GitHub logins of core maintainers. Their changelog entries are written + * without the trailing `by @user` author attribution. + */ +const SKIP_AUTHOR_LOGINS = new Set( + ['barsnes', 'mimarz', 'eirikbacker', 'mrosvik', 'unekinn', 'febakke'] +); + +const isSkippedAuthor = (login: string) => SKIP_AUTHOR_LOGINS.has(login.toLowerCase()); + const firstContributionCache = new Map>(); /** @@ -141,8 +151,9 @@ const changelogFunctions: ChangelogFunctions = { // only link PR or merge commit not both const suffix = links.pull ? ` (${links.pull})` : links.commit ? ` (${links.commit})` : ''; - const authorSuffix = authors.length - ? ` by ${authors.map((author) => author.markdownLink).join(', ')}` + const creditedAuthors = authors.filter((author) => !isSkippedAuthor(author.login)); + const authorSuffix = creditedAuthors.length + ? ` by ${creditedAuthors.map((author) => author.markdownLink).join(', ')}` : ''; const thanksLines = ( From 6412f962e6da2a51a260b09fa0ec779b17614183 Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Wed, 26 Aug 2026 09:21:47 +0200 Subject: [PATCH 3/3] ignore bots as author --- .changeset/changelog-generator.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/changelog-generator.ts b/.changeset/changelog-generator.ts index b3d94def99..8ea187abc6 100644 --- a/.changeset/changelog-generator.ts +++ b/.changeset/changelog-generator.ts @@ -6,14 +6,18 @@ const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com'; const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com'; /** - * GitHub logins of core maintainers. Their changelog entries are written - * without the trailing `by @user` author attribution. + * GitHub logins of core maintainers. Their changelog entries, and those of + * bots (renovate, dependabot, ...), are written without the trailing + * `by @user` author attribution. */ const SKIP_AUTHOR_LOGINS = new Set( ['barsnes', 'mimarz', 'eirikbacker', 'mrosvik', 'unekinn', 'febakke'] ); -const isSkippedAuthor = (login: string) => SKIP_AUTHOR_LOGINS.has(login.toLowerCase()); +const isBot = (login: string) => login.endsWith('[bot]'); + +const isSkippedAuthor = (login: string) => + isBot(login) || SKIP_AUTHOR_LOGINS.has(login.toLowerCase()); const firstContributionCache = new Map>(); @@ -27,7 +31,7 @@ const isFirstContribution = (repo: string, login: string): Promise => { let result = firstContributionCache.get(login); if (!result) { result = (async () => { - if (login.endsWith('[bot]')) return false; + if (isBot(login)) return false; const query = encodeURIComponent(`repo:${repo} type:pr is:merged author:${login}`); const response = await fetch(`${GITHUB_API_URL}/search/issues?q=${query}&per_page=1`, {