From a133bb96afe193679e487552519f27589dfd8496 Mon Sep 17 00:00:00 2001
From: Oleg Krivtsov <olegkrivtsov@gmail.com>
Date: Fri, 21 Jan 2022 15:33:22 +0700
Subject: [PATCH] feat(workers/branch): allow to define a blocked label
 (#12164)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 docs/usage/configuration-options.md           |  5 ++
 lib/config/options/index.ts                   |  7 ++
 lib/config/types.ts                           |  1 +
 lib/workers/branch/index.spec.ts              | 70 +++++++++++++++++++
 lib/workers/branch/index.ts                   | 20 ++++++
 .../pr/body/config-description.spec.ts        | 23 ++++++
 lib/workers/pr/body/config-description.ts     |  2 +-
 lib/workers/types.ts                          |  1 +
 8 files changed, 128 insertions(+), 1 deletion(-)
 create mode 100644 lib/workers/pr/body/config-description.spec.ts

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index d87ec95912..8df0079799 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -2529,6 +2529,11 @@ This works because Renovate will add a "renovate/stability-days" pending status
 
 <!-- markdownlint-enable MD001 -->
 
+## stopUpdatingLabel
+
+On supported platforms it is possible to add a label to a PR to request Renovate stop updating the PR.
+By default this label is `"stop-updating"` however you can configure it to anything you want by changing this `stopUpdatingLabel` field.
+
 ## suppressNotifications
 
 Use this field to suppress various types of warnings and other notifications from Renovate.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index fb738ad170..34cceaece7 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -1313,6 +1313,13 @@ const options: RenovateOptions[] = [
     type: 'string',
     default: 'rebase',
   },
+  {
+    name: 'stopUpdatingLabel',
+    description: 'Label to use to request the bot to stop updating a PR.',
+    type: 'string',
+    default: 'stop-updating',
+    supportedPlatforms: ['azure', 'github', 'gitlab', 'gitea'],
+  },
   {
     name: 'stabilityDays',
     description:
diff --git a/lib/config/types.ts b/lib/config/types.ts
index 258b29e3d5..e102ae643c 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -54,6 +54,7 @@ export interface RenovateSharedConfig {
   productLinks?: Record<string, string>;
   prPriority?: number;
   rebaseLabel?: string;
+  stopUpdatingLabel?: string;
   rebaseWhen?: string;
   recreateClosed?: boolean;
   repository?: string;
diff --git a/lib/workers/branch/index.spec.ts b/lib/workers/branch/index.spec.ts
index c7615c3898..cfaed31930 100644
--- a/lib/workers/branch/index.spec.ts
+++ b/lib/workers/branch/index.spec.ts
@@ -939,6 +939,76 @@ describe('workers/branch/index', () => {
       });
     });
 
+    it('skips branch update if stopUpdatingLabel presents', async () => {
+      getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({
+        updatedPackageFiles: [{}],
+        artifactErrors: [],
+      } as PackageFilesResult);
+      npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({
+        artifactErrors: [],
+        updatedArtifacts: [{}],
+      } as WriteExistingFilesResult);
+      git.branchExists.mockReturnValue(true);
+      platform.getBranchPr.mockResolvedValueOnce({
+        title: 'rebase!',
+        state: PrState.Open,
+        labels: ['stop-updating'],
+        body: `- [ ] <!-- rebase-check -->`,
+      } as Pr);
+      git.isBranchModified.mockResolvedValueOnce(true);
+      schedule.isScheduledNow.mockReturnValueOnce(false);
+      commit.commitFilesToBranch.mockResolvedValueOnce(null);
+      expect(
+        await branchWorker.processBranch({
+          ...config,
+          dependencyDashboardChecks: { 'renovate/some-branch': 'true' },
+          updatedArtifacts: [{ type: 'deletion', path: 'dummy' }],
+        })
+      ).toMatchInlineSnapshot(`
+        Object {
+          "branchExists": true,
+          "prNo": undefined,
+          "result": "no-work",
+        }
+      `);
+      expect(commit.commitFilesToBranch).not.toHaveBeenCalled();
+    });
+
+    it('updates branch if stopUpdatingLabel presents and PR rebase/retry box checked', async () => {
+      getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce({
+        updatedPackageFiles: [{}],
+        artifactErrors: [],
+      } as PackageFilesResult);
+      npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({
+        artifactErrors: [],
+        updatedArtifacts: [{}],
+      } as WriteExistingFilesResult);
+      git.branchExists.mockReturnValue(true);
+      platform.getBranchPr.mockResolvedValueOnce({
+        title: 'Update dependency',
+        state: PrState.Open,
+        labels: ['stop-updating'],
+        body: `- [x] <!-- rebase-check -->`,
+      } as Pr);
+      git.isBranchModified.mockResolvedValueOnce(true);
+      schedule.isScheduledNow.mockReturnValueOnce(false);
+      commit.commitFilesToBranch.mockResolvedValueOnce(null);
+      expect(
+        await branchWorker.processBranch({
+          ...config,
+          reuseExistingBranch: false,
+          updatedArtifacts: [{ type: 'deletion', path: 'dummy' }],
+        })
+      ).toMatchInlineSnapshot(`
+        Object {
+          "branchExists": true,
+          "prNo": undefined,
+          "result": "done",
+        }
+      `);
+      expect(commit.commitFilesToBranch).toHaveBeenCalled();
+    });
+
     it('executes post-upgrade tasks if trust is high', async () => {
       const updatedPackageFile: File = {
         type: 'addition',
diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts
index 11d79a3449..d0d3017188 100644
--- a/lib/workers/branch/index.ts
+++ b/lib/workers/branch/index.ts
@@ -440,6 +440,26 @@ export async function processBranch(
     }
     const forcedManually = userRebaseRequested || !branchExists;
     config.forceCommit = forcedManually || branchPr?.isConflicted;
+
+    config.stopUpdating = branchPr?.labels?.includes(config.stopUpdatingLabel);
+
+    const prRebaseChecked = branchPr?.body?.includes(
+      `- [x] <!-- rebase-check -->`
+    );
+
+    if (branchExists && dependencyDashboardCheck && config.stopUpdating) {
+      if (!prRebaseChecked) {
+        logger.info(
+          'Branch updating is skipped because stopUpdatingLabel is present in config'
+        );
+        return {
+          branchExists: true,
+          prNo: branchPr?.number,
+          result: BranchResult.NoWork,
+        };
+      }
+    }
+
     const commitSha = await commitFilesToBranch(config);
     // istanbul ignore if
     if (branchPr && platform.refreshPr) {
diff --git a/lib/workers/pr/body/config-description.spec.ts b/lib/workers/pr/body/config-description.spec.ts
new file mode 100644
index 0000000000..90d5d94cb4
--- /dev/null
+++ b/lib/workers/pr/body/config-description.spec.ts
@@ -0,0 +1,23 @@
+import { mock } from 'jest-mock-extended';
+import { BranchConfig } from '../../types';
+import { getPrConfigDescription } from './config-description';
+
+jest.mock('../../../util/git');
+
+describe('workers/pr/body/config-description', () => {
+  describe('getPrConfigDescription', () => {
+    let branchConfig: BranchConfig;
+    beforeEach(() => {
+      jest.resetAllMocks();
+      branchConfig = mock<BranchConfig>();
+      branchConfig.branchName = 'branchName';
+    });
+
+    it('handles stopUpdatingLabel correctly', async () => {
+      branchConfig.stopUpdating = true;
+      expect(await getPrConfigDescription(branchConfig)).toContain(
+        `**Rebasing**: Never, or you tick the rebase/retry checkbox.`
+      );
+    });
+  });
+});
diff --git a/lib/workers/pr/body/config-description.ts b/lib/workers/pr/body/config-description.ts
index 17ac4d82d1..3dc8034974 100644
--- a/lib/workers/pr/body/config-description.ts
+++ b/lib/workers/pr/body/config-description.ts
@@ -44,7 +44,7 @@ export async function getPrConfigDescription(
   prBody += emojify(':recycle: **Rebasing**: ');
   if (config.rebaseWhen === 'behind-base-branch') {
     prBody += 'Whenever PR is behind base branch';
-  } else if (config.rebaseWhen === 'never') {
+  } else if (config.rebaseWhen === 'never' || config.stopUpdating) {
     prBody += 'Never';
   } else {
     prBody += 'Whenever PR becomes conflicted';
diff --git a/lib/workers/types.ts b/lib/workers/types.ts
index b007f84e1c..d311d0c18a 100644
--- a/lib/workers/types.ts
+++ b/lib/workers/types.ts
@@ -115,4 +115,5 @@ export interface BranchConfig
   packageFiles?: Record<string, PackageFile[]>;
   prBlockedBy?: PrBlockedBy;
   prNo?: number;
+  stopUpdating?: boolean;
 }
-- 
GitLab