diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index 8274216..9c4b2c6 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -602,7 +602,7 @@ Your release spec could not be processed due to the following issues: }); }); - it('throws if there are any packages in the release with a major version bump using the word "major", but any of their dependents defined as "peerDependencies" are not listed in the release', async () => { + it('throws if there are any packages in the release with a major version bump using the word "major", but any of their dependents defined as "peerDependencies" have changes since their latest release and are not listed in the release', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -657,7 +657,62 @@ ${releaseSpecificationPath} }); }); - it('throws if there are any packages in the release with a major version bump using a literal version, but any of their dependents defined as "peerDependencies" are not listed in the release', async () => { + it('throws if there are any packages in the release with a major version bump using the word "major", but any of their dependents defined as "peerDependencies" are not listed in the release, even if they have no changes', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + validatedManifest: { + peerDependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but any of their dependents defined as "peerDependencies" have changes since their latest release and are not listed in the release', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -712,7 +767,62 @@ ${releaseSpecificationPath} }); }); - it('throws if there are any packages in the release with a major version bump using the word "major", but their dependents via "peerDependencies" have their version specified as null in the release spec', async () => { + it('throws if there are any packages in the release with a major version bump using a literal version, but any of their dependents defined as "peerDependencies" are not listed in the release, even if they have no changes', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using the word "major", but their dependents via "peerDependencies" have changes since their latest release and have their version specified as null in the release spec', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -768,7 +878,63 @@ ${releaseSpecificationPath} }); }); - it('throws if there are any packages in the release with a major version bump using a literal version, but their dependents via "peerDependencies" have their version specified as null in the release spec', async () => { + it('throws if there are any packages in the release with a major version bump using the word "major", but their dependents via "peerDependencies" have their version specified as null in the release spec, even if they have no changes', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + validatedManifest: { + peerDependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but their dependents via "peerDependencies" have changes since their latest release and have their version specified as null in the release spec', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -818,6 +984,62 @@ Your release spec could not be processed due to the following issues: The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but their dependents via "peerDependencies" have their version specified as null in the release spec, even if they have no changes', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + validatedManifest: { + peerDependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + ${releaseSpecificationPath} `.trim(), ); diff --git a/src/release-specification.ts b/src/release-specification.ts index 151a9ad..e551a2e 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -168,17 +168,15 @@ export async function waitForUserToEditReleaseSpecification( } /** - * Finds all workspace packages that depend on the given package and have changes since their latest release. + * Finds all workspace packages that depend on the given package. * * @param project - The project containing workspace packages. * @param packageName - The name of the package to find dependents for. - * @param unvalidatedReleaseSpecificationPackages - The packages in the release specification. - * @returns An array of package names that depend on the given package and are missing from the release spec. + * @returns An array of package names that depend on the given package. */ -export function findMissingUnreleasedDependents( +export function findAllWorkspacePackagesThatDependOnPackage( project: Project, packageName: string, - unvalidatedReleaseSpecificationPackages: Record, ): string[] { const dependentNames = Object.keys(project.workspacePackages).filter( (possibleDependentName) => { @@ -189,14 +187,28 @@ export function findMissingUnreleasedDependents( }, ); - const changedDependentNames = dependentNames.filter( - (possibleDependentName) => { - return project.workspacePackages[possibleDependentName] - .hasChangesSinceLatestRelease; - }, + return dependentNames; +} + +/** + * Finds all workspace packages that depend on the given package. + * + * @param project - The project containing workspace packages. + * @param packageName - The name of the package to find dependents for. + * @param unvalidatedReleaseSpecificationPackages - The packages in the release specification. + * @returns An array of package names that depend on the given package and are missing from the release spec. + */ +export function findMissingUnreleasedDependents( + project: Project, + packageName: string, + unvalidatedReleaseSpecificationPackages: Record, +): string[] { + const dependentNames = findAllWorkspacePackagesThatDependOnPackage( + project, + packageName, ); - return changedDependentNames.filter((dependentName) => { + return dependentNames.filter((dependentName) => { return !unvalidatedReleaseSpecificationPackages[dependentName]; }); } diff --git a/src/ui.ts b/src/ui.ts index 46ff94e..a646f9a 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -10,6 +10,7 @@ import { } from './project.js'; import { Package } from './package.js'; import { + findAllWorkspacePackagesThatDependOnPackage, findMissingUnreleasedDependenciesForRelease, findMissingUnreleasedDependentsForBreakingChanges, IncrementableVersionParts, @@ -130,15 +131,29 @@ function createApp({ app.use(express.static(UI_BUILD_DIR)); app.use(express.json()); - app.get('/api/packages', (_req, res) => { + app.get('/api/packages', (req, res) => { + const { majorBumps } = req.query; + + const majorBumpsArray = + typeof majorBumps === 'string' + ? majorBumps.split(',').filter(Boolean) + : (req.query.majorBumps as string[] | undefined) || []; + + const requiredDependents = new Set( + majorBumpsArray.flatMap((majorBump) => + findAllWorkspacePackagesThatDependOnPackage(project, majorBump), + ), + ); + const pkgs = Object.values(project.workspacePackages).filter( - (pkg) => pkg.hasChangesSinceLatestRelease, + (pkg) => + pkg.hasChangesSinceLatestRelease || + requiredDependents.has(pkg.validatedManifest.name), ); const packages = pkgs.map((pkg) => ({ name: pkg.validatedManifest.name, version: pkg.validatedManifest.version.version, - location: pkg.directoryPath, })); res.json(packages); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2282a4b..f2b2d2c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,11 +1,17 @@ import './style.css'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { SemVer } from 'semver'; import { ErrorMessage } from './ErrorMessage.js'; import { PackageItem } from './PackageItem.js'; import { Package, RELEASE_TYPE_OPTIONS, ReleaseType } from './types.js'; +// Helper function to compare sets +const setsAreEqual = (a: Set, b: Set) => { + if (a.size !== b.size) return false; + return [...a].every((value) => b.has(value)); +}; + type SubmitButtonProps = { selections: Record; packageDependencyErrors: Record< @@ -67,9 +73,14 @@ function App() { new Set(), ); const [showCheckboxes, setShowCheckboxes] = useState(false); + const previousPackages = useRef>(new Set()); useEffect(() => { - fetch('/api/packages') + const majorBumps = Object.entries(selections) + .filter(([_, type]) => type === 'major') + .map(([pkgName]) => pkgName); + + fetch(`/api/packages?majorBumps=${majorBumps.join(',')}`) .then((res) => { if (!res.ok) { throw new Error(`Received ${res.status}`); @@ -77,6 +88,20 @@ function App() { return res.json(); }) .then((data: Package[]) => { + const newPackageNames = new Set(data.map((pkg) => pkg.name)); + + // Only clean up selections if the package list actually changed + if (!setsAreEqual(previousPackages.current, newPackageNames)) { + setSelections((prev) => + Object.fromEntries( + Object.entries(prev).filter(([pkgName]) => + newPackageNames.has(pkgName), + ), + ), + ); + previousPackages.current = newPackageNames; + } + setPackages(data); setLoadingChangelogs( data.reduce((acc, pkg) => ({ ...acc, [pkg.name]: false }), {}), @@ -86,7 +111,7 @@ function App() { setError(err.message); console.error('Error fetching packages:', err); }); - }, []); + }, [selections]); const checkDependencies = async (selectionData: Record) => { if (Object.keys(selectionData).length === 0) return;