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