From 71ff282e343533fb2fd678511c125e8359033ae1 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 6 Nov 2025 16:28:46 +0100 Subject: [PATCH 01/17] feat: add automated dependency bump checker and changelog validator Introduces a new tool to automatically detect dependency version changes and validate/update changelog entries accordingly. Features: - Detects dependency bumps from git diffs in package.json files - Validates changelog entries with exact version matching - Automatically updates changelogs with missing or outdated entries - Smart PR reference concatenation when updating existing entries - Dynamically reads repository URLs and package names - Validates by default with optional --fix flag for updates Usage: yarn check-dependency-bumps # Validate changelogs yarn check-dependency-bumps --fix # Auto-update changelogs yarn check-dependency-bumps --fix --pr 1234 # With PR number --- src/changelog-validator.test.ts | 1742 ++++++++++++++++++++++++ src/changelog-validator.ts | 438 ++++++ src/check-dependency-bumps.test.ts | 1979 ++++++++++++++++++++++++++++ src/check-dependency-bumps.ts | 444 +++++++ src/command-line-arguments.ts | 174 ++- src/initial-parameters.test.ts | 464 ++++--- src/initial-parameters.ts | 7 + src/main.test.ts | 329 +++-- src/main.ts | 20 + src/types.ts | 31 + 10 files changed, 5293 insertions(+), 335 deletions(-) create mode 100644 src/changelog-validator.test.ts create mode 100644 src/changelog-validator.ts create mode 100644 src/check-dependency-bumps.test.ts create mode 100755 src/check-dependency-bumps.ts create mode 100644 src/types.ts diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts new file mode 100644 index 0000000..a800f93 --- /dev/null +++ b/src/changelog-validator.test.ts @@ -0,0 +1,1742 @@ +import fs from 'fs'; +import { when } from 'jest-when'; +import { parseChangelog } from '@metamask/auto-changelog'; +import { validateChangelogs, updateChangelogs } from './changelog-validator.js'; +import * as fsModule from './fs.js'; +import * as packageModule from './package.js'; + +jest.mock('./fs'); +jest.mock('./package'); +jest.mock('@metamask/auto-changelog'); + +describe('changelog-validator', () => { + const mockChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + const mockPackageNames = { + 'controller-utils': '@metamask/controller-utils', + }; + + describe('validateChangelogs', () => { + it('uses fallback package name when not in packageNames map', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const parseChangelogSpy = jest.fn().mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [] }), + }); + (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); + + // Pass empty packageNames to trigger fallback + await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + {}, + ); + + // Verify it uses the directory name as fallback + expect(parseChangelogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tagPrefix: 'controller-utils@', + }), + ); + }); + + it('handles changelog with no Changed section', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({}), // No Changed section + }); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: mockChanges['controller-utils'].dependencyChanges, + existingEntries: [], + }, + ]); + }); + + it('returns validation results indicating missing changelog when file does not exist', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(false); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: false, + hasUnreleasedSection: false, + missingEntries: mockChanges['controller-utils'].dependencyChanges, + existingEntries: [], + }, + ]); + }); + + it('returns validation results indicating missing unreleased section when changelog parse fails', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\nSome content'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + (parseChangelog as jest.Mock).mockImplementation(() => { + throw new Error('Invalid changelog format'); + }); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: mockChanges['controller-utils'].dependencyChanges, + existingEntries: [], + }, + ]); + }); + + it('returns validation results with missing entries when changelog exists but entries are missing', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [] }), + }); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: mockChanges['controller-utils'].dependencyChanges, + existingEntries: [], + }, + ]); + }); + + it('returns validation results with existing entries when changelog has correct entries', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const parseChangelogSpy = jest.fn().mockReturnValue({ + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + ], + }), + }); + (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + }, + ]); + + // Verify it uses the actual package name from packageNames map + expect(parseChangelogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tagPrefix: '@metamask/controller-utils@', + }), + ); + }); + + it('validates entries in release section when package version is provided', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [1.1.0]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + ], + }), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const changesWithVersion = { + 'controller-utils': { + ...mockChanges['controller-utils'], + newVersion: '1.1.0', + }, + }; + + const results = await validateChangelogs( + changesWithVersion, + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(mockChangelog.getReleaseChanges).toHaveBeenCalledWith('1.1.0'); + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + }, + ]); + }); + + it('validates entries in unreleased section when no package version provided', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const results = await validateChangelogs( + mockChanges, // No newVersion in mockChanges + '/path/to/project', + 'https://github.com/MetaMask/core', + mockPackageNames, + ); + + expect(mockChangelog.getUnreleasedChanges).toHaveBeenCalled(); + expect(mockChangelog.getReleaseChanges).not.toHaveBeenCalled(); + expect(results[0].existingEntries).toContain( + '@metamask/transaction-controller', + ); + }); + }); + + describe('updateChangelogs', () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + + it('uses fallback package name when not in packageNames map', async () => { + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + const parseChangelogSpy = jest.fn().mockReturnValue(mockChangelog); + (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); + + // Pass empty packageNames to trigger fallback + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: {}, + stdout, + stderr, + }); + + // Verify it uses the directory name as fallback + expect(parseChangelogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tagPrefix: 'controller-utils@', + }), + ); + }); + + it('concatenates multiple existing PR numbers when updating entry', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5555](https://github.com/MetaMask/core/pull/5555))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + prNumber: '6789', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify all three PR numbers are included + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('#1234'), + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('#5555'), + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('#6789'), + ); + }); + + it('does not duplicate PR numbers when updating entry', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + prNumber: '1234', // Same as existing + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + const writtenContent = writeFileSpy.mock.calls[0][1] as string; + // Count occurrences of #1234 + const matches = writtenContent.match(/#1234/gu); + expect(matches?.length).toBe(1); // Should only appear once + }); + + it('updates peerDependency entry with BREAKING prefix preserved', async () => { + const peerDepChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + await updateChangelogs(peerDepChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + const writtenContent = writeFileSpy.mock.calls[0][1] as string; + // Verify BREAKING prefix is preserved + expect(writtenContent).toContain('**BREAKING:**'); + expect(writtenContent).toContain('^62.0.0'); + expect(writtenContent).toContain('#1234'); + expect(writtenContent).toContain('#5678'); + }); + + it('uses placeholder PR number when prNumber is not provided', async () => { + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + // No prNumber provided + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify placeholder is used in the added change + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('#XXXXX'), + }); + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0`', + ), + }); + }); + + it('uses placeholder when updating entry without prNumber', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + // No prNumber provided + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + const writtenContent = writeFileSpy.mock.calls[0][1] as string; + // Should have both the original PR and XXXXX placeholder + expect(writtenContent).toContain('#1234'); + expect(writtenContent).toContain('#XXXXX'); + }); + + it('preserves existing XXXXX placeholder when updating entry', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#XXXXX](https://github.com/MetaMask/core/pull/XXXXX))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + prNumber: '1234', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + const writtenContent = writeFileSpy.mock.calls[0][1] as string; + // Should have both XXXXX and new PR + expect(writtenContent).toContain('#XXXXX'); + expect(writtenContent).toContain('#1234'); + expect(writtenContent).toContain('^62.0.0'); + }); + + it('skips update and logs warning when changelog does not exist', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(false); + + const count = await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(0); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('No CHANGELOG.md found for controller-utils'), + ); + }); + + it('skips update when all entries already exist', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + ], + }), + }); + + const count = await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(0); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('All entries already exist'), + ); + }); + + it('adds new changelog entries when they are missing', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + const parseChangelogSpy = jest.fn().mockReturnValue(mockChangelog); + (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); + + const count = await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(1); + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0`', + ), + }); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + 'Updated changelog content', + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Added 1 changelog entry'), + ); + + // Verify it uses the actual package name from packageNames map + expect(parseChangelogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tagPrefix: '@metamask/controller-utils@', + }), + ); + }); + + it('updates single existing changelog entry with singular message', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [Unreleased]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }); + + const count = await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(1); + // Should only write once (no new entries to add) + expect(writeFileSpy).toHaveBeenCalledTimes(1); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('^62.0.0'), + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('#1234'), + ); + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('#5678'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated 1 existing entry'), + ); + }); + + it('updates multiple existing entries with plural message', async () => { + const multipleExistingChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry1 = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + const existingEntry2 = + 'Bump `@metamask/network-controller` from `^5.0.0` to `^5.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue( + `# Changelog\n## [Unreleased]\n- ${existingEntry1}\n- ${existingEntry2}`, + ); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + (parseChangelog as jest.Mock).mockReturnValue({ + getUnreleasedChanges: () => ({ + Changed: [existingEntry1, existingEntry2], + }), + }); + + const count = await updateChangelogs(multipleExistingChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(1); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated 2 existing entries'), + ); + }); + + it('handles peerDependencies changes with BREAKING prefix', async () => { + const peerDepChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(peerDepChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('**BREAKING:**'), + }); + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0`', + ), + }); + }); + + it('adds both peerDependencies and dependencies in correct order', async () => { + const mixedTypeChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(mixedTypeChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify peerDependencies (BREAKING) is added first + expect(mockChangelog.addChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + description: expect.stringContaining('**BREAKING:**'), + }), + ); + expect(mockChangelog.addChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + description: expect.stringContaining( + '@metamask/transaction-controller', + ), + }), + ); + + // Verify dependencies is added second + expect(mockChangelog.addChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + description: expect.not.stringContaining('**BREAKING:**'), + }), + ); + expect(mockChangelog.addChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + description: expect.stringContaining('@metamask/network-controller'), + }), + ); + }); + + it('updates existing entries and adds new entries in same package', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + // First read returns original content + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + ) + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))`, + ); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + // First parse shows one outdated entry + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }; + + // Second parse after update, for adding new entries + const mockChangelog2 = { + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))', + ], + }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final updated changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + const count = await updateChangelogs(mixedChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(1); + // Should write twice: once for update, once for final + expect(writeFileSpy).toHaveBeenCalledTimes(2); + // Should add the new network-controller entry + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/network-controller` from `^5.0.0` to `^6.0.0`', + ), + }); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated 1 and added 1'), + ); + + // Verify both parseChangelog calls use the actual package name + expect(parseChangelog).toHaveBeenCalledTimes(2); + expect(parseChangelog).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + tagPrefix: '@metamask/controller-utils@', + }), + ); + expect(parseChangelog).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + tagPrefix: '@metamask/controller-utils@', + }), + ); + }); + + it('updates existing entry and adds only new peerDependency', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'peerDependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + ) + .mockResolvedValueOnce(`# Changelog\n## [Unreleased]\n- Updated`); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ Changed: ['Updated entry'] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + await updateChangelogs(mixedChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Should add the peerDependency with BREAKING prefix + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('**BREAKING:**'), + }); + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('@metamask/network-controller'), + }); + + // Verify the combined update message + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated 1 and added 1 changelog entries'), + ); + }); + + it('updates existing entry and adds only new dependency (no peerDeps)', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + ) + .mockResolvedValueOnce(`# Changelog\n## [Unreleased]\n- Updated`); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ Changed: ['Updated entry'] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + await updateChangelogs(mixedChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Should add the dependency WITHOUT BREAKING prefix + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.not.stringContaining('**BREAKING:**'), + }); + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('@metamask/network-controller'), + }); + }); + + it('updates existing entries and adds new peerDependencies correctly', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'peerDependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + ) + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))`, + ); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))', + ], + }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final updated changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + await updateChangelogs(mixedChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Should write twice + expect(writeFileSpy).toHaveBeenCalledTimes(2); + + // Verify peerDependency is added first (it's the new entry) + expect(mockChangelog2.addChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + description: expect.stringContaining('**BREAKING:**'), + }), + ); + expect(mockChangelog2.addChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + description: expect.stringContaining( + 'Bump `@metamask/network-controller`', + ), + }), + ); + }); + + it('adds single new entry with singular message', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Added 1 changelog entry'), + ); + }); + + it('adds multiple new entries with plural message', async () => { + const multipleChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest.spyOn(fsModule, 'writeFile'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(multipleChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Added 2 changelog entries'), + ); + }); + + it('logs error when changelog parsing fails', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + (parseChangelog as jest.Mock).mockImplementation(() => { + throw new Error('Parse error'); + }); + + const count = await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(count).toBe(0); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Error updating CHANGELOG.md'), + ); + }); + + it('updates entries in release section when package version is provided', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(`# Changelog\n## [1.1.0]\n- ${existingEntry}`); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: jest.fn().mockReturnValue({ + Changed: [existingEntry], + }), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const changesWithVersion = { + 'controller-utils': { + ...mockChanges['controller-utils'], + newVersion: '1.1.0', + }, + }; + + await updateChangelogs(changesWithVersion, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + expect(mockChangelog.getReleaseChanges).toHaveBeenCalledWith('1.1.0'); + // Verify the entry was updated with new version + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + expect.stringContaining('^62.0.0'), + ); + }); + + it('adds new entries to release section when package is being released', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [1.1.0]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const changesWithVersion = { + 'controller-utils': { + ...mockChanges['controller-utils'], + newVersion: '1.1.0', // Package is being released + }, + }; + + await updateChangelogs(changesWithVersion, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify addChange was called with version parameter + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/transaction-controller`', + ), + version: '1.1.0', + }); + expect(writeFileSpy).toHaveBeenCalled(); + }); + + it('adds new entries to unreleased section when package is not being released', async () => { + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(mockChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify addChange was called WITHOUT version parameter (goes to Unreleased) + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining( + 'Bump `@metamask/transaction-controller`', + ), + }); + expect(writeFileSpy).toHaveBeenCalled(); + }); + + it('adds peerDependencies to unreleased section when not being released', async () => { + const peerDepChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(peerDepChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify peerDependency with BREAKING prefix added WITHOUT version (to Unreleased) + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('**BREAKING:**'), + }); + }); + + it('adds peerDependencies to release section when package is being released', async () => { + const peerDepChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + newVersion: '1.1.0', + }, + }; + + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [1.1.0]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(peerDepChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify peerDependency with BREAKING prefix added to release version + expect(mockChangelog.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('**BREAKING:**'), + version: '1.1.0', + }); + }); + + it('updates and adds entries to release section when package is being released', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce(`# Changelog\n## [1.1.0]\n- ${existingEntry}`) + .mockResolvedValueOnce(`# Changelog\n## [1.1.0]\n- Updated entry`); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: ['Updated entry'] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + const mixedChangesWithVersion = { + 'controller-utils': { + ...mixedChanges['controller-utils'], + newVersion: '1.1.0', // Being released + }, + }; + + await updateChangelogs(mixedChangesWithVersion, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify addChange was called with version for release section + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('@metamask/network-controller'), + version: '1.1.0', + }); + }); + + it('updates and adds peerDependency to release section when package is being released', async () => { + const mixedChanges = { + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'peerDependencies' as const, + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + + jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce(`# Changelog\n## [1.1.0]\n- ${existingEntry}`) + .mockResolvedValueOnce(`# Changelog\n## [1.1.0]\n- Updated entry`); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ Changed: [] }), + getReleaseChanges: () => ({ Changed: ['Updated entry'] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + const mixedChangesWithVersion = { + 'controller-utils': { + ...mixedChanges['controller-utils'], + newVersion: '1.1.0', + }, + }; + + await updateChangelogs(mixedChangesWithVersion, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: mockPackageNames, + stdout, + stderr, + }); + + // Verify peerDependency is added with version and BREAKING prefix + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('**BREAKING:**'), + version: '1.1.0', + }); + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('@metamask/network-controller'), + version: '1.1.0', + }); + }); + }); +}); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts new file mode 100644 index 0000000..b87cdbf --- /dev/null +++ b/src/changelog-validator.ts @@ -0,0 +1,438 @@ +/** + * Changelog Validator and Updater + * + * This module handles validation and updating of CHANGELOG.md files + * to ensure dependency bumps are properly documented. + */ + +import path from 'path'; +import type { WriteStream } from 'fs'; +import { parseChangelog } from '@metamask/auto-changelog'; +import { readFile, writeFile, fileExists } from './fs.js'; +import { formatChangelog } from './package.js'; +import type { DependencyChange, PackageChanges } from './types.js'; + +type ChangelogValidationResult = { + package: string; + hasChangelog: boolean; + hasUnreleasedSection: boolean; + missingEntries: DependencyChange[]; + existingEntries: string[]; +}; + +/** + * Formats a changelog entry for a dependency bump. + * + * @param change - The dependency change. + * @param prNumber - Optional PR number (uses placeholder if not provided). + * @param repoUrl - Repository URL for PR links. + * @returns Formatted changelog entry. + */ +function formatChangelogEntry( + change: DependencyChange, + prNumber: string | undefined, + repoUrl: string, +): string { + const pr = prNumber || 'XXXXX'; + const prLink = `[#${pr}](${repoUrl}/pull/${pr})`; + const prefix = change.type === 'peerDependencies' ? '**BREAKING:** ' : ''; + + return `${prefix}Bump \`${change.dependency}\` from \`${change.oldVersion}\` to \`${change.newVersion}\` (${prLink})`; +} + +/** + * Reads a changelog file. + * + * @param changelogPath - Path to the CHANGELOG.md file. + * @returns The changelog content, or null if file doesn't exist. + */ +async function readChangelog(changelogPath: string): Promise { + // Check if file exists first to avoid error handling complexity + if (!(await fileExists(changelogPath))) { + return null; + } + + return await readFile(changelogPath); +} + +/** + * Checks if a changelog entry exists for a dependency change with matching versions. + * + * @param unreleasedChanges - The unreleased changes from the parsed changelog. + * @param change - The dependency change to check. + * @returns Object with match status and existing entry if found. + */ +function hasChangelogEntry( + unreleasedChanges: Partial>, + change: DependencyChange, +): { hasExactMatch: boolean; existingEntry?: string; entryIndex?: number } { + // Check in the Changed category for dependency bumps + const changedEntries = unreleasedChanges.Changed || []; + + const escapedDep = change.dependency.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&'); + const escapedOldVer = change.oldVersion.replace( + /[/\\^$*+?.()|[\]{}]/gu, + '\\$&', + ); + const escapedNewVer = change.newVersion.replace( + /[/\\^$*+?.()|[\]{}]/gu, + '\\$&', + ); + + // Look for exact version match: dependency from oldVersion to newVersion + const exactPattern = new RegExp( + `Bump \`${escapedDep}\` from \`${escapedOldVer}\` to \`${escapedNewVer}\``, + 'u', + ); + + const exactIndex = changedEntries.findIndex((entry) => + exactPattern.test(entry), + ); + + if (exactIndex !== -1) { + return { + hasExactMatch: true, + existingEntry: changedEntries[exactIndex], + entryIndex: exactIndex, + }; + } + + // Check if there's an entry for this dependency with different versions + // Use \x60 (backtick) to avoid template literal issues + const anyVersionPattern = new RegExp( + `Bump \x60${escapedDep}\x60 from \x60[^\x60]+\x60 to \x60[^\x60]+\x60`, + 'u', + ); + + const anyIndex = changedEntries.findIndex((entry) => + anyVersionPattern.test(entry), + ); + + if (anyIndex !== -1) { + return { + hasExactMatch: false, + existingEntry: changedEntries[anyIndex], + entryIndex: anyIndex, + }; + } + + return { hasExactMatch: false }; +} + +/** + * Validates changelog entries for dependency changes. + * + * @param changes - Package changes to validate. + * @param projectRoot - Root directory of the project. + * @param repoUrl - Repository URL for changelog links. + * @param packageNames - Map of directory names to actual package names. + * @returns Validation results for each package. + */ +export async function validateChangelogs( + changes: PackageChanges, + projectRoot: string, + repoUrl: string, + packageNames: Record, +): Promise { + const results: ChangelogValidationResult[] = []; + + for (const [packageName, packageInfo] of Object.entries(changes)) { + const packageChanges = packageInfo.dependencyChanges; + const packageVersion = packageInfo.newVersion; + const packagePath = path.join(projectRoot, 'packages', packageName); + const changelogPath = path.join(packagePath, 'CHANGELOG.md'); + + const changelogContent = await readChangelog(changelogPath); + + if (!changelogContent) { + results.push({ + package: packageName, + hasChangelog: false, + hasUnreleasedSection: false, + missingEntries: packageChanges, + existingEntries: [], + }); + continue; + } + + try { + // Get the actual package name from the provided map + const actualPackageName = packageNames[packageName] || packageName; + + // Parse the changelog using auto-changelog + const changelog = parseChangelog({ + changelogContent, + repoUrl, + tagPrefix: `${actualPackageName}@`, + formatter: formatChangelog, + }); + + // Check if package is being released (has version change) + const changesSection = packageVersion + ? changelog.getReleaseChanges(packageVersion) + : changelog.getUnreleasedChanges(); + + // Check if there's an Unreleased/Release section (at least one category with changes) + const hasUnreleasedSection = Object.keys(changesSection).length > 0; + + const missingEntries: DependencyChange[] = []; + const existingEntries: string[] = []; + + for (const change of packageChanges) { + const entryCheck = hasChangelogEntry(changesSection, change); + + if (entryCheck.hasExactMatch) { + existingEntries.push(change.dependency); + } else { + // Missing or has wrong version + missingEntries.push(change); + } + } + + results.push({ + package: packageName, + hasChangelog: true, + hasUnreleasedSection, + missingEntries, + existingEntries, + }); + } catch (error) { + // If parsing fails, assume changelog is malformed + results.push({ + package: packageName, + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: packageChanges, + existingEntries: [], + }); + } + } + + return results; +} + +/** + * Updates changelogs with missing dependency bump entries. + * + * @param changes - Package changes to add to changelogs. + * @param options - Update options. + * @param options.projectRoot - Root directory of the project. + * @param options.prNumber - PR number to use in entries. + * @param options.repoUrl - Repository URL for changelog links. + * @param options.packageNames - Map of directory names to actual package names. + * @param options.stdout - Stream for output messages. + * @param options.stderr - Stream for error messages. + * @returns Number of changelogs updated. + */ +export async function updateChangelogs( + changes: PackageChanges, + { + projectRoot, + prNumber, + repoUrl, + packageNames, + stdout, + stderr, + }: { + projectRoot: string; + prNumber?: string; + repoUrl: string; + packageNames: Record; + stdout: Pick; + stderr: Pick; + }, +): Promise { + let updatedCount = 0; + + for (const [packageName, packageInfo] of Object.entries(changes)) { + const packageChanges = packageInfo.dependencyChanges; + const packageVersion = packageInfo.newVersion; + const packagePath = path.join(projectRoot, 'packages', packageName); + const changelogPath = path.join(packagePath, 'CHANGELOG.md'); + + const changelogContent = await readChangelog(changelogPath); + + if (!changelogContent) { + stderr.write( + `⚠️ No CHANGELOG.md found for ${packageName} at ${changelogPath}\n`, + ); + continue; + } + + try { + // Get the actual package name from the provided map + const actualPackageName = packageNames[packageName] || packageName; + + // Parse the changelog using auto-changelog + const changelog = parseChangelog({ + changelogContent, + repoUrl, + tagPrefix: `${actualPackageName}@`, + formatter: formatChangelog, + }); + + // Check if package is being released (has version change) + const changesSection = packageVersion + ? changelog.getReleaseChanges(packageVersion) + : changelog.getUnreleasedChanges(); + + // Categorize changes: add new, update existing with wrong versions + const entriesToAdd: DependencyChange[] = []; + const entriesToUpdate: { + change: DependencyChange; + existingEntry: string; + }[] = []; + + for (const change of packageChanges) { + const entryCheck = hasChangelogEntry(changesSection, change); + + if (entryCheck.hasExactMatch) { + // Entry already exists with correct versions + continue; + } else if (entryCheck.existingEntry) { + // Entry exists but with wrong version - needs update + entriesToUpdate.push({ + change, + existingEntry: entryCheck.existingEntry, + }); + } else { + // No entry exists - needs to be added + entriesToAdd.push(change); + } + } + + if (entriesToAdd.length === 0 && entriesToUpdate.length === 0) { + stdout.write(`✅ ${packageName}: All entries already exist\n`); + continue; + } + + // Update existing entries by modifying the changelog content directly + let updatedContent = changelogContent; + + for (const { change, existingEntry } of entriesToUpdate) { + // Extract existing PR numbers from the entry + const prMatches = existingEntry.matchAll(/\[#(\d+|XXXXX)\]/gu); + const existingPRs = Array.from(prMatches, (m) => m[1]); + + // Add new PR number + const newPR = prNumber || 'XXXXX'; + + if (!existingPRs.includes(newPR)) { + existingPRs.push(newPR); + } + + // Create PR links + const prLinks = existingPRs + .map((pr) => `[#${pr}](${repoUrl}/pull/${pr})`) + .join(', '); + + // Create updated entry with new "to" version and all PR numbers + const prefix = + change.type === 'peerDependencies' ? '**BREAKING:** ' : ''; + const updatedEntry = `${prefix}Bump \`${change.dependency}\` from \`${change.oldVersion}\` to \`${change.newVersion}\` (${prLinks})`; + + // Replace the old entry with the updated one + updatedContent = updatedContent.replace(existingEntry, updatedEntry); + } + + // If we updated any entries, write the content and re-parse + if (entriesToUpdate.length > 0) { + await writeFile(changelogPath, updatedContent); + + // Re-parse to add new entries if needed + if (entriesToAdd.length === 0) { + stdout.write( + `✅ ${packageName}: Updated ${entriesToUpdate.length} existing ${entriesToUpdate.length === 1 ? 'entry' : 'entries'}\n`, + ); + updatedCount += 1; + continue; + } + + // Re-parse the updated changelog + const updatedChangelogContent = await readFile(changelogPath); + const updatedChangelog = parseChangelog({ + changelogContent: updatedChangelogContent, + repoUrl, + tagPrefix: `${actualPackageName}@`, + formatter: formatChangelog, + }); + + // Group new entries by type (peerDependencies first, then dependencies) + const peerDeps = entriesToAdd.filter( + (c) => c.type === 'peerDependencies', + ); + const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); + + // Add peerDependencies first (BREAKING changes) + for (const change of peerDeps) { + const description = formatChangelogEntry(change, prNumber, repoUrl); + updatedChangelog.addChange({ + category: 'Changed' as any, + description, + ...(packageVersion && { version: packageVersion }), + }); + } + + // Then add dependencies + for (const change of deps) { + const description = formatChangelogEntry(change, prNumber, repoUrl); + updatedChangelog.addChange({ + category: 'Changed' as any, + description, + ...(packageVersion && { version: packageVersion }), + }); + } + + // Write the final changelog + await writeFile(changelogPath, await updatedChangelog.toString()); + + stdout.write( + `✅ ${packageName}: Updated ${entriesToUpdate.length} and added ${entriesToAdd.length} changelog entries\n`, + ); + } else { + // Only adding new entries + // Group entries by type (peerDependencies first, then dependencies) + const peerDeps = entriesToAdd.filter( + (c) => c.type === 'peerDependencies', + ); + const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); + + // Add peerDependencies first (BREAKING changes) + for (const change of peerDeps) { + const description = formatChangelogEntry(change, prNumber, repoUrl); + changelog.addChange({ + category: 'Changed' as any, + description, + ...(packageVersion && { version: packageVersion }), + }); + } + + // Then add dependencies + for (const change of deps) { + const description = formatChangelogEntry(change, prNumber, repoUrl); + changelog.addChange({ + category: 'Changed' as any, + description, + ...(packageVersion && { version: packageVersion }), + }); + } + + // Write the updated changelog + const updatedChangelogContent = await changelog.toString(); + await writeFile(changelogPath, updatedChangelogContent); + + stdout.write( + `✅ ${packageName}: Added ${entriesToAdd.length} changelog ${entriesToAdd.length === 1 ? 'entry' : 'entries'}\n`, + ); + } + + updatedCount += 1; + } catch (error) { + stderr.write( + `⚠️ Error updating CHANGELOG.md for ${packageName}: ${error}\n`, + ); + } + } + + return updatedCount; +} diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts new file mode 100644 index 0000000..d435c74 --- /dev/null +++ b/src/check-dependency-bumps.test.ts @@ -0,0 +1,1979 @@ +import fs from 'fs'; +import { when } from 'jest-when'; +import { buildMockManifest } from '../tests/unit/helpers.js'; +import { checkDependencyBumps } from './check-dependency-bumps.js'; +import * as repoModule from './repo.js'; +import * as miscUtilsModule from './misc-utils.js'; +import * as projectModule from './project.js'; +import * as packageManifestModule from './package-manifest.js'; +import * as changelogValidatorModule from './changelog-validator.js'; + +jest.mock('./repo'); +jest.mock('./misc-utils'); +jest.mock('./project'); +jest.mock('./package-manifest'); +jest.mock('./changelog-validator'); + +describe('check-dependency-bumps', () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + + describe('checkDependencyBumps', () => { + it('returns empty object when on main branch without fromRef', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest.spyOn(repoModule, 'getCurrentBranchName').mockResolvedValue('main'); + + const result = await checkDependencyBumps({ + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('📌 Current branch: main'), + ); + }); + + it('returns empty object when on master branch without fromRef', async () => { + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('master'); + + const result = await checkDependencyBumps({ + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + }); + + it('auto-detects merge base when fromRef is not provided', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('feature-branch'); + + // Mock merge base command + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'main'], { + cwd: '/path/to/project', + }) + .mockResolvedValue('abc123def456'); + + // Mock git diff command + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123def456', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(''); + + await checkDependencyBumps({ + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('merge base'), + ); + }); + + it('returns empty object when merge base cannot be found', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('feature-branch'); + + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'main'], { + cwd: '/path/to/project', + }) + .mockRejectedValue(new Error('Not found')); + + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'origin/main'], { + cwd: '/path/to/project', + }) + .mockRejectedValue(new Error('Not found')); + + const result = await checkDependencyBumps({ + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not find merge base'), + ); + }); + + it('returns empty object when no package.json changes found', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(''); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('No package.json changes found'), + ); + }); + + it('returns empty object when no dependency bumps found in diff', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithoutDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -1,6 +1,6 @@ + { +- "version": "1.0.0" ++ "version": "1.0.1" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithoutDeps); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('No dependency version bumps found'), + ); + }); + + it('detects dependency version changes and validates changelogs', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + }, + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { repository: 'https://github.com/MetaMask/core' }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + }, + ]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({ + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('📊 JSON Output'), + ); + expect(changelogValidatorModule.validateChangelogs).toHaveBeenCalledWith( + expect.any(Object), + '/path/to/project', + 'https://github.com/MetaMask/core', + { 'controller-utils': '@metamask/controller-utils' }, + ); + }); + + it('calls updateChangelogs when fix flag is set', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { repository: 'https://github.com/MetaMask/core' }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const updateChangelogsSpy = jest + .spyOn(changelogValidatorModule, 'updateChangelogs') + .mockResolvedValue(1); + + await checkDependencyBumps({ + fromRef: 'abc123', + fix: true, + prNumber: '1234', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(updateChangelogsSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + projectRoot: '/path/to/project', + prNumber: '1234', + repoUrl: 'https://github.com/MetaMask/core', + packageNames: { 'controller-utils': '@metamask/controller-utils' }, + stdout, + stderr, + }), + ); + }); + + it('detects peerDependencies changes', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithPeerDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "peerDependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithPeerDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({ + 'controller-utils': { + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }); + }); + + it('handles git diff exit code 1 as no changes', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockRejectedValue({ exitCode: 1, stdout: '' }); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + }); + + it('rethrows git diff errors other than exit code 1', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockRejectedValue({ exitCode: 2, message: 'Git error' }); + + await expect( + checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }), + ).rejects.toMatchObject({ exitCode: 2 }); + }); + + it('uses custom toRef when provided', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + when(getStdoutSpy) + .calledWith( + 'git', + [ + 'diff', + '-U9999', + 'abc123', + 'feature-branch', + '--', + '**/package.json', + ], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(''); + + await checkDependencyBumps({ + fromRef: 'abc123', + toRef: 'feature-branch', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(getStdoutSpy).toHaveBeenCalledWith( + 'git', + ['diff', '-U9999', 'abc123', 'feature-branch', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ); + }); + + it('uses custom defaultBranch when auto-detecting merge base', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('feature-branch'); + + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'develop'], { + cwd: '/path/to/project', + }) + .mockResolvedValue('abc123def456'); + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123def456', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(''); + + await checkDependencyBumps({ + defaultBranch: 'develop', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(getStdoutSpy).toHaveBeenCalledWith( + 'git', + ['merge-base', 'HEAD', 'develop'], + { cwd: '/path/to/project' }, + ); + }); + + it('tries origin/branch when local branch merge-base fails', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('feature-branch'); + + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'main'], { + cwd: '/path/to/project', + }) + .mockRejectedValue(new Error('Not found')); + + when(getStdoutSpy) + .calledWith('git', ['merge-base', 'HEAD', 'origin/main'], { + cwd: '/path/to/project', + }) + .mockResolvedValue('abc123def456'); + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123def456', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(''); + + await checkDependencyBumps({ + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(getStdoutSpy).toHaveBeenCalledWith( + 'git', + ['merge-base', 'HEAD', 'origin/main'], + { cwd: '/path/to/project' }, + ); + }); + + it('reports validation errors for missing changelogs', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: false, + hasUnreleasedSection: false, + missingEntries: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + existingEntries: [], + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('❌ controller-utils: CHANGELOG.md not found'), + ); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('💡 Run with --fix'), + ); + }); + + it('reports validation errors for missing unreleased section', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: [], + existingEntries: [], + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining( + '❌ controller-utils: No [Unreleased] section found', + ), + ); + }); + + it('reports validation errors for missing changelog entries', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + existingEntries: [], + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining( + '❌ controller-utils: Missing 1 changelog entry:', + ), + ); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('- @metamask/transaction-controller'), + ); + }); + + it('reports validation errors for multiple missing changelog entries (plural)', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithMultipleDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,8 +10,8 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0", +- "@metamask/network-controller": "^5.0.0" ++ "@metamask/transaction-controller": "^62.0.0", ++ "@metamask/network-controller": "^6.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithMultipleDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/network-controller', + type: 'dependencies', + oldVersion: '^5.0.0', + newVersion: '^6.0.0', + }, + ], + existingEntries: [], + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining( + '❌ controller-utils: Missing 2 changelog entries:', + ), + ); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('- @metamask/transaction-controller'), + ); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('- @metamask/network-controller'), + ); + }); + + it('reports validation success when all entries are present', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('✅ controller-utils: All entries present'), + ); + }); + + it('does not show fix hint when fix flag is set', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: false, + hasUnreleasedSection: false, + missingEntries: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + existingEntries: [], + }, + ]); + + jest + .spyOn(changelogValidatorModule, 'updateChangelogs') + .mockResolvedValue(1); + + await checkDependencyBumps({ + fromRef: 'abc123', + fix: true, + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not show the fix hint when fix is enabled + expect(stderrWriteSpy).not.toHaveBeenCalledWith( + expect.stringContaining('💡 Run with --fix'), + ); + }); + + it('reports successful updates when fix updates changelogs', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + jest + .spyOn(changelogValidatorModule, 'updateChangelogs') + .mockResolvedValue(2); + + await checkDependencyBumps({ + fromRef: 'abc123', + fix: true, + prNumber: '1234', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('✅ Updated 2 changelogs'), + ); + }); + + it('reports when changelogs are already up to date', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + jest + .spyOn(changelogValidatorModule, 'updateChangelogs') + .mockResolvedValue(0); + + await checkDependencyBumps({ + fromRef: 'abc123', + fix: true, + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('✅ All changelogs are up to date'), + ); + }); + + it('shows placeholder note when no PR number provided with fix', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + jest + .spyOn(changelogValidatorModule, 'updateChangelogs') + .mockResolvedValue(1); + + await checkDependencyBumps({ + fromRef: 'abc123', + fix: true, + // No prNumber provided + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('Placeholder PR numbers (XXXXX) were used'), + ); + }); + + it('skips devDependencies changes', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithDevDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "devDependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDevDeps); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect devDependencies changes + expect(result).toStrictEqual({}); + }); + + it('deduplicates same dependency in different sections', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff showing same dependency changed in both dependencies and peerDependencies + const diffWithDuplicates = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -10,10 +10,10 @@ + }, + "dependencies": { + "@metamask/network-controller": "^5.0.0", +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + }, + "peerDependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDuplicates); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should have two entries: one for dependencies, one for peerDependencies + expect(result['controller-utils'].dependencyChanges).toHaveLength(2); + expect(result['controller-utils'].dependencyChanges[0].type).toBe( + 'dependencies', + ); + expect(result['controller-utils'].dependencyChanges[1].type).toBe( + 'peerDependencies', + ); + }); + + it('handles diff without proper file path', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff without b/ prefix (malformed) + const diffMalformed = ` +diff --git a/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffMalformed); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should handle gracefully + expect(result).toStrictEqual({}); + }); + + it('detects peerDependencies without encountering dependencies keyword', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff where peerDependencies appears but "dependencies" string never appears + const diffOnlyPeerDeps = `diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index abc123..def456 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -1,8 +1,8 @@ + { + "name": "@metamask/controller-utils", + "version": "1.0.0", + "peerDependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + }`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffOnlyPeerDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result['controller-utils'].dependencyChanges[0].type).toBe( + 'peerDependencies', + ); + }); + + it('ignores dependency changes not in packages directory', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff in root package.json (not in packages/) + const diffInRoot = ` +diff --git a/package.json b/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffInRoot); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect changes outside packages/ directory + expect(result).toStrictEqual({}); + }); + + it('ignores malformed dependency lines in diff', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with malformed dependency lines + const diffMalformed = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffMalformed); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect malformed lines + expect(result).toStrictEqual({}); + }); + + it('ignores changes where versions are identical', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff where removed and added versions are the same (formatting change) + const diffSameVersion = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^61.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffSameVersion); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect when versions are the same + expect(result).toStrictEqual({}); + }); + + it('ignores added dependencies without corresponding removal', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with only added dependency (new dependency, not a bump) + const diffOnlyAdd = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,6 +10,7 @@ + "dependencies": { + "@metamask/network-controller": "^5.0.0", ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffOnlyAdd); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect new additions (only bumps) + expect(result).toStrictEqual({}); + }); + + it('handles section end detection with closing braces', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with section ending detection + const diffWithSectionEnd = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -5,12 +5,12 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + }, + "scripts": { + "test": "jest" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithSectionEnd); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result['controller-utils'].dependencyChanges).toHaveLength(1); + }); + + it('handles transition between different dependency sections', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff transitioning from dependencies to peerDependencies + const diffWithTransition = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -5,11 +5,11 @@ + "dependencies": { +- "@metamask/network-controller": "^5.0.0" ++ "@metamask/network-controller": "^6.0.0" + }, + "peerDependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithTransition); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result['controller-utils'].dependencyChanges).toHaveLength(2); + expect( + result['controller-utils'].dependencyChanges.find( + (c) => c.type === 'dependencies', + ), + ).toBeDefined(); + expect( + result['controller-utils'].dependencyChanges.find( + (c) => c.type === 'peerDependencies', + ), + ).toBeDefined(); + }); + + it('ignores lines without package name match in removed dependencies', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with malformed removed line (no proper JSON format) + const diffMalformed = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- bad line without proper format ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffMalformed); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + }); + + it('ignores added lines that start with + and have @ but do not match dependency format', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with line that starts with + and has @, but doesn't match the regex + const diffMalformed = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/something: malformed without closing quote + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffMalformed); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + }); + + it('deduplicates same dependency bumped multiple times in same section', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Multiple diff chunks with same dependency change (simulates complex merge) + const diffWithRealDuplicates = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -5,7 +5,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0", ++ "@metamask/transaction-controller": "^62.0.0", + "@metamask/network-controller": "^5.0.0" +@@ -15,7 +15,7 @@ + "dependencies": { + "@metamask/network-controller": "^5.0.0", +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithRealDuplicates); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should only have one entry in dependencies section despite appearing twice + expect(result['controller-utils'].dependencyChanges).toHaveLength(1); + expect(result['controller-utils'].dependencyChanges[0]).toStrictEqual({ + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }); + }); + + it('handles same dependency bumped to different versions by keeping first', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Same dependency bumped to different versions in same diff + const diffDifferentVersions = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -5,7 +5,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } +@@ -15,7 +15,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^63.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffDifferentVersions); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should only keep the first version (^62.0.0), not the second (^63.0.0) + expect(result['controller-utils'].dependencyChanges).toHaveLength(1); + expect(result['controller-utils'].dependencyChanges[0].newVersion).toBe( + '^62.0.0', + ); + }); + it('ignores version changes in root package.json', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffVersionInRoot = ` +diff --git a/package.json b/package.json +@@ -1,6 +1,6 @@ + { + "name": "@metamask/core", +- "version": "1.0.0", ++ "version": "1.1.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffVersionInRoot); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // No dependency changes, so should be empty + expect(result).toStrictEqual({}); + }); + + it('ignores malformed version lines', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffMalformedVersion = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -1,6 +1,6 @@ + { + "name": "@metamask/controller-utils", ++ "version": malformed without quotes +- "version": "1.0.0" + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffMalformedVersion); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should handle malformed version gracefully + expect(result).toStrictEqual({}); + }); + + it('detects package version changes for release detection', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithVersionAndDep = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +@@ -1,10 +1,10 @@ + { + "name": "@metamask/controller-utils", +- "version": "1.0.0", ++ "version": "1.1.0", + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithVersionAndDep); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/MetaMask/core'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Verify the result includes the package version + expect(result['controller-utils'].newVersion).toBe('1.1.0'); + expect(result['controller-utils'].dependencyChanges).toHaveLength(1); + + // Verify validateChangelogs is called + expect(changelogValidatorModule.validateChangelogs).toHaveBeenCalledWith( + expect.any(Object), + '/path/to/project', + 'https://github.com/MetaMask/core', + { 'controller-utils': '@metamask/controller-utils' }, + ); + }); + }); +}); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts new file mode 100755 index 0000000..202a2ff --- /dev/null +++ b/src/check-dependency-bumps.ts @@ -0,0 +1,444 @@ +/** + * Dependency Bump Checker Script + * + * This script analyzes git diffs to find dependency version changes in package.json files. + * It focuses on dependencies and peerDependencies, excluding devDependencies. + * + */ + +import type { WriteStream } from 'fs'; +import path from 'path'; +import { validateChangelogs, updateChangelogs } from './changelog-validator.js'; +import { getCurrentBranchName } from './repo.js'; +import { getStdoutFromCommand } from './misc-utils.js'; +import { getValidRepositoryUrl } from './project.js'; +import { readPackageManifest } from './package-manifest.js'; +import type { DependencyChange, PackageInfo, PackageChanges } from './types.js'; + +// Re-export types for convenience +export type { DependencyChange, PackageInfo, PackageChanges }; + +/** + * Retrieves the git diff between two references for package.json files. + * + * @param fromRef - The starting git reference (commit, branch, or tag). + * @param toRef - The ending git reference (commit, branch, or tag). + * @param projectRoot - The project root directory. + * @returns The raw git diff output. + */ +async function getGitDiff( + fromRef: string, + toRef: string, + projectRoot: string, +): Promise { + try { + return await getStdoutFromCommand( + 'git', + [ + 'diff', + '-U9999', // Show maximum context to ensure section headers are visible + fromRef, + toRef, + '--', + '**/package.json', + ], + { cwd: projectRoot }, + ); + } catch (error: any) { + // Git diff returns exit code 1 when there are no changes + if (error.exitCode === 1 && error.stdout === '') { + return ''; + } + + throw error; + } +} + +/** + * Parses git diff output to extract dependency version changes and package version changes. + * + * @param diff - Raw git diff output. + * @returns Object mapping package names to their changes and version info. + */ +function parseDiff(diff: string): PackageChanges { + const lines = diff.split('\n'); + const changes: PackageChanges = {}; + + let currentFile = ''; + let currentSection: 'dependencies' | 'peerDependencies' | null = null; + const removedDeps = new Map< + string, + { version: string; section: 'dependencies' | 'peerDependencies' } + >(); + const processedChanges = new Set(); + const packageVersionsMap = new Map(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Track current file + if (line.startsWith('diff --git')) { + const match = line.match(/b\/(.+)/u); + + if (match) { + currentFile = match[1]; + } + } + + // Detect package version changes (for release detection) + if (line.startsWith('+') && line.includes('"version":')) { + const versionMatch = line.match(/^\+\s*"version":\s*"([^"]+)"/u); + + if (versionMatch) { + const newVersion = versionMatch[1]; + const packageMatch = currentFile.match(/packages\/([^/]+)\//u); + + if (packageMatch) { + const packageName = packageMatch[1]; + packageVersionsMap.set(packageName, newVersion); + } + } + } + + // Detect dependency sections (excluding devDependencies) + if (line.includes('"peerDependencies"')) { + currentSection = 'peerDependencies'; + } else if (line.includes('"dependencies"')) { + currentSection = 'dependencies'; + } else if (line.includes('"devDependencies"')) { + // Skip devDependencies section + currentSection = null; + } + + // Check if we're leaving a section + if ((currentSection && line.trim() === '},') || line.trim() === '}') { + // Check if next line is another section or end of sections + const nextLine = lines[i + 1]; + + if (nextLine && !nextLine.includes('Dependencies"')) { + currentSection = null; + } + } + + // Parse removed dependencies + if (line.startsWith('-') && currentSection && line.includes('"@')) { + const match = line.match(/^-\s*"([^"]+)":\s*"([^"]+)"/u); + + if (match && currentSection) { + const [, dep, version] = match; + const key = `${currentFile}:${currentSection}:${dep}`; + removedDeps.set(key, { + version, + section: currentSection, + }); + } + } + + // Parse added dependencies and match with removed + if (line.startsWith('+') && currentSection && line.includes('"@')) { + const match = line.match(/^\+\s*"([^"]+)":\s*"([^"]+)"/u); + + if (match) { + const [, dep, newVersion] = match; + // Look for removed dependency in same section + const key = `${currentFile}:${currentSection}:${dep}`; + const removed = removedDeps.get(key); + + if (removed && removed.version !== newVersion) { + // Extract package name from path + const packageMatch = currentFile.match(/packages\/([^/]+)\//u); + + if (packageMatch) { + const packageName = packageMatch[1]; + + // Create unique change identifier + const changeId = `${packageName}:${currentSection}:${dep}:${newVersion}`; + + // Skip if we've already processed this exact change + if (!processedChanges.has(changeId)) { + processedChanges.add(changeId); + + if (!changes[packageName]) { + const pkgInfo: PackageInfo = { + dependencyChanges: [], + }; + const packageNewVersion = packageVersionsMap.get(packageName); + + if (packageNewVersion) { + pkgInfo.newVersion = packageNewVersion; + } + + changes[packageName] = pkgInfo; + } + + // Check if we already have this dependency for this package and section + const sectionType = currentSection; + const existingChange = changes[ + packageName + ].dependencyChanges.find( + (c) => c.dependency === dep && c.type === sectionType, + ); + + if (!existingChange) { + changes[packageName].dependencyChanges.push({ + package: packageName, + dependency: dep, + type: sectionType, + oldVersion: removed.version, + newVersion, + }); + } + } + } + } + } + } + } + + return changes; +} + +/** + * Reads package names from package.json files for all packages with changes. + * + * @param changes - Package changes with version info keyed by directory name. + * @param projectRoot - The project root directory. + * @returns Map of directory names to actual package names. + * @throws If a package.json cannot be read or is invalid. + */ +async function getPackageNames( + changes: PackageChanges, + projectRoot: string, +): Promise> { + const packageNames: Record = {}; + + for (const packageDirName of Object.keys(changes)) { + const manifestPath = path.join( + projectRoot, + 'packages', + packageDirName, + 'package.json', + ); + + // We detected changes in this package.json via git diff, + // so it must exist and be readable. If it's not, something is wrong. + const { validated: packageManifest } = + await readPackageManifest(manifestPath); + packageNames[packageDirName] = packageManifest.name; + } + + return packageNames; +} + +/** + * Gets the merge base between current branch and the default branch. + * + * @param defaultBranch - The default branch to compare against. + * @param projectRoot - The project root directory. + * @returns The merge base commit SHA. + */ +async function getMergeBase( + defaultBranch: string, + projectRoot: string, +): Promise { + try { + return await getStdoutFromCommand( + 'git', + ['merge-base', 'HEAD', defaultBranch], + { cwd: projectRoot }, + ); + } catch { + // If local branch doesn't exist, try remote + try { + return await getStdoutFromCommand( + 'git', + ['merge-base', 'HEAD', `origin/${defaultBranch}`], + { cwd: projectRoot }, + ); + } catch { + throw new Error( + `Could not find merge base with ${defaultBranch} or origin/${defaultBranch}`, + ); + } + } +} + +/** + * Main entry point for the dependency bump checker. + * + * Automatically validates changelog entries for all dependency bumps. + * Use the --fix option to automatically update changelogs. + * + * @param options - Configuration options. + * @param options.fromRef - The starting git reference (optional). + * @param options.toRef - The ending git reference (defaults to HEAD). + * @param options.defaultBranch - The default branch to compare against (defaults to main). + * @param options.fix - Whether to fix missing changelog entries. + * @param options.prNumber - PR number to use in changelog entries. + * @param options.projectRoot - Root directory of the project. + * @param options.stdout - A stream that can be used to write to standard out. + * @param options.stderr - A stream that can be used to write to standard error. + * @returns Object mapping package names to their changes and version info. + */ +export async function checkDependencyBumps({ + fromRef, + toRef = 'HEAD', + defaultBranch = 'main', + fix = false, + prNumber, + projectRoot, + stdout, + stderr, +}: { + fromRef?: string; + toRef?: string; + defaultBranch?: string; + fix?: boolean; + prNumber?: string; + projectRoot: string; + stdout: Pick; + stderr: Pick; +}): Promise { + let actualFromRef = fromRef || ''; + + // Auto-detect branch changes if fromRef not provided + if (!actualFromRef) { + const currentBranch = await getCurrentBranchName(projectRoot); + stdout.write(`\n📌 Current branch: ${currentBranch}\n`); + + // Skip if we're on main/master + if (currentBranch === 'main' || currentBranch === 'master') { + stdout.write( + '⚠️ You are on the main/master branch. Please specify commits to compare or switch to a feature branch.\n', + ); + return {}; + } + + // Find merge base with default branch + try { + actualFromRef = await getMergeBase(defaultBranch, projectRoot); + stdout.write( + `📍 Comparing against merge base with ${defaultBranch}: ${actualFromRef.substring(0, 8)}...\n`, + ); + } catch { + stderr.write( + `❌ Could not find merge base with ${defaultBranch}. Please specify commits manually using --from, or use --default-branch to specify a different branch.\n`, + ); + return {}; + } + } + + stdout.write( + `\n🔍 Checking dependency changes from ${actualFromRef.substring(0, 8)} to ${toRef}...\n\n`, + ); + + const diff = await getGitDiff(actualFromRef, toRef, projectRoot); + + if (!diff) { + stdout.write('No package.json changes found.\n'); + return {}; + } + + const changes = parseDiff(diff); + + if (Object.keys(changes).length === 0) { + stdout.write('No dependency version bumps found.\n'); + return {}; + } + + stdout.write('\n\n📊 JSON Output:\n'); + stdout.write('==============\n'); + stdout.write(JSON.stringify(changes, null, 2)); + stdout.write('\n'); + + // Get repository URL and package names for validation/fixing + const manifestPath = path.join(projectRoot, 'package.json'); + const { unvalidated: packageManifest } = + await readPackageManifest(manifestPath); + const repoUrl = await getValidRepositoryUrl(packageManifest, projectRoot); + + // Read package names once for all packages with changes + const packageNames = await getPackageNames(changes, projectRoot); + + // Always validate to provide feedback + stdout.write('\n\n🔍 Validating changelogs...\n'); + stdout.write('==========================\n'); + + const validationResults = await validateChangelogs( + changes, + projectRoot, + repoUrl, + packageNames, + ); + + let hasErrors = false; + + for (const result of validationResults) { + if (!result.hasChangelog) { + stderr.write(`❌ ${result.package}: CHANGELOG.md not found\n`); + hasErrors = true; + } else if (!result.hasUnreleasedSection) { + stderr.write(`❌ ${result.package}: No [Unreleased] section found\n`); + hasErrors = true; + } else if (result.missingEntries.length > 0) { + stderr.write( + `❌ ${result.package}: Missing ${result.missingEntries.length} changelog ${result.missingEntries.length === 1 ? 'entry' : 'entries'}:\n`, + ); + + for (const entry of result.missingEntries) { + stderr.write(` - ${entry.dependency}\n`); + } + + hasErrors = true; + } else { + stdout.write(`✅ ${result.package}: All entries present\n`); + } + } + + if (hasErrors && !fix) { + stderr.write('\n💡 Run with --fix to automatically update changelogs\n'); + } + + // Fix changelogs if requested + if (fix) { + stdout.write('\n\n🔧 Updating changelogs...\n'); + stdout.write('========================\n'); + + const updateOptions: { + projectRoot: string; + prNumber?: string; + repoUrl: string; + packageNames: Record; + stdout: Pick; + stderr: Pick; + } = { + projectRoot, + repoUrl, + packageNames, + stdout, + stderr, + }; + + if (prNumber !== undefined) { + updateOptions.prNumber = prNumber; + } + + const updatedCount = await updateChangelogs(changes, updateOptions); + + if (updatedCount > 0) { + stdout.write( + `\n✅ Updated ${updatedCount} changelog${updatedCount === 1 ? '' : 's'}\n`, + ); + + if (!prNumber) { + stdout.write( + '\n💡 Note: Placeholder PR numbers (XXXXX) were used. Update them manually or run with --pr \n', + ); + } + } else { + stdout.write('\n✅ All changelogs are up to date\n'); + } + } + + return changes; +} diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index 383cc39..6ad466b 100644 --- a/src/command-line-arguments.ts +++ b/src/command-line-arguments.ts @@ -1,9 +1,11 @@ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; -export type CommandLineArguments = { +export type ReleaseCommandArguments = { + _: string[]; + command: 'release'; projectDirectory: string; - tempDirectory: string | undefined; + tempDirectory?: string; reset: boolean; backport: boolean; defaultBranch: string; @@ -11,6 +13,20 @@ export type CommandLineArguments = { port: number; }; +export type CheckDepsCommandArguments = { + _: string[]; + command: 'check-deps'; + fromRef?: string; + toRef?: string; + defaultBranch: string; + fix?: boolean; + pr?: string; +}; + +export type CommandLineArguments = + | ReleaseCommandArguments + | CheckDepsCommandArguments; + /** * Parses the arguments provided on the command line using `yargs`. * @@ -21,52 +37,118 @@ export type CommandLineArguments = { export async function readCommandLineArguments( argv: string[], ): Promise { - return await yargs(hideBin(argv)) - .usage( - 'This tool prepares your project for a new release by bumping versions and updating changelogs.', + const args = await yargs(hideBin(argv)) + .scriptName('create-release-branch') + .usage('$0 [options]') + .command( + ['release', '$0'], + 'Prepare your project for a new release by bumping versions and updating changelogs', + (commandYargs) => + commandYargs + .option('project-directory', { + alias: 'd', + describe: 'The directory that holds your project.', + default: '.', + }) + .option('temp-directory', { + describe: + 'The directory that is used to hold temporary files, such as the release spec template.', + type: 'string', + }) + .option('reset', { + describe: + 'Removes any cached files from a previous run that may have been created.', + type: 'boolean', + default: false, + }) + .option('backport', { + describe: + 'Instructs the tool to bump the second part of the version rather than the first for a backport release.', + type: 'boolean', + default: false, + }) + .option('default-branch', { + alias: 'b', + describe: 'The name of the default branch in the repository.', + default: 'main', + type: 'string', + }) + .option('interactive', { + alias: 'i', + describe: + 'Start an interactive web UI for selecting package versions to release', + type: 'boolean', + default: false, + }) + .option('port', { + describe: + 'Port to run the interactive web UI server (only used with --interactive)', + type: 'number', + default: 3000, + }), + ) + .command( + 'check-deps', + 'Check dependency version bumps between git references', + (commandYargs) => + commandYargs + .option('from', { + describe: + 'The starting git reference (commit, branch, or tag). If not provided, auto-detects from merge base with default branch.', + type: 'string', + }) + .option('to', { + describe: 'The ending git reference (commit, branch, or tag).', + type: 'string', + default: 'HEAD', + }) + .option('default-branch', { + alias: 'b', + describe: + 'The name of the default branch to compare against when auto-detecting.', + default: 'main', + type: 'string', + }) + .option('fix', { + describe: + 'Automatically update changelogs with missing dependency bump entries.', + type: 'boolean', + default: false, + }) + .option('pr', { + describe: + 'PR number to use in changelog entries (uses placeholder if not provided).', + type: 'string', + }), ) - .option('project-directory', { - alias: 'd', - describe: 'The directory that holds your project.', - default: '.', - }) - .option('temp-directory', { - describe: - 'The directory that is used to hold temporary files, such as the release spec template.', - type: 'string', - }) - .option('reset', { - describe: - 'Removes any cached files from a previous run that may have been created.', - type: 'boolean', - default: false, - }) - .option('backport', { - describe: - 'Instructs the tool to bump the second part of the version rather than the first for a backport release.', - type: 'boolean', - default: false, - }) - .option('default-branch', { - alias: 'b', - describe: 'The name of the default branch in the repository.', - default: 'main', - type: 'string', - }) - .option('interactive', { - alias: 'i', - describe: - 'Start an interactive web UI for selecting package versions to release', - type: 'boolean', - default: false, - }) - .option('port', { - describe: - 'Port to run the interactive web UI server (only used with --interactive)', - type: 'number', - default: 3000, - }) .help() .strict() + .demandCommand(0, 1) .parse(); + + const command = args._[0] || 'release'; + + if (command === 'check-deps') { + return { + ...args, + command: 'check-deps', + fromRef: args.from, + toRef: args.to, + defaultBranch: args.defaultBranch, + fix: args.fix, + pr: args.pr, + } as CheckDepsCommandArguments; + } + + return { + ...args, + command: 'release', + projectDirectory: args.projectDirectory, + tempDirectory: args.tempDirectory, + reset: args.reset, + backport: args.backport, + defaultBranch: args.defaultBranch, + interactive: args.interactive, + port: args.port, + } as ReleaseCommandArguments; } diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index 8b7d475..ebf0cef 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -25,260 +25,300 @@ describe('initial-parameters', () => { jest.useRealTimers(); }); - it('returns an object derived from command-line arguments and environment variables that contains data necessary to run the workflow', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', + describe('when command is "release"', () => { + it('returns an object derived from command-line arguments and environment variables that contains data necessary to run the workflow', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: true, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); + + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); + + expect(initialParameters).toStrictEqual({ + project, + tempDirectoryPath: '/path/to/temp', reset: true, - backport: false, + releaseType: 'ordinary', defaultBranch: 'main', interactive: false, port: 3000, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/somewhere', - stderr, }); - expect(initialParameters).toStrictEqual({ - project, - tempDirectoryPath: '/path/to/temp', - reset: true, - releaseType: 'ordinary', - defaultBranch: 'main', - interactive: false, - port: 3000, - }); - }); + it('resolves the given project directory relative to the current working directory', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage(), + }); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: 'project', + reset: true, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + const readProjectSpy = jest + .spyOn(projectModule, 'readProject') + .mockResolvedValue(project); - it('resolves the given project directory relative to the current working directory', async () => { - const project = buildMockProject({ - rootPackage: buildMockPackage(), - }); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: 'project', - tempDirectory: undefined, - reset: true, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - const readProjectSpy = jest - .spyOn(projectModule, 'readProject') - .mockResolvedValue(project); - await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/cwd', - stderr, + expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project', { + stderr, + }); }); - expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project', { - stderr, - }); - }); + it('resolves the given temporary directory relative to the current working directory', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: 'tmp', + reset: true, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('resolves the given temporary directory relative to the current working directory', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: 'tmp', - reset: true, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/cwd', - stderr, + expect(initialParameters.tempDirectoryPath).toBe('/path/to/cwd/tmp'); }); - expect(initialParameters.tempDirectoryPath).toBe('/path/to/cwd/tmp'); - }); + it('uses a default temporary directory based on the name of the package if no temporary directory was given', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('@foo/bar'), + }); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + reset: true, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('uses a default temporary directory based on the name of the package if no temporary directory was given', async () => { - const project = buildMockProject({ - rootPackage: buildMockPackage('@foo/bar'), - }); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: undefined, - reset: true, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/cwd', - stderr, + expect(initialParameters.tempDirectoryPath).toStrictEqual( + path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'), + ); }); - expect(initialParameters.tempDirectoryPath).toStrictEqual( - path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'), - ); - }); + it('returns initial parameters including reset: true, derived from a command-line argument of "--reset true"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: true, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('returns initial parameters including reset: true, derived from a command-line argument of "--reset true"', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', - reset: true, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/somewhere', - stderr, + expect(initialParameters.reset).toBe(true); }); - expect(initialParameters.reset).toBe(true); - }); + it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: false, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', - reset: false, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/somewhere', - stderr, + expect(initialParameters.reset).toBe(false); }); - expect(initialParameters.reset).toBe(false); - }); + it('returns initial parameters including a releaseType of "backport", derived from a command-line argument of "--backport true"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: false, + backport: true, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('returns initial parameters including a releaseType of "backport", derived from a command-line argument of "--backport true"', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', - reset: false, - backport: true, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/somewhere', - stderr, + expect(initialParameters.releaseType).toBe('backport'); }); - expect(initialParameters.releaseType).toBe('backport'); - }); + it('returns initial parameters including a releaseType of "ordinary", derived from a command-line argument of "--backport false"', async () => { + const project = buildMockProject(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: false, + backport: false, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project', { stderr }) + .mockResolvedValue(project); - it('returns initial parameters including a releaseType of "ordinary", derived from a command-line argument of "--backport false"', async () => { - const project = buildMockProject(); - const stderr = createNoopWriteStream(); - when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', - reset: false, - backport: false, - defaultBranch: 'main', - interactive: false, - port: 3000, + const initialParameters = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, }); - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project', { stderr }) - .mockResolvedValue(project); - const initialParameters = await determineInitialParameters({ - argv: ['arg1', 'arg2'], - cwd: '/path/to/somewhere', - stderr, + expect(initialParameters.releaseType).toBe('ordinary'); }); + }); - expect(initialParameters.releaseType).toBe('ordinary'); + describe('when command is "check-deps"', () => { + it('throws an error because determineInitialParameters only handles release command', async () => { + const stderr = createNoopWriteStream(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['check-deps']) + .mockResolvedValue({ + _: ['check-deps'], + command: 'check-deps', + toRef: 'HEAD', + defaultBranch: 'main', + }); + + await expect( + determineInitialParameters({ + argv: ['check-deps'], + cwd: '/path/to/somewhere', + stderr, + }), + ).rejects.toThrow( + 'determineInitialParameters should only be called for release command', + ); + }); }); }); }); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index 5ed3f54..660811e 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -46,6 +46,13 @@ export async function determineInitialParameters({ }): Promise { const args = await readCommandLineArguments(argv); + // Ensure we're handling the release command + if (args.command !== 'release') { + throw new Error( + 'determineInitialParameters should only be called for release command', + ); + } + const projectDirectoryPath = path.resolve(cwd, args.projectDirectory); const project = await readProject(projectDirectoryPath, { stderr }); const tempDirectoryPath = diff --git a/src/main.test.ts b/src/main.test.ts index 6e51dc7..bb70158 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,12 +1,17 @@ import fs from 'fs'; +import { when } from 'jest-when'; import { buildMockProject } from '../tests/unit/helpers.js'; import { main } from './main.js'; +import * as commandLineArgumentsModule from './command-line-arguments.js'; import * as initialParametersModule from './initial-parameters.js'; import * as monorepoWorkflowOperations from './monorepo-workflow-operations.js'; +import * as checkDependencyBumpsModule from './check-dependency-bumps.js'; import * as ui from './ui.js'; +jest.mock('./command-line-arguments'); jest.mock('./initial-parameters'); jest.mock('./monorepo-workflow-operations'); +jest.mock('./check-dependency-bumps'); jest.mock('./ui'); jest.mock('./dirname', () => ({ getCurrentDirectoryPath: jest.fn().mockReturnValue('/path/to/somewhere'), @@ -18,103 +23,273 @@ jest.mock('open', () => ({ })); describe('main', () => { - it('executes the CLI monorepo workflow if the project is a monorepo and interactive is false', async () => { - const project = buildMockProject({ isMonorepo: true }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - jest - .spyOn(initialParametersModule, 'determineInitialParameters') - .mockResolvedValue({ + describe('when command is "release"', () => { + it('executes the CLI monorepo workflow if the project is a monorepo and interactive is false', async () => { + const project = buildMockProject({ isMonorepo: true }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith([]) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '.', + reset: true, + backport: true, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: true, + defaultBranch: 'main', + releaseType: 'backport', + interactive: false, + port: 3000, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).toHaveBeenCalledWith({ project, tempDirectoryPath: '/path/to/temp/directory', - reset: true, + firstRemovingExistingReleaseSpecification: true, + releaseType: 'backport', defaultBranch: 'main', + stdout, + stderr, + }); + }); + + it('executes the interactive UI monorepo workflow if the project is a monorepo and interactive is true', async () => { + const project = buildMockProject({ isMonorepo: true }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith([]) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '.', + reset: true, + backport: true, + defaultBranch: 'main', + interactive: true, + port: 3000, + }); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: true, + defaultBranch: 'main', + releaseType: 'backport', + interactive: true, + port: 3000, + }); + const startUISpy = jest.spyOn(ui, 'startUI').mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(startUISpy).toHaveBeenCalledWith({ + project, releaseType: 'backport', - interactive: false, + defaultBranch: 'main', port: 3000, + stdout, + stderr, }); - const followMonorepoWorkflowSpy = jest - .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') - .mockResolvedValue(); - - await main({ - argv: [], - cwd: '/path/to/somewhere', - stdout, - stderr, }); - expect(followMonorepoWorkflowSpy).toHaveBeenCalledWith({ - project, - tempDirectoryPath: '/path/to/temp/directory', - firstRemovingExistingReleaseSpecification: true, - releaseType: 'backport', - defaultBranch: 'main', - stdout, - stderr, + it('executes the polyrepo workflow if the project is within a polyrepo', async () => { + const project = buildMockProject({ isMonorepo: false }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith([]) + .mockResolvedValue({ + _: ['release'], + command: 'release', + projectDirectory: '.', + reset: false, + backport: true, + defaultBranch: 'main', + interactive: false, + port: 3000, + }); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + defaultBranch: 'main', + releaseType: 'backport', + interactive: false, + port: 3000, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).not.toHaveBeenCalled(); }); }); - it('executes the interactive UI monorepo workflow if the project is a monorepo and interactive is true', async () => { - const project = buildMockProject({ isMonorepo: true }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - jest - .spyOn(initialParametersModule, 'determineInitialParameters') - .mockResolvedValue({ - project, - tempDirectoryPath: '/path/to/temp/directory', - reset: true, - defaultBranch: 'main', - releaseType: 'backport', - interactive: true, - port: 3000, + describe('when command is "check-deps"', () => { + it('calls checkDependencyBumps with all provided options', async () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['check-deps', '--from', 'abc123', '--fix', '--pr', '1234']) + .mockResolvedValue({ + _: ['check-deps'], + command: 'check-deps', + fromRef: 'abc123', + toRef: 'HEAD', + defaultBranch: 'main', + fix: true, + pr: '1234', + }); + const checkDependencyBumpsSpy = jest + .spyOn(checkDependencyBumpsModule, 'checkDependencyBumps') + .mockResolvedValue({}); + + await main({ + argv: ['check-deps', '--from', 'abc123', '--fix', '--pr', '1234'], + cwd: '/path/to/project', + stdout, + stderr, }); - const startUISpy = jest.spyOn(ui, 'startUI').mockResolvedValue(); - await main({ - argv: [], - cwd: '/path/to/somewhere', - stdout, - stderr, + expect(checkDependencyBumpsSpy).toHaveBeenCalledWith({ + fromRef: 'abc123', + toRef: 'HEAD', + defaultBranch: 'main', + fix: true, + prNumber: '1234', + projectRoot: '/path/to/project', + stdout, + stderr, + }); }); - expect(startUISpy).toHaveBeenCalledWith({ - project, - releaseType: 'backport', - defaultBranch: 'main', - port: 3000, - stdout, - stderr, + it('calls checkDependencyBumps with default options when optionals are not provided', async () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['check-deps']) + .mockResolvedValue({ + _: ['check-deps'], + command: 'check-deps', + toRef: 'HEAD', + defaultBranch: 'main', + }); + const checkDependencyBumpsSpy = jest + .spyOn(checkDependencyBumpsModule, 'checkDependencyBumps') + .mockResolvedValue({}); + + await main({ + argv: ['check-deps'], + cwd: '/path/to/project', + stdout, + stderr, + }); + + expect(checkDependencyBumpsSpy).toHaveBeenCalledWith({ + toRef: 'HEAD', + defaultBranch: 'main', + projectRoot: '/path/to/project', + stdout, + stderr, + }); }); - }); - it('executes the polyrepo workflow if the project is within a polyrepo', async () => { - const project = buildMockProject({ isMonorepo: false }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - jest - .spyOn(initialParametersModule, 'determineInitialParameters') - .mockResolvedValue({ - project, - tempDirectoryPath: '/path/to/temp/directory', - reset: false, + it('calls checkDependencyBumps with custom toRef when provided', async () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['check-deps', '--to', 'feature-branch']) + .mockResolvedValue({ + _: ['check-deps'], + command: 'check-deps', + toRef: 'feature-branch', + defaultBranch: 'main', + }); + const checkDependencyBumpsSpy = jest + .spyOn(checkDependencyBumpsModule, 'checkDependencyBumps') + .mockResolvedValue({}); + + await main({ + argv: ['check-deps', '--to', 'feature-branch'], + cwd: '/path/to/project', + stdout, + stderr, + }); + + expect(checkDependencyBumpsSpy).toHaveBeenCalledWith({ + toRef: 'feature-branch', defaultBranch: 'main', - releaseType: 'backport', - interactive: false, - port: 3000, + projectRoot: '/path/to/project', + stdout, + stderr, }); - const followMonorepoWorkflowSpy = jest - .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') - .mockResolvedValue(); - - await main({ - argv: [], - cwd: '/path/to/somewhere', - stdout, - stderr, }); - expect(followMonorepoWorkflowSpy).not.toHaveBeenCalled(); + it('calls checkDependencyBumps with custom defaultBranch when provided', async () => { + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['check-deps', '--default-branch', 'develop']) + .mockResolvedValue({ + _: ['check-deps'], + command: 'check-deps', + toRef: 'HEAD', + defaultBranch: 'develop', + }); + const checkDependencyBumpsSpy = jest + .spyOn(checkDependencyBumpsModule, 'checkDependencyBumps') + .mockResolvedValue({}); + + await main({ + argv: ['check-deps', '--default-branch', 'develop'], + cwd: '/path/to/project', + stdout, + stderr, + }); + + expect(checkDependencyBumpsSpy).toHaveBeenCalledWith({ + toRef: 'HEAD', + defaultBranch: 'develop', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + }); }); }); diff --git a/src/main.ts b/src/main.ts index 88ba852..0afe648 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import type { WriteStream } from 'fs'; import { determineInitialParameters } from './initial-parameters.js'; import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; import { startUI } from './ui.js'; +import { readCommandLineArguments } from './command-line-arguments.js'; +import { checkDependencyBumps } from './check-dependency-bumps.js'; /** * The main function for this tool. Designed to not access `process.argv`, @@ -26,6 +28,24 @@ export async function main({ stdout: Pick; stderr: Pick; }) { + const args = await readCommandLineArguments(argv); + + // Route to check-deps command if requested + if (args.command === 'check-deps') { + await checkDependencyBumps({ + projectRoot: cwd, + defaultBranch: args.defaultBranch, + ...(args.fromRef !== undefined && { fromRef: args.fromRef }), + ...(args.toRef !== undefined && { toRef: args.toRef }), + ...(args.fix !== undefined && { fix: args.fix }), + ...(args.pr !== undefined && { prNumber: args.pr }), + stdout, + stderr, + }); + return; + } + + // Otherwise, follow the release workflow const { project, tempDirectoryPath, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..100da7d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +/** + * Shared type definitions for dependency bump checker + */ + +/** + * Represents a single dependency version change + */ +export type DependencyChange = { + package: string; + dependency: string; + type: 'dependencies' | 'peerDependencies'; + oldVersion: string; + newVersion: string; +}; + +/** + * Information about a package with changes + */ +export type PackageInfo = { + /** Dependency changes for this package */ + dependencyChanges: DependencyChange[]; + /** New version if package is being released */ + newVersion?: string; +}; + +/** + * Maps package directory names to their changes and version info + */ +export type PackageChanges = { + [packageDirectoryName: string]: PackageInfo; +}; From 8ee555d30558b9c6c870d480e887a1cae79dabea Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 6 Nov 2025 17:31:38 +0100 Subject: [PATCH 02/17] refactor: inline package name resolution during diff parsing Optimizes package name resolution by reading package.json inline during git diff parsing instead of in a separate enrichment pass. Changes: - Make parseDiff async to read package names inline - Remove enrichWithPackageNames function (no longer needed) - Read packageName immediately when first encountering a package - Simplify validateChangelogs and updateChangelogs signatures - Remove packageNames parameter (now part of PackageInfo) Benefits: - Single-pass processing (parse + enrich in one step) - Simpler code flow (24 lines removed) - Better data locality (package info complete at creation) - Cleaner API (functions receive unified PackageChanges structure) Test coverage maintained: 100% (339 passing tests) --- src/changelog-validator.test.ts | 117 ++++------------------------- src/changelog-validator.ts | 39 +++++----- src/check-dependency-bumps.test.ts | 5 +- src/check-dependency-bumps.ts | 67 ++++++----------- src/types.ts | 2 + 5 files changed, 57 insertions(+), 173 deletions(-) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index a800f93..87c0ac6 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -12,6 +12,7 @@ jest.mock('@metamask/auto-changelog'); describe('changelog-validator', () => { const mockChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -24,41 +25,7 @@ describe('changelog-validator', () => { }, }; - const mockPackageNames = { - 'controller-utils': '@metamask/controller-utils', - }; - describe('validateChangelogs', () => { - it('uses fallback package name when not in packageNames map', async () => { - when(jest.spyOn(fsModule, 'fileExists')) - .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') - .mockResolvedValue(true); - when(jest.spyOn(fsModule, 'readFile')) - .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') - .mockResolvedValue('# Changelog\n## [Unreleased]'); - jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); - - const parseChangelogSpy = jest.fn().mockReturnValue({ - getUnreleasedChanges: () => ({ Changed: [] }), - }); - (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); - - // Pass empty packageNames to trigger fallback - await validateChangelogs( - mockChanges, - '/path/to/project', - 'https://github.com/MetaMask/core', - {}, - ); - - // Verify it uses the directory name as fallback - expect(parseChangelogSpy).toHaveBeenCalledWith( - expect.objectContaining({ - tagPrefix: 'controller-utils@', - }), - ); - }); - it('handles changelog with no Changed section', async () => { when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -75,7 +42,6 @@ describe('changelog-validator', () => { mockChanges, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(results).toStrictEqual([ @@ -98,7 +64,6 @@ describe('changelog-validator', () => { mockChanges, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(results).toStrictEqual([ @@ -128,7 +93,6 @@ describe('changelog-validator', () => { mockChanges, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(results).toStrictEqual([ @@ -158,7 +122,6 @@ describe('changelog-validator', () => { mockChanges, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(results).toStrictEqual([ @@ -194,7 +157,6 @@ describe('changelog-validator', () => { mockChanges, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(results).toStrictEqual([ @@ -245,7 +207,6 @@ describe('changelog-validator', () => { changesWithVersion, '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(mockChangelog.getReleaseChanges).toHaveBeenCalledWith('1.1.0'); @@ -283,7 +244,6 @@ describe('changelog-validator', () => { mockChanges, // No newVersion in mockChanges '/path/to/project', 'https://github.com/MetaMask/core', - mockPackageNames, ); expect(mockChangelog.getUnreleasedChanges).toHaveBeenCalled(); @@ -298,41 +258,6 @@ describe('changelog-validator', () => { const stdout = fs.createWriteStream('/dev/null'); const stderr = fs.createWriteStream('/dev/null'); - it('uses fallback package name when not in packageNames map', async () => { - jest.spyOn(fsModule, 'writeFile'); - when(jest.spyOn(fsModule, 'fileExists')) - .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') - .mockResolvedValue(true); - when(jest.spyOn(fsModule, 'readFile')) - .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') - .mockResolvedValue('# Changelog\n## [Unreleased]'); - jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); - - const mockChangelog = { - getUnreleasedChanges: () => ({ Changed: [] }), - addChange: jest.fn(), - toString: jest.fn().mockResolvedValue('Updated changelog content'), - }; - const parseChangelogSpy = jest.fn().mockReturnValue(mockChangelog); - (parseChangelog as jest.Mock).mockImplementation(parseChangelogSpy); - - // Pass empty packageNames to trigger fallback - await updateChangelogs(mockChanges, { - projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', - packageNames: {}, - stdout, - stderr, - }); - - // Verify it uses the directory name as fallback - expect(parseChangelogSpy).toHaveBeenCalledWith( - expect.objectContaining({ - tagPrefix: 'controller-utils@', - }), - ); - }); - it('concatenates multiple existing PR numbers when updating entry', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = @@ -354,7 +279,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '6789', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -395,7 +319,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '1234', // Same as existing repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -409,6 +332,7 @@ describe('changelog-validator', () => { it('updates peerDependency entry with BREAKING prefix preserved', async () => { const peerDepChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -441,7 +365,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -475,7 +398,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', // No prNumber provided repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -514,7 +436,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', // No prNumber provided repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -546,7 +467,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '1234', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -567,7 +487,6 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -598,7 +517,6 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -631,7 +549,6 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -681,7 +598,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -709,6 +625,7 @@ describe('changelog-validator', () => { it('updates multiple existing entries with plural message', async () => { const multipleExistingChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -756,7 +673,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -770,6 +686,7 @@ describe('changelog-validator', () => { it('handles peerDependencies changes with BREAKING prefix', async () => { const peerDepChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -801,7 +718,6 @@ describe('changelog-validator', () => { await updateChangelogs(peerDepChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -821,6 +737,7 @@ describe('changelog-validator', () => { it('adds both peerDependencies and dependencies in correct order', async () => { const mixedTypeChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -859,7 +776,6 @@ describe('changelog-validator', () => { await updateChangelogs(mixedTypeChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -898,6 +814,7 @@ describe('changelog-validator', () => { it('updates existing entries and adds new entries in same package', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -963,7 +880,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1001,6 +917,7 @@ describe('changelog-validator', () => { it('updates existing entry and adds only new peerDependency', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1057,7 +974,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1081,6 +997,7 @@ describe('changelog-validator', () => { it('updates existing entry and adds only new dependency (no peerDeps)', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1136,7 +1053,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1155,6 +1071,7 @@ describe('changelog-validator', () => { it('updates existing entries and adds new peerDependencies correctly', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1216,7 +1133,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1262,7 +1178,6 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1275,6 +1190,7 @@ describe('changelog-validator', () => { it('adds multiple new entries with plural message', async () => { const multipleChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1314,7 +1230,6 @@ describe('changelog-validator', () => { await updateChangelogs(multipleChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1340,7 +1255,6 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1383,7 +1297,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1426,7 +1339,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1464,7 +1376,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1482,6 +1393,7 @@ describe('changelog-validator', () => { it('adds peerDependencies to unreleased section when not being released', async () => { const peerDepChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1515,7 +1427,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1530,6 +1441,7 @@ describe('changelog-validator', () => { it('adds peerDependencies to release section when package is being released', async () => { const peerDepChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1565,7 +1477,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1581,6 +1492,7 @@ describe('changelog-validator', () => { it('updates and adds entries to release section when package is being released', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1643,7 +1555,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); @@ -1659,6 +1570,7 @@ describe('changelog-validator', () => { it('updates and adds peerDependency to release section when package is being released', async () => { const mixedChanges = { 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1721,7 +1633,6 @@ describe('changelog-validator', () => { projectRoot: '/path/to/project', prNumber: '5678', repoUrl: 'https://github.com/MetaMask/core', - packageNames: mockPackageNames, stdout, stderr, }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index b87cdbf..4dd25c8 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -125,28 +125,26 @@ function hasChangelogEntry( * @param changes - Package changes to validate. * @param projectRoot - Root directory of the project. * @param repoUrl - Repository URL for changelog links. - * @param packageNames - Map of directory names to actual package names. * @returns Validation results for each package. */ export async function validateChangelogs( changes: PackageChanges, projectRoot: string, repoUrl: string, - packageNames: Record, ): Promise { const results: ChangelogValidationResult[] = []; - for (const [packageName, packageInfo] of Object.entries(changes)) { + for (const [packageDirName, packageInfo] of Object.entries(changes)) { const packageChanges = packageInfo.dependencyChanges; const packageVersion = packageInfo.newVersion; - const packagePath = path.join(projectRoot, 'packages', packageName); + const packagePath = path.join(projectRoot, 'packages', packageDirName); const changelogPath = path.join(packagePath, 'CHANGELOG.md'); const changelogContent = await readChangelog(changelogPath); if (!changelogContent) { results.push({ - package: packageName, + package: packageDirName, hasChangelog: false, hasUnreleasedSection: false, missingEntries: packageChanges, @@ -156,8 +154,8 @@ export async function validateChangelogs( } try { - // Get the actual package name from the provided map - const actualPackageName = packageNames[packageName] || packageName; + // Use the actual package name from packageInfo + const actualPackageName = packageInfo.packageName; // Parse the changelog using auto-changelog const changelog = parseChangelog({ @@ -190,7 +188,7 @@ export async function validateChangelogs( } results.push({ - package: packageName, + package: packageDirName, hasChangelog: true, hasUnreleasedSection, missingEntries, @@ -199,7 +197,7 @@ export async function validateChangelogs( } catch (error) { // If parsing fails, assume changelog is malformed results.push({ - package: packageName, + package: packageDirName, hasChangelog: true, hasUnreleasedSection: false, missingEntries: packageChanges, @@ -219,7 +217,6 @@ export async function validateChangelogs( * @param options.projectRoot - Root directory of the project. * @param options.prNumber - PR number to use in entries. * @param options.repoUrl - Repository URL for changelog links. - * @param options.packageNames - Map of directory names to actual package names. * @param options.stdout - Stream for output messages. * @param options.stderr - Stream for error messages. * @returns Number of changelogs updated. @@ -230,38 +227,36 @@ export async function updateChangelogs( projectRoot, prNumber, repoUrl, - packageNames, stdout, stderr, }: { projectRoot: string; prNumber?: string; repoUrl: string; - packageNames: Record; stdout: Pick; stderr: Pick; }, ): Promise { let updatedCount = 0; - for (const [packageName, packageInfo] of Object.entries(changes)) { + for (const [packageDirName, packageInfo] of Object.entries(changes)) { const packageChanges = packageInfo.dependencyChanges; const packageVersion = packageInfo.newVersion; - const packagePath = path.join(projectRoot, 'packages', packageName); + const packagePath = path.join(projectRoot, 'packages', packageDirName); const changelogPath = path.join(packagePath, 'CHANGELOG.md'); const changelogContent = await readChangelog(changelogPath); if (!changelogContent) { stderr.write( - `⚠️ No CHANGELOG.md found for ${packageName} at ${changelogPath}\n`, + `⚠️ No CHANGELOG.md found for ${packageDirName} at ${changelogPath}\n`, ); continue; } try { - // Get the actual package name from the provided map - const actualPackageName = packageNames[packageName] || packageName; + // Use the actual package name from packageInfo + const actualPackageName = packageInfo.packageName; // Parse the changelog using auto-changelog const changelog = parseChangelog({ @@ -302,7 +297,7 @@ export async function updateChangelogs( } if (entriesToAdd.length === 0 && entriesToUpdate.length === 0) { - stdout.write(`✅ ${packageName}: All entries already exist\n`); + stdout.write(`✅ ${packageDirName}: All entries already exist\n`); continue; } @@ -342,7 +337,7 @@ export async function updateChangelogs( // Re-parse to add new entries if needed if (entriesToAdd.length === 0) { stdout.write( - `✅ ${packageName}: Updated ${entriesToUpdate.length} existing ${entriesToUpdate.length === 1 ? 'entry' : 'entries'}\n`, + `✅ ${packageDirName}: Updated ${entriesToUpdate.length} existing ${entriesToUpdate.length === 1 ? 'entry' : 'entries'}\n`, ); updatedCount += 1; continue; @@ -387,7 +382,7 @@ export async function updateChangelogs( await writeFile(changelogPath, await updatedChangelog.toString()); stdout.write( - `✅ ${packageName}: Updated ${entriesToUpdate.length} and added ${entriesToAdd.length} changelog entries\n`, + `✅ ${packageDirName}: Updated ${entriesToUpdate.length} and added ${entriesToAdd.length} changelog entries\n`, ); } else { // Only adding new entries @@ -422,14 +417,14 @@ export async function updateChangelogs( await writeFile(changelogPath, updatedChangelogContent); stdout.write( - `✅ ${packageName}: Added ${entriesToAdd.length} changelog ${entriesToAdd.length === 1 ? 'entry' : 'entries'}\n`, + `✅ ${packageDirName}: Added ${entriesToAdd.length} changelog ${entriesToAdd.length === 1 ? 'entry' : 'entries'}\n`, ); } updatedCount += 1; } catch (error) { stderr.write( - `⚠️ Error updating CHANGELOG.md for ${packageName}: ${error}\n`, + `⚠️ Error updating CHANGELOG.md for ${packageDirName}: ${error}\n`, ); } } diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index d435c74..e400f7f 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -245,6 +245,7 @@ index 1234567..890abcd 100644 expect(result).toStrictEqual({ 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -264,7 +265,6 @@ index 1234567..890abcd 100644 expect.any(Object), '/path/to/project', 'https://github.com/MetaMask/core', - { 'controller-utils': '@metamask/controller-utils' }, ); }); @@ -331,7 +331,6 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ projectRoot: '/path/to/project', prNumber: '1234', repoUrl: 'https://github.com/MetaMask/core', - packageNames: { 'controller-utils': '@metamask/controller-utils' }, stdout, stderr, }), @@ -391,6 +390,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ expect(result).toStrictEqual({ 'controller-utils': { + packageName: '@metamask/controller-utils', dependencyChanges: [ { package: 'controller-utils', @@ -1972,7 +1972,6 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ expect.any(Object), '/path/to/project', 'https://github.com/MetaMask/core', - { 'controller-utils': '@metamask/controller-utils' }, ); }); }); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 202a2ff..3b3fa1f 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -58,9 +58,13 @@ async function getGitDiff( * Parses git diff output to extract dependency version changes and package version changes. * * @param diff - Raw git diff output. + * @param projectRoot - Project root directory for reading package names. * @returns Object mapping package names to their changes and version info. */ -function parseDiff(diff: string): PackageChanges { +async function parseDiff( + diff: string, + projectRoot: string, +): Promise { const lines = diff.split('\n'); const changes: PackageChanges = {}; @@ -159,7 +163,18 @@ function parseDiff(diff: string): PackageChanges { processedChanges.add(changeId); if (!changes[packageName]) { + // Read the actual package name from package.json + const manifestPath = path.join( + projectRoot, + 'packages', + packageName, + 'package.json', + ); + const { validated: packageManifest } = + await readPackageManifest(manifestPath); + const pkgInfo: PackageInfo = { + packageName: packageManifest.name, dependencyChanges: [], }; const packageNewVersion = packageVersionsMap.get(packageName); @@ -198,38 +213,6 @@ function parseDiff(diff: string): PackageChanges { return changes; } -/** - * Reads package names from package.json files for all packages with changes. - * - * @param changes - Package changes with version info keyed by directory name. - * @param projectRoot - The project root directory. - * @returns Map of directory names to actual package names. - * @throws If a package.json cannot be read or is invalid. - */ -async function getPackageNames( - changes: PackageChanges, - projectRoot: string, -): Promise> { - const packageNames: Record = {}; - - for (const packageDirName of Object.keys(changes)) { - const manifestPath = path.join( - projectRoot, - 'packages', - packageDirName, - 'package.json', - ); - - // We detected changes in this package.json via git diff, - // so it must exist and be readable. If it's not, something is wrong. - const { validated: packageManifest } = - await readPackageManifest(manifestPath); - packageNames[packageDirName] = packageManifest.name; - } - - return packageNames; -} - /** * Gets the merge base between current branch and the default branch. * @@ -339,26 +322,23 @@ export async function checkDependencyBumps({ return {}; } - const changes = parseDiff(diff); + const changes = await parseDiff(diff, projectRoot); if (Object.keys(changes).length === 0) { stdout.write('No dependency version bumps found.\n'); return {}; } - stdout.write('\n\n📊 JSON Output:\n'); - stdout.write('==============\n'); - stdout.write(JSON.stringify(changes, null, 2)); - stdout.write('\n'); - - // Get repository URL and package names for validation/fixing + // Get repository URL for validation/fixing const manifestPath = path.join(projectRoot, 'package.json'); const { unvalidated: packageManifest } = await readPackageManifest(manifestPath); const repoUrl = await getValidRepositoryUrl(packageManifest, projectRoot); - // Read package names once for all packages with changes - const packageNames = await getPackageNames(changes, projectRoot); + stdout.write('\n\n📊 JSON Output:\n'); + stdout.write('==============\n'); + stdout.write(JSON.stringify(changes, null, 2)); + stdout.write('\n'); // Always validate to provide feedback stdout.write('\n\n🔍 Validating changelogs...\n'); @@ -368,7 +348,6 @@ export async function checkDependencyBumps({ changes, projectRoot, repoUrl, - packageNames, ); let hasErrors = false; @@ -408,13 +387,11 @@ export async function checkDependencyBumps({ projectRoot: string; prNumber?: string; repoUrl: string; - packageNames: Record; stdout: Pick; stderr: Pick; } = { projectRoot, repoUrl, - packageNames, stdout, stderr, }; diff --git a/src/types.ts b/src/types.ts index 100da7d..44ba83e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,8 @@ export type DependencyChange = { * Information about a package with changes */ export type PackageInfo = { + /** Actual package name from package.json (e.g., '@metamask/controller-utils') */ + packageName: string; /** Dependency changes for this package */ dependencyChanges: DependencyChange[]; /** New version if package is being released */ From 084415e3dd9a7488b8180f51e17c4c39941b8f79 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 6 Nov 2025 17:35:53 +0100 Subject: [PATCH 03/17] refactor: use example repo name instead of core --- src/changelog-validator.test.ts | 114 ++++++++++++++--------------- src/check-dependency-bumps.test.ts | 52 +++++++------ 2 files changed, 85 insertions(+), 81 deletions(-) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index 87c0ac6..8fae1d0 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -41,7 +41,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(results).toStrictEqual([ @@ -63,7 +63,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(results).toStrictEqual([ @@ -92,7 +92,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(results).toStrictEqual([ @@ -121,7 +121,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(results).toStrictEqual([ @@ -147,7 +147,7 @@ describe('changelog-validator', () => { const parseChangelogSpy = jest.fn().mockReturnValue({ getUnreleasedChanges: () => ({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', ], }), }); @@ -156,7 +156,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(results).toStrictEqual([ @@ -190,7 +190,7 @@ describe('changelog-validator', () => { getUnreleasedChanges: () => ({ Changed: [] }), getReleaseChanges: jest.fn().mockReturnValue({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', ], }), }; @@ -206,7 +206,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( changesWithVersion, '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(mockChangelog.getReleaseChanges).toHaveBeenCalledWith('1.1.0'); @@ -233,7 +233,7 @@ describe('changelog-validator', () => { const mockChangelog = { getUnreleasedChanges: jest.fn().mockReturnValue({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', ], }), getReleaseChanges: jest.fn(), @@ -243,7 +243,7 @@ describe('changelog-validator', () => { const results = await validateChangelogs( mockChanges, // No newVersion in mockChanges '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); expect(mockChangelog.getUnreleasedChanges).toHaveBeenCalled(); @@ -261,7 +261,7 @@ describe('changelog-validator', () => { it('concatenates multiple existing PR numbers when updating entry', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5555](https://github.com/MetaMask/core/pull/5555))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5555](https://github.com/example-org/example-repo/pull/5555))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -278,7 +278,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', prNumber: '6789', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -301,7 +301,7 @@ describe('changelog-validator', () => { it('does not duplicate PR numbers when updating entry', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -318,7 +318,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', prNumber: '1234', // Same as existing - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -347,7 +347,7 @@ describe('changelog-validator', () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -364,7 +364,7 @@ describe('changelog-validator', () => { await updateChangelogs(peerDepChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -397,7 +397,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', // No prNumber provided - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -418,7 +418,7 @@ describe('changelog-validator', () => { it('uses placeholder when updating entry without prNumber', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -435,7 +435,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', // No prNumber provided - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -449,7 +449,7 @@ describe('changelog-validator', () => { it('preserves existing XXXXX placeholder when updating entry', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#XXXXX](https://github.com/MetaMask/core/pull/XXXXX))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#XXXXX](https://github.com/example-org/example-repo/pull/XXXXX))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -466,7 +466,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', prNumber: '1234', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -486,7 +486,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -509,14 +509,14 @@ describe('changelog-validator', () => { (parseChangelog as jest.Mock).mockReturnValue({ getUnreleasedChanges: () => ({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', ], }), }); const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -548,7 +548,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -580,7 +580,7 @@ describe('changelog-validator', () => { const stdoutWriteSpy = jest.spyOn(stdout, 'write'); const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -597,7 +597,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -646,9 +646,9 @@ describe('changelog-validator', () => { }; const existingEntry1 = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; const existingEntry2 = - 'Bump `@metamask/network-controller` from `^5.0.0` to `^5.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/network-controller` from `^5.0.0` to `^5.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; const stdoutWriteSpy = jest.spyOn(stdout, 'write'); jest.spyOn(fsModule, 'writeFile'); @@ -672,7 +672,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(multipleExistingChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -717,7 +717,7 @@ describe('changelog-validator', () => { await updateChangelogs(peerDepChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -775,7 +775,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedTypeChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -835,7 +835,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; const stdoutWriteSpy = jest.spyOn(stdout, 'write'); const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); @@ -851,7 +851,7 @@ describe('changelog-validator', () => { `# Changelog\n## [Unreleased]\n- ${existingEntry}`, ) .mockResolvedValueOnce( - `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))`, + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))`, ); jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); @@ -865,7 +865,7 @@ describe('changelog-validator', () => { const mockChangelog2 = { getUnreleasedChanges: () => ({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', ], }), addChange: jest.fn(), @@ -879,7 +879,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mixedChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -938,7 +938,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; const stdoutWriteSpy = jest.spyOn(stdout, 'write'); jest.spyOn(fsModule, 'writeFile'); @@ -973,7 +973,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1018,7 +1018,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; jest.spyOn(fsModule, 'writeFile'); @@ -1052,7 +1052,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1092,7 +1092,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); @@ -1106,7 +1106,7 @@ describe('changelog-validator', () => { `# Changelog\n## [Unreleased]\n- ${existingEntry}`, ) .mockResolvedValueOnce( - `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))`, + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))`, ); jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); @@ -1118,7 +1118,7 @@ describe('changelog-validator', () => { const mockChangelog2 = { getUnreleasedChanges: () => ({ Changed: [ - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/MetaMask/core/pull/1234), [#5678](https://github.com/MetaMask/core/pull/5678))', + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', ], }), addChange: jest.fn(), @@ -1132,7 +1132,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1177,7 +1177,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1229,7 +1229,7 @@ describe('changelog-validator', () => { await updateChangelogs(multipleChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1254,7 +1254,7 @@ describe('changelog-validator', () => { const count = await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1268,7 +1268,7 @@ describe('changelog-validator', () => { it('updates entries in release section when package version is provided', async () => { const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; when(jest.spyOn(fsModule, 'fileExists')) .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') @@ -1296,7 +1296,7 @@ describe('changelog-validator', () => { await updateChangelogs(changesWithVersion, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1338,7 +1338,7 @@ describe('changelog-validator', () => { await updateChangelogs(changesWithVersion, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1375,7 +1375,7 @@ describe('changelog-validator', () => { await updateChangelogs(mockChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1426,7 +1426,7 @@ describe('changelog-validator', () => { await updateChangelogs(peerDepChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1476,7 +1476,7 @@ describe('changelog-validator', () => { await updateChangelogs(peerDepChanges, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1513,7 +1513,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; jest.spyOn(fsModule, 'writeFile'); @@ -1554,7 +1554,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedChangesWithVersion, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); @@ -1591,7 +1591,7 @@ describe('changelog-validator', () => { }; const existingEntry = - 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/MetaMask/core/pull/1234))'; + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; jest.spyOn(fsModule, 'writeFile'); @@ -1632,7 +1632,7 @@ describe('changelog-validator', () => { await updateChangelogs(mixedChangesWithVersion, { projectRoot: '/path/to/project', prNumber: '5678', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }); diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index e400f7f..ad6fbbe 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -207,7 +207,9 @@ index 1234567..890abcd 100644 when(jest.spyOn(packageManifestModule, 'readPackageManifest')) .calledWith('/path/to/project/package.json') .mockResolvedValue({ - unvalidated: { repository: 'https://github.com/MetaMask/core' }, + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, validated: buildMockManifest(), }); @@ -222,7 +224,7 @@ index 1234567..890abcd 100644 jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -264,7 +266,7 @@ index 1234567..890abcd 100644 expect(changelogValidatorModule.validateChangelogs).toHaveBeenCalledWith( expect.any(Object), '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); }); @@ -291,7 +293,9 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ when(jest.spyOn(packageManifestModule, 'readPackageManifest')) .calledWith('/path/to/project/package.json') .mockResolvedValue({ - unvalidated: { repository: 'https://github.com/MetaMask/core' }, + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, validated: buildMockManifest(), }); @@ -306,7 +310,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -330,7 +334,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ expect.objectContaining({ projectRoot: '/path/to/project', prNumber: '1234', - repoUrl: 'https://github.com/MetaMask/core', + repoUrl: 'https://github.com/example-org/example-repo', stdout, stderr, }), @@ -375,7 +379,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -593,7 +597,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -669,7 +673,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -736,7 +740,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -816,7 +820,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -904,7 +908,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -969,7 +973,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1048,7 +1052,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1111,7 +1115,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1173,7 +1177,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1277,7 +1281,7 @@ index 1234567..890abcd 100644 jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1376,7 +1380,7 @@ index abc123..def456 100644 jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1564,7 +1568,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1623,7 +1627,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1756,7 +1760,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1824,7 +1828,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1950,7 +1954,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ jest .spyOn(projectModule, 'getValidRepositoryUrl') - .mockResolvedValue('https://github.com/MetaMask/core'); + .mockResolvedValue('https://github.com/example-org/example-repo'); jest .spyOn(changelogValidatorModule, 'validateChangelogs') @@ -1971,7 +1975,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ expect(changelogValidatorModule.validateChangelogs).toHaveBeenCalledWith( expect.any(Object), '/path/to/project', - 'https://github.com/MetaMask/core', + 'https://github.com/example-org/example-repo', ); }); }); From 2a87c2eb23546bce2e19732241749d54e5da171b Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 6 Nov 2025 18:01:51 +0100 Subject: [PATCH 04/17] fix: remove useless re-exported types --- src/check-dependency-bumps.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 3b3fa1f..83a925f 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -13,10 +13,7 @@ import { getCurrentBranchName } from './repo.js'; import { getStdoutFromCommand } from './misc-utils.js'; import { getValidRepositoryUrl } from './project.js'; import { readPackageManifest } from './package-manifest.js'; -import type { DependencyChange, PackageInfo, PackageChanges } from './types.js'; - -// Re-export types for convenience -export type { DependencyChange, PackageInfo, PackageChanges }; +import type { PackageInfo, PackageChanges } from './types.js'; /** * Retrieves the git diff between two references for package.json files. From 88d116af20304006a8a1b8e390e55a90af48797c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 12 Nov 2025 11:15:51 +0100 Subject: [PATCH 05/17] docs: add changelog entry for check-deps command --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224b5fc..65fcd4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `check-deps` command to validate and update dependency bump changelog entries ([#186](https://github.com/MetaMask/create-release-branch/pull/186)) + - Automatically detects dependency version changes from git diffs + - Validates changelog entries with exact version matching (catches stale entries) + - Auto-updates changelogs with `--fix` flag, preserving PR history + - Detects package releases and validates/updates in correct changelog section (Unreleased vs specific version) + - Smart PR concatenation when same dependency bumped multiple times + - Usage: `yarn check-dependency-bumps --fix --pr ` + ## [4.1.3] ### Fixed From 044b46e98787e8bf04c6bf67229f20fd78200b1c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 19:07:47 +0100 Subject: [PATCH 06/17] fix: correct changelog entry order - BREAKING first, then deps BREAKING entries (peerDependencies) now appear before regular dependencies, both alphabetically ordered in final changelog output. --- src/changelog-validator.test.ts | 16 ++++++------ src/changelog-validator.ts | 44 +++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index 8fae1d0..ed2ac9a 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -780,33 +780,33 @@ describe('changelog-validator', () => { stderr, }); - // Verify peerDependencies (BREAKING) is added first + // addChange prepends entries, so deps are added first (to appear after BREAKING) + // Then peerDeps are added (to appear first in final output) expect(mockChangelog.addChange).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - description: expect.stringContaining('**BREAKING:**'), + description: expect.not.stringContaining('**BREAKING:**'), }), ); expect(mockChangelog.addChange).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - description: expect.stringContaining( - '@metamask/transaction-controller', - ), + description: expect.stringContaining('@metamask/network-controller'), }), ); - // Verify dependencies is added second expect(mockChangelog.addChange).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - description: expect.not.stringContaining('**BREAKING:**'), + description: expect.stringContaining('**BREAKING:**'), }), ); expect(mockChangelog.addChange).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - description: expect.stringContaining('@metamask/network-controller'), + description: expect.stringContaining( + '@metamask/transaction-controller', + ), }), ); }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index 4dd25c8..b62da3e 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -352,15 +352,17 @@ export async function updateChangelogs( formatter: formatChangelog, }); - // Group new entries by type (peerDependencies first, then dependencies) + // Group new entries by type (dependencies first, then peerDependencies) + const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); const peerDeps = entriesToAdd.filter( (c) => c.type === 'peerDependencies', ); - const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); - // Add peerDependencies first (BREAKING changes) - for (const change of peerDeps) { - const description = formatChangelogEntry(change, prNumber, repoUrl); + // addChange prepends entries, so we iterate in reverse to maintain + // alphabetical order in the final changelog output. + // Add dependencies first (they'll appear after BREAKING in final output) + for (let i = deps.length - 1; i >= 0; i--) { + const description = formatChangelogEntry(deps[i], prNumber, repoUrl); updatedChangelog.addChange({ category: 'Changed' as any, description, @@ -368,9 +370,13 @@ export async function updateChangelogs( }); } - // Then add dependencies - for (const change of deps) { - const description = formatChangelogEntry(change, prNumber, repoUrl); + // Then add peerDependencies (BREAKING - they'll appear first in final output) + for (let i = peerDeps.length - 1; i >= 0; i--) { + const description = formatChangelogEntry( + peerDeps[i], + prNumber, + repoUrl, + ); updatedChangelog.addChange({ category: 'Changed' as any, description, @@ -386,15 +392,17 @@ export async function updateChangelogs( ); } else { // Only adding new entries - // Group entries by type (peerDependencies first, then dependencies) + // Group entries by type (dependencies first, then peerDependencies) + const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); const peerDeps = entriesToAdd.filter( (c) => c.type === 'peerDependencies', ); - const deps = entriesToAdd.filter((c) => c.type === 'dependencies'); - // Add peerDependencies first (BREAKING changes) - for (const change of peerDeps) { - const description = formatChangelogEntry(change, prNumber, repoUrl); + // addChange prepends entries, so we iterate in reverse to maintain + // alphabetical order in the final changelog output. + // Add dependencies first (they'll appear after BREAKING in final output) + for (let i = deps.length - 1; i >= 0; i--) { + const description = formatChangelogEntry(deps[i], prNumber, repoUrl); changelog.addChange({ category: 'Changed' as any, description, @@ -402,9 +410,13 @@ export async function updateChangelogs( }); } - // Then add dependencies - for (const change of deps) { - const description = formatChangelogEntry(change, prNumber, repoUrl); + // Then add peerDependencies (BREAKING - they'll appear first in final output) + for (let i = peerDeps.length - 1; i >= 0; i--) { + const description = formatChangelogEntry( + peerDeps[i], + prNumber, + repoUrl, + ); changelog.addChange({ category: 'Changed' as any, description, From ccc66e214a4d8b0a945cbdcb7056fbcbb803bc0e Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 19:27:08 +0100 Subject: [PATCH 07/17] fix: distinguish BREAKING entries when matching changelog entries hasChangelogEntry now checks for **BREAKING:** prefix when matching peerDependencies entries, preventing same dependency in both sections from matching the wrong entry. This fixes the bug where updating both entries would fail because both matched the first entry found. --- src/changelog-validator.test.ts | 157 ++++++++++++++++++++++++++++++++ src/changelog-validator.ts | 36 ++++++-- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index ed2ac9a..df854eb 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -252,6 +252,71 @@ describe('changelog-validator', () => { '@metamask/transaction-controller', ); }); + + it('correctly distinguishes same dependency in dependencies vs peerDependencies', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + // Changelog has both BREAKING and non-BREAKING entries for same dependency + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + // Same dependency in both dependencies and peerDependencies + const changesWithSameDep = { + 'controller-utils': { + packageName: '@metamask/controller-utils', + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + const results = await validateChangelogs( + changesWithSameDep, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + // Both entries should be found (one for deps, one for peerDeps) + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: [ + '@metamask/transaction-controller', + '@metamask/transaction-controller', + ], + }, + ]); + }); }); describe('updateChangelogs', () => { @@ -1649,5 +1714,97 @@ describe('changelog-validator', () => { version: '1.1.0', }); }); + + it('updates both entries when same dependency exists in dependencies and peerDependencies', async () => { + const sameDepInBothSections = { + 'controller-utils': { + packageName: '@metamask/controller-utils', + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'peerDependencies' as const, + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }; + + const existingDepEntry = + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; + const existingPeerDepEntry = + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^61.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; + + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingDepEntry}\n- ${existingPeerDepEntry}`, + ) + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))\n- **BREAKING:** Bump \`@metamask/transaction-controller\` from \`^61.0.0\` to \`^62.0.0\` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))`, + ); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ + Changed: [existingDepEntry, existingPeerDepEntry], + }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + ], + }), + toString: jest.fn().mockResolvedValue('Final updated changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + await updateChangelogs(sameDepInBothSections, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/example-org/example-repo', + stdout, + stderr, + }); + + // Should write once with both entries updated + expect(writeFileSpy).toHaveBeenCalledTimes(1); + const writeCall = writeFileSpy.mock.calls[0]; + expect(writeCall[0]).toBe( + '/path/to/project/packages/controller-utils/CHANGELOG.md', + ); + + // Verify both entries were updated correctly + // (non-BREAKING dependency entry and BREAKING peerDependency entry) + // If hasChangelogEntry didn't distinguish them, one would fail to update + const writtenContent = writeCall[1]; + expect(writtenContent).toContain( + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + ); + expect(writtenContent).toContain( + '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + ); + }); }); }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index b62da3e..77a7c31 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -79,15 +79,28 @@ function hasChangelogEntry( '\\$&', ); + // For peerDependencies, require **BREAKING:** prefix + // For dependencies, explicitly exclude **BREAKING:** prefix + const breakingPrefix = + change.type === 'peerDependencies' ? '\\*\\*BREAKING:\\*\\* ' : ''; + const isBreaking = change.type === 'peerDependencies'; + // Look for exact version match: dependency from oldVersion to newVersion const exactPattern = new RegExp( - `Bump \`${escapedDep}\` from \`${escapedOldVer}\` to \`${escapedNewVer}\``, + `${breakingPrefix}Bump \`${escapedDep}\` from \`${escapedOldVer}\` to \`${escapedNewVer}\``, 'u', ); - const exactIndex = changedEntries.findIndex((entry) => - exactPattern.test(entry), - ); + const exactIndex = changedEntries.findIndex((entry) => { + const matchesPattern = exactPattern.test(entry); + + // For dependencies, also ensure it doesn't have BREAKING prefix + if (!isBreaking) { + return matchesPattern && !entry.startsWith('**BREAKING:**'); + } + + return matchesPattern; + }); if (exactIndex !== -1) { return { @@ -100,13 +113,20 @@ function hasChangelogEntry( // Check if there's an entry for this dependency with different versions // Use \x60 (backtick) to avoid template literal issues const anyVersionPattern = new RegExp( - `Bump \x60${escapedDep}\x60 from \x60[^\x60]+\x60 to \x60[^\x60]+\x60`, + `${breakingPrefix}Bump \x60${escapedDep}\x60 from \x60[^\x60]+\x60 to \x60[^\x60]+\x60`, 'u', ); - const anyIndex = changedEntries.findIndex((entry) => - anyVersionPattern.test(entry), - ); + const anyIndex = changedEntries.findIndex((entry) => { + const matchesPattern = anyVersionPattern.test(entry); + + // For dependencies, also ensure it doesn't have BREAKING prefix + if (!isBreaking) { + return matchesPattern && !entry.startsWith('**BREAKING:**'); + } + + return matchesPattern; + }); if (anyIndex !== -1) { return { From 48221f2118194a6051758ff80def286fef85a5d1 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 20:07:22 +0100 Subject: [PATCH 08/17] fix: show correct section name in changelog validation error When validating changelogs for a release version, the error message now correctly shows the version section (e.g., [1.2.3]) instead of always showing [Unreleased]. --- src/changelog-validator.test.ts | 50 ++++++++++++++++++ src/changelog-validator.ts | 5 ++ src/check-dependency-bumps.test.ts | 84 ++++++++++++++++++++++++++++++ src/check-dependency-bumps.ts | 5 +- 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index df854eb..8674d4d 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -51,6 +51,7 @@ describe('changelog-validator', () => { hasUnreleasedSection: false, missingEntries: mockChanges['controller-utils'].dependencyChanges, existingEntries: [], + checkedVersion: null, }, ]); }); @@ -73,6 +74,7 @@ describe('changelog-validator', () => { hasUnreleasedSection: false, missingEntries: mockChanges['controller-utils'].dependencyChanges, existingEntries: [], + checkedVersion: null, }, ]); }); @@ -102,6 +104,7 @@ describe('changelog-validator', () => { hasUnreleasedSection: false, missingEntries: mockChanges['controller-utils'].dependencyChanges, existingEntries: [], + checkedVersion: null, }, ]); }); @@ -131,6 +134,7 @@ describe('changelog-validator', () => { hasUnreleasedSection: true, missingEntries: mockChanges['controller-utils'].dependencyChanges, existingEntries: [], + checkedVersion: null, }, ]); }); @@ -166,6 +170,7 @@ describe('changelog-validator', () => { hasUnreleasedSection: true, missingEntries: [], existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, }, ]); @@ -217,6 +222,50 @@ describe('changelog-validator', () => { hasUnreleasedSection: true, missingEntries: [], existingEntries: ['@metamask/transaction-controller'], + checkedVersion: '1.1.0', + }, + ]); + }); + + it('catches error when release version section does not exist', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ Changed: [] }), + getReleaseChanges: jest.fn().mockImplementation(() => { + throw new Error('Version not found'); + }), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const changesWithVersion = { + 'controller-utils': { + ...mockChanges['controller-utils'], + newVersion: '1.2.3', + }, + }; + + const results = await validateChangelogs( + changesWithVersion, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + expect(mockChangelog.getReleaseChanges).toHaveBeenCalledWith('1.2.3'); + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: mockChanges['controller-utils'].dependencyChanges, + existingEntries: [], + checkedVersion: '1.2.3', }, ]); }); @@ -314,6 +363,7 @@ describe('changelog-validator', () => { '@metamask/transaction-controller', '@metamask/transaction-controller', ], + checkedVersion: null, }, ]); }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index 77a7c31..1075609 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -18,6 +18,8 @@ type ChangelogValidationResult = { hasUnreleasedSection: boolean; missingEntries: DependencyChange[]; existingEntries: string[]; + /** Version that was checked (null for [Unreleased] section) */ + checkedVersion?: string | null; }; /** @@ -169,6 +171,7 @@ export async function validateChangelogs( hasUnreleasedSection: false, missingEntries: packageChanges, existingEntries: [], + checkedVersion: packageVersion ?? null, }); continue; } @@ -213,6 +216,7 @@ export async function validateChangelogs( hasUnreleasedSection, missingEntries, existingEntries, + checkedVersion: packageVersion ?? null, }); } catch (error) { // If parsing fails, assume changelog is malformed @@ -222,6 +226,7 @@ export async function validateChangelogs( hasUnreleasedSection: false, missingEntries: packageChanges, existingEntries: [], + checkedVersion: packageVersion ?? null, }); } } diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index ad6fbbe..5860277 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -235,6 +235,7 @@ index 1234567..890abcd 100644 hasUnreleasedSection: true, missingEntries: [], existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, }, ]); @@ -616,6 +617,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ }, ], existingEntries: [], + checkedVersion: null, }, ]); @@ -684,6 +686,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ hasUnreleasedSection: false, missingEntries: [], existingEntries: [], + checkedVersion: null, }, ]); @@ -701,6 +704,83 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ ); }); + it('reports correct section name when checking release version', async () => { + const stderrWriteSpy = jest.spyOn(stderr, 'write'); + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithVersion = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -1,6 +1,6 @@ + { + "name": "@metamask/controller-utils", +- "version": "1.2.2", ++ "version": "1.2.3", + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithVersion); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/example-org/example-repo'); + + const validateChangelogsSpy = jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: false, + missingEntries: [], + existingEntries: [], + checkedVersion: '1.2.3', + }, + ]); + + await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(validateChangelogsSpy).toHaveBeenCalled(); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining( + '❌ controller-utils: No [1.2.3] section found', + ), + ); + }); + it('reports validation errors for missing changelog entries', async () => { const stderrWriteSpy = jest.spyOn(stderr, 'write'); const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); @@ -759,6 +839,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ }, ], existingEntries: [], + checkedVersion: null, }, ]); @@ -846,6 +927,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ }, ], existingEntries: [], + checkedVersion: null, }, ]); @@ -919,6 +1001,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ hasUnreleasedSection: true, missingEntries: [], existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, }, ]); @@ -992,6 +1075,7 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ }, ], existingEntries: [], + checkedVersion: null, }, ]); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 83a925f..e163529 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -354,7 +354,10 @@ export async function checkDependencyBumps({ stderr.write(`❌ ${result.package}: CHANGELOG.md not found\n`); hasErrors = true; } else if (!result.hasUnreleasedSection) { - stderr.write(`❌ ${result.package}: No [Unreleased] section found\n`); + const sectionName = result.checkedVersion + ? `[${result.checkedVersion}]` + : '[Unreleased]'; + stderr.write(`❌ ${result.package}: No ${sectionName} section found\n`); hasErrors = true; } else if (result.missingEntries.length > 0) { stderr.write( From 045331a368d4bd0eea507f06c80f397229f2ba94 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 20:30:34 +0100 Subject: [PATCH 09/17] feat: support renamed packages in changelog validation Automatically detect package rename info from package.json scripts and pass it to parseChangelog to correctly handle changelogs with old package name tags. --- src/changelog-validator.test.ts | 389 ++++++++++++++++++++++++++++++++ src/changelog-validator.ts | 59 +++++ 2 files changed, 448 insertions(+) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index 8674d4d..85335ee 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -1,12 +1,15 @@ import fs from 'fs'; import { when } from 'jest-when'; import { parseChangelog } from '@metamask/auto-changelog'; +import { buildMockManifest } from '../tests/unit/helpers.js'; import { validateChangelogs, updateChangelogs } from './changelog-validator.js'; import * as fsModule from './fs.js'; import * as packageModule from './package.js'; +import * as packageManifestModule from './package-manifest.js'; jest.mock('./fs'); jest.mock('./package'); +jest.mock('./package-manifest'); jest.mock('@metamask/auto-changelog'); describe('changelog-validator', () => { @@ -367,6 +370,293 @@ describe('changelog-validator', () => { }, ]); }); + + it('handles renamed packages by reading rename info from package.json scripts', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValue(true) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValue('# Changelog\n## [Unreleased]') + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue( + JSON.stringify({ + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }), + ); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue({ + unvalidated: { + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }, + validated: buildMockManifest({ + name: '@metamask/json-rpc-middleware-stream', + }), + }); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.1.2` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const renamedPackageChanges = { + 'json-rpc-middleware-stream': { + packageName: '@metamask/json-rpc-middleware-stream', + dependencyChanges: [ + { + package: 'json-rpc-middleware-stream', + dependency: '@metamask/json-rpc-engine', + type: 'dependencies' as const, + oldVersion: '^10.1.1', + newVersion: '^10.1.2', + }, + ], + }, + }; + + const results = await validateChangelogs( + renamedPackageChanges, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + // Verify parseChangelog was called with packageRename info + expect(parseChangelog).toHaveBeenCalledWith({ + changelogContent: '# Changelog\n## [Unreleased]', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/json-rpc-middleware-stream@', + formatter: expect.any(Function), + packageRename: { + tagPrefixBeforeRename: 'json-rpc-middleware-stream@', + versionBeforeRename: '5.0.1', + }, + }); + + expect(results).toStrictEqual([ + { + package: 'json-rpc-middleware-stream', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/json-rpc-engine'], + checkedVersion: null, + }, + ]); + }); + + it('works without package rename info when scripts do not contain rename flags', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]') + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue( + JSON.stringify({ + name: '@metamask/controller-utils', + scripts: { + test: 'jest', + }, + }), + ); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: { + name: '@metamask/controller-utils', + scripts: { + test: 'jest', + }, + }, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + // Verify parseChangelog was called without packageRename + expect(parseChangelog).toHaveBeenCalledWith({ + changelogContent: '# Changelog\n## [Unreleased]', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/controller-utils@', + formatter: expect.any(Function), + }); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, + }, + ]); + }); + + it('handles package.json without scripts field', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]') + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue( + JSON.stringify({ + name: '@metamask/controller-utils', + }), + ); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: { + name: '@metamask/controller-utils', + }, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + // Verify parseChangelog was called without packageRename + expect(parseChangelog).toHaveBeenCalledWith({ + changelogContent: '# Changelog\n## [Unreleased]', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/controller-utils@', + formatter: expect.any(Function), + }); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, + }, + ]); + }); + + it('handles errors when reading package.json gracefully', async () => { + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue(true) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue(true); + when(jest.spyOn(fsModule, 'readFile')) + .calledWith('/path/to/project/packages/controller-utils/CHANGELOG.md') + .mockResolvedValue('# Changelog\n## [Unreleased]'); + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + // Mock readPackageManifest to throw an error + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockRejectedValue(new Error('Failed to read package.json')); + + const mockChangelog = { + getUnreleasedChanges: jest.fn().mockReturnValue({ + Changed: [ + 'Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))', + ], + }), + getReleaseChanges: jest.fn(), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + const results = await validateChangelogs( + mockChanges, + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + + // Verify parseChangelog was called without packageRename (error handled gracefully) + expect(parseChangelog).toHaveBeenCalledWith({ + changelogContent: '# Changelog\n## [Unreleased]', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/controller-utils@', + formatter: expect.any(Function), + }); + + expect(results).toStrictEqual([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['@metamask/transaction-controller'], + checkedVersion: null, + }, + ]); + }); }); describe('updateChangelogs', () => { @@ -1856,5 +2146,104 @@ describe('changelog-validator', () => { '**BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', ); }); + + it('handles renamed packages when updating changelogs', async () => { + const renamedPackageChanges = { + 'json-rpc-middleware-stream': { + packageName: '@metamask/json-rpc-middleware-stream', + dependencyChanges: [ + { + package: 'json-rpc-middleware-stream', + dependency: '@metamask/json-rpc-engine', + type: 'dependencies' as const, + oldVersion: '^10.1.1', + newVersion: '^10.1.2', + }, + ], + }, + }; + + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValue(true) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValue('# Changelog\n## [Unreleased]') + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue( + JSON.stringify({ + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }), + ); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue({ + unvalidated: { + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }, + validated: buildMockManifest({ + name: '@metamask/json-rpc-middleware-stream', + }), + }); + + const mockChangelog = { + getUnreleasedChanges: () => ({ Changed: [] }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Updated changelog content'), + }; + (parseChangelog as jest.Mock).mockReturnValue(mockChangelog); + + await updateChangelogs(renamedPackageChanges, { + projectRoot: '/path/to/project', + repoUrl: 'https://github.com/example-org/example-repo', + stdout, + stderr, + }); + + // Verify parseChangelog was called with packageRename info + expect(parseChangelog).toHaveBeenCalledWith({ + changelogContent: '# Changelog\n## [Unreleased]', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/json-rpc-middleware-stream@', + formatter: expect.any(Function), + packageRename: { + tagPrefixBeforeRename: 'json-rpc-middleware-stream@', + versionBeforeRename: '5.0.1', + }, + }); + + // Verify changelog was updated + expect(writeFileSpy).toHaveBeenCalledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + 'Updated changelog content', + ); + expect(mockChangelog.addChange).toHaveBeenCalled(); + }); }); }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index 1075609..4215964 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -10,6 +10,7 @@ import type { WriteStream } from 'fs'; import { parseChangelog } from '@metamask/auto-changelog'; import { readFile, writeFile, fileExists } from './fs.js'; import { formatChangelog } from './package.js'; +import { readPackageManifest } from './package-manifest.js'; import type { DependencyChange, PackageChanges } from './types.js'; type ChangelogValidationResult = { @@ -57,6 +58,56 @@ async function readChangelog(changelogPath: string): Promise { return await readFile(changelogPath); } +/** + * Extracts package rename information from package.json scripts. + * Looks for --tag-prefix-before-package-rename and --version-before-package-rename flags. + * + * @param packagePath - Path to the package directory. + * @returns Package rename info if found, undefined otherwise. + */ +async function getPackageRenameInfo( + packagePath: string, +): Promise< + { versionBeforeRename: string; tagPrefixBeforeRename: string } | undefined +> { + const packageJsonPath = path.join(packagePath, 'package.json'); + + if (!(await fileExists(packageJsonPath))) { + return undefined; + } + + try { + const { unvalidated } = await readPackageManifest(packageJsonPath); + const scripts = unvalidated.scripts as Record | undefined; + + if (!scripts) { + return undefined; + } + + // Look for the flags in any script + for (const script of Object.values(scripts)) { + const tagPrefixMatch = script.match( + /--tag-prefix-before-package-rename\s+(\S+)/u, + ); + const versionMatch = script.match( + /--version-before-package-rename\s+(\S+)/u, + ); + + if (tagPrefixMatch && versionMatch) { + return { + tagPrefixBeforeRename: tagPrefixMatch[1], + versionBeforeRename: versionMatch[1], + }; + } + } + } catch { + // If reading fails, return undefined + return undefined; + } + + return undefined; +} + /** * Checks if a changelog entry exists for a dependency change with matching versions. * @@ -180,12 +231,16 @@ export async function validateChangelogs( // Use the actual package name from packageInfo const actualPackageName = packageInfo.packageName; + // Check for package rename info in package.json scripts + const packageRename = await getPackageRenameInfo(packagePath); + // Parse the changelog using auto-changelog const changelog = parseChangelog({ changelogContent, repoUrl, tagPrefix: `${actualPackageName}@`, formatter: formatChangelog, + ...(packageRename && { packageRename }), }); // Check if package is being released (has version change) @@ -283,12 +338,16 @@ export async function updateChangelogs( // Use the actual package name from packageInfo const actualPackageName = packageInfo.packageName; + // Check for package rename info in package.json scripts + const packageRename = await getPackageRenameInfo(packagePath); + // Parse the changelog using auto-changelog const changelog = parseChangelog({ changelogContent, repoUrl, tagPrefix: `${actualPackageName}@`, formatter: formatChangelog, + ...(packageRename && { packageRename }), }); // Check if package is being released (has version change) From 8af5181104a9980dc1a0318fe022797657217d95 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 20:47:53 +0100 Subject: [PATCH 10/17] fix: include packageRename in second parseChangelog call When updating existing entries and adding new ones for renamed packages, the second parseChangelog call was missing the packageRename parameter. This ensures both calls include packageRename for consistency. --- src/changelog-validator.test.ts | 139 ++++++++++++++++++++++++++++++++ src/changelog-validator.ts | 1 + 2 files changed, 140 insertions(+) diff --git a/src/changelog-validator.test.ts b/src/changelog-validator.test.ts index 85335ee..23f0abc 100644 --- a/src/changelog-validator.test.ts +++ b/src/changelog-validator.test.ts @@ -2245,5 +2245,144 @@ describe('changelog-validator', () => { ); expect(mockChangelog.addChange).toHaveBeenCalled(); }); + + it('handles renamed packages when updating existing entries and adding new ones', async () => { + const renamedPackageChanges = { + 'json-rpc-middleware-stream': { + packageName: '@metamask/json-rpc-middleware-stream', + dependencyChanges: [ + { + package: 'json-rpc-middleware-stream', + dependency: '@metamask/json-rpc-engine', + type: 'dependencies' as const, + oldVersion: '^10.1.1', + newVersion: '^10.1.2', + }, + { + package: 'json-rpc-middleware-stream', + dependency: '@metamask/base-controller', + type: 'dependencies' as const, + oldVersion: '^9.0.0', + newVersion: '^9.1.0', + }, + ], + }, + }; + + const existingEntry = + 'Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.1.0` ([#1234](https://github.com/example-org/example-repo/pull/1234))'; + + const writeFileSpy = jest.spyOn(fsModule, 'writeFile'); + + when(jest.spyOn(fsModule, 'fileExists')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValue(true) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue(true); + + when(jest.spyOn(fsModule, 'readFile')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/CHANGELOG.md', + ) + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + ) + .mockResolvedValueOnce( + `# Changelog\n## [Unreleased]\n- Bump \`@metamask/json-rpc-engine\` from \`^10.1.1\` to \`^10.1.2\` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))`, + ) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue( + JSON.stringify({ + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }), + ); + + jest.spyOn(packageModule, 'formatChangelog').mockResolvedValue(''); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith( + '/path/to/project/packages/json-rpc-middleware-stream/package.json', + ) + .mockResolvedValue({ + unvalidated: { + name: '@metamask/json-rpc-middleware-stream', + scripts: { + 'changelog:update': + '../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1', + }, + }, + validated: buildMockManifest({ + name: '@metamask/json-rpc-middleware-stream', + }), + }); + + const mockChangelog1 = { + getUnreleasedChanges: () => ({ Changed: [existingEntry] }), + }; + + const mockChangelog2 = { + getUnreleasedChanges: () => ({ + Changed: [ + 'Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.1.2` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + ], + }), + addChange: jest.fn(), + toString: jest.fn().mockResolvedValue('Final updated changelog'), + }; + + (parseChangelog as jest.Mock) + .mockReturnValueOnce(mockChangelog1) + .mockReturnValueOnce(mockChangelog2); + + await updateChangelogs(renamedPackageChanges, { + projectRoot: '/path/to/project', + prNumber: '5678', + repoUrl: 'https://github.com/example-org/example-repo', + stdout, + stderr, + }); + + const packageRename = { + tagPrefixBeforeRename: 'json-rpc-middleware-stream@', + versionBeforeRename: '5.0.1', + }; + + // Verify both parseChangelog calls include packageRename + expect(parseChangelog).toHaveBeenCalledTimes(2); + expect(parseChangelog).toHaveBeenNthCalledWith(1, { + changelogContent: `# Changelog\n## [Unreleased]\n- ${existingEntry}`, + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/json-rpc-middleware-stream@', + formatter: expect.any(Function), + packageRename, + }); + expect(parseChangelog).toHaveBeenNthCalledWith(2, { + changelogContent: + '# Changelog\n## [Unreleased]\n- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.1.2` ([#1234](https://github.com/example-org/example-repo/pull/1234), [#5678](https://github.com/example-org/example-repo/pull/5678))', + repoUrl: 'https://github.com/example-org/example-repo', + tagPrefix: '@metamask/json-rpc-middleware-stream@', + formatter: expect.any(Function), + packageRename, + }); + + // Verify new entry was added + expect(mockChangelog2.addChange).toHaveBeenCalledWith({ + category: 'Changed', + description: expect.stringContaining('@metamask/base-controller'), + }); + + // Verify writeFile was called (once for update, once for final) + expect(writeFileSpy).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/changelog-validator.ts b/src/changelog-validator.ts index 4215964..27f5560 100644 --- a/src/changelog-validator.ts +++ b/src/changelog-validator.ts @@ -434,6 +434,7 @@ export async function updateChangelogs( repoUrl, tagPrefix: `${actualPackageName}@`, formatter: formatChangelog, + ...(packageRename && { packageRename }), }); // Group new entries by type (dependencies first, then peerDependencies) From 19c3ccd45b03957bbb7dc2e95d19c0a364924b94 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 2 Dec 2025 23:03:09 +0100 Subject: [PATCH 11/17] fix: detect non-scoped package dependency changes Remove restrictive '@' filter that was silently ignoring non-scoped packages like lodash, react, and typescript. The regex pattern already handles both scoped and non-scoped packages correctly. --- src/check-dependency-bumps.test.ts | 99 ++++++++++++++++++++++++++++++ src/check-dependency-bumps.ts | 4 +- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index 5860277..c79fa75 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -271,6 +271,105 @@ index 1234567..890abcd 100644 ); }); + it('detects non-scoped package dependency changes', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + const diffWithNonScopedDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + }, + "dependencies": { +- "lodash": "^4.17.20" ++ "lodash": "^4.17.21" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithNonScopedDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/example-org/example-repo'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([ + { + package: 'controller-utils', + hasChangelog: true, + hasUnreleasedSection: true, + missingEntries: [], + existingEntries: ['lodash'], + checkedVersion: null, + }, + ]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({ + 'controller-utils': { + packageName: '@metamask/controller-utils', + dependencyChanges: [ + { + package: 'controller-utils', + dependency: 'lodash', + type: 'dependencies', + oldVersion: '^4.17.20', + newVersion: '^4.17.21', + }, + ], + }, + }); + + expect(changelogValidatorModule.validateChangelogs).toHaveBeenCalledWith( + expect.objectContaining({ + 'controller-utils': expect.objectContaining({ + dependencyChanges: [ + expect.objectContaining({ + dependency: 'lodash', + oldVersion: '^4.17.20', + newVersion: '^4.17.21', + }), + ], + }), + }), + '/path/to/project', + 'https://github.com/example-org/example-repo', + ); + }); + it('calls updateChangelogs when fix flag is set', async () => { const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index e163529..8df59cc 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -122,7 +122,7 @@ async function parseDiff( } // Parse removed dependencies - if (line.startsWith('-') && currentSection && line.includes('"@')) { + if (line.startsWith('-') && currentSection) { const match = line.match(/^-\s*"([^"]+)":\s*"([^"]+)"/u); if (match && currentSection) { @@ -136,7 +136,7 @@ async function parseDiff( } // Parse added dependencies and match with removed - if (line.startsWith('+') && currentSection && line.includes('"@')) { + if (line.startsWith('+') && currentSection) { const match = line.match(/^\+\s*"([^"]+)":\s*"([^"]+)"/u); if (match) { From 0d1de5160d8b2456a8aba738a75a9b0bb3cd2942 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:50:07 +0100 Subject: [PATCH 12/17] tests: add functional tests (#189) --- src/functional.test.ts | 1150 +++++++++++++++++ .../helpers/monorepo-environment.ts | 41 + 2 files changed, 1191 insertions(+) diff --git a/src/functional.test.ts b/src/functional.test.ts index 56ebd54..9bd1a1b 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -1065,4 +1065,1154 @@ __metadata: ); }); }); + + describe('check-deps command', () => { + it('detects dependency bumps and validates changelogs', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump dependency version + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump @scope/b to 2.0.0'); + + // Run check-deps + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + }); + + // Should detect the dependency bump and report missing changelog entry + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('@scope/b'); + expect(result.stdout).toContain('1.0.0'); + expect(result.stdout).toContain('2.0.0'); + // The error could be about missing section or missing entries + expect( + result.stderr.includes('Missing') || + result.stderr.includes('No [Unreleased] section'), + ).toBe(true); + }, + ); + }); + + it('automatically fixes missing changelog entries with --fix', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump dependency version + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump @scope/b to 2.0.0'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '123', + }); + + // Should update the changelog + expect(result.exitCode).toBe(0); + // Verify changes were detected + expect(result.stdout).toContain('@scope/b'); + // Verify the command tried to update + expect(result.stdout).toContain('Updating changelogs'); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#123](https://github.com/example-org/example-repo/pull/123)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + }, + ); + }); + + it('detects peerDependency bumps', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + peerDependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump peerDependency version + await environment.updateJsonFileWithinPackage('a', 'package.json', { + peerDependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit( + 'Bump @scope/b peerDependency to 2.0.0', + ); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '456', + }); + + // Should detect and update peerDependency change + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ### Changed + + - **BREAKING:** Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#456](https://github.com/example-org/example-repo/pull/456)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + }, + ); + }); + + it('detects non-scoped package dependency bumps', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + lodash: '4.17.20', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump non-scoped dependency + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + lodash: '4.17.21', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump lodash to 4.17.21'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '789', + }); + + // Should detect and update non-scoped package change + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`lodash\` from \`4.17.20\` to \`4.17.21\` ([#789](https://github.com/example-org/example-repo/pull/789)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + }, + ); + }); + + it('validates existing changelog entries correctly', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#123](https://github.com/example-org/example-repo/pull/123)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump dependency version + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump @scope/b to 2.0.0'); + + // Run check-deps + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + }); + + // Should validate that changelog entry exists + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('All entries present'); + expect(result.stderr).not.toContain('Missing'); + }, + ); + }); + + it('handles multiple dependency bumps in the same package', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + c: { + name: '@scope/c', + version: '1.0.0', + directoryPath: 'packages/c', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + '@scope/c': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump multiple dependencies + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '2.0.0', + '@scope/c': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump multiple dependencies'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '999', + }); + + // Should detect and update all dependency changes + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#999](https://github.com/example-org/example-repo/pull/999)) + - Bump \`@scope/c\` from \`1.0.0\` to \`2.0.0\` ([#999](https://github.com/example-org/example-repo/pull/999)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + }, + ); + }); + + it('orders BREAKING changes before regular dependencies', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + c: { + name: '@scope/c', + version: '1.0.0', + directoryPath: 'packages/c', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + '@scope/c': '1.0.0', + }, + peerDependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump both dependency and peerDependency + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '2.0.0', + '@scope/c': '2.0.0', + }, + peerDependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit( + 'Bump dependencies and peerDependencies', + ); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '111', + }); + + // Should order BREAKING (peerDeps) first, then regular deps + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + + // Find the Changed section + const changedSectionStart = changelog.indexOf('### Changed'); + expect(changedSectionStart).toBeGreaterThan(-1); + + // Extract the Changed section content + const changedSection = changelog.substring(changedSectionStart); + + // Find BREAKING entry (for @scope/b peerDependency) + const breakingIndex = changedSection.indexOf('**BREAKING:**'); + expect(breakingIndex).toBeGreaterThan(-1); + + // Find regular dependency entries (for @scope/b and @scope/c dependencies) + const regularDepBIndex = changedSection.indexOf('Bump `@scope/b`'); + const regularDepCIndex = changedSection.indexOf('Bump `@scope/c`'); + + // BREAKING entry should appear before regular dependency entries + expect(regularDepBIndex).toBeGreaterThan(-1); + expect(regularDepCIndex).toBeGreaterThan(-1); + expect(breakingIndex).toBeLessThan(regularDepBIndex); + expect(breakingIndex).toBeLessThan(regularDepCIndex); + }, + ); + }); + + it('updates existing changelog entry when dependency is bumped again', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state with existing changelog entry + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#100](https://github.com/example-org/example-repo/pull/100)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump dependency version again (from 2.0.0 to 3.0.0) + await environment.updateJsonFileWithinPackage('a', 'package.json', { + dependencies: { + '@scope/b': '3.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump @scope/b to 3.0.0'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '200', + }); + + // Should update the existing entry with new version and preserve old PR + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`3.0.0\` ([#100](https://github.com/example-org/example-repo/pull/100), [#200](https://github.com/example-org/example-repo/pull/200)) + + [Unreleased]: https://github.com/example-org/example-repo/ + `), + ); + }, + ); + }); + + it('handles renamed packages correctly', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/renamed-package', + version: '6.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state with package rename info in scripts + // Package was renamed from "old-package-name" to "@scope/renamed-package" at version 5.0.1 + await environment.updateJsonFileWithinPackage('a', 'package.json', { + name: '@scope/renamed-package', + version: '6.0.0', + dependencies: { + '@scope/b': '1.0.0', + }, + scripts: { + 'auto-changelog': + 'auto-changelog --tag-prefix-before-package-rename old-package-name@ --version-before-package-rename 5.0.1', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + // Changelog has historical entries with old package name (before 5.0.1) + // and entries with new package name (after 5.0.1) + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + ## [6.0.0] + + ### Changed + + - Some change in version 6.0.0 + + ## [5.0.1] + + ### Changed + + - Package renamed from old-package-name to @scope/renamed-package + + ## [5.0.0] + + ### Changed + + - Some change in version 5.0.0 (old package name) + + [Unreleased]: https://github.com/example-org/example-repo/compare/@scope/renamed-package@6.0.0...HEAD + [6.0.0]: https://github.com/example-org/example-repo/releases/tag/@scope/renamed-package@6.0.0 + [5.0.1]: https://github.com/example-org/example-repo/releases/tag/@scope/renamed-package@5.0.1 + [5.0.0]: https://github.com/example-org/example-repo/releases/tag/old-package-name@5.0.0 + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump dependency version + await environment.updateJsonFileWithinPackage('a', 'package.json', { + name: '@scope/renamed-package', + version: '6.0.0', + dependencies: { + '@scope/b': '2.0.0', + }, + scripts: { + 'auto-changelog': + 'auto-changelog --tag-prefix-before-package-rename old-package-name@ --version-before-package-rename 5.0.1', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Bump @scope/b to 2.0.0'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '300', + }); + + // Should detect and update dependency change, preserving historical entries + // and maintaining both old and new tag prefixes in links + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + // Verify the new entry was added to Unreleased section + expect(changelog).toContain( + 'Bump `@scope/b` from `1.0.0` to `2.0.0`', + ); + expect(changelog).toContain('[#300]'); + // Verify historical entries are preserved + expect(changelog).toContain('Some change in version 6.0.0'); + expect(changelog).toContain('Some change in version 5.0.0'); + expect(changelog).toContain( + 'Package renamed from old-package-name to @scope/renamed-package', + ); + // Verify links reference both old and new tag prefixes correctly + // Versions before rename (5.0.0, 5.0.1) use old package name + expect(changelog).toContain('old-package-name@5.0.0'); + // Versions after rename (6.0.0+) use new package name + expect(changelog).toContain('@scope/renamed-package@6.0.0'); + // Verify Unreleased link uses new package name + expect(changelog).toContain( + '[Unreleased]: https://github.com/example-org/example-repo/compare/@scope/renamed-package@6.0.0...HEAD', + ); + }, + ); + }); + + it('adds changelog entry under release version when package is being released', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Set up initial state + await environment.updateJsonFileWithinPackage('a', 'package.json', { + name: '@scope/a', + version: '1.0.0', + dependencies: { + '@scope/b': '1.0.0', + }, + }); + // Format the JSON file so the diff parser can work with it + const initialPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(initialPkg, null, 2)}\n`, + ); + // Create changelog with [2.0.0] section already present + // (in real scenarios, this would be created by the release process) + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + ## [2.0.0] + + ## [1.0.0] + + ### Changed + + - Initial release + + [Unreleased]: https://github.com/example-org/example-repo/compare/@scope/a@2.0.0...HEAD + [2.0.0]: https://github.com/example-org/example-repo/compare/@scope/a@1.0.0...@scope/a@2.0.0 + [1.0.0]: https://github.com/example-org/example-repo/releases/tag/@scope/a@1.0.0 + `), + ); + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Bump both package version and dependency version + // The [2.0.0] section will be created by auto-changelog when we add the entry + await environment.updateJsonFileWithinPackage('a', 'package.json', { + name: '@scope/a', + version: '2.0.0', + dependencies: { + '@scope/b': '2.0.0', + }, + }); + // Format the JSON file again + const updatedPkg = await environment.readJsonFileWithinPackage( + 'a', + 'package.json', + ); + await environment.writeFileWithinPackage( + 'a', + 'package.json', + `${JSON.stringify(updatedPkg, null, 2)}\n`, + ); + await environment.createCommit('Release 2.0.0 and bump @scope/b'); + + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + fix: true, + prNumber: '400', + }); + + // Should add entry under [2.0.0] section, not [Unreleased] + expect(result.exitCode).toBe(0); + const changelog = await environment.readFileWithinPackage( + 'a', + 'CHANGELOG.md', + ); + expect(changelog).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ## [2.0.0] + + ### Changed + + - Bump \`@scope/b\` from \`1.0.0\` to \`2.0.0\` ([#400](https://github.com/example-org/example-repo/pull/400)) + + ## [1.0.0] + + ### Changed + + - Initial release + + [Unreleased]: https://github.com/example-org/example-repo/compare/@scope/a@2.0.0...HEAD + [2.0.0]: https://github.com/example-org/example-repo/compare/@scope/a@1.0.0...@scope/a@2.0.0 + [1.0.0]: https://github.com/example-org/example-repo/releases/tag/@scope/a@1.0.0 + `), + ); + }, + ); + }); + + it('reports no changes when no dependency bumps are found', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + await environment.createCommit('Initial commit'); + const baseCommit = ( + await environment.runCommand('git', ['rev-parse', 'HEAD']) + ).stdout.trim(); + + // Make a non-dependency change + await environment.writeFileWithinPackage('a', 'dummy.txt', 'content'); + await environment.createCommit('Non-dependency change'); + + // Run check-deps + const result = await environment.runCheckDeps({ + fromRef: baseCommit, + }); + + // Should report no dependency bumps (or no package.json changes) + expect(result.exitCode).toBe(0); + expect( + result.stdout.includes('No dependency version bumps found') || + result.stdout.includes('No package.json changes found'), + ).toBe(true); + }, + ); + }); + }); }); diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index 270e95e..e319cc2 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -147,6 +147,47 @@ cat "${releaseSpecificationPath}" > "$1" return result; } + /** + * Runs the check-deps command within the context of the project. + * + * @param args - The arguments to this function. + * @param args.fromRef - The git ref to compare from (required). + * @param args.toRef - The git ref to compare to (optional, defaults to HEAD). + * @param args.fix - Whether to automatically fix missing changelog entries. + * @param args.prNumber - The PR number to use in changelog entries. + * @returns The result of the command. + */ + async runCheckDeps({ + fromRef, + toRef, + fix, + prNumber, + }: { + fromRef: string; + toRef?: string; + fix?: boolean; + prNumber?: string; + }): Promise> { + const args = [ + TOOL_EXECUTABLE_PATH, + 'check-deps', + '--from', + fromRef, + ...(toRef ? ['--to', toRef] : []), + ...(fix ? ['--fix'] : []), + ...(prNumber ? ['--pr', prNumber] : []), + ]; + const result = await this.localRepo.runCommand(TSX_PATH, args); + + debug( + ['---- START OUTPUT -----', result.all, '---- END OUTPUT -----'].join( + '\n', + ), + ); + + return result; + } + protected buildLocalRepo({ packages, workspaces, From 49b197df41c84d9a7eb2b9852ddd0aa3033f726e Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 3 Dec 2025 23:05:44 +0100 Subject: [PATCH 13/17] Fix optionalDependencies incorrectly attributed to dependencies section --- src/check-dependency-bumps.test.ts | 145 +++++++++++++++++++++++++++++ src/check-dependency-bumps.ts | 20 +++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index c79fa75..50ebd11 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -1487,6 +1487,151 @@ index 1234567..890abcd 100644 ); }); + it('ignores optionalDependencies and does not incorrectly attribute changes to dependencies', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with dependencies, then optionalDependencies + // optionalDependencies changes should be ignored + const diffWithOptionalDeps = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -10,10 +10,13 @@ + "dependencies": { + "@metamask/transaction-controller": "^61.0.0" + }, ++ "optionalDependencies": { ++ "@metamask/some-optional": "^1.0.0" ++ } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithOptionalDeps); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/example-org/example-repo'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should not detect any changes (optionalDependencies should be ignored) + expect(result).toStrictEqual({}); + }); + + it('correctly resets section when encountering optionalDependencies after dependencies', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with dependencies change, then optionalDependencies section + // The optionalDependencies section should reset currentSection + const diffWithDepsAndOptional = ` +diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json +index 1234567..890abcd 100644 +--- a/packages/controller-utils/package.json ++++ b/packages/controller-utils/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + }, + "optionalDependencies": { +- "@metamask/some-optional": "^1.0.0" ++ "@metamask/some-optional": "^2.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithDepsAndOptional); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/controller-utils/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/controller-utils', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/example-org/example-repo'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should only detect dependencies change, not optionalDependencies + expect(result).toStrictEqual({ + 'controller-utils': { + packageName: '@metamask/controller-utils', + dependencyChanges: [ + { + package: 'controller-utils', + dependency: '@metamask/transaction-controller', + type: 'dependencies', + oldVersion: '^61.0.0', + newVersion: '^62.0.0', + }, + ], + }, + }); + }); + it('handles diff without proper file path', async () => { const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 8df59cc..1ad8549 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -101,22 +101,32 @@ async function parseDiff( } } - // Detect dependency sections (excluding devDependencies) + // Detect dependency sections (excluding devDependencies and optionalDependencies) if (line.includes('"peerDependencies"')) { currentSection = 'peerDependencies'; } else if (line.includes('"dependencies"')) { currentSection = 'dependencies'; - } else if (line.includes('"devDependencies"')) { - // Skip devDependencies section + } else if ( + line.includes('"devDependencies"') || + line.includes('"optionalDependencies"') + ) { + // Skip devDependencies and optionalDependencies sections currentSection = null; } // Check if we're leaving a section if ((currentSection && line.trim() === '},') || line.trim() === '}') { - // Check if next line is another section or end of sections + // Check if next line is another section we care about const nextLine = lines[i + 1]; - if (nextLine && !nextLine.includes('Dependencies"')) { + // Reset section unless next line starts a section we care about + // Check for exact section names to avoid false matches (e.g., peerDependencies contains "dependencies") + const isNextSectionDependencies = + nextLine && /^\s*"dependencies"\s*:/u.test(nextLine); + const isNextSectionPeerDependencies = + nextLine && /^\s*"peerDependencies"\s*:/u.test(nextLine); + + if (!isNextSectionDependencies && !isNextSectionPeerDependencies) { currentSection = null; } } From b53e89c96012cdf6334e5302d1c9cc52f77024cf Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 3 Dec 2025 23:09:53 +0100 Subject: [PATCH 14/17] Fix operator precedence in section boundary check --- src/check-dependency-bumps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 1ad8549..c7c46f6 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -115,7 +115,7 @@ async function parseDiff( } // Check if we're leaving a section - if ((currentSection && line.trim() === '},') || line.trim() === '}') { + if (currentSection && (line.trim() === '},' || line.trim() === '}')) { // Check if next line is another section we care about const nextLine = lines[i + 1]; From 2f73bd8233127045e71e84b775a20092b04b3f24 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 3 Dec 2025 23:37:05 +0100 Subject: [PATCH 15/17] Fix default branch check to use defaultBranch parameter instead of hardcoded values --- src/check-dependency-bumps.test.ts | 22 ++++++++++++++++++++++ src/check-dependency-bumps.ts | 6 +++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index 50ebd11..757378b 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -49,6 +49,28 @@ describe('check-dependency-bumps', () => { expect(result).toStrictEqual({}); }); + it('returns empty object when on custom defaultBranch without fromRef', async () => { + const stdoutWriteSpy = jest.spyOn(stdout, 'write'); + jest + .spyOn(repoModule, 'getCurrentBranchName') + .mockResolvedValue('develop'); + + const result = await checkDependencyBumps({ + defaultBranch: 'develop', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + expect(result).toStrictEqual({}); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('📌 Current branch: develop'), + ); + expect(stdoutWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ You are on the develop branch'), + ); + }); + it('auto-detects merge base when fromRef is not provided', async () => { const stdoutWriteSpy = jest.spyOn(stdout, 'write'); const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index c7c46f6..51488e8 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -296,10 +296,10 @@ export async function checkDependencyBumps({ const currentBranch = await getCurrentBranchName(projectRoot); stdout.write(`\n📌 Current branch: ${currentBranch}\n`); - // Skip if we're on main/master - if (currentBranch === 'main' || currentBranch === 'master') { + // Skip if we're on the default branch + if (currentBranch === defaultBranch) { stdout.write( - '⚠️ You are on the main/master branch. Please specify commits to compare or switch to a feature branch.\n', + `⚠️ You are on the ${defaultBranch} branch. Please specify commits to compare or switch to a feature branch.\n`, ); return {}; } From 259d980adb84c82bfa73a5e97045bc301d992560 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 3 Dec 2025 23:39:08 +0100 Subject: [PATCH 16/17] Reset section state when parsing new file in diff --- src/check-dependency-bumps.test.ts | 90 ++++++++++++++++++++++++++++++ src/check-dependency-bumps.ts | 2 + 2 files changed, 92 insertions(+) diff --git a/src/check-dependency-bumps.test.ts b/src/check-dependency-bumps.test.ts index 757378b..b0ee34c 100644 --- a/src/check-dependency-bumps.test.ts +++ b/src/check-dependency-bumps.test.ts @@ -2261,6 +2261,96 @@ diff --git a/packages/controller-utils/package.json b/packages/controller-utils/ expect(result).toStrictEqual({}); }); + it('resets section state when switching to a different file', async () => { + const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); + + // Diff with two different files - section state should reset between them + const diffWithMultipleFiles = ` +diff --git a/packages/package-a/package.json b/packages/package-a/package.json +index 1234567..890abcd 100644 +--- a/packages/package-a/package.json ++++ b/packages/package-a/package.json +@@ -10,7 +10,7 @@ + "dependencies": { +- "@metamask/transaction-controller": "^61.0.0" ++ "@metamask/transaction-controller": "^62.0.0" + } + } +diff --git a/packages/package-b/package.json b/packages/package-b/package.json +index abc1234..def5678 100644 +--- a/packages/package-b/package.json ++++ b/packages/package-b/package.json +@@ -10,7 +10,7 @@ + "peerDependencies": { +- "@metamask/network-controller": "^5.0.0" ++ "@metamask/network-controller": "^6.0.0" + } + } +`; + + when(getStdoutSpy) + .calledWith( + 'git', + ['diff', '-U9999', 'abc123', 'HEAD', '--', '**/package.json'], + { cwd: '/path/to/project' }, + ) + .mockResolvedValue(diffWithMultipleFiles); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/package.json') + .mockResolvedValue({ + unvalidated: { + repository: 'https://github.com/example-org/example-repo', + }, + validated: buildMockManifest(), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/package-a/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/package-a', + }), + }); + + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/project/packages/package-b/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@metamask/package-b', + }), + }); + + jest + .spyOn(projectModule, 'getValidRepositoryUrl') + .mockResolvedValue('https://github.com/example-org/example-repo'); + + jest + .spyOn(changelogValidatorModule, 'validateChangelogs') + .mockResolvedValue([]); + + const result = await checkDependencyBumps({ + fromRef: 'abc123', + projectRoot: '/path/to/project', + stdout, + stderr, + }); + + // Should detect changes in both files with correct section types + expect(result['package-a']).toBeDefined(); + expect(result['package-a'].dependencyChanges).toHaveLength(1); + expect(result['package-a'].dependencyChanges[0].type).toBe( + 'dependencies', + ); + expect(result['package-b']).toBeDefined(); + expect(result['package-b'].dependencyChanges).toHaveLength(1); + expect(result['package-b'].dependencyChanges[0].type).toBe( + 'peerDependencies', + ); + }); + it('detects package version changes for release detection', async () => { const getStdoutSpy = jest.spyOn(miscUtilsModule, 'getStdoutFromCommand'); diff --git a/src/check-dependency-bumps.ts b/src/check-dependency-bumps.ts index 51488e8..1505fa0 100755 --- a/src/check-dependency-bumps.ts +++ b/src/check-dependency-bumps.ts @@ -82,6 +82,8 @@ async function parseDiff( const match = line.match(/b\/(.+)/u); if (match) { + // Reset section state when starting a new file (diff --git always starts a new file) + currentSection = null; currentFile = match[1]; } } From 703e1c35e8a2b1366ee867dbfff082f7409a0b08 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 5 Dec 2025 17:14:17 +0100 Subject: [PATCH 17/17] Fix check-deps command usage in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fcd4b..465f0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Auto-updates changelogs with `--fix` flag, preserving PR history - Detects package releases and validates/updates in correct changelog section (Unreleased vs specific version) - Smart PR concatenation when same dependency bumped multiple times - - Usage: `yarn check-dependency-bumps --fix --pr ` + - Usage: `yarn create-release-branch check-deps --fix --pr ` ## [4.1.3]