From 98e702909011d2079d6d50eab372eb6d5b3e38f2 Mon Sep 17 00:00:00 2001 From: Jamie Magee <jamie.magee@gmail.com> Date: Fri, 12 Nov 2021 00:10:52 -0800 Subject: [PATCH] feat: replace deprecated dependencies with their replacements (#5558) Co-authored-by: Michael Kriese <michael.kriese@visualon.de> --- docs/usage/configuration-options.md | 29 +++++++++++++ lib/config/options/index.ts | 39 +++++++++++++++++ lib/config/presets/index.ts | 1 + lib/config/presets/internal/index.ts | 2 + lib/config/presets/internal/replacements.ts | 19 ++++++++ lib/config/types.ts | 3 +- lib/datasource/index.spec.ts | 14 ++++++ lib/datasource/index.ts | 13 ++++++ lib/datasource/types.ts | 4 ++ .../npm/update/dependency/index.spec.ts | 43 +++++++++++++++++++ lib/manager/npm/update/dependency/index.ts | 35 +++++++++++++-- lib/manager/types.ts | 3 ++ lib/util/merge-confidence/index.ts | 1 + lib/util/template/index.ts | 5 ++- .../lookup/__snapshots__/index.spec.ts.snap | 20 +++++++++ .../repository/process/lookup/index.spec.ts | 12 ++++++ .../repository/process/lookup/index.ts | 12 ++++++ .../repository/updates/flatten.spec.ts | 12 +++++- lib/workers/repository/updates/flatten.ts | 22 +++++++--- 19 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 lib/config/presets/internal/replacements.ts diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index b53372ee86..d94e2baa32 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1652,6 +1652,31 @@ For example to apply a special label for Major updates: } ``` +### replacementName + +Use this field to define the name of a replacement package. +Must be used with `replacementVersion` (see example below). +You can suggest a new community package rule by editing [the `replacements.ts` file on the Renovate repository](https://github.com/renovatebot/renovate/blob/main/lib/config/presets/internal/replacements.ts) and opening a pull request. + +### replacementVersion + +Use this field to define the name of a replacement package. +Must be used with `replacementVersion`. +For example to replace the npm package `jade` with version `2.0.0` of the package `pug`: + +```json +{ + "packageRules": [ + { + "matchDatasources": ["npm"], + "matchPackageNames": ["jade"], + "replacementName": "pug", + "replacementVersion": "2.0.0" + } + ] +} +``` + ## patch Add to this object if you wish to define rules that apply only to patch updates. @@ -2233,6 +2258,10 @@ In case there is a need to configure them manually, it can be done using this `r The field supports multiple URLs however it is datasource-dependent on whether only the first is used or multiple. +## replacement + +Add to this object if you wish to define rules that apply only to PRs that replace dependencies. + ## respectLatest Similar to `ignoreUnstable`, this option controls whether to update to versions that are greater than the version tagged as `latest` in the repository. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 786976d37d..7185155a79 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -982,6 +982,28 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'replacementName', + description: + 'The name of the new dependency that replaces the old deprecated dependency.', + type: 'string', + stage: 'package', + parent: 'packageRules', + mergeable: true, + cli: false, + env: false, + }, + { + name: 'replacementVersion', + description: + 'The version of the new dependency that replaces the old deprecated dependency.', + type: 'string', + stage: 'package', + parent: 'packageRules', + mergeable: true, + cli: false, + env: false, + }, { name: 'matchUpdateTypes', description: @@ -1196,6 +1218,23 @@ const options: RenovateOptions[] = [ cli: false, mergeable: true, }, + { + name: 'replacement', + description: 'Configuration to apply when replacing a dependency.', + stage: 'package', + type: 'object', + default: { + branchTopic: '{{{depNameSanitized}}}-replacement', + commitMessageAction: 'Replace', + commitMessageExtra: + 'with {{newName}} {{#if isMajor}}v{{{newMajor}}}{{else}}{{#if isSingleVersion}}v{{{newVersion}}}{{else}}{{{newValue}}}{{/if}}{{/if}}', + prBodyNotes: [ + 'This is a special PR that replaces `{{{depNameSanitized}}}` with the community suggested minimal stable replacement version.', + ], + }, + cli: false, + mergeable: true, + }, // Semantic commit / Semantic release { name: 'semanticCommits', diff --git a/lib/config/presets/index.ts b/lib/config/presets/index.ts index 29e5786815..7ede0fc37a 100644 --- a/lib/config/presets/index.ts +++ b/lib/config/presets/index.ts @@ -120,6 +120,7 @@ export function parsePreset(input: string): ParsedPreset { 'packages', 'preview', 'regexManagers', + 'replacements', 'schedule', 'workarounds', ]; diff --git a/lib/config/presets/internal/index.ts b/lib/config/presets/internal/index.ts index e152b9747e..b0dd3dcbe4 100644 --- a/lib/config/presets/internal/index.ts +++ b/lib/config/presets/internal/index.ts @@ -10,6 +10,7 @@ import * as npm from './npm'; import * as packagesPreset from './packages'; import * as previewPreset from './preview'; import * as regexManagersPreset from './regex-managers'; +import * as replacements from './replacements'; import * as schedulePreset from './schedule'; import * as workaroundsPreset from './workarounds'; @@ -25,6 +26,7 @@ export const groups: Record<string, Record<string, Preset>> = { packages: packagesPreset.presets, preview: previewPreset.presets, regexManagers: regexManagersPreset.presets, + replacements: replacements.presets, schedule: schedulePreset.presets, workarounds: workaroundsPreset.presets, }; diff --git a/lib/config/presets/internal/replacements.ts b/lib/config/presets/internal/replacements.ts new file mode 100644 index 0000000000..8d7bbc5ce9 --- /dev/null +++ b/lib/config/presets/internal/replacements.ts @@ -0,0 +1,19 @@ +import type { Preset } from '../types'; + +export const presets: Record<string, Preset> = { + all: { + description: 'All replacements', + extends: ['replacements:jade-to-pug'], + }, + 'jade-to-pug': { + description: 'Jade was renamed to Pug', + packageRules: [ + { + matchDatasources: ['npm'], + matchPackageNames: ['jade'], + replacementName: 'pug', + replacementVersion: '2.0.0', + }, + ], + }, +}; diff --git a/lib/config/types.ts b/lib/config/types.ts index 07cbe71eb1..f01119b022 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -232,7 +232,8 @@ export type UpdateType = | 'lockFileMaintenance' | 'lockfileUpdate' | 'rollback' - | 'bump'; + | 'bump' + | 'replacement'; export type MatchStringsStrategy = 'any' | 'recursive' | 'combination'; diff --git a/lib/datasource/index.spec.ts b/lib/datasource/index.spec.ts index 8042ddd597..b26cf8d7e1 100644 --- a/lib/datasource/index.spec.ts +++ b/lib/datasource/index.spec.ts @@ -298,4 +298,18 @@ describe('datasource/index', () => { }); expect(res.sourceUrl).toBe('https://github.com/Jasig/cas'); }); + + it('applies replacements', async () => { + npmDatasource.getReleases.mockResolvedValue({ + releases: [{ version: '1.0.0' }], + }); + const res = await datasource.getPkgReleases({ + datasource: datasourceNpm.id, + depName: 'abc', + replacementName: 'def', + replacementVersion: '2.0.0', + }); + expect(res.replacementName).toBe('def'); + expect(res.replacementVersion).toBe('2.0.0'); + }); }); diff --git a/lib/datasource/index.ts b/lib/datasource/index.ts index e77b06d453..978599ee5b 100644 --- a/lib/datasource/index.ts +++ b/lib/datasource/index.ts @@ -213,6 +213,18 @@ export function getDefaultVersioning(datasourceName: string): string { return datasource?.defaultVersioning || 'semver'; } +function applyReplacements( + config: GetReleasesInternalConfig +): Pick<ReleaseResult, 'replacementName' | 'replacementVersion'> | undefined { + if (config.replacementName && config.replacementVersion) { + return { + replacementName: config.replacementName, + replacementVersion: config.replacementVersion, + }; + } + return undefined; +} + async function fetchReleases( config: GetReleasesInternalConfig ): Promise<ReleaseResult | null> { @@ -250,6 +262,7 @@ async function fetchReleases( return null; } addMetaData(dep, datasourceName, config.lookupName); + dep = { ...dep, ...applyReplacements(config) }; return dep; } diff --git a/lib/datasource/types.ts b/lib/datasource/types.ts index 1d271a8a9a..c03502dde6 100644 --- a/lib/datasource/types.ts +++ b/lib/datasource/types.ts @@ -28,6 +28,8 @@ export interface GetPkgReleasesConfig extends ReleasesConfigBase { versioning?: string; extractVersion?: string; constraints?: Record<string, string>; + replacementName?: string; + replacementVersion?: string; } export interface Release { @@ -60,6 +62,8 @@ export interface ReleaseResult { sourceUrl?: string; sourceDirectory?: string; registryUrl?: string; + replacementName?: string; + replacementVersion?: string; } export interface DatasourceApi { diff --git a/lib/manager/npm/update/dependency/index.spec.ts b/lib/manager/npm/update/dependency/index.spec.ts index fda93a11ff..29eb31e9f7 100644 --- a/lib/manager/npm/update/dependency/index.spec.ts +++ b/lib/manager/npm/update/dependency/index.spec.ts @@ -208,5 +208,48 @@ describe('manager/npm/update/dependency/index', () => { }); expect(testContent).toEqual(outputContent); }); + + it('returns null if empty file', () => { + const upgrade = { + depType: 'dependencies', + depName: 'angular-touch-not', + newValue: '1.5.8', + }; + const testContent = npmUpdater.updateDependency({ + fileContent: null, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('replaces package', () => { + const upgrade = { + depType: 'dependencies', + depName: 'config', + newName: 'abc', + newValue: '2.0.0', + }; + const testContent = npmUpdater.updateDependency({ + fileContent: input01Content, + upgrade, + }); + expect(JSON.parse(testContent).dependencies.config).toBeUndefined(); + expect(JSON.parse(testContent).dependencies.abc).toBe('2.0.0'); + }); + + it('replaces glob package resolutions', () => { + const upgrade = { + depType: 'dependencies', + depName: 'config', + newName: 'abc', + newValue: '2.0.0', + }; + const testContent = npmUpdater.updateDependency({ + fileContent: input01GlobContent, + upgrade, + }); + expect(JSON.parse(testContent).resolutions.config).toBeUndefined(); + expect(JSON.parse(testContent).resolutions['**/abc']).toBe('2.0.0'); + }); }); }); diff --git a/lib/manager/npm/update/dependency/index.ts b/lib/manager/npm/update/dependency/index.ts index 838e461105..f39062de36 100644 --- a/lib/manager/npm/update/dependency/index.ts +++ b/lib/manager/npm/update/dependency/index.ts @@ -9,17 +9,22 @@ function replaceAsString( fileContent: string, depType: string, depName: string, - oldVersion: string, + oldValue: string, newValue: string ): string | null { - // Update the file = this is what we want if (depType === 'packageManager') { parsedContents[depType] = newValue; + } else if (depName === oldValue) { + // The old value is the name of the dependency itself + delete Object.assign(parsedContents[depType], { + [newValue]: parsedContents[depType][oldValue], + })[oldValue]; } else { + // The old value is the version of the dependency parsedContents[depType][depName] = newValue; } // Look for the old version number - const searchString = `"${oldVersion}"`; + const searchString = `"${oldValue}"`; const newString = `"${newValue}"`; // Skip ahead to depType section let searchIndex = fileContent.indexOf(`"${depType}"`) + depType.length; @@ -94,6 +99,16 @@ export function updateDependency({ oldVersion, newValue ); + if (upgrade.newName) { + newFileContent = replaceAsString( + parsedContents, + newFileContent, + depType, + depName, + depName, + upgrade.newName + ); + } // istanbul ignore if if (!newFileContent) { logger.debug( @@ -130,6 +145,20 @@ export function updateDependency({ parsedContents.resolutions[depKey], newValue ); + if (upgrade.newName) { + if (depKey === `**/${depName}`) { + // handles the case where a replacement is in a resolution + upgrade.newName = `**/${upgrade.newName}`; + } + newFileContent = replaceAsString( + parsedContents, + newFileContent, + 'resolutions', + depKey, + depKey, + upgrade.newName + ); + } } } return newFileContent; diff --git a/lib/manager/types.ts b/lib/manager/types.ts index baddd9bc1f..90706462d0 100644 --- a/lib/manager/types.ts +++ b/lib/manager/types.ts @@ -130,9 +130,11 @@ export interface LookupUpdate { isPin?: boolean; isRange?: boolean; isRollback?: boolean; + isReplacement?: boolean; newDigest?: string; newMajor?: number; newMinor?: number; + newName?: string; newValue: string; semanticCommitType?: string; pendingChecks?: boolean; @@ -177,6 +179,7 @@ export interface Upgrade<T = Record<string, any>> newDigest?: string; newFrom?: string; newMajor?: number; + newName?: string; newValue?: string; packageFile?: string; rangeStrategy?: RangeStrategy; diff --git a/lib/util/merge-confidence/index.ts b/lib/util/merge-confidence/index.ts index f259bb13ea..ec29993be7 100644 --- a/lib/util/merge-confidence/index.ts +++ b/lib/util/merge-confidence/index.ts @@ -36,6 +36,7 @@ const updateTypeConfidenceMapping: Record<UpdateType, MergeConfidence> = { lockFileMaintenance: 'neutral', lockfileUpdate: 'neutral', rollback: 'neutral', + replacement: 'neutral', major: null, minor: null, patch: null, diff --git a/lib/util/template/index.ts b/lib/util/template/index.ts index cb61a66b10..d5415734a1 100644 --- a/lib/util/template/index.ts +++ b/lib/util/template/index.ts @@ -60,6 +60,7 @@ export const allowedFields = { isPatch: 'true if the upgrade is a patch upgrade', isPin: 'true if the upgrade is pinning dependencies', isRollback: 'true if the upgrade is a rollback PR', + isReplacement: 'true if the upgrade is a replacement', isRange: 'true if the new value is a range', isSingleVersion: 'true if the upgrade is to a single version rather than a range', @@ -72,6 +73,8 @@ export const allowedFields = { 'The major version of the new version. e.g. "3" if the new version if "3.1.0"', newMinor: 'The minor version of the new version. e.g. "1" if the new version if "3.1.0"', + newName: + 'The name of the new dependency that replaces the current deprecated dependency', newValue: 'The new value in the upgrade. Can be a range or version e.g. "^3.0.0" or "3.1.0"', newVersion: 'The new version in the upgrade, e.g. "3.1.0"', @@ -91,7 +94,7 @@ export const allowedFields = { semanticPrefix: 'The fully generated semantic prefix for commit messages', sourceRepoSlug: 'The slugified pathname of the sourceUrl, if present', sourceUrl: 'The source URL for the package', - updateType: 'One of digest, pin, rollback, patch, minor, major', + updateType: 'One of digest, pin, rollback, patch, minor, major, replacement', upgrades: 'An array of upgrade objects in the branch', url: 'The url of the release notes', version: 'The version number of the changelog', diff --git a/lib/workers/repository/process/lookup/__snapshots__/index.spec.ts.snap b/lib/workers/repository/process/lookup/__snapshots__/index.spec.ts.snap index 85185e8157..dd710b7fc1 100644 --- a/lib/workers/repository/process/lookup/__snapshots__/index.spec.ts.snap +++ b/lib/workers/repository/process/lookup/__snapshots__/index.spec.ts.snap @@ -195,6 +195,26 @@ exports[`workers/repository/process/lookup/index .lookupUpdates() handles packag exports[`workers/repository/process/lookup/index .lookupUpdates() handles pypi 404 1`] = `Array []`; +exports[`workers/repository/process/lookup/index .lookupUpdates() handles replacements 1`] = ` +Object { + "changelogUrl": undefined, + "currentVersion": "1.4.1", + "dependencyUrl": undefined, + "fixedVersion": "1.4.1", + "homepage": undefined, + "sourceUrl": "https://github.com/kriskowal/q", + "updates": Array [ + Object { + "newName": "r", + "newValue": "2.0.0", + "updateType": "replacement", + }, + ], + "versioning": "npm", + "warnings": Array [], +} +`; + exports[`workers/repository/process/lookup/index .lookupUpdates() handles sourceUrl packageRules with version restrictions 1`] = ` Object { "changelogUrl": undefined, diff --git a/lib/workers/repository/process/lookup/index.spec.ts b/lib/workers/repository/process/lookup/index.spec.ts index ff172c99ff..81c4acbfae 100644 --- a/lib/workers/repository/process/lookup/index.spec.ts +++ b/lib/workers/repository/process/lookup/index.spec.ts @@ -1399,5 +1399,17 @@ describe('workers/repository/process/lookup/index', () => { // FIXME: explicit assert condition expect(res).toMatchSnapshot(); }); + + it('handles replacements', async () => { + config.currentValue = '1.4.1'; + config.depName = 'q'; + // This config is normally set when packageRules are applied + config.replacementName = 'r'; + config.replacementVersion = '2.0.0'; + config.datasource = datasourceNpmId; + httpMock.scope('https://registry.npmjs.org').get('/q').reply(200, qJson); + const res = await lookup.lookupUpdates(config); + expect(res).toMatchSnapshot(); + }); }); }); diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts index 4c770309ab..a946363ce6 100644 --- a/lib/workers/repository/process/lookup/index.ts +++ b/lib/workers/repository/process/lookup/index.ts @@ -88,6 +88,7 @@ export async function lookupUpdates( logger.debug({ dependency: depName }, 'Found deprecationMessage'); res.deprecationMessage = dependency.deprecationMessage; } + res.sourceUrl = dependency?.sourceUrl; if (dependency.sourceDirectory) { res.sourceDirectory = dependency.sourceDirectory; @@ -144,6 +145,17 @@ export async function lookupUpdates( res.updates.push(rollback); } let rangeStrategy = getRangeStrategy(config); + if (dependency.replacementName && dependency.replacementVersion) { + res.updates.push({ + updateType: 'replacement', + newName: dependency.replacementName, + newValue: versioning.getNewValue({ + currentValue, + newVersion: dependency.replacementVersion, + rangeStrategy, + }), + }); + } // istanbul ignore next if ( isVulnerabilityAlert && diff --git a/lib/workers/repository/updates/flatten.spec.ts b/lib/workers/repository/updates/flatten.spec.ts index 9d56625643..84c3a997eb 100644 --- a/lib/workers/repository/updates/flatten.spec.ts +++ b/lib/workers/repository/updates/flatten.spec.ts @@ -66,6 +66,16 @@ describe('workers/repository/updates/flatten', () => { updateTypes: ['pin'], updates: [{ newValue: '2.0.0' }], }, + { + depName: 'abc', + updates: [ + { + newName: 'def', + newValue: '2.0.0', + updateType: 'replacement', + }, + ], + }, ], }, { @@ -131,7 +141,7 @@ describe('workers/repository/updates/flatten', () => { ], }; const res = await flattenUpdates(config, packageFiles); - expect(res).toHaveLength(13); + expect(res).toHaveLength(14); expect(res.filter((update) => update.sourceRepoSlug)).toHaveLength(3); expect( res.filter((r) => r.updateType === 'lockFileMaintenance') diff --git a/lib/workers/repository/updates/flatten.ts b/lib/workers/repository/updates/flatten.ts index 22f3070e48..5f3bf1e64d 100644 --- a/lib/workers/repository/updates/flatten.ts +++ b/lib/workers/repository/updates/flatten.ts @@ -15,18 +15,25 @@ import { generateBranchName } from './branch-name'; const upper = (str: string): string => str.charAt(0).toUpperCase() + str.substr(1); +function sanitizeDepName(depName: string): string { + return depName + .replace('@types/', '') + .replace('@', '') + .replace(regEx(/\//g), '-') // TODO #12071 + .replace(regEx(/\s+/g), '-') // TODO #12071 + .replace(regEx(/-+/), '-') + .toLowerCase(); +} + export function applyUpdateConfig(input: BranchUpgradeConfig): any { const updateConfig = { ...input }; delete updateConfig.packageRules; // TODO: Remove next line once #8075 is complete updateConfig.depNameSanitized = updateConfig.depName - ? updateConfig.depName - .replace('@types/', '') - .replace('@', '') - .replace(regEx(/\//g), '-') // TODO #12071 - .replace(regEx(/\s+/g), '-') // TODO #12071 - .replace(regEx(/-+/), '-') - .toLowerCase() + ? sanitizeDepName(updateConfig.depName) + : undefined; + updateConfig.newNameSanitized = updateConfig.newName + ? sanitizeDepName(updateConfig.newName) : undefined; if (updateConfig.sourceUrl) { const parsedSourceUrl = parseUrl(updateConfig.sourceUrl); @@ -53,6 +60,7 @@ export async function flattenUpdates( 'pin', 'digest', 'lockFileMaintenance', + 'replacement', ]; for (const [manager, files] of Object.entries(packageFiles)) { const managerConfig = getManagerConfig(config, manager); -- GitLab