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
121 changes: 121 additions & 0 deletions src/utils/__tests__/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
[
Expand Down
54 changes: 35 additions & 19 deletions src/utils/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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'));
}
}

Expand All @@ -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,
Expand Down
Loading