diff --git a/package.json b/package.json index 7fd1a41abea..c5c69713a2e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:docs": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build:docs", "build:only-clean": "rimraf -g 'packages/*/dist'", "build:types": "tsc --build tsconfig.build.json --verbose", + "changelog:merge": "tsx scripts/merge-changelog-conflicts.ts", "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate", "codeowners:check": "tsx scripts/manage-codeowners.ts check", @@ -63,6 +64,7 @@ "@actions/github": "^9.1.1", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", "@metamask/create-release-branch": "^4.2.2", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-jest": "^15.0.0", diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts new file mode 100644 index 00000000000..73696ebfb0f --- /dev/null +++ b/scripts/lib/changelog-conflicts.test.ts @@ -0,0 +1,609 @@ +import execa from 'execa'; +import { promises as fs } from 'fs'; + +import { + findConflictedChangelogFiles, + mergeChangelogs, + readGitBlob, + resolveChangelogConflicts, + resolvePackageMetadata, +} from './changelog-conflicts.js'; + +jest.mock('execa'); + +const REPO_URL = 'https://github.com/MetaMask/core'; +const TAG_PREFIX = '@metamask/example@'; + +/** + * Build minimal changelog content with the given `## [Unreleased]` body. + * + * @param unreleasedBody - The Markdown to place under `## [Unreleased]`. + * @returns The changelog content. + */ +function buildChangelog(unreleasedBody: string): string { + return `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +${unreleasedBody} + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...HEAD +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; +} + +describe('changelog-conflicts', () => { + describe('mergeChangelogs', () => { + it('takes the union of distinct entries added on each side, preserving order', async () => { + const oursContent = buildChangelog( + `### Added + +- Added ours entry ([#10](${REPO_URL}/pull/10))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#11](${REPO_URL}/pull/11))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const addedIndex = content.indexOf('### Added'); + const oursIndex = content.indexOf('Added ours entry'); + const theirsIndex = content.indexOf('Added theirs entry'); + expect(addedIndex).toBeGreaterThan(-1); + expect(oursIndex).toBeGreaterThan(addedIndex); + expect(theirsIndex).toBeGreaterThan(oursIndex); + }); + + it('does not duplicate an entry that both sides added (identified by PR number)', async () => { + const sharedEntry = `- Added shared entry ([#20](${REPO_URL}/pull/20))`; + const oursContent = buildChangelog(`### Added + +${sharedEntry}`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(0); + expect(content.match(/Added shared entry/gu)).toHaveLength(1); + }); + + it('does not duplicate an entry with no PR number that both sides added (identified by description)', async () => { + const sharedEntry = '- Added shared entry with no PR number'; + const oursContent = buildChangelog(`### Added + +${sharedEntry}`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(0); + expect( + content.match(/Added shared entry with no PR number/gu), + ).toHaveLength(1); + }); + + it('keeps distinct entries that share the same PR number', async () => { + const sharedEntry = `- Added shared entry ([#20](${REPO_URL}/pull/20))`; + const oursContent = buildChangelog(`### Added + +${sharedEntry} +- Added a second, distinct entry from the same PR ([#20](${REPO_URL}/pull/20))`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(0); + expect(content.match(/Added shared entry/gu)).toHaveLength(1); + expect(content).toContain( + 'Added a second, distinct entry from the same PR', + ); + }); + + it('inserts a new breaking entry below existing breaking entries, above non-breaking ones', async () => { + const oursContent = buildChangelog( + `### Changed + +- **BREAKING:** Ours existing breaking entry ([#31](${REPO_URL}/pull/31)) +- Ours existing non-breaking entry ([#32](${REPO_URL}/pull/32))`, + ); + const theirsContent = buildChangelog( + `### Changed + +- **BREAKING:** Theirs breaking entry ([#30](${REPO_URL}/pull/30))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const existingBreakingIndex = content.indexOf( + 'Ours existing breaking entry', + ); + const newBreakingIndex = content.indexOf('Theirs breaking entry'); + const nonBreakingIndex = content.indexOf( + 'Ours existing non-breaking entry', + ); + expect(existingBreakingIndex).toBeLessThan(newBreakingIndex); + expect(newBreakingIndex).toBeLessThan(nonBreakingIndex); + }); + + it('merges in a category that only exists on one side', async () => { + const oursContent = buildChangelog( + `### Fixed + +- Fixed ours entry ([#40](${REPO_URL}/pull/40))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#41](${REPO_URL}/pull/41))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + expect(content).toContain('### Fixed'); + expect(content).toContain('Fixed ours entry'); + expect(content).toContain('### Added'); + expect(content).toContain('Added theirs entry'); + }); + + it('merges in a release version that only exists on one side', async () => { + const theirsContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + const oursContent = buildChangelog(''); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + expect(content).toContain('## [2.0.0]'); + expect(content).toContain('Added in 2.0.0'); + }); + + it('appends a new release version that is older than every existing release', async () => { + const theirsContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +## [0.5.0] + +### Added + +- Added in 0.5.0 ([#51](${REPO_URL}/pull/51)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}0.5.0...${TAG_PREFIX}2.0.0 +[0.5.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}0.5.0 +`; + const oursContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}2.0.0 +`; + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const version2Index = content.indexOf('## [2.0.0]'); + const version05Index = content.indexOf('## [0.5.0]'); + expect(version2Index).toBeLessThan(version05Index); + }); + + it('inserts a new release version into its correct descending-SemVer position, not just at the start or end', async () => { + const theirsContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.0] + +### Added + +- Added in 3.0.0 ([#60](${REPO_URL}/pull/60)) + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#61](${REPO_URL}/pull/61)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}3.0.0...HEAD +[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...${TAG_PREFIX}3.0.0 +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + const oursContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.0] + +### Added + +- Added in 3.0.0 ([#60](${REPO_URL}/pull/60)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}3.0.0...HEAD +[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}3.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const version3Index = content.indexOf('## [3.0.0]'); + const version2Index = content.indexOf('## [2.0.0]'); + const version1Index = content.indexOf('## [1.0.0]'); + expect(version3Index).toBeLessThan(version2Index); + expect(version2Index).toBeLessThan(version1Index); + expect(content).toContain( + `[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...${TAG_PREFIX}3.0.0`, + ); + }); + + it('keeps an entry shared by both sides in its "ours" position, appending only genuinely new entries', async () => { + // Regression test for a rebase scenario: "ours" (the branch being + // rebased onto) has since gained a new entry, while "theirs" (the + // replayed commit) already contained an entry that also exists in + // "ours". Only the entry unique to "ours" should be appended; the + // shared entry should not move. + const oursContent = buildChangelog( + `### Changed + +- New entry only on ours ([#6388](${REPO_URL}/pull/6388)) +- Shared entry ([#9960](${REPO_URL}/pull/9960))`, + ); + const theirsContent = buildChangelog( + `### Changed + +- **BREAKING:** Breaking entry only on theirs ([#9168](${REPO_URL}/pull/9168)) +- Shared entry ([#9960](${REPO_URL}/pull/9960))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const breakingIndex = content.indexOf('Breaking entry only on theirs'); + const newEntryIndex = content.indexOf('New entry only on ours'); + const sharedIndex = content.indexOf('Shared entry'); + expect(breakingIndex).toBeLessThan(newEntryIndex); + expect(newEntryIndex).toBeLessThan(sharedIndex); + }); + }); + + describe('findConflictedChangelogFiles', () => { + it('filters unmerged paths down to package changelogs', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ + stdout: [ + 'packages/foo/CHANGELOG.md', + 'packages/foo/package.json', + 'yarn.lock', + ].join('\n'), + }); + + const result = await findConflictedChangelogFiles(); + + expect(result).toStrictEqual(['packages/foo/CHANGELOG.md']); + expect(execa).toHaveBeenCalledWith( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('returns an empty array when there are no unmerged paths', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ stdout: '' }); + + expect(await findConflictedChangelogFiles()).toStrictEqual([]); + }); + }); + + describe('readGitBlob', () => { + it('reads a file at the given ref', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ stdout: 'content' }); + + const result = await readGitBlob(':2', 'packages/foo/CHANGELOG.md'); + + expect(result).toBe('content'); + expect(execa).toHaveBeenCalledWith( + 'git', + ['show', ':2:packages/foo/CHANGELOG.md'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + }); + + describe('resolvePackageMetadata', () => { + it('resolves the package name, repo URL, and tag prefix from the working tree', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { + type: 'git', + url: 'https://github.com/MetaMask/core.git', + }, + }), + ); + + const result = await resolvePackageMetadata( + 'packages/example/CHANGELOG.md', + ); + + expect(result).toStrictEqual({ + name: '@metamask/example', + repoUrl: 'https://github.com/MetaMask/core', + tagPrefix: '@metamask/example@', + }); + expect(fs.readFile).toHaveBeenCalledWith( + expect.stringContaining('packages/example/package.json'), + 'utf8', + ); + expect(execa).not.toHaveBeenCalled(); + }); + + it('falls back to the "ours" conflict stage if package.json is not parseable in the working tree', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue('<<<<<<< HEAD\nconflict'); + (execa as unknown as jest.Mock).mockResolvedValue({ + stdout: JSON.stringify({ + name: '@metamask/example', + repository: { url: 'https://github.com/MetaMask/core' }, + }), + }); + + const result = await resolvePackageMetadata( + 'packages/example/CHANGELOG.md', + ); + + expect(result).toStrictEqual({ + name: '@metamask/example', + repoUrl: 'https://github.com/MetaMask/core', + tagPrefix: '@metamask/example@', + }); + expect(execa).toHaveBeenCalledWith( + 'git', + ['show', ':2:packages/example/package.json'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('throws if the package name or repository URL is missing', async () => { + jest + .spyOn(fs, 'readFile') + .mockResolvedValue(JSON.stringify({ name: '@metamask/example' })); + + await expect( + resolvePackageMetadata('packages/example/CHANGELOG.md'), + ).rejects.toThrow( + 'Could not resolve package name or repository for "packages/example/CHANGELOG.md".', + ); + }); + }); + + describe('resolveChangelogConflicts', () => { + it('resolves each conflicted file', async () => { + const changelogPath = 'packages/example/CHANGELOG.md'; + const oursContent = buildChangelog( + `### Added + +- Added ours entry ([#10](${REPO_URL}/pull/10))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#11](${REPO_URL}/pull/11))`, + ); + + (execa as unknown as jest.Mock).mockImplementation( + async (command: string, args: string[]) => { + if (args[0] === 'diff') { + return { stdout: changelogPath }; + } + if (args[0] === 'show' && args[1] === `:2:${changelogPath}`) { + return { stdout: oursContent }; + } + if (args[0] === 'show' && args[1] === `:3:${changelogPath}`) { + return { stdout: theirsContent }; + } + if (args[0] === 'add') { + return { stdout: '' }; + } + throw new Error( + `Unexpected execa call: ${command} ${args.join(' ')}`, + ); + }, + ); + + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { type: 'git', url: `${REPO_URL}.git` }, + }), + ); + jest.spyOn(fs, 'writeFile').mockResolvedValue(); + + const result = await resolveChangelogConflicts(); + + expect(result.skipped).toStrictEqual([]); + expect(result.resolved).toStrictEqual([ + { path: changelogPath, mergedEntryCount: 1 }, + ]); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(changelogPath), + expect.stringContaining('Added theirs entry'), + 'utf8', + ); + }); + + it('skips a file that cannot be parsed and leaves it unresolved', async () => { + const changelogPath = 'packages/example/CHANGELOG.md'; + + (execa as unknown as jest.Mock).mockImplementation( + async (command: string, args: string[]) => { + if (args[0] === 'diff') { + return { stdout: changelogPath }; + } + if (args[0] === 'show' && args[1] === `:2:${changelogPath}`) { + return { stdout: 'this is not a valid changelog' }; + } + if (args[0] === 'show' && args[1] === `:3:${changelogPath}`) { + return { stdout: buildChangelog('') }; + } + throw new Error( + `Unexpected execa call: ${command} ${args.join(' ')}`, + ); + }, + ); + + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { type: 'git', url: `${REPO_URL}.git` }, + }), + ); + const writeFileSpy = jest.spyOn(fs, 'writeFile').mockResolvedValue(); + + const result = await resolveChangelogConflicts(); + + expect(result.resolved).toStrictEqual([]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.path).toBe(changelogPath); + expect(writeFileSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts new file mode 100644 index 00000000000..3d796dac0fe --- /dev/null +++ b/scripts/lib/changelog-conflicts.ts @@ -0,0 +1,380 @@ +import type { ReleaseChanges } from '@metamask/auto-changelog'; +import { oxfmt, parseChangelog } from '@metamask/auto-changelog'; +import { getErrorMessage } from '@metamask/utils'; +import execa from 'execa'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { gt } from 'semver'; + +export const ROOT_WORKSPACE = path.resolve(__dirname, '../..'); + +const CHANGELOG_PATH_PATTERN = /^packages\/[^/]+\/CHANGELOG\.md$/u; + +/** + * A regular expression for breaking entries. Assumes the entry is in the + * "**BREAKING**:" or "**BREAKING:**" format. + */ +const BREAKING_CHANGE_PATTERN = /^\*\*BREAKING:?\*\*/u; + +type Category = keyof ReleaseChanges; +type Change = NonNullable[number]; + +type PackageJson = { + name?: string; + repository?: { url?: string }; +}; + +type PackageMetadata = { + name: string; + repoUrl: string; + tagPrefix: string; +}; + +type ConflictResolution = { + /** OS-native path, for display (e.g. in a terminal). */ + path: string; + mergedEntryCount: number; +}; + +type ConflictSkip = { + /** OS-native path, for display (e.g. in a terminal). */ + path: string; + reason: string; +}; + +type ConflictResolutionResult = { + resolved: ConflictResolution[]; + skipped: ConflictSkip[]; +}; + +/** + * Find `packages/*\/CHANGELOG.md` files that currently have an unresolved + * Git merge conflict. + * + * @returns The repo-relative paths of the conflicted changelog files. + */ +export async function findConflictedChangelogFiles(): Promise { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd: ROOT_WORKSPACE, encoding: 'utf8' }, + ); + + return stdout + .trim() + .split('\n') + .filter((filePath) => CHANGELOG_PATH_PATTERN.test(filePath)); +} + +/** + * Read a file as it exists at a given Git ref, e.g. a conflict stage such as + * `:2` (ours) or `:3` (theirs). + * + * @param ref - The Git ref to read the file from. + * @param filePath - The repo-relative path of the file. + * @returns The contents of the file at that ref. + */ +export async function readGitBlob( + ref: string, + filePath: string, +): Promise { + const { stdout } = await execa('git', ['show', `${ref}:${filePath}`], { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }); + + return stdout; +} + +/** + * Read and parse a `package.json` file, preferring the working tree copy + * since it's normally not part of the conflict; if it can't be parsed there + * (e.g. it's also mid-merge and contains conflict markers), it's re-read + * from the "ours" conflict stage instead. + * + * @param packageJsonPath - The repo-relative path of the `package.json` file. + * @returns The parsed `package.json` contents. + */ +async function readPackageJson(packageJsonPath: string): Promise { + try { + const content = await fs.readFile( + path.join(ROOT_WORKSPACE, packageJsonPath), + 'utf8', + ); + + return JSON.parse(content); + } catch { + const content = await readGitBlob(':2', packageJsonPath); + return JSON.parse(content); + } +} + +/** + * Resolve the package name and repository URL for the package that owns the + * given changelog file. + * + * @param changelogPath - The repo-relative path of the changelog file. + * @returns The package's name, repository URL, and changelog tag prefix. + */ +export async function resolvePackageMetadata( + changelogPath: string, +): Promise { + const packageJsonPath = path.posix.join( + path.posix.dirname(changelogPath), + 'package.json', + ); + + const packageJson = await readPackageJson(packageJsonPath); + const repositoryUrl = packageJson.repository?.url; + if (!packageJson.name || !repositoryUrl) { + throw new Error( + `Could not resolve package name or repository for "${changelogPath}".`, + ); + } + + return { + name: packageJson.name, + repoUrl: repositoryUrl.replace(/\.git$/u, ''), + tagPrefix: `${packageJson.name}@`, + }; +} + +/** + * Determine whether a change entry is a breaking change, per the + * `**BREAKING:**` description prefix convention. + * + * @param change - The change entry. + * @returns Whether the change is a breaking change. + */ +function isBreakingChange(change: Change): boolean { + return BREAKING_CHANGE_PATTERN.test(change.description.trim()); +} + +/** + * Build a key that identifies "the same change" across both conflict sides. + * Includes the PR numbers alongside the description, since a single PR can add + * multiple distinct changelog entries that all reference it. + * + * @param change - The change entry. + * @returns The dedup key for the change. + */ +function getChangeKey(change: Change): string { + const prKey = [...change.prNumbers].sort().join(','); + return `${prKey}:${change.description.trim()}`; +} + +/** + * Merge new entries from `incoming` into `base`, mutating `base` in place. + * Breaking changes are inserted below any existing leading breaking changes; + * other changes are appended to the end. Relative order within `incoming` is + * preserved. + * + * @param base - The category's changes to merge into. + * @param incoming - The category's changes to merge from. + * @returns The number of new entries added to `base`. + */ +function mergeCategoryEntries(base: Change[], incoming: Change[]): number { + const initialLength = base.length; + const existingKeys = new Set(base.map(getChangeKey)); + + for (const change of incoming) { + const key = getChangeKey(change); + if (existingKeys.has(key)) { + continue; + } + + existingKeys.add(key); + + if (isBreakingChange(change)) { + const firstNonBreakingIndex = base.findIndex( + (entry) => !isBreakingChange(entry), + ); + + const insertIndex = + firstNonBreakingIndex === -1 ? base.length : firstNonBreakingIndex; + + base.splice(insertIndex, 0, change); + } else { + base.push(change); + } + } + + return base.length - initialLength; +} + +/** + * Merge new entries from `incoming` into `base` across every category + * present on either side, mutating `base` in place. + * + * @param base - The release's changes (by category) to merge into. + * @param incoming - The release's changes (by category) to merge from. + * @returns The number of new entries added to `base`. + */ +function mergeReleaseChanges( + base: ReleaseChanges, + incoming: ReleaseChanges, +): number { + let addedCount = 0; + const categories = new Set([ + ...Object.keys(base), + ...Object.keys(incoming), + ]) as Set; + + for (const category of categories) { + const incomingEntries = incoming[category] ?? []; + if (incomingEntries.length === 0) { + continue; + } + + base[category] ??= []; + + addedCount += mergeCategoryEntries( + base[category] as Change[], + incomingEntries, + ); + } + + return addedCount; +} + +/** + * Merge two conflicting versions of a changelog by taking the union of their + * entries: every entry unique to either side is kept, deduplicated by PR + * number and description together, with new `**BREAKING:**` entries placed + * below existing breaking entries and other new entries appended. + * + * @param options - Options. + * @param options.oursContent - The changelog content on the "ours" conflict + * side. + * @param options.theirsContent - The changelog content on the "theirs" + * conflict side. + * @param options.repoUrl - The GitHub repository URL for the package. + * @param options.tagPrefix - The changelog tag prefix for the package. + * @returns The merged, re-serialized changelog content and the number of new + * entries that were merged in. + */ +export async function mergeChangelogs({ + oursContent, + theirsContent, + repoUrl, + tagPrefix, +}: { + oursContent: string; + theirsContent: string; + repoUrl: string; + tagPrefix: string; +}): Promise<{ content: string; mergedEntryCount: number }> { + // `ours` is used as the base to mutate and stringify. During a Git merge, + // `ours` is the current branch (HEAD); during a rebase, it's the upstream + // branch being rebased onto. In both cases, that's the side more likely to + // already contain entries also present in `theirs`, so preserving its + // existing order (and only appending genuinely new entries from `theirs`) + // produces more intuitive results than the reverse. + const ours = parseChangelog({ + changelogContent: oursContent, + repoUrl, + tagPrefix, + formatter: oxfmt, + shouldExtractPrLinks: true, + }); + + const theirs = parseChangelog({ + changelogContent: theirsContent, + repoUrl, + tagPrefix, + shouldExtractPrLinks: true, + }); + + let mergedEntryCount = mergeReleaseChanges( + ours.getUnreleasedChanges(), + theirs.getUnreleasedChanges(), + ); + + const oursVersions = new Set( + ours.getReleases().map(({ version }) => version), + ); + + for (const theirsRelease of theirs.getReleases()) { + if (!oursVersions.has(theirsRelease.version)) { + // `addRelease` can only add to the very start or end of the release + // list, so insert at the start and then reposition it into its + // correct descending-SemVer slot among the existing releases. + ours.addRelease(theirsRelease); + const releases = ours.getReleases(); + const [inserted] = releases.splice(0, 1); + + const sortedIndex = releases.findIndex(({ version }) => + gt(inserted.version, version), + ); + + const insertIndex = sortedIndex === -1 ? releases.length : sortedIndex; + releases.splice(insertIndex, 0, inserted); + } + + const oursReleaseChanges = ours.getReleaseChanges(theirsRelease.version); + const theirsReleaseChanges = theirs.getReleaseChanges( + theirsRelease.version, + ); + mergedEntryCount += mergeReleaseChanges( + oursReleaseChanges, + theirsReleaseChanges ?? {}, + ); + } + + return { + content: await ours.toString(), + mergedEntryCount, + }; +} + +/** + * Find every conflicted `packages/*\/CHANGELOG.md` file, resolve as many as + * possible via {@link mergeChangelogs}, and write the merged result back to + * the working tree (without staging it, so it can still be reviewed before + * committing). Files that can't be automatically merged (e.g. a + * structurally invalid side) are left with their conflict markers intact. + * + * @returns The set of files that were resolved and the set that were + * skipped, along with the reason for each skip. + */ +export async function resolveChangelogConflicts(): Promise { + const paths = await findConflictedChangelogFiles(); + const resolved: ConflictResolution[] = []; + const skipped: ConflictSkip[] = []; + + for (const changelogPath of paths) { + try { + const [oursContent, theirsContent, { repoUrl, tagPrefix }] = + await Promise.all([ + readGitBlob(':2', changelogPath), + readGitBlob(':3', changelogPath), + resolvePackageMetadata(changelogPath), + ]); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl, + tagPrefix, + }); + + await fs.writeFile( + path.join(ROOT_WORKSPACE, changelogPath), + content, + 'utf8', + ); + + resolved.push({ + path: path.normalize(changelogPath), + mergedEntryCount, + }); + } catch (error) { + skipped.push({ + path: path.normalize(changelogPath), + reason: getErrorMessage(error), + }); + } + } + + return { resolved, skipped }; +} diff --git a/scripts/merge-changelog-conflicts.test.ts b/scripts/merge-changelog-conflicts.test.ts new file mode 100644 index 00000000000..63c72e10cbd --- /dev/null +++ b/scripts/merge-changelog-conflicts.test.ts @@ -0,0 +1,75 @@ +import * as changelogConflicts from './lib/changelog-conflicts.js'; +import { main } from './merge-changelog-conflicts.js'; + +jest.mock('./lib/changelog-conflicts'); + +describe('merge-changelog-conflicts', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + // The module under test invokes `main()` once as a side effect of being + // imported (using the auto-mocked, undefined-returning + // `resolveChangelogConflicts`), which leaves a stale exit code. + process.exitCode = 0; + }); + + afterEach(() => { + process.exitCode = 0; + }); + + it('logs a message and exits cleanly when there are no conflicted files', async () => { + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ resolved: [], skipped: [] }); + + await main(); + + expect(console.log).toHaveBeenCalledWith( + 'No CHANGELOG.md files with conflicts found.', + ); + expect(process.exitCode).toBe(0); + }); + + it('logs each resolved file and exits cleanly', async () => { + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ + resolved: [ + { path: 'packages/example/CHANGELOG.md', mergedEntryCount: 2 }, + ], + skipped: [], + }); + + await main(); + + expect(console.log).toHaveBeenCalledWith( + 'Resolved packages/example/CHANGELOG.md (merged 2 new entries).', + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('yarn changelog:validate'), + ); + expect(process.exitCode).toBe(0); + }); + + it('warns about skipped files and exits with a non-zero code', async () => { + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ + resolved: [], + skipped: [ + { + path: 'packages/example/CHANGELOG.md', + reason: 'Malformed release header', + }, + ], + }); + + await main(); + + expect(console.warn).toHaveBeenCalledWith( + 'Could not automatically resolve packages/example/CHANGELOG.md: Malformed release header', + ); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/scripts/merge-changelog-conflicts.ts b/scripts/merge-changelog-conflicts.ts new file mode 100644 index 00000000000..d3d08d6ed7e --- /dev/null +++ b/scripts/merge-changelog-conflicts.ts @@ -0,0 +1,46 @@ +import { resolveChangelogConflicts } from './lib/changelog-conflicts.js'; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +/** + * The entrypoint to this script. + * + * Automatically resolves Git merge conflicts in `packages/*\/CHANGELOG.md` + * files by taking the union of entries added on each side of the conflict. + * Files that can't be automatically merged are left with their conflict + * markers intact, and cause the script to exit with a non-zero code. + * + * Usage: `tsx scripts/merge-changelog-conflicts.ts` + */ +export async function main(): Promise { + const { resolved, skipped } = await resolveChangelogConflicts(); + + if (resolved.length === 0 && skipped.length === 0) { + console.log('No CHANGELOG.md files with conflicts found.'); + return; + } + + for (const { path, mergedEntryCount } of resolved) { + const entryWord = mergedEntryCount === 1 ? 'entry' : 'entries'; + console.log( + `Resolved ${path} (merged ${mergedEntryCount} new ${entryWord}).`, + ); + } + + for (const { path, reason } of skipped) { + console.warn(`Could not automatically resolve ${path}: ${reason}`); + } + + if (resolved.length > 0) { + console.log( + '\nRun `yarn changelog:validate` to confirm the merged changelogs are still valid.', + ); + } + + if (skipped.length > 0) { + process.exitCode = 1; + } +} diff --git a/yarn.lock b/yarn.lock index ce9edb7e018..c1138984df7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6677,6 +6677,7 @@ __metadata: "@actions/github": "npm:^9.1.1" "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/create-release-branch": "npm:^4.2.2" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-jest": "npm:^15.0.0"