From ff8f233faeb241dd3dbe902b0ea66322ddd11ddb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:12:29 +0000 Subject: [PATCH 1/6] feat(changelog): Credit requesters of Junior pull requests Co-Authored-By: Jan Michael Auer <1433023+jan-auer@users.noreply.github.com> --- docs/src/content/docs/configuration.md | 8 +++ .../__tests__/changelog-generate.test.ts | 23 ++++++++ src/utils/__tests__/changelog-utils.test.ts | 55 +++++++++++++++++++ src/utils/changelog.ts | 41 +++++++++++++- 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 74381d9b..fd186047 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -572,6 +572,14 @@ Example output with scope grouping: - General improvement by @dave in [#4](https://github.com/...) ``` +### Junior pull requests + +Pull requests opened by Sentry's Junior bot (`sentry-junior[bot]`) credit the +person who requested them instead of the bot. Craft reads the first GitHub +`@login` from Junior's request attribution block in the PR body and falls back +to the bot when the requester has no linked GitHub account. Author exclusions +still match the bot login. + ### Workspace-aware changelogs When you select a Craft release workspace, automatic changelogs include commits diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 165c5bea..cac49140 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -726,6 +726,29 @@ describe('generateChangesetFromGit', () => { expect(result.changelog).toMatchSnapshot(); }); + it('credits the requester of a PR opened by Junior', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + pr: { + remote: { + number: '123', + author: { login: 'sentry-junior' }, + body: 'Upgrades the kernel.\n\n\nvia @alice.\n', + }, + }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain('by @alice in [#123]'); + expect(result.changelog).not.toContain('sentry-junior'); + }); + it('handles null PR author gracefully', async () => { setup( [ diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index d2d7b2bc..b9b4d4b2 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -20,6 +20,7 @@ import { extractRevertedTitle, extractRevertedSha, processReverts, + resolveChangelogAuthor, getNormalizedReleaseConfig, clearReleaseConfigCache, SKIP_CHANGELOG_MAGIC_WORD, @@ -79,6 +80,60 @@ describe('shouldExcludePR', () => { }); }); +describe('resolveChangelogAuthor', () => { + const attribution = (labels: string) => + `Body\n\n\n${labels}\n`; + + it.each(['sentry-junior', 'sentry-junior[bot]'])( + 'credits the requester of a PR opened by %s', + login => { + expect(resolveChangelogAuthor(login, attribution('via @alice.'))).toBe( + 'alice', + ); + }, + ); + + it('credits the first requester with a linked GitHub login', () => { + expect( + resolveChangelogAuthor( + 'sentry-junior[bot]', + attribution('via **Jane Doe**, @bob_corp, @carol.'), + ), + ).toBe('bob_corp'); + }); + + it('supports the legacy "Requested by" wording', () => { + expect( + resolveChangelogAuthor( + 'sentry-junior[bot]', + attribution('Requested by @alice.'), + ), + ).toBe('alice'); + }); + + it('keeps the bot author when no requester has a GitHub login', () => { + expect( + resolveChangelogAuthor( + 'sentry-junior[bot]', + attribution('via **Jane @home**.'), + ), + ).toBe('sentry-junior[bot]'); + expect(resolveChangelogAuthor('sentry-junior[bot]', 'Thanks @alice!')).toBe( + 'sentry-junior[bot]', + ); + expect(resolveChangelogAuthor('sentry-junior', null)).toBe('sentry-junior'); + }); + + it('ignores attribution on PRs from other authors', () => { + expect(resolveChangelogAuthor('bob', attribution('via @alice.'))).toBe( + 'bob', + ); + expect(resolveChangelogAuthor(undefined, attribution('via @alice.'))).toBe( + undefined, + ); + }); +}); + describe('shouldSkipCurrentPR', () => { // Note: This function reads config internally, so we can only test // the skip magic word behavior without mocking diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 85a897d7..24696272 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -643,6 +643,42 @@ interface PullRequest { description?: string; } +/** + * GitHub logins of the Junior bot. REST returns `sentry-junior[bot]`, while + * GraphQL returns the bare `sentry-junior` login for bot authors. + */ +const JUNIOR_BOT_LOGINS = new Set(['sentry-junior', 'sentry-junior[bot]']); + +/** Junior's runtime-owned requester block, e.g. `via @octocat, **Jane**.` */ +const JUNIOR_REQUEST_ATTRIBUTION_REGEX = + /([\s\S]*?)/; + +/** A requester label that is a GitHub `@login` mention, not a display name. */ +const JUNIOR_REQUESTER_LOGIN_REGEX = + /(?:\b(?:via|by) |, )@([A-Za-z0-9][A-Za-z0-9_-]*)(?=[,.\s]|$)/; + +/** + * Resolves the author to credit in the changelog. + * + * PRs opened by Junior on someone's behalf credit the first requester with a + * linked GitHub login from Junior's attribution block. Falls back to the PR + * author when there is no such requester. + * + * @param author The PR author login + * @param prBody The PR description/body text + * @returns The login to credit in the changelog + */ +export function resolveChangelogAuthor( + author: string | undefined, + prBody: string | null | undefined, +): string | undefined { + if (!author || !prBody || !JUNIOR_BOT_LOGINS.has(author)) { + return author; + } + const attribution = prBody.match(JUNIOR_REQUEST_ATTRIBUTION_REGEX)?.[1]; + return attribution?.match(JUNIOR_REQUESTER_LOGIN_REGEX)?.[1] ?? author; +} + /** * Creates PullRequest entries from raw commit info, handling custom changelog entries. * If the PR body contains a "Changelog Entry" section, each entry becomes a separate PR entry. @@ -668,10 +704,11 @@ function createPREntriesFromRaw( ? defaultTitle : undefined; const customEntries = extractChangelogEntry(raw.prBody); + const author = resolveChangelogAuthor(raw.author, raw.prBody); if (customEntries) { return customEntries.map(entry => ({ - author: raw.author, + author, number: raw.pr ?? '', hash: raw.hash, body: entry.nestedContent ?? '', @@ -683,7 +720,7 @@ function createPREntriesFromRaw( return [ { - author: raw.author, + author, number: raw.pr ?? '', hash: raw.hash, body: '', From bdacfc04259553c6f6a5851eef58bde80911370c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:17:22 +0000 Subject: [PATCH 2/6] docs(changelog): Drop Junior attribution note from configuration docs --- docs/src/content/docs/configuration.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index fd186047..74381d9b 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -572,14 +572,6 @@ Example output with scope grouping: - General improvement by @dave in [#4](https://github.com/...) ``` -### Junior pull requests - -Pull requests opened by Sentry's Junior bot (`sentry-junior[bot]`) credit the -person who requested them instead of the bot. Craft reads the first GitHub -`@login` from Junior's request attribution block in the PR body and falls back -to the bot when the requester has no linked GitHub account. Author exclusions -still match the bot login. - ### Workspace-aware changelogs When you select a Craft release workspace, automatic changelogs include commits From 65ff78f88fd6dd47619e8ed477a6c6c9fdfd34e6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:02:39 +0000 Subject: [PATCH 3/6] feat(changelog): Credit commit co-authors and add hide_authors Replace the Junior-specific PR body parsing with GitHub's Commit.authors field, which resolves Co-authored-by trailers to GitHub logins. Add a hide_authors release config option to keep PRs in the changelog without crediting specific authors, such as bots. Co-Authored-By: Jan Michael Auer <1433023+jan-auer@users.noreply.github.com> --- docs/src/content/docs/configuration.md | 13 ++ .../__tests__/changelog-generate.test.ts | 184 +++++++++++++----- src/utils/__tests__/changelog-utils.test.ts | 55 ------ src/utils/__tests__/fixtures/changelog.ts | 2 + src/utils/changelog.ts | 175 +++++++++++++---- 5 files changed, 291 insertions(+), 138 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 74381d9b..6b51d745 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -687,6 +687,19 @@ changelog: - renovate[bot] ``` +#### Author Credits + +Entries credit the PR author and any co-authors from `Co-authored-by` commit +trailers that GitHub links to an account, for example `by @alice, @bob and 2 +others`. To keep a PR in the changelog without crediting an author, such as a +bot that opens PRs on someone's behalf, list it in `hide_authors`: + +```yaml +changelog: + hide_authors: + - my-bot[bot] +``` + ### Configuration Options | Option | Description | diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index cac49140..5eb68681 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -47,6 +47,13 @@ import { } from '../changelog'; import { type TestCommit } from './fixtures/changelog'; +/** Mocks `Commit.authors`: the git author first, then `Co-authored-by` logins. */ +function commitAuthors(coAuthors: string[] = []) { + return { + nodes: [{ user: null }, ...coAuthors.map(login => ({ user: { login } }))], + }; +} + const getConfigFileDirMock = config.getConfigFileDir as MockedFunction< typeof config.getConfigFileDir >; @@ -97,10 +104,11 @@ describe('generateChangesetFromGit', () => { mockClient.mockResolvedValueOnce({ repository: Object.fromEntries( - commits.map(({ hash, author, title, pr }: TestCommit) => [ + commits.map(({ hash, author, coAuthors, title, pr }: TestCommit) => [ `C${hash}`, { author: { user: author }, + authors: commitAuthors(coAuthors), associatedPullRequests: { nodes: pr?.remote ? [ @@ -726,29 +734,6 @@ describe('generateChangesetFromGit', () => { expect(result.changelog).toMatchSnapshot(); }); - it('credits the requester of a PR opened by Junior', async () => { - setup( - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - pr: { - remote: { - number: '123', - author: { login: 'sentry-junior' }, - body: 'Upgrades the kernel.\n\n\nvia @alice.\n', - }, - }, - }, - ], - null, - ); - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - expect(result.changelog).toContain('by @alice in [#123]'); - expect(result.changelog).not.toContain('sentry-junior'); - }); - it('handles null PR author gracefully', async () => { setup( [ @@ -836,6 +821,87 @@ describe('generateChangesetFromGit', () => { }); }); + describe('author credits', () => { + function junior(coAuthors: string[]): TestCommit { + return { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + coAuthors, + pr: { + remote: { number: '123', author: { login: 'sentry-junior' } }, + }, + }; + } + + it('credits co-authors after the PR author without duplicates', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + coAuthors: ['bob', 'Alice', 'carol'], + pr: { remote: { number: '123', author: { login: 'alice' } } }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @alice, @bob and @carol in [#123]', + ); + }); + + it('collapses long author lists', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + coAuthors: ['bob', 'carol', 'dave'], + pr: { remote: { number: '123', author: { login: 'alice' } } }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @alice, @bob and 2 others in [#123]', + ); + }); + + it('hides authors listed in hide_authors but keeps their PRs', async () => { + setup( + [junior(['sentry-junior[bot]', 'jan-auer'])], + ` +changelog: + hide_authors: + - sentry-junior[bot] +`, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @jan-auer in [#123]', + ); + expect(result.changelog).not.toContain('sentry-junior'); + }); + + it('omits the credit when every author is hidden', async () => { + setup( + [junior([])], + ` +changelog: + hide_authors: + - sentry-junior +`, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain('- Upgraded the kernel in [#123]'); + }); + }); + // ============================================================================ // Category matching tests // ============================================================================ @@ -2213,29 +2279,32 @@ describe('generateChangelogWithHighlight', () => { // Mock GraphQL for commit info mockGraphqlClient.mockResolvedValueOnce({ repository: Object.fromEntries( - existingCommits.map(({ hash, author, title, pr }: TestCommit) => [ - `C${hash}`, - { - author: { user: author }, - associatedPullRequests: { - nodes: pr?.remote - ? [ - { - author: pr.remote.author, - number: pr.remote.number, - title: pr.remote.title ?? title, - body: pr.remote.body || '', - labels: { - nodes: (pr.remote.labels || []).map(label => ({ - name: label, - })), + existingCommits.map( + ({ hash, author, coAuthors, title, pr }: TestCommit) => [ + `C${hash}`, + { + author: { user: author }, + authors: commitAuthors(coAuthors), + associatedPullRequests: { + nodes: pr?.remote + ? [ + { + author: pr.remote.author, + number: pr.remote.number, + title: pr.remote.title ?? title, + body: pr.remote.body || '', + labels: { + nodes: (pr.remote.labels || []).map(label => ({ + name: label, + })), + }, }, - }, - ] - : [], + ] + : [], + }, }, - }, - ]), + ], + ), ), }); @@ -2259,6 +2328,31 @@ describe('generateChangelogWithHighlight', () => { } } + it('credits co-authors of the current PR', async () => { + setup({ + currentPR: { number: 7, title: 'feat: Add thing', author: 'alice' }, + existingCommits: [{ hash: 'abc123', title: 'fix: Older fix', body: '' }], + }); + mockGraphqlClient.mockResolvedValueOnce({ + repository: { + pullRequest: { + commits: { + nodes: [ + { commit: { authors: commitAuthors(['bob']) } }, + { commit: { authors: commitAuthors(['carol']) } }, + ], + }, + }, + }, + }); + + const result = await generateChangelogWithHighlight(dummyGit, '1.0.0', 7); + + expect(result.changelog).toContain( + 'Add thing by **alice**, **bob** and **carol** in', + ); + }); + describe('revert handling in PR preview', () => { it('cancels out revert PR with its target commit', async () => { setup({ diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index b9b4d4b2..d2d7b2bc 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -20,7 +20,6 @@ import { extractRevertedTitle, extractRevertedSha, processReverts, - resolveChangelogAuthor, getNormalizedReleaseConfig, clearReleaseConfigCache, SKIP_CHANGELOG_MAGIC_WORD, @@ -80,60 +79,6 @@ describe('shouldExcludePR', () => { }); }); -describe('resolveChangelogAuthor', () => { - const attribution = (labels: string) => - `Body\n\n\n${labels}\n`; - - it.each(['sentry-junior', 'sentry-junior[bot]'])( - 'credits the requester of a PR opened by %s', - login => { - expect(resolveChangelogAuthor(login, attribution('via @alice.'))).toBe( - 'alice', - ); - }, - ); - - it('credits the first requester with a linked GitHub login', () => { - expect( - resolveChangelogAuthor( - 'sentry-junior[bot]', - attribution('via **Jane Doe**, @bob_corp, @carol.'), - ), - ).toBe('bob_corp'); - }); - - it('supports the legacy "Requested by" wording', () => { - expect( - resolveChangelogAuthor( - 'sentry-junior[bot]', - attribution('Requested by @alice.'), - ), - ).toBe('alice'); - }); - - it('keeps the bot author when no requester has a GitHub login', () => { - expect( - resolveChangelogAuthor( - 'sentry-junior[bot]', - attribution('via **Jane @home**.'), - ), - ).toBe('sentry-junior[bot]'); - expect(resolveChangelogAuthor('sentry-junior[bot]', 'Thanks @alice!')).toBe( - 'sentry-junior[bot]', - ); - expect(resolveChangelogAuthor('sentry-junior', null)).toBe('sentry-junior'); - }); - - it('ignores attribution on PRs from other authors', () => { - expect(resolveChangelogAuthor('bob', attribution('via @alice.'))).toBe( - 'bob', - ); - expect(resolveChangelogAuthor(undefined, attribution('via @alice.'))).toBe( - undefined, - ); - }); -}); - describe('shouldSkipCurrentPR', () => { // Note: This function reads config internally, so we can only test // the skip magic word behavior without mocking diff --git a/src/utils/__tests__/fixtures/changelog.ts b/src/utils/__tests__/fixtures/changelog.ts index 745d40de..fb5068c7 100644 --- a/src/utils/__tests__/fixtures/changelog.ts +++ b/src/utils/__tests__/fixtures/changelog.ts @@ -54,6 +54,8 @@ export const SAMPLE_CHANGESET_WITH_SUBHEADING = { export interface TestCommit { author?: string; + /** GitHub logins from `Co-authored-by` trailers */ + coAuthors?: string[]; hash: string; title: string; body: string; diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 24696272..53b0018e 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -71,6 +71,62 @@ async function fetchPRInfo(prNumber: number): Promise { }; } +interface CommitAuthors { + nodes: Array<{ user: { login: string } | null }>; +} + +/** Returns the GitHub logins of commit authors that GitHub matched to a user. */ +function authorLogins(nodes: CommitAuthors['nodes'] = []): string[] { + return nodes + .map(node => node.user?.login) + .filter((login): login is string => Boolean(login)); +} + +/** + * Fetches the GitHub logins of everyone who authored or co-authored a commit + * in the given PR. Co-authors come from `Co-authored-by` commit trailers. + * + * Co-authors only add credit, so failures return an empty list. + * + * @param prNumber The PR number to fetch + * @returns Logins in commit order, possibly with duplicates + */ +async function fetchPRCoAuthors(prNumber: number): Promise { + const { repo, owner } = await getGlobalGitHubConfig(); + try { + const response = (await getGitHubClient().graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + commits(first: ${MAX_PR_COMMITS}) { + nodes { + commit { + authors(first: ${MAX_COMMIT_AUTHORS}) { nodes { user { login } } } + } + } + } + } + } + }`, + { owner, repo, number: prNumber }, + )) as { + repository?: { + pullRequest?: { + commits: { nodes: Array<{ commit: { authors: CommitAuthors } }> }; + }; + }; + }; + return (response?.repository?.pullRequest?.commits.nodes ?? []).flatMap( + node => authorLogins(node.commit.authors.nodes), + ); + } catch (error: any) { + logger.warn( + `Failed to fetch co-authors for PR #${prNumber}: ${error.message || error}`, + ); + return []; + } +} + /** * Version bump types. */ @@ -105,6 +161,12 @@ const VERSION_HEADER_LEVEL = 2; const SUBSECTION_HEADER_LEVEL = VERSION_HEADER_LEVEL + 1; const SCOPE_HEADER_LEVEL = SUBSECTION_HEADER_LEVEL + 1; const MAX_COMMITS_PER_QUERY = 50; +/** Commits read from the current PR when previewing its changelog entry */ +const MAX_PR_COMMITS = 100; +/** Authors read per commit: the git author plus `Co-authored-by` trailers */ +const MAX_COMMIT_AUTHORS = 10; +/** Authors shown per changelog entry before collapsing into "N others" */ +const MAX_CREDITED_AUTHORS = 3; const MAX_LEFTOVERS = 24; // Ensure subsections are nested under version headers otherwise we won't be @@ -628,7 +690,8 @@ export function prependChangeset( } interface PullRequest { - author?: string; + /** GitHub logins credited for this entry */ + authors: string[]; number: string; hash: string; body: string; @@ -644,39 +707,40 @@ interface PullRequest { } /** - * GitHub logins of the Junior bot. REST returns `sentry-junior[bot]`, while - * GraphQL returns the bare `sentry-junior` login for bot authors. + * Normalizes a GitHub login for comparison. Some GitHub APIs return bot logins + * with a `[bot]` suffix and others without it. */ -const JUNIOR_BOT_LOGINS = new Set(['sentry-junior', 'sentry-junior[bot]']); - -/** Junior's runtime-owned requester block, e.g. `via @octocat, **Jane**.` */ -const JUNIOR_REQUEST_ATTRIBUTION_REGEX = - /([\s\S]*?)/; - -/** A requester label that is a GitHub `@login` mention, not a display name. */ -const JUNIOR_REQUESTER_LOGIN_REGEX = - /(?:\b(?:via|by) |, )@([A-Za-z0-9][A-Za-z0-9_-]*)(?=[,.\s]|$)/; +function authorKey(login: string): string { + return login.toLowerCase().replace(/\[bot\]$/, ''); +} /** - * Resolves the author to credit in the changelog. + * Lists the GitHub logins to credit for a changelog entry: the author first, + * then co-authors, without duplicates or authors in `hide_authors`. * - * PRs opened by Junior on someone's behalf credit the first requester with a - * linked GitHub login from Junior's attribution block. Falls back to the PR - * author when there is no such requester. - * - * @param author The PR author login - * @param prBody The PR description/body text - * @returns The login to credit in the changelog + * @param author The PR or commit author login + * @param coAuthors Logins of commit authors and co-authors + * @returns Logins to credit, possibly empty */ -export function resolveChangelogAuthor( +function creditedAuthors( author: string | undefined, - prBody: string | null | undefined, -): string | undefined { - if (!author || !prBody || !JUNIOR_BOT_LOGINS.has(author)) { - return author; + coAuthors: string[] = [], +): string[] { + const hidden = getNormalizedReleaseConfig()?.changelog.hideAuthors; + const seen = new Set(); + const authors: string[] = []; + for (const login of [author, ...coAuthors]) { + if (!login) { + continue; + } + const key = authorKey(login); + if (seen.has(key) || hidden?.has(key)) { + continue; + } + seen.add(key); + authors.push(login); } - const attribution = prBody.match(JUNIOR_REQUEST_ATTRIBUTION_REGEX)?.[1]; - return attribution?.match(JUNIOR_REQUESTER_LOGIN_REGEX)?.[1] ?? author; + return authors; } /** @@ -691,6 +755,7 @@ export function resolveChangelogAuthor( function createPREntriesFromRaw( raw: { author?: string; + coAuthors?: string[]; pr?: string; hash: string; prBody?: string | null; @@ -704,11 +769,11 @@ function createPREntriesFromRaw( ? defaultTitle : undefined; const customEntries = extractChangelogEntry(raw.prBody); - const author = resolveChangelogAuthor(raw.author, raw.prBody); + const authors = creditedAuthors(raw.author, raw.coAuthors); if (customEntries) { return customEntries.map(entry => ({ - author, + authors, number: raw.pr ?? '', hash: raw.hash, body: entry.nestedContent ?? '', @@ -720,7 +785,7 @@ function createPREntriesFromRaw( return [ { - author, + authors, number: raw.pr ?? '', hash: raw.hash, body: '', @@ -734,6 +799,7 @@ function createPREntriesFromRaw( interface Commit { author?: string; + coAuthors?: string[]; hash: string; title: string; body: string; @@ -756,6 +822,8 @@ interface RawCommitInfo { title: string; body: string; author?: string; + /** GitHub logins credited after `author`, e.g. from `Co-authored-by` trailers */ + coAuthors?: string[]; pr?: string; prTitle?: string; prBody?: string; @@ -901,6 +969,8 @@ export interface ReleaseConfig { labels?: string[]; authors?: string[]; }; + /** Authors never credited in entries. Their PRs are still included. */ + hide_authors?: string[]; categories?: ReleaseConfigCategory[]; }; } @@ -972,6 +1042,8 @@ export interface NormalizedReleaseConfig { labels: Set; authors: Set; }; + /** Normalized logins (see `authorKey`) never credited in entries */ + hideAuthors: Set; categories: NormalizedCategory[]; }; } @@ -1046,6 +1118,9 @@ export function normalizeReleaseConfig( labels: new Set(), authors: new Set(), }, + hideAuthors: new Set( + (config.changelog.hide_authors ?? []).map(authorKey), + ), categories: [], }, }; @@ -1348,7 +1423,8 @@ const SHORT_SHA_LENGTH = 8; interface ChangelogEntry { title: string; - author?: string; + /** GitHub logins credited for this entry */ + authors: string[]; prNumber?: string; hash: string; body?: string; @@ -1395,6 +1471,7 @@ export function stripTitle( /** * Formats a single changelog entry with consistent full markdown link format. * Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)` + * Several authors render as `@a and @b`, `@a, @b and @c`, or `@a, @b and 2 others`. * When highlight is true, the entry is prefixed with `> ` (blockquote). * When disableMentions is true, uses `**author**` instead of `@author` to avoid pinging. * When disablePRLinks is true, uses plain `#123` instead of markdown links to avoid cross-references. @@ -1418,11 +1495,20 @@ function formatChangelogEntry( let text = `- ${title}`; // Format author reference: use bold instead of @ mention when disableMentions is true - const authorRef = entry.author - ? disableMentions - ? `**${entry.author}**` - : `@${entry.author}` - : null; + const authorRefs = entry.authors.map(author => + disableMentions ? `**${author}**` : `@${author}`, + ); + const shownRefs = + authorRefs.length > MAX_CREDITED_AUTHORS + ? [ + ...authorRefs.slice(0, MAX_CREDITED_AUTHORS - 1), + `${authorRefs.length - MAX_CREDITED_AUTHORS + 1} others`, + ] + : authorRefs; + const authorRef = + shownRefs.length > 1 + ? `${shownRefs.slice(0, -1).join(', ')} and ${shownRefs.at(-1)}` + : shownRefs[0]; if (entry.prNumber) { // Format PR reference: use backticks to prevent GitHub auto-linking when disablePRLinks is true @@ -1627,6 +1713,7 @@ export async function generateChangelogWithHighlight( title: prInfo.title.trim(), body: prInfo.body, author: prInfo.author, + coAuthors: await fetchPRCoAuthors(currentPRNumber), pr: currentPRStr, prTitle: prInfo.title, prBody: prInfo.body, @@ -1720,6 +1807,7 @@ async function fetchRawCommitInfo( title: gitCommit.title, body: gitCommit.body, author: githubCommit?.author, + coAuthors: githubCommit?.coAuthors, pr, prTitle: githubCommit?.prTitle ?? undefined, prBody: githubCommit?.prBody ?? undefined, @@ -1787,6 +1875,7 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { // No category match - goes to leftovers leftovers.push({ author: raw.author, + coAuthors: raw.coAuthors, hash: raw.hash, title: raw.title, body: raw.body, @@ -2024,7 +2113,7 @@ async function serializeChangelog( formatChangelogEntry( { title: stripTitle(pr.title, pr.matchedPattern, !showsScopeHeader), - author: pr.author, + authors: pr.authors, prNumber: pr.number, hash: pr.hash, body: pr.body, @@ -2066,6 +2155,7 @@ async function serializeChangelog( const prEntries = createPREntriesFromRaw( { author: commit.author, + coAuthors: commit.coAuthors, pr: commit.pr ?? undefined, hash: commit.hash, prBody: commit.prBody, @@ -2080,7 +2170,7 @@ async function serializeChangelog( formatChangelogEntry( { title: pr.title, - author: pr.author, + authors: pr.authors, prNumber: pr.number || undefined, hash: pr.hash, repoUrl, @@ -2173,6 +2263,7 @@ interface CommitInfo { author: { user?: { login: string }; }; + authors?: CommitAuthors; associatedPullRequests: { nodes: Array<{ number: string; @@ -2203,6 +2294,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< /* hash */ string, { author?: string; + coAuthors: string[]; pr: string | null; prTitle: string | null; prBody: string | null; @@ -2241,6 +2333,9 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< author { user { login } } + authors(first: ${MAX_COMMIT_AUTHORS}) { + nodes { user { login } } + } associatedPullRequests(first: 1) { nodes { author { @@ -2302,12 +2397,15 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< return Object.fromEntries( Object.entries(commitInfo).map(([hash, commit]) => { const pr = commit?.associatedPullRequests.nodes[0]; + // The git author always comes first, followed by `Co-authored-by` trailers + const coAuthors = authorLogins(commit?.authors?.nodes.slice(1)); return [ // Strip the prefix on the hash we used to workaround in GraphQL hash.slice(1), pr ? { author: pr.author?.login, + coAuthors, pr: pr.number, prTitle: pr.title, prBody: pr.body, @@ -2315,6 +2413,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< } : { author: commit?.author.user?.login, + coAuthors, pr: null, prTitle: null, prBody: null, From 1d34c2d76dbacdffd107dd42db769983e1ce1dac Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:10:31 +0000 Subject: [PATCH 4/6] feat(changelog): Credit bots after people Render bot authors after human authors, as in "by @alice with @some-bot", so PRs a bot opens on someone's behalf credit both. Bot PR authors come from the GraphQL author type or REST user type; bot co-authors from their [bot] login suffix. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/configuration.md | 5 +- .../__tests__/changelog-generate.test.ts | 40 +++++++- src/utils/__tests__/fixtures/changelog.ts | 2 +- src/utils/changelog.ts | 93 +++++++++++++------ 4 files changed, 107 insertions(+), 33 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 6b51d745..4dd4dbd6 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -691,8 +691,9 @@ changelog: Entries credit the PR author and any co-authors from `Co-authored-by` commit trailers that GitHub links to an account, for example `by @alice, @bob and 2 -others`. To keep a PR in the changelog without crediting an author, such as a -bot that opens PRs on someone's behalf, list it in `hide_authors`: +others`. Bots are credited after people, as in `by @alice with @some-bot`. To +keep a PR in the changelog without crediting an author, list it in +`hide_authors`: ```yaml changelog: diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 5eb68681..948c863e 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -829,7 +829,10 @@ describe('generateChangesetFromGit', () => { body: '', coAuthors, pr: { - remote: { number: '123', author: { login: 'sentry-junior' } }, + remote: { + number: '123', + author: { login: 'sentry-junior', __typename: 'Bot' }, + }, }, }; } @@ -872,6 +875,41 @@ describe('generateChangesetFromGit', () => { ); }); + it('credits people before bots', async () => { + setup([junior(['sentry-junior[bot]', 'jan-auer'])], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @jan-auer with @sentry-junior in [#123]', + ); + }); + + it('credits bot co-authors after people', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + coAuthors: ['some-bot[bot]'], + pr: { remote: { number: '123', author: { login: 'alice' } } }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @alice with @some-bot in [#123]', + ); + }); + + it('credits a bot alone when no person is credited', async () => { + setup([junior([])], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @sentry-junior in [#123]', + ); + }); + it('hides authors listed in hide_authors but keeps their PRs', async () => { setup( [junior(['sentry-junior[bot]', 'jan-auer'])], diff --git a/src/utils/__tests__/fixtures/changelog.ts b/src/utils/__tests__/fixtures/changelog.ts index fb5068c7..c8f9b1ba 100644 --- a/src/utils/__tests__/fixtures/changelog.ts +++ b/src/utils/__tests__/fixtures/changelog.ts @@ -62,7 +62,7 @@ export interface TestCommit { pr?: { local?: string; remote?: { - author?: { login: string }; + author?: { login: string; __typename?: string }; number: string; title?: string; body?: string; diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 53b0018e..9f0d0cff 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -30,6 +30,8 @@ export interface CurrentPRInfo { title: string; body: string; author: string; + /** Whether the PR author is a bot account */ + authorIsBot?: boolean; labels: string[]; /** Base branch ref (e.g., "master") for computing merge base */ baseRef: string; @@ -65,6 +67,7 @@ async function fetchPRInfo(prNumber: number): Promise { title: pr.title, body: pr.body ?? '', author: pr.user?.login ?? '', + authorIsBot: pr.user?.type === 'Bot', labels: labels.map(l => l.name), baseRef: pr.base.ref, headSha: pr.head.sha, @@ -689,9 +692,14 @@ export function prependChangeset( return markdown.slice(0, startIdx) + newSection + markdown.slice(startIdx); } +/** GitHub logins credited for a changelog entry */ +interface Credits { + people: string[]; + bots: string[]; +} + interface PullRequest { - /** GitHub logins credited for this entry */ - authors: string[]; + credits: Credits; number: string; hash: string; body: string; @@ -716,19 +724,22 @@ function authorKey(login: string): string { /** * Lists the GitHub logins to credit for a changelog entry: the author first, - * then co-authors, without duplicates or authors in `hide_authors`. + * then co-authors, without duplicates or authors in `hide_authors`. Bots are + * listed separately so that people can be credited first. * * @param author The PR or commit author login + * @param authorIsBot Whether the author is a bot account * @param coAuthors Logins of commit authors and co-authors - * @returns Logins to credit, possibly empty + * @returns People and bots to credit, possibly empty */ -function creditedAuthors( +function creditAuthors( author: string | undefined, + authorIsBot = false, coAuthors: string[] = [], -): string[] { +): Credits { const hidden = getNormalizedReleaseConfig()?.changelog.hideAuthors; const seen = new Set(); - const authors: string[] = []; + const credits: Credits = { people: [], bots: [] }; for (const login of [author, ...coAuthors]) { if (!login) { continue; @@ -738,9 +749,14 @@ function creditedAuthors( continue; } seen.add(key); - authors.push(login); + // Co-authors only identify bots by their `[bot]` login suffix + if ((login === author && authorIsBot) || /\[bot\]$/i.test(login)) { + credits.bots.push(login.replace(/\[bot\]$/i, '')); + } else { + credits.people.push(login); + } } - return authors; + return credits; } /** @@ -755,6 +771,7 @@ function creditedAuthors( function createPREntriesFromRaw( raw: { author?: string; + authorIsBot?: boolean; coAuthors?: string[]; pr?: string; hash: string; @@ -769,11 +786,11 @@ function createPREntriesFromRaw( ? defaultTitle : undefined; const customEntries = extractChangelogEntry(raw.prBody); - const authors = creditedAuthors(raw.author, raw.coAuthors); + const credits = creditAuthors(raw.author, raw.authorIsBot, raw.coAuthors); if (customEntries) { return customEntries.map(entry => ({ - authors, + credits, number: raw.pr ?? '', hash: raw.hash, body: entry.nestedContent ?? '', @@ -785,7 +802,7 @@ function createPREntriesFromRaw( return [ { - authors, + credits, number: raw.pr ?? '', hash: raw.hash, body: '', @@ -799,6 +816,7 @@ function createPREntriesFromRaw( interface Commit { author?: string; + authorIsBot?: boolean; coAuthors?: string[]; hash: string; title: string; @@ -822,6 +840,8 @@ interface RawCommitInfo { title: string; body: string; author?: string; + /** Whether the author is a bot account */ + authorIsBot?: boolean; /** GitHub logins credited after `author`, e.g. from `Co-authored-by` trailers */ coAuthors?: string[]; pr?: string; @@ -1423,8 +1443,7 @@ const SHORT_SHA_LENGTH = 8; interface ChangelogEntry { title: string; - /** GitHub logins credited for this entry */ - authors: string[]; + credits: Credits; prNumber?: string; hash: string; body?: string; @@ -1472,6 +1491,7 @@ export function stripTitle( * Formats a single changelog entry with consistent full markdown link format. * Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)` * Several authors render as `@a and @b`, `@a, @b and @c`, or `@a, @b and 2 others`. + * Bots follow people, as in `@a with @some-bot`. * When highlight is true, the entry is prefixed with `> ` (blockquote). * When disableMentions is true, uses `**author**` instead of `@author` to avoid pinging. * When disablePRLinks is true, uses plain `#123` instead of markdown links to avoid cross-references. @@ -1495,20 +1515,27 @@ function formatChangelogEntry( let text = `- ${title}`; // Format author reference: use bold instead of @ mention when disableMentions is true - const authorRefs = entry.authors.map(author => - disableMentions ? `**${author}**` : `@${author}`, - ); - const shownRefs = - authorRefs.length > MAX_CREDITED_AUTHORS - ? [ - ...authorRefs.slice(0, MAX_CREDITED_AUTHORS - 1), - `${authorRefs.length - MAX_CREDITED_AUTHORS + 1} others`, - ] - : authorRefs; + const formatAuthors = (authors: string[]): string | undefined => { + const refs = authors.map(author => + disableMentions ? `**${author}**` : `@${author}`, + ); + const shown = + refs.length > MAX_CREDITED_AUTHORS + ? [ + ...refs.slice(0, MAX_CREDITED_AUTHORS - 1), + `${refs.length - MAX_CREDITED_AUTHORS + 1} others`, + ] + : refs; + return shown.length > 1 + ? `${shown.slice(0, -1).join(', ')} and ${shown.at(-1)}` + : shown[0]; + }; + const peopleRef = formatAuthors(entry.credits.people); + const botsRef = formatAuthors(entry.credits.bots); const authorRef = - shownRefs.length > 1 - ? `${shownRefs.slice(0, -1).join(', ')} and ${shownRefs.at(-1)}` - : shownRefs[0]; + peopleRef && botsRef + ? `${peopleRef} with ${botsRef}` + : peopleRef || botsRef; if (entry.prNumber) { // Format PR reference: use backticks to prevent GitHub auto-linking when disablePRLinks is true @@ -1713,6 +1740,7 @@ export async function generateChangelogWithHighlight( title: prInfo.title.trim(), body: prInfo.body, author: prInfo.author, + authorIsBot: prInfo.authorIsBot, coAuthors: await fetchPRCoAuthors(currentPRNumber), pr: currentPRStr, prTitle: prInfo.title, @@ -1807,6 +1835,7 @@ async function fetchRawCommitInfo( title: gitCommit.title, body: gitCommit.body, author: githubCommit?.author, + authorIsBot: githubCommit?.authorIsBot, coAuthors: githubCommit?.coAuthors, pr, prTitle: githubCommit?.prTitle ?? undefined, @@ -1875,6 +1904,7 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { // No category match - goes to leftovers leftovers.push({ author: raw.author, + authorIsBot: raw.authorIsBot, coAuthors: raw.coAuthors, hash: raw.hash, title: raw.title, @@ -2113,7 +2143,7 @@ async function serializeChangelog( formatChangelogEntry( { title: stripTitle(pr.title, pr.matchedPattern, !showsScopeHeader), - authors: pr.authors, + credits: pr.credits, prNumber: pr.number, hash: pr.hash, body: pr.body, @@ -2155,6 +2185,7 @@ async function serializeChangelog( const prEntries = createPREntriesFromRaw( { author: commit.author, + authorIsBot: commit.authorIsBot, coAuthors: commit.coAuthors, pr: commit.pr ?? undefined, hash: commit.hash, @@ -2170,7 +2201,7 @@ async function serializeChangelog( formatChangelogEntry( { title: pr.title, - authors: pr.authors, + credits: pr.credits, prNumber: pr.number || undefined, hash: pr.hash, repoUrl, @@ -2270,6 +2301,7 @@ interface CommitInfo { title: string; body: string; author?: { + __typename?: string; login: string; }; labels: { @@ -2294,6 +2326,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< /* hash */ string, { author?: string; + authorIsBot?: boolean; coAuthors: string[]; pr: string | null; prTitle: string | null; @@ -2339,6 +2372,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< associatedPullRequests(first: 1) { nodes { author { + __typename login } number @@ -2405,6 +2439,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< pr ? { author: pr.author?.login, + authorIsBot: pr.author?.__typename === 'Bot', coAuthors, pr: pr.number, prTitle: pr.title, From 7f65a1826e7b31d147c3c6330f20a14a47890595 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:12:15 +0000 Subject: [PATCH 5/6] fix(changelog): Credit co-authors from every commit of a PR Rebase merges put several commits of one PR on the base branch. Merge the co-authors of all of them into the deduplicated entry instead of keeping only the newest commit's. --- .../__tests__/changelog-generate.test.ts | 26 +++++++++++++++++++ src/utils/changelog.ts | 23 +++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 948c863e..e58c6f48 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -856,6 +856,32 @@ describe('generateChangesetFromGit', () => { ); }); + it('credits co-authors from every commit of a rebase-merged PR', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + coAuthors: ['bob'], + pr: { remote: { number: '123', author: { login: 'alice' } } }, + }, + { + hash: 'bcdef1234567890a', + title: 'Prepared the kernel', + body: '', + coAuthors: ['carol'], + pr: { remote: { number: '123', author: { login: 'alice' } } }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toContain( + 'Upgraded the kernel by @alice, @bob and @carol in [#123]', + ); + }); + it('collapses long author lists', async () => { setup( [ diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 9f0d0cff..f978bc57 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -1817,20 +1817,25 @@ async function fetchRawCommitInfo( // 1. Rebase-merge workflows where multiple commits share the same PR number // 2. Already-merged PRs that appear in git history when generating previews // Note: PR body magic word check is handled by shouldExcludePR in categorizeCommits - const seenPRs = new Set(); + const seenPRs = new Map(); const result: RawCommitInfo[] = []; for (const gitCommit of gitCommits) { const githubCommit = githubCommits[gitCommit.hash]; const pr = githubCommit?.pr ?? gitCommit.pr ?? undefined; - // Deduplicate by PR number, but keep all commits without PR association - if (pr) { - if (seenPRs.has(pr)) continue; - seenPRs.add(pr); + // Deduplicate by PR number, but keep all commits without PR association. + // Co-authors of every commit in the PR are credited on the kept entry. + const seen = pr ? seenPRs.get(pr) : undefined; + if (seen) { + seen.coAuthors = [ + ...(seen.coAuthors ?? []), + ...(githubCommit?.coAuthors ?? []), + ]; + continue; } - result.push({ + const commit: RawCommitInfo = { hash: gitCommit.hash, title: gitCommit.title, body: gitCommit.body, @@ -1841,7 +1846,11 @@ async function fetchRawCommitInfo( prTitle: githubCommit?.prTitle ?? undefined, prBody: githubCommit?.prBody ?? undefined, labels: githubCommit?.labels ?? [], - }); + }; + if (pr) { + seenPRs.set(pr, commit); + } + result.push(commit); } return result; From 9232efefc9aede7914b6cc3587a43aa94c803b5d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:29:56 +0000 Subject: [PATCH 6/6] ref(changelog): Address review feedback on author credits Default hidden authors to an empty set, document that a sole bot author is credited on its own, and explain why the PR author __typename is queried. --- .../__tests__/changelog-generate.test.ts | 21 ++++++++++++++++--- src/utils/changelog.ts | 10 ++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index e58c6f48..fb275d36 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -928,11 +928,26 @@ describe('generateChangesetFromGit', () => { ); }); - it('credits a bot alone when no person is credited', async () => { - setup([junior([])], null); + it('credits a bot alone when it is the sole author', async () => { + setup( + [ + { + hash: 'abcdef1234567890', + title: 'Bumped the kernel', + body: '', + pr: { + remote: { + number: '123', + author: { login: 'dependabot', __typename: 'Bot' }, + }, + }, + }, + ], + null, + ); const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); expect(result.changelog).toContain( - 'Upgraded the kernel by @sentry-junior in [#123]', + 'Bumped the kernel by @dependabot in [#123]', ); }); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index f978bc57..8561db2e 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -737,7 +737,8 @@ function creditAuthors( authorIsBot = false, coAuthors: string[] = [], ): Credits { - const hidden = getNormalizedReleaseConfig()?.changelog.hideAuthors; + const hidden = + getNormalizedReleaseConfig()?.changelog.hideAuthors ?? new Set(); const seen = new Set(); const credits: Credits = { people: [], bots: [] }; for (const login of [author, ...coAuthors]) { @@ -745,7 +746,7 @@ function creditAuthors( continue; } const key = authorKey(login); - if (seen.has(key) || hidden?.has(key)) { + if (seen.has(key) || hidden.has(key)) { continue; } seen.add(key); @@ -1491,7 +1492,8 @@ export function stripTitle( * Formats a single changelog entry with consistent full markdown link format. * Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)` * Several authors render as `@a and @b`, `@a, @b and @c`, or `@a, @b and 2 others`. - * Bots follow people, as in `@a with @some-bot`. + * Bots follow people, as in `@a with @some-bot`. A bot that is the only + * credited author renders on its own, as in `by @dependabot`. * When highlight is true, the entry is prefixed with `> ` (blockquote). * When disableMentions is true, uses `**author**` instead of `@author` to avoid pinging. * When disablePRLinks is true, uses plain `#123` instead of markdown links to avoid cross-references. @@ -2310,6 +2312,7 @@ interface CommitInfo { title: string; body: string; author?: { + /** GraphQL type of the author; `Bot` for GitHub App accounts */ __typename?: string; login: string; }; @@ -2381,6 +2384,7 @@ export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< associatedPullRequests(first: 1) { nodes { author { + # Identifies bots, whose PR author login lacks the [bot] suffix __typename login }