import { isNullOrUndefined } from '@sindresorhus/is'; import { supportedDatasources as presetSupportedDatasources } from '../../config/presets/internal/merge-confidence.preset.ts'; import type { AllConfig, UpdateType } from '../../config/types.ts'; import { instrument } from '../../instrumentation/index.ts'; import { ATTR_RENOVATE_DATASOURCE, ATTR_RENOVATE_PACKAGE_NAME, } from '../../instrumentation/types.ts'; import { logger } from '../../logger/index.ts'; import { ExternalHostError } from '../../types/errors/external-host-error.ts'; import * as packageCache from '../cache/package/index.ts'; import * as hostRules from '../host-rules.ts'; import { memCacheProvider } from '../http/cache/memory-http-cache-provider.ts'; import { Http } from '../http/index.ts'; import { regEx } from '../regex.ts'; import { ensureTrailingSlash, joinUrlParts, parseUrl } from '../url.ts'; import { MERGE_CONFIDENCE } from './common.ts'; import { MergeConfidenceResponse } from './schema.ts'; import type { MergeConfidence } from './types.ts'; const hostType = 'merge-confidence'; const http = new Http(hostType); let token: string | undefined; let apiBaseUrl: string | undefined; let supportedDatasources: string[] = []; export const confidenceLevels: Record = { low: -1, neutral: 0, high: 1, 'very high': 2, }; export function initConfig({ mergeConfidenceEndpoint, mergeConfidenceDatasources, }: AllConfig): void { apiBaseUrl = getApiBaseUrl(mergeConfidenceEndpoint); token = getApiToken(); supportedDatasources = mergeConfidenceDatasources ?? presetSupportedDatasources; if (!isNullOrUndefined(token)) { logger.debug(`Merge confidence token found for ${apiBaseUrl}`); } } export function resetConfig(): void { token = undefined; apiBaseUrl = undefined; supportedDatasources = []; } export function isMergeConfidence(value: string): value is MergeConfidence { return MERGE_CONFIDENCE.includes(value as MergeConfidence); } export function isActiveConfidenceLevel(confidence: string): boolean { return isMergeConfidence(confidence) && confidence !== 'low'; } export function satisfiesConfidenceLevel( confidence: MergeConfidence, minimumConfidence: MergeConfidence, ): boolean { return confidenceLevels[confidence] >= confidenceLevels[minimumConfidence]; } const updateTypeConfidenceMapping: Record = { pin: 'high', digest: 'neutral', pinDigest: 'high', bump: 'neutral', lockFileMaintenance: 'neutral', lockfileUpdate: 'neutral', rollback: 'neutral', replacement: 'neutral', major: null, minor: null, patch: null, }; /** * Retrieves the merge confidence of a package update if the merge confidence API is enabled. Otherwise, undefined is returned. * * @param datasource * @param packageName * @param currentVersion * @param newVersion * @param updateType * * @returns The merge confidence level for the given package release. * @throws {ExternalHostError} If a request has been made and an error occurs during the request, such as a timeout, connection reset, authentication failure, or internal server error. */ export async function getMergeConfidenceLevel( datasource: string, packageName: string, currentVersion: string, newVersion: string, updateType: UpdateType, ): Promise { return await instrument( 'getMergeConfidenceLevel', async () => { if (isNullOrUndefined(apiBaseUrl) || isNullOrUndefined(token)) { return undefined; } if (!supportedDatasources.includes(datasource)) { return undefined; } if (!(currentVersion && newVersion && updateType)) { return 'neutral'; } const mappedConfidence = updateTypeConfidenceMapping[updateType]; if (mappedConfidence) { return mappedConfidence; } return await queryApi( datasource, packageName, currentVersion, newVersion, ); }, { attributes: { [ATTR_RENOVATE_DATASOURCE]: datasource, [ATTR_RENOVATE_PACKAGE_NAME]: packageName, }, }, ); } /** * Queries the Merge Confidence API with the given package release information. * * @param datasource * @param packageName * @param currentVersion * @param newVersion * * @returns The merge confidence level for the given package release. * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request. * * @remarks * Results are cached for 60 minutes to reduce the number of API calls. */ async function queryApi( datasource: string, packageName: string, currentVersion: string, newVersion: string, ): Promise { // istanbul ignore if: defensive, already been validated before calling this function if (isNullOrUndefined(apiBaseUrl) || isNullOrUndefined(token)) { return 'neutral'; } const escapedPackageName = packageName .replace(regEx(/\//g), '%2f') .replace(regEx(/:/g), '%3A'); const url = joinUrlParts( apiBaseUrl, 'api/mc/json', datasource, escapedPackageName, currentVersion, newVersion, ); const cacheKey = `${token}:${url}`; const cachedResult = await packageCache.get(hostType, cacheKey); // istanbul ignore if if (cachedResult) { logger.debug( { datasource, packageName, currentVersion, newVersion, cachedResult, }, 'using merge confidence cached result', ); return cachedResult; } let confidence: MergeConfidence = 'neutral'; try { const res = ( await http.getJson( url, { cacheProvider: memCacheProvider }, MergeConfidenceResponse, ) ).body; if (isMergeConfidence(res.confidence)) { confidence = res.confidence; } } catch (err) { apiErrorHandler(err); } await packageCache.set(hostType, cacheKey, confidence, 60); return confidence; } /** * Checks the health of the Merge Confidence API by attempting to authenticate with it. * * @returns Resolves when the API health check is completed successfully. * * @throws {ExternalHostError} if a timeout, connection reset error, authentication failure, or internal server error occurs during the request. * * @remarks * This function first checks that the API base URL and an authentication bearer token are defined before attempting to * authenticate with the API. If either the base URL or token is not defined, it will immediately return * without making a request. */ export async function initMergeConfidence(config: AllConfig): Promise { initConfig(config); if (isNullOrUndefined(apiBaseUrl) || isNullOrUndefined(token)) { logger.trace('merge confidence API usage is disabled'); return; } const url = joinUrlParts(apiBaseUrl, 'api/mc/availability'); try { await http.get(url); } catch (err) { apiErrorHandler(err); } logger.debug( { supportedDatasources }, 'merge confidence API - successfully authenticated', ); return; } function getApiBaseUrl(mergeConfidenceEndpoint: string | undefined): string { const defaultBaseUrl = 'https://developer.mend.io/'; const baseFromEnv = mergeConfidenceEndpoint ?? defaultBaseUrl; const parsedBaseUrl = parseUrl(baseFromEnv)?.toString(); if (!parsedBaseUrl) { logger.warn( { baseFromEnv }, 'invalid merge confidence API base URL found in environment variables - using default value instead', ); return defaultBaseUrl; } logger.trace( { baseUrl: parsedBaseUrl }, 'using merge confidence API base found in environment variables', ); return ensureTrailingSlash(parsedBaseUrl); } export function getApiToken(): string | undefined { return hostRules.find({ url: apiBaseUrl, hostType, })?.token; } /** * Handles errors returned by the Merge Confidence API. * * @param err - The error object returned by the API. * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request. */ function apiErrorHandler(err: any): void { if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET') { logger.error({ err }, 'merge confidence API request failed - aborting run'); throw new ExternalHostError(err, hostType); } if (err.statusCode === 403) { logger.error({ err }, 'merge confidence API token rejected - aborting run'); throw new ExternalHostError(err, hostType); } if (err.statusCode >= 500 && err.statusCode < 600) { logger.error({ err }, 'merge confidence API failure: 5xx - aborting run'); throw new ExternalHostError(err, hostType); } logger.warn({ err }, 'error fetching merge confidence data'); }