From 692b7192ed21d5e4876449c49b92cc6291ea5e14 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 27 Dec 2025 12:33:19 +0300 Subject: [PATCH 1/5] feat(changelog): Strip conventional commit prefixes from changelog entries By default, conventional commit type prefixes (e.g., 'feat(api): ') are now stripped from changelog entries. The scope is preserved when entries aren't grouped under a scope header. This is controlled via named capture groups in commit_patterns: - (?...) - The prefix to strip - (?...) - The scope to preserve when no scope header is shown Users can disable stripping by using non-capturing groups in their patterns. --- docs/src/content/docs/configuration.md | 46 +++- .../changelog-generate.test.ts.snap | 8 +- src/utils/__tests__/changelog-utils.test.ts | 124 +++++++++ src/utils/__tests__/changelog.fixtures.ts | 239 ++++++++++++++++++ src/utils/changelog.ts | 123 ++++++--- 5 files changed, 496 insertions(+), 44 deletions(-) create mode 100644 src/utils/__tests__/changelog.fixtures.ts 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..f83e0e98 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`] = ` 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/__tests__/changelog.fixtures.ts b/src/utils/__tests__/changelog.fixtures.ts new file mode 100644 index 00000000..692ed6d5 --- /dev/null +++ b/src/utils/__tests__/changelog.fixtures.ts @@ -0,0 +1,239 @@ +/** + * 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 6f4d116b..c1c0a248 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); } } @@ -1451,18 +1512,6 @@ async function serializeChangelog( const entriesWithoutHeaders: string[] = []; for (const [scope, prs] of sortedScopes) { - const prEntries = prs.map(pr => - formatChangelogEntry({ - title: pr.title, - author: pr.author, - prNumber: pr.number, - hash: pr.hash, - body: pr.body, - repoUrl, - highlight: pr.highlight, - }) - ); - // Determine scope header: // - Scoped entries with multiple PRs get formatted scope title // - Scopeless entries get "Other" header when other scope headers exist @@ -1476,6 +1525,22 @@ async function serializeChangelog( } } + // 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: stripTitle(pr.title, pr.matchedPattern, !showsScopeHeader), + author: pr.author, + prNumber: pr.number, + hash: pr.hash, + body: pr.body, + repoUrl, + highlight: pr.highlight, + }) + ); + if (scopeHeader) { changelogSections.push(markdownHeader(SCOPE_HEADER_LEVEL, scopeHeader)); changelogSections.push(prEntries.join('\n')); From 6b7f6b5957e7b91a956331b34e42814d0c5383f4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 27 Dec 2025 12:41:31 +0300 Subject: [PATCH 2/5] fix: Remove accidentally committed fixtures file --- src/utils/__tests__/changelog.fixtures.ts | 239 ---------------------- 1 file changed, 239 deletions(-) delete mode 100644 src/utils/__tests__/changelog.fixtures.ts diff --git a/src/utils/__tests__/changelog.fixtures.ts b/src/utils/__tests__/changelog.fixtures.ts deleted file mode 100644 index 692ed6d5..00000000 --- a/src/utils/__tests__/changelog.fixtures.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * 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')}`; -} From 997b0da8e6e4c788c6fd0b97748727a5ca9c0062 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 27 Dec 2025 12:47:46 +0300 Subject: [PATCH 3/5] fix(changelog): Always show 'Other' header when there are scoped entries Previously, 'Other' header for scopeless entries was only shown when there were scopes with 2+ entries (getting their own header). This caused confusion when a single unscoped entry appeared after a scope header, looking like it was part of that scope. Now 'Other' header is shown whenever there are any scoped entries, except when scopeless is the only group (where 'Other' would be meaningless). --- src/utils/changelog.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index c1c0a248..e523c783 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -1503,9 +1503,10 @@ async function serializeChangelog( return scopeA.localeCompare(scopeB); }); - // Check if any scope has multiple entries (would get a header) - const hasScopeHeaders = [...category.scopeGroups.entries()].some( - ([s, entries]) => s !== null && entries.length > 1 + // Check if there are any scoped entries (non-null scopes) + // Used to determine if "Other" header should be shown for scopeless entries + const hasScopedEntries = [...category.scopeGroups.keys()].some( + s => s !== null ); // Collect entries without headers to combine them into a single section @@ -1514,13 +1515,14 @@ async function serializeChangelog( for (const [scope, prs] of sortedScopes) { // Determine scope header: // - Scoped entries with multiple PRs get formatted scope title - // - Scopeless entries get "Other" header when other scope headers exist + // - Scopeless entries get "Other" header when there are any scoped entries + // (except when scopeless is the only group, then "Other" is meaningless) // - 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) { + } else if (scope === null && hasScopedEntries) { scopeHeader = 'Other'; } } From cb777e2cd5b554da75db59ec8acc6df8037da039 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 27 Dec 2025 12:55:04 +0300 Subject: [PATCH 4/5] fix(changelog): Show 'Other' header for all non-grouped scope entries When a scope has 2+ entries it gets its own header (e.g., '#### Api'). Single-scope entries and scopeless entries now go under an 'Other' header to clearly separate them from the grouped scopes. This prevents confusion where single entries appeared to belong to the previous scope's header. --- .../changelog-generate.test.ts.snap | 2 ++ src/utils/changelog.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap index f83e0e98..77cbbcad 100644 --- a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap @@ -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/changelog.ts b/src/utils/changelog.ts index e523c783..360319bb 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -1503,10 +1503,9 @@ async function serializeChangelog( return scopeA.localeCompare(scopeB); }); - // Check if there are any scoped entries (non-null scopes) - // Used to determine if "Other" header should be shown for scopeless entries - const hasScopedEntries = [...category.scopeGroups.keys()].some( - s => s !== null + // 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 ); // Collect entries without headers to combine them into a single section @@ -1515,15 +1514,12 @@ async function serializeChangelog( for (const [scope, prs] of sortedScopes) { // Determine scope header: // - Scoped entries with multiple PRs get formatted scope title - // - Scopeless entries get "Other" header when there are any scoped entries - // (except when scopeless is the only group, then "Other" is meaningless) - // - Otherwise no header (entries collected for later) + // - 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); - } else if (scope === null && hasScopedEntries) { - scopeHeader = 'Other'; } } @@ -1552,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')); } } From 4efdfab480d4dda581c2a86cbeb2405dd97b1438 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 27 Dec 2025 13:01:10 +0300 Subject: [PATCH 5/5] test(changelog): Add explicit tests for 'Other' header behavior - Test that single-scope entries get 'Other' header when scope groups exist - Test that no 'Other' header when only scopeless entries - Test that no 'Other' header when all scopes are single-entry --- .../__tests__/changelog-generate.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) 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'); + }); }); // ============================================================================