diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index ce0c1d80b261da5a76bb6afbdb6db98ac4d8cb92..07e9112da80c8db2e5eea33b1f1deccbb97536b0 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2123,6 +2123,15 @@ Currently this applies to the `minimumReleaseAge` check only. The `flexible` mode can result in "flapping" of Pull Requests, for example: a pending PR with version `1.0.3` is first released but then downgraded to `1.0.2` once it passes `minimumReleaseAge`. We recommend that you use the `strict` mode, and enable the `dependencyDashboard` so that you can see suppressed PRs. +## keepUpdatedLabel + +On supported platforms it is possible to add a label to a PR to recreate/rebase it when the branch falls 1 or more commits behind its base branch. +Adding the `keepUpdatedLabel` label to a PR makes Renovate behave as if `rebaseWhen` were set to `behind-base-branch`, but only for the given PR. +The label is not removed from the PR after the rebase is complete, unlike what happens with `rebaseLabel`. + +This can be useful when you have approved certain PRs and want to keep them updated until they are ready to be merged. +The setting `keepUpdatedLabel` is best used in conjunction with `rebaseWhen` set to the values of `never` or `conflicted` that limit rebasing. + ## labels By default, Renovate won't add any labels to PRs. diff --git a/docs/usage/updating-rebasing.md b/docs/usage/updating-rebasing.md index 6bfadb298f1c374e58ecf5100d72822ab02c7507..67d50660edc42d8e8ec7a53fd76be43262232dbf 100644 --- a/docs/usage/updating-rebasing.md +++ b/docs/usage/updating-rebasing.md @@ -12,6 +12,7 @@ Here is a list of the most common cases where Renovate must update/rebase the br - When a pull request has conflicts due to changes on the base branch - When you have enabled "Require branches to be up to date before merging" on GitHub - When you have manually told Renovate to rebase when behind the base branch with `"rebaseWhen": "behind-base-branch"` +- When you have set `keepUpdatedLabel` and included the label on a PR - When a newer version of the dependency is released - When you request a manual rebase from the Renovate bot - When you use `"automerge": true` and `"rebaseWhen": "auto"` on a branch / pr diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 4b441f962d47707a21cd66f132e4b855b687a54a..f2b509c9a59e2028b222a5985d679e81cf686e12 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1752,6 +1752,13 @@ const options: RenovateOptions[] = [ default: 'auto', }, // PR Behaviour + { + name: 'keepUpdatedLabel', + description: + 'Label to request Renovate bot always rebase to keep branch updated.', + type: 'string', + supportedPlatforms: ['azure', 'gitea', 'github', 'gitlab'], + }, { name: 'rollbackPrs', description: diff --git a/lib/config/types.ts b/lib/config/types.ts index 6e0715dabd2d606cf0e949ba7db7c23f0fd3449f..ac5935ed999141956e2adbf3fb7278adf1caef2a 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -58,6 +58,7 @@ export interface RenovateSharedConfig { ignorePaths?: string[]; ignoreTests?: boolean; internalChecksAsSuccess?: boolean; + keepUpdatedLabel?: string; labels?: string[]; addLabels?: string[]; dependencyDashboardApproval?: boolean; diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts index 511063135527a57106ff2371d8134181a81c6702..25e4511fa8d17ae02fbf3d8cdfb9dfab0a9756c0 100644 --- a/lib/workers/repository/update/branch/index.spec.ts +++ b/lib/workers/repository/update/branch/index.spec.ts @@ -907,6 +907,35 @@ describe('workers/repository/update/branch/index', () => { expect(prAutomerge.checkAutoMerge).toHaveBeenCalledTimes(1); }); + it('ensures PR when impossible to automerge with mismatch keepUpdatedLabel', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce( + partial<PackageFilesResult>({ + updatedPackageFiles: [partial<FileChange>()], + }), + ); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [partial<FileChange>()], + }); + scm.branchExists.mockResolvedValue(true); + automerge.tryBranchAutomerge.mockResolvedValueOnce('stale'); + prWorker.ensurePr.mockResolvedValueOnce({ + type: 'with-pr', + pr: partial<Pr>({ labels: ['keep-not-updated'] }), + }); + prAutomerge.checkAutoMerge.mockResolvedValueOnce({ automerged: false }); + commit.commitFilesToBranch.mockResolvedValueOnce(null); + await branchWorker.processBranch({ + ...config, + automerge: true, + rebaseWhen: 'conflicted', + keepUpdatedLabel: 'keep-updated', + }); + expect(prWorker.ensurePr).toHaveBeenCalledTimes(1); + expect(platform.ensureCommentRemoval).toHaveBeenCalledTimes(0); + expect(prAutomerge.checkAutoMerge).toHaveBeenCalledTimes(1); + }); + it('skips when automerge is off schedule', async () => { getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce( partial<PackageFilesResult>(), @@ -2037,6 +2066,60 @@ describe('workers/repository/update/branch/index', () => { expect(commit.commitFilesToBranch).not.toHaveBeenCalled(); }); + it('continues when rebaseWhen=never and keepUpdatedLabel', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ + ...updatedPackageFiles, + }); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [], + }); + scm.branchExists.mockResolvedValue(true); + platform.getBranchPr.mockResolvedValueOnce( + partial<Pr>({ + state: 'open', + title: '', + labels: ['keep-updated'], + }), + ); + commit.commitFilesToBranch.mockResolvedValueOnce(null); + expect( + await branchWorker.processBranch({ + ...config, + rebaseWhen: 'never', + keepUpdatedLabel: 'keep-updated', + }), + ).toMatchObject({ result: 'done' }); + expect(commit.commitFilesToBranch).toHaveBeenCalled(); + }); + + it('returns when rebaseWhen=never and keepUpdatedLabel does not match', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ + ...updatedPackageFiles, + }); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [], + }); + scm.branchExists.mockResolvedValue(true); + platform.getBranchPr.mockResolvedValueOnce( + partial<Pr>({ + state: 'open', + title: '', + labels: ['keep-updated'], + }), + ); + commit.commitFilesToBranch.mockResolvedValueOnce(null); + expect( + await branchWorker.processBranch({ + ...config, + rebaseWhen: 'never', + keepUpdatedLabel: 'keep-not-updated', + }), + ).toMatchObject({ result: 'no-work' }); + expect(commit.commitFilesToBranch).not.toHaveBeenCalled(); + }); + it('continues when rebaseWhen=never but checked', async () => { getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({ ...updatedPackageFiles, diff --git a/lib/workers/repository/update/branch/index.ts b/lib/workers/repository/update/branch/index.ts index 050ce7403f71e67b97b720b9142b30fe50eb9a83..6e23c8f6c7f94a6083e802fcf55f54ea79d2df4c 100644 --- a/lib/workers/repository/update/branch/index.ts +++ b/lib/workers/repository/update/branch/index.ts @@ -145,6 +145,7 @@ export async function processBranch( config.rebaseRequested = await rebaseCheck(config, branchPr); logger.debug(`PR rebase requested=${config.rebaseRequested}`); } + const keepUpdatedLabel = config.keepUpdatedLabel; const artifactErrorTopic = emojify(':warning: Artifact update problem'); try { // Check if branch already existed @@ -429,6 +430,7 @@ export async function processBranch( } else if ( branchExists && config.rebaseWhen === 'never' && + !(keepUpdatedLabel && branchPr?.labels?.includes(keepUpdatedLabel)) && !dependencyDashboardCheck ) { logger.debug('rebaseWhen=never so skipping branch update check'); @@ -636,7 +638,8 @@ export async function processBranch( } if ( mergeStatus === 'stale' && - ['conflicted', 'never'].includes(config.rebaseWhen!) + ['conflicted', 'never'].includes(config.rebaseWhen!) && + !(keepUpdatedLabel && branchPr?.labels?.includes(keepUpdatedLabel)) ) { logger.warn( 'Branch cannot automerge because it is behind base branch and rebaseWhen setting disallows rebasing - raising a PR instead', diff --git a/lib/workers/repository/update/branch/reuse.spec.ts b/lib/workers/repository/update/branch/reuse.spec.ts index 40856c5cd2aaecf201a90025c5d6cbb3be2c0751..6ebe09a6888f662684ad49fc1525aea26383c6ea 100644 --- a/lib/workers/repository/update/branch/reuse.spec.ts +++ b/lib/workers/repository/update/branch/reuse.spec.ts @@ -10,6 +10,7 @@ describe('workers/repository/update/branch/reuse', () => { sourceBranch: 'master', state: 'open', title: 'any', + labels: ['keep-updated'], }; let config: BranchConfig; @@ -189,5 +190,37 @@ describe('workers/repository/update/branch/reuse', () => { const res = await shouldReuseExistingBranch(config); expect(res.reuseExistingBranch).toBeTrue(); }); + + it('returns false if rebaseWhen=never, keepUpdatedLabel and stale', async () => { + config.rebaseWhen = 'never'; + config.keepUpdatedLabel = 'keep-updated'; + platform.getBranchPr.mockResolvedValueOnce(pr); + scm.branchExists.mockResolvedValueOnce(true); + scm.isBranchBehindBase.mockResolvedValueOnce(true); + const res = await shouldReuseExistingBranch(config); + expect(res.reuseExistingBranch).toBeFalse(); + }); + + it('returns false if rebaseWhen=conflicted, keepUpdatedLabel and modified', async () => { + config.rebaseWhen = 'never'; + config.keepUpdatedLabel = 'keep-updated'; + platform.getBranchPr.mockResolvedValue(pr); + scm.branchExists.mockResolvedValueOnce(true); + scm.isBranchConflicted.mockResolvedValueOnce(true); + scm.isBranchModified.mockResolvedValueOnce(false); + const res = await shouldReuseExistingBranch(config); + expect(res.reuseExistingBranch).toBeFalse(); + expect(res.isModified).toBeUndefined(); + }); + + it('returns true if rebaseWhen=never, miss-match keepUpdatedLabel and stale', async () => { + config.rebaseWhen = 'never'; + config.keepUpdatedLabel = 'keep-not-updated'; + platform.getBranchPr.mockResolvedValueOnce(pr); + scm.branchExists.mockResolvedValueOnce(true); + scm.isBranchBehindBase.mockResolvedValueOnce(true); + const res = await shouldReuseExistingBranch(config); + expect(res.reuseExistingBranch).toBeTrue(); + }); }); }); diff --git a/lib/workers/repository/update/branch/reuse.ts b/lib/workers/repository/update/branch/reuse.ts index 0de0afe1e207490396f7371728b6c0652c1f649c..90c79ae5d1f608c25602685f1802935684e7a708 100644 --- a/lib/workers/repository/update/branch/reuse.ts +++ b/lib/workers/repository/update/branch/reuse.ts @@ -10,6 +10,28 @@ type ParentBranch = { isConflicted?: boolean; }; +async function shouldKeepUpdated( + config: BranchConfig, + baseBranch: string, + branchName: string, +): Promise<boolean> { + const keepUpdatedLabel = config.keepUpdatedLabel; + if (!keepUpdatedLabel) { + return false; + } + + const branchPr = await platform.getBranchPr( + config.branchName, + config.baseBranch, + ); + + if (branchPr?.labels?.includes(keepUpdatedLabel)) { + return true; + } + + return false; +} + export async function shouldReuseExistingBranch( config: BranchConfig, ): Promise<ParentBranch> { @@ -23,6 +45,7 @@ export async function shouldReuseExistingBranch( logger.debug(`Branch already exists`); if ( config.rebaseWhen === 'behind-base-branch' || + (await shouldKeepUpdated(config, baseBranch, branchName)) || (config.rebaseWhen === 'auto' && (config.automerge === true || (await platform.getBranchForceRebase?.(config.baseBranch)))) @@ -53,7 +76,10 @@ export async function shouldReuseExistingBranch( if ((await scm.isBranchModified(branchName)) === false) { logger.debug(`Branch is not mergeable and needs rebasing`); - if (config.rebaseWhen === 'never') { + if ( + config.rebaseWhen === 'never' && + !(await shouldKeepUpdated(config, baseBranch, branchName)) + ) { logger.debug('Rebasing disabled by config'); result.reuseExistingBranch = true; result.isModified = false;