From b25022066e68dec810cc17d8368df8188b397c72 Mon Sep 17 00:00:00 2001
From: Adam Setch <adam.setch@outlook.com>
Date: Tue, 21 Mar 2023 12:42:13 -0400
Subject: [PATCH] feat(replacements): support for replacement name templating
 (#20905)

Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
---
 docs/usage/configuration-options.md           |  42 ++++++-
 lib/config/options/index.ts                   |  10 ++
 .../repository/process/lookup/index.spec.ts   | 103 ++++++++++++++++++
 .../repository/process/lookup/index.ts        |  39 ++-----
 .../repository/process/lookup/types.ts        |   1 +
 .../repository/process/lookup/utils.ts        |  72 ++++++++++++
 6 files changed, 237 insertions(+), 30 deletions(-)
 create mode 100644 lib/workers/repository/process/lookup/utils.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 82305b51ac..f7fdb90932 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -2176,9 +2176,49 @@ Managers which do not support replacement:
 - `regex`
 
 Use the `replacementName` config option to set the name of a replacement package.
-Must be used with `replacementVersion` (see example below).
+
+Can be used in combination with `replacementVersion`.
+
 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.
 
+### replacementNameTemplate
+
+<!-- prettier-ignore -->
+!!! note
+    `replacementName` will take precedence if used within the same package rule.
+
+Use the `replacementNameTemplate` config option to control the replacement name.
+
+Use the triple brace `{{{ }}}` notation to avoid Handlebars escaping any special characters.
+
+For example, the following package rule can be used to replace the registry for `docker` images:
+
+```json
+{
+  "packageRules": [
+    {
+      "matchDatasources": ["docker"],
+      "matchPackagePrefix": ["^docker.io/.*)"],
+      "replacementNameTemplate": "{{{replace 'docker.io/' 'ghcr.io/' packageName}}}"
+    }
+  ]
+}
+```
+
+Or, to add a registry prefix to any `docker` images that do not contain an explicit registry:
+
+```json
+{
+  "packageRules": [
+    {
+      "matchDatasources": ["docker"],
+      "matchPackagePrefix": ["^([^.]+)(\\/\\:)?$"],
+      "replacementNameTemplate": "some.registry.org/{{{packageName}}}"
+    }
+  ]
+}
+```
+
 ### replacementVersion
 
 This config option only works with some managers.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index ec44d89375..428b8dc177 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -1214,6 +1214,16 @@ const options: RenovateOptions[] = [
     cli: false,
     env: false,
   },
+  {
+    name: 'replacementNameTemplate',
+    description: 'Controls what the replacement package name.',
+    type: 'string',
+    default: '{{{packageName}}}',
+    stage: 'package',
+    parent: 'packageRules',
+    cli: false,
+    env: false,
+  },
   {
     name: 'replacementVersion',
     description:
diff --git a/lib/workers/repository/process/lookup/index.spec.ts b/lib/workers/repository/process/lookup/index.spec.ts
index 40f8e62f99..5cea45a80f 100644
--- a/lib/workers/repository/process/lookup/index.spec.ts
+++ b/lib/workers/repository/process/lookup/index.spec.ts
@@ -1941,6 +1941,14 @@ describe('workers/repository/process/lookup/index', () => {
       ]);
     });
 
+    it('handles replacements - skips if package and replacement names match', async () => {
+      config.packageName = 'openjdk';
+      config.currentValue = undefined;
+      config.datasource = DockerDatasource.id;
+      config.replacementName = 'openjdk';
+      expect((await lookup.lookupUpdates(config)).updates).toMatchObject([]);
+    });
+
     it('handles replacements - name and version', async () => {
       config.currentValue = '1.4.1';
       config.packageName = 'q';
@@ -1958,6 +1966,101 @@ describe('workers/repository/process/lookup/index', () => {
       ]);
     });
 
