From eb074924655488bbd62dba7f55e75bfb925e0f94 Mon Sep 17 00:00:00 2001
From: RahulGautamSingh <rahultesnik@gmail.com>
Date: Fri, 13 Dec 2024 01:53:03 +0530
Subject: [PATCH] fix(config/inherit): resolve presets (#31642)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 docs/usage/config-overview.md                 |  13 ++
 lib/workers/repository/init/inherited.spec.ts | 136 +++++++++++++++++-
 lib/workers/repository/init/inherited.ts      |  40 +++++-
 3 files changed, 187 insertions(+), 2 deletions(-)

diff --git a/docs/usage/config-overview.md b/docs/usage/config-overview.md
index 99d6b40c96..669e15ea87 100644
--- a/docs/usage/config-overview.md
+++ b/docs/usage/config-overview.md
@@ -159,6 +159,19 @@ Inherited config may use all Repository config settings, and any Global config o
 
 For information on how the Mend Renovate App supports Inherited config, see the dedicated "Mend Renovate App Config" section toward the end of this page.
 
+#### Presets handling
+
+If the inherited config contains `extends` presets, then Renovate will:
+
+1. Resolve the presets
+1. Add the resolved preset config to the beginning of the inherited config
+1. Merge the presets on top of the global config
+
+##### You can not ignore presets from inherited config
+
+You can _not_ use `ignorePresets` in your repository config to ignore presets _within_ inherited config.
+This is because inherited config is resolved _before_ the repository config.
+
 ### Repository config
 
 Repository config is the config loaded from a config file in the repository.
diff --git a/lib/workers/repository/init/inherited.spec.ts b/lib/workers/repository/init/inherited.spec.ts
index f89c182016..e31f5ba2fc 100644
--- a/lib/workers/repository/init/inherited.spec.ts
+++ b/lib/workers/repository/init/inherited.spec.ts
@@ -1,5 +1,7 @@
-import { platform } from '../../../../test/util';
+import { mocked, platform } from '../../../../test/util';
+import * as presets_ from '../../../config/presets';
 import type { RenovateConfig } from '../../../config/types';
+import * as validation from '../../../config/validation';
 import {
   CONFIG_INHERIT_NOT_FOUND,
   CONFIG_INHERIT_PARSE_ERROR,
@@ -8,6 +10,10 @@ import {
 import { logger } from '../../../logger';
 import { mergeInheritedConfig } from './inherited';
 
+jest.mock('../../../config/presets');
+
+const presets = mocked(presets_);
+
 describe('workers/repository/init/inherited', () => {
   let config: RenovateConfig;
 
@@ -84,4 +90,132 @@ describe('workers/repository/init/inherited', () => {
     expect(res.onboarding).toBeFalse();
     expect(logger.warn).not.toHaveBeenCalled();
   });
+
+  it('should resolve presets found in inherited config', async () => {
+    platform.getRawFile.mockResolvedValue(
+      '{"onboarding":false,"labels":["test"],"extends":[":automergeAll"]}',
+    );
+    presets.resolveConfigPresets.mockResolvedValue({
+      onboarding: false,
+      labels: ['test'],
+      automerge: true,
+    });
+    const res = await mergeInheritedConfig(config);
+    expect(res.labels).toEqual(['test']);
+    expect(res.onboarding).toBeFalse();
+    expect(logger.warn).not.toHaveBeenCalled();
+    expect(logger.debug).toHaveBeenCalledWith(
+      'Resolving presets found in inherited config',
+    );
+  });
+
+  it('should warn if presets fails validation with warnings', async () => {
+    platform.getRawFile.mockResolvedValue(
+      '{"onboarding":false,"labels":["test"],"extends":[":automergeAll"]}',
+    );
+    jest
+      .spyOn(validation, 'validateConfig')
+      .mockResolvedValueOnce({
+        warnings: [],
+        errors: [],
+      })
+      .mockResolvedValueOnce({
+        warnings: [
+          {
+            message: 'some warning',
+            topic: 'Configuration Error',
+          },
+        ],
+        errors: [],
+      });
+    presets.resolveConfigPresets.mockResolvedValue({
+      onboarding: false,
+      labels: ['test'],
+      automerge: true,
+    });
+    const res = await mergeInheritedConfig(config);
+    expect(res.binarySource).toBeUndefined();
+    expect(logger.warn).toHaveBeenCalledWith(
+      {
+        warnings: [
+          {
+            message: 'some warning',
+            topic: 'Configuration Error',
+          },
+        ],
+      },
+      'Found warnings in presets inside the inherited configuration.',
+    );
+  });
+
+  it('should throw error if presets fails validation with errors', async () => {
+    platform.getRawFile.mockResolvedValue(
+      '{"labels":["test"],"extends":[":automergeAll"]}',
+    );
+    jest
+      .spyOn(validation, 'validateConfig')
+      .mockResolvedValueOnce({
+        warnings: [],
+        errors: [],
+      })
+      .mockResolvedValueOnce({
+        warnings: [],
+        errors: [
+          {
+            message: 'some error',
+            topic: 'Configuration Error',
+          },
+        ],
+      });
+    presets.resolveConfigPresets.mockResolvedValue({
+      labels: ['test'],
+      automerge: true,
+    });
+    await expect(mergeInheritedConfig(config)).rejects.toThrow(
+      CONFIG_VALIDATION,
+    );
+    expect(logger.warn).toHaveBeenCalledWith(
+      {
+        errors: [
+          {
+            message: 'some error',
+            topic: 'Configuration Error',
+          },
+        ],
+      },
+      'Found errors in presets inside the inherited configuration.',
+    );
+  });
+
+  it('should remove global config from presets found in inherited config', async () => {
+    platform.getRawFile.mockResolvedValue(
+      '{"labels":["test"],"extends":[":automergeAll"]}',
+    );
+    jest.spyOn(validation, 'validateConfig').mockResolvedValue({
+      warnings: [],
+      errors: [],
+    });
+    presets.resolveConfigPresets.mockResolvedValue({
+      labels: ['test'],
+      automerge: true,
+      binarySource: 'docker', // global config option: should not be here
+    });
+    const res = await mergeInheritedConfig(config);
+    expect(res.labels).toEqual(['test']);
+    expect(logger.warn).not.toHaveBeenCalled();
+    expect(logger.debug).toHaveBeenCalledWith(
+      {
+        inheritedConfig: {
+          labels: ['test'],
+          automerge: true,
+          binarySource: 'docker',
+        },
+        filteredConfig: {
+          labels: ['test'],
+          automerge: true,
+        },
+      },
+      'Removed global config from inherited config presets.',
+    );
+  });
 });
diff --git a/lib/workers/repository/init/inherited.ts b/lib/workers/repository/init/inherited.ts
index f25be4f3f3..d924ff825b 100644
--- a/lib/workers/repository/init/inherited.ts
+++ b/lib/workers/repository/init/inherited.ts
@@ -2,6 +2,7 @@ import is from '@sindresorhus/is';
 import { dequal } from 'dequal';
 import { mergeChildConfig, removeGlobalConfig } from '../../../config';
 import { parseFileConfig } from '../../../config/parse';
+import { resolveConfigPresets } from '../../../config/presets';
 import type { RenovateConfig } from '../../../config/types';
 import { validateConfig } from '../../../config/validation';
 import {
@@ -92,12 +93,49 @@ export async function mergeInheritedConfig(
       'Found warnings in inherited configuration.',
     );
   }
-  const filteredConfig = removeGlobalConfig(inheritedConfig, true);
+  let filteredConfig = removeGlobalConfig(inheritedConfig, true);
   if (!dequal(inheritedConfig, filteredConfig)) {
     logger.debug(
       { inheritedConfig, filteredConfig },
       'Removed global config from inherited config.',
     );
   }
+
+  if (is.nullOrUndefined(filteredConfig.extends)) {
+    return mergeChildConfig(config, filteredConfig);
+  }
+
+  logger.debug('Resolving presets found in inherited config');
+  const resolvedConfig = await resolveConfigPresets(
+    filteredConfig,
+    config,
+    config.ignorePresets,
+  );
+  logger.trace({ config: resolvedConfig }, 'Resolved inherited config');
+
+  const validationRes = await validateConfig('inherit', resolvedConfig);
+  if (validationRes.errors.length) {
+    logger.warn(
+      { errors: validationRes.errors },
+      'Found errors in presets inside the inherited configuration.',
+    );
+    throw new Error(CONFIG_VALIDATION);
+  }
+  if (validationRes.warnings.length) {
+    logger.warn(
+      { warnings: validationRes.warnings },
+      'Found warnings in presets inside the inherited configuration.',
+    );
+  }
+
+  // remove global config options once again, as resolved presets could have added some
+  filteredConfig = removeGlobalConfig(resolvedConfig, true);
+  if (!dequal(resolvedConfig, filteredConfig)) {
+    logger.debug(
+      { inheritedConfig: resolvedConfig, filteredConfig },
+      'Removed global config from inherited config presets.',
+    );
+  }
+
   return mergeChildConfig(config, filteredConfig);
 }
-- 
GitLab