diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts index ae2770a8..92b7c005 100644 --- a/src/utils/__tests__/changelog.test.ts +++ b/src/utils/__tests__/changelog.test.ts @@ -1660,6 +1660,65 @@ describe('generateChangesetFromGit', () => { 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', () => { @@ -2121,6 +2180,68 @@ describe('generateChangesetFromGit', () => { 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( [ diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index df381658..7eb4ef2f 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -775,7 +775,10 @@ async function generateChangesetFromGitImpl( } // Use PR title if available, otherwise use commit title for pattern matching - const titleForMatching = githubCommit?.prTitle ?? gitCommit.title; + // Trim to handle any leading/trailing whitespace that could break pattern matching + const titleForMatching = ( + githubCommit?.prTitle ?? gitCommit.title + ).trim(); const matchedCategory = matchCommitToCategory( labels, author, @@ -828,7 +831,8 @@ async function generateChangesetFromGitImpl( } // Extract and normalize scope from PR title - const prTitle = commit.prTitle ?? commit.title; + // Trim to handle any leading/trailing whitespace + const prTitle = (commit.prTitle ?? commit.title).trim(); const scope = extractScope(prTitle); // Get or create the scope group @@ -919,22 +923,10 @@ async function generateChangesetFromGitImpl( ([s, entries]) => s !== null && entries.length > 1 ); - for (const [scope, prs] of sortedScopes) { - // Add scope header if: - // - scope grouping is enabled AND - // - scope exists (not null) AND - // - there's more than one entry in this scope (single entry headers aren't useful) - // OR - // - scope is null (scopeless) AND there are other scope headers (to visually separate) - if (scopeGroupingEnabled && scope !== null && prs.length > 1) { - changelogSections.push( - markdownHeader(SCOPE_HEADER_LEVEL, formatScopeTitle(scope)) - ); - } else if (scopeGroupingEnabled && scope === null && hasScopeHeaders) { - // Add "Other" header for scopeless commits when there are other scope headers - changelogSections.push(markdownHeader(SCOPE_HEADER_LEVEL, 'Other')); - } + // Collect entries without headers to combine them into a single section + const entriesWithoutHeaders: string[] = []; + for (const [scope, prs] of sortedScopes) { const prEntries = prs.map(pr => formatChangelogEntry({ title: pr.title, @@ -946,7 +938,31 @@ async function generateChangesetFromGitImpl( }) ); - changelogSections.push(prEntries.join('\n')); + // 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')); + } else { + // No header for this scope group - collect entries to combine later + entriesWithoutHeaders.push(...prEntries); + } + } + + // Push all entries without headers as a single section to avoid extra newlines + if (entriesWithoutHeaders.length > 0) { + changelogSections.push(entriesWithoutHeaders.join('\n')); } } @@ -961,7 +977,7 @@ async function generateChangesetFromGitImpl( .slice(0, maxLeftovers) .map(commit => formatChangelogEntry({ - title: commit.prTitle ?? commit.title, + title: (commit.prTitle ?? commit.title).trim(), author: commit.author, prNumber: commit.pr ?? undefined, hash: commit.hash,