diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 9ba6508c..8f69e2b2 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -115,6 +115,76 @@ changelog: - "^fix(\\(\\w+\\))?:" ``` +### Custom Changelog Entries from PR Descriptions + +By default, the changelog entry for a PR is generated from its title. However, +PR authors can override this by adding a "Changelog Entry" section to the PR +description. This allows for more detailed, user-facing changelog entries without +cluttering the PR title. + +To use this feature, add a markdown heading (level 2 or 3) titled "Changelog Entry" +to your PR description, followed by the desired changelog text: + +```markdown +### Description + +Add `foo` function, and add unit tests to thoroughly check all edge cases. + +### Changelog Entry + +Add a new function called `foo` which prints "Hello, world!" + +### Issues + +Closes #123 +``` + +The text under "Changelog Entry" will be used verbatim in the changelog instead +of the PR title. If no such section is present, the PR title is used as usual. + +#### Advanced Features + +1. **Multiple Entries**: If you use multiple top-level bullet points in the + "Changelog Entry" section, each bullet will become a separate changelog entry: + + ```markdown + ### Changelog Entry + + - Add OAuth2 authentication + - Add two-factor authentication + - Add session management + ``` + +2. **Nested Content**: Indented bullets (4+ spaces or tabs) are preserved as + nested content under their parent entry: + + ```markdown + ### Changelog Entry + + - Add authentication system + - OAuth2 support + - Two-factor authentication + - Session management + ``` + + This will generate: + ```markdown + - Add authentication system by @user in [#123](url) + - OAuth2 support + - Two-factor authentication + - Session management + ``` + + Note: Nested items do NOT get author/PR attribution - only the top-level entry does. + +3. **Plain Text**: If no bullets are used, the entire content is treated as a + single changelog entry. Multi-line text is automatically joined with spaces + to ensure valid markdown output. + +4. **Content Isolation**: Only content within the "Changelog Entry" section is + included in the changelog. Other sections (Description, Issues, etc.) are + ignored. + ### Scope Grouping Changes are automatically grouped by scope (e.g., `feat(api):` groups under "Api"): @@ -125,6 +195,26 @@ changelog: scopeGrouping: true # default ``` +Scope headers are only shown for scopes with more than one entry. Entries without +a scope are listed at the bottom of each category section without a sub-header. + +Example output with scope grouping: + +```text +### New Features + +#### Api + +- feat(api): add user endpoint by @alice in [#1](https://github.com/...) +- feat(api): add auth endpoint by @bob in [#2](https://github.com/...) + +#### Ui + +- feat(ui): add dashboard by @charlie in [#3](https://github.com/...) + +- feat: general improvement by @dave in [#4](https://github.com/...) +``` + ### Configuration Options | Option | Description | diff --git a/jest.config.js b/jest.config.js index e31d8458..3af717ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,16 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testPathIgnorePatterns: ['/dist/', '/node_modules/'], + testPathIgnorePatterns: [ + '/dist/', + '/node_modules/', + '/src/.*/fixtures/', + ], modulePathIgnorePatterns: ['/dist/'], transformIgnorePatterns: [ 'node_modules/(?!(dot-prop|configstore)/)', ], + moduleNameMapper: { + '^marked$': '/node_modules/marked/lib/marked.umd.js', + }, }; diff --git a/package.json b/package.json index 16eda59d..25bce0df 100644 --- a/package.json +++ b/package.json @@ -106,5 +106,8 @@ "volta": { "node": "22.12.0", "yarn": "1.22.19" + }, + "dependencies": { + "marked": "^17.0.1" } } diff --git a/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap new file mode 100644 index 00000000..950e4fe7 --- /dev/null +++ b/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractChangelogEntry basic extraction extracts from ## Changelog Entry section 1`] = ` +[ + { + "text": "Add a new function called \`foo\` which prints "Hello, world!"", + }, +] +`; + +exports[`extractChangelogEntry basic extraction extracts from ### Changelog Entry section 1`] = ` +[ + { + "text": "Add a new function called \`foo\` which prints "Hello, world!"", + }, +] +`; + +exports[`extractChangelogEntry basic extraction handles changelog entry at end of body 1`] = ` +[ + { + "text": "This is the last section with no sections after it.", + }, +] +`; + +exports[`extractChangelogEntry bullet point handling handles multiple top-level bullets with nested content 1`] = ` +[ + { + "nestedContent": " - Detail A + - Detail B", + "text": "First feature", + }, + { + "nestedContent": " - Detail C", + "text": "Second feature", + }, +] +`; + +exports[`extractChangelogEntry bullet point handling handles nested bullets 1`] = ` +[ + { + "nestedContent": " - Nested item 1 + - Nested item 2", + "text": "Main entry", + }, +] +`; + +exports[`extractChangelogEntry bullet point handling parses multiple bullets as separate entries 1`] = ` +[ + { + "text": "First entry", + }, + { + "text": "Second entry", + }, + { + "text": "Third entry", + }, +] +`; + +exports[`extractChangelogEntry edge cases case-insensitive heading match 1`] = ` +[ + { + "text": "This should still be extracted.", + }, +] +`; + +exports[`extractChangelogEntry edge cases handles CRLF line endings 1`] = ` +[ + { + "text": "Entry with CRLF", + }, + { + "text": "Another entry", + }, +] +`; + +exports[`extractChangelogEntry edge cases treats multi-line plain text as single entry 1`] = ` +[ + { + "text": "This is a multi-line changelog entry that spans several lines.", + }, +] +`; + +exports[`extractChangelogEntry paragraph and list combinations handles paragraph followed by list with blank line 1`] = ` +[ + { + "text": "Intro paragraph:", + }, + { + "text": "First item", + }, + { + "text": "Second item", + }, +] +`; diff --git a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap new file mode 100644 index 00000000..bc7d1158 --- /dev/null +++ b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateChangesetFromGit category matching applies global exclusions 1`] = ` +"### Features + +- Normal feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)" +`; + +exports[`generateChangesetFromGit category matching matches PRs to categories based on labels 1`] = ` +"### Features + +- Feature PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) + +### Bug Fixes + +- Bug fix PR by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)" +`; + +exports[`generateChangesetFromGit category matching supports wildcard category matching 1`] = ` +"### Changes + +- Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)" +`; + +exports[`generateChangesetFromGit commit patterns labels take precedence over commit_patterns 1`] = ` +"### Labeled Features + +- feat: labeled feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) + +### Pattern Features + +- feat: pattern-only feature by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)" +`; + +exports[`generateChangesetFromGit commit patterns matches PRs based on commit_patterns 1`] = ` +"### Features + +- feat: add new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) + +### Bug Fixes + +- fix: fix bug by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)" +`; + +exports[`generateChangesetFromGit commit patterns uses default conventional commits config when no config exists 1`] = ` +"### New Features ✨ + +- feat: new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) + +### Bug Fixes 🐛 + +- fix: bug fix by @bob in [#2](https://github.com/test-owner/test-repo/pull/2) + +### Documentation 📚 + +- docs: update readme by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)" +`; + +exports[`generateChangesetFromGit custom changelog entries handles multiple bullets in changelog entry 1`] = ` +"### New Features ✨ + +- First entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) +- Second entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) +- Third entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)" +`; + +exports[`generateChangesetFromGit custom changelog entries handles nested bullets in changelog entry 1`] = ` +"### New Features ✨ + +- Main entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) + - Nested item 1 + - Nested item 2" +`; + +exports[`generateChangesetFromGit custom changelog entries uses custom entry from PR body 1`] = ` +"### New Features ✨ + +- Custom changelog entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)" +`; + +exports[`generateChangesetFromGit output formatting escapes underscores in titles 1`] = `"- Serialized \\_meta in [#123](https://github.com/test-owner/test-repo/pull/123)"`; + +exports[`generateChangesetFromGit output formatting formats local commit with short SHA 1`] = `"- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)"`; + +exports[`generateChangesetFromGit output formatting handles multiple commits 1`] = ` +"- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890) +- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123) +- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456) + +_Plus 1 more_" +`; + +exports[`generateChangesetFromGit output formatting handles null PR author gracefully 1`] = `"- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)"`; + +exports[`generateChangesetFromGit output formatting uses PR number and author from remote 1`] = `"- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)"`; + +exports[`generateChangesetFromGit output formatting uses PR number when available locally 1`] = `"- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)"`; + +exports[`generateChangesetFromGit output formatting uses PR title from GitHub instead of commit message 1`] = ` +"### New Features ✨ + +- feat: A much better PR title with more context by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)" +`; + +exports[`generateChangesetFromGit scope grouping groups PRs by scope when multiple entries exist 1`] = ` +"### Features + +#### Api + +- feat(api): add endpoint 1 by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) +- feat(api): add endpoint 2 by @bob in [#2](https://github.com/test-owner/test-repo/pull/2) + +- feat(ui): add button by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)" +`; + +exports[`generateChangesetFromGit scope grouping places scopeless entries at bottom 1`] = ` +"### Features + +#### Api + +- feat(api): scoped feature 1 by @alice in [#1](https://github.com/test-owner/test-repo/pull/1) +- feat(api): scoped feature 2 by @bob in [#2](https://github.com/test-owner/test-repo/pull/2) + +#### Other + +- feat: scopeless feature by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)" +`; diff --git a/src/utils/__tests__/changelog-extract.test.ts b/src/utils/__tests__/changelog-extract.test.ts new file mode 100644 index 00000000..7ed236d0 --- /dev/null +++ b/src/utils/__tests__/changelog-extract.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for changelog extraction and parsing functions. + * - extractScope: Extracts scope from conventional commit titles + * - formatScopeTitle: Formats scope for display + * - extractChangelogEntry: Extracts custom changelog entries from PR bodies + */ + +import { extractScope, formatScopeTitle, extractChangelogEntry } from '../changelog'; + +describe('extractScope', () => { + it.each([ + ['feat(api): add endpoint', 'api'], + ['fix(ui): fix button', 'ui'], + ['feat(my-component): add feature', 'my-component'], + ['feat(my_component): add feature', 'my-component'], + ['feat(API): uppercase scope', 'api'], + ['feat(MyComponent): mixed case', 'mycomponent'], + ['feat(scope)!: breaking change', 'scope'], + ['fix(core)!: another breaking', 'core'], + ['docs(readme): update docs', 'readme'], + ['chore(deps): update dependencies', 'deps'], + ['feat(my-long_scope): mixed separators', 'my-long-scope'], + ])('extracts scope from "%s" as "%s"', (title, expected) => { + expect(extractScope(title)).toBe(expected); + }); + + it.each([ + ['feat: no scope', null], + ['fix: simple fix', null], + ['random commit message', null], + ['feat!: breaking without scope', null], + ['(scope): missing type', null], + ['feat(): empty scope', null], + ])('returns null for "%s"', (title, expected) => { + expect(extractScope(title)).toBe(expected); + }); +}); + +describe('formatScopeTitle', () => { + it.each([ + ['api', 'Api'], + ['ui', 'Ui'], + ['my-component', 'My Component'], + ['my_component', 'My Component'], + ['multi-word-scope', 'Multi Word Scope'], + ['multi_word_scope', 'Multi Word Scope'], + ['API', 'API'], + ['mycomponent', 'Mycomponent'], + ])('formats "%s" as "%s"', (scope, expected) => { + expect(formatScopeTitle(scope)).toBe(expected); + }); +}); + +describe('extractChangelogEntry', () => { + describe('basic extraction', () => { + it('extracts from ### Changelog Entry section', () => { + const prBody = `### Description + +This PR adds a new feature. + +### Changelog Entry + +Add a new function called \`foo\` which prints "Hello, world!" + +### Issues + +Closes #123`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('extracts from ## Changelog Entry section', () => { + const prBody = `## Description + +This PR adds a new feature. + +## Changelog Entry + +Add a new function called \`foo\` which prints "Hello, world!" + +## Issues + +Closes #123`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('handles changelog entry at end of body', () => { + const prBody = `### Description + +This PR adds a new feature. + +### Changelog Entry + +This is the last section with no sections after it.`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + }); + + describe('bullet point handling', () => { + it('parses multiple bullets as separate entries', () => { + const prBody = `### Changelog Entry + +- First entry +- Second entry +- Third entry`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('handles nested bullets', () => { + const prBody = `### Changelog Entry + +- Main entry + - Nested item 1 + - Nested item 2`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('handles multiple top-level bullets with nested content', () => { + const prBody = `### Changelog Entry + +- First feature + - Detail A + - Detail B +- Second feature + - Detail C`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + }); + + describe('edge cases', () => { + it('returns null for null/undefined input', () => { + expect(extractChangelogEntry(null)).toBeNull(); + expect(extractChangelogEntry(undefined)).toBeNull(); + expect(extractChangelogEntry('')).toBeNull(); + }); + + it('returns null when no changelog entry section exists', () => { + const prBody = `### Description + +This PR has no changelog entry section.`; + + expect(extractChangelogEntry(prBody)).toBeNull(); + }); + + it('returns null for empty changelog entry section', () => { + const prBody = `### Changelog Entry + +### Next Section`; + + expect(extractChangelogEntry(prBody)).toBeNull(); + }); + + it('handles CRLF line endings', () => { + const prBody = '### Changelog Entry\r\n\r\n- Entry with CRLF\r\n- Another entry\r\n\r\n### Next'; + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('treats multi-line plain text as single entry', () => { + const prBody = `### Changelog Entry + +This is a multi-line +changelog entry that +spans several lines.`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + + it('case-insensitive heading match', () => { + const prBody = `### CHANGELOG ENTRY + +This should still be extracted.`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + }); + + describe('paragraph and list combinations', () => { + it('handles paragraph followed by list with blank line', () => { + const prBody = `### Changelog Entry + +Intro paragraph: + +- First item +- Second item`; + + expect(extractChangelogEntry(prBody)).toMatchSnapshot(); + }); + }); +}); + diff --git a/src/utils/__tests__/changelog-file-ops.test.ts b/src/utils/__tests__/changelog-file-ops.test.ts new file mode 100644 index 00000000..0b00da17 --- /dev/null +++ b/src/utils/__tests__/changelog-file-ops.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for changelog file operations: findChangeset, removeChangeset, prependChangeset. + * These functions work with CHANGELOG.md file content. + */ + +import { + findChangeset, + removeChangeset, + prependChangeset, +} from '../changelog'; +import { + SAMPLE_CHANGESET, + SAMPLE_CHANGESET_WITH_SUBHEADING, + createFullChangelog, +} from './fixtures/changelog'; + +describe('findChangeset', () => { + test.each([ + [ + 'regular ATX heading', + `# Changelog\n## ${SAMPLE_CHANGESET.name}\n${SAMPLE_CHANGESET.body}\n`, + ], + [ + 'with date in parentheses', + createFullChangelog('Changelog', [ + { version: '1.0.1', body: 'newer' }, + { version: `${SAMPLE_CHANGESET.name} (2019-02-02)`, body: SAMPLE_CHANGESET.body }, + { version: '0.9.0', body: 'older' }, + ]), + ], + [ + 'between other headings', + createFullChangelog('Changelog', [ + { version: '1.0.1', body: 'newer' }, + { version: SAMPLE_CHANGESET.name, body: SAMPLE_CHANGESET.body }, + { version: '0.9.0', body: 'older' }, + ]), + ], + [ + 'setext-style headings', + `Changelog\n====\n${SAMPLE_CHANGESET.name}\n----\n${SAMPLE_CHANGESET.body}\n`, + ], + ])('extracts %s', (_testName, markdown) => { + expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET); + }); + + test('supports sub-headings within version section', () => { + const markdown = createFullChangelog('Changelog', [ + { version: SAMPLE_CHANGESET_WITH_SUBHEADING.name, body: SAMPLE_CHANGESET_WITH_SUBHEADING.body }, + ]); + expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET_WITH_SUBHEADING); + }); + + test.each([ + ['changeset cannot be found', 'v1.0.0'], + ['invalid version', 'not a version'], + ])('returns null when %s', (_testName, version) => { + const markdown = createFullChangelog('Changelog', [ + { version: '1.0.1', body: 'newer' }, + { version: '0.9.0', body: 'older' }, + ]); + expect(findChangeset(markdown, version)).toEqual(null); + }); +}); + +describe('removeChangeset', () => { + const fullChangelog = [ + '# Changelog', + '', + '## 1.0.1', + '', + 'newer', + '', + '1.0.0', + '-------', + '', + 'this is a test', + '', + '## 0.9.1', + '', + 'slightly older', + '', + '## 0.9.0', + '', + 'older', + ].join('\n'); + + test('removes from the top', () => { + const expected = [ + '# Changelog', + '', + '1.0.0', + '-------', + '', + 'this is a test', + '', + '## 0.9.1', + '', + 'slightly older', + '', + '## 0.9.0', + '', + 'older', + ].join('\n'); + expect(removeChangeset(fullChangelog, '1.0.1')).toEqual(expected); + }); + + test('removes from the middle', () => { + const expected = [ + '# Changelog', + '', + '## 1.0.1', + '', + 'newer', + '', + '1.0.0', + '-------', + '', + 'this is a test', + '', + '## 0.9.0', + '', + 'older', + ].join('\n'); + expect(removeChangeset(fullChangelog, '0.9.1')).toEqual(expected); + }); + + test('removes setext-style heading', () => { + const expected = [ + '# Changelog', + '', + '## 1.0.1', + '', + 'newer', + '', + '## 0.9.1', + '', + 'slightly older', + '', + '## 0.9.0', + '', + 'older', + ].join('\n'); + expect(removeChangeset(fullChangelog, '1.0.0')).toEqual(expected); + }); + + test('removes from the bottom', () => { + const expected = [ + '# Changelog', + '', + '## 1.0.1', + '', + 'newer', + '', + '1.0.0', + '-------', + '', + 'this is a test', + '', + '## 0.9.1', + '', + 'slightly older', + '', + '', + ].join('\n'); + expect(removeChangeset(fullChangelog, '0.9.0')).toEqual(expected); + }); + + test('returns unchanged when header not found', () => { + expect(removeChangeset(fullChangelog, 'non-existent version')).toEqual(fullChangelog); + }); + + test('returns unchanged when header is empty', () => { + expect(removeChangeset(fullChangelog, '')).toEqual(fullChangelog); + }); +}); + +describe('prependChangeset', () => { + const newChangeset = { + body: '- rewrote everything from scratch\n- with multiple lines', + name: '2.0.0', + }; + + test.each([ + ['to empty text', '', '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n'], + [ + 'without top-level header', + '## 1.0.0\n\nthis is a test\n', + '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n', + ], + [ + 'after top-level header (empty body)', + '# Changelog\n', + '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n', + ], + [ + 'after top-level header', + '# Changelog\n\n## 1.0.0\n\nthis is a test\n', + '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n', + ], + [ + 'matching setext style when detected', + '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n', + '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n', + ], + ])('prepends %s', (_testName, markdown, expected) => { + expect(prependChangeset(markdown, newChangeset)).toEqual(expected); + }); +}); + diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts new file mode 100644 index 00000000..eadd6840 --- /dev/null +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -0,0 +1,535 @@ +/** + * Tests for generateChangesetFromGit - the main changelog generation function. + * Uses snapshot testing for output validation to reduce test file size. + */ + +/* eslint-env jest */ + +jest.mock('../githubApi.ts'); +import { getGitHubClient } from '../githubApi'; +jest.mock('../git'); +import { getChangesSince } from '../git'; +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn(), +})); +jest.mock('../../config', () => ({ + ...jest.requireActual('../../config'), + getConfigFileDir: jest.fn(), + getGlobalGitHubConfig: jest.fn(), +})); +import * as config from '../../config'; +import { readFileSync } from 'fs'; +import type { SimpleGit } from 'simple-git'; + +import { generateChangesetFromGit, clearChangesetCache } from '../changelog'; +import { type TestCommit } from './fixtures/changelog'; + +const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction; +const getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction; +const readFileSyncMock = readFileSync as jest.MockedFunction; + +describe('generateChangesetFromGit', () => { + let mockClient: jest.Mock; + const mockGetChangesSince = getChangesSince as jest.MockedFunction; + const dummyGit = {} as SimpleGit; + + beforeEach(() => { + jest.resetAllMocks(); + clearChangesetCache(); + mockClient = jest.fn(); + (getGitHubClient as jest.MockedFunction).mockReturnValue({ + graphql: mockClient, + } as any); + getConfigFileDirMock.mockReturnValue(undefined); + getGlobalGitHubConfigMock.mockResolvedValue({ + repo: 'test-repo', + owner: 'test-owner', + }); + readFileSyncMock.mockImplementation(() => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + }); + + function setup(commits: TestCommit[], releaseConfig?: string | null): void { + mockGetChangesSince.mockResolvedValueOnce( + commits.map(commit => ({ + hash: commit.hash, + title: commit.title, + body: commit.body, + pr: commit.pr?.local || null, + })) + ); + + mockClient.mockResolvedValueOnce({ + repository: Object.fromEntries( + commits.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, + })), + }, + }, + ] + : [], + }, + }, + ]) + ), + }); + + if (releaseConfig !== undefined) { + if (releaseConfig === null) { + getConfigFileDirMock.mockReturnValue(undefined); + readFileSyncMock.mockImplementation(() => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + } else { + getConfigFileDirMock.mockReturnValue('/workspace'); + readFileSyncMock.mockImplementation((path: any) => { + if (typeof path === 'string' && path.includes('.github/release.yml')) { + return releaseConfig; + } + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + } + } + } + + // ============================================================================ + // Basic output formatting tests - use snapshots + // ============================================================================ + + describe('output formatting', () => { + it('returns empty string for empty changeset', async () => { + setup([], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toBe(''); + }); + + it('formats local commit with short SHA', async () => { + setup([{ hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '' }], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('uses PR number when available locally', async () => { + setup([ + { hash: 'abcdef1234567890', title: 'Upgraded the kernel (#123)', body: '', pr: { local: '123' } }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('uses PR number and author from remote', async () => { + setup([ + { + hash: 'abcdef1234567890', + title: 'Upgraded the kernel', + body: '', + pr: { remote: { number: '123', author: { login: 'sentry' } } }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('handles null PR author gracefully', async () => { + setup([ + { hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '', pr: { remote: { number: '123' } } }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('uses PR title from GitHub instead of commit message', async () => { + setup([ + { + hash: 'abcdef1234567890', + title: 'fix: quick fix for issue', + body: '', + pr: { + remote: { + number: '123', + title: 'feat: A much better PR title with more context', + author: { login: 'sentry' }, + }, + }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('handles multiple commits', async () => { + setup([ + { hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '' }, + { + hash: 'bcdef1234567890a', + title: 'Upgraded the manifold (#123)', + body: '', + pr: { local: '123', remote: { number: '123', author: { login: 'alice' } } }, + }, + { + hash: 'cdef1234567890ab', + title: 'Refactored the crankshaft', + body: '', + pr: { remote: { number: '456', author: { login: 'bob' } } }, + }, + { + hash: 'cdef1234567890ad', + title: 'Refactored the crankshaft again', + body: '', + pr: { remote: { number: '458', author: { login: 'bob' } } }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('escapes underscores in titles', async () => { + setup([ + { + hash: 'abcdef1234567890', + title: 'Serialized _meta', + body: '', + pr: { remote: { number: '123' } }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + }); + + // ============================================================================ + // Category matching tests + // ============================================================================ + + describe('category matching', () => { + const BASIC_CONFIG = ` +changelog: + categories: + - title: Features + labels: + - enhancement + - title: Bug Fixes + labels: + - bug +`; + + it('matches PRs to categories based on labels', async () => { + setup([ + { + hash: 'abc123', + title: 'Feature PR', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } }, + }, + { + hash: 'def456', + title: 'Bug fix PR', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' }, labels: ['bug'] } }, + }, + ], BASIC_CONFIG); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('applies global exclusions', async () => { + const configWithExclusions = ` +changelog: + exclude: + labels: + - skip-changelog + authors: + - dependabot + categories: + - title: Features + labels: + - enhancement +`; + setup([ + { + hash: 'abc123', + title: 'Normal feature', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } }, + }, + { + hash: 'def456', + title: 'Should be excluded by label', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' }, labels: ['enhancement', 'skip-changelog'] } }, + }, + { + hash: 'ghi789', + title: 'Should be excluded by author', + body: '', + pr: { local: '3', remote: { number: '3', author: { login: 'dependabot' }, labels: ['enhancement'] } }, + }, + ], configWithExclusions); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + + it('supports wildcard category matching', async () => { + const wildcardConfig = ` +changelog: + categories: + - title: Changes + labels: + - "*" +`; + setup([ + { + hash: 'abc123', + title: 'Any PR', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['random-label'] } }, + }, + ], wildcardConfig); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + }); + + // ============================================================================ + // Commit patterns matching tests + // ============================================================================ + + describe('commit patterns', () => { + const PATTERN_CONFIG = ` +changelog: + categories: + - title: Features + commit_patterns: + - "^feat(\\\\([^)]+\\\\))?:" + - title: Bug Fixes + commit_patterns: + - "^fix(\\\\([^)]+\\\\))?:" +`; + + it('matches PRs based on commit_patterns', async () => { + setup([ + { + hash: 'abc123', + title: 'feat: add new feature', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } }, + }, + { + hash: 'def456', + title: 'fix: fix bug', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } }, + }, + ], PATTERN_CONFIG); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('labels take precedence over commit_patterns', async () => { + const mixedConfig = ` +changelog: + categories: + - title: Labeled Features + labels: + - enhancement + - title: Pattern Features + commit_patterns: + - "^feat:" +`; + setup([ + { + hash: 'abc123', + title: 'feat: labeled feature', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } }, + }, + { + hash: 'def456', + title: 'feat: pattern-only feature', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } }, + }, + ], mixedConfig); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('uses default conventional commits config when no config exists', async () => { + setup([ + { + hash: 'abc123', + title: 'feat: new feature', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } }, + }, + { + hash: 'def456', + title: 'fix: bug fix', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } }, + }, + { + hash: 'ghi789', + title: 'docs: update readme', + body: '', + pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + }); + + // ============================================================================ + // Scope grouping tests + // ============================================================================ + + describe('scope grouping', () => { + const SCOPE_CONFIG = ` +changelog: + scopeGrouping: true + categories: + - title: Features + commit_patterns: + - "^feat(\\\\([^)]+\\\\))?:" +`; + + it('groups PRs by scope when multiple entries exist', async () => { + setup([ + { + hash: 'abc123', + title: 'feat(api): add endpoint 1', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } }, + }, + { + hash: 'def456', + title: 'feat(api): add endpoint 2', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } }, + }, + { + hash: 'ghi789', + title: 'feat(ui): add button', + body: '', + pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } }, + }, + ], SCOPE_CONFIG); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + + it('places scopeless entries at bottom', async () => { + setup([ + { + hash: 'abc123', + title: 'feat(api): scoped feature 1', + body: '', + pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } }, + }, + { + hash: 'def456', + title: 'feat(api): scoped feature 2', + body: '', + pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } }, + }, + { + hash: 'ghi789', + title: 'feat: scopeless feature', + body: '', + pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } }, + }, + ], SCOPE_CONFIG); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + }); + + // ============================================================================ + // Custom changelog entries tests + // ============================================================================ + + describe('custom changelog entries', () => { + it('uses custom entry from PR body', async () => { + setup([ + { + hash: 'abc123', + title: 'feat: original title', + body: '', + pr: { + local: '1', + remote: { + number: '1', + author: { login: 'alice' }, + body: '## Changelog Entry\n\n- Custom changelog entry', + }, + }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + expect(result.changelog).toMatchSnapshot(); + }); + + it('handles multiple bullets in changelog entry', async () => { + setup([ + { + hash: 'abc123', + title: 'feat: original title', + body: '', + pr: { + local: '1', + remote: { + number: '1', + author: { login: 'alice' }, + body: '## Changelog Entry\n\n- First entry\n- Second entry\n- Third entry', + }, + }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + + it('handles nested bullets in changelog entry', async () => { + setup([ + { + hash: 'abc123', + title: 'feat: original title', + body: '', + pr: { + local: '1', + remote: { + number: '1', + author: { login: 'alice' }, + body: '## Changelog Entry\n\n- Main entry\n - Nested item 1\n - Nested item 2', + }, + }, + }, + ], null); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toMatchSnapshot(); + }); + }); +}); + diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts new file mode 100644 index 00000000..0a7be1f5 --- /dev/null +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for changelog utility functions. + * - shouldExcludePR: Checks if a PR should be excluded from changelog + * - shouldSkipCurrentPR: Checks if current PR should skip changelog generation + * - getBumpTypeForPR: Determines the version bump type for a PR + * + * Note: shouldSkipCurrentPR and getBumpTypeForPR read config internally, + * so they only take the PRInfo argument. More comprehensive tests are + * in the main changelog.test.ts file with proper config mocking. + */ + +import { + shouldExcludePR, + shouldSkipCurrentPR, + getBumpTypeForPR, + SKIP_CHANGELOG_MAGIC_WORD, + BODY_IN_CHANGELOG_MAGIC_WORD, + type CurrentPRInfo, +} from '../changelog'; + +describe('shouldExcludePR', () => { + // Config must match NormalizedReleaseConfig structure + const baseConfig = { + changelog: { + exclude: { + labels: ['skip-changelog', 'no-changelog'], + authors: new Set(['dependabot', 'renovate']), + }, + }, + }; + + it('returns true when PR has excluded label', () => { + expect(shouldExcludePR( + new Set(['bug', 'skip-changelog']), + 'alice', + baseConfig as any, + '' + )).toBe(true); + }); + + it('returns true when PR has excluded author', () => { + expect(shouldExcludePR( + new Set(['bug']), + 'dependabot', + baseConfig as any, + '' + )).toBe(true); + }); + + it('returns false when PR has no exclusion criteria', () => { + expect(shouldExcludePR( + new Set(['bug']), + 'alice', + baseConfig as any, + '' + )).toBe(false); + }); + + it('returns true when body contains skip magic word', () => { + expect(shouldExcludePR( + new Set(['bug']), + 'alice', + baseConfig as any, + `Some text\n${SKIP_CHANGELOG_MAGIC_WORD}\nMore text` + )).toBe(true); + }); + + it('returns false when config is null', () => { + expect(shouldExcludePR( + new Set(['bug']), + 'alice', + null, + '' + )).toBe(false); + }); +}); + +describe('shouldSkipCurrentPR', () => { + // Note: This function reads config internally, so we can only test + // the skip magic word behavior without mocking + const basePRInfo: CurrentPRInfo = { + number: 123, + title: 'Test PR', + body: '', + author: 'alice', + labels: [], + baseRef: 'main', + }; + + it('returns false when PR has no skip magic word', () => { + expect(shouldSkipCurrentPR(basePRInfo)).toBe(false); + }); + + it('returns true when PR body contains skip magic word', () => { + const prInfo = { ...basePRInfo, body: `Some text\n${SKIP_CHANGELOG_MAGIC_WORD}` }; + expect(shouldSkipCurrentPR(prInfo)).toBe(true); + }); +}); + +describe('getBumpTypeForPR', () => { + // Note: This function reads config internally and uses default + // conventional commits patterns + const basePRInfo: CurrentPRInfo = { + number: 123, + title: 'feat: new feature', + body: '', + author: 'alice', + labels: [], + baseRef: 'main', + }; + + it('returns major for breaking changes', () => { + const prInfo = { ...basePRInfo, title: 'feat!: breaking change' }; + expect(getBumpTypeForPR(prInfo)).toBe('major'); + }); + + it('returns minor for feat commits', () => { + const prInfo = { ...basePRInfo, title: 'feat: new feature' }; + expect(getBumpTypeForPR(prInfo)).toBe('minor'); + }); + + it('returns patch for fix commits', () => { + const prInfo = { ...basePRInfo, title: 'fix: bug fix' }; + expect(getBumpTypeForPR(prInfo)).toBe('patch'); + }); + + it('returns null for unrecognized commit types', () => { + const prInfo = { ...basePRInfo, title: 'random commit' }; + expect(getBumpTypeForPR(prInfo)).toBeNull(); + }); +}); + +describe('magic word constants', () => { + it('SKIP_CHANGELOG_MAGIC_WORD is defined', () => { + expect(SKIP_CHANGELOG_MAGIC_WORD).toBeDefined(); + expect(typeof SKIP_CHANGELOG_MAGIC_WORD).toBe('string'); + }); + + it('BODY_IN_CHANGELOG_MAGIC_WORD is defined', () => { + expect(BODY_IN_CHANGELOG_MAGIC_WORD).toBeDefined(); + expect(typeof BODY_IN_CHANGELOG_MAGIC_WORD).toBe('string'); + }); +}); + diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts deleted file mode 100644 index e2946329..00000000 --- a/src/utils/__tests__/changelog.test.ts +++ /dev/null @@ -1,3115 +0,0 @@ -/* eslint-env jest */ - -jest.mock('../githubApi.ts'); -import { getGitHubClient } from '../githubApi'; -jest.mock('../git'); -import { getChangesSince } from '../git'; -jest.mock('fs', () => ({ - ...jest.requireActual('fs'), - readFileSync: jest.fn(), -})); -jest.mock('../../config', () => ({ - ...jest.requireActual('../../config'), - getConfigFileDir: jest.fn(), - getGlobalGitHubConfig: jest.fn(), -})); -import * as config from '../../config'; - -import { readFileSync } from 'fs'; -import type { SimpleGit } from 'simple-git'; - -import { - findChangeset, - removeChangeset, - prependChangeset, - generateChangesetFromGit, - extractScope, - formatScopeTitle, - clearChangesetCache, - shouldExcludePR, - shouldSkipCurrentPR, - getBumpTypeForPR, - SKIP_CHANGELOG_MAGIC_WORD, - BODY_IN_CHANGELOG_MAGIC_WORD, - CurrentPRInfo, -} from '../changelog'; - -const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction; -const getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction; -const readFileSyncMock = readFileSync as jest.MockedFunction; - -describe('findChangeset', () => { - const sampleChangeset = { - body: '- this is a test', - name: 'Version 1.0.0', - }; - - test.each([ - [ - 'regular', - `# Changelog\n## ${sampleChangeset.name}\n${sampleChangeset.body}\n`, - ], - [ - 'ignore date in parentheses', - `# Changelog - ## 1.0.1 - newer - - ## ${sampleChangeset.name} (2019-02-02) - ${sampleChangeset.body} - - ## 0.9.0 - older - `, - ], - [ - 'extracts a change between headings', - `# Changelog - ## 1.0.1 - newer - - ## ${sampleChangeset.name} - ${sampleChangeset.body} - - ## 0.9.0 - older - `, - ], - [ - 'extracts changes from underlined headings', - `Changelog\n====\n${sampleChangeset.name}\n----\n${sampleChangeset.body}\n`, - ], - [ - 'extracts changes from alternating headings', - `# Changelog - ## 1.0.1 - newer - - ${sampleChangeset.name} - ------- - ${sampleChangeset.body} - - ## 0.9.0 - older - `, - ], - ])('should extract %s', (_testName, markdown) => { - expect(findChangeset(markdown, 'v1.0.0')).toEqual(sampleChangeset); - }); - - test('supports sub-headings', () => { - const changeset = { - body: '### Features\nthis is a test', - name: 'Version 1.0.0', - }; - - const markdown = `# Changelog - ## ${changeset.name} - ${changeset.body} - `; - - expect(findChangeset(markdown, 'v1.0.0')).toEqual(changeset); - }); - - test.each([ - ['changeset cannot be found', 'v1.0.0'], - ['invalid version', 'not a version'], - ])('should return null on %s', (_testName, version) => { - const markdown = `# Changelog - ## 1.0.1 - newer - - ## 0.9.0 - older - `; - expect(findChangeset(markdown, version)).toEqual(null); - }); -}); - -test.each([ - [ - 'remove from the top', - '1.0.1', - `# Changelog - 1.0.0 - ------- - this is a test - - ## 0.9.1 - slightly older - - ## 0.9.0 - older - `, - ], - [ - 'remove from the middle', - '0.9.1', - `# Changelog - ## 1.0.1 - newer - - 1.0.0 - ------- - this is a test - - ## 0.9.0 - older - `, - ], - [ - 'remove from underlined', - '1.0.0', - `# Changelog - ## 1.0.1 - newer - - ## 0.9.1 - slightly older - - ## 0.9.0 - older - `, - ], - [ - 'remove from the bottom', - '0.9.0', - `# Changelog - ## 1.0.1 - newer - - 1.0.0 - ------- - this is a test - - ## 0.9.1 - slightly older - -`, - ], - [ - 'not remove missing', - 'non-existent version', - `# Changelog - ## 1.0.1 - newer - - 1.0.0 - ------- - this is a test - - ## 0.9.1 - slightly older - - ## 0.9.0 - older - `, - ], - [ - 'not remove empty', - '', - `# Changelog - ## 1.0.1 - newer - - 1.0.0 - ------- - this is a test - - ## 0.9.1 - slightly older - - ## 0.9.0 - older - `, - ], -])('remove changeset should %s', (_testName, header, expected) => { - const markdown = `# Changelog - ## 1.0.1 - newer - - 1.0.0 - ------- - this is a test - - ## 0.9.1 - slightly older - - ## 0.9.0 - older - `; - - expect(removeChangeset(markdown, header)).toEqual(expected); -}); - -test.each([ - [ - 'prepend to empty text', - '', - '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n', - ], - [ - 'prepend without top-level header', - '## 1.0.0\n\nthis is a test\n', - '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n', - ], - [ - 'prepend after top-level header (empty body)', - '# Changelog\n', - '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n', - ], - [ - 'prepend after top-level header', - '# Changelog\n\n## 1.0.0\n\nthis is a test\n', - '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n', - ], - [ - 'prepend with underlined when detected', - '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n', - '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n', - ], - [ - 'prepend with consistent padding with the rest', - '# Changelog\n\n ## 1.0.0\n\n this is a test\n', - '# Changelog\n\n ## 2.0.0\n\n - rewrote everything from scratch\n - with multiple lines\n\n ## 1.0.0\n\n this is a test\n', - ], - [ - 'prepend with consistent padding with the rest (underlined)', - '# Changelog\n\n 1.0.0\n-----\n\n this is a test\n', - '# Changelog\n\n 2.0.0\n-----\n\n - rewrote everything from scratch\n - with multiple lines\n\n 1.0.0\n-----\n\n this is a test\n', - ], -])('prependChangeset should %s', (_testName, markdown, expected) => { - expect( - prependChangeset(markdown, { - body: '- rewrote everything from scratch\n- with multiple lines', - name: '2.0.0', - }) - ).toEqual(expected); -}); - -describe('generateChangesetFromGit', () => { - let mockClient: jest.Mock; - - const mockGetChangesSince = getChangesSince as jest.MockedFunction< - typeof getChangesSince - >; - const dummyGit = {} as SimpleGit; - - beforeEach(() => { - jest.resetAllMocks(); - mockClient = jest.fn(); - (getGitHubClient as jest.MockedFunction< - typeof getGitHubClient - // @ts-ignore we only need to mock a subset - >).mockReturnValue({ graphql: mockClient }); - // Default: no config file - getConfigFileDirMock.mockReturnValue(undefined); - getGlobalGitHubConfigMock.mockResolvedValue({ - repo: 'test-repo', - owner: 'test-owner', - }); - readFileSyncMock.mockImplementation(() => { - const error: any = new Error('ENOENT'); - error.code = 'ENOENT'; - throw error; - }); - }); - - interface TestCommit { - author?: string; - hash: string; - title: string; - body: string; - pr?: { - local?: string; - remote?: { - author?: { login: string }; - number: string; - title?: string; - body?: string; - labels?: string[]; - }; - }; - } - - function setup( - commits: TestCommit[], - releaseConfig?: string | null - ): void { - // Clear memoization cache to ensure fresh results - clearChangesetCache(); - - mockGetChangesSince.mockResolvedValueOnce( - commits.map(commit => ({ - hash: commit.hash, - title: commit.title, - body: commit.body, - pr: commit.pr?.local || null, - })) - ); - - mockClient.mockResolvedValueOnce({ - repository: Object.fromEntries( - commits.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, - })), - }, - }, - ] - : [], - }, - }, - ]) - ), - }); - - // Mock release config file reading - if (releaseConfig !== undefined) { - if (releaseConfig === null) { - getConfigFileDirMock.mockReturnValue(undefined); - readFileSyncMock.mockImplementation(() => { - const error: any = new Error('ENOENT'); - error.code = 'ENOENT'; - throw error; - }); - } else { - getConfigFileDirMock.mockReturnValue('/workspace'); - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('.github/release.yml')) { - return releaseConfig; - } - const error: any = new Error('ENOENT'); - error.code = 'ENOENT'; - throw error; - }); - } - } - } - - it.each([ - ['empty changeset', [], null, ''], - [ - 'short commit SHA for local commits w/o pull requests', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - }, - ], - null, - '- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)', - ], - [ - 'use pull request number when available locally', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel (#123)', - body: '', - pr: { local: '123' }, - }, - ], - null, - // Local PR: links to the PR (strips duplicate PR number from title) - '- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)', - ], - [ - 'use pull request number when available remotely', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - pr: { remote: { number: '123', author: { login: 'sentry' } } }, - }, - ], - null, - '- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)', - ], - [ - 'Does not error when PR author is null', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - pr: { remote: { number: '123' } }, - }, - ], - null, - '- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)', - ], - [ - 'use PR title from GitHub instead of commit message', - [ - { - hash: 'abcdef1234567890', - title: 'fix: quick fix for issue', // commit message - body: '', - pr: { - remote: { - number: '123', - title: 'feat: A much better PR title with more context', // actual PR title - author: { login: 'sentry' }, - }, - }, - }, - ], - null, - // Default config matches "feat:" prefix, so it goes to "New Features" category - '### New Features ✨\n\n- feat: A much better PR title with more context by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)', - ], - [ - 'handle multiple commits properly', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - }, - { - hash: 'bcdef1234567890a', - title: 'Upgraded the manifold (#123)', - body: '', - pr: { - local: '123', - remote: { number: '123', author: { login: 'alice' } }, - }, - }, - { - hash: 'cdef1234567890ab', - title: 'Refactored the crankshaft', - body: '', - pr: { remote: { number: '456', author: { login: 'bob' } } }, - }, - { - hash: 'cdef1234567890ad', - title: 'Refactored the crankshaft again', - body: '', - pr: { remote: { number: '458', author: { login: 'bob' } } }, - }, - ], - null, - [ - '- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)', - '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)', - '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)', - '', - '_Plus 1 more_', - ].join('\n'), - ], - [ - 'group prs under categories', - [ - { - hash: 'bcdef1234567890a', - title: 'Upgraded the manifold (#123)', - body: '', - pr: { - local: '123', - remote: { - number: '123', - author: { login: 'alice' }, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'cdef1234567890ab', - title: 'Refactored the crankshaft', - body: '', - pr: { - remote: { - number: '456', - author: { login: 'bob' }, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'def1234567890abc', - title: 'Upgrade the HUD (#789)', - body: '', - pr: { - local: '789', - remote: { - number: '789', - author: { login: 'charlie' }, - labels: ['driver-experience'], - }, - }, - }, - { - hash: 'ef1234567890abcd', - title: 'Upgrade the steering wheel (#900)', - body: '', - pr: { - local: '900', - remote: { - number: '900', - author: { login: 'charlie' }, - labels: ['driver-experience'], - }, - }, - }, - { - hash: 'f1234567890abcde', - title: 'Fix the clacking sound on gear changes (#950)', - body: '', - pr: { - local: '950', - remote: { number: '950', author: { login: 'bob' } }, - }, - }, - ], - `changelog: - categories: - - title: Better drivetrain - labels: - - drivetrain - - title: Better driver experience - labels: - - driver-experience`, - [ - '### Better drivetrain', - '', - '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)', - '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)', - '', - '### Better driver experience', - '', - '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)', - '- Upgrade the steering wheel by @charlie in [#900](https://github.com/test-owner/test-repo/pull/900)', - '', - '### Other', - '', - '- Fix the clacking sound on gear changes by @bob in [#950](https://github.com/test-owner/test-repo/pull/950)', - ].join('\n'), - ], - [ - 'should escape # signs on category titles', - [ - { - hash: 'abcdef1234567890', - title: 'Upgraded the kernel', - body: '', - pr: { - local: '123', - remote: { - number: '123', - author: { login: 'sentry' }, - labels: ['drivetrain'], - }, - }, - }, - ], - `changelog: - categories: - - title: "Drivetrain #1 in town" - labels: - - drivetrain`, - [ - '### Drivetrain #1 in town', - '', - '- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)', - ].join('\n'), - ], - [ - 'should escape leading underscores in changelog entries', - [ - { - hash: 'abcdef1234567890', - title: 'Serialized _meta (#123)', - body: '', - pr: { local: '123' }, - }, - ], - null, - // Local PR: links to the PR (strips duplicate PR number from title) - '- Serialized \\_meta in [#123](https://github.com/test-owner/test-repo/pull/123)', - ], - // NOTE: #skip-changelog is now redundant as we can skip PRs with certain labels - // via .github/release.yml configuration (changelog.exclude.labels) - [ - `should skip commits & prs with the magic ${SKIP_CHANGELOG_MAGIC_WORD}`, - [ - { - hash: 'bcdef1234567890a', - title: 'Upgraded the manifold (#123)', - body: '', - pr: { - local: '123', - remote: { - number: '123', - author: { login: 'alice' }, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'cdef1234567890ab', - title: 'Refactored the crankshaft', - body: '', - pr: { - remote: { - number: '456', - author: { login: 'bob' }, - body: `This is important but we'll ${SKIP_CHANGELOG_MAGIC_WORD} for internal.`, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'def1234567890abc', - title: 'Upgrade the HUD (#789)', - body: '', - pr: { - local: '789', - remote: { - number: '789', - author: { login: 'charlie' }, - labels: ['driver-experience'], - }, - }, - }, - { - hash: 'f1234567890abcde', - title: 'Fix the clacking sound on gear changes (#950)', - body: '', - pr: { - local: '950', - remote: { number: '950', author: { login: 'alice' } }, - }, - }, - ], - `changelog: - categories: - - title: Better drivetrain - labels: - - drivetrain - - title: Better driver experience - labels: - - driver-experience`, - [ - '### Better drivetrain', - '', - '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)', - '', - '### Better driver experience', - '', - '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)', - '', - '### Other', - '', - '- Fix the clacking sound on gear changes by @alice in [#950](https://github.com/test-owner/test-repo/pull/950)', - ].join('\n'), - ], - [ - `should expand commits & prs with the magic ${BODY_IN_CHANGELOG_MAGIC_WORD}`, - [ - { - hash: 'bcdef1234567890a', - title: 'Upgraded the manifold (#123)', - body: '', - pr: { - local: '123', - remote: { - number: '123', - author: { login: 'alice' }, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'cdef1234567890ab', - title: 'Refactored the crankshaft', - body: '', - pr: { - remote: { - number: '456', - author: { login: 'bob' }, - body: `This is important and we'll include the __body__ for attention. ${BODY_IN_CHANGELOG_MAGIC_WORD}`, - labels: ['drivetrain'], - }, - }, - }, - { - hash: 'def1234567890abc', - title: 'Upgrade the HUD (#789)', - body: '', - pr: { - local: '789', - remote: { - number: '789', - author: { login: 'charlie' }, - labels: ['driver-experience'], - }, - }, - }, - { - hash: 'ef1234567890abcd', - title: 'Upgrade the steering wheel (#900)', - body: `Some very important update ${BODY_IN_CHANGELOG_MAGIC_WORD}`, - pr: { local: '900' }, - }, - { - hash: 'f1234567890abcde', - title: 'Fix the clacking sound on gear changes (#950)', - body: '', - pr: { - local: '950', - remote: { number: '950', author: { login: 'alice' } }, - }, - }, - ], - `changelog: - categories: - - title: Better drivetrain - labels: - - drivetrain - - title: Better driver experience - labels: - - driver-experience`, - [ - '### Better drivetrain', - '', - '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)', - '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)', - " This is important and we'll include the __body__ for attention.", - '', - '### Better driver experience', - '', - '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)', - '', - '### Other', - '', - // Local PR: links to the PR (strips duplicate PR number from title) - '- Upgrade the steering wheel in [#900](https://github.com/test-owner/test-repo/pull/900)', - ' Some very important update', - '- Fix the clacking sound on gear changes by @alice in [#950](https://github.com/test-owner/test-repo/pull/950)', - ].join('\n'), - ], - ])( - '%s', - async ( - _name: string, - commits: TestCommit[], - releaseConfig: string | null, - output: string - ) => { - setup(commits, releaseConfig); - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - expect(changes).toBe(output); - } - ); - - describe('category matching', () => { - it('should match PRs to categories based on labels', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - labels: - - feature - - title: Bug Fixes - labels: - - bug`; - - setup( - [ - { - hash: 'abc123', - title: 'Feature PR', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['feature'], - }, - }, - }, - { - hash: 'def456', - title: 'Bug fix PR', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['bug'], - }, - }, - }, - ], - releaseConfigYaml - ); - - // Verify mocks are set up before calling generateChangesetFromGit - expect(getConfigFileDirMock).toBeDefined(); - expect(readFileSyncMock).toBeDefined(); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - // Verify getConfigFileDir was called - expect(getConfigFileDirMock).toHaveBeenCalled(); - // Verify readFileSync was called to read the config - expect(readFileSyncMock).toHaveBeenCalled(); - - expect(changes).toContain('### Features'); - expect(changes).toContain('### Bug Fixes'); - expect(changes).toContain( - 'Feature PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)' - ); - expect(changes).toContain( - 'Bug fix PR by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)' - ); - }); - - it('should apply global exclusions', async () => { - setup( - [ - { - hash: 'abc123', - title: 'Internal PR (#1)', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['internal'], - }, - }, - }, - { - hash: 'def456', - title: 'Public PR (#2)', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['feature'], - }, - }, - }, - ], - `changelog: - exclude: - labels: - - internal - categories: - - title: Features - labels: - - feature` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - expect(changes).not.toContain('#1'); - expect(changes).toContain('#2'); - }); - - it('should apply category-level exclusions', async () => { - setup( - [ - { - hash: 'abc123', - title: 'Feature PR (#1)', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['feature', 'skip-release'], - }, - }, - }, - { - hash: 'def456', - title: 'Another Feature PR (#2)', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['feature'], - }, - }, - }, - ], - `changelog: - categories: - - title: Features - labels: - - feature - exclude: - labels: - - skip-release` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - // PR #1 is excluded from Features category but should appear in Other - // (category-level exclusions only exclude from that specific category) - expect(changes).toContain('#1'); - // PR #1 should NOT be in the Features section - const featuresSection = changes.split('### Other')[0]; - expect(featuresSection).not.toContain('Feature PR by @alice'); - // But it should be in the Other section - const otherSection = changes.split('### Other')[1]; - expect(otherSection).toContain('Feature PR by @alice in [#1]'); - expect(changes).toContain('#2'); - expect(changes).toContain('### Features'); - }); - - it('should support wildcard category matching', async () => { - setup( - [ - { - hash: 'abc123', - title: 'Any PR (#1)', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['random-label'], - }, - }, - }, - ], - `changelog: - categories: - - title: All Changes - labels: - - '*'` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - expect(changes).toContain('### All Changes'); - expect(changes).toContain( - 'Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)' - ); - }); - - it('should use default conventional commits config when no config exists', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat: Add new feature (#1)', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'fix: Bug fix (#2)', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - // When no config exists, default conventional commits patterns are used - expect(changes).toContain('### New Features'); - expect(changes).toContain('### Bug Fixes'); - expect(changes).toContain('#1'); - expect(changes).toContain('#2'); - }); - - it('should categorize PRs without author in their designated category', async () => { - setup( - [ - { - hash: 'abc123', - title: 'Feature PR without author', - body: '', - pr: { - remote: { - number: '1', - // No author - simulates deleted GitHub user - labels: ['feature'], - }, - }, - }, - ], - `changelog: - categories: - - title: Features - labels: - - feature` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - expect(changes).toContain('### Features'); - expect(changes).not.toContain('### Other'); - expect(changes).toContain( - 'Feature PR without author in [#1](https://github.com/test-owner/test-repo/pull/1)' - ); - }); - - it('should handle malformed release config gracefully (non-array categories)', async () => { - setup( - [ - { - hash: 'abc123', - title: 'Some PR (#1)', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['feature'], - }, - }, - }, - ], - `changelog: - categories: "this is a string, not an array"` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - // Should not crash, and PR should appear in output (no categories applied) - expect(changes).toContain('#1'); - }); - }); - - describe('commit_patterns matching', () => { - it('should match PRs to categories based on commit_patterns', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat:" - - title: Bug Fixes - commit_patterns: - - "^fix:"`; - - setup( - [ - { - hash: 'abc123', - title: 'feat: add new feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'fix: resolve bug', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - expect(changes).toContain('### Bug Fixes'); - expect(changes).toContain( - 'feat: add new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)' - ); - expect(changes).toContain( - 'fix: resolve bug by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)' - ); - }); - - it('should give labels precedence over commit_patterns', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Labeled Features - labels: - - feature - - title: Pattern Features - commit_patterns: - - "^feat:"`; - - setup( - [ - { - hash: 'abc123', - title: 'feat: labeled feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['feature'], - }, - }, - }, - { - hash: 'def456', - title: 'feat: unlabeled feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Labeled Features'); - expect(changes).toContain('### Pattern Features'); - // PR with label should be in Labeled Features (labels take precedence) - const labeledSection = changes.split('### Pattern Features')[0]; - expect(labeledSection).toContain('feat: labeled feature by @alice'); - // PR without label should be in Pattern Features - const patternSection = changes.split('### Pattern Features')[1]; - expect(patternSection).toContain('feat: unlabeled feature by @bob'); - }); - - it('should use default conventional commits config when no release.yml exists', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat: new feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'fix: bug fix', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'docs: update readme', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'chore: update deps', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - { - hash: 'mno345', - title: 'feat(scope)!: breaking change', - body: '', - pr: { - remote: { - number: '5', - author: { login: 'eve' }, - labels: [], - }, - }, - }, - ], - null // No release.yml - should use default config - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### New Features'); - expect(changes).toContain('### Bug Fixes'); - expect(changes).toContain('### Documentation'); - expect(changes).toContain('### Build / dependencies / internal'); - expect(changes).toContain('### Breaking Changes'); - }); - - it('should handle invalid regex patterns gracefully', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat:" - - "[invalid(regex"`; - - setup( - [ - { - hash: 'abc123', - title: 'feat: new feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - // Should not crash, and valid pattern should still work - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - expect(changes).toContain('### Features'); - expect(changes).toContain('feat: new feature'); - }); - - it('should support combined labels and commit_patterns in same category', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - labels: - - enhancement - commit_patterns: - - "^feat:"`; - - setup( - [ - { - hash: 'abc123', - title: 'add cool thing', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['enhancement'], - }, - }, - }, - { - hash: 'def456', - title: 'feat: another cool thing', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - expect(changes).not.toContain('### Other'); - // Both PRs should be in Features - expect(changes).toContain('add cool thing by @alice'); - expect(changes).toContain('feat: another cool thing by @bob'); - }); - - it('should apply category exclusions to pattern-matched PRs', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat:" - exclude: - labels: - - skip-release`; - - setup( - [ - { - hash: 'abc123', - title: 'feat: feature to skip', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['skip-release'], - }, - }, - }, - { - hash: 'def456', - title: 'feat: normal feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - // PR #1 should be excluded from Features (but appear in Other) - expect(changes).toContain('### Other'); - const featuresSection = changes.split('### Other')[0]; - expect(featuresSection).not.toContain('feat: feature to skip'); - expect(featuresSection).toContain('feat: normal feature'); - }); - - it('should match pattern case-insensitively', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat:"`; - - setup( - [ - { - hash: 'abc123', - title: 'FEAT: uppercase feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'Feat: mixed case feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - expect(changes).not.toContain('### Other'); - expect(changes).toContain('FEAT: uppercase feature'); - expect(changes).toContain('Feat: mixed case feature'); - }); - - it('should match PR title from commit log pattern with scope', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat(\\\\(\\\\w+\\\\))?:"`; - - setup( - [ - { - hash: 'abc123', - title: 'feat(api): add endpoint', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat: no scope', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - expect(changes).toContain('feat(api): add endpoint'); - expect(changes).toContain('feat: no scope'); - }); - - it('should match conventional commit scopes with dashes', async () => { - setup( - [ - { - hash: 'abc123', - title: 'fix(my-component): resolve issue', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(some-other-scope): add feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'docs(multi-part-scope): update docs', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - ], - null // Use default config - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### Bug Fixes'); - expect(changes).toContain('### New Features'); - expect(changes).toContain('### Documentation'); - expect(changes).toContain('fix(my-component): resolve issue'); - expect(changes).toContain('feat(some-other-scope): add feature'); - expect(changes).toContain('docs(multi-part-scope): update docs'); - // Should NOT appear in Other section - all commits should be categorized - expect(changes).not.toContain('### Other'); - }); - - it('should match refactor and meta types in internal category', async () => { - setup( - [ - { - hash: 'abc123', - title: 'refactor: clean up code', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'meta: update project config', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'refactor(utils): restructure helpers', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - ], - null // Use default config - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### Build / dependencies / internal'); - expect(changes).toContain('refactor: clean up code'); - expect(changes).toContain('meta: update project config'); - expect(changes).toContain('refactor(utils): restructure helpers'); - // Should NOT appear in Other section - expect(changes).not.toContain('### Other'); - }); - - it('should match breaking changes with scopes containing dashes', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(my-api)!: breaking api change', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'fix!: breaking fix', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - null // Use default config - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### Breaking Changes'); - expect(changes).toContain('feat(my-api)!: breaking api change'); - expect(changes).toContain('fix!: breaking fix'); - }); - - it('should trim leading and trailing whitespace from PR titles before pattern matching', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - commit_patterns: - - "^feat:" - - title: Bug Fixes - commit_patterns: - - "^fix:"`; - - setup( - [ - { - hash: 'abc123', - title: ' feat: feature with leading space', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - title: ' feat: feature with leading space', // PR title from GitHub has leading space - }, - }, - }, - { - hash: 'def456', - title: 'fix: bug fix with trailing space ', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - title: 'fix: bug fix with trailing space ', // PR title from GitHub has trailing space - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); - const changes = result.changelog; - - // Both should be properly categorized despite whitespace - expect(changes).toContain('### Features'); - expect(changes).toContain('### Bug Fixes'); - // Titles should be trimmed in output (no leading/trailing spaces) - expect(changes).toContain( - 'feat: feature with leading space by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)' - ); - expect(changes).toContain( - 'fix: bug fix with trailing space by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)' - ); - // Should NOT go to Other section - expect(changes).not.toContain('### Other'); - }); - }); - - describe('section ordering', () => { - it('should sort sections by config order regardless of PR encounter order', async () => { - // Config defines order: Features, Bug Fixes, Documentation - // But PRs are encountered in order: Bug Fix, Documentation, Feature - const releaseConfigYaml = `changelog: - categories: - - title: Features - labels: - - feature - - title: Bug Fixes - labels: - - bug - - title: Documentation - labels: - - docs`; - - setup( - [ - // First PR encountered is a bug fix - { - hash: 'abc123', - title: 'Fix critical bug', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['bug'], - }, - }, - }, - // Second PR is documentation - { - hash: 'def456', - title: 'Update README', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['docs'], - }, - }, - }, - // Third PR is a feature - { - hash: 'ghi789', - title: 'Add new feature', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: ['feature'], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Sections should appear in config order, not encounter order - const featuresIndex = changes.indexOf('### Features'); - const bugFixesIndex = changes.indexOf('### Bug Fixes'); - const docsIndex = changes.indexOf('### Documentation'); - - expect(featuresIndex).toBeGreaterThan(-1); - expect(bugFixesIndex).toBeGreaterThan(-1); - expect(docsIndex).toBeGreaterThan(-1); - - // Features should come before Bug Fixes, which should come before Documentation - expect(featuresIndex).toBeLessThan(bugFixesIndex); - expect(bugFixesIndex).toBeLessThan(docsIndex); - }); - - it('should maintain stable ordering with multiple PRs per category', async () => { - const releaseConfigYaml = `changelog: - categories: - - title: Features - labels: - - feature - - title: Bug Fixes - labels: - - bug`; - - setup( - [ - // Mix of bug fixes and features, bug fixes encountered first - { - hash: 'abc123', - title: 'First bug fix', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['bug'], - }, - }, - }, - { - hash: 'def456', - title: 'First feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['feature'], - }, - }, - }, - { - hash: 'ghi789', - title: 'Second bug fix', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: ['bug'], - }, - }, - }, - { - hash: 'jkl012', - title: 'Second feature', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: ['feature'], - }, - }, - }, - ], - releaseConfigYaml - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Features should still come before Bug Fixes per config order - const featuresIndex = changes.indexOf('### Features'); - const bugFixesIndex = changes.indexOf('### Bug Fixes'); - - expect(featuresIndex).toBeGreaterThan(-1); - expect(bugFixesIndex).toBeGreaterThan(-1); - expect(featuresIndex).toBeLessThan(bugFixesIndex); - - // Both features should be in Features section - expect(changes).toContain('First feature'); - expect(changes).toContain('Second feature'); - // Both bug fixes should be in Bug Fixes section - expect(changes).toContain('First bug fix'); - expect(changes).toContain('Second bug fix'); - }); - - it('should maintain default conventional commits order', async () => { - // No config - uses default conventional commits categories - // Order should be: Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features - setup( - [ - // Encounter order: feat, fix, breaking, docs, chore - { - hash: 'abc123', - title: 'feat: new feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'fix: bug fix', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat!: breaking change', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'docs: update docs', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - { - hash: 'mno345', - title: 'chore: update deps', - body: '', - pr: { - remote: { - number: '5', - author: { login: 'eve' }, - labels: [], - }, - }, - }, - ], - null // No config - use defaults - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Default order from DEFAULT_RELEASE_CONFIG: - // Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features - const breakingIndex = changes.indexOf('### Breaking Changes'); - const buildIndex = changes.indexOf('### Build / dependencies / internal'); - const bugFixesIndex = changes.indexOf('### Bug Fixes'); - const docsIndex = changes.indexOf('### Documentation'); - const featuresIndex = changes.indexOf('### New Features'); - - expect(breakingIndex).toBeGreaterThan(-1); - expect(buildIndex).toBeGreaterThan(-1); - expect(bugFixesIndex).toBeGreaterThan(-1); - expect(docsIndex).toBeGreaterThan(-1); - expect(featuresIndex).toBeGreaterThan(-1); - - // Verify order matches default config order - expect(breakingIndex).toBeLessThan(buildIndex); - expect(featuresIndex).toBeLessThan(bugFixesIndex); - expect(bugFixesIndex).toBeLessThan(docsIndex); - expect(docsIndex).toBeLessThan(buildIndex); - }); - }); - - describe('scope grouping', () => { - /** - * Helper to extract content between two headers (or to end of string). - * Returns the content after the start header and before the next header of same or higher level. - */ - function getSectionContent( - markdown: string, - headerPattern: RegExp - ): string | null { - const match = markdown.match(headerPattern); - if (!match) return null; - - const startIndex = match.index! + match[0].length; - // Find the next header (### or ####) - const restOfContent = markdown.slice(startIndex); - const nextHeaderMatch = restOfContent.match(/^#{3,4} /m); - const endIndex = nextHeaderMatch - ? startIndex + nextHeaderMatch.index! - : markdown.length; - - return markdown.slice(startIndex, endIndex).trim(); - } - - it('should group PRs by scope within categories when scope has multiple entries', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api): add endpoint', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(ui): add button 1', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat(api): add another endpoint', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'feat(ui): add button 2', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - ], - null // Use default config - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Verify Api scope header exists (has 2 entries) - const apiSection = getSectionContent(changes, /#### Api\n/); - expect(apiSection).not.toBeNull(); - expect(apiSection).toContain('feat(api): add endpoint'); - expect(apiSection).toContain('feat(api): add another endpoint'); - expect(apiSection).not.toContain('feat(ui):'); - - // Ui scope has 2 entries, so header should be shown - const uiSection = getSectionContent(changes, /#### Ui\n/); - expect(uiSection).not.toBeNull(); - expect(uiSection).toContain('feat(ui): add button 1'); - expect(uiSection).toContain('feat(ui): add button 2'); - expect(uiSection).not.toContain('feat(api):'); - }); - - it('should place scopeless entries at the bottom under "Other" header when scoped entries exist', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api): add endpoint', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(api): add another endpoint', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat: add feature without scope', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Should have Api scope header (has 2 entries) - expect(changes).toContain('#### Api'); - - // Should have an "Other" header for scopeless entries (since Api has a header) - expect(changes).toContain('#### Other'); - - // Scopeless entry should appear after the "Other" header - const otherHeaderIndex = changes.indexOf('#### Other'); - const scopelessIndex = changes.indexOf('feat: add feature without scope'); - expect(scopelessIndex).toBeGreaterThan(otherHeaderIndex); - - // Verify Api scope entry comes before scopeless entry - const apiEntryIndex = changes.indexOf('feat(api): add endpoint'); - expect(apiEntryIndex).toBeLessThan(scopelessIndex); - }); - - it('should not add "Other" header when no scoped entries have headers', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api): single api feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat: feature without scope', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Neither scope should have a header (both have only 1 entry) - expect(changes).not.toContain('#### Api'); - // No "Other" header should be added since there are no other scope headers - expect(changes).not.toContain('#### Other'); - - // But both PRs should still appear in the output - expect(changes).toContain('feat(api): single api feature'); - expect(changes).toContain('feat: feature without scope'); - }); - - it('should not add extra newlines between entries without scope headers', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(docker): add docker feature', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat: add feature without scope 1', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat: add feature without scope 2', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // All three should appear without extra blank lines between them - // (no scope headers since docker only has 1 entry and scopeless don't trigger headers) - expect(changes).not.toContain('#### Docker'); - expect(changes).not.toContain('#### Other'); - - // Verify no double newlines between entries (which would indicate separate sections) - const featuresSection = getSectionContent(changes, /### New Features[^\n]*\n/); - expect(featuresSection).not.toBeNull(); - // There should be no blank lines between the three entries - expect(featuresSection).not.toMatch(/\n\n-/); - // All entries should be present - expect(featuresSection).toContain('feat(docker): add docker feature'); - expect(featuresSection).toContain('feat: add feature without scope 1'); - expect(featuresSection).toContain('feat: add feature without scope 2'); - }); - - it('should skip scope header for scopes with only one entry', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api): add endpoint', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(ui): single ui feature', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Neither scope should have a header (both have only 1 entry) - expect(changes).not.toContain('#### Api'); - expect(changes).not.toContain('#### Ui'); - - // But both PRs should still appear in the output - expect(changes).toContain('feat(api): add endpoint'); - expect(changes).toContain('feat(ui): single ui feature'); - }); - - it('should merge scopes with different casing', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(API): uppercase scope', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(api): lowercase scope', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat(Api): mixed case scope', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Should only have one Api header (all merged) - const apiMatches = changes.match(/#### Api/gi); - expect(apiMatches).toHaveLength(1); - - // All three PRs should be under the same Api scope section - const apiSection = getSectionContent(changes, /#### Api\n/); - expect(apiSection).not.toBeNull(); - expect(apiSection).toContain('feat(API): uppercase scope'); - expect(apiSection).toContain('feat(api): lowercase scope'); - expect(apiSection).toContain('feat(Api): mixed case scope'); - }); - - it('should sort scope groups alphabetically', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(zulu): z feature 1', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(zulu): z feature 2', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat(alpha): a feature 1', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'feat(alpha): a feature 2', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - { - hash: 'mno345', - title: 'feat(beta): b feature 1', - body: '', - pr: { - remote: { - number: '5', - author: { login: 'eve' }, - labels: [], - }, - }, - }, - { - hash: 'pqr678', - title: 'feat(beta): b feature 2', - body: '', - pr: { - remote: { - number: '6', - author: { login: 'frank' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - const alphaIndex = changes.indexOf('#### Alpha'); - const betaIndex = changes.indexOf('#### Beta'); - const zuluIndex = changes.indexOf('#### Zulu'); - - expect(alphaIndex).toBeLessThan(betaIndex); - expect(betaIndex).toBeLessThan(zuluIndex); - - // Also verify each section contains the correct PR - const alphaSection = getSectionContent(changes, /#### Alpha\n/); - expect(alphaSection).toContain('feat(alpha): a feature 1'); - expect(alphaSection).toContain('feat(alpha): a feature 2'); - expect(alphaSection).not.toContain('feat(beta)'); - expect(alphaSection).not.toContain('feat(zulu)'); - }); - - it('should format scope with dashes and underscores as title case', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(my-component): feature with dashes 1', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(my-component): feature with dashes 2', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat(another_component): feature with underscores 1', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'feat(another_component): feature with underscores 2', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Verify scope headers are formatted correctly (each has 2 entries) - expect(changes).toContain('#### Another Component'); - expect(changes).toContain('#### My Component'); - - // Verify PRs are under correct scope sections - const myComponentSection = getSectionContent( - changes, - /#### My Component\n/ - ); - expect(myComponentSection).toContain('feat(my-component): feature with dashes 1'); - expect(myComponentSection).toContain('feat(my-component): feature with dashes 2'); - expect(myComponentSection).not.toContain('feat(another_component)'); - - const anotherComponentSection = getSectionContent( - changes, - /#### Another Component\n/ - ); - expect(anotherComponentSection).toContain( - 'feat(another_component): feature with underscores 1' - ); - expect(anotherComponentSection).toContain( - 'feat(another_component): feature with underscores 2' - ); - expect(anotherComponentSection).not.toContain('feat(my-component)'); - }); - - it('should apply scope grouping to label-categorized PRs', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api): add endpoint', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: ['enhancement'], - }, - }, - }, - { - hash: 'def456', - title: 'feat(api): add another endpoint', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: ['enhancement'], - }, - }, - }, - { - hash: 'ghi789', - title: 'feat(ui): add button', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: ['enhancement'], - }, - }, - }, - { - hash: 'jkl012', - title: 'feat(ui): add dialog', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: ['enhancement'], - }, - }, - }, - ], - `changelog: - categories: - - title: Features - labels: - - enhancement` - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### Features'); - - // Verify PRs are grouped under correct scopes (each scope has 2 entries) - const apiSection = getSectionContent(changes, /#### Api\n/); - expect(apiSection).not.toBeNull(); - expect(apiSection).toContain('feat(api): add endpoint'); - expect(apiSection).toContain('feat(api): add another endpoint'); - expect(apiSection).not.toContain('feat(ui)'); - - const uiSection = getSectionContent(changes, /#### Ui\n/); - expect(uiSection).not.toBeNull(); - expect(uiSection).toContain('feat(ui): add button'); - expect(uiSection).toContain('feat(ui): add dialog'); - expect(uiSection).not.toContain('feat(api)'); - }); - - it('should handle breaking changes with scopes', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(api)!: breaking api change', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(api)!: another breaking api change', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - { - hash: 'ghi789', - title: 'fix(core)!: breaking core fix', - body: '', - pr: { - remote: { - number: '3', - author: { login: 'charlie' }, - labels: [], - }, - }, - }, - { - hash: 'jkl012', - title: 'fix(core)!: another breaking core fix', - body: '', - pr: { - remote: { - number: '4', - author: { login: 'dave' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - expect(changes).toContain('### Breaking Changes'); - - // Verify PRs are grouped under correct scopes (each scope has 2 entries) - const apiSection = getSectionContent(changes, /#### Api\n/); - expect(apiSection).not.toBeNull(); - expect(apiSection).toContain('feat(api)!: breaking api change'); - expect(apiSection).toContain('feat(api)!: another breaking api change'); - expect(apiSection).not.toContain('fix(core)'); - - const coreSection = getSectionContent(changes, /#### Core\n/); - expect(coreSection).not.toBeNull(); - expect(coreSection).toContain('fix(core)!: breaking core fix'); - expect(coreSection).toContain('fix(core)!: another breaking core fix'); - expect(coreSection).not.toContain('feat(api)'); - }); - - it('should merge scopes with dashes and underscores as equivalent', async () => { - setup( - [ - { - hash: 'abc123', - title: 'feat(my-component): feature with dashes', - body: '', - pr: { - remote: { - number: '1', - author: { login: 'alice' }, - labels: [], - }, - }, - }, - { - hash: 'def456', - title: 'feat(my_component): feature with underscores', - body: '', - pr: { - remote: { - number: '2', - author: { login: 'bob' }, - labels: [], - }, - }, - }, - ], - null - ); - - const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); - const changes = result.changelog; - - // Should only have one "My Component" header (merged via normalization) - const myComponentMatches = changes.match(/#### My Component/gi); - expect(myComponentMatches).toHaveLength(1); - - // Both PRs should appear under the same scope section - const myComponentSection = getSectionContent(changes, /#### My Component\n/); - expect(myComponentSection).not.toBeNull(); - expect(myComponentSection).toContain('feat(my-component): feature with dashes'); - expect(myComponentSection).toContain('feat(my_component): feature with underscores'); - }); - }); -}); - -describe('extractScope', () => { - it.each([ - ['feat(api): add endpoint', 'api'], - ['fix(ui): fix button', 'ui'], - ['feat(my-component): add feature', 'my-component'], - ['feat(my_component): add feature', 'my-component'], // underscores normalized to dashes - ['feat(API): uppercase scope', 'api'], - ['feat(MyComponent): mixed case', 'mycomponent'], - ['feat(scope)!: breaking change', 'scope'], - ['fix(core)!: another breaking', 'core'], - ['docs(readme): update docs', 'readme'], - ['chore(deps): update dependencies', 'deps'], - ['feat(my-long_scope): mixed separators', 'my-long-scope'], // underscores normalized to dashes - ])('should extract scope from "%s" as "%s"', (title, expected) => { - expect(extractScope(title)).toBe(expected); - }); - - it.each([ - ['feat: no scope', null], - ['fix: simple fix', null], - ['random commit message', null], - ['feat!: breaking without scope', null], - ['(scope): missing type', null], - ['feat(): empty scope', null], - ])('should return null for "%s"', (title, expected) => { - expect(extractScope(title)).toBe(expected); - }); -}); - -describe('formatScopeTitle', () => { - it.each([ - ['api', 'Api'], - ['ui', 'Ui'], - ['my-component', 'My Component'], - ['my_component', 'My Component'], - ['multi-word-scope', 'Multi Word Scope'], - ['multi_word_scope', 'Multi Word Scope'], - ['API', 'API'], - ['mycomponent', 'Mycomponent'], - ])('should format "%s" as "%s"', (scope, expected) => { - expect(formatScopeTitle(scope)).toBe(expected); - }); -}); - -describe('shouldExcludePR', () => { - it('should return true when body contains #skip-changelog', () => { - const labels = new Set(); - expect(shouldExcludePR(labels, 'user', null, 'Some text #skip-changelog here')).toBe(true); - }); - - it('should return false when body does not contain magic word', () => { - const labels = new Set(); - expect(shouldExcludePR(labels, 'user', null, 'Normal body text')).toBe(false); - }); - - it('should return false when body is undefined', () => { - const labels = new Set(); - expect(shouldExcludePR(labels, 'user', null, undefined)).toBe(false); - }); - - it('should return false when body is empty', () => { - const labels = new Set(); - expect(shouldExcludePR(labels, 'user', null, '')).toBe(false); - }); - - it('should check body before config (early exit)', () => { - // Even with no config, magic word should cause exclusion - const labels = new Set(['feature']); - expect(shouldExcludePR(labels, 'user', null, '#skip-changelog')).toBe(true); - }); -}); - -describe('shouldSkipCurrentPR', () => { - const basePRInfo: CurrentPRInfo = { - number: 123, - title: 'Test PR', - body: '', - author: 'testuser', - labels: [], - baseRef: 'main', - }; - - beforeEach(() => { - clearChangesetCache(); - getConfigFileDirMock.mockReturnValue('/test'); - }); - - it('should return true when PR body contains #skip-changelog', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'This is a PR description\n\n#skip-changelog', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - }); - - it('should return true when PR body contains #skip-changelog inline', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'This is internal work #skip-changelog for now', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - }); - - it('should return false when PR body does not contain skip marker', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'This is a regular PR description', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(false); - }); - - it('should return true when PR has an excluded label from config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'Normal description', - labels: ['skip-changelog'], - }; - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('release.yml')) { - return `changelog: - exclude: - labels: - - skip-changelog - categories: - - title: Features - labels: - - feature`; - } - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - }); - - it('should return true when PR author is excluded in config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'Normal description', - author: 'dependabot[bot]', - }; - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('release.yml')) { - return `changelog: - exclude: - authors: - - dependabot[bot] - categories: - - title: Features - labels: - - feature`; - } - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - }); - - it('should return false when PR does not match any exclusion criteria', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'Normal description', - labels: ['feature'], - author: 'regularuser', - }; - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('release.yml')) { - return `changelog: - exclude: - labels: - - skip-changelog - authors: - - dependabot[bot] - categories: - - title: Features - labels: - - feature`; - } - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(false); - }); - - it('should prioritize magic word over config (skip even without config)', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - body: 'Description with #skip-changelog', - labels: ['feature'], - }; - // No release config - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - }); -}); - -describe('getBumpTypeForPR', () => { - const basePRInfo: CurrentPRInfo = { - number: 123, - title: 'Test PR', - body: '', - author: 'testuser', - labels: [], - baseRef: 'main', - }; - - beforeEach(() => { - clearChangesetCache(); - getConfigFileDirMock.mockReturnValue('/test'); - }); - - it('should return minor for feat: prefix with default config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'feat: Add new feature', - }; - // No release config - uses default conventional commits - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('minor'); - }); - - it('should return patch for fix: prefix with default config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'fix: Fix a bug', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('patch'); - }); - - it('should return major for breaking change with default config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'feat!: Breaking change', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('major'); - }); - - it('should return null for unmatched title', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'Random commit message', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBeNull(); - }); - - it('should match by label when config has label-based categories', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'Some random title', - labels: ['feature'], - }; - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('release.yml')) { - return `changelog: - categories: - - title: Features - labels: - - feature - semver: minor`; - } - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('minor'); - }); - - it('should work for skipped PRs (still determines bump type)', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'feat: New feature', - body: '#skip-changelog', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - // PR is skipped but should still have a bump type - expect(shouldSkipCurrentPR(prInfo)).toBe(true); - expect(getBumpTypeForPR(prInfo)).toBe('minor'); - }); - - it('should return minor for feat with scope', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'feat(api): Add new endpoint', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('minor'); - }); - - it('should return patch for fix with scope', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'fix(core): Fix memory leak', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - expect(getBumpTypeForPR(prInfo)).toBe('patch'); - }); - - it('should return patch for docs: prefix in default config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'docs: Update README', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - // docs category in default config has patch semver - expect(getBumpTypeForPR(prInfo)).toBe('patch'); - }); - - it('should return patch for chore: prefix in default config', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'chore: Update dependencies', - }; - readFileSyncMock.mockImplementation(() => { - throw { code: 'ENOENT' }; - }); - - // chore is in the build/internal category with patch semver - expect(getBumpTypeForPR(prInfo)).toBe('patch'); - }); - - it('should prefer label over title pattern when both match', () => { - const prInfo: CurrentPRInfo = { - ...basePRInfo, - title: 'feat: This looks like a feature', - labels: ['bug'], // Label says bug, title says feat - }; - readFileSyncMock.mockImplementation((path: any) => { - if (typeof path === 'string' && path.includes('release.yml')) { - return `changelog: - categories: - - title: Bug Fixes - labels: - - bug - semver: patch - - title: Features - labels: - - feature - semver: minor`; - } - throw { code: 'ENOENT' }; - }); - - // Label takes precedence, so should be patch (bug) not minor (feat) - expect(getBumpTypeForPR(prInfo)).toBe('patch'); - }); -}); diff --git a/src/utils/__tests__/fixtures/changelog-mocks.ts b/src/utils/__tests__/fixtures/changelog-mocks.ts new file mode 100644 index 00000000..21e973de --- /dev/null +++ b/src/utils/__tests__/fixtures/changelog-mocks.ts @@ -0,0 +1,120 @@ +/** + * Shared mock setup for changelog tests. + */ +import type { SimpleGit } from 'simple-git'; +import type { TestCommit } from './changelog'; + +// Re-export for convenience +export type { TestCommit } from './changelog'; + +// These will be set up by the test files that import this +export let mockClient: jest.Mock; +export let mockGetChangesSince: jest.MockedFunction; +export let getConfigFileDirMock: jest.MockedFunction; +export let getGlobalGitHubConfigMock: jest.MockedFunction; +export let readFileSyncMock: jest.MockedFunction; + +export const dummyGit = {} as SimpleGit; + +/** + * Initialize mocks - call this in beforeEach of test files that need GitHub mocking. + */ +export function initMocks( + getGitHubClient: any, + getChangesSince: any, + config: any, + readFileSync: any, + clearChangesetCache: () => void +): void { + mockClient = jest.fn(); + mockGetChangesSince = getChangesSince as jest.MockedFunction; + getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction; + getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction; + readFileSyncMock = readFileSync as jest.MockedFunction; + + jest.resetAllMocks(); + clearChangesetCache(); + + (getGitHubClient as jest.MockedFunction).mockReturnValue({ + graphql: mockClient, + } as any); + + getConfigFileDirMock.mockReturnValue(undefined); + getGlobalGitHubConfigMock.mockResolvedValue({ + repo: 'test-repo', + owner: 'test-owner', + }); + readFileSyncMock.mockImplementation(() => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); +} + +/** + * Setup function for generateChangesetFromGit tests. + * Configures mocks for a specific test scenario. + */ +export function setupGenerateTest( + commits: TestCommit[], + releaseConfig?: string | null +): void { + mockGetChangesSince.mockResolvedValueOnce( + commits.map(commit => ({ + hash: commit.hash, + title: commit.title, + body: commit.body, + pr: commit.pr?.local || null, + })) + ); + + mockClient.mockResolvedValueOnce({ + repository: Object.fromEntries( + commits.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, + })), + }, + }, + ] + : [], + }, + }, + ]) + ), + }); + + if (releaseConfig !== undefined) { + if (releaseConfig === null) { + getConfigFileDirMock.mockReturnValue(undefined); + readFileSyncMock.mockImplementation(() => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + } else { + getConfigFileDirMock.mockReturnValue('/workspace'); + readFileSyncMock.mockImplementation((path: any) => { + if (typeof path === 'string' && path.includes('.github/release.yml')) { + return releaseConfig; + } + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + } + } +} + diff --git a/src/utils/__tests__/fixtures/changelog.ts b/src/utils/__tests__/fixtures/changelog.ts new file mode 100644 index 00000000..a322fcbc --- /dev/null +++ b/src/utils/__tests__/fixtures/changelog.ts @@ -0,0 +1,240 @@ +/** + * Common test fixtures and helpers for changelog tests. + * Extracted to reduce test file size and improve maintainability. + */ + +// ============================================================================ +// Markdown Helpers - create markdown without template literal indentation issues +// ============================================================================ + +/** + * Creates a changelog markdown string with proper formatting. + * Avoids template literal indentation issues. + */ +export function createChangelog( + sections: Array<{ version: string; body: string; style?: 'atx' | 'setext' }> +): string { + return sections + .map(({ version, body, style = 'atx' }) => { + if (style === 'setext') { + return `${version}\n${'-'.repeat(version.length)}\n\n${body}`; + } + return `## ${version}\n\n${body}`; + }) + .join('\n\n'); +} + +/** + * Creates a full changelog with title. + */ +export function createFullChangelog( + title: string, + sections: Array<{ version: string; body: string; style?: 'atx' | 'setext' }> +): string { + return `# ${title}\n\n${createChangelog(sections)}`; +} + +// ============================================================================ +// Sample Changesets +// ============================================================================ + +export const SAMPLE_CHANGESET = { + body: '- this is a test', + name: 'Version 1.0.0', +}; + +export const SAMPLE_CHANGESET_WITH_SUBHEADING = { + body: '### Features\nthis is a test', + name: 'Version 1.0.0', +}; + +// ============================================================================ +// Test Commit Types - reusable commit definitions +// ============================================================================ + +export interface TestCommit { + author?: string; + hash: string; + title: string; + body: string; + pr?: { + local?: string; + remote?: { + author?: { login: string }; + number: string; + title?: string; + body?: string; + labels?: string[]; + }; + }; +} + +/** + * Creates a simple local commit (no PR). + */ +export function localCommit( + hash: string, + title: string, + body = '' +): TestCommit { + return { hash, title, body }; +} + +/** + * Creates a commit with a linked PR. + */ +export function prCommit( + hash: string, + title: string, + prNumber: string, + options: { + author?: string; + body?: string; + labels?: string[]; + prTitle?: string; + prBody?: string; + } = {} +): TestCommit { + return { + hash, + title, + body: options.body ?? '', + author: options.author, + pr: { + local: prNumber, + remote: { + author: options.author ? { login: options.author } : undefined, + number: prNumber, + title: options.prTitle ?? title, + body: options.prBody ?? '', + labels: options.labels ?? [], + }, + }, + }; +} + +// ============================================================================ +// Common Release Configs +// ============================================================================ + +export const BASIC_RELEASE_CONFIG = ` +changelog: + categories: + - title: Features + labels: + - enhancement + - title: Bug Fixes + labels: + - bug +`; + +export const RELEASE_CONFIG_WITH_PATTERNS = ` +changelog: + categories: + - title: Features + labels: + - enhancement + commit_patterns: + - "^feat(\\\\([^)]+\\\\))?:" + - title: Bug Fixes + labels: + - bug + commit_patterns: + - "^fix(\\\\([^)]+\\\\))?:" +`; + +export const RELEASE_CONFIG_WITH_EXCLUSIONS = ` +changelog: + exclude: + labels: + - skip-changelog + authors: + - dependabot + - renovate + categories: + - title: Features + labels: + - enhancement + - title: Bug Fixes + labels: + - bug +`; + +export const RELEASE_CONFIG_WITH_WILDCARD = ` +changelog: + categories: + - title: Changes + labels: + - "*" +`; + +export const RELEASE_CONFIG_WITH_SCOPE_GROUPING = ` +changelog: + scopeGrouping: true + categories: + - title: Features + labels: + - enhancement + commit_patterns: + - "^feat(\\\\([^)]+\\\\))?:" + - title: Bug Fixes + labels: + - bug + commit_patterns: + - "^fix(\\\\([^)]+\\\\))?:" +`; + +// ============================================================================ +// Expected Output Helpers +// ============================================================================ + +const BASE_URL = 'https://github.com/test-owner/test-repo'; + +/** + * Creates an expected PR link. + */ +export function prLink(number: string): string { + return `[#${number}](${BASE_URL}/pull/${number})`; +} + +/** + * Creates an expected commit link. + */ +export function commitLink(hash: string, shortHash?: string): string { + const display = shortHash ?? hash.slice(0, 8); + return `[${display}](${BASE_URL}/commit/${hash})`; +} + +/** + * Creates an expected changelog entry line. + */ +export function changelogEntry( + title: string, + options: { author?: string; prNumber?: string; hash?: string } = {} +): string { + const parts = [title]; + + if (options.author) { + parts.push(`by @${options.author}`); + } + + if (options.prNumber) { + parts.push(`in ${prLink(options.prNumber)}`); + } else if (options.hash) { + parts.push(`in ${commitLink(options.hash)}`); + } + + return `- ${parts.join(' ')}`; +} + +/** + * Creates a changelog section with title. + */ +export function changelogSection( + title: string, + emoji: string, + entries: string[] +): string { + return `### ${title} ${emoji}\n\n${entries.join('\n')}`; +} + diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 78f398b4..6f4d116b 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -2,6 +2,7 @@ import type { SimpleGit } from 'simple-git'; import { readFileSync } from 'fs'; import { join } from 'path'; import { load } from 'js-yaml'; +import { marked, type Token, type Tokens } from 'marked'; import { logger } from '../logger'; import { @@ -109,12 +110,19 @@ export interface Changeset { } /** - * A changeset location based on RegExpExecArrays + * A changeset location with position info for slicing */ -export interface ChangesetLoc { - start: RegExpExecArray; - end: RegExpExecArray | null; - padding: string; +interface ChangesetLoc { + /** Start index in the original markdown */ + startIndex: number; + /** End index (start of next heading, or end of document) */ + endIndex: number; + /** The heading title text */ + title: string; + /** Length of the raw heading including newlines */ + headingLength: number; + /** Whether this was a setext-style heading */ + isSetext: boolean; } function escapeMarkdownPound(text: string): string { @@ -158,63 +166,226 @@ export function formatScopeTitle(scope: string): string { } /** - * Extracts a specific changeset from a markdown document + * Represents a single changelog entry item, which may have nested sub-items + */ +export interface ChangelogEntryItem { + /** The main text of the changelog entry */ + text: string; + /** Optional nested content (e.g., sub-bullets) to be indented under this entry */ + nestedContent?: string; +} + +/** + * Extracts the "Changelog Entry" section from a PR description and parses it into structured entries. + * This allows PR authors to override the default changelog entry (which is the PR title) + * with custom text that's more user-facing and detailed. + * + * Looks for a markdown heading (either ### or ##) with the text "Changelog Entry" + * and extracts the content until the next heading of the same or higher level. * - * The changes are bounded by a header preceding the changes and an optional - * header at the end. If the latter is omitted, the markdown document will be - * read until its end. The title of the changes will be extracted from the - * given header. + * Parsing rules: + * - Multiple top-level bullets (-, *, +) become separate changelog entries + * - Plain text (no bullets) becomes a single entry + * - Nested bullets are preserved as nested content under their parent entry + * - Only content within the "Changelog Entry" section is included + * + * @param prBody The PR description/body text + * @returns Array of changelog entry items, or null if no "Changelog Entry" section is found + */ +export function extractChangelogEntry(prBody: string | null | undefined): ChangelogEntryItem[] | null { + if (!prBody) { + return null; + } + + // Use marked's lexer to properly parse the markdown + const tokens = marked.lexer(prBody); + + // Find the "Changelog Entry" heading (level 2 or 3, case-insensitive) + const headingIndex = tokens.findIndex( + (t): t is Tokens.Heading => + t.type === 'heading' && + (t.depth === 2 || t.depth === 3) && + t.text.toLowerCase() === 'changelog entry' + ); + + if (headingIndex === -1) { + return null; + } + + // Collect tokens between this heading and the next heading of same or higher level + const headingDepth = (tokens[headingIndex] as Tokens.Heading).depth; + const contentTokens: Token[] = []; + + for (let i = headingIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + // Stop at next heading of same or higher level + if (token.type === 'heading' && (token as Tokens.Heading).depth <= headingDepth) { + break; + } + contentTokens.push(token); + } + + // If no content tokens, return null + if (contentTokens.length === 0) { + return null; + } + + // Process the content tokens into changelog entries + return parseTokensToEntries(contentTokens); +} + +/** + * Recursively extracts nested content from a list item's tokens. + */ +function extractNestedContent(tokens: Token[]): string { + const nestedLines: string[] = []; + + for (const token of tokens) { + if (token.type === 'list') { + const listToken = token as Tokens.List; + for (const item of listToken.items) { + // Get the text of this nested item + const itemText = getListItemText(item); + nestedLines.push(` - ${itemText}`); + + // Recursively get any deeper nested content + const deeperNested = extractNestedContent(item.tokens); + if (deeperNested) { + // Indent deeper nested content further + const indentedDeeper = deeperNested + .split('\n') + .map(line => ' ' + line) + .join('\n'); + nestedLines.push(indentedDeeper); + } + } + } + } + + return nestedLines.join('\n'); +} + +/** + * Gets the text content of a list item, excluding nested lists. + */ +function getListItemText(item: Tokens.ListItem): string { + // The item.text contains the raw text, but we want just the first line + // (before any nested lists) + const firstToken = item.tokens.find(t => t.type === 'text' || t.type === 'paragraph'); + if (firstToken && 'text' in firstToken) { + return firstToken.text.split('\n')[0].trim(); + } + return item.text.split('\n')[0].trim(); +} + +/** + * Parses content tokens into structured changelog entries. + */ +function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null { + const entries: ChangelogEntryItem[] = []; + + for (const token of tokens) { + if (token.type === 'list') { + // Each top-level list item becomes a changelog entry + const listToken = token as Tokens.List; + for (const item of listToken.items) { + const text = getListItemText(item); + const nestedContent = extractNestedContent(item.tokens); + + entries.push({ + text, + ...(nestedContent ? { nestedContent } : {}), + }); + } + } else if (token.type === 'paragraph') { + // Paragraph text becomes a single entry + // Join multiple lines with spaces to avoid broken markdown + const paragraphToken = token as Tokens.Paragraph; + const text = paragraphToken.text + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join(' '); + + if (text) { + entries.push({ text }); + } + } + } + + return entries.length > 0 ? entries : null; +} + +/** + * Extracts a specific changeset from a markdown document using the location info. * * @param markdown The full changelog markdown - * @param location The start & end location for the section - * @returns The extracted changes + * @param location The changeset location + * @returns The extracted changeset */ function extractChangeset(markdown: string, location: ChangesetLoc): Changeset { - const start = location.start.index + location.start[0].length; - const end = location.end ? location.end.index : undefined; - const body = markdown.substring(start, end).trim(); - const name = (location.start[2] || location.start[3]) - .replace(/\(.*\)$/, '') - .trim(); + const bodyStart = location.startIndex + location.headingLength; + const body = markdown.substring(bodyStart, location.endIndex).trim(); + // Remove trailing parenthetical content (e.g., dates) from the title + const name = location.title.replace(/\(.*\)$/, '').trim(); return { name, body }; } /** - * Locates and returns a changeset section with the title passed in header. - * Supports an optional "predicate" callback used to compare the expected title - * and the title found in text. Useful for normalizing versions. + * Locates a changeset section matching the predicate using marked tokenizer. + * Supports both ATX-style (## Header) and Setext-style (Header\n---) headings. * * @param markdown The full changelog markdown - * @param predicate A callback that takes the found title and returns true if - * this is a match, false otherwise - * @returns A ChangesetLoc object where "start" has the matche for the header, - * and "end" has the match for the next header so the contents - * inbetween can be extracted + * @param predicate A callback that takes the found title and returns true if match + * @returns A ChangesetLoc object or null if not found */ function locateChangeset( markdown: string, predicate: (match: string) => boolean ): ChangesetLoc | null { - const HEADER_REGEX = new RegExp( - `^( *)(?:#{${VERSION_HEADER_LEVEL}} +([^\\n]+?) *(?:#{${VERSION_HEADER_LEVEL}})?|([^\\n]+)\\n *(?:-){2,}) *(?:\\n+|$)`, - 'gm' - ); + const tokens = marked.lexer(markdown); + + // Track position by accumulating raw lengths + let pos = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.type === 'heading' && token.depth === VERSION_HEADER_LEVEL) { + const headingToken = token as Tokens.Heading; + + if (predicate(headingToken.text)) { + // Find the end position (start of next same-level or higher heading) + let endIndex = markdown.length; + let searchPos = pos + headingToken.raw.length; + + for (let j = i + 1; j < tokens.length; j++) { + const nextToken = tokens[j]; + if ( + nextToken.type === 'heading' && + (nextToken as Tokens.Heading).depth <= VERSION_HEADER_LEVEL + ) { + endIndex = searchPos; + break; + } + searchPos += nextToken.raw.length; + } - for ( - let match = HEADER_REGEX.exec(markdown); - match !== null; - match = HEADER_REGEX.exec(markdown) - ) { - const matchedTitle = match[2] || match[3]; - if (predicate(matchedTitle)) { - const padSize = match?.[1]?.length || 0; - return { - end: HEADER_REGEX.exec(markdown), - start: match, - padding: new Array(padSize + 1).join(' '), - }; + // Detect setext-style headings (raw contains \n followed by dashes) + const isSetext = /\n\s*-{2,}/.test(headingToken.raw); + + return { + startIndex: pos, + endIndex, + title: headingToken.text, + headingLength: headingToken.raw.length, + isSetext, + }; + } } + + pos += token.raw.length; } + return null; } @@ -269,9 +440,7 @@ export function removeChangeset(markdown: string, header: string): string { return markdown; } - const start = location.start.index; - const end = location.end?.index ?? markdown.length; - return markdown.slice(0, start) + markdown.slice(end); + return markdown.slice(0, location.startIndex) + markdown.slice(location.endIndex); } /** @@ -290,22 +459,17 @@ export function prependChangeset( changeset: Changeset ): string { // Try to locate the top-most non-empty header, no matter what is inside - const { start, padding } = locateChangeset(markdown, Boolean) || { - padding: '', - }; - const body = changeset.body || `${padding}${DEFAULT_CHANGESET_BODY}`; + const firstHeading = locateChangeset(markdown, Boolean); + const body = changeset.body || DEFAULT_CHANGESET_BODY; let header; - if (start?.[3]) { + if (firstHeading?.isSetext) { const underline = new Array(changeset.name.length + 1).join('-'); header = `${changeset.name}\n${underline}`; } else { header = markdownHeader(VERSION_HEADER_LEVEL, changeset.name); } - const newSection = `${padding}${header}\n\n${body.replace( - /^/gm, - padding - )}\n\n`; - const startIdx = start?.index ?? markdown.length; + const newSection = `${header}\n\n${body}\n\n`; + const startIdx = firstHeading?.startIndex ?? markdown.length; return markdown.slice(0, startIdx) + newSection + markdown.slice(startIdx); } @@ -320,6 +484,60 @@ interface PullRequest { highlight?: boolean; } +/** + * 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. + * Otherwise, a single entry is created using the PR title. + * + * @param raw Raw commit/PR info + * @param defaultTitle The default title to use if no custom entries (usually PR title) + * @param fallbackBody Optional fallback body to check for magic word (used for leftovers) + * @returns Array of PullRequest entries + */ +function createPREntriesFromRaw( + raw: { + author?: string; + pr?: string; + hash: string; + prBody?: string | null; + highlight?: boolean; + }, + defaultTitle: string, + fallbackBody?: string +): PullRequest[] { + const customEntries = extractChangelogEntry(raw.prBody); + + if (customEntries) { + return customEntries.map(entry => ({ + author: raw.author, + number: raw.pr ?? '', + hash: raw.hash, + body: entry.nestedContent ?? '', + title: entry.text, + highlight: raw.highlight, + })); + } + + // For default entries, only include body if it contains the magic word + let body = ''; + if (raw.prBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) { + body = raw.prBody; + } else if (fallbackBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) { + body = fallbackBody; + } + + return [ + { + author: raw.author, + number: raw.pr ?? '', + hash: raw.hash, + body, + title: defaultTitle, + highlight: raw.highlight, + }, + ]; +} + interface Commit { author?: string; hash: string; @@ -568,9 +786,6 @@ export function normalizeReleaseConfig( return normalized; } -/** - * Checks if a PR should be excluded globally based on release config - */ /** * Checks if a PR should be excluded globally based on: * 1. The #skip-changelog magic word in the body (commit body or PR body) @@ -795,11 +1010,23 @@ function formatChangelogEntry(entry: ChangelogEntry): string { } } - // Add body if magic word is present - if (entry.body?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) { - const body = entry.body.replace(BODY_IN_CHANGELOG_MAGIC_WORD, '').trim(); - if (body) { - text += `\n ${body}`; + // Add body content + // Two cases: 1) legacy magic word behavior, 2) nested content from structured changelog entries + if (entry.body) { + if (entry.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) { + // Legacy behavior: extract and format body with magic word + const body = entry.body.replace(BODY_IN_CHANGELOG_MAGIC_WORD, '').trim(); + if (body) { + text += `\n ${body}`; + } + } else if (entry.body.trim()) { + // New behavior: nested content from parsed changelog entries + // Don't trim() before splitting to preserve indentation on all lines + const lines = entry.body.split('\n'); + for (const line of lines) { + // Each line already has the proper indentation from parsing + text += `\n${line}`; + } } } @@ -1098,14 +1325,9 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { category.scopeGroups.set(scope, scopeGroup); } - scopeGroup.push({ - author: raw.author, - number: raw.pr, - hash: raw.hash, - body: raw.prBody ?? '', - title: prTitle, - highlight: raw.highlight, - }); + // Create PR entries (handles custom changelog entries if present) + const prEntries = createPREntriesFromRaw(raw, prTitle, raw.body); + scopeGroup.push(...prEntries); } } @@ -1275,27 +1497,36 @@ async function serializeChangelog( if (changelogSections.length > 0) { changelogSections.push(markdownHeader(SUBSECTION_HEADER_LEVEL, 'Other')); } - changelogSections.push( - leftovers - .slice(0, maxLeftovers) - .map(commit => + const leftoverEntries: string[] = []; + for (const commit of leftovers.slice(0, maxLeftovers)) { + // Create PR entries (handles custom changelog entries if present) + const prEntries = createPREntriesFromRaw( + { + author: commit.author, + pr: commit.pr ?? undefined, + hash: commit.hash, + prBody: commit.prBody, + highlight: commit.highlight, + }, + (commit.prTitle ?? commit.title).trim(), + commit.body // fallback for magic word check + ); + + for (const pr of prEntries) { + leftoverEntries.push( formatChangelogEntry({ - title: (commit.prTitle ?? commit.title).trim(), - author: commit.author, - prNumber: commit.pr ?? undefined, - hash: commit.hash, + title: pr.title, + author: pr.author, + prNumber: pr.number || undefined, + hash: pr.hash, repoUrl, - // Check both prBody and commit body for the magic word - body: commit.prBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD) - ? commit.prBody - : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) - ? commit.body - : undefined, - highlight: commit.highlight, + body: pr.body || undefined, + highlight: pr.highlight, }) - ) - .join('\n') - ); + ); + } + } + changelogSections.push(leftoverEntries.join('\n')); if (nLeftovers > maxLeftovers) { changelogSections.push(`_Plus ${nLeftovers - maxLeftovers} more_`); } diff --git a/yarn.lock b/yarn.lock index e654e11e..bb55d9bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4955,6 +4955,11 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +marked@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.1.tgz#9db34197ac145e5929572ee49ef701e37ee9b2e6" + integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg== + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"