Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ Auto mode uses `.github/release.yml` to categorize PRs by labels or commit patte

| Category | Pattern |
|----------|---------|
| Breaking Changes | `^\w+(\(\w+\))?!:` |
| Build / dependencies | `^(build\|ref\|chore\|ci)(\(\w+\))?:` |
| Bug Fixes | `^fix(\(\w+\))?:` |
| Documentation | `^docs?(\(\w+\))?:` |
| New Features | `^feat(\(\w+\))?:` |
| Breaking Changes | `^(?<type>\w+(?:\((?<scope>[^)]+)\))?!:\s*)` |
| New Features | `^(?<type>feat(?:\((?<scope>[^)]+)\))?!?:\s*)` |
| Bug Fixes | `^(?<type>fix(?:\((?<scope>[^)]+)\))?!?:\s*)` |
| Documentation | `^(?<type>docs?(?:\((?<scope>[^)]+)\))?!?:\s*)` |
| Build / dependencies | `^(?<type>(?:build\|refactor\|chore\|ci)(?:\((?<scope>[^)]+)\))?!?:\s*)` |

Example `.github/release.yml`:

Expand All @@ -107,12 +107,12 @@ changelog:
labels:
- enhancement
commit_patterns:
- "^feat(\\(\\w+\\))?:"
- "^(?<type>feat(?:\\((?<scope>[^)]+)\\))?!?:\\s*)"
- title: Bug Fixes
labels:
- bug
commit_patterns:
- "^fix(\\(\\w+\\))?:"
- "^(?<type>fix(?:\\((?<scope>[^)]+)\\))?!?:\\s*)"
```

### Custom Changelog Entries from PR Descriptions
Expand Down Expand Up @@ -205,14 +205,38 @@ Example output with scope grouping:

#### 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/...)
- Add user endpoint by @alice in [#1](https://github.com/...)
- Add auth endpoint by @bob in [#2](https://github.com/...)

#### Ui

- feat(ui): add dashboard by @charlie in [#3](https://github.com/...)
- Add dashboard by @charlie in [#3](https://github.com/...)

- feat: general improvement by @dave in [#4](https://github.com/...)
- General improvement by @dave in [#4](https://github.com/...)
```

### Title Stripping (Default Behavior)

By default, conventional commit prefixes are stripped from changelog entries.
The type (e.g., `feat:`) is removed, and the scope is preserved when entries
aren't grouped under a scope header.

This behavior is controlled by named capture groups in `commit_patterns`:

- `(?<type>...)` - The type prefix to strip (includes type, scope, and colon)
- `(?<scope>...)` - Scope to preserve when not under a scope header

| Original Title | Scope Header | Displayed Title |
|----------------|--------------|-----------------|
| `feat(api): add endpoint` | Yes (Api) | `Add endpoint` |
| `feat(api): add endpoint` | No | `(api) Add endpoint` |
| `feat: add endpoint` | N/A | `Add endpoint` |

To disable stripping, provide custom patterns using non-capturing groups:

```yaml
commit_patterns:
- "^feat(?:\\([^)]+\\))?!?:" # No named groups = no stripping
```

### Configuration Options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ exports[`generateChangesetFromGit commit patterns matches PRs based on commit_pa
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)
- 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)
- 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)"
- 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`] = `
Expand Down Expand Up @@ -99,7 +99,7 @@ exports[`generateChangesetFromGit output formatting uses PR number when availabl
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)"
- 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`] = `
Expand All @@ -110,6 +110,8 @@ exports[`generateChangesetFromGit scope grouping groups PRs by scope when multip
- 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)

#### Other

