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
131 changes: 131 additions & 0 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
66 changes: 46 additions & 20 deletions src/utils/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
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;
}

/**
Expand Down
Loading