diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 3f4945f7..741723cd 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -1136,6 +1136,86 @@ See incident report: https://example.com/incident/123`, expect(result.bumpType).toBeNull(); }); }); + + describe('PR deduplication', () => { + it('deduplicates commits with same PR number from rebase-merge workflows', async () => { + // When using rebase-merge, all individual commits from a PR are added to + // the base branch and each gets associated with the same PR number. + // We should only show the PR once in the changelog. + setup([ + { + hash: 'commit1', + title: 'feat(ui): add button component', + body: '', + pr: { + local: '42', + remote: { + number: '42', + title: 'feat(ui): add button component', + author: { login: 'alice' }, + }, + }, + }, + { + hash: 'commit2', + title: 'feat(ui): add button styles', + body: '', + pr: { + local: '42', + remote: { + number: '42', + title: 'feat(ui): add button component', // Same PR, same title from API + author: { login: 'alice' }, + }, + }, + }, + { + hash: 'commit3', + title: 'feat(ui): add button tests', + body: '', + pr: { + local: '42', + remote: { + number: '42', + title: 'feat(ui): add button component', // Same PR, same title from API + author: { login: 'alice' }, + }, + }, + }, + ], null); + + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + + // PR #42 should appear only once, not three times + const matches = result.changelog.match(/#42/g); + expect(matches).toHaveLength(1); + expect(result.changelog).toContain('Add button component'); + }); + + it('keeps commits without PR association even if they share hashes', async () => { + // Commits without PR association should all be kept + setup([ + { + hash: 'commit1', + title: 'chore: update dependencies', + body: '', + // No PR association + }, + { + hash: 'commit2', + title: 'chore: fix typo', + body: '', + // No PR association + }, + ], null); + + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + + // Both commits should appear (in leftovers since no PR) + expect(result.changelog).toContain('update dependencies'); + expect(result.changelog).toContain('fix typo'); + }); + }); }); describe('generateChangelogWithHighlight', () => { @@ -1545,6 +1625,57 @@ describe('generateChangelogWithHighlight', () => { expect(result.changelog).not.toContain('@bob'); expect(result.changelog).not.toContain('@alice'); }); + + it('deduplicates already-merged PRs to avoid duplicate entries (PR 648 fix)', async () => { + // When a PR is already merged but its title/description is updated, + // the changelog preview would show the PR twice: + // 1. From git history (via fetchRawCommitInfo) + // 2. From the current PR fetch (with highlight: true) + // This test verifies the deduplication fix. + setup({ + currentPR: { + number: 2, + title: 'feat: updated title after merge', // Title updated after merge + body: 'Updated description.', + author: 'bob', + headSha: 'def456', // Same SHA as in existingCommits + }, + existingCommits: [ + { + hash: 'abc123', + title: 'fix: other bug fix', + body: '', + pr: { + local: '1', + remote: { number: '1', author: { login: 'alice' } }, + }, + }, + { + hash: 'def456', // Same PR, appears in git history + title: 'feat: original title before merge', + body: '', + pr: { + local: '2', + remote: { + number: '2', + title: 'feat: original title before merge', + author: { login: 'bob' }, + }, + }, + }, + ], + }); + + const result = await generateChangelogWithHighlight(dummyGit, '1.0.0', 2); + + // PR #2 should appear only once (with the updated title from current PR fetch) + expect(result.changelog).toContain('Updated title after merge'); + expect(result.changelog).not.toContain('Original title before merge'); + // The highlighted entry should use the fresh PR data + expect(result.changelog).toContain('> - Updated title after merge'); + // Other PRs should still appear normally + expect(result.changelog).toContain('Other bug fix'); + }); }); describe('skip-changelog handling', () => { diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index fdf726f9..4ed642c5 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -1408,21 +1408,29 @@ export async function generateChangelogWithHighlight( // Step 5: Fetch raw commit info up to base branch const rawCommits = await fetchRawCommitInfo(git, rev, baseRef); - // Step 6: Add current PR to the list with highlight flag (at the beginning) - const currentPRCommit: RawCommitInfo = { - hash: prInfo.headSha, - title: prInfo.title.trim(), - body: prInfo.body, - author: prInfo.author, - pr: String(prInfo.number), - prTitle: prInfo.title, - prBody: prInfo.body, - labels: prInfo.labels, - highlight: true, - }; - // Prepend current PR to make it newest in the list. - // Git log returns commits newest-first, so this maintains that order. - const allCommits = [currentPRCommit, ...rawCommits]; + // Step 6: Build combined commit list with current PR highlighted at the beginning. + // Filter out the current PR from git history if already merged - this ensures we use + // the freshly-fetched PR data (with highlight) instead of potentially stale data + // from git history (e.g., if PR title was edited after merge). + const currentPRStr = String(currentPRNumber); + const allCommits: RawCommitInfo[] = [ + { + hash: prInfo.headSha, + title: prInfo.title.trim(), + body: prInfo.body, + author: prInfo.author, + pr: currentPRStr, + prTitle: prInfo.title, + prBody: prInfo.body, + labels: prInfo.labels, + highlight: true, + }, + ]; + for (const commit of rawCommits) { + if (commit.pr !== currentPRStr) { + allCommits.push(commit); + } + } // Step 7: Process reverts - cancel out revert/reverted pairs const filteredCommits = processReverts(allCommits); @@ -1475,20 +1483,38 @@ async function fetchRawCommitInfo( gitCommits.map(({ hash }) => hash) ); + // Build commit list with deduplication by PR number in a single pass. + // Keep first occurrence (newest commit since git log is newest-first). + // This handles: + // 1. Rebase-merge workflows where multiple commits share the same PR number + // 2. Already-merged PRs that appear in git history when generating previews // Note: PR body magic word check is handled by shouldExcludePR in categorizeCommits - return gitCommits.map(gitCommit => { + const seenPRs = new Set(); + const result: RawCommitInfo[] = []; + + for (const gitCommit of gitCommits) { const githubCommit = githubCommits[gitCommit.hash]; - return { + const pr = githubCommit?.pr ?? gitCommit.pr ?? undefined; + + // Deduplicate by PR number, but keep all commits without PR association + if (pr) { + if (seenPRs.has(pr)) continue; + seenPRs.add(pr); + } + + result.push({ hash: gitCommit.hash, title: gitCommit.title, body: gitCommit.body, author: githubCommit?.author, - pr: githubCommit?.pr ?? gitCommit.pr ?? undefined, + pr, prTitle: githubCommit?.prTitle ?? undefined, prBody: githubCommit?.prBody ?? undefined, labels: githubCommit?.labels ?? [], - }; - }); + }); + } + + return result; } /**