import { isTruthy } from '@sindresorhus/is'; import { logger } from '../../../logger/index.ts'; import { withCache } from '../../../util/cache/package/with-cache.ts'; import { HttpError } from '../../../util/http/index.ts'; import * as p from '../../../util/promises.ts'; import { regEx } from '../../../util/regex.ts'; import { ensureTrailingSlash, joinUrlParts } from '../../../util/url.ts'; import * as pep440Versioning from '../../versioning/pep440/index.ts'; import { Datasource } from '../datasource.ts'; import type { GetReleasesConfig, Release, ReleaseResult } from '../types.ts'; import { GalaxyV3, GalaxyV3DetailedVersion, GalaxyV3Versions, } from './schema.ts'; const ansibleProtocolRegex = regEx(/^\S+\/api\/ansible\/.+/); const repositoryRegex = regEx( /^\S+\/api\/galaxy\/content\/(?[^/]+)/, ); export class GalaxyCollectionDatasource extends Datasource { static readonly id = 'galaxy-collection'; constructor() { super(GalaxyCollectionDatasource.id); } override readonly customRegistrySupport = true; override readonly registryStrategy = 'hunt'; override readonly defaultRegistryUrls = ['https://galaxy.ansible.com/api/']; override readonly defaultVersioning = pep440Versioning.id; override readonly releaseTimestampSupport = true; override releaseTimestampNote = 'The release timestamp is determined from the `created_at` field in the results.'; // sourceUrl is returned in each release as well as the ReleaseResult // the one present in release result is the sourceUrl of the latest release override readonly sourceUrlSupport = 'release'; override readonly sourceUrlNote = 'The `sourceUrl` is determined from the `repository` field in the results.'; private async _getReleases({ packageName, registryUrl, }: GetReleasesConfig): Promise { const baseUrl = this.constructBaseUrl(registryUrl!, packageName); const { val: baseProject, err: baseErr } = await this.http .getJsonSafe(baseUrl, GalaxyV3) .onError((err) => { if (!(err instanceof HttpError && err.response?.statusCode === 404)) { logger.warn( { url: baseUrl, datasource: this.id, packageName, err }, 'Error fetching from url', ); } }) .unwrap(); if (baseErr) { this.handleGenericErrors(baseErr); } const versionsUrl = ensureTrailingSlash(joinUrlParts(baseUrl, 'versions')); const { val: rawReleases, err: versionsErr } = await this.http .getJsonSafe(versionsUrl, GalaxyV3Versions) .onError((err) => { logger.warn( { url: versionsUrl, datasource: this.id, packageName, err }, 'Error fetching from url', ); }) .unwrap(); if (versionsErr) { this.handleGenericErrors(versionsErr); } const releases = rawReleases.map((value) => { return { ...value, isDeprecated: baseProject.deprecated, }; }); // asynchronously get release details const enrichedReleases = await p.map( releases, (release) => this.getVersionDetails(packageName, versionsUrl, release), { concurrency: 4 }, ); // filter failed versions const filteredReleases = enrichedReleases.filter(isTruthy); // extract base information which are only provided on the release from the newest release // Find the source URL of the highest version release const sourceUrlOfHighestRelease = enrichedReleases.find( (release) => baseProject.highest_version.version === release.version, )?.sourceUrl; return { releases: filteredReleases, sourceUrl: sourceUrlOfHighestRelease, }; } getReleases(config: GetReleasesConfig): Promise { return withCache( { namespace: `datasource-${GalaxyCollectionDatasource.id}`, key: `getReleases:${config.packageName}`, fallback: true, }, () => this._getReleases(config), ); } constructBaseUrl(registryUrl: string, packageName: string): string { const [namespace, projectName] = packageName.split('.'); if (ansibleProtocolRegex.test(registryUrl)) { return ensureTrailingSlash( joinUrlParts(registryUrl, 'api/v3/collections', namespace, projectName), ); } const repository = repositoryRegex.exec(registryUrl)?.groups?.repository ?? 'published'; return ensureTrailingSlash( joinUrlParts( registryUrl, 'v3/plugin/ansible/content', repository, 'collections/index', namespace, projectName, ), ); } private async _getVersionDetails( packageName: string, versionsUrl: string, basicRelease: Release, ): Promise { const detailedVersionUrl = ensureTrailingSlash( joinUrlParts(versionsUrl, basicRelease.version), ); const { val: rawDetailedVersion, err: versionsErr } = await this.http .getJsonSafe(detailedVersionUrl, GalaxyV3DetailedVersion) .onError((err) => { logger.warn( { url: versionsUrl, datasource: this.id, packageName, err }, 'Error fetching from url', ); }) .unwrap(); if (versionsErr) { this.handleGenericErrors(versionsErr); } return { ...rawDetailedVersion, isDeprecated: basicRelease.isDeprecated, releaseTimestamp: basicRelease.releaseTimestamp, }; } getVersionDetails( packageName: string, versionsUrl: string, basicRelease: Release, ): Promise { return withCache( { namespace: `datasource-${GalaxyCollectionDatasource.id}`, key: `getVersionDetails:${versionsUrl}:${basicRelease.version}`, ttlMinutes: 10080, // 1 week }, () => this._getVersionDetails(packageName, versionsUrl, basicRelease), ); } }