From cb8fd6b4ed09a719339f3dc7271712a26e703804 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@keylocation.sg>
Date: Mon, 11 Dec 2017 19:14:51 +0100
Subject: [PATCH] feat: fork mode (#1287)

This PR adds the capability to run Renovate in a new "fork mode". This new mode must be configured by the Renovate admin, and cannot be configured within repositories themselves (for now). Example use: `renovate --autodiscover --fork-mode`

In this mode:
* Renovate will fork the repository if necessary (first run only)
* If the fork already existed, Renovate will ensure that its base branch is up to date with the source repository's
 * Branches will be created within the fork, PRs will be created in the source
---
 docs/configuration.md                         |   8 ++
 lib/config/definitions.js                     |   8 ++
 lib/platform/github/index.js                  |  87 +++++++++++----
 lib/workers/repository/init/apis.js           |   3 +-
 package.json                                  |   1 +
 .../__snapshots__/resolve.spec.js.snap        |   7 ++
 .../github/__snapshots__/index.spec.js.snap   |  18 +++-
 test/platform/github/index.spec.js            | 101 ++++++++++++++++++
 .../__snapshots__/branchify.spec.js.snap      |   5 +
 yarn.lock                                     |  10 ++
 10 files changed, 227 insertions(+), 21 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index f84546396f..1b9da6e5a4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -173,6 +173,14 @@ location with this method.
   <td>`RENOVATE_RENOVATE_FORK`</td>
   <td>`--renovate-fork`<td>
 </tr>
+<tr>
+  <td>`forkMode`</td>
+  <td>Set to true if Renovate should fork the source repository and create branches there instead</td>
+  <td>boolean</td>
+  <td><pre>false</pre></td>
+  <td>`RENOVATE_FORK_MODE`</td>
+  <td>`--fork-mode`<td>
+</tr>
 <tr>
   <td>`privateKey`</td>
   <td>Server-side private key</td>
diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index 0bd0c0a7f7..282966bcb8 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -78,6 +78,14 @@ const options = [
     type: 'boolean',
     default: false,
   },
+  {
+    name: 'forkMode',
+    description:
+      'Set to true if Renovate should fork the source repository and create branches there instead',
+    stage: 'repository',
+    type: 'boolean',
+    default: false,
+  },
   // encryption
   {
     name: 'privateKey',
diff --git a/lib/platform/github/index.js b/lib/platform/github/index.js
index 8246ab375e..b1dae82aff 100644
--- a/lib/platform/github/index.js
+++ b/lib/platform/github/index.js
@@ -2,6 +2,7 @@ const get = require('./gh-got-wrapper');
 const addrs = require('email-addresses');
 const moment = require('moment');
 const openpgp = require('openpgp');
+const delay = require('delay');
 const path = require('path');
 
 let config = {};
@@ -66,7 +67,7 @@ async function getRepos(token, endpoint) {
 }
 
 // Initialize GitHub by getting base branch and SHA
-async function initRepo(repoName, token, endpoint) {
+async function initRepo(repoName, token, endpoint, forkMode = false) {
   logger.debug(`initRepo("${repoName}")`);
   if (token) {
     process.env.GITHUB_TOKEN = token;
@@ -108,6 +109,42 @@ async function initRepo(repoName, token, endpoint) {
   delete config.prList;
   delete config.fileList;
   await Promise.all([getPrList(), getFileList()]);
+  if (forkMode) {
+    logger.info('Renovate is in forkMode');
+    // Save parent SHA then delete
+    config.parentSha = await getBaseCommitSHA();
+    delete config.baseCommitSHA;
+    // save parent name then delete
+    config.parentRepo = config.repoName;
+    delete config.repoName;
+    // Get list of existing repos
+    const existingRepos = (await get('user/repos?per_page=100', {
+      paginate: true,
+    })).body.map(r => r.full_name);
+    config.repoName = (await get.post(
+      `repos/${repoName}/forks`
+    )).body.full_name;
+    if (existingRepos.includes(config.repoName)) {
+      logger.info({ repository_fork: config.repoName }, 'Found existing fork');
+      // Need to update base branch
+      logger.debug(
+        { baseBranch: config.baseBranch, parentSha: config.parentSha },
+        'Setting baseBranch ref in fork'
+      );
+      await get.patch(
+        `repos/${config.repoName}/git/refs/heads/${config.baseBranch}`,
+        {
+          body: {
+            sha: config.parentSha,
+          },
+        }
+      );
+    } else {
+      logger.info({ repository_fork: config.repoName }, 'Created fork');
+      // Let's wait an arbitrary 30s to hopefully give GitHub enough time
+      await delay(30000);
+    }
+  }
   return platformConfig;
 }
 
@@ -389,7 +426,8 @@ async function addAssignees(issueNo, assignees) {
 async function addReviewers(issueNo, reviewers) {
   logger.debug(`Adding reviewers ${reviewers} to #${issueNo}`);
   const res = await get.post(
-    `repos/${config.repoName}/pulls/${issueNo}/requested_reviewers`,
+    `repos/${config.parentRepo ||
+      config.repoName}/pulls/${issueNo}/requested_reviewers`,
     {
       headers: {
         accept: 'application/vnd.github.thor-preview+json',
@@ -486,7 +524,8 @@ async function getPrList() {
   if (!config.prList) {
     logger.debug('Retrieving PR list');
     const res = await get(
-      `repos/${config.repoName}/pulls?per_page=100&state=all`,
+      `repos/${config.parentRepo ||
+        config.repoName}/pulls?per_page=100&state=all`,
       { paginate: true }
     );
     config.prList = res.body.map(pr => ({
@@ -533,14 +572,19 @@ async function findPr(branchName, prTitle, state = 'all') {
 // Creates PR and returns PR number
 async function createPr(branchName, title, body, labels, useDefaultBranch) {
   const base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
-  const pr = (await get.post(`repos/${config.repoName}/pulls`, {
-    body: {
-      title,
-      head: branchName,
-      base,
-      body,
-    },
-  })).body;
+  // Include the repository owner to handle forkMode and regular mode
+  const head = `${config.repoName.split('/')[0]}:${branchName}`;
+  const pr = (await get.post(
+    `repos/${config.parentRepo || config.repoName}/pulls`,
+    {
+      body: {
+        title,
+        head,
+        base,
+        body,
+      },
+    }
+  )).body;
   pr.displayNumber = `Pull Request #${pr.number}`;
   await addLabels(pr.number, labels);
   return pr;
@@ -551,7 +595,9 @@ async function getPr(prNo) {
   if (!prNo) {
     return null;
   }
-  const pr = (await get(`repos/${config.repoName}/pulls/${prNo}`)).body;
+  const pr = (await get(
+    `repos/${config.parentRepo || config.repoName}/pulls/${prNo}`
+  )).body;
   if (!pr) {
     return null;
   }
@@ -620,8 +666,9 @@ async function getPrFiles(prNo) {
   if (!prNo) {
     return [];
   }
-  const files = (await get(`repos/${config.repoName}/pulls/${prNo}/files`))
-    .body;
+  const files = (await get(
+    `repos/${config.parentRepo || config.repoName}/pulls/${prNo}/files`
+  )).body;
   return files.map(f => f.filename);
 }
 
@@ -631,9 +678,12 @@ async function updatePr(prNo, title, body) {
   if (body) {
     patchBody.body = body;
   }
-  await get.patch(`repos/${config.repoName}/pulls/${prNo}`, {
-    body: patchBody,
-  });
+  await get.patch(
+    `repos/${config.parentRepo || config.repoName}/pulls/${prNo}`,
+    {
+      body: patchBody,
+    }
+  );
 }
 
 async function mergePr(prNo, branchName) {
@@ -652,7 +702,8 @@ async function mergePr(prNo, branchName) {
       'Branch protection: Attempting to merge PR when PR reviews are enabled'
     );
   }
-  const url = `repos/${config.repoName}/pulls/${prNo}/merge`;
+  const url = `repos/${config.parentRepo ||
+    config.repoName}/pulls/${prNo}/merge`;
   const options = {
     body: {},
   };
diff --git a/lib/workers/repository/init/apis.js b/lib/workers/repository/init/apis.js
index 2f6e0d25ce..6a3fcb6b1e 100644
--- a/lib/workers/repository/init/apis.js
+++ b/lib/workers/repository/init/apis.js
@@ -11,7 +11,8 @@ async function getPlatformConfig(config) {
   const platformConfig = await platform.initRepo(
     config.repository,
     config.token,
-    config.endpoint
+    config.endpoint,
+    config.forkMode
   );
   return {
     ...config,
diff --git a/package.json b/package.json
index a2cebf965b..ff121c5a09 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
     "conventional-commits-detector": "0.1.1",
     "convert-hrtime": "2.0.0",
     "deepcopy": "0.6.3",
+    "delay": "2.0.0",
     "detect-indent": "5.0.0",
     "email-addresses": "3.0.1",
     "fs-extra": "4.0.3",
diff --git a/test/manager/__snapshots__/resolve.spec.js.snap b/test/manager/__snapshots__/resolve.spec.js.snap
index 1bf5251d37..881c90c316 100644
--- a/test/manager/__snapshots__/resolve.spec.js.snap
+++ b/test/manager/__snapshots__/resolve.spec.js.snap
@@ -236,6 +236,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -766,6 +767,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -1302,6 +1304,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -2097,6 +2100,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -2630,6 +2634,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -3155,6 +3160,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -3689,6 +3695,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
diff --git a/test/platform/github/__snapshots__/index.spec.js.snap b/test/platform/github/__snapshots__/index.spec.js.snap
index 02fc930c34..26d8382c04 100644
--- a/test/platform/github/__snapshots__/index.spec.js.snap
+++ b/test/platform/github/__snapshots__/index.spec.js.snap
@@ -218,7 +218,7 @@ Array [
       "body": Object {
         "base": "master",
         "body": "Hello world",
-        "head": "some-branch",
+        "head": "some:some-branch",
         "title": "The Title",
       },
     },
@@ -250,7 +250,7 @@ Array [
       "body": Object {
         "base": "master",
         "body": "Hello world",
-        "head": "some-branch",
+        "head": "some:some-branch",
         "title": "The Title",
       },
     },
@@ -549,6 +549,13 @@ Array [
 ]
 `;
 
+exports[`platform/github initRepo should forks when forkMode 1`] = `
+Object {
+  "isFork": false,
+  "privateRepo": false,
+}
+`;
+
 exports[`platform/github initRepo should initialise the config for the repo - 0 1`] = `
 Array [
   Array [
@@ -649,6 +656,13 @@ Object {
 }
 `;
 
+exports[`platform/github initRepo should update fork when forkMode 1`] = `
+Object {
+  "isFork": false,
+  "privateRepo": false,
+}
+`;
+
 exports[`platform/github mergeBranch(branchName, mergeType) should perform a branch-merge-commit merge 1`] = `
 Array [
   Array [
diff --git a/test/platform/github/index.spec.js b/test/platform/github/index.spec.js
index 073a7bc75e..0421e7dc88 100644
--- a/test/platform/github/index.spec.js
+++ b/test/platform/github/index.spec.js
@@ -8,6 +8,7 @@ describe('platform/github', () => {
 
     // reset module
     jest.resetModules();
+    jest.mock('delay');
     jest.mock('../../../lib/platform/github/gh-got-wrapper');
     get = require('../../../lib/platform/github/gh-got-wrapper');
     github = require('../../../lib/platform/github');
@@ -145,6 +146,106 @@ describe('platform/github', () => {
       const config = await squashInitRepo('some/repo', 'token');
       expect(config).toMatchSnapshot();
     });
+    it('should forks when forkMode', async () => {
+      function forkInitRepo(...args) {
+        // repo info
+        get.mockImplementationOnce(() => ({
+          body: {
+            owner: {
+              login: 'theowner',
+            },
+            default_branch: 'master',
+            allow_rebase_merge: true,
+            allow_squash_merge: true,
+            allow_merge_commit: true,
+          },
+        }));
+        // getPrList
+        get.mockImplementationOnce(() => ({
+          body: [],
+        }));
+        // getFileList
+        get.mockImplementationOnce(() => ({
+          body: [],
+        }));
+        // getBranchCommit
+        get.mockImplementationOnce(() => ({
+          body: {
+            object: {
+              sha: '1234',
+            },
+          },
+        }));
+        // getRepos
+        get.mockImplementationOnce(() => ({
+          body: [],
+        }));
+        // getBranchCommit
+        get.post.mockImplementationOnce(() => ({
+          body: {},
+        }));
+        return github.initRepo(...args);
+      }
+      const config = await forkInitRepo(
+        'some/repo',
+        'token',
+        'some-endpoint',
+        true
+      );
+      expect(config).toMatchSnapshot();
+    });
+    it('should update fork when forkMode', async () => {
+      function forkInitRepo(...args) {
+        // repo info
+        get.mockImplementationOnce(() => ({
+          body: {
+            owner: {
+              login: 'theowner',
+            },
+            default_branch: 'master',
+            allow_rebase_merge: true,
+            allow_squash_merge: true,
+            allow_merge_commit: true,
+          },
+        }));
+        // getPrList
+        get.mockImplementationOnce(() => ({
+          body: [],
+        }));
+        // getFileList
+        get.mockImplementationOnce(() => ({
+          body: [],
+        }));
+        // getBranchCommit
+        get.mockImplementationOnce(() => ({
+          body: {
+            object: {
+              sha: '1234',
+            },
+          },
+        }));
+        // getRepos
+        get.mockImplementationOnce(() => ({
+          body: [
+            {
+              full_name: 'forked_repo',
+            },
+          ],
+        }));
+        // fork
+        get.post.mockImplementationOnce(() => ({
+          body: { full_name: 'forked_repo' },
+        }));
+        return github.initRepo(...args);
+      }
+      const config = await forkInitRepo(
+        'some/repo',
+        'token',
+        'some-endpoint',
+        true
+      );
+      expect(config).toMatchSnapshot();
+    });
     it('should squash', async () => {
       function mergeInitRepo(...args) {
         // repo info
diff --git a/test/workers/repository/updates/__snapshots__/branchify.spec.js.snap b/test/workers/repository/updates/__snapshots__/branchify.spec.js.snap
index 94d943b05f..66aa73c8cf 100644
--- a/test/workers/repository/updates/__snapshots__/branchify.spec.js.snap
+++ b/test/workers/repository/updates/__snapshots__/branchify.spec.js.snap
@@ -281,6 +281,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -843,6 +844,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -1411,6 +1413,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -1967,6 +1970,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
@@ -2518,6 +2522,7 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
   "excludePackageNames": Array [],
   "excludePackagePatterns": Array [],
   "extends": Array [],
+  "forkMode": false,
   "gitAuthor": null,
   "gitPrivateKey": null,
   "group": Object {
diff --git a/yarn.lock b/yarn.lock
index 87a4c9fcba..6f6c4b7427 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1208,6 +1208,12 @@ del@^2.0.2:
     pinkie-promise "^2.0.0"
     rimraf "^2.2.8"
 
+delay@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/delay/-/delay-2.0.0.tgz#9112eadc03e4ec7e00297337896f273bbd91fae5"
+  dependencies:
+    p-defer "^1.0.0"
+
 delayed-stream@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@@ -4060,6 +4066,10 @@ p-cancelable@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
 
+p-defer@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-- 
GitLab