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