From f619736677af1b0981b2226221681c849ae79a28 Mon Sep 17 00:00:00 2001 From: Jason Sipula Date: Mon, 26 Aug 2024 02:22:11 -0700 Subject: [PATCH] feat(manager/gleam): extract locked versions (#31000) Co-authored-by: Rhys Arkins Co-authored-by: Michael Kriese --- lib/modules/manager/gleam/extract.spec.ts | 164 +++++++++++++++++- lib/modules/manager/gleam/extract.ts | 59 ++++++- .../manager/gleam/locked-version.spec.ts | 74 ++++++++ lib/modules/manager/gleam/locked-version.ts | 36 ++++ lib/modules/manager/gleam/schema.ts | 13 ++ 5 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 lib/modules/manager/gleam/locked-version.spec.ts create mode 100644 lib/modules/manager/gleam/locked-version.ts diff --git a/lib/modules/manager/gleam/extract.spec.ts b/lib/modules/manager/gleam/extract.spec.ts index 7e91b12ce98338..ecd61afc62c56f 100644 --- a/lib/modules/manager/gleam/extract.spec.ts +++ b/lib/modules/manager/gleam/extract.spec.ts @@ -1,8 +1,14 @@ import { codeBlock } from 'common-tags'; +import { mocked } from '../../../../test/util'; +import * as _fs from '../../../util/fs'; import * as gleamManager from '.'; +jest.mock('../../../util/fs'); + +const fs = mocked(_fs); + describe('modules/manager/gleam/extract', () => { - it('should extract dev and prod dependencies', () => { + it('should extract dev and prod dependencies', async () => { const gleamTomlString = codeBlock` name = "test_gleam_toml" version = "1.0.0" @@ -13,7 +19,12 @@ describe('modules/manager/gleam/extract', () => { [dev-dependencies] gleeunit = "~> 1.0" `; - const extracted = gleamManager.extractPackageFile(gleamTomlString); + + fs.readLocalFile.mockResolvedValueOnce(gleamTomlString); + const extracted = await gleamManager.extractPackageFile( + gleamTomlString, + 'gleam.toml', + ); expect(extracted?.deps).toEqual([ { currentValue: '~> 0.6.0', @@ -30,7 +41,7 @@ describe('modules/manager/gleam/extract', () => { ]); }); - it('should extract dev only dependencies', () => { + it('should extract dev only dependencies', async () => { const gleamTomlString = codeBlock` name = "test_gleam_toml" version = "1.0.0" @@ -38,7 +49,12 @@ describe('modules/manager/gleam/extract', () => { [dev-dependencies] gleeunit = "~> 1.0" `; - const extracted = gleamManager.extractPackageFile(gleamTomlString); + + fs.readLocalFile.mockResolvedValueOnce(gleamTomlString); + const extracted = await gleamManager.extractPackageFile( + gleamTomlString, + 'gleam.toml', + ); expect(extracted?.deps).toEqual([ { currentValue: '~> 1.0', @@ -49,7 +65,7 @@ describe('modules/manager/gleam/extract', () => { ]); }); - it('should return null when no dependencies are found', () => { + it('should return null when no dependencies are found', async () => { const gleamTomlString = codeBlock` name = "test_gleam_toml" version = "1.0.0" @@ -57,7 +73,143 @@ describe('modules/manager/gleam/extract', () => { [unknown] gleam_http = "~> 3.6.0" `; - const extracted = gleamManager.extractPackageFile(gleamTomlString); + + fs.readLocalFile.mockResolvedValueOnce(gleamTomlString); + const extracted = await gleamManager.extractPackageFile( + gleamTomlString, + 'gleam.toml', + ); + expect(extracted).toBeNull(); + }); + + it('should return null when gleam.toml is invalid', async () => { + fs.readLocalFile.mockResolvedValueOnce('foo'); + const extracted = await gleamManager.extractPackageFile( + 'foo', + 'gleam.toml', + ); expect(extracted).toBeNull(); }); + + it('should return locked versions', async () => { + const packageFileContent = codeBlock` + name = "test_gleam_toml" + version = "1.0.0" + + [dependencies] + foo = ">= 1.0.0 and < 2.0.0" + `; + const lockFileContent = codeBlock` + packages = [ + { name = "foo", version = "1.0.4", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + ] + + [requirements] + foo = { version = ">= 1.0.0 and < 2.0.0" } + `; + + fs.getSiblingFileName.mockReturnValueOnce('manifest.toml'); + fs.readLocalFile.mockResolvedValueOnce(lockFileContent); + fs.localPathExists.mockResolvedValueOnce(true); + const extracted = await gleamManager.extractPackageFile( + packageFileContent, + 'gleam.toml', + ); + expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(true); + }); + + it('should fail to extract locked version', async () => { + const packageFileContent = codeBlock` + name = "test_gleam_toml" + version = "1.0.0" + + [dependencies] + foo = ">= 1.0.0 and < 2.0.0" + `; + + fs.getSiblingFileName.mockReturnValueOnce('manifest.toml'); + fs.readLocalFile.mockResolvedValueOnce(null); + fs.localPathExists.mockResolvedValueOnce(true); + const extracted = await gleamManager.extractPackageFile( + packageFileContent, + 'gleam.toml', + ); + expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(false); + }); + + it('should fail to find locked version in range', async () => { + const packageFileContent = codeBlock` + name = "test_gleam_toml" + version = "1.0.0" + + [dependencies] + foo = ">= 1.0.0 and < 2.0.0" + `; + const lockFileContent = codeBlock` + packages = [ + { name = "foo", version = "2.0.1", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + ] + + [requirements] + foo = { version = ">= 1.0.0 and < 2.0.0" } + `; + + fs.getSiblingFileName.mockReturnValueOnce('manifest.toml'); + fs.readLocalFile.mockResolvedValueOnce(lockFileContent); + fs.localPathExists.mockResolvedValueOnce(true); + const extracted = await gleamManager.extractPackageFile( + packageFileContent, + 'gleam.toml', + ); + expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(false); + }); + + it('should handle invalid versions in lock file', async () => { + const packageFileContent = codeBlock` + name = "test_gleam_toml" + version = "1.0.0" + + [dependencies] + foo = ">= 1.0.0 and < 2.0.0" + `; + const lockFileContent = codeBlock` + packages = [ + { name = "foo", version = "fooey", build_tools = ["gleam"], requirements = [], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + ] + + [requirements] + foo = { version = ">= 1.0.0 and < 2.0.0" } + `; + + fs.getSiblingFileName.mockReturnValueOnce('manifest.toml'); + fs.readLocalFile.mockResolvedValueOnce(lockFileContent); + fs.localPathExists.mockResolvedValueOnce(true); + const extracted = await gleamManager.extractPackageFile( + packageFileContent, + 'gleam.toml', + ); + expect(extracted!.deps).not.toHaveProperty('lockedVersion'); + }); + + it('should handle lock file parsing and extracting errors', async () => { + const packageFileContent = codeBlock` + name = "test_gleam_toml" + version = "1.0.0" + + [dependencies] + foo = ">= 1.0.0 and < 2.0.0" + `; + const lockFileContent = codeBlock`invalid`; + + fs.getSiblingFileName.mockReturnValueOnce('manifest.toml'); + fs.readLocalFile.mockResolvedValueOnce(lockFileContent); + fs.localPathExists.mockResolvedValueOnce(true); + const extracted = await gleamManager.extractPackageFile( + packageFileContent, + 'gleam.toml', + ); + expect(extracted!.deps).not.toHaveProperty('lockedVersion'); + }); }); diff --git a/lib/modules/manager/gleam/extract.ts b/lib/modules/manager/gleam/extract.ts index 5e44489857acc5..0e8e05aaf5a9dc 100644 --- a/lib/modules/manager/gleam/extract.ts +++ b/lib/modules/manager/gleam/extract.ts @@ -1,5 +1,10 @@ +import { logger } from '../../../logger'; +import { coerceArray } from '../../../util/array'; +import { getSiblingFileName, localPathExists } from '../../../util/fs'; import { HexDatasource } from '../../datasource/hex'; +import { api as versioning } from '../../versioning/hex'; import type { PackageDependency, PackageFileContent } from '../types'; +import { extractLockFileVersions } from './locked-version'; import { GleamToml } from './schema'; const dependencySections = ['dependencies', 'dev-dependencies'] as const; @@ -53,7 +58,55 @@ function extractGleamTomlDeps(gleamToml: GleamToml): PackageDependency[] { ); } -export function extractPackageFile(content: string): PackageFileContent | null { - const deps = extractGleamTomlDeps(GleamToml.parse(content)); - return deps.length ? { deps } : null; +export async function extractPackageFile( + content: string, + packageFile: string, +): Promise { + const result = GleamToml.safeParse(content); + if (!result.success) { + logger.debug( + { err: result.error, packageFile }, + 'Error parsing Gleam package file content', + ); + return null; + } + + const deps = extractGleamTomlDeps(result.data); + if (!deps.length) { + logger.debug(`No dependencies found in Gleam package file ${packageFile}`); + return null; + } + + const packageFileContent: PackageFileContent = { deps }; + const lockFileName = getSiblingFileName(packageFile, 'manifest.toml'); + + const lockFileExists = await localPathExists(lockFileName); + if (!lockFileExists) { + logger.debug(`Lock file ${lockFileName} does not exist.`); + return packageFileContent; + } + + const versionsByPackage = await extractLockFileVersions(lockFileName); + if (!versionsByPackage) { + return packageFileContent; + } + + packageFileContent.lockFiles = [lockFileName]; + + for (const dep of packageFileContent.deps) { + const packageName = dep.depName!; + const versions = coerceArray(versionsByPackage.get(packageName)); + const lockedVersion = versioning.getSatisfyingVersion( + versions, + dep.currentValue!, + ); + if (lockedVersion) { + dep.lockedVersion = lockedVersion; + } else { + logger.debug( + `No locked version found for package ${dep.depName} in the range of ${dep.currentValue}.`, + ); + } + } + return packageFileContent; } diff --git a/lib/modules/manager/gleam/locked-version.spec.ts b/lib/modules/manager/gleam/locked-version.spec.ts new file mode 100644 index 00000000000000..3203d04f141c58 --- /dev/null +++ b/lib/modules/manager/gleam/locked-version.spec.ts @@ -0,0 +1,74 @@ +import { codeBlock } from 'common-tags'; +import { mocked } from '../../../../test/util'; +import { logger } from '../../../logger'; +import * as _fs from '../../../util/fs'; +import { extractLockFileVersions, parseLockFile } from './locked-version'; + +jest.mock('../../../util/fs'); + +const fs = mocked(_fs); + +const lockFileContent = codeBlock` + packages = [ + { name = "foo", version = "1.0.4", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, + ] + + [requirements] + foo = { version = ">= 1.0.0 and < 2.0.0" } +`; + +describe('modules/manager/gleam/locked-version', () => { + describe('extractLockFileVersions()', () => { + it('returns null for missing lock file', async () => { + expect(await extractLockFileVersions('manifest.toml')).toBeNull(); + }); + + it('returns null for invalid lock file', async () => { + fs.readLocalFile.mockResolvedValueOnce('foo'); + expect(await extractLockFileVersions('manifest.toml')).toBeNull(); + }); + + it('returns empty map for lock file without packages', async () => { + fs.readLocalFile.mockResolvedValueOnce('[requirements]'); + expect(await extractLockFileVersions('manifest.toml')).toEqual(new Map()); + }); + + it('returns a map of package versions', async () => { + fs.readLocalFile.mockResolvedValueOnce(lockFileContent); + expect(await extractLockFileVersions('manifest.toml')).toEqual( + new Map([ + ['foo', ['1.0.4']], + ['bar', ['2.1.0']], + ]), + ); + }); + }); + + describe('parseLockFile', () => { + it('parses lockfile string into an object', () => { + const parseLockFileResult = parseLockFile(lockFileContent); + logger.debug({ parseLockFileResult }, 'parseLockFile'); + expect(parseLockFileResult).toStrictEqual({ + packages: [ + { + name: 'foo', + version: '1.0.4', + requirements: ['bar'], + }, + { + name: 'bar', + version: '2.1.0', + requirements: [], + }, + ], + }); + }); + + it('can deal with invalid lockfiles', () => { + const lockFile = 'foo'; + const parseLockFileResult = parseLockFile(lockFile); + expect(parseLockFileResult).toBeNull(); + }); + }); +}); diff --git a/lib/modules/manager/gleam/locked-version.ts b/lib/modules/manager/gleam/locked-version.ts new file mode 100644 index 00000000000000..e36cbc3faac548 --- /dev/null +++ b/lib/modules/manager/gleam/locked-version.ts @@ -0,0 +1,36 @@ +import { logger } from '../../../logger'; +import { coerceArray } from '../../../util/array'; +import { readLocalFile } from '../../../util/fs'; +import { ManifestToml } from './schema'; + +export async function extractLockFileVersions( + lockFilePath: string, +): Promise | null> { + const content = await readLocalFile(lockFilePath, 'utf8'); + if (!content) { + logger.debug(`Gleam lock file ${lockFilePath} not found`); + return null; + } + + const versionsByPackage = new Map(); + const lock = parseLockFile(content); + if (!lock) { + logger.debug(`Error parsing Gleam lock file ${lockFilePath}`); + return null; + } + for (const pkg of coerceArray(lock.packages)) { + const versions = coerceArray(versionsByPackage.get(pkg.name)); + versions.push(pkg.version); + versionsByPackage.set(pkg.name, versions); + } + return versionsByPackage; +} + +export function parseLockFile(lockFileContent: string): ManifestToml | null { + const res = ManifestToml.safeParse(lockFileContent); + if (res.success) { + return res.data; + } + logger.debug({ err: res.error }, 'Error parsing manifest.toml.'); + return null; +} diff --git a/lib/modules/manager/gleam/schema.ts b/lib/modules/manager/gleam/schema.ts index 3a53f7e1353e6a..a80a64a6c4bf3f 100644 --- a/lib/modules/manager/gleam/schema.ts +++ b/lib/modules/manager/gleam/schema.ts @@ -9,4 +9,17 @@ export const GleamToml = Toml.pipe( }), ); +const Package = z.object({ + name: z.string(), + version: z.string(), + requirements: z.array(z.string()).optional(), +}); + +export const ManifestToml = Toml.pipe( + z.object({ + packages: z.array(Package).optional(), + }), +); + export type GleamToml = z.infer; +export type ManifestToml = z.infer;