Files
Sebastian Poxhofer 3ed45d1efd refactor(misc): remove getJsonUnchecked usages (#43696)
* fix(datasource): replace getJsonUnchecked with validated getJson in misc+merge-confidence group

Replaces all getJsonUnchecked calls with Zod-validated getJson calls in:
- crate: adds CrateMetadataSchema to existing schema.ts
- conan: uses existing ConanJSON schema directly
- puppet-forge: new schema.ts with PuppetModuleSchema
- conda: new schema.ts with CondaPackageSchema
- repology: new schema.ts with RepologyPackagesSchema
- jenkins-plugins: new schema.ts, parameterizes helper with ZodType<T>
- custom/formats/json: uses z.unknown() for user-defined responses
- merge-confidence: adds inline MergeConfidenceResponseSchema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: correct nullable/optional schema fields for crate, jenkins-plugins, puppet-forge

- crate: remove .optional() from all CrateMetadata fields to match string|null type
- jenkins-plugins: use .default({}) instead of .optional() for plugins fields so schemas match required Record types; widen ZodType input to unknown; update JenkinsPluginVersion.releaseTimestamp to Timestamp|null to match MaybeTimestamp output
- puppet-forge: change releases from .optional() to .default([]) to match Release[] type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: migrate zod/v3 imports to zod/v4 in misc datasources and merge-confidence

Update remaining zod/v3 imports in files added/modified by the getJsonUnchecked
migration to use zod/v4 as required after the upstream migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore minor improvements

* remove unnecessary types and reintroduce nullable where necessary

* refactor: move logic into transform

* test: add tests to improve coverage

* chore removed some unused types

* remove type declaration

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:58:20 +00:00

298 lines
8.6 KiB
TypeScript

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<MergeConfidence, number> = {
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<UpdateType, MergeConfidence | null> =
{
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<MergeConfidence | undefined> {
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<MergeConfidence> {
// 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<void> {
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');
}