Skip to content
14 changes: 14 additions & 0 deletions docs/src/content/docs/changelogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@ 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`. 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` in `.github/release.yml`:

```yaml
changelog:
hide_authors:
- my-bot[bot]
```

## Previewing a changelog

Generate a changelog from git history without preparing a release. This is useful for previewing what would be included in a release or for CI integrations.
Expand Down
240 changes: 218 additions & 22 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>;
Expand Down Expand Up @@ -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
? [
Expand Down Expand Up @@ -813,6 +821,166 @@ 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', __typename: 'Bot' },
},
},
};
}

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('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(
[
{
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('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 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(
'Bumped the kernel by @dependabot 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
// ============================================================================
Expand Down Expand Up @@ -2190,29 +2358,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,
})),
},
},
},
]
: [],
]
: [],
},
},
},
]),
],
),
),
});

Expand All @@ -2236,6 +2407,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({
Expand Down
4 changes: 3 additions & 1 deletion src/utils/__tests__/fixtures/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ 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;
pr?: {
local?: string;
remote?: {
author?: { login: string };
author?: { login: string; __typename?: string };
number: string;
title?: string;
body?: string;
Expand Down
Loading
Loading