- feat(ui): add button by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)"
`;

Expand Down
75 changes: 75 additions & 0 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,81 @@ changelog:
const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
expect(result.changelog).toMatchSnapshot();
});

it('shows Other header for single-scope entries when scope groups exist', async () => {
setup([
{
hash: 'abc123',
title: 'feat(api): api feature 1',
body: '',
pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
},
{
hash: 'def456',
title: 'feat(api): api feature 2',
body: '',
pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
},
{
hash: 'ghi789',
title: 'feat(ui): single ui feature',
body: '',
pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } },
},
], SCOPE_CONFIG);
const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
// Single-scope entry should be under "Other" header
expect(result.changelog).toContain('#### Api');
expect(result.changelog).toContain('#### Other');
expect(result.changelog).toContain('feat(ui): single ui feature');
});

it('does not show Other header when only scopeless entries exist', async () => {
setup([
{
hash: 'abc123',
title: 'feat: feature 1',
body: '',
pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
},
{
hash: 'def456',
title: 'feat: feature 2',
body: '',
pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
},
], SCOPE_CONFIG);
const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
// No scope headers, so no "Other" header needed
expect(result.changelog).not.toContain('#### Api');
expect(result.changelog).not.toContain('#### Other');
expect(result.changelog).toContain('feat: feature 1');
expect(result.changelog).toContain('feat: feature 2');
});

it('does not show Other header when all scopes are single-entry', async () => {
setup([
{
hash: 'abc123',
title: 'feat(api): single api feature',
body: '',
pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
},
{
hash: 'def456',
title: 'feat(ui): single ui feature',
body: '',
pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
},
], SCOPE_CONFIG);
const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
// No scope gets 2+ entries, so no headers at all
expect(result.changelog).not.toContain('#### Api');
expect(result.changelog).not.toContain('#### Ui');
expect(result.changelog).not.toContain('#### Other');
expect(result.changelog).toContain('feat(api): single api feature');
expect(result.changelog).toContain('feat(ui): single ui feature');
});
});

// ============================================================================
Expand Down
124 changes: 124 additions & 0 deletions src/utils/__tests__/changelog-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
shouldExcludePR,
shouldSkipCurrentPR,
getBumpTypeForPR,
stripTitle,
SKIP_CHANGELOG_MAGIC_WORD,
BODY_IN_CHANGELOG_MAGIC_WORD,
type CurrentPRInfo,
Expand Down Expand Up @@ -142,3 +143,126 @@ describe('magic word constants', () => {
});
});

describe('stripTitle', () => {
describe('with type named group', () => {
const pattern = /^(?<type>feat(?:\((?<scope>[^)]+)\))?!?:\s*)/;

it('strips the type prefix', () => {
expect(stripTitle('feat: add endpoint', pattern, false)).toBe(
'Add endpoint'
);
});

it('strips type and scope when preserveScope is false', () => {
expect(stripTitle('feat(api): add endpoint', pattern, false)).toBe(
'Add endpoint'
);
});

it('preserves scope when preserveScope is true', () => {
expect(stripTitle('feat(api): add endpoint', pattern, true)).toBe(
'(api) Add endpoint'
);
});

it('capitalizes first letter after stripping', () => {
expect(stripTitle('feat: lowercase start', pattern, false)).toBe(
'Lowercase start'
);
});

it('handles already capitalized content', () => {
expect(stripTitle('feat: Already Capitalized', pattern, false)).toBe(
'Already Capitalized'
);
});

it('does not strip if no type match', () => {
expect(stripTitle('random title', pattern, false)).toBe('random title');
});

it('handles breaking change indicator', () => {
const breakingPattern = /^(?<type>feat(?:\((?<scope>[^)]+)\))?!:\s*)/;
expect(stripTitle('feat!: breaking change', breakingPattern, false)).toBe(
'Breaking change'
);
expect(
stripTitle('feat(api)!: breaking api change', breakingPattern, false)
).toBe('Breaking api change');
});

it('does not strip when pattern has no type group', () => {
const noGroupPattern = /^feat(?:\([^)]+\))?!?:\s*/;
expect(stripTitle('feat: add endpoint', noGroupPattern, false)).toBe(
'feat: add endpoint'
);
});
});

describe('edge cases', () => {
const pattern = /^(?<type>feat(?:\((?<scope>[^)]+)\))?!?:\s*)/;

it('returns original if pattern is undefined', () => {
expect(stripTitle('feat: add endpoint', undefined, false)).toBe(
'feat: add endpoint'
);
});

it('does not strip if nothing remains after stripping', () => {
const exactPattern = /^(?<type>feat:\s*)$/;
expect(stripTitle('feat: ', exactPattern, false)).toBe('feat: ');
});

it('handles scope with special characters', () => {
expect(stripTitle('feat(my-api): add endpoint', pattern, true)).toBe(
'(my-api) Add endpoint'
);
expect(stripTitle('feat(my_api): add endpoint', pattern, true)).toBe(
'(my_api) Add endpoint'
);
});

it('does not preserve scope when scope is not captured', () => {
const noScopePattern = /^(?<type>feat(?:\([^)]+\))?!?:\s*)/;
expect(stripTitle('feat(api): add endpoint', noScopePattern, true)).toBe(
'Add endpoint'
);
});
});

describe('with different commit types', () => {
it('works with fix type', () => {
const pattern = /^(?<type>fix(?:\((?<scope>[^)]+)\))?!?:\s*)/;
expect(stripTitle('fix(core): resolve bug', pattern, false)).toBe(
'Resolve bug'
);
expect(stripTitle('fix(core): resolve bug', pattern, true)).toBe(
'(core) Resolve bug'
);
});

it('works with docs type', () => {
const pattern = /^(?<type>docs?(?:\((?<scope>[^)]+)\))?!?:\s*)/;
expect(stripTitle('docs(readme): update docs', pattern, false)).toBe(
'Update docs'
);
expect(stripTitle('doc(readme): update docs', pattern, false)).toBe(
'Update docs'
);
});

it('works with build/chore types', () => {
const pattern =
/^(?<type>(?:build|refactor|meta|chore|ci|ref|perf)(?:\((?<scope>[^)]+)\))?!?:\s*)/;
expect(stripTitle('chore(deps): update deps', pattern, false)).toBe(
'Update deps'
);
expect(stripTitle('build(ci): fix pipeline', pattern, false)).toBe(
'Fix pipeline'
);
expect(stripTitle('refactor(api): simplify logic', pattern, true)).toBe(
'(api) Simplify logic'
);
});
});
});
Loading
Loading