From aa7f1cb95293c94d632a333e5973ce6c4c53cc18 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Wed, 23 Jun 2021 22:19:14 +0200
Subject: [PATCH] feat: minimumConfidence (experimental, non-public) (#10313)

---
 lib/config/definitions.ts                     |  11 ++
 lib/config/validation.ts                      |   1 +
 lib/util/merge-confidence/index.spec.ts       | 177 ++++++++++++++++++
 lib/util/merge-confidence/index.ts            |  91 +++++++++
 .../branch/__snapshots__/index.spec.ts.snap   |  16 ++
 lib/workers/branch/index.spec.ts              |  32 ++++
 lib/workers/branch/index.ts                   |  44 ++++-
 lib/workers/branch/status-checks.spec.ts      |  50 ++++-
 lib/workers/branch/status-checks.ts           |  27 +++
 .../__snapshots__/filter-checks.spec.ts.snap  |  24 +++
 .../process/lookup/filter-checks.spec.ts      |  51 +++--
 .../process/lookup/filter-checks.ts           |  40 +++-
 .../repository/process/lookup/index.ts        |  13 +-
 .../repository/process/lookup/types.ts        |   1 +
 lib/workers/types.ts                          |   3 +-
 15 files changed, 550 insertions(+), 31 deletions(-)
 create mode 100644 lib/util/merge-confidence/index.spec.ts
 create mode 100644 lib/util/merge-confidence/index.ts

diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts
index 598f91dacb..b59c9ae23f 100644
--- a/lib/config/definitions.ts
+++ b/lib/config/definitions.ts
@@ -1195,6 +1195,17 @@ const options: RenovateOptions[] = [
     type: 'integer',
     default: 0,
   },
+  /*
+   * Undocumented experimental feature
+  {
+    name: 'minimumConfidence',
+    description:
+      'Minimum Merge confidence level to filter by. Requires authentication to work.',
+    type: 'string',
+    allowedValues: ['low', 'neutral', 'high', 'very high'],
+    default: 'low',
+  },
+  */
   {
     name: 'internalChecksFilter',
     description: 'When/how to filter based on internal checks.',
diff --git a/lib/config/validation.ts b/lib/config/validation.ts
index 2cf5b2adc4..d6e8e97d14 100644
--- a/lib/config/validation.ts
+++ b/lib/config/validation.ts
@@ -35,6 +35,7 @@ const ignoredNodes = [
   'isVulnerabilityAlert',
   'copyLocalLibs', // deprecated - functionality is now enabled by default
   'prBody', // deprecated
+  'minimumConfidence', // undocumented feature flag
 ];
 
 function isManagerPath(parentPath: string): boolean {
diff --git a/lib/util/merge-confidence/index.spec.ts b/lib/util/merge-confidence/index.spec.ts
new file mode 100644
index 0000000000..ca3eda4652
--- /dev/null
+++ b/lib/util/merge-confidence/index.spec.ts
@@ -0,0 +1,177 @@
+import * as httpMock from '../../../test/http-mock';
+import { getName } from '../../../test/util';
+import * as memCache from '../cache/memory';
+import * as hostRules from '../host-rules';
+import {
+  getMergeConfidenceLevel,
+  isActiveConfidenceLevel,
+  satisfiesConfidenceLevel,
+} from '.';
+
+describe(getName(), () => {
+  describe('isActiveConfidenceLevel()', () => {
+    it('returns false if null', () => {
+      expect(isActiveConfidenceLevel(null)).toBe(false);
+    });
+
+    it('returns false if low', () => {
+      expect(isActiveConfidenceLevel('low')).toBe(false);
+    });
+
+    it('returns false if nonsense', () => {
+      expect(isActiveConfidenceLevel('nonsense')).toBe(false);
+    });
+
+    it('returns true if valid value (high)', () => {
+      expect(isActiveConfidenceLevel('high')).toBe(true);
+    });
+  });
+
+  describe('satisfiesConfidenceLevel()', () => {
+    it('returns false if less', () => {
+      expect(satisfiesConfidenceLevel('low', 'high')).toBe(false);
+    });
+
+    it('returns true if equal', () => {
+      expect(satisfiesConfidenceLevel('high', 'high')).toBe(true);
+    });
+
+    it('returns true if more', () => {
+      expect(satisfiesConfidenceLevel('very high', 'high')).toBe(true);
+    });
+  });
+
+  describe('getMergeConfidenceLevel()', () => {
+    beforeEach(() => {
+      hostRules.clear();
+      memCache.reset();
+    });
+
+    it('returns neutral if undefined updateType', async () => {
+      expect(
+        await getMergeConfidenceLevel(
+          'npm',
+          'renovate',
+          '25.0.0',
+          '25.0.0',
+          undefined
+        )
+      ).toBe('neutral');
+    });
+
+    it('returns neutral if irrelevant updateType', async () => {
+      expect(
+        await getMergeConfidenceLevel(
+          'npm',
+          'renovate',
+          '24.1.0',
+          '25.0.0',
+          'bump'
+        )
+      ).toBe('neutral');
+    });
+
+    it('returns high if pinning', async () => {
+      expect(
+        await getMergeConfidenceLevel(
+          'npm',
+          'renovate',
+          '25.0.1',
+          '25.0.1',
+          'pin'
+        )
+      ).toBe('high');
+    });
+
+    it('returns neutral if no token', async () => {
+      expect(
+        await getMergeConfidenceLevel(
+          'npm',
+          'renovate',
+          '24.2.0',
+          '25.0.0',
+          'major'
+        )
+      ).toBe('neutral');
+    });
+
+    it('returns valid confidence level', async () => {
+      hostRules.add({ hostType: 'merge-confidence', token: 'abc123' });
+      const datasource = 'npm';
+      const depName = 'renovate';
+      const currentVersion = '24.3.0';
+      const newVersion = '25.0.0';
+      httpMock
+        .scope('https://badges.renovateapi.com')
+        .get(
+          `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}`
+        )
+        .reply(200, { confidence: 'high' });
+      expect(
+        await getMergeConfidenceLevel(
+          datasource,
+          depName,
+          currentVersion,
+          newVersion,
+          'major'
+        )
+      ).toBe('high');
+    });
+
+    it('returns neutral if invalid confidence level', async () => {
+      hostRules.add({ hostType: 'merge-confidence', token: 'abc123' });
+      const datasource = 'npm';
+      const depName = 'renovate';
+      const currentVersion = '25.0.0';
+      const newVersion = '25.1.0';
+      httpMock
+        .scope('https://badges.renovateapi.com')
+        .get(
+          `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}`
+        )
+        .reply(200, { nope: 'nope' });
+      expect(
+        await getMergeConfidenceLevel(
+          datasource,
+          depName,
+          currentVersion,
+          newVersion,
+          'minor'
+        )
+      ).toBe('neutral');
+    });
+
+    it('returns neutral if exception from API', async () => {
+      hostRules.add({ hostType: 'merge-confidence', token: 'abc123' });
+      const datasource = 'npm';
+      const depName = 'renovate';
+      const currentVersion = '25.0.0';
+      const newVersion = '25.4.0';
+      httpMock
+        .scope('https://badges.renovateapi.com')
+        .get(
+          `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}`
+        )
+        .reply(403);
+      expect(
+        await getMergeConfidenceLevel(
+          datasource,
+          depName,
+          currentVersion,
+          newVersion,
+          'minor'
+        )
+      ).toBe('neutral');
+      // memory cache
+      expect(
+        await getMergeConfidenceLevel(
+          datasource,
+          depName + '-new',
+          currentVersion,
+          newVersion,
+          'minor'
+        )
+      ).toBe('neutral');
+    });
+  });
+});
diff --git a/lib/util/merge-confidence/index.ts b/lib/util/merge-confidence/index.ts
new file mode 100644
index 0000000000..f259bb13ea
--- /dev/null
+++ b/lib/util/merge-confidence/index.ts
@@ -0,0 +1,91 @@
+import type { UpdateType } from '../../config/types';
+import { logger } from '../../logger';
+import * as memCache from '../cache/memory';
+import * as packageCache from '../cache/package';
+import * as hostRules from '../host-rules';
+import { Http } from '../http';
+
+const http = new Http('merge-confidence');
+
+const MERGE_CONFIDENCE = ['low', 'neutral', 'high', 'very high'];
+type MergeConfidenceTuple = typeof MERGE_CONFIDENCE;
+export type MergeConfidence = MergeConfidenceTuple[number];
+
+export const confidenceLevels: Record<MergeConfidence, number> = {
+  low: -1,
+  neutral: 0,
+  high: 1,
+  'very high': 2,
+};
+
+export function isActiveConfidenceLevel(confidence: string): boolean {
+  return confidence !== 'low' && MERGE_CONFIDENCE.includes(confidence);
+}
+
+export function satisfiesConfidenceLevel(
+  confidence: MergeConfidence,
+  minimumConfidence: MergeConfidence
+): boolean {
+  return confidenceLevels[confidence] >= confidenceLevels[minimumConfidence];
+}
+
+const updateTypeConfidenceMapping: Record<UpdateType, MergeConfidence> = {
+  pin: 'high',
+  digest: 'neutral',
+  bump: 'neutral',
+  lockFileMaintenance: 'neutral',
+  lockfileUpdate: 'neutral',
+  rollback: 'neutral',
+  major: null,
+  minor: null,
+  patch: null,
+};
+
+export async function getMergeConfidenceLevel(
+  datasource: string,
+  depName: string,
+  currentVersion: string,
+  newVersion: string,
+  updateType: UpdateType
+): Promise<MergeConfidence> {
+  if (!(currentVersion && newVersion && updateType)) {
+    return 'neutral';
+  }
+  const mappedConfidence = updateTypeConfidenceMapping[updateType];
+  if (mappedConfidence) {
+    return mappedConfidence;
+  }
+  const { token } = hostRules.find({
+    hostType: 'merge-confidence',
+    url: 'https://badges.renovateapi.com',
+  });
+  if (!token) {
+    logger.warn('No Merge Confidence API token found');
+    return 'neutral';
+  }
+  // istanbul ignore if
+  if (memCache.get('merge-confidence-invalid-token')) {
+    return 'neutral';
+  }
+  const url = `https://badges.renovateapi.com/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}`;
+  const cachedResult = await packageCache.get('merge-confidence', token + url);
+  // istanbul ignore if
+  if (cachedResult) {
+    return cachedResult;
+  }
+  let confidence = 'neutral';
+  try {
+    const res = (await http.getJson<{ confidence: MergeConfidence }>(url)).body;
+    if (MERGE_CONFIDENCE.includes(res.confidence)) {
+      confidence = res.confidence;
+    }
+  } catch (err) {
+    logger.debug({ err }, 'Error fetching merge confidence');
+    if (err.statusCode === 403) {
+      memCache.set('merge-confidence-invalid-token', true);
+      logger.warn('Merge Confidence API token rejected');
+    }
+  }
+  await packageCache.set('merge-confidence', token + url, confidence, 60);
+  return confidence;
+}
diff --git a/lib/workers/branch/__snapshots__/index.spec.ts.snap b/lib/workers/branch/__snapshots__/index.spec.ts.snap
index 567b322f4c..9e7a5a8c52 100644
--- a/lib/workers/branch/__snapshots__/index.spec.ts.snap
+++ b/lib/workers/branch/__snapshots__/index.spec.ts.snap
@@ -96,6 +96,14 @@ Object {
 }
 `;
 
+exports[`workers/branch/index processBranch processes branch if minimumConfidence is met 1`] = `
+Object {
+  "branchExists": false,
+  "prNo": undefined,
+  "result": "error",
+}
+`;
+
 exports[`workers/branch/index processBranch returns if PR creation failed 1`] = `
 Object {
   "branchExists": true,
@@ -208,6 +216,14 @@ Object {
 }
 `;
 
+exports[`workers/branch/index processBranch skips branch if minimumConfidence not met 1`] = `
+Object {
+  "branchExists": false,
+  "prNo": undefined,
+  "result": "error",
+}
+`;
+
 exports[`workers/branch/index processBranch skips branch if not scheduled and branch does not exist 1`] = `
 Object {
   "branchExists": false,
diff --git a/lib/workers/branch/index.spec.ts b/lib/workers/branch/index.spec.ts
index 418d9fdc90..22859091f2 100644
--- a/lib/workers/branch/index.spec.ts
+++ b/lib/workers/branch/index.spec.ts
@@ -17,6 +17,7 @@ import type { WriteExistingFilesResult } from '../../manager/npm/post-update/typ
 import { PrState } from '../../types';
 import * as _exec from '../../util/exec';
 import { File, StatusResult } from '../../util/git';
+import * as _mergeConfidence from '../../util/merge-confidence';
 import * as _sanitize from '../../util/sanitize';
 import * as _limits from '../global/limits';
 import * as _prWorker from '../pr';
@@ -44,6 +45,7 @@ jest.mock('./commit');
 jest.mock('../pr');
 jest.mock('../pr/automerge');
 jest.mock('../../util/exec');
+jest.mock('../../util/merge-confidence');
 jest.mock('../../util/sanitize');
 jest.mock('../../util/git');
 jest.mock('fs-extra');
@@ -56,6 +58,7 @@ const reuse = mocked(_reuse);
 const npmPostExtract = mocked(_npmPostExtract);
 const automerge = mocked(_automerge);
 const commit = mocked(_commit);
+const mergeConfidence = mocked(_mergeConfidence);
 const prAutomerge = mocked(_prAutomerge);
 const prWorker = mocked(_prWorker);
 const exec = mocked(_exec);
@@ -154,6 +157,35 @@ describe(getName(), () => {
       const res = await branchWorker.processBranch(config);
       expect(res).toMatchSnapshot();
     });
+
+    it('skips branch if minimumConfidence not met', async () => {
+      schedule.isScheduledNow.mockReturnValueOnce(true);
+      config.prCreation = 'not-pending';
+      (config.upgrades as Partial<BranchUpgradeConfig>[]) = [
+        {
+          minimumConfidence: 'high',
+        },
+      ];
+      mergeConfidence.isActiveConfidenceLevel.mockReturnValue(true);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(false);
+      const res = await branchWorker.processBranch(config);
+      expect(res).toMatchSnapshot();
+    });
+
+    it('processes branch if minimumConfidence is met', async () => {
+      schedule.isScheduledNow.mockReturnValueOnce(true);
+      config.prCreation = 'not-pending';
+      (config.upgrades as Partial<BranchUpgradeConfig>[]) = [
+        {
+          minimumConfidence: 'high',
+        },
+      ];
+      mergeConfidence.isActiveConfidenceLevel.mockReturnValue(true);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(true);
+      const res = await branchWorker.processBranch(config);
+      expect(res).toMatchSnapshot();
+    });
+
     it('processes branch if not scheduled but updating out of schedule', async () => {
       schedule.isScheduledNow.mockReturnValueOnce(false);
       config.updateNotScheduled = true;
diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts
index cfbe2ebe75..5a871a46d6 100644
--- a/lib/workers/branch/index.ts
+++ b/lib/workers/branch/index.ts
@@ -27,6 +27,11 @@ import {
   branchExists as gitBranchExists,
   isBranchModified,
 } from '../../util/git';
+import {
+  getMergeConfidenceLevel,
+  isActiveConfidenceLevel,
+  satisfiesConfidenceLevel,
+} from '../../util/merge-confidence';
 import { Limit, isLimitReached } from '../global/limits';
 import { ensurePr, getPlatformPrOptions } from '../pr';
 import { checkAutoMerge } from '../pr/automerge';
@@ -39,7 +44,7 @@ import { getUpdatedPackageFiles } from './get-updated';
 import { handlepr } from './handle-existing';
 import { shouldReuseExistingBranch } from './reuse';
 import { isScheduledNow } from './schedule';
-import { setStability } from './status-checks';
+import { setConfidence, setStability } from './status-checks';
 
 function rebaseCheck(config: RenovateConfig, branchPr: Pr): boolean {
   const titleRebase = branchPr.title?.startsWith('rebase!');
@@ -260,7 +265,9 @@ export async function processBranch(
 
     if (
       config.upgrades.some(
-        (upgrade) => upgrade.stabilityDays && upgrade.releaseTimestamp
+        (upgrade) =>
+          (upgrade.stabilityDays && upgrade.releaseTimestamp) ||
+          isActiveConfidenceLevel(upgrade.minimumConfidence)
       )
     ) {
       // Only set a stability status check if one or more of the updates contain
@@ -280,6 +287,34 @@ export async function processBranch(
               'Update has not passed stability days'
             );
             config.stabilityStatus = BranchStatus.yellow;
+            continue; // eslint-disable-line no-continue
+          }
+        }
+        const {
+          datasource,
+          depName,
+          minimumConfidence,
+          updateType,
+          currentVersion,
+          newVersion,
+        } = upgrade;
+        if (isActiveConfidenceLevel(minimumConfidence)) {
+          const confidence = await getMergeConfidenceLevel(
+            datasource,
+            depName,
+            currentVersion,
+            newVersion,
+            updateType
+          );
+          if (satisfiesConfidenceLevel(confidence, minimumConfidence)) {
+            config.confidenceStatus = BranchStatus.green;
+          } else {
+            logger.debug(
+              { depName, confidence, minimumConfidence },
+              'Update does not meet minimum confidence scores'
+            );
+            config.confidenceStatus = BranchStatus.yellow;
+            continue; // eslint-disable-line no-continue
           }
         }
       }
@@ -290,7 +325,9 @@ export async function processBranch(
         config.stabilityStatus === BranchStatus.yellow &&
         ['not-pending', 'status-success'].includes(config.prCreation)
       ) {
-        logger.debug('Skipping branch creation due to stability days not met');
+        logger.debug(
+          'Skipping branch creation due to internal status checks not met'
+        );
         return {
           branchExists,
           prNo: branchPr?.number,
@@ -420,6 +457,7 @@ export async function processBranch(
     }
     // Set branch statuses
     await setStability(config);
+    await setConfidence(config);
 
     // break if we pushed a new commit because status check are pretty sure pending but maybe not reported yet
     if (
diff --git a/lib/workers/branch/status-checks.spec.ts b/lib/workers/branch/status-checks.spec.ts
index 7123d978da..c85c9673e8 100644
--- a/lib/workers/branch/status-checks.spec.ts
+++ b/lib/workers/branch/status-checks.spec.ts
@@ -1,6 +1,11 @@
 import { defaultConfig, getName, platform } from '../../../test/util';
 import { BranchStatus } from '../../types';
-import { StabilityConfig, setStability } from './status-checks';
+import {
+  ConfidenceConfig,
+  StabilityConfig,
+  setConfidence,
+  setStability,
+} from './status-checks';
 
 describe(getName(), () => {
   describe('setStability', () => {
@@ -38,4 +43,47 @@ describe(getName(), () => {
       expect(platform.setBranchStatus).toHaveBeenCalledTimes(0);
     });
   });
+
+  describe('setConfidence', () => {
+    let config: ConfidenceConfig;
+    beforeEach(() => {
+      config = {
+        ...defaultConfig,
+        branchName: 'renovate/some-branch',
+      };
+    });
+    afterEach(() => {
+      jest.resetAllMocks();
+    });
+
+    it('returns if not configured', async () => {
+      await setConfidence(config);
+      expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(0);
+    });
+
+    it('sets status yellow', async () => {
+      config.minimumConfidence = 'high';
+      config.confidenceStatus = BranchStatus.yellow;
+      await setConfidence(config);
+      expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1);
+      expect(platform.setBranchStatus).toHaveBeenCalledTimes(1);
+    });
+
+    it('sets status green', async () => {
+      config.minimumConfidence = 'high';
+      config.confidenceStatus = BranchStatus.green;
+      await setConfidence(config);
+      expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1);
+      expect(platform.setBranchStatus).toHaveBeenCalledTimes(1);
+    });
+
+    it('skips status if already set', async () => {
+      config.minimumConfidence = 'high';
+      config.confidenceStatus = BranchStatus.green;
+      platform.getBranchStatusCheck.mockResolvedValueOnce(BranchStatus.green);
+      await setConfidence(config);
+      expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1);
+      expect(platform.setBranchStatus).toHaveBeenCalledTimes(0);
+    });
+  });
 });
diff --git a/lib/workers/branch/status-checks.ts b/lib/workers/branch/status-checks.ts
index 58313744f8..97ec3eb769 100644
--- a/lib/workers/branch/status-checks.ts
+++ b/lib/workers/branch/status-checks.ts
@@ -2,6 +2,10 @@ import type { RenovateConfig } from '../../config/types';
 import { logger } from '../../logger';
 import { platform } from '../../platform';
 import { BranchStatus } from '../../types';
+import {
+  MergeConfidence,
+  isActiveConfidenceLevel,
+} from '../../util/merge-confidence';
 
 async function setStatusCheck(
   branchName: string,
@@ -50,3 +54,26 @@ export async function setStability(config: StabilityConfig): Promise<void> {
     config.productLinks.documentation
   );
 }
+
+export type ConfidenceConfig = RenovateConfig & {
+  confidenceStatus?: BranchStatus;
+  minimumConfidence?: MergeConfidence;
+};
+
+export async function setConfidence(config: ConfidenceConfig): Promise<void> {
+  if (!isActiveConfidenceLevel(config.minimumConfidence)) {
+    return;
+  }
+  const context = `renovate/merge-confidence`;
+  const description =
+    config.confidenceStatus === BranchStatus.green
+      ? 'Updates have met Merge Confidence requirement'
+      : 'Updates have not met Merge Confidence requirement';
+  await setStatusCheck(
+    config.branchName,
+    context,
+    description,
+    config.confidenceStatus,
+    config.productLinks.documentation
+  );
+}
diff --git a/lib/workers/repository/process/lookup/__snapshots__/filter-checks.spec.ts.snap b/lib/workers/repository/process/lookup/__snapshots__/filter-checks.spec.ts.snap
index 045360b8b2..66f3ea6ff3 100644
--- a/lib/workers/repository/process/lookup/__snapshots__/filter-checks.spec.ts.snap
+++ b/lib/workers/repository/process/lookup/__snapshots__/filter-checks.spec.ts.snap
@@ -1,5 +1,29 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`workers/repository/process/lookup/filter-checks .filterInternalChecks() picks up minimumConfidence settings from updateType 1`] = `
+Object {
+  "pendingChecks": false,
+  "pendingReleases": Array [
+    Object {
+      "releaseTimestamp": "2021-01-03T00:00:00.000Z",
+      "version": "1.0.2",
+    },
+    Object {
+      "releaseTimestamp": "2021-01-05T00:00:00.000Z",
+      "version": "1.0.3",
+    },
+    Object {
+      "releaseTimestamp": "2021-01-07T00:00:00.000Z",
+      "version": "1.0.4",
+    },
+  ],
+  "release": Object {
+    "releaseTimestamp": "2021-01-01T00:00:01.000Z",
+    "version": "1.0.1",
+  },
+}
+`;
+
 exports[`workers/repository/process/lookup/filter-checks .filterInternalChecks() picks up stabilityDays settings from hostRules 1`] = `
 Object {
   "pendingChecks": false,
diff --git a/lib/workers/repository/process/lookup/filter-checks.spec.ts b/lib/workers/repository/process/lookup/filter-checks.spec.ts
index 7ba7c0bb02..fd63fe53c3 100644
--- a/lib/workers/repository/process/lookup/filter-checks.spec.ts
+++ b/lib/workers/repository/process/lookup/filter-checks.spec.ts
@@ -2,13 +2,16 @@ import { getConfig, getName, mocked } from '../../../../../test/util';
 import type { Release } from '../../../../datasource';
 import { clone } from '../../../../util/clone';
 import * as _dateUtil from '../../../../util/date';
+import * as _mergeConfidence from '../../../../util/merge-confidence';
 import * as allVersioning from '../../../../versioning';
 import { filterInternalChecks } from './filter-checks';
 import type { LookupUpdateConfig, UpdateResult } from './types';
 
 jest.mock('../../../../util/date');
+jest.mock('../../../../util/merge-confidence');
 
 const dateUtil = mocked(_dateUtil);
+const mergeConfidence = mocked(_mergeConfidence);
 
 let config: Partial<LookupUpdateConfig & UpdateResult>;
 
@@ -47,8 +50,8 @@ describe(getName(), () => {
   });
 
   describe('.filterInternalChecks()', () => {
-    it('returns latest release if internalChecksFilter=none', () => {
-      const res = filterInternalChecks(
+    it('returns latest release if internalChecksFilter=none', async () => {
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -60,10 +63,10 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.4');
     });
 
-    it('returns non-pending latest release if internalChecksFilter=flexible and none pass checks', () => {
+    it('returns non-pending latest release if internalChecksFilter=flexible and none pass checks', async () => {
       config.internalChecksFilter = 'flexible';
       config.stabilityDays = 10;
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -75,10 +78,10 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.4');
     });
 
-    it('returns pending latest release if internalChecksFilter=strict and none pass checks', () => {
+    it('returns pending latest release if internalChecksFilter=strict and none pass checks', async () => {
       config.internalChecksFilter = 'strict';
       config.stabilityDays = 10;
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -90,10 +93,10 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.4');
     });
 
-    it('returns non-latest release if internalChecksFilter=strict and some pass checks', () => {
+    it('returns non-latest release if internalChecksFilter=strict and some pass checks', async () => {
       config.internalChecksFilter = 'strict';
       config.stabilityDays = 6;
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -105,10 +108,10 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.2');
     });
 
-    it('returns non-latest release if internalChecksFilter=flexible and some pass checks', () => {
+    it('returns non-latest release if internalChecksFilter=flexible and some pass checks', async () => {
       config.internalChecksFilter = 'strict';
       config.stabilityDays = 6;
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -120,11 +123,11 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.2');
     });
 
-    it('picks up stabilityDays settings from hostRules', () => {
+    it('picks up stabilityDays settings from hostRules', async () => {
       config.internalChecksFilter = 'strict';
       config.stabilityDays = 6;
       config.packageRules = [{ matchUpdateTypes: ['patch'], stabilityDays: 1 }];
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -136,10 +139,10 @@ describe(getName(), () => {
       expect(res.release.version).toEqual('1.0.4');
     });
 
-    it('picks up stabilityDays settings from updateType', () => {
+    it('picks up stabilityDays settings from updateType', async () => {
       config.internalChecksFilter = 'strict';
       config.patch = { stabilityDays: 4 };
-      const res = filterInternalChecks(
+      const res = await filterInternalChecks(
         config,
         versioning,
         'patch',
@@ -150,5 +153,25 @@ describe(getName(), () => {
       expect(res.pendingReleases).toHaveLength(1);
       expect(res.release.version).toEqual('1.0.3');
     });
+
+    it('picks up minimumConfidence settings from updateType', async () => {
+      config.internalChecksFilter = 'strict';
+      config.minimumConfidence = 'high';
+      mergeConfidence.isActiveConfidenceLevel.mockReturnValue(true);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(false);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(false);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(false);
+      mergeConfidence.satisfiesConfidenceLevel.mockReturnValueOnce(true);
+      const res = await filterInternalChecks(
+        config,
+        versioning,
+        'patch',
+        sortedReleases
+      );
+      expect(res).toMatchSnapshot();
+      expect(res.pendingChecks).toBe(false);
+      expect(res.pendingReleases).toHaveLength(3);
+      expect(res.release.version).toEqual('1.0.1');
+    });
   });
 });
diff --git a/lib/workers/repository/process/lookup/filter-checks.ts b/lib/workers/repository/process/lookup/filter-checks.ts
index f93d7e32e8..1208a7d716 100644
--- a/lib/workers/repository/process/lookup/filter-checks.ts
+++ b/lib/workers/repository/process/lookup/filter-checks.ts
@@ -3,6 +3,11 @@ import { mergeChildConfig } from '../../../../config';
 import type { Release } from '../../../../datasource';
 import { logger } from '../../../../logger';
 import { getElapsedDays } from '../../../../util/date';
+import {
+  getMergeConfidenceLevel,
+  isActiveConfidenceLevel,
+  satisfiesConfidenceLevel,
+} from '../../../../util/merge-confidence';
 import { applyPackageRules } from '../../../../util/package-rules';
 import type { VersioningApi } from '../../../../versioning';
 import type { LookupUpdateConfig, UpdateResult } from './types';
@@ -14,18 +19,18 @@ export interface InternalChecksResult {
   pendingReleases?: Release[];
 }
 
-export function filterInternalChecks(
+export async function filterInternalChecks(
   config: Partial<LookupUpdateConfig & UpdateResult>,
   versioning: VersioningApi,
   bucket: string,
   sortedReleases: Release[]
-): InternalChecksResult {
-  const { currentVersion, depName, internalChecksFilter } = config;
+): Promise<InternalChecksResult> {
+  const { currentVersion, datasource, depName, internalChecksFilter } = config;
   let release: Release;
   let pendingChecks = false;
   let pendingReleases: Release[] = [];
   if (internalChecksFilter === 'none') {
-    // Don't care if stabilityDays are unmet
+    // Don't care if stabilityDays or minimumConfidence are unmet
     release = sortedReleases.pop();
   } else {
     // iterate through releases from highest to lowest, looking for the first which will pass checks if present
@@ -46,12 +51,35 @@ export function filterInternalChecks(
       // Apply packageRules in case any apply to updateType
       releaseConfig = applyPackageRules(releaseConfig);
       // Now check for a stabilityDays config
-      const { stabilityDays, releaseTimestamp } = releaseConfig;
+      const {
+        minimumConfidence,
+        stabilityDays,
+        releaseTimestamp,
+        version: newVersion,
+        updateType,
+      } = releaseConfig;
       if (is.integer(stabilityDays) && releaseTimestamp) {
         if (getElapsedDays(releaseTimestamp) < stabilityDays) {
           // Skip it if it doesn't pass checks
           logger.debug(
-            { depName },
+            { depName, check: 'stabilityDays' },
+            `Release ${candidateRelease.version} is pending status checks`
+          );
+          pendingReleases.unshift(candidateRelease);
+          continue; // eslint-disable-line no-continue
+        }
+      }
+      if (isActiveConfidenceLevel(minimumConfidence)) {
+        const confidenceLevel = await getMergeConfidenceLevel(
+          datasource,
+          depName,
+          currentVersion,
+          newVersion,
+          updateType
+        );
+        if (!satisfiesConfidenceLevel(confidenceLevel, minimumConfidence)) {
+          logger.debug(
+            { depName, check: 'minimumConfidence' },
             `Release ${candidateRelease.version} is pending status checks`
           );
           pendingReleases.unshift(candidateRelease);
diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts
index b60c91f4ba..e520d15dbc 100644
--- a/lib/workers/repository/process/lookup/index.ts
+++ b/lib/workers/repository/process/lookup/index.ts
@@ -216,12 +216,13 @@ export async function lookupUpdates(
       const sortedReleases = releases.sort((r1, r2) =>
         versioning.sortVersions(r1.version, r2.version)
       );
-      const { release, pendingChecks, pendingReleases } = filterInternalChecks(
-        depResultConfig,
-        versioning,
-        bucket,
-        sortedReleases
-      );
+      const { release, pendingChecks, pendingReleases } =
+        await filterInternalChecks(
+          depResultConfig,
+          versioning,
+          bucket,
+          sortedReleases
+        );
       // istanbul ignore next
       if (!release) {
         return res;
diff --git a/lib/workers/repository/process/lookup/types.ts b/lib/workers/repository/process/lookup/types.ts
index 176f8667df..104ce7fbea 100644
--- a/lib/workers/repository/process/lookup/types.ts
+++ b/lib/workers/repository/process/lookup/types.ts
@@ -39,6 +39,7 @@ export interface LookupUpdateConfig
   separateMultipleMajor?: boolean;
   datasource: string;
   depName: string;
+  minimumConfidence?: string;
 }
 
 export interface UpdateResult {
diff --git a/lib/workers/types.ts b/lib/workers/types.ts
index 248d451352..401464f966 100644
--- a/lib/workers/types.ts
+++ b/lib/workers/types.ts
@@ -15,6 +15,7 @@ import type {
 } from '../manager/types';
 import type { PlatformPrOptions } from '../platform/types';
 import type { File } from '../util/git';
+import type { MergeConfidence } from '../util/merge-confidence';
 import type { ChangeLogResult } from './pr/changelog/types';
 
 export interface BranchUpgradeConfig
@@ -52,7 +53,7 @@ export interface BranchUpgradeConfig
   releases?: Release[];
   releaseTimestamp?: string;
   repoName?: string;
-
+  minimumConfidence?: MergeConfidence;
   sourceDirectory?: string;
 
   updatedPackageFiles?: File[];
-- 
GitLab