+    it('handles replacements - can template replacement name without a replacement version', async () => {
+      config.packageName = 'mirror.some.org/library/openjdk';
+      config.currentValue = '17.0.0';
+      config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`;
+      config.datasource = DockerDatasource.id;
+      getDockerReleases.mockResolvedValueOnce({
+        releases: [
+          {
+            version: '17.0.0',
+          },
+          {
+            version: '18.0.0',
+          },
+        ],
+      });
+
+      expect((await lookup.lookupUpdates(config)).updates).toMatchObject([
+        {
+          updateType: 'replacement',
+          newName: 'new.registry.io/library/openjdk',
+          newValue: '17.0.0',
+        },
+        {
+          updateType: 'major',
+          newMajor: 18,
+          newValue: '18.0.0',
+          newVersion: '18.0.0',
+        },
+      ]);
+    });
+
+    it('handles replacements - can template replacement name with a replacement version', async () => {
+      config.packageName = 'mirror.some.org/library/openjdk';
+      config.currentValue = '17.0.0';
+      config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`;
+      config.replacementVersion = '18.0.0';
+      config.datasource = DockerDatasource.id;
+      getDockerReleases.mockResolvedValueOnce({
+        releases: [
+          {
+            version: '17.0.0',
+          },
+          {
+            version: '18.0.0',
+          },
+        ],
+      });
+
+      expect((await lookup.lookupUpdates(config)).updates).toMatchObject([
+        {
+          updateType: 'replacement',
+          newName: 'new.registry.io/library/openjdk',
+          newValue: '18.0.0',
+        },
+        {
+          updateType: 'major',
+          newMajor: 18,
+          newValue: '18.0.0',
+          newVersion: '18.0.0',
+        },
+      ]);
+    });
+
+    it('handles replacements - replacementName takes precedence over replacementNameTemplate', async () => {
+      config.packageName = 'mirror.some.org/library/openjdk';
+      config.currentValue = '17.0.0';
+      config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`;
+      config.replacementName = 'eclipse-temurin';
+      config.datasource = DockerDatasource.id;
+      getDockerReleases.mockResolvedValueOnce({
+        releases: [
+          {
+            version: '17.0.0',
+          },
+          {
+            version: '18.0.0',
+          },
+        ],
+      });
+
+      expect((await lookup.lookupUpdates(config)).updates).toMatchObject([
+        {
+          updateType: 'replacement',
+          newName: 'eclipse-temurin',
+          newValue: '17.0.0',
+        },
+        {
+          updateType: 'major',
+          newMajor: 18,
+          newValue: '18.0.0',
+          newVersion: '18.0.0',
+        },
+      ]);
+    });
+
     it('rollback for invalid version to last stable version', async () => {
       config.currentValue = '2.5.17';
       config.packageName = 'vue';
diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts
index a262baf992..70c1e0595a 100644
--- a/lib/workers/repository/process/lookup/index.ts
+++ b/lib/workers/repository/process/lookup/index.ts
@@ -26,6 +26,11 @@ import { filterInternalChecks } from './filter-checks';
 import { generateUpdate } from './generate';
 import { getRollbackUpdate } from './rollback';
 import type { LookupUpdateConfig, UpdateResult } from './types';
+import {
+  addReplacementUpdateIfValid,
+  isReplacementNameRulesConfigured,
+  isReplacementRulesConfigured,
+} from './utils';
 
 export async function lookupUpdates(
   inconfig: LookupUpdateConfig
@@ -157,27 +162,10 @@ export async function lookupUpdates(
       }
       let rangeStrategy = getRangeStrategy(config);
 
-      if (config.replacementName && !config.replacementVersion) {
-        res.updates.push({
-          updateType: 'replacement',
-          newName: config.replacementName,
-          newValue: currentValue!,
-        });
+      if (isReplacementRulesConfigured(config)) {
+        addReplacementUpdateIfValid(res.updates, config);
       }
 
-      if (config.replacementName && config.replacementVersion) {
-        res.updates.push({
-          updateType: 'replacement',
-          newName: config.replacementName,
-          newValue: versioning.getNewValue({
-            // TODO #7154
-            currentValue: currentValue!,
-            newVersion: config.replacementVersion,
-            rangeStrategy: rangeStrategy!,
-            isReplacement: true,
-          })!,
-        });
-      }
       // istanbul ignore next
       if (
         isVulnerabilityAlert &&
@@ -344,19 +332,12 @@ export async function lookupUpdates(
       } else {
         delete res.skipReason;
       }
-    } else if (
-      !currentValue &&
-      config.replacementName &&
-      !config.replacementVersion
-    ) {
+    } else if (!currentValue && isReplacementNameRulesConfigured(config)) {
       logger.debug(
         `Handle name-only replacement for ${packageName} without current version`
       );
-      res.updates.push({
-        updateType: 'replacement',
-        newName: config.replacementName,
-        newValue: currentValue!,
-      });
+
+      addReplacementUpdateIfValid(res.updates, config);
     } else {
       res.skipReason = 'invalid-value';
     }
diff --git a/lib/workers/repository/process/lookup/types.ts b/lib/workers/repository/process/lookup/types.ts
index 5a0e1a40bb..14f1187c62 100644
--- a/lib/workers/repository/process/lookup/types.ts
+++ b/lib/workers/repository/process/lookup/types.ts
@@ -46,6 +46,7 @@ export interface LookupUpdateConfig
   packageName: string;
   minimumConfidence?: MergeConfidence | undefined;
   replacementName?: string;
+  replacementNameTemplate?: string;
   replacementVersion?: string;
 }
 
diff --git a/lib/workers/repository/process/lookup/utils.ts b/lib/workers/repository/process/lookup/utils.ts
new file mode 100644
index 0000000000..4abf290368
--- /dev/null
+++ b/lib/workers/repository/process/lookup/utils.ts
@@ -0,0 +1,72 @@
+import is from '@sindresorhus/is';
+
+import { getRangeStrategy } from '../../../../modules/manager';
+import type { LookupUpdate } from '../../../../modules/manager/types';
+import * as allVersioning from '../../../../modules/versioning';
+import * as template from '../../../../util/template';
+import type { LookupUpdateConfig } from './types';
+
+export function addReplacementUpdateIfValid(
+  updates: LookupUpdate[],
+  config: LookupUpdateConfig
+): void {
+  const replacementNewName = determineNewReplacementName(config);
+  const replacementNewValue = determineNewReplacementValue(config);
+
+  if (
+    config.packageName !== replacementNewName ||
+    config.currentValue !== replacementNewValue
+  ) {
+    updates.push({
+      updateType: 'replacement',
+      newName: replacementNewName,
+      newValue: replacementNewValue!,
+    });
+  }
+}
+
+export function isReplacementNameRulesConfigured(
+  config: LookupUpdateConfig
+): boolean {
+  return (
+    is.nonEmptyString(config.replacementName) ||
+    is.nonEmptyString(config.replacementNameTemplate)
+  );
+}
+
+export function isReplacementRulesConfigured(
+  config: LookupUpdateConfig
+): boolean {
+  return (
+    isReplacementNameRulesConfigured(config) ||
+    is.nonEmptyString(config.replacementVersion)
+  );
+}
+
+export function determineNewReplacementName(
+  config: LookupUpdateConfig
+): string {
+  return (
+    config.replacementName ??
+    template.compile(config.replacementNameTemplate!, config, true)
+  );
+}
+
+export function determineNewReplacementValue(
+  config: LookupUpdateConfig
+): string | undefined | null {
+  const versioning = allVersioning.get(config.versioning);
+  const rangeStrategy = getRangeStrategy(config);
+
+  if (!is.nullOrUndefined(config.replacementVersion)) {
+    return versioning.getNewValue({
+      // TODO #7154
+      currentValue: config.currentValue!,
+      newVersion: config.replacementVersion,
+      rangeStrategy: rangeStrategy!,
+      isReplacement: true,
+    });
+  }
+
+  return config.currentValue;
+}
-- 
GitLab