diff --git a/src/getDependencyStats.test.ts b/src/getDependencyStats.test.ts index 37f4924d..31355d71 100644 --- a/src/getDependencyStats.test.ts +++ b/src/getDependencyStats.test.ts @@ -170,322 +170,104 @@ describe('getDependencyStats', () => { }); }); - // it('marks out of date minors for pre-v1.0.0 versions as out of date majors', async () => { - // mock.outdated = [ - // ['some-dep', '0.0.1', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 1, - // upToDate: 0, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '0.00', - // major: '100.00', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); - - // it('marks out of date minors for pre-v1.0.0 versions as out of date majors', async () => { - // mock.outdated = [ - // ['some-dep', '0.0.1', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 1, - // upToDate: 0, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '0.00', - // major: '100.00', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); - - // it('marks out of date patch for pre-v0.1.0 versions as out of date major', async () => { - // mock.outdated = [ - // ['some-dep', '0.0.1', '0.0.2', '0.0.2', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 1, - // upToDate: 0, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '0.00', - // major: '100.00', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); - - // it('marks out of date patch for pre-v1.0.0 versions as out of date minor', async () => { - // mock.outdated = [ - // ['some-dep', '0.2.0', '0.2.1', '0.2.1', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 1, - // upToDate: 0, - // major: 0, - // minor: 1, - // patch: 0, - // }, - // percents: { - // upToDate: '0.00', - // major: '0.00', - // minor: '100.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [], - // minor: [mock.outdated[0]], - // patch: [], - // }, - // }); - // }); - - // it('handles dependencies marked as "exotic" (their latest version can not be found)', async () => { - // mock.outdated = [ - // ['some-dep', '0.0.1', '0.1.0', '0.1.0', 'dependencies', ''], - // ['some-exotic-dep', '0.0.1', 'exotic', 'exotic', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 2, - // upToDate: 1, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '50.00', - // major: '50.00', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); - - // it('returns stats about out of date major, minor, and patch versions', async () => { - // const majorDepName = 'some-major-dep'; - // const minorDepName = 'some-minor-dep'; - // const patchDepName = 'some-patch-dep'; - // mock.outdated = [ - // [majorDepName, '1.0.0', '1.0.0', '2.0.0', 'dependencies', ''], - // [minorDepName, '1.0.0', '1.0.0', '1.1.0', 'dependencies', ''], - // [patchDepName, '1.0.0', '1.0.0', '1.0.1', 'dependencies', ''], - // ['up-to-date-dep', '2.0.0', '2.0.0', '2.0.0', 'dependencies', ''], - // ['up-to-date-dep-2', '3.0.0', '3.0.0', '3.0.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 5, - // upToDate: 2, - // major: 1, - // minor: 1, - // patch: 1, - // }, - // percents: { - // upToDate: '40.00', - // major: '20.00', - // minor: '20.00', - // patch: '20.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [mock.outdated[1]], - // patch: [mock.outdated[2]], - // }, - // }); - // }); - - // it('ignores dependencies which are ignored in dependabot settings', async () => { - // jest.spyOn(fs, 'existsSync').mockReturnValue(true); - // const majorDepName = 'some-major-dep'; - // const fakeDependabotConfigContent = yaml.dump({ - // updates: [ - // { - // 'package-ecosystem': 'npm', - // directory: '/', - // ignore: [{ 'dependency-name': majorDepName }], - // }, - // ], - // }); - // jest - // .spyOn(fs, 'readFileSync') - // .mockReturnValue(Buffer.from(fakeDependabotConfigContent)); - // mock.outdated = [ - // [majorDepName, '1.0.0', '1.0.0', '2.0.0', 'dependencies', ''], - // ['some-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ['some-other-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 3, - // upToDate: 3, - // major: 0, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '100.00', - // major: '0.00', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [], - // minor: [], - // patch: [], - // }, - // }); - // }); - - // it('does not ignore any dependencies if dependabot config does not contain npm package ecosystem settings', async () => { - // jest.spyOn(fs, 'existsSync').mockReturnValue(true); - // const majorDepName = 'some-major-dep'; - // const fakeDependabotConfigContent = yaml.dump({ - // updates: [ - // { - // directory: '/', - // ignore: [{ 'dependency-name': majorDepName }], - // }, - // ], - // }); - // jest - // .spyOn(fs, 'readFileSync') - // .mockReturnValue(Buffer.from(fakeDependabotConfigContent)); - // mock.outdated = [ - // [majorDepName, '1.0.0', '1.0.0', '2.0.0', 'dependencies', ''], - // ['some-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ['some-other-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 3, - // upToDate: 2, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '66.67', - // major: '33.33', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); + it('marks out of date minors for pre-v1.0.0 versions as out of date majors', async () => { + const majorDepName = 'some-dep'; + mock.outdatedDependencies = { + [majorDepName]: { + current: '0.0.1', + wanted: '0.1.0', + latest: '0.1.0', + dependent: 'npm-dependency-stats-action', + location: `/~/npm-dependency-stats-action/node_modules/${majorDepName}`, + }, + }; + const result = await getDependencyStats(); + expect(result).toMatchObject({ + counts: { + total: 1, + upToDate: 0, + major: 1, + minor: 0, + patch: 0, + }, + percents: { + upToDate: '0.00', + major: '100.00', + minor: '0.00', + patch: '0.00', + }, + dependencies: { + major: mock.outdatedDependencies, + minor: {}, + patch: {}, + }, + }); + }); - // it('handles empty dependabot config file (not ignoring any dependencies)', async () => { - // jest.spyOn(fs, 'existsSync').mockReturnValue(true); - // const majorDepName = 'some-major-dep'; - // jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('')); - // mock.outdated = [ - // [majorDepName, '1.0.0', '1.0.0', '2.0.0', 'dependencies', ''], - // ['some-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ['some-other-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 3, - // upToDate: 2, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // major: '33.33', - // minor: '0.00', - // patch: '0.00', - // upToDate: '66.67', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); + it('handles dependencies not installed at the current level (monorepo)', async () => { + const majorDepName = 'some-dep'; + mock.outdatedDependencies = { + [majorDepName]: { + wanted: '0.0.1', + latest: '0.1.0', + dependent: 'npm-dependency-stats-action', + }, + }; + const result = await getDependencyStats(); + expect(result).toMatchObject({ + counts: { + total: 1, + upToDate: 0, + major: 1, + minor: 0, + patch: 0, + }, + percents: { + upToDate: '0.00', + major: '100.00', + minor: '0.00', + patch: '0.00', + }, + dependencies: { + major: mock.outdatedDependencies, + minor: {}, + patch: {}, + }, + }); + }); - // it('handles error parsing dependabot config file (not ignoring any dependencies)', async () => { - // jest.spyOn(fs, 'existsSync').mockReturnValue(true); - // const majorDepName = 'some-major-dep'; - // jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('{{{1asf`asdf')); - // mock.outdated = [ - // [majorDepName, '1.0.0', '1.0.0', '2.0.0', 'dependencies', ''], - // ['some-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ['some-other-dep', '0.1.0', '0.1.0', '0.1.0', 'dependencies', ''], - // ]; - // const result = await getDependencyStats(); - // expect(result).toMatchObject({ - // counts: { - // total: 3, - // upToDate: 2, - // major: 1, - // minor: 0, - // patch: 0, - // }, - // percents: { - // upToDate: '66.67', - // major: '33.33', - // minor: '0.00', - // patch: '0.00', - // }, - // dependencies: { - // major: [mock.outdated[0]], - // minor: [], - // patch: [], - // }, - // }); - // }); + it('skips dependency if latest is exotic (i.e. pointing to a github repo in package file)', async () => { + const majorDepName = 'some-dep'; + mock.outdatedDependencies = { + [majorDepName]: { + wanted: '0.0.1', + latest: 'exotic', + dependent: 'npm-dependency-stats-action', + }, + }; + const result = await getDependencyStats(); + expect(mockCore.debug).toHaveBeenCalledWith( + `Skipping check of ${majorDepName} since it's latest version is "exotic" (i.e. not found in package registry)`, + ); + expect(result).toMatchObject({ + counts: { + total: 1, + upToDate: 1, + major: 0, + minor: 0, + patch: 0, + }, + percents: { + upToDate: '100.00', + major: '0.00', + minor: '0.00', + patch: '0.00', + }, + dependencies: { + major: {}, + minor: {}, + patch: {}, + }, + }); + }); }); diff --git a/src/getDependencyStats.ts b/src/getDependencyStats.ts index 5a0c213e..35f7ca9b 100644 --- a/src/getDependencyStats.ts +++ b/src/getDependencyStats.ts @@ -2,13 +2,13 @@ import * as core from '@actions/core'; import semver from 'semver'; import path from 'path'; import { getNumberOfDependenciesByType } from './getNumberOfDependencies'; -import { NpmOutdatedOutput, npmOutdatedByType } from './npmOutdated'; +import { type NpmOutdatedOutput, npmOutdatedByType } from './npmOutdated'; -interface PackagesByOutVersion { +type PackagesByOutVersion = { major: NpmOutdatedOutput; minor: NpmOutdatedOutput; patch: NpmOutdatedOutput; -} +}; /** * Sort packages by their out of date version (major, minor, patch) @@ -20,7 +20,11 @@ function groupPackagesByOutOfDateName( ): PackagesByOutVersion { return Object.entries(packages).reduce( (acc, [packageName, packageInfo]) => { - const { latest, current } = packageInfo; + const { latest, current, wanted } = packageInfo; + core.debug( + `Checking if ${packageName} is out of date. ${JSON.stringify(packageInfo)}`, + ); + // Skip dependencies which have "exotic" version (can be caused by pointing to a github repo in package file) if (latest === 'exotic') { core.debug( @@ -29,9 +33,20 @@ function groupPackagesByOutOfDateName( return acc; } - const currentMajor = semver.major(current); + // NOTE: Fallback to wanted version if current version is not found (i.e. monorepo with deps installed at root) + const toCheck = current ?? wanted; + + // Skip dependencies (with warning) which have no current or latest version + if (!toCheck || !latest) { + core.warning( + `Skipping check of ${packageName} since it's ${toCheck ? 'latest' : 'current'} version is not found`, + ); + return acc; + } + + const currentMajor = semver.major(toCheck); const latestMajor = semver.major(latest); - const currentMinor = semver.minor(current); + const currentMinor = semver.minor(toCheck); const latestMinor = semver.minor(latest); const preMajor = currentMajor === 0 || latestMajor === 0; @@ -47,7 +62,7 @@ function groupPackagesByOutOfDateName( } else { acc.minor[packageName] = packageInfo; } - } else if (semver.patch(current) !== semver.patch(latest)) { + } else if (semver.patch(toCheck) !== semver.patch(latest)) { if (preMinor) { // If the major & minor version numbers are zero (0.0.x), treat a // change of the patch version number as a major change. @@ -70,7 +85,7 @@ function groupPackagesByOutOfDateName( ); } -export interface StatsOutput { +export type StatsOutput = { dependencies: { major: NpmOutdatedOutput; minor: NpmOutdatedOutput; @@ -89,7 +104,7 @@ export interface StatsOutput { minor: string; patch: string; }; -} +}; /** * @param numDeps - Total number of dependencies (of a specific type or all) @@ -166,18 +181,33 @@ export type GlobalStatsOutput = StatsOutput & { }; /** - * Get stats about dependencies which are outdated by at least 1 major version - * @returns Object containing stats about out of date packages + * Get working directory + * @param depPath - Path to dependencies + * @returns workding directory */ -export async function getDependencyStats(): Promise { +function getWorkingDirectory(depPath?: string): string { + if (depPath) { + return path.resolve(depPath); + } const startWorkingDirectory = process.cwd(); // seems the working directory should be absolute to work correctly // https://github.com/cypress-io/github-action/issues/211 const workingDirectoryInput = core.getInput('working-directory'); - const workingDirectory = workingDirectoryInput + return workingDirectoryInput ? path.resolve(workingDirectoryInput) : startWorkingDirectory; - core.debug(`working directory ${workingDirectory}`); +} + +/** + * Get stats about dependencies which are outdated by at least 1 major version + * @param depPath - Path to dependencies + * @returns Object containing stats about out of date packages + */ +export async function getDependencyStats( + depPath?: string, +): Promise { + const workingDirectory = getWorkingDirectory(depPath); + core.debug(`Getting dep stats for directory: ${workingDirectory}`); const { dependencies: dependenciesOutOfDate, diff --git a/src/getNumberOfDependencies.ts b/src/getNumberOfDependencies.ts index 2d2cd331..c2820525 100644 --- a/src/getNumberOfDependencies.ts +++ b/src/getNumberOfDependencies.ts @@ -1,9 +1,9 @@ import { DepTypes, getRepoPackageFile } from './utils/repo'; -interface NumberOfDependenciesByType { +type NumberOfDependenciesByType = { [DepTypes.dependencies]: number; [DepTypes.devDependencies]: number; -} +}; /** * @param basePath - Base path of package.json diff --git a/src/npmOutdated.ts b/src/npmOutdated.ts index a398b259..489bbef5 100644 --- a/src/npmOutdated.ts +++ b/src/npmOutdated.ts @@ -2,13 +2,13 @@ import * as core from '@actions/core'; import * as exec from '@actions/exec'; import { type DepType, DepTypes, getRepoPackageFile } from './utils/repo'; -export interface NpmOutdatedPackageOutput { - current: string; +export type NpmOutdatedPackageOutput = { + current?: string; latest: string; wanted: string; dependent: string; - location: string; -} + location?: string; +}; export type NpmOutdatedOutput = Record; diff --git a/src/run.test.ts b/src/run.test.ts index c9ad9f4f..5c0dab7f 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1,8 +1,8 @@ import * as core from '@actions/core'; import fs from 'fs'; import { run } from './run'; -import { StatsOutput } from './getDependencyStats'; -import { NpmOutdatedOutput } from './npmOutdated'; +import { type StatsOutput } from './getDependencyStats'; +import { type NpmOutdatedOutput } from './npmOutdated'; const mockCore = core as jest.Mocked; interface MockObj { @@ -55,7 +55,7 @@ describe('run', () => { it('should set output by default', async () => { await run(); - expect(mockCore.setOutput).toBeCalledWith( + expect(mockCore.setOutput).toHaveBeenCalledWith( 'dependencies', mock.depStats.dependencies, ); @@ -63,11 +63,44 @@ describe('run', () => { it('should write ot output file if output-file input is set', async () => { jest.spyOn(fs, 'writeFileSync').mockImplementation(() => ''); - mockCore.getInput.mockReturnValue('./some-path'); + mockCore.getInput.mockReturnValueOnce('./some-path'); await run(); - expect(mockCore.setOutput).toBeCalledWith( + expect(mockCore.setOutput).toHaveBeenCalledWith( 'dependencies', mock.depStats.dependencies, ); }); + + describe('monorepo', () => { + beforeEach(() => { + mockCore.getInput.mockImplementation((inputName) => { + if (inputName === 'is-monorepo') { + return 'true'; + } + return './some-path'; + }); + }); + + it('should exit with failure if no packages folder found', async () => { + mockCore.getInput.mockReturnValueOnce('true'); + mockCore.getInput.mockReturnValueOnce(''); + await run(); + expect(mockCore.setFailed).toHaveBeenCalledWith( + 'Monorepo detected, but no packages folder found', + ); + }); + + it('should write check dependencies for packages if is-monorepo is true', async () => { + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => ''); + jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true); + jest + .spyOn(fs, 'readdirSync') + .mockReturnValueOnce(['package1', 'package2']); + await run(); + expect(mockCore.setOutput).toHaveBeenCalledWith('dependencies', { + package1: mock.depStats.dependencies, + package2: mock.depStats.dependencies, + }); + }); + }); }); diff --git a/src/run.ts b/src/run.ts index 62535547..61242a7a 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,53 +1,82 @@ -import * as core from '@actions/core'; import fs from 'fs'; +import * as core from '@actions/core'; import path from 'path'; import { getDependencyStats, type GlobalStatsOutput, } from './getDependencyStats'; +const depstatsFolder = 'dep-stats'; + /** * Run npm-dependency-stats action. All outputs are set * at this level */ export async function run(): Promise { const isMonorepoInput = core.getInput('is-monorepo'); + const outputFileConfig = core.getInput('output-file'); + core.debug( + `Inputs: is-monorepo:${isMonorepoInput}, output-file:${outputFileConfig}`, + ); // If package is a monorepo report on each subpackage if (isMonorepoInput === 'true') { - const packageFolders = fs.readdirSync(`${process.cwd()}/packages`); + const packagesFolder = `${process.cwd()}/packages`; + core.debug('Monorepo detected - getting deps stats for each package'); + + // Exit with failure if no packages folder found + if (!fs.existsSync(packagesFolder)) { + core.setFailed('Monorepo detected, but no packages folder found'); + return; + } + + const packageFolders = fs.readdirSync(packagesFolder); const dependenciesByName: Record< string, GlobalStatsOutput['dependencies'] > = {}; const countsByName: Record = {}; const percentsByName: Record = {}; - await Promise.allSettled( + await Promise.all( packageFolders.map(async (packageFolder) => { - const pkgDepStats = await getDependencyStats(); - dependenciesByName[packageFolder] = pkgDepStats.dependencies; - countsByName[packageFolder] = pkgDepStats.counts; - percentsByName[packageFolder] = pkgDepStats.percents; - const outputFileConfig = core.getInput('output-file'); - if (outputFileConfig) { - fs.writeFileSync( - path.resolve('dep-stats', packageFolder, outputFileConfig), - JSON.stringify(pkgDepStats, null, 2), + core.debug(`Getting deps stats for ${packageFolder}`); + try { + const pkgDepStats = await getDependencyStats( + `${packagesFolder}/${packageFolder}`, + ); + dependenciesByName[packageFolder] = pkgDepStats.dependencies; + countsByName[packageFolder] = pkgDepStats.counts; + percentsByName[packageFolder] = pkgDepStats.percents; + if (outputFileConfig) { + const packageFolderPath = `${depstatsFolder}/${packageFolder}`; + // Create output folder if it doesn't exist + fs.mkdirSync(packageFolderPath, { recursive: true }); + const outputPath = path.resolve( + packageFolderPath, + outputFileConfig, + ); + core.debug(`Writing output to ${outputPath}`); + fs.writeFileSync(outputPath, JSON.stringify(pkgDepStats, null, 2)); + } + } catch (err) { + const error = err as Error; + core.error( + `Error getting dependency stats for ${packageFolder}: ${error.message}`, ); } }), ); + + // Set outputs core.setOutput('dependencies', dependenciesByName); core.setOutput('counts', countsByName); core.setOutput('percents', percentsByName); } else { const depStats = await getDependencyStats(); - const outputFileConfig = core.getInput('output-file'); if (outputFileConfig) { - fs.writeFileSync( - path.resolve(outputFileConfig), - JSON.stringify(depStats, null, 2), - ); + const outputPath = path.resolve(outputFileConfig); + core.debug(`Writing output to ${outputPath}`); + fs.writeFileSync(outputPath, JSON.stringify(depStats, null, 2)); } core.setOutput('dependencies', depStats.dependencies); core.setOutput('counts', depStats.counts); diff --git a/src/utils/repo.ts b/src/utils/repo.ts index 7d5480a3..d4d05f6c 100644 --- a/src/utils/repo.ts +++ b/src/utils/repo.ts @@ -7,15 +7,15 @@ import { readFile } from 'fs/promises'; * @param filePath - File path * @returns Parsed JSON file contents */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function loadJsonFile(filePath: string): Promise { +export async function loadJsonFile>( + filePath: string, +): Promise { const fileBuff = await readFile(filePath); try { - return JSON.parse(fileBuff.toString()); + return JSON.parse(fileBuff.toString()) as T; } catch (err) { - core.error(`Error parsing json file "${filePath}"`); - const { message } = err as Error; - throw new Error(message); + core.error(`Error parsing json file "${filePath}": ${err}`); + throw err; } } @@ -26,14 +26,14 @@ export const DepTypes = { export type DepType = (typeof DepTypes)[keyof typeof DepTypes]; -export interface PackageFile { +export type PackageFile = { [DepTypes.dependencies]?: Record; [DepTypes.devDependencies]?: Record; version?: string; engines?: { node?: string; }; -} +}; /** * Get package file of repo @@ -48,5 +48,5 @@ export async function getRepoPackageFile( core.warning(`Package file does not exist at path ${basePath}`); return {}; } - return loadJsonFile(pkgPath); + return loadJsonFile(pkgPath); }