From f200c96d19808f05962e3f04e66b2b7bc3dcc7cf Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Mon, 16 Sep 2024 15:19:04 +1000 Subject: [PATCH 1/8] feat(manager): add devbox manager module --- lib/modules/manager/api.ts | 2 + lib/modules/manager/devbox/artifacts.spec.ts | 139 +++++++++++ lib/modules/manager/devbox/artifacts.ts | 67 +++++ lib/modules/manager/devbox/extract.spec.ts | 248 +++++++++++++++++++ lib/modules/manager/devbox/extract.ts | 69 ++++++ lib/modules/manager/devbox/index.ts | 13 + lib/modules/manager/devbox/readme.md | 5 + lib/modules/manager/devbox/schema.ts | 13 + 8 files changed, 556 insertions(+) create mode 100644 lib/modules/manager/devbox/artifacts.spec.ts create mode 100644 lib/modules/manager/devbox/artifacts.ts create mode 100644 lib/modules/manager/devbox/extract.spec.ts create mode 100644 lib/modules/manager/devbox/extract.ts create mode 100644 lib/modules/manager/devbox/index.ts create mode 100644 lib/modules/manager/devbox/readme.md create mode 100644 lib/modules/manager/devbox/schema.ts diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 573fcf95375e93..259f893928a03c 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -28,6 +28,7 @@ import * as copier from './copier'; import * as cpanfile from './cpanfile'; import * as crossplane from './crossplane'; import * as depsEdn from './deps-edn'; +import * as devbox from './devbox'; import * as devContainer from './devcontainer'; import * as dockerCompose from './docker-compose'; import * as dockerfile from './dockerfile'; @@ -135,6 +136,7 @@ api.set('copier', copier); api.set('cpanfile', cpanfile); api.set('crossplane', crossplane); api.set('deps-edn', depsEdn); +api.set('devbox', devbox); api.set('devcontainer', devContainer); api.set('docker-compose', dockerCompose); api.set('dockerfile', dockerfile); diff --git a/lib/modules/manager/devbox/artifacts.spec.ts b/lib/modules/manager/devbox/artifacts.spec.ts new file mode 100644 index 00000000000000..89309a425a07a4 --- /dev/null +++ b/lib/modules/manager/devbox/artifacts.spec.ts @@ -0,0 +1,139 @@ +import { codeBlock } from 'common-tags'; +import { mockExecAll } from '../../../../test/exec-util'; +import { fs, git, partial } from '../../../../test/util'; +import { GlobalConfig } from '../../../config/global'; +import type { RepoGlobalConfig } from '../../../config/types'; +import type { StatusResult } from '../../../util/git/types'; +import type { UpdateArtifact } from '../types'; +import { updateArtifacts } from './artifacts'; + +jest.mock('../../../util/exec/env'); +jest.mock('../../../util/git'); +jest.mock('../../../util/fs'); + +const globalConfig: RepoGlobalConfig = { + localDir: '', +}; + +const devboxJson = codeBlock` + { + "$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/0.10.1/.schema/devbox.schema.json", + "packages": ["nodejs@20", "metabase@0.49.1", "postgresql@latest", "gh@latest"], + } +`; + +describe('modules/manager/devbox/artifacts', () => { + describe('updateArtifacts()', () => { + let updateArtifact: UpdateArtifact; + + beforeEach(() => { + GlobalConfig.set(globalConfig); + updateArtifact = { + config: {}, + newPackageFileContent: '', + packageFileName: '', + updatedDeps: [], + }; + }); + + it('skips if no updatedDeps and no lockFileMaintenance', async () => { + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('skips if no lock file in config', async () => { + updateArtifact.updatedDeps = [{}]; + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('skips if cannot read lock file', async () => { + updateArtifact.updatedDeps = [ + { manager: 'devbox', lockFiles: ['devbox.lock'] }, + ]; + expect(await updateArtifacts(updateArtifact)).toBeNull(); + }); + + it('returns installed devbox.lock', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + const execSnapshots = mockExecAll(); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: ['devbox.lock'], + }), + ); + fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(devboxJson); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [], + config: {}, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'devbox.lock', + contents: 'New devbox.lock', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'devbox install', + options: { + cwd: '.', + encoding: 'utf-8', + env: {}, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + + it('returns updated devbox.lock', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + const execSnapshots = mockExecAll(); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: ['devbox.lock'], + }), + ); + fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(devboxJson); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [], + config: { + isLockFileMaintenance: true, + }, + }), + ).toEqual([ + { + file: { + type: 'addition', + path: 'devbox.lock', + contents: 'New devbox.lock', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'devbox update', + options: { + cwd: '.', + encoding: 'utf-8', + env: {}, + maxBuffer: 10485760, + timeout: 900000, + }, + }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/devbox/artifacts.ts b/lib/modules/manager/devbox/artifacts.ts new file mode 100644 index 00000000000000..eb714aef54a5e9 --- /dev/null +++ b/lib/modules/manager/devbox/artifacts.ts @@ -0,0 +1,67 @@ +import { logger } from '../../../logger'; +import { exec } from '../../../util/exec'; +import type { ExecOptions } from '../../../util/exec/types'; +import { getSiblingFileName, readLocalFile } from '../../../util/fs'; +import { getRepoStatus } from '../../../util/git'; +import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; + +export async function updateArtifacts( + updateConfig: UpdateArtifact, +): Promise { + const lockFileName = getSiblingFileName( + updateConfig.packageFileName, + 'devbox.lock', + ); + const existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); + if (!existingLockFileContent) { + logger.debug('No devbox.lock found'); + return null; + } + const execOptions: ExecOptions = { + cwdFile: updateConfig.packageFileName, + toolConstraints: [ + { + toolName: 'devbox', + constraint: updateConfig.config.constraints?.devbox, + }, + ], + docker: {}, + userConfiguredEnv: updateConfig.config.env, + }; + + let cmd = ''; + if (updateConfig.config.isLockFileMaintenance) { + cmd += 'devbox update'; + } else { + cmd += 'devbox install'; + } + + try { + await exec(cmd, execOptions); + const status = await getRepoStatus(); + + if (!status.modified.includes(lockFileName)) { + return null; + } + logger.debug('Returning updated devbox.lock'); + return [ + { + file: { + type: 'addition', + path: lockFileName, + contents: await readLocalFile(lockFileName), + }, + }, + ]; + } catch (err) { + logger.warn({ err }, 'Error updating devbox.lock'); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/modules/manager/devbox/extract.spec.ts b/lib/modules/manager/devbox/extract.spec.ts new file mode 100644 index 00000000000000..aa3933ce044b0f --- /dev/null +++ b/lib/modules/manager/devbox/extract.spec.ts @@ -0,0 +1,248 @@ +import { codeBlock } from 'common-tags'; +import { extractPackageFile } from '.'; + +describe('modules/manager/devbox/extract', () => { + describe('extractPackageFile', () => { + it('returns null when the devbox JSON file is empty', () => { + const result = extractPackageFile(''); + expect(result).toBeNull(); + }); + + it('returns null when the devbox JSON file is malformed', () => { + const result = extractPackageFile('malformed json}}}}}'); + expect(result).toBeNull(); + }); + + it('returns null when the devbox JSON file has no packages', () => { + const result = extractPackageFile('{}'); + expect(result).toBeNull(); + }); + + it('returns a package dependency when the devbox JSON file has a single package', () => { + const result = extractPackageFile(codeBlock` + { + "packages": ["nodejs@20.1.8"] + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has a single package with a version object', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": "20.1.8" + } + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + ], + }); + }); + + it('returns null when the devbox JSON file has a single package with a version object and a version range', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": "^20.1.8" + } + } + `); + expect(result).toBeNull(); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages', () => { + const result = extractPackageFile(codeBlock` + { + "packages": ["nodejs@20.1.8", "yarn@1.22.10"] + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages with in a packages object', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10" + } + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + + it('returns a package dependency when the devbox JSON file has multiple packages with package objects', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": { + "version": "20.1.8" + }, + "yarn": { + "version": "1.22.10" + } + } + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + + it('skips invalid dependencies', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10", + "invalid": "invalid" + } + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + + it('skips invalid dependencies with package objects', () => { + const result = extractPackageFile(codeBlock` + { + "packages": { + "nodejs": "20.1.8", + "yarn": "1.22.10", + "invalid": { + "version": "invalid" + } + } + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + + it('skips invalid dependencies from the packages array', () => { + const result = extractPackageFile(codeBlock` + { + "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid"] + } + `); + expect(result).toEqual({ + deps: [ + { + depName: 'nodejs', + currentValue: '20.1.8', + datasource: 'devbox', + packageName: 'nodejs', + versioning: 'devbox', + }, + { + depName: 'yarn', + currentValue: '1.22.10', + datasource: 'devbox', + packageName: 'yarn', + versioning: 'devbox', + }, + ], + }); + }); + }); +}); diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts new file mode 100644 index 00000000000000..816000dde7166a --- /dev/null +++ b/lib/modules/manager/devbox/extract.ts @@ -0,0 +1,69 @@ +import { logger } from '../../../logger'; +import { DevboxDatasource } from '../../datasource/devbox'; +import * as devboxVersioning from '../../versioning/devbox'; +import type { PackageDependency, PackageFileContent } from '../types'; +import { DevboxFile } from './schema'; + +function isValidDependency(dep: PackageDependency): boolean { + return !!dep.currentValue && devboxVersioning.api.isValid(dep.currentValue); +} + +export function extractPackageFile(content: string): PackageFileContent | null { + logger.trace('devbox.extractPackageFile()'); + + const parsedFile = DevboxFile.safeParse(content); + if (parsedFile.error) { + logger.debug({ error: parsedFile.error }, 'Error parsing devbox.json'); + return null; + } + + const file = parsedFile.data; + const deps: PackageDependency[] = []; + + if (Array.isArray(file.packages)) { + for (const pkgStr of file.packages) { + const [pkgName, pkgVer] = pkgStr.split('@'); + const pkgDep = getDep(pkgName, pkgVer); + if (pkgDep) { + deps.push(pkgDep); + } + } + } else { + for (const [pkgName, pkgVer] of Object.entries(file.packages)) { + const pkgDep = getDep( + pkgName, + typeof pkgVer === 'string' ? pkgVer : pkgVer.version, + ); + if (pkgDep) { + deps.push(pkgDep); + } + } + } + + if (deps.length) { + return { deps }; + } + + return null; +} + +function getDep( + packageName: string, + version: string, +): PackageDependency | null { + const dep = { + depName: packageName, + currentValue: version, + datasource: DevboxDatasource.id, + packageName, + versioning: devboxVersioning.id, + }; + if (!isValidDependency(dep)) { + logger.trace( + { packageName }, + 'Skipping invalid devbox dependency in devbox JSON file.', + ); + return null; + } + return dep; +} diff --git a/lib/modules/manager/devbox/index.ts b/lib/modules/manager/devbox/index.ts new file mode 100644 index 00000000000000..0f9109ac958c93 --- /dev/null +++ b/lib/modules/manager/devbox/index.ts @@ -0,0 +1,13 @@ +import { DevboxDatasource } from '../../datasource/devbox'; + +export { extractPackageFile } from './extract'; +export { updateArtifacts } from './artifacts'; + +export const supportsLockFileMaintenance = true; + +export const defaultConfig = { + fileMatch: ['(^|/)devbox\\.json$'], + enabled: true, +}; + +export const supportedDatasources = [DevboxDatasource.id]; diff --git a/lib/modules/manager/devbox/readme.md b/lib/modules/manager/devbox/readme.md new file mode 100644 index 00000000000000..5b91520f9fed8b --- /dev/null +++ b/lib/modules/manager/devbox/readme.md @@ -0,0 +1,5 @@ +Used for updating [devbox](https://www.jetify.com/devbox) projects. + +Devbox is a tool for creating isolated, reproducible development environments that run anywhere. + +It uses nix packages sourced from the devbox package registry. diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts new file mode 100644 index 00000000000000..c0ea466fa38afb --- /dev/null +++ b/lib/modules/manager/devbox/schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { Jsonc } from '../../../util/schema-utils'; + +export const DevboxFile = Jsonc.pipe( + z.object({ + packages: z.union([ + z.array(z.string()), + z.record(z.union([z.string(), z.object({ version: z.string() })])), + ]), + }), +); + +export type DevboxFile = z.infer; From ee855b74357e9841ea6d514378101b0aa427eab1 Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Thu, 16 Jan 2025 22:36:53 +1100 Subject: [PATCH 2/8] Fix coverage --- lib/modules/manager/devbox/artifacts.spec.ts | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lib/modules/manager/devbox/artifacts.spec.ts b/lib/modules/manager/devbox/artifacts.spec.ts index 89309a425a07a4..4b067d5838e901 100644 --- a/lib/modules/manager/devbox/artifacts.spec.ts +++ b/lib/modules/manager/devbox/artifacts.spec.ts @@ -135,5 +135,46 @@ describe('modules/manager/devbox/artifacts', () => { }, ]); }); + + it('returns null if devbox.lock not modified', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(devboxJson); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [], + config: {}, + }), + ).toBeNull(); + }); + + it('returns an artifact error on failure', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [], + config: {}, + }), + ).toEqual([ + { + artifactError: { + lockFile: 'devbox.lock', + stderr: "Cannot read properties of undefined (reading 'stdout')", + }, + }, + ]); + }); }); }); From 199907e8be13b2221bc2d94b7180e7f0475839f5 Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Fri, 17 Jan 2025 17:29:49 +1100 Subject: [PATCH 3/8] Address PR comments --- lib/modules/manager/devbox/artifacts.spec.ts | 99 +++++++++++++--- lib/modules/manager/devbox/artifacts.ts | 22 +++- lib/modules/manager/devbox/extract.spec.ts | 116 +++++++++++++------ lib/modules/manager/devbox/extract.ts | 22 ++-- lib/modules/manager/devbox/index.ts | 1 - 5 files changed, 191 insertions(+), 69 deletions(-) diff --git a/lib/modules/manager/devbox/artifacts.spec.ts b/lib/modules/manager/devbox/artifacts.spec.ts index 4b067d5838e901..f2ffbd4dcfc2de 100644 --- a/lib/modules/manager/devbox/artifacts.spec.ts +++ b/lib/modules/manager/devbox/artifacts.spec.ts @@ -56,18 +56,15 @@ describe('modules/manager/devbox/artifacts', () => { fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); const execSnapshots = mockExecAll(); - git.getRepoStatus.mockResolvedValueOnce( - partial({ - modified: ['devbox.lock'], - }), - ); - fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); - fs.readLocalFile.mockResolvedValueOnce(devboxJson); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + const newLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); expect( await updateArtifacts({ packageFileName: 'devbox.json', newPackageFileContent: devboxJson, - updatedDeps: [], + updatedDeps: [{ manager: 'devbox', lockFiles: ['devbox.lock'] }], config: {}, }), ).toEqual([ @@ -75,7 +72,7 @@ describe('modules/manager/devbox/artifacts', () => { file: { type: 'addition', path: 'devbox.lock', - contents: 'New devbox.lock', + contents: newLockFileContent, }, }, ]); @@ -102,13 +99,15 @@ describe('modules/manager/devbox/artifacts', () => { modified: ['devbox.lock'], }), ); - fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); - fs.readLocalFile.mockResolvedValueOnce(devboxJson); + const oldLockFileContent = Buffer.from('old devbox.lock'); + const newLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); expect( await updateArtifacts({ packageFileName: 'devbox.json', newPackageFileContent: devboxJson, - updatedDeps: [], + updatedDeps: [{}], config: { isLockFileMaintenance: true, }, @@ -118,7 +117,7 @@ describe('modules/manager/devbox/artifacts', () => { file: { type: 'addition', path: 'devbox.lock', - contents: 'New devbox.lock', + contents: newLockFileContent, }, }, ]); @@ -136,7 +135,7 @@ describe('modules/manager/devbox/artifacts', () => { ]); }); - it('returns null if devbox.lock not modified', async () => { + it('returns null if no changes are found', async () => { fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); git.getRepoStatus.mockResolvedValueOnce( @@ -145,8 +144,6 @@ describe('modules/manager/devbox/artifacts', () => { }), ); mockExecAll(); - fs.readLocalFile.mockResolvedValueOnce('New devbox.lock'); - fs.readLocalFile.mockResolvedValueOnce(devboxJson); expect( await updateArtifacts({ packageFileName: 'devbox.json', @@ -157,14 +154,80 @@ describe('modules/manager/devbox/artifacts', () => { ).toBeNull(); }); - it('returns an artifact error on failure', async () => { + it('returns null if devbox.lock not found after update', async () => { fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); expect( await updateArtifacts({ packageFileName: 'devbox.json', newPackageFileContent: devboxJson, - updatedDeps: [], + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns null if devbox.lock not found', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce(null); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns null if no lock file changes are found', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(codeBlock`{}`); + git.getRepoStatus.mockResolvedValueOnce( + partial({ + modified: [], + }), + ); + mockExecAll(); + const oldLockFileContent = Buffer.from('Old devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], + config: {}, + }), + ).toBeNull(); + }); + + it('returns an artifact error on failure', async () => { + fs.getSiblingFileName.mockReturnValueOnce('devbox.lock'); + const newLockFileContent = codeBlock`{}`; + const oldLockFileContent = Buffer.from('New devbox.lock'); + fs.readLocalFile.mockResolvedValueOnce(oldLockFileContent as never); + fs.readLocalFile.mockResolvedValueOnce(newLockFileContent as never); + expect( + await updateArtifacts({ + packageFileName: 'devbox.json', + newPackageFileContent: devboxJson, + updatedDeps: [{}], config: {}, }), ).toEqual([ diff --git a/lib/modules/manager/devbox/artifacts.ts b/lib/modules/manager/devbox/artifacts.ts index eb714aef54a5e9..45ebd790634a51 100644 --- a/lib/modules/manager/devbox/artifacts.ts +++ b/lib/modules/manager/devbox/artifacts.ts @@ -1,8 +1,8 @@ +import is from '@sindresorhus/is'; import { logger } from '../../../logger'; import { exec } from '../../../util/exec'; import type { ExecOptions } from '../../../util/exec/types'; import { getSiblingFileName, readLocalFile } from '../../../util/fs'; -import { getRepoStatus } from '../../../util/git'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; export async function updateArtifacts( @@ -32,15 +32,27 @@ export async function updateArtifacts( let cmd = ''; if (updateConfig.config.isLockFileMaintenance) { cmd += 'devbox update'; - } else { + } else if (is.nonEmptyArray(updateConfig.updatedDeps)) { cmd += 'devbox install'; + } else { + logger.debug('No updated devbox packages - returning null'); + return null; + } + + const oldLockFileContent = await readLocalFile(lockFileName); + if (!oldLockFileContent) { + logger.debug(`No ${lockFileName} found`); + return null; } try { await exec(cmd, execOptions); - const status = await getRepoStatus(); + const newLockFileContent = await readLocalFile(lockFileName); - if (!status.modified.includes(lockFileName)) { + if ( + !newLockFileContent || + Buffer.compare(oldLockFileContent, newLockFileContent) === 0 + ) { return null; } logger.debug('Returning updated devbox.lock'); @@ -49,7 +61,7 @@ export async function updateArtifacts( file: { type: 'addition', path: lockFileName, - contents: await readLocalFile(lockFileName), + contents: newLockFileContent, }, }, ]; diff --git a/lib/modules/manager/devbox/extract.spec.ts b/lib/modules/manager/devbox/extract.spec.ts index aa3933ce044b0f..a7a13f98b0ef74 100644 --- a/lib/modules/manager/devbox/extract.spec.ts +++ b/lib/modules/manager/devbox/extract.spec.ts @@ -4,26 +4,29 @@ import { extractPackageFile } from '.'; describe('modules/manager/devbox/extract', () => { describe('extractPackageFile', () => { it('returns null when the devbox JSON file is empty', () => { - const result = extractPackageFile(''); + const result = extractPackageFile('', 'devbox.lock'); expect(result).toBeNull(); }); it('returns null when the devbox JSON file is malformed', () => { - const result = extractPackageFile('malformed json}}}}}'); + const result = extractPackageFile('malformed json}}}}}', 'devbox.lock'); expect(result).toBeNull(); }); it('returns null when the devbox JSON file has no packages', () => { - const result = extractPackageFile('{}'); + const result = extractPackageFile('{}', 'devbox.lock'); expect(result).toBeNull(); }); it('returns a package dependency when the devbox JSON file has a single package', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": ["nodejs@20.1.8"] } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -31,20 +34,22 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, ], }); }); it('returns a package dependency when the devbox JSON file has a single package with a version object', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": "20.1.8" } } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -52,29 +57,44 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, ], }); }); it('returns null when the devbox JSON file has a single package with a version object and a version range', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": "^20.1.8" } } - `); - expect(result).toBeNull(); + `, + 'devbox.lock', + ); + expect(result).toEqual({ + deps: [ + { + currentValue: '^20.1.8', + datasource: 'devbox', + depName: 'nodejs', + packageName: 'nodejs', + skipReason: 'invalid-version', + }, + ], + }); }); it('returns a package dependency when the devbox JSON file has multiple packages', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": ["nodejs@20.1.8", "yarn@1.22.10"] } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -82,28 +102,29 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', }, ], }); }); it('returns a package dependency when the devbox JSON file has multiple packages with in a packages object', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": "20.1.8", "yarn": "1.22.10" } } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -111,21 +132,20 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', }, ], }); }); it('returns a package dependency when the devbox JSON file has multiple packages with package objects', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": { @@ -136,7 +156,9 @@ describe('modules/manager/devbox/extract', () => { } } } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -144,21 +166,20 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', }, ], }); }); it('skips invalid dependencies', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": "20.1.8", @@ -166,7 +187,9 @@ describe('modules/manager/devbox/extract', () => { "invalid": "invalid" } } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -174,21 +197,27 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', }, ], }); }); it('skips invalid dependencies with package objects', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": { "nodejs": "20.1.8", @@ -198,7 +227,9 @@ describe('modules/manager/devbox/extract', () => { } } } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -206,25 +237,33 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', }, ], }); }); it('skips invalid dependencies from the packages array', () => { - const result = extractPackageFile(codeBlock` + const result = extractPackageFile( + codeBlock` { "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid"] } - `); + `, + 'devbox.lock', + ); expect(result).toEqual({ deps: [ { @@ -232,14 +271,19 @@ describe('modules/manager/devbox/extract', () => { currentValue: '20.1.8', datasource: 'devbox', packageName: 'nodejs', - versioning: 'devbox', }, { depName: 'yarn', currentValue: '1.22.10', datasource: 'devbox', packageName: 'yarn', - versioning: 'devbox', + }, + { + currentValue: 'invalid', + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'invalid-version', }, ], }); diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts index 816000dde7166a..820a0e4ae38abe 100644 --- a/lib/modules/manager/devbox/extract.ts +++ b/lib/modules/manager/devbox/extract.ts @@ -4,16 +4,18 @@ import * as devboxVersioning from '../../versioning/devbox'; import type { PackageDependency, PackageFileContent } from '../types'; import { DevboxFile } from './schema'; -function isValidDependency(dep: PackageDependency): boolean { - return !!dep.currentValue && devboxVersioning.api.isValid(dep.currentValue); -} - -export function extractPackageFile(content: string): PackageFileContent | null { +export function extractPackageFile( + content: string, + packageFile: string, +): PackageFileContent | null { logger.trace('devbox.extractPackageFile()'); const parsedFile = DevboxFile.safeParse(content); if (parsedFile.error) { - logger.debug({ error: parsedFile.error }, 'Error parsing devbox.json'); + logger.debug( + { packageFile, error: parsedFile.error }, + 'Error parsing devbox.json', + ); return null; } @@ -56,14 +58,16 @@ function getDep( currentValue: version, datasource: DevboxDatasource.id, packageName, - versioning: devboxVersioning.id, }; - if (!isValidDependency(dep)) { + if (!(dep.currentValue && devboxVersioning.api.isValid(dep.currentValue))) { logger.trace( { packageName }, 'Skipping invalid devbox dependency in devbox JSON file.', ); - return null; + return { + ...dep, + skipReason: 'invalid-version', + }; } return dep; } diff --git a/lib/modules/manager/devbox/index.ts b/lib/modules/manager/devbox/index.ts index 0f9109ac958c93..d6764af34e43ef 100644 --- a/lib/modules/manager/devbox/index.ts +++ b/lib/modules/manager/devbox/index.ts @@ -7,7 +7,6 @@ export const supportsLockFileMaintenance = true; export const defaultConfig = { fileMatch: ['(^|/)devbox\\.json$'], - enabled: true, }; export const supportedDatasources = [DevboxDatasource.id]; From 534fb7a798d460b34e0e697148700abc9a6651c2 Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Mon, 20 Jan 2025 10:44:00 +1100 Subject: [PATCH 4/8] Address PR comments --- lib/modules/manager/devbox/artifacts.ts | 11 +++++--- lib/modules/manager/devbox/extract.spec.ts | 29 ++++++++++++++++++---- lib/modules/manager/devbox/extract.ts | 19 +++++++++++--- lib/modules/manager/devbox/schema.ts | 2 -- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/lib/modules/manager/devbox/artifacts.ts b/lib/modules/manager/devbox/artifacts.ts index 45ebd790634a51..c90c6faa3345e6 100644 --- a/lib/modules/manager/devbox/artifacts.ts +++ b/lib/modules/manager/devbox/artifacts.ts @@ -30,18 +30,21 @@ export async function updateArtifacts( }; let cmd = ''; - if (updateConfig.config.isLockFileMaintenance) { + if ( + updateConfig.config.isLockFileMaintenance || + updateConfig.config.updateType === 'lockFileMaintenance' + ) { cmd += 'devbox update'; } else if (is.nonEmptyArray(updateConfig.updatedDeps)) { cmd += 'devbox install'; } else { - logger.debug('No updated devbox packages - returning null'); + logger.trace('No updated devbox packages - returning null'); return null; } const oldLockFileContent = await readLocalFile(lockFileName); if (!oldLockFileContent) { - logger.debug(`No ${lockFileName} found`); + logger.trace(`No ${lockFileName} found`); return null; } @@ -55,7 +58,7 @@ export async function updateArtifacts( ) { return null; } - logger.debug('Returning updated devbox.lock'); + logger.trace('Returning updated devbox.lock'); return [ { file: { diff --git a/lib/modules/manager/devbox/extract.spec.ts b/lib/modules/manager/devbox/extract.spec.ts index a7a13f98b0ef74..48af9fda22f78b 100644 --- a/lib/modules/manager/devbox/extract.spec.ts +++ b/lib/modules/manager/devbox/extract.spec.ts @@ -62,7 +62,7 @@ describe('modules/manager/devbox/extract', () => { }); }); - it('returns null when the devbox JSON file has a single package with a version object and a version range', () => { + it('returns invalid-version when the devbox JSON file has a single package with an invalid version', () => { const result = extractPackageFile( codeBlock` { @@ -177,7 +177,7 @@ describe('modules/manager/devbox/extract', () => { }); }); - it('skips invalid dependencies', () => { + it('returns invalid dependencies', () => { const result = extractPackageFile( codeBlock` { @@ -215,7 +215,7 @@ describe('modules/manager/devbox/extract', () => { }); }); - it('skips invalid dependencies with package objects', () => { + it('returns invalid dependencies with package objects', () => { const result = extractPackageFile( codeBlock` { @@ -255,11 +255,11 @@ describe('modules/manager/devbox/extract', () => { }); }); - it('skips invalid dependencies from the packages array', () => { + it('returns invalid dependencies from the packages array', () => { const result = extractPackageFile( codeBlock` { - "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid"] + "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid", "invalid"] } `, 'devbox.lock', @@ -285,8 +285,27 @@ describe('modules/manager/devbox/extract', () => { packageName: 'invalid', skipReason: 'invalid-version', }, + { + currentValue: undefined, + datasource: 'devbox', + depName: 'invalid', + packageName: 'invalid', + skipReason: 'not-a-version', + }, ], }); }); + + it('returns null if there are no dependencies', () => { + const result = extractPackageFile( + codeBlock` + { + "packages": [] + } + `, + 'devbox.lock', + ); + expect(result).toBeNull(); + }); }); }); diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts index 820a0e4ae38abe..fb920a410b4d8e 100644 --- a/lib/modules/manager/devbox/extract.ts +++ b/lib/modules/manager/devbox/extract.ts @@ -24,7 +24,10 @@ export function extractPackageFile( if (Array.isArray(file.packages)) { for (const pkgStr of file.packages) { - const [pkgName, pkgVer] = pkgStr.split('@'); + const [pkgName, pkgVer] = pkgStr.split('@') as [ + string, + string | undefined, + ]; const pkgDep = getDep(pkgName, pkgVer); if (pkgDep) { deps.push(pkgDep); @@ -51,7 +54,7 @@ export function extractPackageFile( function getDep( packageName: string, - version: string, + version?: string, ): PackageDependency | null { const dep = { depName: packageName, @@ -59,7 +62,17 @@ function getDep( datasource: DevboxDatasource.id, packageName, }; - if (!(dep.currentValue && devboxVersioning.api.isValid(dep.currentValue))) { + if (!dep.currentValue) { + logger.trace( + { packageName }, + 'Skipping devbox dependency with no version in devbox JSON file.', + ); + return { + ...dep, + skipReason: 'not-a-version', + }; + } + if (!devboxVersioning.api.isValid(dep.currentValue)) { logger.trace( { packageName }, 'Skipping invalid devbox dependency in devbox JSON file.', diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts index c0ea466fa38afb..53e82e9e13c570 100644 --- a/lib/modules/manager/devbox/schema.ts +++ b/lib/modules/manager/devbox/schema.ts @@ -9,5 +9,3 @@ export const DevboxFile = Jsonc.pipe( ]), }), ); - -export type DevboxFile = z.infer; From b293bbe1ac4eabcf4252a0d7fc59916c2b2b1784 Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Mon, 20 Jan 2025 20:10:22 +1100 Subject: [PATCH 5/8] Update extract.ts Co-authored-by: Michael Kriese --- lib/modules/manager/devbox/extract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts index fb920a410b4d8e..259016a4827e78 100644 --- a/lib/modules/manager/devbox/extract.ts +++ b/lib/modules/manager/devbox/extract.ts @@ -54,7 +54,7 @@ export function extractPackageFile( function getDep( packageName: string, - version?: string, + version: string | undefined, ): PackageDependency | null { const dep = { depName: packageName, From 95c19f1af605c5c7a6d66e3d26c1a1d86f018722 Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Wed, 29 Jan 2025 20:51:19 +1100 Subject: [PATCH 6/8] Move devbox packages variation logic into zod transform --- lib/modules/manager/devbox/extract.spec.ts | 6 +-- lib/modules/manager/devbox/extract.ts | 28 +++------- lib/modules/manager/devbox/schema.spec.ts | 59 ++++++++++++++++++++++ lib/modules/manager/devbox/schema.ts | 33 +++++++++--- 4 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 lib/modules/manager/devbox/schema.spec.ts diff --git a/lib/modules/manager/devbox/extract.spec.ts b/lib/modules/manager/devbox/extract.spec.ts index 48af9fda22f78b..e97a2f470c2535 100644 --- a/lib/modules/manager/devbox/extract.spec.ts +++ b/lib/modules/manager/devbox/extract.spec.ts @@ -259,7 +259,7 @@ describe('modules/manager/devbox/extract', () => { const result = extractPackageFile( codeBlock` { - "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid", "invalid"] + "packages": ["nodejs@20.1.8", "yarn@1.22.10", "invalid@invalid", "invalid2"] } `, 'devbox.lock', @@ -288,8 +288,8 @@ describe('modules/manager/devbox/extract', () => { { currentValue: undefined, datasource: 'devbox', - depName: 'invalid', - packageName: 'invalid', + depName: 'invalid2', + packageName: 'invalid2', skipReason: 'not-a-version', }, ], diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts index 259016a4827e78..a79f15aa3bebb7 100644 --- a/lib/modules/manager/devbox/extract.ts +++ b/lib/modules/manager/devbox/extract.ts @@ -2,7 +2,7 @@ import { logger } from '../../../logger'; import { DevboxDatasource } from '../../datasource/devbox'; import * as devboxVersioning from '../../versioning/devbox'; import type { PackageDependency, PackageFileContent } from '../types'; -import { DevboxFile } from './schema'; +import { DevboxFileSchema } from './schema'; export function extractPackageFile( content: string, @@ -10,7 +10,7 @@ export function extractPackageFile( ): PackageFileContent | null { logger.trace('devbox.extractPackageFile()'); - const parsedFile = DevboxFile.safeParse(content); + const parsedFile = DevboxFileSchema.safeParse(content); if (parsedFile.error) { logger.debug( { packageFile, error: parsedFile.error }, @@ -22,26 +22,10 @@ export function extractPackageFile( const file = parsedFile.data; const deps: PackageDependency[] = []; - if (Array.isArray(file.packages)) { - for (const pkgStr of file.packages) { - const [pkgName, pkgVer] = pkgStr.split('@') as [ - string, - string | undefined, - ]; - const pkgDep = getDep(pkgName, pkgVer); - if (pkgDep) { - deps.push(pkgDep); - } - } - } else { - for (const [pkgName, pkgVer] of Object.entries(file.packages)) { - const pkgDep = getDep( - pkgName, - typeof pkgVer === 'string' ? pkgVer : pkgVer.version, - ); - if (pkgDep) { - deps.push(pkgDep); - } + for (const [pkgName, pkgVer] of Object.entries(file.packages)) { + const pkgDep = getDep(pkgName, pkgVer); + if (pkgDep) { + deps.push(pkgDep); } } diff --git a/lib/modules/manager/devbox/schema.spec.ts b/lib/modules/manager/devbox/schema.spec.ts new file mode 100644 index 00000000000000..2ef41b6b064b5e --- /dev/null +++ b/lib/modules/manager/devbox/schema.spec.ts @@ -0,0 +1,59 @@ +import { DevboxSchema } from './schema'; + +describe('modules/manager/devbox/schema', () => { + it('parses devbox.json with empty packages', () => { + expect(DevboxSchema.parse({ packages: {} })).toEqual({ + packages: {}, + }); + }); + + it('parses devbox.json with packages record', () => { + expect(DevboxSchema.parse({ packages: { foo: '1.2.3' } })).toEqual({ + packages: { foo: '1.2.3' }, + }); + expect( + DevboxSchema.parse({ packages: { foo: '1.2.3', bar: '1.2.3' } }), + ).toEqual({ + packages: { foo: '1.2.3', bar: '1.2.3' }, + }); + }); + + it('parses devbox.json with packages record with named version', () => { + expect( + DevboxSchema.parse({ + packages: { + foo: { + version: '1.2.3', + }, + }, + }), + ).toEqual({ + packages: { foo: '1.2.3' }, + }); + expect( + DevboxSchema.parse({ + packages: { + foo: { + version: '1.2.3', + }, + bar: { + version: '1.2.3', + }, + }, + }), + ).toEqual({ + packages: { foo: '1.2.3', bar: '1.2.3' }, + }); + }); + + it('parses devbox.json with packages array', () => { + expect(DevboxSchema.parse({ packages: ['foo@1.2.3'] })).toEqual({ + packages: { foo: '1.2.3' }, + }); + expect( + DevboxSchema.parse({ packages: ['foo@1.2.3', 'bar@1.2.3'] }), + ).toEqual({ + packages: { foo: '1.2.3', bar: '1.2.3' }, + }); + }); +}); diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts index 53e82e9e13c570..c2867e8550113d 100644 --- a/lib/modules/manager/devbox/schema.ts +++ b/lib/modules/manager/devbox/schema.ts @@ -1,11 +1,32 @@ import { z } from 'zod'; import { Jsonc } from '../../../util/schema-utils'; -export const DevboxFile = Jsonc.pipe( - z.object({ - packages: z.union([ +type Packages = Record; + +export const DevboxSchema = z.object({ + packages: z + .union([ z.array(z.string()), z.record(z.union([z.string(), z.object({ version: z.string() })])), - ]), - }), -); + ]) + .transform((packages): Packages => { + const result: Packages = {}; + if (Array.isArray(packages)) { + for (const pkg of packages) { + const [name, version] = pkg.split('@'); + result[name] = version; + } + } else { + for (const [name, value] of Object.entries(packages)) { + if (typeof value === 'string') { + result[name] = value; + } else { + result[name] = value.version; + } + } + } + return result; + }), +}); + +export const DevboxFileSchema = Jsonc.pipe(DevboxSchema); From 92de509d12b5116f7b1c3434c3253403a974eeba Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Thu, 30 Jan 2025 09:08:11 +1100 Subject: [PATCH 7/8] Move all dep logic to transformer --- lib/modules/manager/devbox/extract.ts | 47 +------------ lib/modules/manager/devbox/schema.spec.ts | 78 ++++++++++++---------- lib/modules/manager/devbox/schema.ts | 80 ++++++++++++++++------- 3 files changed, 104 insertions(+), 101 deletions(-) diff --git a/lib/modules/manager/devbox/extract.ts b/lib/modules/manager/devbox/extract.ts index a79f15aa3bebb7..ab557ce7ea4b9e 100644 --- a/lib/modules/manager/devbox/extract.ts +++ b/lib/modules/manager/devbox/extract.ts @@ -1,7 +1,5 @@ import { logger } from '../../../logger'; -import { DevboxDatasource } from '../../datasource/devbox'; -import * as devboxVersioning from '../../versioning/devbox'; -import type { PackageDependency, PackageFileContent } from '../types'; +import type { PackageFileContent } from '../types'; import { DevboxFileSchema } from './schema'; export function extractPackageFile( @@ -19,15 +17,7 @@ export function extractPackageFile( return null; } - const file = parsedFile.data; - const deps: PackageDependency[] = []; - - for (const [pkgName, pkgVer] of Object.entries(file.packages)) { - const pkgDep = getDep(pkgName, pkgVer); - if (pkgDep) { - deps.push(pkgDep); - } - } + const deps = parsedFile.data.packages; if (deps.length) { return { deps }; @@ -35,36 +25,3 @@ export function extractPackageFile( return null; } - -function getDep( - packageName: string, - version: string | undefined, -): PackageDependency | null { - const dep = { - depName: packageName, - currentValue: version, - datasource: DevboxDatasource.id, - packageName, - }; - if (!dep.currentValue) { - logger.trace( - { packageName }, - 'Skipping devbox dependency with no version in devbox JSON file.', - ); - return { - ...dep, - skipReason: 'not-a-version', - }; - } - if (!devboxVersioning.api.isValid(dep.currentValue)) { - logger.trace( - { packageName }, - 'Skipping invalid devbox dependency in devbox JSON file.', - ); - return { - ...dep, - skipReason: 'invalid-version', - }; - } - return dep; -} diff --git a/lib/modules/manager/devbox/schema.spec.ts b/lib/modules/manager/devbox/schema.spec.ts index 2ef41b6b064b5e..02bd8b97ac537f 100644 --- a/lib/modules/manager/devbox/schema.spec.ts +++ b/lib/modules/manager/devbox/schema.spec.ts @@ -3,35 +3,26 @@ import { DevboxSchema } from './schema'; describe('modules/manager/devbox/schema', () => { it('parses devbox.json with empty packages', () => { expect(DevboxSchema.parse({ packages: {} })).toEqual({ - packages: {}, + packages: [], }); }); - it('parses devbox.json with packages record', () => { - expect(DevboxSchema.parse({ packages: { foo: '1.2.3' } })).toEqual({ - packages: { foo: '1.2.3' }, - }); - expect( - DevboxSchema.parse({ packages: { foo: '1.2.3', bar: '1.2.3' } }), - ).toEqual({ - packages: { foo: '1.2.3', bar: '1.2.3' }, - }); - }); - - it('parses devbox.json with packages record with named version', () => { - expect( - DevboxSchema.parse({ + it.each([ + [ + 'parses devbox.json with packages record', + { packages: { foo: '1.2.3' } }, + { packages: { foo: '1.2.3', bar: '1.2.3' } }, + ], + [ + 'parses devbox.json with packages record with version key', + { packages: { foo: { version: '1.2.3', }, }, - }), - ).toEqual({ - packages: { foo: '1.2.3' }, - }); - expect( - DevboxSchema.parse({ + }, + { packages: { foo: { version: '1.2.3', @@ -40,20 +31,39 @@ describe('modules/manager/devbox/schema', () => { version: '1.2.3', }, }, - }), - ).toEqual({ - packages: { foo: '1.2.3', bar: '1.2.3' }, - }); - }); - - it('parses devbox.json with packages array', () => { - expect(DevboxSchema.parse({ packages: ['foo@1.2.3'] })).toEqual({ - packages: { foo: '1.2.3' }, + }, + ], + [ + 'parses devbox.json with packages array', + { packages: ['foo@1.2.3'] }, + { packages: ['foo@1.2.3', 'bar@1.2.3'] }, + ], + ])('%s', (_, singleTest, multipleTest) => { + expect(DevboxSchema.parse(singleTest)).toEqual({ + packages: [ + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'foo', + packageName: 'foo', + }, + ], }); - expect( - DevboxSchema.parse({ packages: ['foo@1.2.3', 'bar@1.2.3'] }), - ).toEqual({ - packages: { foo: '1.2.3', bar: '1.2.3' }, + expect(DevboxSchema.parse(multipleTest)).toEqual({ + packages: [ + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'foo', + packageName: 'foo', + }, + { + currentValue: '1.2.3', + datasource: 'devbox', + depName: 'bar', + packageName: 'bar', + }, + ], }); }); }); diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts index c2867e8550113d..a8a722f9e11dc2 100644 --- a/lib/modules/manager/devbox/schema.ts +++ b/lib/modules/manager/devbox/schema.ts @@ -1,32 +1,68 @@ import { z } from 'zod'; import { Jsonc } from '../../../util/schema-utils'; - -type Packages = Record; +import { PackageDependency } from '../types'; +import { DevboxDatasource } from '../../datasource/devbox'; +import { logger } from '../../../logger'; +import * as devboxVersioning from '../../versioning/devbox'; export const DevboxSchema = z.object({ packages: z .union([ - z.array(z.string()), - z.record(z.union([z.string(), z.object({ version: z.string() })])), + z.array(z.string()).transform((packages) => + packages.reduce( + (result, pkg) => { + const [name, version] = pkg.split('@'); + result[name] = version; + return result; + }, + {} as Record, + ), + ), + z.record( + z.union([ + z.string(), + z.object({ version: z.string() }).transform(({ version }) => version), + ]), + ), ]) - .transform((packages): Packages => { - const result: Packages = {}; - if (Array.isArray(packages)) { - for (const pkg of packages) { - const [name, version] = pkg.split('@'); - result[name] = version; - } - } else { - for (const [name, value] of Object.entries(packages)) { - if (typeof value === 'string') { - result[name] = value; - } else { - result[name] = value.version; - } - } - } - return result; - }), + .transform((packages): PackageDependency[] => + Object.entries(packages) + .map(([pkgName, pkgVer]) => getDep(pkgName, pkgVer)) + .filter((pkgDep): pkgDep is PackageDependency => !!pkgDep), + ), }); export const DevboxFileSchema = Jsonc.pipe(DevboxSchema); + +function getDep( + packageName: string, + version: string | undefined, +): PackageDependency { + const dep = { + depName: packageName, + currentValue: version, + datasource: DevboxDatasource.id, + packageName, + }; + if (!dep.currentValue) { + logger.trace( + { packageName }, + 'Skipping devbox dependency with no version in devbox JSON file.', + ); + return { + ...dep, + skipReason: 'not-a-version', + }; + } + if (!devboxVersioning.api.isValid(dep.currentValue)) { + logger.trace( + { packageName }, + 'Skipping invalid devbox dependency in devbox JSON file.', + ); + return { + ...dep, + skipReason: 'invalid-version', + }; + } + return dep; +} From bef0720768ad6bc07a4c17a70a1be7615c58436d Mon Sep 17 00:00:00 2001 From: Will Brennan Date: Thu, 30 Jan 2025 09:16:59 +1100 Subject: [PATCH 8/8] Fix eslint issues --- lib/modules/manager/devbox/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/modules/manager/devbox/schema.ts b/lib/modules/manager/devbox/schema.ts index a8a722f9e11dc2..d96a4eb9417bb3 100644 --- a/lib/modules/manager/devbox/schema.ts +++ b/lib/modules/manager/devbox/schema.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; +import { logger } from '../../../logger'; import { Jsonc } from '../../../util/schema-utils'; -import { PackageDependency } from '../types'; import { DevboxDatasource } from '../../datasource/devbox'; -import { logger } from '../../../logger'; import * as devboxVersioning from '../../versioning/devbox'; +import type { PackageDependency } from '../types'; export const DevboxSchema = z.object({ packages: z