diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 1488dd58..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,73 +0,0 @@ -module.exports = { - root: true, - - extends: ['@metamask/eslint-config', '@metamask/eslint-config-nodejs'], - - parserOptions: { - sourceType: 'module', - }, - - rules: { - // This makes integration tests easier to read by allowing setup code and - // assertions to be grouped together better - 'padding-line-between-statements': [ - 'error', - { - blankLine: 'always', - prev: 'directive', - next: '*', - }, - { - blankLine: 'any', - prev: 'directive', - next: 'directive', - }, - { - blankLine: 'always', - prev: 'multiline-block-like', - next: '*', - }, - { - blankLine: 'always', - prev: '*', - next: 'multiline-block-like', - }, - ], - // It's common for scripts to access `process.env` - 'node/no-process-env': 'off', - }, - - overrides: [ - { - files: ['*.cjs'], - parserOptions: { - sourceType: 'script', - }, - }, - { - files: ['*.ts', '*.tsx', '*.mts'], - extends: ['@metamask/eslint-config-typescript'], - }, - - { - files: ['*.test.ts', '*.test.tsx'], - extends: ['@metamask/eslint-config-jest'], - }, - - { - files: ['src/ui/**/*.tsx'], - extends: ['plugin:react/recommended', 'plugin:react/jsx-runtime'], - rules: { - // This rule isn't useful for us - 'react/no-unescaped-entities': 'off', - }, - settings: { - react: { - version: 'detect', - }, - }, - }, - ], - - ignorePatterns: ['dist/', 'node_modules/'], -}; diff --git a/bin/create-release-branch.js b/bin/create-release-branch.js index f71e12c5..0e568f87 100755 --- a/bin/create-release-branch.js +++ b/bin/create-release-branch.js @@ -1,5 +1,10 @@ #!/usr/bin/env node -/* eslint-disable import/extensions */ -// eslint-disable-next-line import/no-unassigned-import, import/no-unresolved +// Three things: +// - This file doesn't export anything, as it's a script. +// - We are using a `.js` extension because that's what appears in `dist/`. +// - This file will only exist after running `yarn build`. We don't want +// developers or CI to receive a lint error if the script has not been run. +// (A warning will appear if the script *has* been run, but that is okay.) +// eslint-disable-next-line import-x/no-unassigned-import, import-x/extensions, import-x/no-unresolved import '../dist/cli.js'; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..3ba8f715 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,290 @@ +import base, { createConfig } from '@metamask/eslint-config'; +import browser from '@metamask/eslint-config-browser'; +import jest from '@metamask/eslint-config-jest'; +import nodejs from '@metamask/eslint-config-nodejs'; +import typescript from '@metamask/eslint-config-typescript'; +import react from 'eslint-plugin-react'; + +// Copied from `jsdoc/check-tag-names`, except `@property` is omitted +// +const typedTagsAlwaysUnnecessary = new Set([ + 'augments', + 'callback', + 'class', + 'enum', + 'implements', + 'private', + 'protected', + 'public', + 'readonly', + 'this', + 'type', + 'typedef', +]); + +// Copied from `jsdoc/check-tag-names` +// +const typedTagsNeedingName = new Set(['template']); + +// Copied from `jsdoc/check-tag-names`, except `@property` is omitted +// +const typedTagsUnnecessaryOutsideDeclare = new Set([ + 'abstract', + 'access', + 'class', + 'constant', + 'constructs', + 'enum', + 'export', + 'exports', + 'function', + 'global', + 'inherits', + 'instance', + 'interface', + 'member', + 'memberof', + 'memberOf', + 'method', + 'mixes', + 'mixin', + 'module', + 'name', + 'namespace', + 'override', + 'requires', + 'static', + 'this', +]); + +// Consider copying this to @metamask/eslint-config +const requireJsdocOverride = { + 'jsdoc/require-jsdoc': [ + 'error', + { + require: { + // Methods + MethodDefinition: true, + }, + contexts: [ + // Type interfaces defined at the topmost scope of a file + 'Program > TSInterfaceDeclaration', + // Type aliases defined at the topmost scope of a file + 'Program > TSTypeAliasDeclaration', + // Enums defined at the topmost scope of a file + 'Program > TSEnumDeclaration', + // Class declarations defined at the topmost scope of a file + 'Program > ClassDeclaration', + // Function declarations defined at the topmost scope of a file + 'Program > FunctionDeclaration', + // Arrow functions defined at the topmost scope of a file + 'Program > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression', + // Function expressions defined at the topmost scope of a file + 'Program > VariableDeclaration > VariableDeclarator > FunctionExpression', + // Exported variables defined at the topmost scope of a file + 'ExportNamedDeclaration:has(> VariableDeclaration)', + ], + }, + ], +}; + +const config = createConfig([ + { + ignores: ['dist/', 'docs/', '.yarn/'], + }, + + { + extends: base, + + languageOptions: { + sourceType: 'module', + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + + rules: { + // Consider copying this to @metamask/eslint-config + 'jsdoc/no-blank-blocks': 'error', + ...requireJsdocOverride, + }, + + settings: { + 'import-x/extensions': ['.js', '.mjs'], + }, + }, + + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts'], + extends: typescript, + rules: { + // Consider copying this to @metamask/eslint-config + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + }, + ], + // Consider copying this to @metamask/eslint-config + 'jsdoc/no-blank-blocks': 'error', + ...requireJsdocOverride, + // Override this rule so that the JSDoc tags that were checked with + // `typed: true` still apply, but `@property` is excluded + 'jsdoc/check-tag-names': ['error', { typed: false }], + 'jsdoc/no-restricted-syntax': [ + 'error', + { + contexts: [ + ...Array.from(typedTagsAlwaysUnnecessary).map((tag) => ({ + comment: `JsdocBlock:has(JsdocTag[tag='${tag}'])`, + message: `'@${tag}' is redundant when using a type system.`, + })), + ...Array.from(typedTagsNeedingName).map((tag) => ({ + comment: `JsdocBlock:has(JsdocTag[tag='${tag}']:not([name]))`, + message: `'@${tag}' is redundant without a name when using a type system.`, + })), + ...Array.from(typedTagsUnnecessaryOutsideDeclare).map((tag) => ({ + // We want to allow the use of these tags inside of `declare` + // blocks. The only way to do this seems to be to name all common + // node types, but exclude `TSModuleBlock` and + // `TSModuleDeclaration`. + context: + 'TSTypeAliasDeclaration, TSInterfaceDeclaration, ClassDeclaration, FunctionDeclaration, MethodDefinition, VariableDeclaration, TSEnumDeclaration, PropertyDefinition, TSPropertySignature, TSMethodSignature', + comment: `JsdocBlock:has(JsdocTag[tag='${tag}'])`, + message: `'@${tag}' is redundant when using a type system outside of ambient declarations.`, + })), + ], + }, + ], + }, + }, + + { + files: ['**/*.js', '**/*.cjs', '**/*.ts', '**/*.test.ts', '**/*.test.js'], + ignores: ['src/ui/**'], + extends: nodejs, + }, + + { + files: ['**/*.test.ts', '**/*.test.tsx'], + extends: jest, + }, + + { + files: ['src/ui/**.tsx'], + extends: [ + browser, + react.configs.flat.recommended, + react.configs.flat['jsx-runtime'], + ], + rules: { + // Copied from `@metamask/eslint-config`, but tweaked to allow functions + // to be formatted as PascalCase + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow', + trailingUnderscore: 'forbid', + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + trailingUnderscore: 'forbid', + }, + { + selector: 'enumMember', + format: ['PascalCase'], + }, + { + selector: 'import', + format: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE'], + }, + { + selector: 'interface', + format: ['PascalCase'], + custom: { + regex: '^I[A-Z]', + match: false, + }, + }, + { + selector: 'objectLiteralMethod', + format: ['camelCase', 'PascalCase', 'UPPER_CASE'], + }, + { + selector: 'objectLiteralProperty', + // Disabled because object literals are often parameters to 3rd party libraries/services, + // which we don't set the naming conventions for + format: null, + }, + { + selector: 'typeLike', + format: ['PascalCase'], + }, + { + selector: 'typeParameter', + format: ['PascalCase'], + custom: { + regex: '^.{3,}', + match: true, + }, + }, + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'parameter', + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember', + ], + format: null, + modifiers: ['requiresQuotes'], + }, + ], + // `event` is a common argument of event listeners. + '@typescript-eslint/no-shadow': [ + 'error', + { + allow: ['event'], + }, + ], + // This rule isn't useful for us + 'react/no-unescaped-entities': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + + // List this last to override any settings inherited from plugins, + // especially `eslint-config-n`, which mistakenly assumes that all `.cjs` + // files are modules (since we specified `type: module` in `package.json`) + { + files: ['**/*.js', '**/*.cjs'], + // This *is* a script, but is written using ESM. + ignores: ['bin/create-release-branch.js'], + languageOptions: { + sourceType: 'script', + }, + }, +]); + +export default config; diff --git a/package.json b/package.json index 3483ea7b..2ef71dbb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build:ui": "vite build", "build:clean": "rimraf dist && yarn build", "lint": "yarn lint:eslint && yarn lint:misc --check", - "lint:eslint": "eslint . --cache --ext cjs,cts,js,mjs,mts,ts,tsx", + "lint:eslint": "eslint .", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", "prepack": "./scripts/prepack.sh", @@ -46,10 +46,11 @@ "@babel/preset-env": "^7.23.5", "@babel/preset-typescript": "^7.23.3", "@lavamoat/allow-scripts": "^3.1.0", - "@metamask/eslint-config": "^10.0.0", - "@metamask/eslint-config-jest": "^10.0.0", - "@metamask/eslint-config-nodejs": "^10.0.0", - "@metamask/eslint-config-typescript": "^10.0.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-browser": "^15.0.0", + "@metamask/eslint-config-jest": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", "@tailwindcss/vite": "^4.0.9", "@types/debug": "^4.1.7", "@types/express": "^5.0.0", @@ -63,18 +64,21 @@ "@types/validate-npm-package-name": "^4.0.2", "@types/which": "^3.0.0", "@types/yargs": "^17.0.10", - "@typescript-eslint/eslint-plugin": "^5.62.0", - "@typescript-eslint/parser": "^5.62.0", + "@typescript-eslint/eslint-plugin": "^8.25.0", + "@typescript-eslint/parser": "^8.25.0", "@vitejs/plugin-react": "^4.3.4", "babel-jest": "^29.7.0", "deepmerge": "^4.2.2", - "eslint": "^8.27.0", + "eslint": "^9.21.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^26.9.0", - "eslint-plugin-jsdoc": "^39.6.2", + "eslint-import-resolver-typescript": "^3.8.3", + "eslint-plugin-import-x": "^4.6.1", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-jsdoc": "^50.6.3", + "eslint-plugin-n": "^17.15.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-promise": "^7.2.1", "eslint-plugin-react": "^7.37.5", "jest": "^29.7.0", "jest-it-up": "^3.0.0", @@ -90,6 +94,7 @@ "tailwindcss": "^4.0.9", "tsx": "^4.6.1", "typescript": "~5.1.6", + "typescript-eslint": "^8.49.0", "vite": "^6.2.0" }, "peerDependencies": { @@ -107,7 +112,8 @@ "allowScripts": { "@lavamoat/preinstall-always-fail": false, "tsx>esbuild": false, - "vite>esbuild": false + "vite>esbuild": false, + "eslint-plugin-import-x>unrs-resolver": false } } } diff --git a/src/cli.ts b/src/cli.ts index efc326b1..b23333f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import { main } from './main.js'; /** * The entrypoint to this tool. */ -async function cli() { +async function cli(): Promise { await main({ argv: process.argv, cwd: process.cwd(), diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index 383cc39d..f184de55 100644 --- a/src/command-line-arguments.ts +++ b/src/command-line-arguments.ts @@ -1,6 +1,9 @@ -import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs/yargs'; +/** + * The set of positional and named arguments that can be passed to this tool. + */ export type CommandLineArguments = { projectDirectory: string; tempDirectory: string | undefined; diff --git a/src/dirname.ts b/src/dirname.ts index d3723895..efdae826 100644 --- a/src/dirname.ts +++ b/src/dirname.ts @@ -1,14 +1,11 @@ -import { fileURLToPath } from 'url'; import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import { fileURLToPath } from 'url'; /** * Get the current directory path. * * @returns The current directory path. */ -export function getCurrentDirectoryPath() { - return __dirname; +export function getCurrentDirectoryPath(): string { + return dirname(fileURLToPath(import.meta.url)); } diff --git a/src/editor.test.ts b/src/editor.test.ts index 70b8a957..177d352b 100644 --- a/src/editor.test.ts +++ b/src/editor.test.ts @@ -1,4 +1,5 @@ import { when } from 'jest-when'; + import { determineEditor } from './editor.js'; import * as envModule from './env.js'; import * as miscUtils from './misc-utils.js'; diff --git a/src/editor.ts b/src/editor.ts index ba1b376a..b298f8c7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,3 +1,5 @@ +import { getErrorMessage } from '@metamask/utils'; + import { getEnvironmentVariables } from './env.js'; import { debug, resolveExecutable } from './misc-utils.js'; @@ -31,7 +33,7 @@ export async function determineEditor(): Promise { executablePath = await resolveExecutable(EDITOR); } catch (error) { debug( - `Could not resolve executable ${EDITOR} (${error}), falling back to VSCode`, + `Could not resolve executable ${EDITOR} (${getErrorMessage(error)}), falling back to VSCode`, ); } } @@ -43,7 +45,7 @@ export async function determineEditor(): Promise { executableArgs.push('--wait'); } catch (error) { debug( - `Could not resolve path to VSCode: ${error}, continuing regardless`, + `Could not resolve path to VSCode: ${getErrorMessage(error)}, continuing regardless`, ); } } diff --git a/src/env.test.ts b/src/env.test.ts index 5a8c6f4e..a88591c5 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -1,3 +1,7 @@ +// This file tests a file that is concerned with accessing environment +// variables. +/* eslint-disable n/no-process-env */ + import { getEnvironmentVariables } from './env.js'; describe('env', () => { diff --git a/src/env.ts b/src/env.ts index 58d63af3..7d784229 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,13 @@ +// This file tests a file that is concerned with accessing environment +// variables. +/* eslint-disable n/no-process-env */ + +/** + * Environment variables that this tool uses. + */ type Env = { + // Environment variables are uppercase by convention. + // eslint-disable-next-line @typescript-eslint/naming-convention EDITOR: string | undefined; }; diff --git a/src/fs.test.ts b/src/fs.test.ts index 474a6039..ab712e0d 100644 --- a/src/fs.test.ts +++ b/src/fs.test.ts @@ -1,9 +1,9 @@ +import * as actionUtils from '@metamask/action-utils'; import fs from 'fs'; +import { when } from 'jest-when'; import path from 'path'; import { rimraf } from 'rimraf'; -import { when } from 'jest-when'; -import * as actionUtils from '@metamask/action-utils'; -import { withSandbox } from '../tests/helpers.js'; + import { readFile, writeFile, @@ -13,6 +13,7 @@ import { ensureDirectoryPathExists, removeFile, } from './fs.js'; +import { withSandbox } from '../tests/helpers.js'; jest.mock('@metamask/action-utils'); diff --git a/src/fs.ts b/src/fs.ts index c9afd136..793bedac 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,8 +1,9 @@ -import fs from 'fs'; import { readJsonObjectFile as underlyingReadJsonObjectFile, writeJsonFile as underlyingWriteJsonFile, } from '@metamask/action-utils'; +import fs from 'fs'; + import { wrapError, isErrorWithCode } from './misc-utils.js'; /** diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index 8b7d4759..bb499ed3 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -1,15 +1,16 @@ +import { when } from 'jest-when'; import os from 'os'; import path from 'path'; -import { when } from 'jest-when'; + +import * as commandLineArgumentsModule from './command-line-arguments.js'; +import * as envModule from './env.js'; +import { determineInitialParameters } from './initial-parameters.js'; +import * as projectModule from './project.js'; import { buildMockProject, buildMockPackage, createNoopWriteStream, } from '../tests/unit/helpers.js'; -import { determineInitialParameters } from './initial-parameters.js'; -import * as commandLineArgumentsModule from './command-line-arguments.js'; -import * as envModule from './env.js'; -import * as projectModule from './project.js'; jest.mock('./command-line-arguments'); jest.mock('./env'); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index 5ed3f54e..acd82087 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -1,5 +1,6 @@ import os from 'os'; import path from 'path'; + import { readCommandLineArguments } from './command-line-arguments.js'; import { WriteStreamLike } from './fs.js'; import { readProject, Project } from './project.js'; @@ -7,14 +8,18 @@ import { readProject, Project } from './project.js'; /** * The type of release being created as determined by the parent release. * - * - An *ordinary* release includes features or fixes applied against the - * latest release and is designated by bumping the first part of that release's - * version string. + * - An *ordinary* release includes features or fixes applied against the latest + * release and is designated by bumping the first part of that release's + * version string. * - A *backport* release includes fixes applied against a previous release and - * is designated by bumping the second part of that release's version string. + * is designated by bumping the second part of that release's version string. */ export type ReleaseType = 'ordinary' | 'backport'; +/** + * Various pieces of information that the tool uses to run, derived from + * command-line arguments. + */ type InitialParameters = { project: Project; tempDirectoryPath: string; diff --git a/src/main.test.ts b/src/main.test.ts index 6e51dc73..a7cbdf93 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,9 +1,10 @@ import fs from 'fs'; -import { buildMockProject } from '../tests/unit/helpers.js'; -import { main } from './main.js'; + import * as initialParametersModule from './initial-parameters.js'; +import { main } from './main.js'; import * as monorepoWorkflowOperations from './monorepo-workflow-operations.js'; import * as ui from './ui.js'; +import { buildMockProject } from '../tests/unit/helpers.js'; jest.mock('./initial-parameters'); jest.mock('./monorepo-workflow-operations'); diff --git a/src/main.ts b/src/main.ts index 88ba8522..184bb909 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import type { WriteStream } from 'fs'; + import { determineInitialParameters } from './initial-parameters.js'; import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; import { startUI } from './ui.js'; @@ -25,7 +26,7 @@ export async function main({ cwd: string; stdout: Pick; stderr: Pick; -}) { +}): Promise { const { project, tempDirectoryPath, diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index cccf8a6a..f9a6252d 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -1,5 +1,6 @@ -import * as whichModule from 'which'; import * as execaModule from 'execa'; +import * as whichModule from 'which'; + import { isErrorWithCode, isErrorWithMessage, diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 0c1e777c..e4abd3b1 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -1,8 +1,8 @@ -import which from 'which'; -import { execa, Options } from 'execa'; +import { getErrorMessage, isObject } from '@metamask/utils'; import createDebug from 'debug'; +import { execa, Options } from 'execa'; import { ErrorWithCause } from 'pony-cause'; -import { isObject } from '@metamask/utils'; +import which from 'which'; export { isTruthyString } from '@metamask/action-utils'; export { hasProperty, isNullOrUndefined } from '@metamask/utils'; @@ -94,18 +94,23 @@ export function isErrorWithStack(error: unknown): error is { stack: string } { * something throwable). * @returns A new error object. */ -export function wrapError(message: string, originalError: unknown) { +export function wrapError( + message: string, + originalError: unknown, +): Error & { code?: string } { if (isError(originalError)) { - const error: any = new ErrorWithCause(message, { cause: originalError }); + const error = new ErrorWithCause(message, { cause: originalError }); if (isErrorWithCode(originalError)) { + // @ts-expect-error `code` does not exist on ErrorWithCause, but we add it + // anyway error.code = originalError.code; } return error; } - return new Error(`${message}: ${originalError}`); + return new Error(`${message}: ${getErrorMessage(originalError)}`); } /** diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index cd47c022..a5a68e2d 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -1,19 +1,29 @@ import fs from 'fs'; -import path from 'path'; import { when } from 'jest-when'; +import path from 'path'; import { MockWritable } from 'stdio-mock'; -import { withSandbox, Sandbox, isErrorWithCode } from '../tests/helpers.js'; -import { buildMockProject, Require } from '../tests/unit/helpers.js'; -import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; -import * as editorModule from './editor.js'; + +import { determineEditor } from './editor.js'; import type { Editor } from './editor.js'; -import * as releaseSpecificationModule from './release-specification.js'; -import type { ReleaseSpecification } from './release-specification.js'; -import * as releasePlanModule from './release-plan.js'; +import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; +import { Project } from './project.js'; +import { executeReleasePlan, planRelease } from './release-plan.js'; import type { ReleasePlan } from './release-plan.js'; -import * as repoModule from './repo.js'; -import * as yarnCommands from './yarn-commands.js'; -import * as workflowOperations from './workflow-operations.js'; +import { + generateReleaseSpecificationTemplateForMonorepo, + waitForUserToEditReleaseSpecification, + validateReleaseSpecification, +} from './release-specification.js'; +import type { ReleaseSpecification } from './release-specification.js'; +import { commitAllChanges } from './repo.js'; +import * as workflowOperationsModule from './workflow-operations.js'; +import { + deduplicateDependencies, + fixConstraints, + updateYarnLockfile, +} from './yarn-commands.js'; +import { withSandbox, Sandbox, isErrorWithCode } from '../tests/helpers.js'; +import { buildMockProject, Require } from '../tests/unit/helpers.js'; jest.mock('./editor'); jest.mock('./release-plan'); @@ -21,6 +31,23 @@ jest.mock('./release-specification'); jest.mock('./repo'); jest.mock('./yarn-commands.js'); +const determineEditorMock = jest.mocked(determineEditor); +const generateReleaseSpecificationTemplateForMonorepoMock = jest.mocked( + generateReleaseSpecificationTemplateForMonorepo, +); +const waitForUserToEditReleaseSpecificationMock = jest.mocked( + waitForUserToEditReleaseSpecification, +); +const validateReleaseSpecificationMock = jest.mocked( + validateReleaseSpecification, +); +const planReleaseMock = jest.mocked(planRelease); +const executeReleasePlanMock = jest.mocked(executeReleasePlan); +const commitAllChangesMock = jest.mocked(commitAllChanges); +const fixConstraintsMock = jest.mocked(fixConstraints); +const updateYarnLockfileMock = jest.mocked(updateYarnLockfile); +const deduplicateDependenciesMock = jest.mocked(deduplicateDependencies); + /** * Tests the given path to determine whether it represents a file. * @@ -40,42 +67,6 @@ async function fileExists(entryPath: string): Promise { } } -/** - * Mocks the dependencies for `followMonorepoWorkflow`. - * - * @returns The corresponding mock functions for each of the dependencies. - */ -function getDependencySpies() { - return { - determineEditorSpy: jest.spyOn(editorModule, 'determineEditor'), - createReleaseBranchSpy: jest.spyOn( - workflowOperations, - 'createReleaseBranch', - ), - generateReleaseSpecificationTemplateForMonorepoSpy: jest.spyOn( - releaseSpecificationModule, - 'generateReleaseSpecificationTemplateForMonorepo', - ), - waitForUserToEditReleaseSpecificationSpy: jest.spyOn( - releaseSpecificationModule, - 'waitForUserToEditReleaseSpecification', - ), - validateReleaseSpecificationSpy: jest.spyOn( - releaseSpecificationModule, - 'validateReleaseSpecification', - ), - planReleaseSpy: jest.spyOn(releasePlanModule, 'planRelease'), - executeReleasePlanSpy: jest.spyOn(releasePlanModule, 'executeReleasePlan'), - commitAllChangesSpy: jest.spyOn(repoModule, 'commitAllChanges'), - fixConstraintsSpy: jest.spyOn(yarnCommands, 'fixConstraints'), - updateYarnLockfileSpy: jest.spyOn(yarnCommands, 'updateYarnLockfile'), - deduplicateDependenciesSpy: jest.spyOn( - yarnCommands, - 'deduplicateDependencies', - ), - }; -} - /** * Builds a release specification object for use in tests. All properties have * default values, so you can specify only the properties you care about. @@ -178,20 +169,16 @@ async function setupFollowMonorepoWorkflow({ errorUponPlanningRelease?: Error; errorUponExecutingReleasePlan?: Error; releaseVersion?: string; -}) { - const { - determineEditorSpy, - createReleaseBranchSpy, - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - validateReleaseSpecificationSpy, - planReleaseSpy, - executeReleasePlanSpy, - commitAllChangesSpy, - fixConstraintsSpy, - updateYarnLockfileSpy, - deduplicateDependenciesSpy, - } = getDependencySpies(); +}): Promise<{ + project: Project; + projectDirectoryPath: string; + stdout: MockWritable; + stderr: MockWritable; + releaseSpecification: ReleaseSpecification; + releasePlan: ReleasePlan; + releaseVersion: string; + releaseSpecificationPath: string; +}> { const editor = buildMockEditor(); const releaseSpecificationPath = path.join( sandbox.directoryPath, @@ -205,33 +192,33 @@ async function setupFollowMonorepoWorkflow({ const project = buildMockProject({ directoryPath: projectDirectoryPath }); const stdout = new MockWritable(); const stderr = new MockWritable(); - determineEditorSpy.mockResolvedValue(isEditorAvailable ? editor : null); - when(generateReleaseSpecificationTemplateForMonorepoSpy) + determineEditorMock.mockResolvedValue(isEditorAvailable ? editor : null); + when(generateReleaseSpecificationTemplateForMonorepoMock) .calledWith({ project, isEditorAvailable }) .mockResolvedValue(''); if (errorUponEditingReleaseSpec) { - when(waitForUserToEditReleaseSpecificationSpy) + when(waitForUserToEditReleaseSpecificationMock) .calledWith(releaseSpecificationPath, editor) .mockRejectedValue(errorUponEditingReleaseSpec); } else { - when(waitForUserToEditReleaseSpecificationSpy) + when(waitForUserToEditReleaseSpecificationMock) .calledWith(releaseSpecificationPath, editor) .mockResolvedValue(); } if (errorUponValidatingReleaseSpec) { - when(validateReleaseSpecificationSpy) + when(validateReleaseSpecificationMock) .calledWith(project, releaseSpecificationPath) .mockRejectedValue(errorUponValidatingReleaseSpec); } else { - when(validateReleaseSpecificationSpy) + when(validateReleaseSpecificationMock) .calledWith(project, releaseSpecificationPath) .mockResolvedValue(releaseSpecification); } if (errorUponPlanningRelease) { - when(planReleaseSpy) + when(planReleaseMock) .calledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, @@ -239,7 +226,7 @@ async function setupFollowMonorepoWorkflow({ }) .mockRejectedValue(errorUponPlanningRelease); } else { - when(planReleaseSpy) + when(planReleaseMock) .calledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, @@ -249,16 +236,16 @@ async function setupFollowMonorepoWorkflow({ } if (errorUponExecutingReleasePlan) { - when(executeReleasePlanSpy) + when(executeReleasePlanMock) .calledWith(project, releasePlan, stderr) .mockRejectedValue(errorUponExecutingReleasePlan); } else { - when(executeReleasePlanSpy) + when(executeReleasePlanMock) .calledWith(project, releasePlan, stderr) .mockResolvedValue(undefined); } - when(commitAllChangesSpy) + when(commitAllChangesMock) .calledWith(projectDirectoryPath, '') .mockResolvedValue(); @@ -274,19 +261,10 @@ async function setupFollowMonorepoWorkflow({ projectDirectoryPath, stdout, stderr, - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, releaseSpecification, - planReleaseSpy, - executeReleasePlanSpy, - commitAllChangesSpy, - createReleaseBranchSpy, releasePlan, releaseVersion, releaseSpecificationPath, - fixConstraintsSpy, - updateYarnLockfileSpy, - deduplicateDependenciesSpy, }; } @@ -295,12 +273,17 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is available', () => { it('should call createReleaseBranch with the correct arguments if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, createReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ + const createReleaseBranchMock = jest.spyOn( + workflowOperationsModule, + 'createReleaseBranch', + ); + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - }); + }, + ); await followMonorepoWorkflow({ project, @@ -312,7 +295,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(createReleaseBranchSpy).toHaveBeenCalledWith({ + expect(createReleaseBranchMock).toHaveBeenCalledWith({ project, releaseType: 'ordinary', }); @@ -321,12 +304,17 @@ describe('monorepo-workflow-operations', () => { it('should call createReleaseBranch with the correct arguments if given releaseType: "backport"', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, createReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ + const createReleaseBranchMock = jest.spyOn( + workflowOperationsModule, + 'createReleaseBranch', + ); + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - }); + }, + ); await followMonorepoWorkflow({ project, @@ -338,7 +326,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(createReleaseBranchSpy).toHaveBeenCalledWith({ + expect(createReleaseBranchMock).toHaveBeenCalledWith({ project, releaseType: 'backport', }); @@ -347,17 +335,12 @@ describe('monorepo-workflow-operations', () => { it('plans an ordinary release if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -369,7 +352,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '2.0.0', @@ -379,17 +362,12 @@ describe('monorepo-workflow-operations', () => { it('plans a backport release if given releaseType: "backport"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -401,7 +379,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '1.1.0', @@ -412,24 +390,19 @@ describe('monorepo-workflow-operations', () => { it('follows the workflow correctly when executed twice', async () => { await withSandbox(async (sandbox) => { const releaseVersion = '1.1.0'; - const { - project, - stdout, - stderr, - createReleaseBranchSpy, - commitAllChangesSpy, - projectDirectoryPath, - fixConstraintsSpy, - updateYarnLockfileSpy, - deduplicateDependenciesSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - releaseVersion, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - }); + const createReleaseBranchMock = jest.spyOn( + workflowOperationsModule, + 'createReleaseBranch', + ); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + releaseVersion, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); - createReleaseBranchSpy.mockResolvedValueOnce({ + createReleaseBranchMock.mockResolvedValueOnce({ version: releaseVersion, firstRun: true, }); @@ -444,40 +417,40 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(createReleaseBranchSpy).toHaveBeenCalledTimes(1); - expect(createReleaseBranchSpy).toHaveBeenLastCalledWith({ + expect(createReleaseBranchMock).toHaveBeenCalledTimes(1); + expect(createReleaseBranchMock).toHaveBeenLastCalledWith({ project, releaseType: 'ordinary', }); - expect(commitAllChangesSpy).toHaveBeenCalledTimes(2); - expect(commitAllChangesSpy).toHaveBeenNthCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledTimes(2); + expect(commitAllChangesMock).toHaveBeenNthCalledWith( 1, projectDirectoryPath, `Initialize Release ${releaseVersion}`, ); - expect(commitAllChangesSpy).toHaveBeenNthCalledWith( + expect(commitAllChangesMock).toHaveBeenNthCalledWith( 2, projectDirectoryPath, `Update Release ${releaseVersion}`, ); - expect(fixConstraintsSpy).toHaveBeenCalledTimes(1); - expect(fixConstraintsSpy).toHaveBeenCalledWith(projectDirectoryPath); + expect(fixConstraintsMock).toHaveBeenCalledTimes(1); + expect(fixConstraintsMock).toHaveBeenCalledWith(projectDirectoryPath); - expect(updateYarnLockfileSpy).toHaveBeenCalledTimes(1); - expect(updateYarnLockfileSpy).toHaveBeenCalledWith( + expect(updateYarnLockfileMock).toHaveBeenCalledTimes(1); + expect(updateYarnLockfileMock).toHaveBeenCalledWith( projectDirectoryPath, ); - expect(deduplicateDependenciesSpy).toHaveBeenCalledTimes(1); - expect(deduplicateDependenciesSpy).toHaveBeenCalledWith( + expect(deduplicateDependenciesMock).toHaveBeenCalledTimes(1); + expect(deduplicateDependenciesMock).toHaveBeenCalledWith( projectDirectoryPath, ); // Second call of followMonorepoWorkflow - createReleaseBranchSpy.mockResolvedValueOnce({ + createReleaseBranchMock.mockResolvedValueOnce({ version: releaseVersion, firstRun: false, // It's no longer the first run }); @@ -492,14 +465,14 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(createReleaseBranchSpy).toHaveBeenCalledTimes(2); - expect(createReleaseBranchSpy).toHaveBeenLastCalledWith({ + expect(createReleaseBranchMock).toHaveBeenCalledTimes(2); + expect(createReleaseBranchMock).toHaveBeenLastCalledWith({ project, releaseType: 'ordinary', }); - expect(commitAllChangesSpy).toHaveBeenCalledTimes(3); - expect(commitAllChangesSpy).toHaveBeenNthCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledTimes(3); + expect(commitAllChangesMock).toHaveBeenNthCalledWith( 3, projectDirectoryPath, `Update Release ${releaseVersion}`, @@ -509,18 +482,13 @@ describe('monorepo-workflow-operations', () => { it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - executeReleasePlanSpy, - releasePlan, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - releaseVersion: '2.0.0', - }); + const { project, stdout, stderr, releasePlan } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseVersion: '2.0.0', + }); await followMonorepoWorkflow({ project, @@ -532,7 +500,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).toHaveBeenCalledWith( + expect(executeReleasePlanMock).toHaveBeenCalledWith( project, releasePlan, stderr, @@ -542,18 +510,13 @@ describe('monorepo-workflow-operations', () => { it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - releaseVersion: '4.38.0', - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseVersion: '4.38.0', + }); await followMonorepoWorkflow({ project, @@ -565,12 +528,12 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Initialize Release 2.0.0', ); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -602,13 +565,14 @@ describe('monorepo-workflow-operations', () => { it('does not attempt to execute the release spec if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponEditingReleaseSpec: new Error('oops'), - }); + }, + ); await expect( followMonorepoWorkflow({ @@ -622,24 +586,19 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the final release update commit when release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -653,11 +612,11 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Initialize Release 2.0.0', ); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -802,12 +761,13 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is not available', () => { it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: false, - }); + }, + ); await followMonorepoWorkflow({ project, @@ -819,23 +779,18 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: false, - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, @@ -847,7 +802,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -907,15 +862,12 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is false and the release spec file already exists', () => { it('does not open the editor', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - waitForUserToEditReleaseSpecificationSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - }); + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { + sandbox, + doesReleaseSpecFileExist: true, + }, + ); await followMonorepoWorkflow({ project, @@ -928,23 +880,18 @@ describe('monorepo-workflow-operations', () => { }); expect( - waitForUserToEditReleaseSpecificationSpy, + waitForUserToEditReleaseSpecificationMock, ).not.toHaveBeenCalled(); }); }); it('plans an ordinary release if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); await followMonorepoWorkflow({ project, @@ -956,7 +903,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '2.0.0', @@ -966,16 +913,11 @@ describe('monorepo-workflow-operations', () => { it('plans a backport release if given releaseType: "backport"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); await followMonorepoWorkflow({ project, @@ -987,7 +929,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '1.1.0', @@ -997,17 +939,12 @@ describe('monorepo-workflow-operations', () => { it('attempts to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - executeReleasePlanSpy, - releasePlan, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - releaseVersion: '2.0.0', - }); + const { project, stdout, stderr, releasePlan } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + releaseVersion: '2.0.0', + }); await followMonorepoWorkflow({ project, @@ -1019,7 +956,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).toHaveBeenCalledWith( + expect(executeReleasePlanMock).toHaveBeenCalledWith( project, releasePlan, stderr, @@ -1029,17 +966,12 @@ describe('monorepo-workflow-operations', () => { it('should make exactly two commits named after the generated release version if validating and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - releaseVersion: '4.38.0', - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + releaseVersion: '4.38.0', + }); await followMonorepoWorkflow({ project, @@ -1051,12 +983,12 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Initialize Release 2.0.0', ); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -1169,17 +1101,12 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is available', () => { it('plans an ordinary release if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -1191,7 +1118,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '2.0.0', @@ -1201,17 +1128,12 @@ describe('monorepo-workflow-operations', () => { it('plans a backport release if given releaseType: "backport"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -1223,7 +1145,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '1.1.0', @@ -1233,18 +1155,13 @@ describe('monorepo-workflow-operations', () => { it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - executeReleasePlanSpy, - releasePlan, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - releaseVersion: '2.0.0', - }); + const { project, stdout, stderr, releasePlan } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseVersion: '2.0.0', + }); await followMonorepoWorkflow({ project, @@ -1256,7 +1173,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).toHaveBeenCalledWith( + expect(executeReleasePlanMock).toHaveBeenCalledWith( project, releasePlan, stderr, @@ -1266,18 +1183,13 @@ describe('monorepo-workflow-operations', () => { it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - releaseVersion: '4.38.0', - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + releaseVersion: '4.38.0', + }); await followMonorepoWorkflow({ project, @@ -1289,12 +1201,12 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Initialize Release 2.0.0', ); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -1326,13 +1238,14 @@ describe('monorepo-workflow-operations', () => { it('does not attempt to execute the release spec if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponEditingReleaseSpec: new Error('oops'), - }); + }, + ); await expect( followMonorepoWorkflow({ @@ -1346,24 +1259,19 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the release update commit if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -1377,7 +1285,7 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -1522,12 +1430,13 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is not available', () => { it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: false, - }); + }, + ); await followMonorepoWorkflow({ project, @@ -1539,23 +1448,18 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: false, - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, @@ -1567,7 +1471,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -1627,16 +1531,13 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, and an editor is available', () => { it('generates a new release spec instead of using the existing one', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - generateReleaseSpecificationTemplateForMonorepoSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - }); + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + }, + ); await followMonorepoWorkflow({ project, @@ -1649,7 +1550,7 @@ describe('monorepo-workflow-operations', () => { }); expect( - generateReleaseSpecificationTemplateForMonorepoSpy, + generateReleaseSpecificationTemplateForMonorepoMock, ).toHaveBeenCalledWith({ project, isEditorAvailable: true, @@ -1659,17 +1560,12 @@ describe('monorepo-workflow-operations', () => { it('plans an ordinary release if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -1681,7 +1577,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '2.0.0', @@ -1691,17 +1587,12 @@ describe('monorepo-workflow-operations', () => { it('plans a backport release if given releaseType: "backport"', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - releaseSpecification, - planReleaseSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - }); + const { project, stdout, stderr, releaseSpecification } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + }); await followMonorepoWorkflow({ project, @@ -1713,7 +1604,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(planReleaseSpy).toHaveBeenCalledWith({ + expect(planReleaseMock).toHaveBeenCalledWith({ project, releaseSpecificationPackages: releaseSpecification.packages, newReleaseVersion: '1.1.0', @@ -1723,18 +1614,13 @@ describe('monorepo-workflow-operations', () => { it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - executeReleasePlanSpy, - releasePlan, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - releaseVersion: '2.0.0', - }); + const { project, stdout, stderr, releasePlan } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + releaseVersion: '2.0.0', + }); await followMonorepoWorkflow({ project, @@ -1746,7 +1632,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).toHaveBeenCalledWith( + expect(executeReleasePlanMock).toHaveBeenCalledWith( project, releasePlan, stderr, @@ -1756,18 +1642,13 @@ describe('monorepo-workflow-operations', () => { it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - releaseVersion: '4.38.0', - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + releaseVersion: '4.38.0', + }); await followMonorepoWorkflow({ project, @@ -1779,12 +1660,12 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Initialize Release 2.0.0', ); - expect(commitAllChangesSpy).toHaveBeenCalledWith( + expect(commitAllChangesMock).toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -1816,13 +1697,14 @@ describe('monorepo-workflow-operations', () => { it('does not attempt to execute the release spec if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, errorUponEditingReleaseSpec: new Error('oops'), - }); + }, + ); await expect( followMonorepoWorkflow({ @@ -1836,24 +1718,19 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the release update commit if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -1867,7 +1744,7 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); @@ -2012,16 +1889,13 @@ describe('monorepo-workflow-operations', () => { describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, and an editor is not available', () => { it('generates a new release spec instead of using the existing one', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - generateReleaseSpecificationTemplateForMonorepoSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: false, - }); + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, + }, + ); await followMonorepoWorkflow({ project, @@ -2034,7 +1908,7 @@ describe('monorepo-workflow-operations', () => { }); expect( - generateReleaseSpecificationTemplateForMonorepoSpy, + generateReleaseSpecificationTemplateForMonorepoMock, ).toHaveBeenCalledWith({ project, isEditorAvailable: false, @@ -2044,12 +1918,13 @@ describe('monorepo-workflow-operations', () => { it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, executeReleasePlanSpy } = - await setupFollowMonorepoWorkflow({ + const { project, stdout, stderr } = await setupFollowMonorepoWorkflow( + { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: false, - }); + }, + ); await followMonorepoWorkflow({ project, @@ -2061,23 +1936,18 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(executeReleasePlanMock).not.toHaveBeenCalled(); }); }); it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { - project, - stdout, - stderr, - commitAllChangesSpy, - projectDirectoryPath, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: false, - }); + const { project, stdout, stderr, projectDirectoryPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, @@ -2089,7 +1959,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + expect(commitAllChangesMock).not.toHaveBeenCalledWith( projectDirectoryPath, 'Update Release 2.0.0', ); diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 231fc37f..f9bc28a1 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -1,12 +1,13 @@ import type { WriteStream } from 'fs'; import path from 'path'; + +import { determineEditor } from './editor.js'; import { ensureDirectoryPathExists, fileExists, removeFile, writeFile, } from './fs.js'; -import { determineEditor } from './editor.js'; import { ReleaseType } from './initial-parameters.js'; import { Project, @@ -14,12 +15,12 @@ import { restoreChangelogsForSkippedPackages, } from './project.js'; import { planRelease, executeReleasePlan } from './release-plan.js'; -import { commitAllChanges } from './repo.js'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, } from './release-specification.js'; +import { commitAllChanges } from './repo.js'; import { createReleaseBranch } from './workflow-operations.js'; import { deduplicateDependencies, @@ -76,7 +77,7 @@ export async function followMonorepoWorkflow({ defaultBranch: string; stdout: Pick; stderr: Pick; -}) { +}): Promise { const { version: newReleaseVersion, firstRun } = await createReleaseBranch({ project, releaseType, diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index 08c02cca..3c0afd8c 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -1,8 +1,9 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; -import { withSandbox } from '../tests/helpers.js'; + import { readPackageManifest } from './package-manifest.js'; +import { withSandbox } from '../tests/helpers.js'; describe('package-manifest', () => { describe('readPackageManifest', () => { diff --git a/src/package-manifest.ts b/src/package-manifest.ts index eea83e56..904750b7 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -1,10 +1,11 @@ -import path from 'path'; import { ManifestFieldNames as PackageManifestFieldNames, ManifestDependencyFieldNames as PackageManifestDependenciesFieldNames, } from '@metamask/action-utils'; import { isPlainObject } from '@metamask/utils'; +import path from 'path'; import validateNPMPackageName from 'validate-npm-package-name'; + import { readJsonObjectFile } from './fs.js'; import { isTruthyString } from './misc-utils.js'; import { semver, SemVer } from './semver.js'; @@ -23,8 +24,8 @@ export type UnvalidatedPackageManifest = Readonly>; * @property version - The version of the package. * @property private - Whether the package is private. * @property workspaces - Paths to subpackages within the package. - * @property bundledDependencies - The set of packages that are expected to be - * bundled when publishing the package. + * @property dependencies - The declared dependencies. + * @property peerDependencies - The declared peer dependencies. */ export type ValidatedPackageManifest = { readonly [PackageManifestFieldNames.Name]: string; @@ -60,7 +61,7 @@ function buildPackageManifestFieldValidationErrorMessage({ parentDirectory: string; fieldName: keyof UnvalidatedPackageManifest; verbPhrase: string; -}) { +}): string { const subject = isTruthyString(manifest[PackageManifestFieldNames.Name]) ? `The value of "${fieldName}" in the manifest for "${ manifest[PackageManifestFieldNames.Name] @@ -184,7 +185,7 @@ function isValidPackageManifestDependencyValue( validateNPMPackageName(redirectedName)?.validForOldPackages && isValidPackageManifestVersionField(redirectedVersion) ); - } catch (e) /* istanbul ignore next */ { + } catch /* istanbul ignore next */ { return false; } } diff --git a/src/package.test.ts b/src/package.test.ts index 6e972da6..c097ec3f 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -1,16 +1,12 @@ +import * as autoChangelog from '@metamask/auto-changelog'; import fs from 'fs'; -import path from 'path'; import { when } from 'jest-when'; -import * as autoChangelog from '@metamask/auto-changelog'; +import path from 'path'; import { SemVer } from 'semver'; import { MockWritable } from 'stdio-mock'; -import { buildChangelog, withSandbox } from '../tests/helpers.js'; -import { - buildMockPackage, - buildMockProject, - buildMockManifest, - createNoopWriteStream, -} from '../tests/unit/helpers.js'; + +import * as fsModule from './fs.js'; +import * as packageManifestModule from './package-manifest.js'; import { formatChangelog, readMonorepoRootPackage, @@ -18,9 +14,14 @@ import { updatePackage, updatePackageChangelog, } from './package.js'; -import * as fsModule from './fs.js'; -import * as packageManifestModule from './package-manifest.js'; import * as repoModule from './repo.js'; +import { buildChangelog, withSandbox } from '../tests/helpers.js'; +import { + buildMockPackage, + buildMockProject, + buildMockManifest, + createNoopWriteStream, +} from '../tests/unit/helpers.js'; jest.mock('./package-manifest'); jest.mock('./repo'); diff --git a/src/package.ts b/src/package.ts index 15f5f529..36e44455 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,9 +1,10 @@ +import { parseChangelog, updateChangelog } from '@metamask/auto-changelog'; import fs, { WriteStream } from 'fs'; import path from 'path'; -import { format } from 'util'; -import { parseChangelog, updateChangelog } from '@metamask/auto-changelog'; -import { format as formatPrettier } from 'prettier/standalone'; import * as markdown from 'prettier/plugins/markdown'; +import { format as formatPrettier } from 'prettier/standalone'; +import { format } from 'util'; + import { WriteStreamLike, readFile, writeFile, writeJsonFile } from './fs.js'; import { isErrorWithCode } from './misc-utils.js'; import { @@ -25,9 +26,13 @@ const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; * @property directoryPath - The path to the directory where the package is * located. * @property manifestPath - The path to the manifest file. - * @property manifest - The data extracted from the manifest. + * @property unvalidatedManifest - The data extracted from the manifest. + * @property validatedManifest - The data extracted from the manifest, in a + * typed version. * @property changelogPath - The path to the changelog file (which may or may * not exist). + * @property hasChangesSinceLatestRelease - Whether there have been changes to + * the package since its latest release. */ export type Package = { directoryPath: string; @@ -45,7 +50,9 @@ export type Package = { * @param packageVersion - The version of the package. * @returns An array of possible release tag names. */ -function generateMonorepoRootPackageReleaseTagName(packageVersion: string) { +function generateMonorepoRootPackageReleaseTagName( + packageVersion: string, +): string { return `v${packageVersion}`; } @@ -61,7 +68,7 @@ function generateMonorepoRootPackageReleaseTagName(packageVersion: string) { function generateMonorepoWorkspacePackageReleaseTagName( packageName: string, packageVersion: string, -) { +): string { return `${packageName}@${packageVersion}`; } @@ -89,7 +96,7 @@ export async function readMonorepoRootPackage({ await readPackageManifest(manifestPath); const expectedTagNameForLatestRelease = generateMonorepoRootPackageReleaseTagName( - validatedManifest.version.toString(), + validatedManifest.version.version, ); const matchingTagNameForLatestRelease = projectTagNames.find( (tagName) => tagName === expectedTagNameForLatestRelease, @@ -164,10 +171,10 @@ export async function readMonorepoWorkspacePackage({ const expectedTagNameForWorkspacePackageLatestRelease = generateMonorepoWorkspacePackageReleaseTagName( validatedManifest.name, - validatedManifest.version.toString(), + validatedManifest.version.version, ); const expectedTagNameForRootPackageLatestRelease = - generateMonorepoRootPackageReleaseTagName(rootPackageVersion.toString()); + generateMonorepoRootPackageReleaseTagName(rootPackageVersion.version); const matchingTagNameForWorkspacePackageLatestRelease = projectTagNames.find( (tagName) => tagName === expectedTagNameForWorkspacePackageLatestRelease, ); @@ -289,7 +296,7 @@ export async function migrateUnreleasedChangelogChangesToRelease({ * @param changelog - The changelog to format. * @returns The formatted changelog. */ -export async function formatChangelog(changelog: string) { +export async function formatChangelog(changelog: string): Promise { return await formatPrettier(changelog, { parser: 'markdown', plugins: [markdown], diff --git a/src/project.test.ts b/src/project.test.ts index 5907bd5a..6f79551f 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -1,25 +1,26 @@ +import * as actionUtils from '@metamask/action-utils'; import { mkdir } from 'fs/promises'; -import path from 'path'; import { when } from 'jest-when'; +import path from 'path'; import { SemVer } from 'semver'; -import * as actionUtils from '@metamask/action-utils'; -import { withProtectedProcessEnv, withSandbox } from '../tests/helpers.js'; -import { - buildMockPackage, - buildMockProject, - createNoopWriteStream, -} from '../tests/unit/helpers.js'; + +import * as fs from './fs.js'; import * as miscUtils from './misc-utils.js'; +import * as packageModule from './package.js'; import { getValidRepositoryUrl, readProject, restoreChangelogsForSkippedPackages, updateChangelogsForChangedPackages, } from './project.js'; -import * as packageModule from './package.js'; -import * as repoModule from './repo.js'; -import * as fs from './fs.js'; import { IncrementableVersionParts } from './release-specification.js'; +import * as repoModule from './repo.js'; +import { withProtectedProcessEnv, withSandbox } from '../tests/helpers.js'; +import { + buildMockPackage, + buildMockProject, + createNoopWriteStream, +} from '../tests/unit/helpers.js'; jest.mock('./package'); jest.mock('./repo'); @@ -135,6 +136,10 @@ describe('project', () => { describe('if the `npm_package_repository_url` environment variable is set', () => { it('returns the HTTPS version of this URL', async () => { await withProtectedProcessEnv(async () => { + // This function consults an environment variable that NPM sets + // in order to know the repository URL. + // Changes to environment variables are protected in this test. + // eslint-disable-next-line n/no-process-env process.env.npm_package_repository_url = 'git@github.com:example-org/example-repo.git'; const packageManifest = {}; diff --git a/src/project.ts b/src/project.ts index fe639f14..e3494b39 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,25 +1,26 @@ -import { WriteStream } from 'fs'; -import { resolve } from 'path'; import { getWorkspaceLocations } from '@metamask/action-utils'; import { isPlainObject } from '@metamask/utils'; +import { WriteStream } from 'fs'; +import { resolve } from 'path'; + import { WriteStreamLike, fileExists } from './fs.js'; +import { + convertToHttpsGitHubRepositoryUrl, + getStdoutFromCommand, +} from './misc-utils.js'; +import { + PackageManifestFieldNames, + UnvalidatedPackageManifest, +} from './package-manifest.js'; import { Package, readMonorepoRootPackage, readMonorepoWorkspacePackage, updatePackageChangelog, } from './package.js'; +import { ReleaseSpecification } from './release-specification.js'; import { getTagNames, restoreFiles } from './repo.js'; import { SemVer } from './semver.js'; -import { - PackageManifestFieldNames, - UnvalidatedPackageManifest, -} from './package-manifest.js'; -import { ReleaseSpecification } from './release-specification.js'; -import { - convertToHttpsGitHubRepositoryUrl, - getStdoutFromCommand, -} from './misc-utils.js'; /** * The release version of the root package of a monorepo extracted from its @@ -49,6 +50,8 @@ type ReleaseVersion = { * project is a monorepo). * @property workspacePackages - Information about packages that are referenced * via workspaces (assuming that the project is a monorepo). + * @property isMonorepo - Whether the project is a monorepo. + * @property releaseVersion - The new version that is being released. */ export type Project = { directoryPath: string; @@ -124,12 +127,9 @@ export async function readProject( }); }), ) - ).reduce( - (obj, pkg) => { - return { ...obj, [pkg.validatedManifest.name]: pkg }; - }, - {} as Record, - ); + ).reduce>((obj, pkg) => { + return { ...obj, [pkg.validatedManifest.name]: pkg }; + }, {}); const isMonorepo = Object.keys(workspacePackages).length > 0; @@ -174,6 +174,7 @@ export async function getValidRepositoryUrl( repositoryDirectoryPath: string, ): Promise { // Set automatically by NPM or Yarn 1.x + // eslint-disable-next-line n/no-process-env const npmPackageRepositoryUrl = process.env.npm_package_repository_url; if (npmPackageRepositoryUrl) { @@ -222,7 +223,7 @@ export async function updateChangelogsForChangedPackages({ .filter( ({ hasChangesSinceLatestRelease }) => hasChangesSinceLatestRelease, ) - .map((pkg) => + .map(async (pkg) => updatePackageChangelog({ project, package: pkg, diff --git a/src/release-plan.test.ts b/src/release-plan.test.ts index c0d7692e..18a3b4e8 100644 --- a/src/release-plan.test.ts +++ b/src/release-plan.test.ts @@ -1,9 +1,10 @@ import fs from 'fs'; import { SemVer } from 'semver'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; + +import * as packageUtils from './package.js'; import { planRelease, executeReleasePlan } from './release-plan.js'; import { IncrementableVersionParts } from './release-specification.js'; -import * as packageUtils from './package.js'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; jest.mock('./package'); diff --git a/src/release-plan.ts b/src/release-plan.ts index df35039d..b61356ec 100644 --- a/src/release-plan.ts +++ b/src/release-plan.ts @@ -1,5 +1,6 @@ import { WriteStream } from 'fs'; import { SemVer } from 'semver'; + import { debug } from './misc-utils.js'; import { Package, updatePackage } from './package.js'; import { Project } from './project.js'; @@ -31,9 +32,11 @@ export type ReleasePlan = { * Instructions for how to update a package within a project in order to prepare * it for a new release. * - * @property package - Information about the package. - * @property newVersion - The new version for the package, as a - * SemVer-compatible string. + * Properties: + * + * - `package` - Information about the package. + * - `newVersion` - The new version for the package, as a SemVer-compatible + * string. */ export type PackageReleasePlan = { package: Package; @@ -75,11 +78,11 @@ export async function planRelease({ const newVersion = versionSpecifier instanceof SemVer ? versionSpecifier - : new SemVer(currentVersion.toString()).inc(versionSpecifier); + : new SemVer(currentVersion.version).inc(versionSpecifier); return { package: pkg, - newVersion: newVersion.toString(), + newVersion: newVersion.version, }; }); @@ -102,7 +105,7 @@ export async function executeReleasePlan( project: Project, releasePlan: ReleasePlan, stderr: Pick, -) { +): Promise { await Promise.all( releasePlan.packages.map(async (workspaceReleasePlan) => { debug( diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index fcd594f0..4264e2d9 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -1,17 +1,18 @@ import fs from 'fs'; -import path from 'path'; import { when } from 'jest-when'; +import path from 'path'; +import { SemVer } from 'semver'; import { MockWritable } from 'stdio-mock'; import YAML from 'yaml'; -import { SemVer } from 'semver'; -import { withSandbox } from '../tests/helpers.js'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; + +import * as miscUtils from './misc-utils.js'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, } from './release-specification.js'; -import * as miscUtils from './misc-utils.js'; +import { withSandbox } from '../tests/helpers.js'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; jest.mock('./misc-utils', () => { return { diff --git a/src/release-specification.ts b/src/release-specification.ts index 4cc38610..a18ad1ef 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -1,6 +1,7 @@ import fs, { WriteStream } from 'fs'; -import YAML from 'yaml'; import { diff } from 'semver'; +import YAML from 'yaml'; + import { Editor } from './editor.js'; import { readFile } from './fs.js'; import { @@ -10,32 +11,40 @@ import { isObject, runCommand, } from './misc-utils.js'; +import { Package } from './package.js'; import { Project } from './project.js'; import { isValidSemver, semver, SemVer } from './semver.js'; -import { Package } from './package.js'; /** * The SemVer-compatible parts of a version string that can be bumped by this * tool. */ -export enum IncrementableVersionParts { - major = 'major', - minor = 'minor', - patch = 'patch', -} +export const IncrementableVersionParts = { + major: 'major', + minor: 'minor', + patch: 'patch', +} as const; + +/** + * The SemVer-compatible parts of a version string that can be bumped by this + * tool. + */ +export type IncrementableVersionParts = + (typeof IncrementableVersionParts)[keyof typeof IncrementableVersionParts]; /** * Describes how to update the version for a package, either by bumping a part * of the version or by setting that version exactly. */ -type VersionSpecifier = IncrementableVersionParts | SemVer; +export type VersionSpecifier = IncrementableVersionParts | SemVer; /** * User-provided instructions for how to update this project in order to prepare * it for a new release. * - * @property packages - A mapping of package names to version specifiers. - * @property path - The path to the original release specification file. + * packages - A mapping of package names to version specifiers. + * + * path - The path to the original release specification file. */ export type ReleaseSpecification = { packages: Record; @@ -61,7 +70,7 @@ export async function generateReleaseSpecificationTemplateForMonorepo({ }: { project: Project; isEditorAvailable: boolean; -}) { +}): Promise { const afterEditingInstructions = isEditorAvailable ? ` # When you're finished, save this file and close it. The tool will update the @@ -132,7 +141,7 @@ export async function waitForUserToEditReleaseSpecification( releaseSpecificationPath: string, editor: Editor, stdout: Pick = fs.createWriteStream('/dev/null'), -) { +): Promise { let caughtError: unknown; debug( @@ -384,7 +393,7 @@ export function validateAllPackageEntries( errors.push({ message: [ `${JSON.stringify(versionSpecifierOrDirective)} is not a valid version specifier for package "${changedPackageName}"`, - `("${changedPackageName}" is at a greater version "${project.workspacePackages[changedPackageName].validatedManifest.version}")`, + `("${changedPackageName}" is at a greater version "${project.workspacePackages[changedPackageName].validatedManifest.version.version}")`, ], lineNumber, }); @@ -567,11 +576,8 @@ export async function validateReleaseSpecification( .flatMap((error) => { const itemPrefix = '* '; - if (error.lineNumber === undefined) { - return `${itemPrefix}${error.message}`; - } - - const lineNumberPrefix = `Line ${error.lineNumber}: `; + const lineNumberPrefix = + error.lineNumber === undefined ? '' : `Line ${error.lineNumber}: `; if (Array.isArray(error.message)) { return [ @@ -592,41 +598,38 @@ export async function validateReleaseSpecification( throw new Error(message); } - const packages = Object.keys(unvalidatedReleaseSpecification.packages).reduce( - (obj, packageName) => { - const versionSpecifierOrDirective = - unvalidatedReleaseSpecification.packages[packageName]; - - if ( - versionSpecifierOrDirective !== SKIP_PACKAGE_DIRECTIVE && - versionSpecifierOrDirective !== INTENTIONALLY_SKIP_PACKAGE_DIRECTIVE - ) { - if ( - Object.values(IncrementableVersionParts).includes( - // Typecast: It doesn't matter what type versionSpecifierOrDirective - // is as we are checking for inclusion. - versionSpecifierOrDirective as any, - ) - ) { - return { - ...obj, - // Typecast: We know what this is as we've checked it above. - [packageName]: - versionSpecifierOrDirective as IncrementableVersionParts, - }; - } - + const packages = Object.keys(unvalidatedReleaseSpecification.packages).reduce< + ReleaseSpecification['packages'] + >((obj, packageName) => { + const versionSpecifierOrDirective = + unvalidatedReleaseSpecification.packages[packageName]; + // Downcast this so that we can check for inclusion below. + const incrementableVersionParts = Object.values( + IncrementableVersionParts, + ) as string[]; + + if ( + versionSpecifierOrDirective !== SKIP_PACKAGE_DIRECTIVE && + versionSpecifierOrDirective !== INTENTIONALLY_SKIP_PACKAGE_DIRECTIVE + ) { + if (incrementableVersionParts.includes(versionSpecifierOrDirective)) { return { ...obj, - // Typecast: We know that this will safely parse. - [packageName]: semver.parse(versionSpecifierOrDirective) as SemVer, + // Typecast: We know what this is as we've checked it above. + [packageName]: + versionSpecifierOrDirective as IncrementableVersionParts, }; } - return obj; - }, - {} as ReleaseSpecification['packages'], - ); + return { + ...obj, + // Typecast: We know that this will safely parse. + [packageName]: semver.parse(versionSpecifierOrDirective) as SemVer, + }; + } + + return obj; + }, {}); return { packages, path: releaseSpecificationPath }; } diff --git a/src/repo.test.ts b/src/repo.test.ts index c9c81a29..957c1105 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -1,4 +1,6 @@ import { when } from 'jest-when'; + +import * as miscUtils from './misc-utils.js'; import { commitAllChanges, getTagNames, @@ -7,7 +9,6 @@ import { branchExists, restoreFiles, } from './repo.js'; -import * as miscUtils from './misc-utils.js'; jest.mock('./misc-utils'); diff --git a/src/repo.ts b/src/repo.ts index ec4074f9..bfc7a2ba 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -1,4 +1,5 @@ import path from 'path'; + import { runCommand, getStdoutFromCommand, @@ -135,7 +136,7 @@ async function getFilesChangedSince( export async function commitAllChanges( repositoryDirectoryPath: string, commitMessage: string, -) { +): Promise { await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'add', ['-A']); await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'commit', [ '-m', @@ -149,7 +150,9 @@ export async function commitAllChanges( * @param repositoryDirectoryPath - The file system path to the git repository. * @returns The name of the current branch in the specified repository. */ -export function getCurrentBranchName(repositoryDirectoryPath: string) { +export async function getCurrentBranchName( + repositoryDirectoryPath: string, +): Promise { return getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'rev-parse', [ '--abbrev-ref', 'HEAD', @@ -173,7 +176,7 @@ export async function restoreFiles( repositoryDirectoryPath: string, repositoryDefaultBranch: string, filePaths: string[], -) { +): Promise { const ancestorCommitSha = await getStdoutFromGitCommandWithin( repositoryDirectoryPath, 'merge-base', @@ -197,7 +200,7 @@ export async function restoreFiles( export async function branchExists( repositoryDirectoryPath: string, branchName: string, -) { +): Promise { const branchNames = await getLinesFromGitCommandWithin( repositoryDirectoryPath, 'branch', diff --git a/src/ui.ts b/src/ui.ts index 1f235ec4..70349d9c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,14 +1,18 @@ +import { getErrorMessage } from '@metamask/utils'; +import express, { static as expressStatic, json as expressJson } from 'express'; import type { WriteStream } from 'fs'; -import { join } from 'path'; -import express from 'express'; import open from 'open'; +import { join } from 'path'; +import { getCurrentDirectoryPath } from './dirname.js'; +import { readFile } from './fs.js'; +import { Package } from './package.js'; import { restoreChangelogsForSkippedPackages, updateChangelogsForChangedPackages, - type Project, } from './project.js'; -import { Package } from './package.js'; +import type { Project } from './project.js'; +import { executeReleasePlan, planRelease } from './release-plan.js'; import { findWorkspaceDependentNamesOfType, findCandidateDependencies, @@ -17,20 +21,20 @@ import { ReleaseSpecification, validateAllPackageEntries, } from './release-specification.js'; -import { createReleaseBranch } from './workflow-operations.js'; import { commitAllChanges } from './repo.js'; import { SemVer, semver } from './semver.js'; -import { executeReleasePlan, planRelease } from './release-plan.js'; +import { createReleaseBranch } from './workflow-operations.js'; import { deduplicateDependencies, fixConstraints, updateYarnLockfile, } from './yarn-commands.js'; -import { readFile } from './fs.js'; -import { getCurrentDirectoryPath } from './dirname.js'; const UI_BUILD_DIR = join(getCurrentDirectoryPath(), 'ui'); +/** + * The set of options that can be used to start the UI. + */ type UIOptions = { project: Project; releaseType: 'ordinary' | 'backport'; @@ -84,20 +88,22 @@ export async function startUI({ }, }); - const server = app.listen(port, async () => { + const server = app.listen(port, () => { const url = `http://localhost:${port}`; - try { - stdout.write(`\nAttempting to open UI in browser...`); - await open(url); - stdout.write(`\nUI server running at ${url}\n`); - } catch (error) { - stderr.write(`\n---------------------------------------------------\n`); - stderr.write(`Error automatically opening browser: ${error}\n`); - stderr.write(`Please open the following URL manually:\n`); - stderr.write(`${url}\n`); - stderr.write(`---------------------------------------------------\n\n`); - } + stdout.write(`\nAttempting to open UI in browser...`); + open(url) + .then(() => { + stdout.write(`\nUI server running at ${url}\n`); + return undefined; + }) + .catch((error) => { + stderr.write(`\n---------------------------------------------------\n`); + stderr.write(`Error automatically opening browser: ${error}\n`); + stderr.write(`Please open the following URL manually:\n`); + stderr.write(`${url}\n`); + stderr.write(`---------------------------------------------------\n\n`); + }); }); return new Promise((resolve, reject) => { @@ -138,8 +144,8 @@ function createApp({ }): express.Application { const app = express(); - app.use(express.static(UI_BUILD_DIR)); - app.use(express.json()); + app.use(expressStatic(UI_BUILD_DIR)); + app.use(expressJson()); app.get('/api/packages', (req, res) => { const { majorBumps } = req.query; @@ -147,7 +153,7 @@ function createApp({ const majorBumpsArray = typeof majorBumps === 'string' ? majorBumps.split(',').filter(Boolean) - : (req.query.majorBumps as string[] | undefined) || []; + : ((req.query.majorBumps as string[] | undefined) ?? []); const requiredDirectDependentNames = new Set( majorBumpsArray.flatMap((majorBump) => @@ -192,7 +198,7 @@ function createApp({ res.send(changelogContent); } catch (error) { - stderr.write(`Changelog error: ${error}\n`); + stderr.write(`Changelog error: ${getErrorMessage(error)}\n`); res.status(500).send('Internal Server Error'); } }); @@ -263,7 +269,7 @@ function createApp({ res.json({ status: 'success' }); } catch (error) { - stderr.write(`Release error: ${error}\n`); + stderr.write(`Release error: ${getErrorMessage(error)}\n`); res.status(400).send('Invalid request'); } }, @@ -287,35 +293,37 @@ function createApp({ const releaseSpecificationPackages = Object.keys( releasedPackages, - ).reduce( - (obj, packageName) => { - const versionSpecifierOrDirective = releasedPackages[packageName]; - - if (versionSpecifierOrDirective !== 'intentionally-skip') { - if ( - Object.values(IncrementableVersionParts).includes( - versionSpecifierOrDirective as any, - ) - ) { - return { - ...obj, - [packageName]: - versionSpecifierOrDirective as IncrementableVersionParts, - }; - } - + ).reduce((obj, packageName) => { + const versionSpecifierOrDirective = releasedPackages[packageName]; + // Downcast this so that we can check for inclusion below. + const incrementableVersionParts = Object.values( + IncrementableVersionParts, + ) as string[]; + + if (versionSpecifierOrDirective !== 'intentionally-skip') { + if ( + versionSpecifierOrDirective && + incrementableVersionParts.includes(versionSpecifierOrDirective) + ) { return { ...obj, - [packageName]: semver.parse( - versionSpecifierOrDirective, - ) as SemVer, + [packageName]: + // Typecast: We know what this is as we've checked it above. + versionSpecifierOrDirective as IncrementableVersionParts, }; } - return obj; - }, - {} as ReleaseSpecification['packages'], - ); + return { + ...obj, + // Typecast: We know that this will safely parse. + [packageName]: semver.parse( + versionSpecifierOrDirective, + ) as SemVer, + }; + } + + return obj; + }, {}); await restoreChangelogsForSkippedPackages({ project, @@ -341,7 +349,7 @@ function createApp({ closeServer(); } catch (error) { - stderr.write(`Release error: ${error}\n`); + stderr.write(`Release error: ${getErrorMessage(error)}\n`); res.status(400).send('Invalid request'); } }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 303f2203..9f2913aa 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,16 +1,25 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { getErrorMessage } from '@metamask/utils'; +import React, { useState, useEffect, useRef, ReactNode } 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'; // This file doesn't export anything, it is used to load Tailwind. -// eslint-disable-next-line import/no-unassigned-import +// eslint-disable-next-line import-x/no-unassigned-import import './style.css'; -// Helper function to compare sets -const setsAreEqual = (a: Set, b: Set) => { +/** + * Determine whether two sets are equal (i.e., they have the same exact + * contents). + * + * @param a - The first set. + * @param b - The second set. + * @returns True if the two sets are equal, false otherwise. + */ +const setsAreEqual = (a: Set, b: Set): boolean => { if (a.size !== b.size) { return false; } @@ -18,6 +27,9 @@ const setsAreEqual = (a: Set, b: Set) => { return [...a].every((value) => b.has(value)); }; +/** + * Props for the `SubmitButton` component. + */ type SubmitButtonProps = { selections: Record; packageDependencyErrors: Record< @@ -28,7 +40,7 @@ type SubmitButtonProps = { missingDependencies: string[]; } >; - onSubmit: () => Promise; + onSubmit: () => void; }; /** @@ -45,7 +57,7 @@ function SubmitButton({ selections, packageDependencyErrors, onSubmit, -}: SubmitButtonProps) { +}: SubmitButtonProps): ReactNode { const isDisabled = Object.keys(selections).length === 0 || Object.keys(packageDependencyErrors).length > 0 || @@ -71,7 +83,7 @@ function SubmitButton({ * * @returns The app component. */ -function App() { +function App(): React.ReactNode { const [packages, setPackages] = useState([]); const [selections, setSelections] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -102,19 +114,22 @@ function App() { const previousPackages = useRef>(new Set()); useEffect(() => { - 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}`); + const fetchPackages = async (): Promise => { + const majorBumps = Object.entries(selections) + .filter(([_, type]) => type === 'major') + .map(([pkgName]) => pkgName); + + try { + const response = await fetch( + `/api/packages?majorBumps=${majorBumps.join(',')}`, + ); + + if (!response.ok) { + throw new Error(`Received ${response.status}`); } - return res.json(); - }) - .then((data: Package[]) => { + const data: Package[] = await response.json(); + const newPackageNames = new Set(data.map((pkg) => pkg.name)); // Only clean up selections if the package list actually changed @@ -133,14 +148,20 @@ function App() { setLoadingChangelogs( data.reduce((acc, pkg) => ({ ...acc, [pkg.name]: false }), {}), ); - }) - .catch((err) => { - setError(err.message); - console.error('Error fetching packages:', err); - }); + } catch (fetchingPackagesError) { + setError(getErrorMessage(fetchingPackagesError)); + console.error('Error fetching packages:', fetchingPackagesError); + } + }; + + // We already handle errors. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchPackages(); }, [selections]); - const checkDependencies = async (selectionData: Record) => { + const checkDependencies = async ( + selectionData: Record, + ): Promise => { if (Object.keys(selectionData).length === 0) { return false; } @@ -162,24 +183,31 @@ function App() { setSubmitErrors([]); setPackageDependencyErrors({}); return true; - } catch (err) { + } catch (checkDependenciesError) { const errorMessage = - err instanceof Error ? err.message : 'An error occurred'; + checkDependenciesError instanceof Error + ? checkDependenciesError.message + : 'An error occurred'; setError(errorMessage); - console.error('Error checking dependencies:', err); + console.error('Error checking dependencies:', checkDependenciesError); return false; } }; useEffect(() => { const timeoutId = setTimeout(() => { + // We already handle errors. + // eslint-disable-next-line @typescript-eslint/no-floating-promises checkDependencies(selections); }, 500); return () => clearTimeout(timeoutId); }, [selections]); - const handleCustomVersionChange = (packageName: string, version: string) => { + const handleCustomVersionChange = ( + packageName: string, + version: string, + ): void => { try { if (!version) { setVersionErrors((prev) => ({ @@ -191,7 +219,7 @@ function App() { const newVersion = new SemVer(version); const currentVersion = new SemVer( - packages.find((p) => p.name === packageName)?.version || '0.0.0', + packages.find((pkg) => pkg.name === packageName)?.version ?? '0.0.0', ); if (newVersion.compare(currentVersion) <= 0) { @@ -211,7 +239,7 @@ function App() { ...prev, [packageName]: version, })); - } catch (err) { + } catch { setVersionErrors((prev) => ({ ...prev, [packageName]: 'Invalid semver version', @@ -224,10 +252,11 @@ function App() { value: ReleaseType | '', ): void => { if (value === '') { - const { [packageName]: _, ...rest } = selections; + const { [packageName]: _unusedPackageName1, ...rest } = selections; setSelections(rest); - const { [packageName]: __, ...remainingErrors } = packageDependencyErrors; + const { [packageName]: _unusedPackageName2, ...remainingErrors } = + packageDependencyErrors; setPackageDependencyErrors(remainingErrors); } else { setSelections({ @@ -278,11 +307,13 @@ function App() { if (data.status === 'success') { setIsSuccess(true); } - } catch (err) { + } catch (handleSubmitError) { const errorMessage = - err instanceof Error ? err.message : 'An error occurred'; + handleSubmitError instanceof Error + ? handleSubmitError.message + : 'An error occurred'; setError(errorMessage); - console.error('Error submitting selections:', err); + console.error('Error submitting selections:', handleSubmitError); // TODO: Show an error message instead of an alert // eslint-disable-next-line no-alert alert('Failed to submit selections. Please try again.'); @@ -303,16 +334,18 @@ function App() { const changelog = await response.text(); setChangelogs((prev) => ({ ...prev, [packageName]: changelog })); - } catch (err) { + } catch (fetchChangelogError) { const errorMessage = - err instanceof Error ? err.message : 'Failed to fetch changelog'; + fetchChangelogError instanceof Error + ? fetchChangelogError.message + : 'Failed to fetch changelog'; setError(errorMessage); } finally { setLoadingChangelogs((prev) => ({ ...prev, [packageName]: false })); } }; - const handleBulkAction = (action: ReleaseType) => { + const handleBulkAction = (action: ReleaseType): void => { const newSelections = { ...selections }; selectedPackages.forEach((packageName) => { newSelections[packageName] = action; @@ -322,7 +355,7 @@ function App() { setShowCheckboxes(true); }; - const togglePackageSelection = (packageName: string) => { + const togglePackageSelection = (packageName: string): void => { setSelectedPackages((prev) => { const newSet = new Set(prev); @@ -449,7 +482,11 @@ function App() { { + // We already handle errors. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleSubmit(); + }} /> )} diff --git a/src/ui/DependencyErrorSection.tsx b/src/ui/DependencyErrorSection.tsx index eda40891..1cffe093 100644 --- a/src/ui/DependencyErrorSection.tsx +++ b/src/ui/DependencyErrorSection.tsx @@ -1,9 +1,14 @@ +import { ReactNode } from 'react'; + +/** + * Props for the `DependencyErrorSection` component. + */ type DependencyErrorSectionProps = { title: string; items: string[]; setSelections: React.Dispatch>>; errorSubject: string; - errorDetails: React.ReactNode; + errorDetails: ReactNode; }; /** @@ -24,7 +29,7 @@ export function DependencyErrorSection({ setSelections, errorSubject, errorDetails, -}: DependencyErrorSectionProps) { +}: DependencyErrorSectionProps): ReactNode { return (
diff --git a/src/ui/ErrorMessage.tsx b/src/ui/ErrorMessage.tsx index a2788f72..3661b55c 100644 --- a/src/ui/ErrorMessage.tsx +++ b/src/ui/ErrorMessage.tsx @@ -1,3 +1,8 @@ +import { ReactNode } from 'react'; + +/** + * Props for the `ErrorMessage` component. + */ type ErrorMessageProps = { errors: string[]; }; @@ -9,7 +14,7 @@ type ErrorMessageProps = { * @param props.errors - The list of errors. * @returns The error message component. */ -export function ErrorMessage({ errors }: ErrorMessageProps) { +export function ErrorMessage({ errors }: ErrorMessageProps): ReactNode { if (errors.length === 0) { return null; } diff --git a/src/ui/Markdown.tsx b/src/ui/Markdown.tsx index c64d9d0d..8f48d951 100644 --- a/src/ui/Markdown.tsx +++ b/src/ui/Markdown.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; /** @@ -7,7 +8,7 @@ import ReactMarkdown from 'react-markdown'; * @param props.content - The text to render. * @returns The rendered Markdown. */ -export function Markdown({ content }: { content: string }) { +export function Markdown({ content }: { content: string }): ReactNode { return ( ; @@ -75,7 +80,7 @@ export function PackageItem({ setSelections, setChangelogs, onToggleSelect, -}: PackageItemProps) { +}: PackageItemProps): ReactNode { return (
New version:{' '} {['patch', 'minor', 'major'].includes(selections[pkg.name]) - ? new SemVer(pkg.version) - .inc( - selections[pkg.name] as Exclude< - ReleaseType, - 'intentionally-skip' | 'custom' | string - >, - ) - .toString() + ? new SemVer(pkg.version).inc( + selections[pkg.name] as Exclude< + ReleaseType, + 'intentionally-skip' | 'custom' | string + >, + ).version : selections[pkg.name]}

)} @@ -139,7 +142,7 @@ export function PackageItem({ onSelectionChange={onSelectionChange} onCustomVersionChange={onCustomVersionChange} onFetchChangelog={onFetchChangelog} - isLoadingChangelog={loadingChangelogs[pkg.name] === true} + isLoadingChangelog={loadingChangelogs[pkg.name]} />
diff --git a/src/ui/VersionSelector.tsx b/src/ui/VersionSelector.tsx index 320c2a3e..9758d7a5 100644 --- a/src/ui/VersionSelector.tsx +++ b/src/ui/VersionSelector.tsx @@ -1,5 +1,10 @@ +import { ReactNode } from 'react'; + import { RELEASE_TYPE_OPTIONS, ReleaseType } from './types.js'; +/** + * Props for the `VersionSelector` component. + */ type VersionSelectorProps = { packageName: string; selection: string; @@ -32,14 +37,12 @@ export function VersionSelector({ onCustomVersionChange, onFetchChangelog, isLoadingChangelog, -}: VersionSelectorProps) { +}: VersionSelectorProps): ReactNode { return (
onCustomVersionChange(packageName, e.target.value)} + onChange={(event) => + onCustomVersionChange(packageName, event.target.value) + } className="border rounded px-2 py-1" /> )}