diff --git a/lib/modules/datasource/api.ts b/lib/modules/datasource/api.ts index e7284442c0a36ba4638f81e168f6f93e81e45c5c..84ba7fc52c5d2a4713dfa0e40a0253c80bfb588d 100644 --- a/lib/modules/datasource/api.ts +++ b/lib/modules/datasource/api.ts @@ -17,6 +17,7 @@ import { DartDatasource } from './dart'; import { DartVersionDatasource } from './dart-version'; import { DebDatasource } from './deb'; import { DenoDatasource } from './deno'; +import { DevboxDatasource } from './devbox'; import { DockerDatasource } from './docker'; import { DotnetVersionDatasource } from './dotnet-version'; import { EndoflifeDateDatasource } from './endoflife-date'; @@ -88,6 +89,7 @@ api.set(DartDatasource.id, new DartDatasource()); api.set(DartVersionDatasource.id, new DartVersionDatasource()); api.set(DebDatasource.id, new DebDatasource()); api.set(DenoDatasource.id, new DenoDatasource()); +api.set(DevboxDatasource.id, new DevboxDatasource()); api.set(DockerDatasource.id, new DockerDatasource()); api.set(DotnetVersionDatasource.id, new DotnetVersionDatasource()); api.set(EndoflifeDateDatasource.id, new EndoflifeDateDatasource()); diff --git a/lib/modules/datasource/devbox/common.ts b/lib/modules/datasource/devbox/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..9149c754171e9ae1e632605de388d4b74c5832f3 --- /dev/null +++ b/lib/modules/datasource/devbox/common.ts @@ -0,0 +1,3 @@ +export const defaultRegistryUrl = 'https://search.devbox.sh/v2/'; + +export const datasource = 'devbox'; diff --git a/lib/modules/datasource/devbox/index.spec.ts b/lib/modules/datasource/devbox/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b525a34d9c0b8dafb8d0a417df9898640e0b226 --- /dev/null +++ b/lib/modules/datasource/devbox/index.spec.ts @@ -0,0 +1,159 @@ +import { getPkgReleases } from '..'; +import * as httpMock from '../../../../test/http-mock'; +import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages'; +import { datasource, defaultRegistryUrl } from './common'; + +const packageName = 'nodejs'; + +function getPath(packageName: string): string { + return `/pkg?name=${encodeURIComponent(packageName)}`; +} + +const sampleReleases = [ + { + version: '22.2.0', + last_updated: '2024-05-22T06:18:38Z', + }, + { + version: '22.0.0', + last_updated: '2024-05-12T16:19:40Z', + }, + { + version: '21.7.3', + last_updated: '2024-04-19T21:36:04Z', + }, +]; + +describe('modules/datasource/devbox/index', () => { + describe('getReleases', () => { + it('throws for error', async () => { + httpMock + .scope(defaultRegistryUrl) + .get(getPath(packageName)) + .replyWithError('error'); + await expect( + getPkgReleases({ + datasource, + packageName, + }), + ).rejects.toThrow(EXTERNAL_HOST_ERROR); + }); + }); + + it('returns null for 404', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(404); + expect( + await getPkgReleases({ + datasource, + packageName, + }), + ).toBeNull(); + }); + + it('returns null for empty result', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(200, {}); + expect( + await getPkgReleases({ + datasource, + packageName, + }), + ).toBeNull(); + }); + + it('returns null for empty 200 OK', async () => { + httpMock + .scope(defaultRegistryUrl) + .get(getPath(packageName)) + .reply(200, { versions: [] }); + expect( + await getPkgReleases({ + datasource, + packageName, + }), + ).toBeNull(); + }); + + it('throws for 5xx', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(502); + await expect( + getPkgReleases({ + datasource, + packageName, + }), + ).rejects.toThrow(EXTERNAL_HOST_ERROR); + }); + + it('processes real data', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(200, { + name: 'nodejs', + summary: 'Event-driven I/O framework for the V8 JavaScript engine', + homepage_url: 'https://nodejs.org', + license: 'MIT', + releases: sampleReleases, + }); + const res = await getPkgReleases({ + datasource, + packageName, + }); + expect(res).toEqual({ + homepage: 'https://nodejs.org', + registryUrl: 'https://search.devbox.sh/v2', + releases: [ + { + version: '21.7.3', + releaseTimestamp: '2024-04-19T21:36:04.000Z', + }, + { + version: '22.0.0', + releaseTimestamp: '2024-05-12T16:19:40.000Z', + }, + { + version: '22.2.0', + releaseTimestamp: '2024-05-22T06:18:38.000Z', + }, + ], + }); + }); + + it('processes empty data', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(200, { + name: 'nodejs', + summary: 'Event-driven I/O framework for the V8 JavaScript engine', + homepage_url: 'https://nodejs.org', + license: 'MIT', + releases: [], + }); + const res = await getPkgReleases({ + datasource, + packageName, + }); + expect(res).toBeNull(); + }); + + it('returns null when no body is returned', async () => { + httpMock + .scope(defaultRegistryUrl) + .get(getPath(packageName)) + .reply(200, undefined); + const res = await getPkgReleases({ + datasource, + packageName, + }); + expect(res).toBeNull(); + }); + + it('falls back to a default homepage_url', async () => { + httpMock.scope(defaultRegistryUrl).get(getPath(packageName)).reply(200, { + name: 'nodejs', + summary: 'Event-driven I/O framework for the V8 JavaScript engine', + homepage_url: undefined, + license: 'MIT', + releases: sampleReleases, + }); + const res = await getPkgReleases({ + datasource, + packageName, + }); + expect(res?.homepage).toBeUndefined(); + }); +}); diff --git a/lib/modules/datasource/devbox/index.ts b/lib/modules/datasource/devbox/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..be89dced54ee8fe22e2dcfe36178ea3e9394348e --- /dev/null +++ b/lib/modules/datasource/devbox/index.ts @@ -0,0 +1,57 @@ +import { logger } from '../../../logger'; +import { ExternalHostError } from '../../../types/errors/external-host-error'; +import { HttpError } from '../../../util/http'; +import { joinUrlParts } from '../../../util/url'; +import * as devboxVersioning from '../../versioning/devbox'; +import { Datasource } from '../datasource'; +import type { GetReleasesConfig, ReleaseResult } from '../types'; +import { datasource, defaultRegistryUrl } from './common'; +import { DevboxResponse } from './schema'; + +export class DevboxDatasource extends Datasource { + static readonly id = datasource; + + constructor() { + super(datasource); + } + + override readonly customRegistrySupport = true; + override readonly releaseTimestampSupport = true; + + override readonly registryStrategy = 'first'; + + override readonly defaultVersioning = devboxVersioning.id; + + override readonly defaultRegistryUrls = [defaultRegistryUrl]; + + async getReleases({ + registryUrl, + packageName, + }: GetReleasesConfig): Promise<ReleaseResult | null> { + const res: ReleaseResult = { + releases: [], + }; + + logger.trace({ registryUrl, packageName }, 'fetching devbox release'); + + const devboxPkgUrl = joinUrlParts( + registryUrl!, + `/pkg?name=${encodeURIComponent(packageName)}`, + ); + + try { + const response = await this.http.getJson(devboxPkgUrl, DevboxResponse); + res.releases = response.body.releases; + res.homepage = response.body.homepage; + } catch (err) { + // istanbul ignore else: not testable with nock + if (err instanceof HttpError) { + if (err.response?.statusCode !== 404) { + throw new ExternalHostError(err); + } + } + this.handleGenericErrors(err); + } + return res.releases.length ? res : null; + } +} diff --git a/lib/modules/datasource/devbox/schema.ts b/lib/modules/datasource/devbox/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc31efae879850cde7898aebf4417110ccd37e4d --- /dev/null +++ b/lib/modules/datasource/devbox/schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const DevboxRelease = z.object({ + version: z.string(), + last_updated: z.string(), +}); + +export const DevboxResponse = z + .object({ + name: z.string(), + summary: z.string().optional(), + homepage_url: z.string().optional(), + license: z.string().optional(), + releases: DevboxRelease.array(), + }) + .transform((response) => ({ + name: response.name, + homepage: response.homepage_url, + releases: response.releases.map((release) => ({ + version: release.version, + releaseTimestamp: release.last_updated, + })), + }));