From b817db10a3f6bc7d9afe3c13858e6614e5933b22 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Fri, 14 Sep 2018 12:51:33 +0200
Subject: [PATCH] feat: rebase on demand (#2522)

Adds functionality to force rebase a PR if the label "rebase" is added. Also, the label is configurable via a new `rebaseLabel` config option.

Closes #1406
---
 lib/config/definitions.js                     |  7 +++++++
 lib/platform/bitbucket/index.js               |  7 +++++++
 lib/platform/github/index.js                  | 19 +++++++++++++++++++
 lib/platform/github/storage.js                |  2 +-
 lib/platform/gitlab/index.js                  |  6 ++++++
 lib/platform/vsts/index.js                    |  6 ++++++
 lib/workers/branch/parent.js                  |  6 ++++++
 .../github/graphql/pullrequest-1.json         |  7 +++++++
 .../platform/__snapshots__/index.spec.js.snap |  3 +++
 .../github/__snapshots__/index.spec.js.snap   |  8 ++++++++
 test/platform/github/index.spec.js            | 10 ++++++++++
 test/workers/branch/parent.spec.js            | 11 +++++++++++
 .../__snapshots__/flatten.spec.js.snap        |  8 ++++++++
 website/docs/configuration-options.md         |  4 ++++
 website/docs/updating-rebasing.md             | 10 ++++++++++
 15 files changed, 113 insertions(+), 1 deletion(-)

diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index c3435d386e..ec5ad8b3d3 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -638,6 +638,13 @@ const options = [
     type: 'boolean',
     default: null,
   },
+  {
+    name: 'rebaseLabel',
+    description:
+      'Label to use to instruct Renovate to rebase a PR manually (GitHub only)',
+    type: 'string',
+    default: 'rebase',
+  },
   {
     name: 'statusCheckVerify',
     description: '`Set a "renovate/verify" status check for all PRs`',
diff --git a/lib/platform/bitbucket/index.js b/lib/platform/bitbucket/index.js
index acb4484973..333f41da9d 100644
--- a/lib/platform/bitbucket/index.js
+++ b/lib/platform/bitbucket/index.js
@@ -26,10 +26,12 @@ module.exports = {
   deleteBranch,
   mergeBranch,
   getBranchLastCommitTime,
+  // Issue
   ensureIssue,
   ensureIssueClosing,
   addAssignees,
   addReviewers,
+  deleteLabel,
   // Comments
   ensureComment,
   ensureCommentRemoval,
@@ -316,6 +318,11 @@ function addReviewers() {
   return Promise.resolve();
 }
 
+// istanbul ignore next
+function deleteLabel() {
+  throw new Error('deleteLabel not implemented');
+}
+
 function ensureComment() {
   // https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment
   logger.warn('Comment functionality not implemented yet');
diff --git a/lib/platform/github/index.js b/lib/platform/github/index.js
index 767b0e5227..ab5bd1fc30 100644
--- a/lib/platform/github/index.js
+++ b/lib/platform/github/index.js
@@ -40,6 +40,7 @@ module.exports = {
   ensureIssueClosing,
   addAssignees,
   addReviewers,
+  deleteLabel,
   // Comments
   ensureComment,
   ensureCommentRemoval,
@@ -637,6 +638,16 @@ async function addLabels(issueNo, labels) {
   }
 }
 
+async function deleteLabel(issueNo, label) {
+  logger.debug(`Deleting label ${label} from #${issueNo}`);
+  const repository = config.parentRepo || config.repository;
+  try {
+    await get.delete(`repos/${repository}/issues/${issueNo}/labels/${label}`);
+  } catch (err) /* istanbul ignore next */ {
+    logger.warn({ issueNo, label }, 'Failed to delte label');
+  }
+}
+
 async function getComments(issueNo) {
   const pr = (await getClosedPrs())[issueNo];
   if (pr) {
@@ -865,6 +876,11 @@ async function getOpenPrs() {
               title
               mergeable
               mergeStateStatus
+              labels(last: 100) {
+                nodes {
+                  name
+                }
+              }
               commits(first: 2) {
                 nodes {
                   commit {
@@ -952,6 +968,9 @@ async function getOpenPrs() {
             pr.isStale = true;
           }
         }
+        if (pr.labels) {
+          pr.labels = pr.labels.nodes.map(label => label.name);
+        }
         delete pr.mergeable;
         delete pr.mergeStateStatus;
         delete pr.commits;
diff --git a/lib/platform/github/storage.js b/lib/platform/github/storage.js
index 289f3657ed..cd491ebb4f 100644
--- a/lib/platform/github/storage.js
+++ b/lib/platform/github/storage.js
@@ -281,7 +281,7 @@ class Storage {
       try {
         if (isBranchExisting) {
           await updateBranch(branchName, commit);
-          logger.info({ branchName }, 'Branch updated');
+          logger.debug({ branchName }, 'Branch updated');
           return 'updated';
         }
         await createBranch(branchName, commit);
diff --git a/lib/platform/gitlab/index.js b/lib/platform/gitlab/index.js
index ed320929a5..ecb55aaa01 100644
--- a/lib/platform/gitlab/index.js
+++ b/lib/platform/gitlab/index.js
@@ -35,6 +35,7 @@ module.exports = {
   ensureIssueClosing,
   addAssignees,
   addReviewers,
+  deleteLabel,
   // Comments
   ensureComment,
   ensureCommentRemoval,
@@ -487,6 +488,11 @@ function addReviewers(iid, reviewers) {
   logger.warn('Unimplemented in GitLab: approvals');
 }
 
+// istanbul ignore next
+function deleteLabel() {
+  throw new Error('deleteLabel not implemented');
+}
+
 async function getComments(issueNo) {
   // GET projects/:owner/:repo/merge_requests/:number/notes
   logger.debug(`Getting comments for #${issueNo}`);
diff --git a/lib/platform/vsts/index.js b/lib/platform/vsts/index.js
index d637c85ab4..c0ad16b473 100644
--- a/lib/platform/vsts/index.js
+++ b/lib/platform/vsts/index.js
@@ -31,6 +31,7 @@ module.exports = {
   ensureIssueClosing,
   addAssignees,
   addReviewers,
+  deleteLabel,
   // Comments
   ensureComment,
   ensureCommentRemoval,
@@ -576,6 +577,11 @@ async function addReviewers(prNo, reviewers) {
   );
 }
 
+// istanbul ignore next
+function deleteLabel() {
+  throw new Error('deleteLabel not implemented');
+}
+
 // to become async?
 function getPrFiles(prNo) {
   logger.info(`getPrFiles(prNo)(${prNo}) - Not supported by VSTS (yet!)`);
diff --git a/lib/workers/branch/parent.js b/lib/workers/branch/parent.js
index a6b7e5a620..7ad9c5ce7c 100644
--- a/lib/workers/branch/parent.js
+++ b/lib/workers/branch/parent.js
@@ -15,6 +15,12 @@ async function getParentBranch(config) {
   // Check for existing PR
   const pr = await platform.getBranchPr(branchName);
 
+  if (pr && pr.labels && pr.labels.includes(config.rebaseLabel)) {
+    logger.info({ prNo: pr.number }, 'Manual rebase requested for PR');
+    await platform.deleteLabel(pr.number, config.rebaseLabel);
+    return { parentBranch: undefined };
+  }
+
   if (
     config.rebaseStalePrs ||
     (config.rebaseStalePrs === null && (await platform.getRepoForceRebase())) ||
diff --git a/test/_fixtures/github/graphql/pullrequest-1.json b/test/_fixtures/github/graphql/pullrequest-1.json
index c17fdd04b6..ccf390af60 100644
--- a/test/_fixtures/github/graphql/pullrequest-1.json
+++ b/test/_fixtures/github/graphql/pullrequest-1.json
@@ -9,6 +9,13 @@
             "title": "build(deps): update got packages (major)",
             "mergeable": "MERGEABLE",
             "mergeStateStatus": "CLEAN",
+            "labels": {
+              "nodes": [
+                {
+                  "name": "blocked"
+                }
+              ]
+            },
             "commits": {
               "nodes": [
                 {
diff --git a/test/platform/__snapshots__/index.spec.js.snap b/test/platform/__snapshots__/index.spec.js.snap
index 6e4a8bc010..e6870bffda 100644
--- a/test/platform/__snapshots__/index.spec.js.snap
+++ b/test/platform/__snapshots__/index.spec.js.snap
@@ -23,6 +23,7 @@ Array [
   "ensureIssueClosing",
   "addAssignees",
   "addReviewers",
+  "deleteLabel",
   "ensureComment",
   "ensureCommentRemoval",
   "getPrList",
@@ -63,6 +64,7 @@ Array [
   "ensureIssueClosing",
   "addAssignees",
   "addReviewers",
+  "deleteLabel",
   "ensureComment",
   "ensureCommentRemoval",
   "getPrList",
@@ -103,6 +105,7 @@ Array [
   "ensureIssueClosing",
   "addAssignees",
   "addReviewers",
+  "deleteLabel",
   "ensureComment",
   "ensureCommentRemoval",
   "getPrList",
diff --git a/test/platform/github/__snapshots__/index.spec.js.snap b/test/platform/github/__snapshots__/index.spec.js.snap
index c780519bb6..a6686ab1ee 100644
--- a/test/platform/github/__snapshots__/index.spec.js.snap
+++ b/test/platform/github/__snapshots__/index.spec.js.snap
@@ -140,6 +140,14 @@ Array [
 ]
 `;
 
+exports[`platform/github deleteLabel(issueNo, label) should delete the label 1`] = `
+Array [
+  Array [
+    "repos/some/repo/issues/42/labels/rebase",
+  ],
+]
+`;
+
 exports[`platform/github ensureComment add comment if not found 1`] = `
 Array [
   "repos/some/repo/issues/42/comments",
diff --git a/test/platform/github/index.spec.js b/test/platform/github/index.spec.js
index ffa000898e..1328a4deeb 100644
--- a/test/platform/github/index.spec.js
+++ b/test/platform/github/index.spec.js
@@ -949,6 +949,16 @@ describe('platform/github', () => {
       await github.ensureIssueClosing('title-2');
     });
   });
+  describe('deleteLabel(issueNo, label)', () => {
+    it('should delete the label', async () => {
+      await initRepo({
+        repository: 'some/repo',
+        token: 'token',
+      });
+      await github.deleteLabel(42, 'rebase');
+      expect(get.delete.mock.calls).toMatchSnapshot();
+    });
+  });
   describe('addAssignees(issueNo, assignees)', () => {
     it('should add the given assignees to the issue', async () => {
       await initRepo({
diff --git a/test/workers/branch/parent.spec.js b/test/workers/branch/parent.spec.js
index 53e3fbeb33..707fd884bf 100644
--- a/test/workers/branch/parent.spec.js
+++ b/test/workers/branch/parent.spec.js
@@ -6,6 +6,7 @@ describe('workers/branch/parent', () => {
     beforeEach(() => {
       config = {
         branchName: 'renovate/some-branch',
+        rebaseLabel: 'rebase',
       };
     });
     afterEach(() => {
@@ -39,6 +40,16 @@ describe('workers/branch/parent', () => {
       const res = await getParentBranch(config);
       expect(res.parentBranch).toBe(config.branchName);
     });
+    it('returns undefined if manual rebase by label', async () => {
+      platform.branchExists.mockReturnValue(true);
+      platform.getBranchPr.mockReturnValue({
+        isUnmergeable: true,
+        canRebase: false,
+        labels: ['rebase'],
+      });
+      const res = await getParentBranch(config);
+      expect(res.parentBranch).toBe(undefined);
+    });
     it('returns undefined if unmergeable and can rebase', async () => {
       platform.branchExists.mockReturnValue(true);
       platform.getBranchPr.mockReturnValue({
diff --git a/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap b/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap
index 2d25bc477e..5654110755 100644
--- a/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap
+++ b/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap
@@ -46,6 +46,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
@@ -116,6 +117,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
@@ -183,6 +185,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": true,
     "recreateClosed": true,
     "requiredStatusChecks": Array [],
@@ -256,6 +259,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
@@ -323,6 +327,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": true,
     "recreateClosed": true,
     "requiredStatusChecks": Array [],
@@ -396,6 +401,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
@@ -466,6 +472,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
@@ -536,6 +543,7 @@ Array [
     "prNotPendingHours": 25,
     "prTitle": null,
     "raiseDeprecationWarnings": true,
+    "rebaseLabel": "rebase",
     "rebaseStalePrs": null,
     "recreateClosed": false,
     "requiredStatusChecks": Array [],
diff --git a/website/docs/configuration-options.md b/website/docs/configuration-options.md
index bdc6928ff3..7c13e6767d 100644
--- a/website/docs/configuration-options.md
+++ b/website/docs/configuration-options.md
@@ -607,6 +607,10 @@ For example, if your `package.json` specifies a value for `left-pad` of `^1.0.0`
 
 This feature supports simple caret (`^`) and tilde (`~`) ranges only, like `^1.0.0` and `~1.0.0`.
 
+## rebaseLabel
+
+On GitHub it is possible to add a label to a PR to manually request Renovate to recreate/rebase it. By default this label is "rebase" however you can configure it to anything you want by changing this `rebaseLabel` field.
+
 ## rebaseStalePrs
 
 This field is defaulted to `null` because it has a potential to create a lot of noise and additional builds to your repository. If you enable it to true, it means each Renovate branch will be updated whenever the base branch has changed. If enabled, this also means that whenever a Renovate PR is merged (whether by automerge or manually via GitHub web) then any other existing Renovate PRs will then need to get rebased and retested.
diff --git a/website/docs/updating-rebasing.md b/website/docs/updating-rebasing.md
index f3b6eccb49..36206b355d 100644
--- a/website/docs/updating-rebasing.md
+++ b/website/docs/updating-rebasing.md
@@ -32,3 +32,13 @@ If an existing PR is open to upgrade dependency "foo" to v1.1.0 and then v1.1.1
 
 - Each Renovate branch will always have 1 and only 1 commit
 - The newest version will be based off the latest base branch commit at the time
+
+## Manual rebasing
+
+In GitHub, it is possible to manually request that Renovate rebase a PR by adding the label "rebase" to it. This label name is also configurable via the `rebaseLabel` config option too.
+
+If you apply this label then Renovate will regenerate its commit for the branch, even if the branch has been modified. Therefore it is useful in situations such as:
+
+- If a branch is stale but you don't have `rebaseStalePrs` enabled
+- If a branch has been edited and you wish to discard the edits and have Renovate create it again
+- If a branch was created with an error (e.g. lockfile generation) and you wish to have it retried.
-- 
GitLab