import { logger } from '../../../logger/index.ts'; import { withCache } from '../../../util/cache/package/with-cache.ts'; import type { GithubRestRelease } from '../../../util/github/types.ts'; import { getApiBaseUrl } from '../../../util/github/url.ts'; import { GithubHttp } from '../../../util/http/github.ts'; import { regEx } from '../../../util/regex.ts'; import { streamToString } from '../../../util/streams.ts'; import { coerceString } from '../../../util/string.ts'; import { parseUrl } from '../../../util/url.ts'; import { id } from '../../versioning/hermit/index.ts'; import { Datasource } from '../datasource.ts'; import type { GetReleasesConfig, ReleaseResult } from '../types.ts'; import type { HermitSearchResult } from './types.ts'; /** * Hermit Datasource searches a given package from the specified `hermit-packages` * repository. It expects the search manifest to come from an asset `index.json` from * a release named index. */ export class HermitDatasource extends Datasource { static readonly id = 'hermit'; override readonly customRegistrySupport = true; override readonly registryStrategy = 'first'; override readonly defaultVersioning = id; override readonly defaultRegistryUrls = [ 'https://github.com/cashapp/hermit-packages', ]; override readonly sourceUrlSupport = 'release'; override readonly sourceUrlNote = 'The source URL is determined from the `Repository` field in the results.'; pathRegex: RegExp; constructor() { super(HermitDatasource.id); this.http = new GithubHttp(id); this.pathRegex = regEx('^/(?[^/]+)/(?[^/]+)$'); } private async _getReleases({ packageName, registryUrl, }: GetReleasesConfig): Promise { logger.trace(`HermitDataSource.getReleases()`); if (!registryUrl) { logger.error('registryUrl must be supplied'); return null; } const parsedUrl = parseUrl(registryUrl); if (parsedUrl === null) { logger.warn({ registryUrl }, 'invalid registryUrl given'); return null; } if (!registryUrl.startsWith('https://github.com/')) { logger.warn({ registryUrl }, 'Only Github registryUrl is supported'); return null; } const items = await this.getHermitSearchManifest(parsedUrl); if (items === null) { return null; } const res = items.find((i) => i.Name === packageName); if (!res) { logger.debug( `Could not find hermit package ${packageName} at URL ${registryUrl}`, ); return null; } const sourceUrl = res.Repository; return { sourceUrl, releases: [ ...res.Versions.map((v) => ({ version: v, sourceUrl, })), ...res.Channels.map((v) => ({ version: v, sourceUrl, })), ], }; } getReleases(config: GetReleasesConfig): Promise { return withCache( { namespace: `datasource-${HermitDatasource.id}`, key: `getReleases:${config.registryUrl ?? ''}-${config.packageName}`, fallback: true, }, () => this._getReleases(config), ); } /** * getHermitSearchManifest fetch the index.json from release * named index, parses it and returned the parsed JSON result */ private async _getHermitSearchManifest( u: URL, ): Promise { const registryUrl = u.toString(); const host = coerceString(u.host); const groups = this.pathRegex.exec(coerceString(u.pathname))?.groups; if (!groups) { logger.warn( { registryUrl }, 'failed to get owner and repo from given url', ); return null; } const { owner, repo } = groups; const apiBaseUrl = getApiBaseUrl(`https://${host}`); const indexRelease = await this.http.getJsonUnchecked( `${apiBaseUrl}repos/${owner}/${repo}/releases/tags/index`, ); // finds asset with name index.json const asset = indexRelease.body.assets.find( (asset) => asset.name === 'index.json', ); if (!asset) { logger.warn( { registryUrl }, `can't find asset index.json in the given registryUrl`, ); return null; } // stream down the content of index.json // Note: need to use stream here with // the accept header as octet-stream to // download asset from private github repository // see GithubDoc: // https://docs.github.com/en/rest/releases/assets#get-a-release-asset const indexContent = await streamToString( this.http.stream(asset.url, { headers: { accept: 'application/octet-stream', }, }), ); try { return JSON.parse(indexContent) as HermitSearchResult[]; } catch { logger.warn('error parsing hermit search manifest from remote respond'); } return null; } getHermitSearchManifest(u: URL): Promise { return withCache( { namespace: `datasource-${HermitDatasource.id}`, key: `getHermitSearchManifest:${u.toString()}`, }, () => this._getHermitSearchManifest(u), ); } }