From 2c264af8d2e2169676debeb6fe8d772ed9141a16 Mon Sep 17 00:00:00 2001
From: RahulGautamSingh <rahultesnik@gmail.com>
Date: Mon, 12 Jun 2023 21:33:40 +0545
Subject: [PATCH] feat: migrate`recreateClosed` to `recreateWhen` (#21039)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 docs/usage/configuration-options.md           | 21 ++++++++----
 docs/usage/key-concepts/pull-requests.md      |  1 +
 lib/config/__snapshots__/index.spec.ts.snap   |  2 +-
 .../custom/recreate-closed-migration.spec.ts  | 25 ++++++++++++++
 .../custom/recreate-closed-migration.ts       | 13 ++++++++
 lib/config/migrations/migrations-service.ts   |  2 ++
 lib/config/options/index.ts                   |  9 ++---
 lib/config/types.ts                           |  2 ++
 lib/workers/global/config/parse/cli.spec.ts   | 28 +++++++++-------
 lib/workers/global/config/parse/cli.ts        |  3 ++
 lib/workers/global/config/parse/env.spec.ts   | 25 ++++++++++----
 lib/workers/global/config/parse/env.ts        | 31 +++++++++++++++++
 .../update/branch/check-existing.spec.ts      |  4 +--
 .../update/branch/check-existing.ts           |  8 +++--
 .../repository/update/branch/index.spec.ts    |  2 +-
 .../update/pr/body/config-description.spec.ts | 15 ++++++++-
 .../repository/update/pr/pr-fingerprint.ts    | 10 ++++--
 .../__snapshots__/generate.spec.ts.snap       |  2 ++
 .../repository/updates/generate.spec.ts       | 33 ++++++++++++++++---
 lib/workers/repository/updates/generate.ts    |  8 +++--
 20 files changed, 200 insertions(+), 44 deletions(-)
 create mode 100644 lib/config/migrations/custom/recreate-closed-migration.spec.ts
 create mode 100644 lib/config/migrations/custom/recreate-closed-migration.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 5283cde172..53232f4c4b 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -2889,18 +2889,27 @@ It is also recommended to avoid `rebaseWhen=never` as it can result in conflicte
 
 Avoid setting `rebaseWhen=never` and then also setting `prCreation=not-pending` as this can prevent creation of PRs.
 
-## recreateClosed
+## recreateWhen
 
-By default, Renovate will detect if it has proposed an update to a project before and not propose the same one again.
-For example the Webpack 3.x case described above.
-This field lets you customize this behavior down to a per-package level.
-For example we override it to `true` in the following cases where branch names and PR titles need to be reused:
+This feature used to be called `recreateClosed`.
+
+By default, Renovate detects if it proposed an update to a project before, and will not propose the same update again.
+For example the Webpack 3.x case described in the [`separateMajorMinor`](#separatemajorminor) documentation.
+You can use `recreateWhen` to customize this behavior down to a per-package level.
+For example we override it to `always` in the following cases where branch names and PR titles must be reused:
 
 - Package groups
 - When pinning versions
 - Lock file maintenance
 
-Typically you shouldn't need to modify this setting.
+You can select which behavior you want from Renovate:
+
+- `always`: Recreates all closed or blocking PRs
+- `auto`: The default option. Recreates only immortal PRs (default)
+- `never`: No PR is recreated, doesn't matter if it is immortal or not
+
+We recommend that you stick with the default setting for this option.
+Only change this setting if you really need to.
 
 ## regexManagers
 
diff --git a/docs/usage/key-concepts/pull-requests.md b/docs/usage/key-concepts/pull-requests.md
index 4f63045a5f..7e2af2e96a 100644
--- a/docs/usage/key-concepts/pull-requests.md
+++ b/docs/usage/key-concepts/pull-requests.md
@@ -76,6 +76,7 @@ If you regularly wish to close immortal PRs, it's an indication that you may be
 ### How to fix immortal PRs
 
 Avoid grouping dependencies together which have different versions, or which you have a high chance of wanting to ignore.
+If you have immortal PRs which you want to keep closed, then set `"recreateWhen": "never"`.
 
 #### Major updates require Dependency Dashboard approval
 
diff --git a/lib/config/__snapshots__/index.spec.ts.snap b/lib/config/__snapshots__/index.spec.ts.snap
index 83825d9ee3..e8b55b377a 100644
--- a/lib/config/__snapshots__/index.spec.ts.snap
+++ b/lib/config/__snapshots__/index.spec.ts.snap
@@ -12,7 +12,7 @@ exports[`config/index mergeChildConfig(parentConfig, childConfig) merges 1`] = `
     "Change": "All locks refreshed",
   },
   "rebaseStalePrs": true,
-  "recreateClosed": true,
+  "recreateWhen": "always",
   "schedule": [
     "on monday",
   ],
diff --git a/lib/config/migrations/custom/recreate-closed-migration.spec.ts b/lib/config/migrations/custom/recreate-closed-migration.spec.ts
new file mode 100644
index 0000000000..38628ca6c4
--- /dev/null
+++ b/lib/config/migrations/custom/recreate-closed-migration.spec.ts
@@ -0,0 +1,25 @@
+import { RecreateClosedMigration } from './recreate-closed-migration';
+
+describe('config/migrations/custom/recreate-closed-migration', () => {
+  it('should migrate true', () => {
+    expect(RecreateClosedMigration).toMigrate(
+      {
+        recreateClosed: true,
+      },
+      {
+        recreateWhen: 'always',
+      }
+    );
+  });
+
+  it('should migrate false', () => {
+    expect(RecreateClosedMigration).toMigrate(
+      {
+        recreateClosed: false,
+      },
+      {
+        recreateWhen: 'auto',
+      }
+    );
+  });
+});
diff --git a/lib/config/migrations/custom/recreate-closed-migration.ts b/lib/config/migrations/custom/recreate-closed-migration.ts
new file mode 100644
index 0000000000..81cd106f4e
--- /dev/null
+++ b/lib/config/migrations/custom/recreate-closed-migration.ts
@@ -0,0 +1,13 @@
+import is from '@sindresorhus/is';
+import { AbstractMigration } from '../base/abstract-migration';
+
+export class RecreateClosedMigration extends AbstractMigration {
+  override readonly deprecated = true;
+  override readonly propertyName = 'recreateClosed';
+
+  override run(value: unknown): void {
+    if (is.boolean(value)) {
+      this.setSafely('recreateWhen', value ? 'always' : 'auto');
+    }
+  }
+}
diff --git a/lib/config/migrations/migrations-service.ts b/lib/config/migrations/migrations-service.ts
index 1b6c0d357d..e7e3b314bb 100644
--- a/lib/config/migrations/migrations-service.ts
+++ b/lib/config/migrations/migrations-service.ts
@@ -39,6 +39,7 @@ import { PostUpdateOptionsMigration } from './custom/post-update-options-migrati
 import { RaiseDeprecationWarningsMigration } from './custom/raise-deprecation-warnings-migration';
 import { RebaseConflictedPrs } from './custom/rebase-conflicted-prs-migration';
 import { RebaseStalePrsMigration } from './custom/rebase-stale-prs-migration';
+import { RecreateClosedMigration } from './custom/recreate-closed-migration';
 import { RenovateForkMigration } from './custom/renovate-fork-migration';
 import { RequireConfigMigration } from './custom/require-config-migration';
 import { RequiredStatusChecksMigration } from './custom/required-status-checks-migration';
@@ -145,6 +146,7 @@ export class MigrationsService {
     SemanticPrefixMigration,
     MatchDatasourcesMigration,
     DatasourceMigration,
+    RecreateClosedMigration,
     StabilityDaysMigration,
   ];
 
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index f71066be46..f539bbf96b 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -1574,10 +1574,11 @@ const options: RenovateOptions[] = [
     default: false,
   },
   {
-    name: 'recreateClosed',
+    name: 'recreateWhen',
     description: 'Recreate PRs even if same ones were closed previously.',
-    type: 'boolean',
-    default: false,
+    type: 'string',
+    default: 'auto',
+    allowedValues: ['auto', 'always', 'never'],
   },
   {
     name: 'rebaseWhen',
@@ -1899,7 +1900,7 @@ const options: RenovateOptions[] = [
     type: 'object',
     default: {
       enabled: false,
-      recreateClosed: true,
+      recreateWhen: 'always',
       rebaseStalePrs: true,
       branchTopic: 'lock-file-maintenance',
       commitMessageAction: 'Lock file maintenance',
diff --git a/lib/config/types.ts b/lib/config/types.ts
index 5c390b09c8..ac915cd803 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -21,6 +21,7 @@ export interface GroupConfig extends Record<string, unknown> {
   branchTopic?: string;
 }
 
+export type RecreateWhen = 'auto' | 'never' | 'always';
 // TODO: Proper typings
 export interface RenovateSharedConfig {
   $schema?: string;
@@ -70,6 +71,7 @@ export interface RenovateSharedConfig {
   respectLatest?: boolean;
   stopUpdatingLabel?: string;
   rebaseWhen?: string;
+  recreateWhen?: RecreateWhen;
   recreateClosed?: boolean;
   repository?: string;
   repositoryCache?: RepositoryCacheConfig;
diff --git a/lib/workers/global/config/parse/cli.spec.ts b/lib/workers/global/config/parse/cli.spec.ts
index a98b4f8939..8cb7a3d785 100644
--- a/lib/workers/global/config/parse/cli.spec.ts
+++ b/lib/workers/global/config/parse/cli.spec.ts
@@ -33,19 +33,19 @@ describe('workers/global/config/parse/cli', () => {
     });
 
     it('supports boolean no value', () => {
-      argv.push('--recreate-closed');
-      expect(cli.getConfig(argv)).toEqual({ recreateClosed: true });
+      argv.push('--config-migration');
+      expect(cli.getConfig(argv)).toEqual({ configMigration: true });
       argv = argv.slice(0, -1);
     });
 
     it('supports boolean space true', () => {
-      argv.push('--recreate-closed');
+      argv.push('--config-migration');
       argv.push('true');
-      expect(cli.getConfig(argv)).toEqual({ recreateClosed: true });
+      expect(cli.getConfig(argv)).toEqual({ configMigration: true });
     });
 
     it('throws exception for invalid boolean value', () => {
-      argv.push('--recreate-closed');
+      argv.push('--config-migration');
       argv.push('badvalue');
       expect(() => cli.getConfig(argv)).toThrow(
         Error(
@@ -55,19 +55,19 @@ describe('workers/global/config/parse/cli', () => {
     });
 
     it('supports boolean space false', () => {
-      argv.push('--recreate-closed');
+      argv.push('--config-migration');
       argv.push('false');
-      expect(cli.getConfig(argv)).toEqual({ recreateClosed: false });
+      expect(cli.getConfig(argv)).toEqual({ configMigration: false });
     });
 
     it('supports boolean equals true', () => {
-      argv.push('--recreate-closed=true');
-      expect(cli.getConfig(argv)).toEqual({ recreateClosed: true });
+      argv.push('--config-migration=true');
+      expect(cli.getConfig(argv)).toEqual({ configMigration: true });
     });
 
     it('supports boolean equals false', () => {
-      argv.push('--recreate-closed=false');
-      expect(cli.getConfig(argv)).toEqual({ recreateClosed: false });
+      argv.push('--config-migration=false');
+      expect(cli.getConfig(argv)).toEqual({ configMigration: false });
     });
 
     it('supports list single', () => {
@@ -130,6 +130,12 @@ describe('workers/global/config/parse/cli', () => {
       ${'--git-lab-automerge=false'}   | ${{ platformAutomerge: false }}
       ${'--git-lab-automerge=true'}    | ${{ platformAutomerge: true }}
       ${'--git-lab-automerge'}         | ${{ platformAutomerge: true }}
+      ${'--recreate-closed=false'}     | ${{ recreateWhen: 'auto' }}
+      ${'--recreate-closed=true'}      | ${{ recreateWhen: 'always' }}
+      ${'--recreate-closed'}           | ${{ recreateWhen: 'always' }}
+      ${'--recreate-when=auto'}        | ${{ recreateWhen: 'auto' }}
+      ${'--recreate-when=always'}      | ${{ recreateWhen: 'always' }}
+      ${'--recreate-when=never'}       | ${{ recreateWhen: 'never' }}
     `('"$arg" -> $config', ({ arg, config }) => {
       argv.push(arg);
       expect(cli.getConfig(argv)).toMatchObject(config);
diff --git a/lib/workers/global/config/parse/cli.ts b/lib/workers/global/config/parse/cli.ts
index f12305fa3e..37c8d37cac 100644
--- a/lib/workers/global/config/parse/cli.ts
+++ b/lib/workers/global/config/parse/cli.ts
@@ -34,6 +34,9 @@ export function getConfig(input: string[]): AllConfig {
         .replace('--aliases', '--registry-aliases')
         .replace('--include-forks=true', '--fork-processing=enabled')
         .replace('--include-forks', '--fork-processing=enabled')
+        .replace('--recreate-closed=false', '--recreate-when=auto')
+        .replace('--recreate-closed=true', '--recreate-when=always')
+        .replace('--recreate-closed', '--recreate-when=always')
     )
     .filter((a) => !a.startsWith('--git-fs'));
   const options = getOptions();
diff --git a/lib/workers/global/config/parse/env.spec.ts b/lib/workers/global/config/parse/env.spec.ts
index ff963b7658..672839f1c3 100644
--- a/lib/workers/global/config/parse/env.spec.ts
+++ b/lib/workers/global/config/parse/env.spec.ts
@@ -10,18 +10,20 @@ describe('workers/global/config/parse/env', () => {
     });
 
     it('supports boolean true', () => {
-      const envParam: NodeJS.ProcessEnv = { RENOVATE_RECREATE_CLOSED: 'true' };
-      expect(env.getConfig(envParam).recreateClosed).toBeTrue();
+      const envParam: NodeJS.ProcessEnv = { RENOVATE_CONFIG_MIGRATION: 'true' };
+      expect(env.getConfig(envParam).configMigration).toBeTrue();
     });
 
     it('supports boolean false', () => {
-      const envParam: NodeJS.ProcessEnv = { RENOVATE_RECREATE_CLOSED: 'false' };
-      expect(env.getConfig(envParam).recreateClosed).toBeFalse();
+      const envParam: NodeJS.ProcessEnv = {
+        RENOVATE_CONFIG_MIGRATION: 'false',
+      };
+      expect(env.getConfig(envParam).configMigration).toBeFalse();
     });
 
     it('throws exception for invalid boolean value', () => {
       const envParam: NodeJS.ProcessEnv = {
-        RENOVATE_RECREATE_CLOSED: 'badvalue',
+        RENOVATE_CONFIG_MIGRATION: 'badvalue',
       };
       expect(() => env.getConfig(envParam)).toThrow(
         Error(
@@ -30,7 +32,7 @@ describe('workers/global/config/parse/env', () => {
       );
     });
 
-    delete process.env.RENOVATE_RECREATE_CLOSED;
+    delete process.env.RENOVATE_CONFIG_MIGRATION;
 
     it('supports list single', () => {
       const envParam: NodeJS.ProcessEnv = { RENOVATE_LABELS: 'a' };
@@ -83,6 +85,17 @@ describe('workers/global/config/parse/env', () => {
       expect(res).toMatchObject({ hostRules: [{ foo: 'bar' }] });
     });
 
+    test.each`
+      envArg                                   | config
+      ${{ RENOVATE_RECREATE_CLOSED: 'true' }}  | ${{ recreateWhen: 'always' }}
+      ${{ RENOVATE_RECREATE_CLOSED: 'false' }} | ${{ recreateWhen: 'auto' }}
+      ${{ RENOVATE_RECREATE_WHEN: 'auto' }}    | ${{ recreateWhen: 'auto' }}
+      ${{ RENOVATE_RECREATE_WHEN: 'always' }}  | ${{ recreateWhen: 'always' }}
+      ${{ RENOVATE_RECREATE_WHEN: 'never' }}   | ${{ recreateWhen: 'never' }}
+    `('"$envArg" -> $config', ({ envArg, config }) => {
+      expect(env.getConfig(envArg)).toMatchObject(config);
+    });
+
     it('skips misconfigured arrays', () => {
       const envName = 'RENOVATE_HOST_RULES';
       const val = JSON.stringify('foobar');
diff --git a/lib/workers/global/config/parse/env.ts b/lib/workers/global/config/parse/env.ts
index 4c1afc8b7e..f176043f08 100644
--- a/lib/workers/global/config/parse/env.ts
+++ b/lib/workers/global/config/parse/env.ts
@@ -53,10 +53,41 @@ function renameEnvKeys(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
   return result;
 }
 
+const migratedKeysWithValues = [
+  {
+    oldName: 'recreateClosed',
+    newName: 'recreateWhen',
+    from: 'true',
+    to: 'always',
+  },
+  {
+    oldName: 'recreateClosed',
+    newName: 'recreateWhen',
+    from: 'false',
+    to: 'auto',
+  },
+];
+
+function massageEnvKeyValues(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+  const result = { ...env };
+  for (const { oldName, newName, from, to } of migratedKeysWithValues) {
+    const key = getEnvName({ name: oldName });
+    if (env[key] !== undefined) {
+      if (result[key] === from) {
+        delete result[key];
+        result[getEnvName({ name: newName })] = to;
+      }
+    }
+  }
+  return result;
+}
+
 export function getConfig(inputEnv: NodeJS.ProcessEnv): AllConfig {
   let env = inputEnv;
   env = normalizePrefixes(inputEnv, inputEnv.ENV_PREFIX);
   env = renameEnvKeys(env);
+  // massage the values of migrated configuration keys
+  env = massageEnvKeyValues(env);
 
   const options = getOptions();
 
diff --git a/lib/workers/repository/update/branch/check-existing.spec.ts b/lib/workers/repository/update/branch/check-existing.spec.ts
index ff7c545d85..c1f219f22a 100644
--- a/lib/workers/repository/update/branch/check-existing.spec.ts
+++ b/lib/workers/repository/update/branch/check-existing.spec.ts
@@ -20,13 +20,13 @@ describe('workers/repository/update/branch/check-existing', () => {
     });
 
     it('returns false if recreating closed PRs', async () => {
-      config.recreateClosed = true;
+      config.recreateWhen = 'always';
       expect(await prAlreadyExisted(config)).toBeNull();
       expect(platform.findPr).toHaveBeenCalledTimes(0);
     });
 
     it('returns false if check misses', async () => {
-      config.recreatedClosed = true;
+      config.recreateWhen = 'auto';
       expect(await prAlreadyExisted(config)).toBeNull();
       expect(platform.findPr).toHaveBeenCalledTimes(1);
     });
diff --git a/lib/workers/repository/update/branch/check-existing.ts b/lib/workers/repository/update/branch/check-existing.ts
index 877d28fa0d..c0fdba1154 100644
--- a/lib/workers/repository/update/branch/check-existing.ts
+++ b/lib/workers/repository/update/branch/check-existing.ts
@@ -8,11 +8,13 @@ export async function prAlreadyExisted(
   config: BranchConfig
 ): Promise<Pr | null> {
   logger.trace({ config }, 'prAlreadyExisted');
-  if (config.recreateClosed) {
-    logger.debug('recreateClosed is true');
+  if (config.recreateWhen === 'always') {
+    logger.debug('recreateWhen is "always". No need to check for closed PR.');
     return null;
   }
-  logger.debug('recreateClosed is false');
+  logger.debug(
+    'Check for closed PR because recreating closed PRs is disabled.'
+  );
   // Return if same PR already existed
   let pr = await platform.findPr({
     branchName: config.branchName,
diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts
index 66e276a240..4a438e2544 100644
--- a/lib/workers/repository/update/branch/index.spec.ts
+++ b/lib/workers/repository/update/branch/index.spec.ts
@@ -1026,7 +1026,7 @@ describe('workers/repository/update/branch/index', () => {
         artifactErrors: [partial<ArtifactError>()],
         updatedArtifacts: [partial<FileChange>()],
       });
-      config.recreateClosed = true;
+      config.recreateWhen = 'always';
       scm.branchExists.mockResolvedValue(true);
       automerge.tryBranchAutomerge.mockResolvedValueOnce('failed');
       prWorker.ensurePr.mockResolvedValueOnce({
diff --git a/lib/workers/repository/update/pr/body/config-description.spec.ts b/lib/workers/repository/update/pr/body/config-description.spec.ts
index 435c37258c..37bba58b2c 100644
--- a/lib/workers/repository/update/pr/body/config-description.spec.ts
+++ b/lib/workers/repository/update/pr/body/config-description.spec.ts
@@ -67,7 +67,7 @@ describe('workers/repository/update/pr/body/config-description', () => {
       expect(res).toContain(`At any time (no schedule defined).`);
     });
 
-    it('renders recreateClosed', () => {
+    it('renders recreateClosed=true', () => {
       const res = getPrConfigDescription({
         ...config,
         recreateClosed: true,
@@ -75,6 +75,19 @@ describe('workers/repository/update/pr/body/config-description', () => {
       expect(res).toContain(`**Immortal**`);
     });
 
+    it('does not render recreateClosed=false', () => {
+      const res = getPrConfigDescription({
+        ...config,
+        recreateClosed: false,
+      });
+      expect(res).not.toContain(`**Immortal**`);
+    });
+
+    it('does not render recreateClosed=undefined', () => {
+      const res = getPrConfigDescription(config);
+      expect(res).not.toContain(`**Immortal**`);
+    });
+
     it('renders singular', () => {
       const res = getPrConfigDescription({
         ...config,
diff --git a/lib/workers/repository/update/pr/pr-fingerprint.ts b/lib/workers/repository/update/pr/pr-fingerprint.ts
index 9558522781..cd91d44069 100644
--- a/lib/workers/repository/update/pr/pr-fingerprint.ts
+++ b/lib/workers/repository/update/pr/pr-fingerprint.ts
@@ -1,7 +1,11 @@
 // fingerprint config is based on the old skip pr update logic
 // https://github.com/renovatebot/renovate/blob/3d85b6048d6a8c57887b64ed4929e2e02ea41aa0/lib/workers/repository/update/pr/index.ts#L294-L306
 
-import type { UpdateType, ValidationMessage } from '../../../../config/types';
+import type {
+  RecreateWhen,
+  UpdateType,
+  ValidationMessage,
+} from '../../../../config/types';
 import { logger } from '../../../../logger';
 import type { PrCache } from '../../../../util/cache/repository/types';
 import { getElapsedHours } from '../../../../util/date';
@@ -28,7 +32,7 @@ export interface PrBodyFingerprintConfig {
   prHeader?: string;
   prTitle?: string;
   rebaseWhen?: string;
-  recreateClosed?: boolean;
+  recreateWhen?: RecreateWhen;
   schedule?: string[];
   stopUpdating?: boolean;
   timezone?: string;
@@ -67,7 +71,7 @@ export function generatePrBodyFingerprintConfig(
     prHeader: config.prHeader,
     prTitle: config.prTitle,
     rebaseWhen: config.rebaseWhen,
-    recreateClosed: config.recreateClosed,
+    recreateWhen: config.recreateWhen,
     schedule: config.schedule,
     stopUpdating: config.stopUpdating,
     timezone: config.timezone,
diff --git a/lib/workers/repository/updates/__snapshots__/generate.spec.ts.snap b/lib/workers/repository/updates/__snapshots__/generate.spec.ts.snap
index 9455b1b87a..693e02a74f 100644
--- a/lib/workers/repository/updates/__snapshots__/generate.spec.ts.snap
+++ b/lib/workers/repository/updates/__snapshots__/generate.spec.ts.snap
@@ -178,6 +178,7 @@ exports[`workers/repository/updates/generate generateBranchConfig() handles lock
   "prBodyColumns": [],
   "prTitle": "some-title",
   "prettyDepType": "dependency",
+  "recreateClosed": true,
   "releaseTimestamp": undefined,
   "reuseLockFiles": true,
   "upgrades": [
@@ -191,6 +192,7 @@ exports[`workers/repository/updates/generate generateBranchConfig() handles lock
       "manager": "some-manager",
       "prTitle": "some-title",
       "prettyDepType": "dependency",
+      "recreateClosed": true,
     },
   ],
 }
diff --git a/lib/workers/repository/updates/generate.spec.ts b/lib/workers/repository/updates/generate.spec.ts
index c44d2e74ee..ca0b111abb 100644
--- a/lib/workers/repository/updates/generate.spec.ts
+++ b/lib/workers/repository/updates/generate.spec.ts
@@ -67,6 +67,7 @@ describe('workers/repository/updates/generate', () => {
             branchName: 'some-branch',
             prTitle: 'some-title',
             isLockFileMaintenance: true,
+            recreateClosed: true,
           },
         ],
       });
@@ -216,7 +217,7 @@ describe('workers/repository/updates/generate', () => {
       });
     });
 
-    it('groups major updates with different versions but same newValue, no recreateClosed', () => {
+    it('groups major updates with different versions but same newValue, no recreateWhen', () => {
       const branch = [
         {
           manager: 'some-manager',
@@ -282,7 +283,7 @@ describe('workers/repository/updates/generate', () => {
       expect(res.recreateClosed).toBeTrue();
     });
 
-    it('Grouped pin & pinDigest can be recreated', () => {
+    it('recreates grouped pin & pinDigest', () => {
       const branch = [
         {
           ...requiredDefaultOptions,
@@ -304,7 +305,31 @@ describe('workers/repository/updates/generate', () => {
       expect(res.recreateClosed).toBeTrue();
     });
 
-    it('Grouped pin can be recreated', () => {
+    it('does not recreate grouped pin & pinDigest when closed if recreateWhen=never', () => {
+      const branch = [
+        {
+          ...requiredDefaultOptions,
+          isPinDigest: true,
+          updateType: 'pinDigest',
+          newValue: 'v2',
+          newDigest: 'dc323e67f16fb5f7663d20ff7941f27f5809e9b6',
+          recreateWhen: 'never',
+        },
+        {
+          ...requiredDefaultOptions,
+          updateType: 'pin',
+          isPin: true,
+          newValue: "'2.2.0'",
+          newVersion: '2.2.0',
+          newMajor: 2,
+          recreateWhen: 'never',
+        },
+      ] as BranchUpgradeConfig[];
+      const res = generateBranchConfig(branch);
+      expect(res.recreateClosed).toBeFalse();
+    });
+
+    it('recreates grouped pin', () => {
       const branch = [
         {
           ...requiredDefaultOptions,
@@ -331,7 +356,7 @@ describe('workers/repository/updates/generate', () => {
       expect(res.recreateClosed).toBeTrue();
     });
 
-    it('grouped pinDigest can be recreated', () => {
+    it('recreates grouped pinDigest', () => {
       const branch = [
         {
           ...requiredDefaultOptions,
diff --git a/lib/workers/repository/updates/generate.ts b/lib/workers/repository/updates/generate.ts
index c56cb14345..c044fb94d1 100644
--- a/lib/workers/repository/updates/generate.ts
+++ b/lib/workers/repository/updates/generate.ts
@@ -103,6 +103,10 @@ export function generateBranchConfig(
       upg.displayFrom = upg.currentValue;
       upg.displayTo = upg.newValue;
     }
+
+    if (upg.isLockFileMaintenance) {
+      upg.recreateClosed = upg.recreateWhen !== 'never';
+    }
     upg.displayFrom ??= '';
     upg.displayTo ??= '';
     if (!depNames.includes(upg.depName!)) {
@@ -178,14 +182,14 @@ export function generateBranchConfig(
       logger.trace({ toVersions });
       logger.trace({ toValues });
       delete upgrade.commitMessageExtra;
-      upgrade.recreateClosed = true;
+      upgrade.recreateClosed = upgrade.recreateWhen !== 'never';
     } else if (
       newValue.length > 1 &&
       (upgrade.isDigest || upgrade.isPinDigest)
     ) {
       logger.trace({ newValue });
       delete upgrade.commitMessageExtra;
-      upgrade.recreateClosed = true;
+      upgrade.recreateClosed = upgrade.recreateWhen !== 'never';
     } else if (semver.valid(toVersions[0])) {
       upgrade.isRange = false;
     }
-- 
GitLab