diff --git a/lib/modules/datasource/packagist/index.spec.ts b/lib/modules/datasource/packagist/index.spec.ts index a0c7b09fc9f92ba9aa2731eb58beea5732b772e0..4ef75442e11e33227c2a323606f98ed25188319f 100644 --- a/lib/modules/datasource/packagist/index.spec.ts +++ b/lib/modules/datasource/packagist/index.spec.ts @@ -82,7 +82,12 @@ describe('modules/datasource/packagist/index', () => { .scope('https://composer.renovatebot.com') .get('/packages.json') .replyWithError({ code: 'ETIMEDOUT' }); - httpMock.scope(baseUrl).get('/p2/vendor/package-name2.json').reply(200); + httpMock + .scope(baseUrl) + .get('/p2/vendor/package-name2.json') + .reply(200) + .get('/p2/vendor/package-name2~dev.json') + .reply(200); const res = await getPkgReleases({ ...config, datasource, @@ -97,7 +102,12 @@ describe('modules/datasource/packagist/index', () => { .scope('https://composer.renovatebot.com') .get('/packages.json') .reply(403); - httpMock.scope(baseUrl).get('/p2/vendor/package-name.json').reply(200); + httpMock + .scope(baseUrl) + .get('/p2/vendor/package-name.json') + .reply(200) + .get('/p2/vendor/package-name~dev.json') + .reply(200); const res = await getPkgReleases({ ...config, datasource, @@ -112,7 +122,12 @@ describe('modules/datasource/packagist/index', () => { .scope('https://composer.renovatebot.com') .get('/packages.json') .reply(404); - httpMock.scope(baseUrl).get('/p2/drewm/mailchimp-api.json').reply(200); + httpMock + .scope(baseUrl) + .get('/p2/drewm/mailchimp-api.json') + .reply(200) + .get('/p2/drewm/mailchimp-api~dev.json') + .reply(200); const res = await getPkgReleases({ ...config, datasource, @@ -266,7 +281,12 @@ describe('modules/datasource/packagist/index', () => { '/p/providers-2018-09$14346045d7a7261cb3a12a6b7a1a7c4151982530347b115e5e277d879cad1942.json' ) .reply(200, fileJson); - httpMock.scope(baseUrl).get('/p2/some/other.json').reply(200, beytJson); + httpMock + .scope(baseUrl) + .get('/p2/some/other.json') + .reply(200, beytJson) + .get('/p2/some/other~dev.json') + .reply(200, beytJson); const res = await getPkgReleases({ ...config, datasource, @@ -357,7 +377,12 @@ describe('modules/datasource/packagist/index', () => { .scope('https://composer.renovatebot.com') .get('/packages.json') .reply(200, packagesJson); - httpMock.scope(baseUrl).get('/p2/some/other.json').reply(200, beytJson); + httpMock + .scope(baseUrl) + .get('/p2/some/other.json') + .reply(200, beytJson) + .get('/p2/some/other~dev.json') + .reply(200, beytJson); const res = await getPkgReleases({ ...config, datasource, diff --git a/lib/modules/datasource/packagist/index.ts b/lib/modules/datasource/packagist/index.ts index 528941feee8aa3549cdbc572e32ddc9d768dbfdc..9171467dc48c1d6d4b9dcfc22a0014e58d7b20fc 100644 --- a/lib/modules/datasource/packagist/index.ts +++ b/lib/modules/datasource/packagist/index.ts @@ -1,7 +1,6 @@ import URL from 'url'; import { logger } from '../../../logger'; import { ExternalHostError } from '../../../types/errors/external-host-error'; -import * as packageCache from '../../../util/cache/package'; import { cache } from '../../../util/cache/package/decorator'; import * as hostRules from '../../../util/host-rules'; import type { HttpOptions } from '../../../util/http/types'; @@ -11,6 +10,7 @@ import { ensureTrailingSlash, joinUrlParts } from '../../../util/url'; import * as composerVersioning from '../../versioning/composer'; import { Datasource } from '../datasource'; import type { GetReleasesConfig, ReleaseResult } from '../types'; +import * as schema from './schema'; import type { AllPackages, PackageMeta, @@ -195,35 +195,19 @@ export class PackagistDatasource extends Datasource { return allPackages; } + @cache({ + namespace: `datasource-${PackagistDatasource.id}-org`, + key: (regUrl: string) => regUrl, + ttlMinutes: 10, + }) async packagistOrgLookup(name: string): Promise<ReleaseResult | null> { - const cacheNamespace = 'datasource-packagist-org'; - const cachedResult = await packageCache.get<ReleaseResult>( - cacheNamespace, - name - ); - // istanbul ignore if - if (cachedResult) { - return cachedResult; - } - let dep: ReleaseResult | null = null; const regUrl = 'https://packagist.org'; - const pkgUrl = [ - joinUrlParts(regUrl, `/p2/${name}.json`), - joinUrlParts(regUrl, `/p2/${name}~dev.json`), - ]; - // TODO: fix types (#9610) - let res = (await this.http.getJson<any>(pkgUrl[0])).body.packages[name]; - res = [ - ...res, - ...(await this.http.getJson<any>(pkgUrl[1])).body.packages[name], - ]; - if (res) { - dep = PackagistDatasource.extractDepReleases(res); - logger.trace({ dep }, 'dep'); - } - const cacheMinutes = 10; - await packageCache.set(cacheNamespace, name, dep, cacheMinutes); - return dep; + const pkgUrl = joinUrlParts(regUrl, `/p2/${name}.json`); + const devUrl = joinUrlParts(regUrl, `/p2/${name}~dev.json`); + const results = await p.map([pkgUrl, devUrl], (url) => + this.http.getJson(url).then(({ body }) => body) + ); + return schema.parsePackagesResponses(name, results); } public override async getReleases({ diff --git a/lib/modules/datasource/packagist/schema.spec.ts b/lib/modules/datasource/packagist/schema.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..679d5c14713e3e9661b614af36d4e82bc4964b53 --- /dev/null +++ b/lib/modules/datasource/packagist/schema.spec.ts @@ -0,0 +1,154 @@ +import { + ComposerRelease, + ComposerReleaseArray, + parsePackagesResponse, + parsePackagesResponses, +} from './schema'; + +describe('modules/datasource/packagist/schema', () => { + describe('ComposerRelease', () => { + it('rejects', () => { + expect(() => ComposerRelease.parse(null)).toThrow(); + expect(() => ComposerRelease.parse(undefined)).toThrow(); + expect(() => ComposerRelease.parse('')).toThrow(); + expect(() => ComposerRelease.parse({})).toThrow(); + expect(() => ComposerRelease.parse({ version: null })).toThrow(); + expect(() => ComposerRelease.parse({ version: null })).toThrow(); + expect(() => ComposerRelease.parse({ version: '' })).toThrow(); + expect(() => ComposerRelease.parse({ version: 'dev-main' })).toThrow(); + }); + + it('parses', () => { + const defaultResult = { + version: '1.2.3', + homepage: null, + source: null, + time: null, + }; + + expect(ComposerRelease.parse({ version: '1.2.3' })).toEqual( + defaultResult + ); + + expect(ComposerRelease.parse({ version: '1.2.3', homepage: 42 })).toEqual( + defaultResult + ); + + expect( + ComposerRelease.parse({ version: '1.2.3', homepage: 'example.com' }) + ).toEqual({ ...defaultResult, homepage: 'example.com' }); + + expect( + ComposerRelease.parse({ version: '1.2.3', source: 'nonsense' }) + ).toEqual(defaultResult); + + expect( + ComposerRelease.parse({ version: '1.2.3', source: { url: 'foobar' } }) + ).toEqual({ ...defaultResult, source: 'foobar' }); + + expect( + ComposerRelease.parse({ version: '1.2.3', time: '12345' }) + ).toEqual({ ...defaultResult, time: '12345' }); + }); + }); + + describe('ComposerReleaseArray', () => { + it('rejects', () => { + expect(() => ComposerReleaseArray.parse(null)).toThrow(); + expect(() => ComposerReleaseArray.parse(undefined)).toThrow(); + expect(() => ComposerReleaseArray.parse('')).toThrow(); + expect(() => ComposerReleaseArray.parse({})).toThrow(); + }); + + it('parses', () => { + expect(ComposerReleaseArray.parse([])).toEqual([]); + expect(ComposerReleaseArray.parse([null])).toEqual([]); + expect(ComposerReleaseArray.parse([1, 2, 3])).toEqual([]); + expect(ComposerReleaseArray.parse(['foobar'])).toEqual([]); + expect(ComposerReleaseArray.parse([{ version: '1.2.3' }])).toEqual([ + { version: '1.2.3', homepage: null, source: null, time: null }, + ]); + }); + }); + + describe('parsePackageResponse', () => { + it('parses', () => { + expect(parsePackagesResponse('foo/bar', null)).toEqual([]); + expect(parsePackagesResponse('foo/bar', {})).toEqual([]); + expect(parsePackagesResponse('foo/bar', { packages: '123' })).toEqual([]); + expect(parsePackagesResponse('foo/bar', { packages: {} })).toEqual([]); + expect( + parsePackagesResponse('foo/bar', { + packages: { + 'foo/bar': [{ version: '1.2.3' }], + 'baz/qux': [{ version: '4.5.6' }], + }, + }) + ).toEqual([ + { version: '1.2.3', homepage: null, source: null, time: null }, + ]); + }); + }); + + describe('parsePackagesResponses', () => { + it('parses', () => { + expect(parsePackagesResponses('foo/bar', [null])).toBeNull(); + expect(parsePackagesResponses('foo/bar', [{}])).toBeNull(); + expect( + parsePackagesResponses('foo/bar', [{ packages: '123' }]) + ).toBeNull(); + expect(parsePackagesResponses('foo/bar', [{ packages: {} }])).toBeNull(); + expect( + parsePackagesResponses('foo/bar', [ + { + packages: { + 'foo/bar': [ + { + version: 'v1.1.1', + time: '111', + homepage: 'https://example.com/1', + source: { url: 'git@example.com:foo/bar-1' }, + }, + ], + 'baz/qux': [ + { + version: 'v2.2.2', + time: '222', + homepage: 'https://example.com/2', + source: { url: 'git@example.com:baz/qux-2' }, + }, + ], + }, + }, + { + packages: { + 'foo/bar': [ + { + version: 'v3.3.3', + time: '333', + homepage: 'https://example.com/3', + source: { url: 'git@example.com:foo/bar-3' }, + }, + ], + 'baz/qux': [ + { + version: 'v4.4.4', + time: '444', + homepage: 'https://example.com/4', + source: { url: 'git@example.com:baz/qux-3' }, + }, + ], + }, + }, + ]) + ).toEqual({ + homepage: 'https://example.com/3', + sourceUrl: 'git@example.com:foo/bar-3', + releases: [ + { version: '1.1.1', gitRef: 'v1.1.1', releaseTimestamp: '111' }, + { version: '3.3.3', gitRef: 'v3.3.3', releaseTimestamp: '333' }, + ], + }); + }); + }); +}); diff --git a/lib/modules/datasource/packagist/schema.ts b/lib/modules/datasource/packagist/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..afaacc61d4d019f006ccc04683907ca37bd5732f --- /dev/null +++ b/lib/modules/datasource/packagist/schema.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; +import { api as versioning } from '../../versioning/composer'; +import type { Release, ReleaseResult } from '../types'; + +export const ComposerRelease = z.object({ + version: z + .string() + .refine((v) => versioning.isSingleVersion(v), 'Invalid version'), + homepage: z.string().nullable().catch(null), + source: z + .object({ + url: z.string(), + }) + .transform((x) => x.url) + .nullable() + .catch(null), + time: z.string().nullable().catch(null), +}); + +export const ComposerReleaseArray = z + .array(ComposerRelease.nullable().catch(null)) + .transform((xs) => + xs.filter((x): x is z.infer<typeof ComposerRelease> => x !== null) + ); +export type ComposerReleaseArray = z.infer<typeof ComposerReleaseArray>; + +export const ComposerPackagesResponse = z.object({ + packages: z.record(z.unknown()), +}); + +export function parsePackagesResponse( + packageName: string, + packagesResponse: unknown +): ComposerReleaseArray { + const packagesResponseParsed = + ComposerPackagesResponse.safeParse(packagesResponse); + if (!packagesResponseParsed.success) { + return []; + } + + const { packages } = packagesResponseParsed.data; + const releaseArray = packages[packageName]; + const releaseArrayParsed = ComposerReleaseArray.safeParse(releaseArray); + if (!releaseArrayParsed.success) { + return []; + } + + return releaseArrayParsed.data; +} + +export function parsePackagesResponses( + packageName: string, + packagesResponses: unknown[] +): ReleaseResult | null { + const releases: Release[] = []; + let maxVersion: string | null = null; + let homepage: string | null = null; + let sourceUrl: string | null = null; + + for (const packagesResponse of packagesResponses) { + const releaseArray = parsePackagesResponse(packageName, packagesResponse); + for (const composerRelease of releaseArray) { + const version = composerRelease.version.replace(/^v/, ''); + const gitRef = composerRelease.version; + + const dep: Release = { version, gitRef }; + + if (composerRelease.time) { + dep.releaseTimestamp = composerRelease.time; + } + + releases.push(dep); + + if (!maxVersion || versioning.isGreaterThan(version, maxVersion)) { + maxVersion = version; + homepage = composerRelease.homepage; + sourceUrl = composerRelease.source; + } + } + } + + if (releases.length === 0) { + return null; + } + + const result: ReleaseResult = { releases }; + + if (homepage) { + result.homepage = homepage; + } + + if (sourceUrl) { + result.sourceUrl = sourceUrl; + } + + return result; +}