From 7e7124ef9313d4b08c39a023dfc920f630fd09d6 Mon Sep 17 00:00:00 2001
From: RahulGautamSingh <rahultesnik@gmail.com>
Date: Wed, 28 Feb 2024 01:28:33 +0545
Subject: [PATCH] feat(package-rules): matchNewValue (#27374)

---
 docs/usage/configuration-options.md      | 34 +++++++++++++
 lib/config/options/index.ts              | 11 ++++
 lib/config/types.ts                      |  2 +
 lib/config/validation.spec.ts            | 29 +++++++++++
 lib/config/validation.ts                 | 10 ++++
 lib/util/package-rules/matchers.ts       |  2 +
 lib/util/package-rules/new-value.spec.ts | 65 ++++++++++++++++++++++++
 lib/util/package-rules/new-value.ts      | 31 +++++++++++
 8 files changed, 184 insertions(+)
 create mode 100644 lib/util/package-rules/new-value.spec.ts
 create mode 100644 lib/util/package-rules/new-value.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 9cb987176c..ce0c1d80b2 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -2821,6 +2821,40 @@ It is recommended that you avoid using "negative" globs, like `**/!(package.json
 
 ### matchDepPatterns
 
+### matchNewValue
+
+This option is matched against the `newValue` field of a dependency.
+
+`matchNewValue` supports Regular Expressions which must begin and end with `/`.
+For example, the following enforces that only `1.*` versions will be used:
+
+```json
+{
+  "packageRules": [
+    {
+      "matchPackagePatterns": ["io.github.resilience4j"],
+      "matchNewValue": "/^1\\./"
+    }
+  ]
+}
+```
+
+This field also supports a special negated regex syntax to ignore certain versions.
+Use the syntax `!/ /` like this:
+
+```json
+{
+  "packageRules": [
+    {
+      "matchPackagePatterns": ["io.github.resilience4j"],
+      "matchNewValue": "!/^0\\./"
+    }
+  ]
+}
+```
+
+For more details on this syntax see Renovate's [string pattern matching documentation](./string-pattern-matching.md).
+
 ### matchPackageNames
 
 Use this field if you want to have one or more exact name matches in your package rule.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index 5063e00c09..0ece410309 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -1374,6 +1374,17 @@ const options: RenovateOptions[] = [
     cli: false,
     env: false,
   },
+  {
+    name: 'matchNewValue',
+    description:
+      'A regex to match against the raw `newValue` string of a dependency. Valid only within a `packageRules` object.',
+    type: 'string',
+    stage: 'package',
+    parents: ['packageRules'],
+    mergeable: true,
+    cli: false,
+    env: false,
+  },
   {
     name: 'matchSourceUrlPrefixes',
     description:
diff --git a/lib/config/types.ts b/lib/config/types.ts
index da2d47b9c8..6e0715dabd 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -359,6 +359,7 @@ export interface PackageRule
   excludePackagePatterns?: string[];
   excludePackagePrefixes?: string[];
   excludeRepositories?: string[];
+  matchNewValue?: string;
   matchCurrentValue?: string;
   matchCurrentVersion?: string;
   matchSourceUrlPrefixes?: string[];
@@ -498,6 +499,7 @@ export interface PackageRuleInputConfig extends Record<string, unknown> {
   depTypes?: string[];
   depName?: string;
   packageName?: string | null;
+  newValue?: string | null;
   currentValue?: string | null;
   currentVersion?: string;
   lockedVersion?: string;
diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts
index 70d5233a9d..485d4678e5 100644
--- a/lib/config/validation.spec.ts
+++ b/lib/config/validation.spec.ts
@@ -129,6 +129,35 @@ describe('config/validation', () => {
       expect(errors).toHaveLength(2);
     });
 
+    it('catches invalid matchNewValue', async () => {
+      const config = {
+        packageRules: [
+          {
+            matchPackageNames: ['foo'],
+            matchNewValue: '/^2/',
+            enabled: true,
+          },
+          {
+            matchPackageNames: ['bar'],
+            matchNewValue: '^1',
+            enabled: true,
+          },
+          {
+            matchPackageNames: ['quack'],
+            matchNewValue: '<1.0.0',
+            enabled: true,
+          },
+          {
+            matchPackageNames: ['foo'],
+            matchNewValue: '/^2/i',
+            enabled: true,
+          },
+        ],
+      };
+      const { errors } = await configValidation.validateConfig(false, config);
+      expect(errors).toHaveLength(2);
+    });
+
     it('catches invalid matchCurrentVersion regex', async () => {
       const config = {
         packageRules: [
diff --git a/lib/config/validation.ts b/lib/config/validation.ts
index 7b3ea7af65..61621457b6 100644
--- a/lib/config/validation.ts
+++ b/lib/config/validation.ts
@@ -285,6 +285,15 @@ export async function validateConfig(
           topic: 'Configuration Error',
           message: `Invalid regExp for ${currentPath}: \`${val}\``,
         });
