diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index d0d5738c2140b2fcae50028a2a19b36fe1887ff7..c10ce7dec5ec7cc33fb4e9c5b771c8fc52013495 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -206,6 +206,23 @@ If so then Renovate will reflect this setting in its description and use package Configuring this to `true` means that Renovate will detect and apply the default reviewers rules to PRs (Bitbucket only). +## branchConcurrentLimit + +By default, Renovate won't enforce any concurrent branch limits. If you want the same limit for both concurrent branches +and concurrent PRs, then just set a value for `prConcurrentLimit` and it will be reused for branch calculations too. +However, if you want to allow more concurrent branches than concurrent PRs, you can configure both values ( +e.g. `branchConcurrentLimit=5` and `prConcurrentLimit=3`). + +This limit is enforced on a per-repository basis. + +Example config: + +```json +{ + "branchConcurrentLimit": 3 +} +``` + ## branchName Warning: it's strongly recommended not to configure this field directly. diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index b705862c1a963b40adfa5965fdb6b1ae20e647db..f4c66a57d99fcabe671dba8ffb3261759c503816 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -1186,6 +1186,13 @@ const options: RenovateOptions[] = [ type: 'integer', default: 0, // no limit }, + { + name: 'branchConcurrentLimit', + description: + 'Limit to a maximum of x concurrent branches. 0 means no limit, `null` (default) inherits value from `prConcurrentLimit`.', + type: 'integer', + default: null, // inherit prConcurrentLimit + }, { name: 'prPriority', description: diff --git a/lib/workers/branch/index.spec.ts b/lib/workers/branch/index.spec.ts index a1c920a41bec671c117b2452dda1659dc8b22c47..d73f0ca77dcdceed7530a248d903cbe3a8a465fe 100644 --- a/lib/workers/branch/index.spec.ts +++ b/lib/workers/branch/index.spec.ts @@ -216,7 +216,7 @@ describe('workers/branch', () => { const res = await branchWorker.processBranch(config); expect(res).toEqual(ProcessBranchResult.PrEdited); }); - it('returns if pr creation limit exceeded', async () => { + it('returns if branch creation limit exceeded', async () => { getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ ...updatedPackageFiles, }); @@ -227,7 +227,7 @@ describe('workers/branch', () => { limits.isLimitReached.mockReturnValueOnce(true); limits.isLimitReached.mockReturnValueOnce(false); expect(await branchWorker.processBranch(config)).toEqual( - ProcessBranchResult.PrLimitReached + ProcessBranchResult.BranchLimitReached ); }); it('returns if pr creation limit exceeded and branch exists', async () => { diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts index ba15f4943e580f6eac61a474cca22946a01f77ec..8a46fe8da5ef5f164ec88a0dc09d0096b1c2d790 100644 --- a/lib/workers/branch/index.ts +++ b/lib/workers/branch/index.ts @@ -152,12 +152,12 @@ export async function processBranch( } if ( !branchExists && - isLimitReached(Limit.PullRequests) && + isLimitReached(Limit.Branches) && !dependencyDashboardCheck && !config.vulnerabilityAlert ) { - logger.debug('Reached PR limit - skipping branch creation'); - return ProcessBranchResult.PrLimitReached; + logger.debug('Reached branch limit - skipping branch creation'); + return ProcessBranchResult.BranchLimitReached; } if ( isLimitReached(Limit.Commits) && diff --git a/lib/workers/common.ts b/lib/workers/common.ts index bc378100beb4727f5fc19d8bd7144a500bd5be22..ae41f5553623ed164a994f9d2231bf92b634f298 100644 --- a/lib/workers/common.ts +++ b/lib/workers/common.ts @@ -94,6 +94,7 @@ export enum ProcessBranchResult { PrEdited = 'pr-edited', PrLimitReached = 'pr-limit-reached', CommitLimitReached = 'commit-limit-reached', + BranchLimitReached = 'branch-limit-reached', Rebase = 'rebase', } diff --git a/lib/workers/global/limits.ts b/lib/workers/global/limits.ts index c5476f8922fb81b7c78843675fc278233142bc88..01c80148bc071efbff6c293a85b529654704f4df 100644 --- a/lib/workers/global/limits.ts +++ b/lib/workers/global/limits.ts @@ -3,6 +3,7 @@ import { logger } from '../../logger'; export enum Limit { Commits = 'Commits', PullRequests = 'PullRequests', + Branches = 'Branches', } interface LimitValue { diff --git a/lib/workers/pr/index.ts b/lib/workers/pr/index.ts index 6adc52e4eb1907d4fb673962fe9065b7b15fe4a1..2a6b63c3cda16ed9959df717f4715d4e319d90fc 100644 --- a/lib/workers/pr/index.ts +++ b/lib/workers/pr/index.ts @@ -16,7 +16,7 @@ import { } from '../../util/git'; import * as template from '../../util/template'; import { BranchConfig, PrResult } from '../common'; -import { Limit, isLimitReached } from '../global/limits'; +import { Limit, incLimitedValue, isLimitReached } from '../global/limits'; import { getPrBody } from './body'; import { ChangeLogError } from './changelog'; import { codeOwnersForPr } from './code-owners'; @@ -396,6 +396,7 @@ export async function ensurePr( platformOptions: getPlatformPrOptions(config), draftPR: config.draftPR, }); + incLimitedValue(Limit.PullRequests); logger.info({ pr: pr.number, prTitle }, 'PR created'); } } catch (err) /* istanbul ignore next */ { diff --git a/lib/workers/repository/dependency-dashboard.ts b/lib/workers/repository/dependency-dashboard.ts index dc09aef5e39f294fd0641cc19951f3accf2aa491..0e546c7c00140e760087062334a91caa331a7092 100644 --- a/lib/workers/repository/dependency-dashboard.ts +++ b/lib/workers/repository/dependency-dashboard.ts @@ -119,6 +119,7 @@ export async function ensureMasterIssue( } const rateLimited = branches.filter( (branch) => + branch.res === ProcessBranchResult.BranchLimitReached || branch.res === ProcessBranchResult.PrLimitReached || branch.res === ProcessBranchResult.CommitLimitReached ); @@ -185,6 +186,7 @@ export async function ensureMasterIssue( ProcessBranchResult.NotScheduled, ProcessBranchResult.PrLimitReached, ProcessBranchResult.CommitLimitReached, + ProcessBranchResult.BranchLimitReached, ProcessBranchResult.AlreadyExisted, ProcessBranchResult.Error, ProcessBranchResult.Automerged, diff --git a/lib/workers/repository/process/limits.spec.ts b/lib/workers/repository/process/limits.spec.ts index a268bec875ca18200a8583547a4f23d5bcfc311d..57045ba460d838850b1fa921fbacd34a6a31cc9f 100644 --- a/lib/workers/repository/process/limits.spec.ts +++ b/lib/workers/repository/process/limits.spec.ts @@ -1,9 +1,16 @@ import { DateTime } from 'luxon'; -import { RenovateConfig, getConfig, platform } from '../../../../test/util'; +import { + RenovateConfig, + getConfig, + git, + platform, +} from '../../../../test/util'; import { PrState } from '../../../types'; import { BranchConfig } from '../../common'; import * as limits from './limits'; +jest.mock('../../../util/git'); + let config: RenovateConfig; beforeEach(() => { jest.resetAllMocks(); @@ -82,4 +89,50 @@ describe('workers/repository/process/limits', () => { expect(res).toEqual(5); }); }); + + describe('getConcurrentBranchesRemaining()', () => { + it('calculates concurrent limit remaining', () => { + config.branchConcurrentLimit = 20; + git.branchExists.mockReturnValueOnce(true); + const res = limits.getConcurrentBranchesRemaining(config, [ + { branchName: 'foo' }, + ] as never); + expect(res).toEqual(19); + }); + it('defaults to prConcurrentLimit', () => { + config.branchConcurrentLimit = null; + config.prConcurrentLimit = 20; + git.branchExists.mockReturnValueOnce(true); + const res = limits.getConcurrentBranchesRemaining(config, [ + { branchName: 'foo' }, + ] as never); + expect(res).toEqual(19); + }); + it('does not use prConcurrentLimit for explicit branchConcurrentLimit=0', () => { + config.branchConcurrentLimit = 0; + config.prConcurrentLimit = 20; + const res = limits.getConcurrentBranchesRemaining(config, []); + expect(res).toEqual(99); + }); + it('returns 99 if no limits are set', () => { + const res = limits.getConcurrentBranchesRemaining(config, []); + expect(res).toEqual(99); + }); + it('returns prConcurrentLimit if errored', () => { + config.branchConcurrentLimit = 2; + const res = limits.getConcurrentBranchesRemaining(config, null); + expect(res).toEqual(2); + }); + }); + + describe('getBranchesRemaining()', () => { + it('returns concurrent branches', () => { + config.branchConcurrentLimit = 20; + git.branchExists.mockReturnValueOnce(true); + const res = limits.getBranchesRemaining(config, [ + { branchName: 'foo' }, + ] as never); + expect(res).toEqual(19); + }); + }); }); diff --git a/lib/workers/repository/process/limits.ts b/lib/workers/repository/process/limits.ts index 88ba4b53e1d69bdaf86ddfd7f3104a9a9c8ec7f3..536c959a84db2f9dc2cb5962762c8fe8feb97270 100644 --- a/lib/workers/repository/process/limits.ts +++ b/lib/workers/repository/process/limits.ts @@ -4,6 +4,7 @@ import { logger } from '../../../logger'; import { Pr, platform } from '../../../platform'; import { PrState } from '../../../types'; import { ExternalHostError } from '../../../types/errors/external-host-error'; +import { branchExists } from '../../../util/git'; import { BranchConfig } from '../../common'; export async function getPrHourlyRemaining( @@ -84,3 +85,40 @@ export async function getPrsRemaining( const concurrentRemaining = await getConcurrentPrsRemaining(config, branches); return Math.min(hourlyRemaining, concurrentRemaining); } + +export function getConcurrentBranchesRemaining( + config: RenovateConfig, + branches: BranchConfig[] +): number { + const { branchConcurrentLimit, prConcurrentLimit } = config; + const limit = + typeof branchConcurrentLimit === 'number' + ? branchConcurrentLimit + : prConcurrentLimit; + if (typeof limit === 'number' && limit) { + logger.debug(`Calculating branchConcurrentLimit (${limit})`); + try { + let currentlyOpen = 0; + for (const branch of branches) { + if (branchExists(branch.branchName)) { + currentlyOpen += 1; + } + } + logger.debug(`${currentlyOpen} branches are currently open`); + const concurrentRemaining = Math.max(0, limit - currentlyOpen); + logger.debug(`Branch concurrent limit remaining: ${concurrentRemaining}`); + return concurrentRemaining; + } catch (err) { + logger.error({ err }, 'Error checking concurrent branches'); + return limit; + } + } + return 99; +} + +export function getBranchesRemaining( + config: RenovateConfig, + branches: BranchConfig[] +): number { + return getConcurrentBranchesRemaining(config, branches); +} diff --git a/lib/workers/repository/process/write.spec.ts b/lib/workers/repository/process/write.spec.ts index 378544c3bb9f6f390d1d993f6fa8a2f26d4dfee1..c220580e2efa399b1a97a53baddc589700353b6a 100644 --- a/lib/workers/repository/process/write.spec.ts +++ b/lib/workers/repository/process/write.spec.ts @@ -13,6 +13,7 @@ const limits = mocked(_limits); branchWorker.processBranch = jest.fn(); limits.getPrsRemaining = jest.fn().mockResolvedValue(99); +limits.getBranchesRemaining = jest.fn().mockReturnValue(99); let config: RenovateConfig; beforeEach(() => { @@ -61,14 +62,14 @@ describe('workers/repository/write', () => { it('increments branch counter', async () => { const branches: BranchConfig[] = [{}] as never; branchWorker.processBranch.mockResolvedValueOnce( - ProcessBranchResult.Pending + ProcessBranchResult.PrCreated ); git.branchExists.mockReturnValueOnce(false); git.branchExists.mockReturnValueOnce(true); - limits.getPrsRemaining.mockResolvedValueOnce(1); - expect(isLimitReached(Limit.PullRequests)).toBeFalse(); + limits.getBranchesRemaining.mockReturnValueOnce(1); + expect(isLimitReached(Limit.Branches)).toBeFalse(); await writeUpdates({ config }, branches); - expect(isLimitReached(Limit.PullRequests)).toBeTrue(); + expect(isLimitReached(Limit.Branches)).toBeTrue(); }); }); }); diff --git a/lib/workers/repository/process/write.ts b/lib/workers/repository/process/write.ts index 19c2bb98a7644ce9be22a7f3ac19bd9a23ca0f09..02e1e1b920957ee091e9197ab7af10e08b7ef601 100644 --- a/lib/workers/repository/process/write.ts +++ b/lib/workers/repository/process/write.ts @@ -1,24 +1,13 @@ import { RenovateConfig } from '../../../config'; import { addMeta, logger, removeMeta } from '../../../logger'; -import { platform } from '../../../platform'; -import { PrState } from '../../../types'; import { branchExists } from '../../../util/git'; import { processBranch } from '../../branch'; import { BranchConfig, ProcessBranchResult } from '../../common'; import { Limit, incLimitedValue, setMaxLimit } from '../../global/limits'; -import { getPrsRemaining } from './limits'; +import { getBranchesRemaining, getPrsRemaining } from './limits'; export type WriteUpdateResult = 'done' | 'automerged'; -async function prExists(branchName: string): Promise<boolean> { - try { - const pr = await platform.getBranchPr(branchName); - return pr?.state === PrState.Open; - } catch (err) /* istanbul ignore next */ { - return false; - } -} - export async function writeUpdates( config: RenovateConfig, allBranches: BranchConfig[] @@ -39,13 +28,21 @@ export async function writeUpdates( } return true; }); + const prsRemaining = await getPrsRemaining(config, branches); logger.debug({ prsRemaining }, 'Calculated maximum PRs remaining this run'); setMaxLimit(Limit.PullRequests, prsRemaining); + + const branchesRemaining = getBranchesRemaining(config, branches); + logger.debug( + { branchesRemaining }, + 'Calculated maximum branches remaining this run' + ); + setMaxLimit(Limit.Branches, branchesRemaining); + for (const branch of branches) { addMeta({ branch: branch.branchName }); const branchExisted = branchExists(branch.branchName); - const prExisted = await prExists(branch.branchName); const res = await processBranch(branch); branch.res = res; if ( @@ -55,26 +52,8 @@ export async function writeUpdates( // Stop processing other branches because base branch has been changed return 'automerged'; } - if (res === ProcessBranchResult.PrCreated) { - incLimitedValue(Limit.PullRequests); - } - // istanbul ignore if - else if ( - res === ProcessBranchResult.Automerged && - branch.automergeType === 'pr-comment' && - branch.requiredStatusChecks === null - ) { - incLimitedValue(Limit.PullRequests); - } else if ( - res === ProcessBranchResult.Pending && - !branchExisted && - branchExists(branch.branchName) - ) { - incLimitedValue(Limit.PullRequests); - } - // istanbul ignore if - else if (!prExisted && (await prExists(branch.branchName))) { - incLimitedValue(Limit.PullRequests); + if (!branchExisted && branchExists(branch.branchName)) { + incLimitedValue(Limit.Branches); } } removeMeta(['branch']);