diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 8f69e2b2..4577a050 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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 | `^(?\w+(?:\((?[^)]+)\))?!:\s*)` | +| New Features | `^(?feat(?:\((?[^)]+)\))?!?:\s*)` | +| Bug Fixes | `^(?fix(?:\((?[^)]+)\))?!?:\s*)` | +| Documentation | `^(?docs?(?:\((?[^)]+)\))?!?:\s*)` | +| Build / dependencies | `^(?(?:build\|refactor\|chore\|ci)(?:\((?[^)]+)\))?!?:\s*)` | Example `.github/release.yml`: @@ -107,12 +107,12 @@ changelog: labels: - enhancement commit_patterns: - - "^feat(\\(\\w+\\))?:" + - "^(?feat(?:\\((?[^)]+)\\))?!?:\\s*)" - title: Bug Fixes labels: - bug commit_patterns: - - "^fix(\\(\\w+\\))?:" + - "^(?fix(?:\\((?[^)]+)\\))?!?:\\s*)" ``` ### Custom Changelog Entries from PR Descriptions @@ -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`: + +- `(?...)` - The type prefix to strip (includes type, scope, and colon) +- `(?...)` - 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 diff --git a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap index bc7d1158..77cbbcad 100644 --- a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap @@ -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`] = ` @@ -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`] = ` @@ -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)" `; diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index eadd6840..a7dce6a9 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -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'); + }); }); // ============================================================================ diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index 0a7be1f5..461dc69a 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -13,6 +13,7 @@ import { shouldExcludePR, shouldSkipCurrentPR, getBumpTypeForPR, + stripTitle, SKIP_CHANGELOG_MAGIC_WORD, BODY_IN_CHANGELOG_MAGIC_WORD, type CurrentPRInfo, @@ -142,3 +143,126 @@ describe('magic word constants', () => { }); }); +describe('stripTitle', () => { + describe('with type named group', () => { + const pattern = /^(?feat(?:\((?[^)]+)\))?!?:\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 = /^(?feat(?:\((?[^)]+)\))?!:\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 = /^(?feat(?:\((?[^)]+)\))?!?:\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 = /^(?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 = /^(?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 = /^(?fix(?:\((?[^)]+)\))?!?:\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 = /^(?docs?(?:\((?[^)]+)\))?!?:\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 = + /^(?(?:build|refactor|meta|chore|ci|ref|perf)(?:\((?[^)]+)\))?!?:\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' + ); + }); + }); +}); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 6f4d116b..360319bb 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -482,6 +482,8 @@ interface PullRequest { title: string; /** Whether this entry should be highlighted in output */ highlight?: boolean; + /** The pattern that matched this PR (for title stripping) */ + matchedPattern?: RegExp; } /** @@ -603,6 +605,10 @@ export interface ReleaseConfig { /** * Default release configuration based on conventional commits * Used when .github/release.yml doesn't exist + * + * Patterns use named capture groups for title stripping: + * - (?...) - The prefix to strip from changelog entries + * - (?...) - The scope to preserve when not under a scope header */ export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { changelog: { @@ -612,27 +618,29 @@ export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { categories: [ { title: 'Breaking Changes 🛠', - commit_patterns: ['^\\w+(?:\\([^)]+\\))?!:'], + commit_patterns: ['^(?\\w+(?:\\((?[^)]+)\\))?!:\\s*)'], semver: 'major', }, { title: 'New Features ✨', - commit_patterns: ['^feat\\b'], + commit_patterns: ['^(?feat(?:\\((?[^)]+)\\))?!?:\\s*)'], semver: 'minor', }, { title: 'Bug Fixes 🐛', - commit_patterns: ['^fix\\b'], + commit_patterns: ['^(?fix(?:\\((?[^)]+)\\))?!?:\\s*)'], semver: 'patch', }, { title: 'Documentation 📚', - commit_patterns: ['^docs?\\b'], + commit_patterns: ['^(?docs?(?:\\((?[^)]+)\\))?!?:\\s*)'], semver: 'patch', }, { title: 'Build / dependencies / internal 🔧', - commit_patterns: ['^(?:build|refactor|meta|chore|ci|ref|perf)\\b'], + commit_patterns: [ + '^(?(?:build|refactor|meta|chore|ci|ref|perf)(?:\\((?[^)]+)\\))?!?:\\s*)', + ], semver: 'patch', }, ], @@ -856,14 +864,14 @@ export function getBumpTypeForPR(prInfo: CurrentPRInfo): BumpType | null { const releaseConfig = normalizeReleaseConfig(rawConfig); const labels = new Set(prInfo.labels); - const matchedCategory = matchCommitToCategory( + const match = matchCommitToCategory( labels, prInfo.author, prInfo.title.trim(), releaseConfig ); - return matchedCategory?.semver ?? null; + return match?.category.semver ?? null; } /** @@ -889,19 +897,28 @@ export function isCategoryExcluded( return false; } +/** + * Result of matching a commit to a category. + */ +export interface CategoryMatchResult { + category: NormalizedCategory; + /** The pattern that matched (only set when matched via commit_patterns) */ + matchedPattern?: RegExp; +} + /** * Matches a PR's labels or commit title to a category from release config. * Labels take precedence over commit log pattern matching. * Category-level exclusions are checked here - they exclude the PR from matching this specific category, * allowing it to potentially match other categories or fall through to "Other" - * @returns The matched category or null if no match or excluded from all categories + * @returns The matched category and pattern, or null if no match or excluded from all categories */ export function matchCommitToCategory( labels: Set, author: string | undefined, title: string, config: NormalizedReleaseConfig | null -): NormalizedCategory | null { +): CategoryMatchResult | null { if (!config?.changelog || config.changelog.categories.length === 0) { return null; } @@ -927,22 +944,26 @@ export function matchCommitToCategory( } // First pass: try label matching (skip if no labels) + // Label matches don't return a pattern (no stripping for label-based categorization) if (labels.size > 0) { for (const category of regularCategories) { const matchesCategory = category.labels.some(label => labels.has(label)); if (matchesCategory && !isCategoryExcluded(category, labels, author)) { - return category; + return { category }; } } } // Second pass: try commit_patterns matching + // Return the matched pattern for title stripping for (const category of regularCategories) { - const matchesPattern = category.commitLogPatterns.some(re => - re.test(title) - ); - if (matchesPattern && !isCategoryExcluded(category, labels, author)) { - return category; + for (const pattern of category.commitLogPatterns) { + if (pattern.test(title)) { + if (!isCategoryExcluded(category, labels, author)) { + return { category, matchedPattern: pattern }; + } + break; // This category is excluded, try next category + } } } @@ -950,7 +971,7 @@ export function matchCommitToCategory( if (isCategoryExcluded(wildcardCategory, labels, author)) { return null; } - return wildcardCategory; + return { category: wildcardCategory }; } return null; @@ -972,6 +993,40 @@ interface ChangelogEntry { highlight?: boolean; } +/** + * Strips the conventional commit type prefix from a title using named capture groups. + * + * Named groups in the pattern control stripping behavior: + * - `(?...)` - The type prefix to strip (e.g., `feat(scope):`) + * - `(?...)` - Scope to preserve when not under a scope header + * + * @param title The original PR/commit title + * @param pattern The matched pattern (may contain named groups) + * @param preserveScope Whether to preserve the scope in the output + * @returns The stripped title, or the original if no stripping applies + */ +export function stripTitle( + title: string, + pattern: RegExp | undefined, + preserveScope: boolean +): string { + if (!pattern) return title; + + const match = pattern.exec(title); + if (!match?.groups?.type) return title; + + const remainder = title.slice(match.groups.type.length); + if (!remainder) return title; // Don't strip if nothing remains + + const capitalized = remainder.charAt(0).toUpperCase() + remainder.slice(1); + + if (preserveScope && match.groups.scope) { + return `(${match.groups.scope}) ${capitalized}`; + } + + return capitalized; +} + /** * Formats a single changelog entry with consistent full markdown link format. * Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)` @@ -1268,12 +1323,14 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { // Use PR title if available, otherwise use commit title for pattern matching const titleForMatching = (raw.prTitle ?? raw.title).trim(); - const matchedCategory = matchCommitToCategory( + const match = matchCommitToCategory( labels, raw.author, titleForMatching, releaseConfig ); + const matchedCategory = match?.category ?? null; + const matchedPattern = match?.matchedPattern; const categoryTitle = matchedCategory?.title ?? null; // Track bump type if category has semver field @@ -1327,6 +1384,10 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult { // Create PR entries (handles custom changelog entries if present) const prEntries = createPREntriesFromRaw(raw, prTitle, raw.body); + // Add matched pattern to each entry for title stripping + for (const entry of prEntries) { + entry.matchedPattern = matchedPattern; + } scopeGroup.push(...prEntries); } } @@ -1442,7 +1503,7 @@ async function serializeChangelog( return scopeA.localeCompare(scopeB); }); - // Check if any scope has multiple entries (would get a header) + // Check if any scope has multiple entries (would get its own header) const hasScopeHeaders = [...category.scopeGroups.entries()].some( ([s, entries]) => s !== null && entries.length > 1 ); @@ -1451,9 +1512,24 @@ async function serializeChangelog( const entriesWithoutHeaders: string[] = []; for (const [scope, prs] of sortedScopes) { + // Determine scope header: + // - Scoped entries with multiple PRs get formatted scope title + // - All other entries (single scoped or scopeless) go to entriesWithoutHeaders + // and get an "Other" header if there are scope headers shown + let scopeHeader: string | null = null; + if (scopeGroupingEnabled) { + if (scope !== null && prs.length > 1) { + scopeHeader = formatScopeTitle(scope); + } + } + + // When a scope header is shown, we can strip the scope from titles + // When no scope header, preserve the scope for context + const showsScopeHeader = scopeHeader !== null && scope !== null; + const prEntries = prs.map(pr => formatChangelogEntry({ - title: pr.title, + title: stripTitle(pr.title, pr.matchedPattern, !showsScopeHeader), author: pr.author, prNumber: pr.number, hash: pr.hash, @@ -1463,19 +1539,6 @@ async function serializeChangelog( }) ); - // Determine scope header: - // - Scoped entries with multiple PRs get formatted scope title - // - Scopeless entries get "Other" header when other scope headers exist - // - Otherwise no header (entries collected for later) - let scopeHeader: string | null = null; - if (scopeGroupingEnabled) { - if (scope !== null && prs.length > 1) { - scopeHeader = formatScopeTitle(scope); - } else if (scope === null && hasScopeHeaders) { - scopeHeader = 'Other'; - } - } - if (scopeHeader) { changelogSections.push(markdownHeader(SCOPE_HEADER_LEVEL, scopeHeader)); changelogSections.push(prEntries.join('\n')); @@ -1485,8 +1548,12 @@ async function serializeChangelog( } } - // Push all entries without headers as a single section to avoid extra newlines + // Push all entries without headers as a single section + // Add "Other" header if there are scope headers to separate them if (entriesWithoutHeaders.length > 0) { + if (hasScopeHeaders) { + changelogSections.push(markdownHeader(SCOPE_HEADER_LEVEL, 'Other')); + } changelogSections.push(entriesWithoutHeaders.join('\n')); } }