+      } else if (
+        key === 'matchNewValue' &&
+        is.string(val) &&
+        !getRegexPredicate(val)
+      ) {
+        errors.push({
+          topic: 'Configuration Error',
+          message: `Invalid regExp for ${currentPath}: \`${val}\``,
+        });
       } else if (key === 'timezone' && val !== null) {
         const [validTimezone, errorMessage] = hasValidTimezone(val as string);
         if (!validTimezone) {
@@ -386,6 +395,7 @@ export async function validateConfig(
               'matchConfidence',
               'matchCurrentAge',
               'matchRepositories',
+              'matchNewValue',
             ];
             if (key === 'packageRules') {
               for (const [subIndex, packageRule] of val.entries()) {
diff --git a/lib/util/package-rules/matchers.ts b/lib/util/package-rules/matchers.ts
index 67cc39ec3b..4bef5c5784 100644
--- a/lib/util/package-rules/matchers.ts
+++ b/lib/util/package-rules/matchers.ts
@@ -10,6 +10,7 @@ import { DepTypesMatcher } from './dep-types';
 import { FileNamesMatcher } from './files';
 import { ManagersMatcher } from './managers';
 import { MergeConfidenceMatcher } from './merge-confidence';
+import { NewValueMatcher } from './new-value';
 import { PackageNameMatcher } from './package-names';
 import { PackagePatternsMatcher } from './package-patterns';
 import { PackagePrefixesMatcher } from './package-prefixes';
@@ -43,6 +44,7 @@ matchers.push([new DatasourcesMatcher()]);
 matchers.push([new UpdateTypesMatcher()]);
 matchers.push([new SourceUrlsMatcher(), new SourceUrlPrefixesMatcher()]);
 matchers.push([new CurrentValueMatcher()]);
+matchers.push([new NewValueMatcher()]);
 matchers.push([new CurrentVersionMatcher()]);
 matchers.push([new RepositoriesMatcher()]);
 matchers.push([new CategoriesMatcher()]);
diff --git a/lib/util/package-rules/new-value.spec.ts b/lib/util/package-rules/new-value.spec.ts
new file mode 100644
index 0000000000..323de64d54
--- /dev/null
+++ b/lib/util/package-rules/new-value.spec.ts
@@ -0,0 +1,65 @@
+import { NewValueMatcher } from './new-value';
+
+describe('util/package-rules/new-value', () => {
+  const matcher = new NewValueMatcher();
+
+  describe('match', () => {
+    it('return null if non-regex', () => {
+      const result = matcher.matches(
+        {
+          newValue: '"~> 1.1.0"',
+        },
+        {
+          matchNewValue: '^v',
+        },
+      );
+      expect(result).toBeFalse();
+    });
+
+    it('return false for regex version non match', () => {
+      const result = matcher.matches(
+        {
+          newValue: '"~> 1.1.0"',
+        },
+        {
+          matchNewValue: '/^v/',
+        },
+      );
+      expect(result).toBeFalse();
+    });
+
+    it('case insensitive match', () => {
+      const result = matcher.matches(
+        {
+          newValue: '"V1.1.0"',
+        },
+        {
+          matchNewValue: '/^"v/i',
+        },
+      );
+      expect(result).toBeTrue();
+    });
+
+    it('return true for regex version match', () => {
+      const result = matcher.matches(
+        {
+          newValue: '"~> 0.1.0"',
+        },
+        {
+          matchNewValue: '/^"/',
+        },
+      );
+      expect(result).toBeTrue();
+    });
+
+    it('return false for now value', () => {
+      const result = matcher.matches(
+        {},
+        {
+          matchNewValue: '/^v?[~ -]?0/',
+        },
+      );
+      expect(result).toBeFalse();
+    });
+  });
+});
diff --git a/lib/util/package-rules/new-value.ts b/lib/util/package-rules/new-value.ts
new file mode 100644
index 0000000000..b8c28f5f82
--- /dev/null
+++ b/lib/util/package-rules/new-value.ts
@@ -0,0 +1,31 @@
+import is from '@sindresorhus/is';
+import type { PackageRule, PackageRuleInputConfig } from '../../config/types';
+import { logger } from '../../logger';
+import { getRegexPredicate } from '../string-match';
+import { Matcher } from './base';
+
+export class NewValueMatcher extends Matcher {
+  override matches(
+    { newValue }: PackageRuleInputConfig,
+    { matchNewValue }: PackageRule,
+  ): boolean | null {
+    if (is.undefined(matchNewValue)) {
+      return null;
+    }
+    const matchNewValuePred = getRegexPredicate(matchNewValue);
+
+    if (!matchNewValuePred) {
+      logger.debug(
+        { matchNewValue },
+        'matchNewValue should be a regex, starting and ending with `/`',
+      );
+      return false;
+    }
+
+    if (!newValue) {
+      return false;
+    }
+
+    return matchNewValuePred(newValue);
+  }
+}
-- 
GitLab