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