Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(npm): Use schema for registry response #33715

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions lib/config/presets/npm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
resolvePackageUrl,
resolveRegistryUrl,
} from '../../../modules/datasource/npm/npmrc';
import type {
NpmResponse,
NpmResponseVersion,
} from '../../../modules/datasource/npm/types';
import { NpmResponse } from '../../../modules/datasource/npm/schema';
import { Http } from '../../../util/http';
import type { Preset, PresetConfig } from '../types';
import {
Expand All @@ -23,31 +20,36 @@
repo: pkg,
presetName = 'default',
}: PresetConfig): Promise<Preset | undefined> {
let dep: (NpmResponseVersion & { 'renovate-config'?: any }) | undefined;
try {
const registryUrl = resolveRegistryUrl(pkg);
logger.once.warn(
{ registryUrl, pkg },
'Using npm packages for Renovate presets is now deprecated. Please migrate to repository-based presets instead.',
);
const packageUrl = resolvePackageUrl(registryUrl, pkg);
const body = (await http.getJsonUnchecked<NpmResponse>(packageUrl)).body;
// TODO: check null #22198
dep = body.versions![body['dist-tags']!.latest];
} catch {
const registryUrl = resolveRegistryUrl(pkg);

Check warning on line 23 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L23

Added line #L23 was not covered by tests

logger.once.warn(

Check warning on line 25 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L25

Added line #L25 was not covered by tests
{ registryUrl, pkg },
'Using npm packages for Renovate presets is now deprecated. Please migrate to repository-based presets instead.',
);

const packageUrl = resolvePackageUrl(registryUrl, pkg);
const { val: dep, err } = await http

Check warning on line 31 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L30-L31

Added lines #L30 - L31 were not covered by tests
.getJsonSafe(packageUrl, NpmResponse)
.unwrap();

if (err) {
throw new Error(PRESET_DEP_NOT_FOUND);
}
if (!dep?.['renovate-config']) {

const presets = dep?.latestVersion?.npmHostedPresets;

Check warning on line 39 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L39

Added line #L39 was not covered by tests
if (!presets) {
throw new Error(PRESET_RENOVATE_CONFIG_NOT_FOUND);
}
const presetConfig = dep['renovate-config'][presetName];

const presetConfig = presets[presetName];

Check warning on line 44 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L44

Added line #L44 was not covered by tests
if (!presetConfig) {
const presetNames = Object.keys(dep['renovate-config']);
const presetNames = Object.keys(presets);

Check warning on line 46 in lib/config/presets/npm/index.ts

View check run for this annotation

Codecov / codecov/patch

lib/config/presets/npm/index.ts#L46

Added line #L46 was not covered by tests
logger.debug(
{ presetNames, presetName },
'Preset not found within renovate-config',
);
throw new Error(PRESET_NOT_FOUND);
}

return presetConfig;
}
98 changes: 23 additions & 75 deletions lib/modules/datasource/npm/get.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import url from 'node:url';
import is from '@sindresorhus/is';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { GlobalConfig } from '../../../config/global';
import { HOST_DISABLED } from '../../../constants/error-messages';
import { logger } from '../../../logger';
Expand All @@ -14,63 +13,11 @@ import { regEx } from '../../../util/regex';
import { HttpCacheStats } from '../../../util/stats';
import { joinUrlParts } from '../../../util/url';
import type { Release, ReleaseResult } from '../types';
import type { CachedReleaseResult, NpmResponse } from './types';
import { NpmResponse } from './schema';
import type { CachedReleaseResult } from './types';

export const CACHE_REVISION = 1;

const SHORT_REPO_REGEX = regEx(
/^((?<platform>bitbucket|github|gitlab):)?(?<shortRepo>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
);

const platformMapping: Record<string, string> = {
bitbucket: 'https://bitbucket.org/',
github: 'https://github.com/',
gitlab: 'https://gitlab.com/',
};

interface PackageSource {
sourceUrl: string | null;
sourceDirectory: string | null;
}

const PackageSource = z
.union([
z
.string()
.nonempty()
.transform((repository): PackageSource => {
let sourceUrl: string | null = null;
const sourceDirectory = null;
const shortMatch = repository.match(SHORT_REPO_REGEX);
if (shortMatch?.groups) {
const { platform = 'github', shortRepo } = shortMatch.groups;
sourceUrl = platformMapping[platform] + shortRepo;
} else {
sourceUrl = repository;
}
return { sourceUrl, sourceDirectory };
}),
z
.object({
url: z.string().nonempty().nullish(),
directory: z.string().nonempty().nullish(),
})
.transform(({ url, directory }) => {
const res: PackageSource = { sourceUrl: null, sourceDirectory: null };

if (url) {
res.sourceUrl = url;
}

if (directory) {
res.sourceDirectory = directory;
}

return res;
}),
])
.catch({ sourceUrl: null, sourceDirectory: null });

export async function getDependency(
http: Http,
registryUrl: string,
Expand Down Expand Up @@ -144,7 +91,7 @@ export async function getDependency(
});
}

const raw = await http.getJsonUnchecked<NpmResponse>(packageUrl, options);
const raw = await http.getJson(packageUrl, options, NpmResponse);
if (cachedResult?.cacheData && raw.statusCode === 304) {
logger.trace(`Cached npm result for ${packageName} is revalidated`);
HttpCacheStats.incRemoteHits(packageUrl);
Expand All @@ -167,26 +114,28 @@ export async function getDependency(
return null;
}

const latestVersion = res.versions[res['dist-tags']?.latest ?? ''];
res.repository ??= latestVersion?.repository;
res.homepage ??= latestVersion?.homepage;

const { sourceUrl, sourceDirectory } = PackageSource.parse(res.repository);
const { latestVersion, tags } = res;

// Simplify response before caching and returning
const dep: ReleaseResult = {
homepage: res.homepage,
releases: [],
tags: res['dist-tags'],
tags,
registryUrl,
};

if (sourceUrl) {
dep.sourceUrl = sourceUrl;
const homepage = res.homepage ?? latestVersion?.homepage;
if (homepage) {
res.homepage = homepage;
}

const repo = res.repository ?? latestVersion?.repository;

if (repo?.url) {
dep.sourceUrl = repo.url;
}

if (sourceDirectory) {
dep.sourceDirectory = sourceDirectory;
if (repo?.directory) {
dep.sourceDirectory = repo.directory;
}

if (latestVersion?.deprecated) {
Expand All @@ -209,15 +158,14 @@ export async function getDependency(
if (is.nonEmptyString(nodeConstraint)) {
release.constraints = { node: [nodeConstraint] };
}
const source = PackageSource.parse(res.versions?.[version].repository);
if (source.sourceUrl && source.sourceUrl !== dep.sourceUrl) {
release.sourceUrl = source.sourceUrl;
const repo = res.versions?.[version].repository;
const sourceUrl = repo?.url;
if (sourceUrl && sourceUrl !== dep.sourceUrl) {
release.sourceUrl = sourceUrl;
}
if (
source.sourceDirectory &&
source.sourceDirectory !== dep.sourceDirectory
) {
release.sourceDirectory = source.sourceDirectory;
const sourceDirectory = repo?.directory;
if (sourceDirectory && sourceDirectory !== dep.sourceDirectory) {
release.sourceDirectory = sourceDirectory;
}
if (dep.deprecationMessage) {
release.isDeprecated = true;
Expand Down
76 changes: 76 additions & 0 deletions lib/modules/datasource/npm/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { logger } from '../../../logger';
import { regEx } from '../../../util/regex';
import { LooseRecord } from '../../../util/schema-utils';

const SHORT_REPO_REGEX = regEx(
/^((?<platform>bitbucket|github|gitlab):)?(?<shortRepo>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
);

const platformMapping: Record<string, string> = {
bitbucket: 'https://bitbucket.org/',
github: 'https://github.com/',
gitlab: 'https://gitlab.com/',
};

const Repository = z.union([
z
.string()
.nonempty()
.transform((repository) => {
let url: string | undefined;
const shortMatch = repository.match(SHORT_REPO_REGEX);
if (shortMatch?.groups) {
const { platform = 'github', shortRepo } = shortMatch.groups;
url = platformMapping[platform] + shortRepo;
} else {
url = repository;
}
return { url, directory: undefined };
}),
z.object({
url: z.string().nonempty().optional().catch(undefined),
directory: z.string().nonempty().optional().catch(undefined),
}),
]);

const NpmResponseVersion = z
.object({
repository: Repository.optional().catch(undefined),
homepage: z.string().optional().catch(undefined),
deprecated: z.union([z.boolean(), z.string()]).catch(false),
gitHead: z.string().optional().catch(undefined),
dependencies: z.record(z.string()).optional().catch(undefined),
devDependencies: z.record(z.string()).optional().catch(undefined),
engines: z.record(z.string()).optional().catch(undefined),
'renovate-config': z
.record(z.any())
.optional()
.catch(
/* istanbul ignore next */
() => {
logger.debug(`Skipping 'renovate-config': object was expected`);
return undefined;
},
),
})
.transform(({ 'renovate-config': npmHostedPresets, ...rest }) => ({
npmHostedPresets,
...rest,
}));
type NpmResponseVersion = z.infer<typeof NpmResponseVersion>;

export const NpmResponse = z
.object({
name: z.string(),
versions: LooseRecord(NpmResponseVersion).catch({}),
repository: Repository.optional().catch(undefined),
homepage: z.string().optional().catch(undefined),
time: z.record(z.string()).optional().catch(undefined),
'dist-tags': LooseRecord(z.string()).catch({}),
})
.transform(({ 'dist-tags': tags, versions, ...rest }) => {
const latest: string | undefined = tags['latest'];
const latestVersion = versions[latest] as NpmResponseVersion | undefined;
return { tags, versions, latestVersion, ...rest };
});
26 changes: 0 additions & 26 deletions lib/modules/datasource/npm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,6 @@ export interface NpmrcRules {
packageRules: PackageRule[];
}

export interface NpmResponseVersion {
repository?: {
url: string;
directory: string;
};
homepage?: string;
deprecated?: boolean;
gitHead?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
engines?: Record<string, string>;
}

export interface NpmResponse {
_id: string;
name: string;
versions?: Record<string, NpmResponseVersion>;
repository?: {
url?: string;
directory?: string;
};
homepage?: string;
time?: Record<string, string>;
'dist-tags'?: Record<string, string>;
}

export interface CachedReleaseResult extends ReleaseResult {
cacheData?: {
revision?: number;
Expand Down
Loading