diff --git a/docs/configuration.md b/docs/configuration.md index 49e73039d498f8b54ddf8e463c54f009ed4c34b9..889e7282317b28c37ca48b2c4e96795b7937c8aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,6 +85,7 @@ $ node renovate --help --recreate-closed [boolean] Recreate PRs even if same ones were closed previously --rebase-stale-prs [boolean] Rebase stale PRs (GitHub only) --pr-creation <string> When to create the PR for a branch. Values: immediate, not-pending, status-success. + --automerge <string> What types of upgrades to merge to base branch automatically. Values: none, minor or any --maintain-yarn-lock [boolean] Keep yarn.lock files updated in base branch --group-name <string> Human understandable name for the dependency group --group-slug <string> Slug to use for group (e.g. in branch name). Will be calculated from groupName if null @@ -140,6 +141,7 @@ Obviously, you can't set repository or package file location with this method. | `recreateClosed` | Recreate PRs even if same ones were closed previously | boolean | `false` | `RENOVATE_RECREATE_CLOSED` | `--recreate-closed` | | `rebaseStalePrs` | Rebase stale PRs (GitHub only) | boolean | `false` | `RENOVATE_REBASE_STALE_PRS` | `--rebase-stale-prs` | | `prCreation` | When to create the PR for a branch. Values: immediate, not-pending, status-success. | string | `"immediate"` | `RENOVATE_PR_CREATION` | `--pr-creation` | +| `automerge` | What types of upgrades to merge to base branch automatically. Values: none, minor or any | string | `"none"` | `RENOVATE_AUTOMERGE` | `--automerge` | | `branchName` | Branch name template | string | `"renovate/{{depName}}-{{newVersionMajor}}.x"` | | | | `commitMessage` | Commit message template | string | `"Update dependency {{depName}} to version {{newVersion}}"` | | | | `prTitle` | Pull Request title template | string | `"{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}}"` | | | diff --git a/lib/api/github.js b/lib/api/github.js index e90992511af2442befffc267c950b2c7e5e61aa4..c33c3fd7d19e2706755c67b2ffcdefb2b0fa3199 100644 --- a/lib/api/github.js +++ b/lib/api/github.js @@ -21,6 +21,7 @@ module.exports = { createPr, getPr, updatePr, + mergePr, // file commitFilesToBranch, getFile, @@ -45,10 +46,17 @@ async function initRepo(repoName, token, endpoint) { config.owner = res.body.owner.login; logger.debug(`${repoName} owner = ${config.owner}`); config.defaultBranch = res.body.default_branch; + if (res.body.allow_rebase_merge) { + config.mergeMethod = 'rebase'; + } else if (res.body.allow_squash_merge) { + config.mergeMethod = 'squash'; + } else { + config.mergeMethod = 'merge'; + } logger.debug(`${repoName} default branch = ${config.defaultBranch}`); config.baseCommitSHA = await getBranchCommit(config.defaultBranch); config.baseTreeSHA = await getCommitTree(config.baseCommitSHA); - } catch (err) { + } catch (err) /* istanbul ignore next */ { logger.error(`GitHub init error: ${JSON.stringify(err)}`); throw err; } @@ -241,6 +249,16 @@ async function updatePr(prNo, title, body) { }); } +async function mergePr(pr) { + await ghGot.put(`repos/${config.repoName}/pulls/${pr.number}/merge`, { + body: { + merge_method: config.mergeMethod, + }, + }); + // Delete branch + await ghGot.delete(`repos/${config.repoName}/git/refs/heads/${pr.head.ref}`); +} + // Generic File operations async function getFile(filePath, branchName = config.defaultBranch) { diff --git a/lib/api/gitlab.js b/lib/api/gitlab.js index 24022584a833886e82c4f9fa1575510ac36914e8..64889312b35cb0b1103fcf46cb26631944d060f5 100644 --- a/lib/api/gitlab.js +++ b/lib/api/gitlab.js @@ -21,6 +21,7 @@ module.exports = { createPr, getPr, updatePr, + mergePr, // file commitFilesToBranch, getFile, @@ -236,6 +237,14 @@ async function updatePr(prNo, title, body) { }); } +async function mergePr(pr) { + await glGot.put(`projects/${config.repoName}/merge_requests/${pr.number}/merge`, { + body: { + should_remove_source_branch: true, + }, + }); +} + // Generic File operations async function getFile(filePath, branchName = config.defaultBranch) { diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 43f4ef74b4291e055ffcefcc3cf1d46a6dc81594..700a37585981bfa9937f589e9e89e8004854cabc 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -93,6 +93,13 @@ const options = [ type: 'string', default: 'immediate', }, + // Automatic merging + { + name: 'automerge', + description: 'What types of upgrades to merge to base branch automatically. Values: none, minor or any', + type: 'string', + default: 'none', + }, // String templates { name: 'branchName', diff --git a/lib/helpers/versions.js b/lib/helpers/versions.js index 0fa20a57f0892d753e09b01da3e95d1ec333f03e..9c4f418b5103282c9874edb8bb463774c5b19307 100644 --- a/lib/helpers/versions.js +++ b/lib/helpers/versions.js @@ -56,7 +56,8 @@ function determineUpgrades(dep, currentVersion, config) { .forEach((newVersion) => { // Group by major versions const newVersionMajor = semver.major(newVersion); - const separateMajors = config.separateMajorReleases && !config.groupName; + // Only split majors if configured to do so, and no group or 'any' automerge + const separateMajors = config.separateMajorReleases && !config.groupName && config.automerge !== 'any'; const upgradeKey = separateMajors ? newVersionMajor : 'latest'; // Save this, if it's a new major version or greater than the previous greatest if (!allUpgrades[upgradeKey] || diff --git a/lib/worker.js b/lib/worker.js index bfeeb3969ab5bd59dfa9adfbe0e0a63b537af70e..818d48748ba12fea0661162bce617d59c385da72 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -208,7 +208,10 @@ async function updateBranch(upgrades) { } const branchCreated = await branchWorker.ensureBranch(upgrades); if (branchCreated) { - await prWorker.ensurePr(upgrade0); + const pr = await prWorker.ensurePr(upgrade0); + if (pr) { + await prWorker.checkAutoMerge(pr, upgrade0); + } } } catch (error) { logger.error(`Error updating branch ${branchName}: ${error}`); diff --git a/lib/workers/pr.js b/lib/workers/pr.js index a019e9bfd77cfa74a6c30876200d6c7076d3f0bb..c99c1ea3d96b4ff4a2badb0dd983c7ef5d3df4e6 100644 --- a/lib/workers/pr.js +++ b/lib/workers/pr.js @@ -4,6 +4,7 @@ const getChangeLog = require('../helpers/changelog'); module.exports = { ensurePr, + checkAutoMerge, }; // Ensures that PR exists with matching title/body @@ -69,3 +70,29 @@ async function ensurePr(upgradeConfig) { } return null; } + +async function checkAutoMerge(pr, config) { + logger.debug(`Checking #${pr.number} for automerge`); + if (config.automerge === 'any' || + (config.automerge === 'minor' && config.upgradeType === 'minor')) { + logger.verbose('PR is configured for automerge'); + logger.debug(JSON.stringify(pr)); + // Return if PR not ready for automerge + if (pr.mergeable !== true || pr.mergeable_state === 'unstable') { + logger.verbose('PR is not ready for merge'); + return; + } + // Check branch status + const branchStatus = await config.api.getBranchStatus(pr.head.ref); + logger.debug(`branchStatus=${branchStatus}`); + if (branchStatus !== 'success') { + logger.verbose('Branch status is not "success"'); + return; + } + // Let's merge this + logger.info(`Automerging #${pr.number}`); + await config.api.mergePr(pr); + } else { + logger.verbose('No automerge'); + } +} diff --git a/readme.md b/readme.md index ea348ed0f9348c6d082facf711c5e3cf1f09b55a..f50a48db38f4c988ffe4a44bd870e97a81e7eb01 100644 --- a/readme.md +++ b/readme.md @@ -54,6 +54,7 @@ $ node renovate --help --recreate-closed [boolean] Recreate PRs even if same ones were closed previously --rebase-stale-prs [boolean] Rebase stale PRs (GitHub only) --pr-creation <string> When to create the PR for a branch. Values: immediate, not-pending, status-success. + --automerge <string> What types of upgrades to merge to base branch automatically. Values: none, minor or any --maintain-yarn-lock [boolean] Keep yarn.lock files updated in base branch --group-name <string> Human understandable name for the dependency group --group-slug <string> Slug to use for group (e.g. in branch name). Will be calculated from groupName if null diff --git a/test/api/__snapshots__/github.spec.js.snap b/test/api/__snapshots__/github.spec.js.snap index 62d554f0b96ae474dd848c62b1c10619b7c74642..f3308236b93373f26aede528952d25656739ed77 100644 --- a/test/api/__snapshots__/github.spec.js.snap +++ b/test/api/__snapshots__/github.spec.js.snap @@ -652,6 +652,7 @@ Object { "baseCommitSHA": "1234", "baseTreeSHA": "5678", "defaultBranch": "master", + "mergeMethod": "rebase", "owner": "theowner", "repoName": "some/repo", } @@ -676,6 +677,7 @@ Object { "baseCommitSHA": "1234", "baseTreeSHA": "5678", "defaultBranch": "master", + "mergeMethod": "rebase", "owner": "theowner", "repoName": "some/repo", } @@ -700,11 +702,55 @@ Object { "baseCommitSHA": "1234", "baseTreeSHA": "5678", "defaultBranch": "master", + "mergeMethod": "rebase", "owner": "theowner", "repoName": "some/repo", } `; +exports[`api/github initRepo should merge 1`] = ` +Object { + "baseCommitSHA": "1234", + "baseTreeSHA": "5678", + "defaultBranch": "master", + "mergeMethod": "merge", + "owner": "theowner", + "repoName": "some/repo", +} +`; + +exports[`api/github initRepo should squash 1`] = ` +Object { + "baseCommitSHA": "1234", + "baseTreeSHA": "5678", + "defaultBranch": "master", + "mergeMethod": "squash", + "owner": "theowner", + "repoName": "some/repo", +} +`; + +exports[`api/github mergePr(prNo) should merge the PR 1`] = ` +Array [ + Array [ + "repos/some/repo/pulls/1234/merge", + Object { + "body": Object { + "merge_method": "rebase", + }, + }, + ], +] +`; + +exports[`api/github mergePr(prNo) should merge the PR 2`] = ` +Array [ + Array [ + "repos/some/repo/git/refs/heads/someref", + ], +] +`; + exports[`api/github updatePr(prNo, title, body) should update the PR 1`] = ` Array [ Array [ diff --git a/test/api/github.spec.js b/test/api/github.spec.js index de08142728cea576378d7a9ec3598b6714d66f6e..0cbae5e55d97788d9227557f69aabdcb6dc01c1a 100644 --- a/test/api/github.spec.js +++ b/test/api/github.spec.js @@ -21,6 +21,8 @@ describe('api/github', () => { login: 'theowner', }, default_branch: 'master', + allow_rebase_merge: true, + allow_squash_merge: true, }, })); // getBranchCommit @@ -68,6 +70,74 @@ describe('api/github', () => { } expect(err.message).toBe('No token found for GitHub repository some/repo'); }); + it('should squash', async () => { + async function squashInitRepo(...args) { + // repo info + ghGot.mockImplementationOnce(() => ({ + body: { + owner: { + login: 'theowner', + }, + default_branch: 'master', + allow_rebase_merge: false, + allow_squash_merge: true, + }, + })); + // getBranchCommit + ghGot.mockImplementationOnce(() => ({ + body: { + object: { + sha: '1234', + }, + }, + })); + // getCommitTree + ghGot.mockImplementationOnce(() => ({ + body: { + tree: { + sha: '5678', + }, + }, + })); + return github.initRepo(...args); + } + const config = await squashInitRepo('some/repo', 'token'); + expect(config).toMatchSnapshot(); + }); + it('should merge', async () => { + async function mergeInitRepo(...args) { + // repo info + ghGot.mockImplementationOnce(() => ({ + body: { + owner: { + login: 'theowner', + }, + default_branch: 'master', + allow_rebase_merge: false, + allow_squash_merge: false, + }, + })); + // getBranchCommit + ghGot.mockImplementationOnce(() => ({ + body: { + object: { + sha: '1234', + }, + }, + })); + // getCommitTree + ghGot.mockImplementationOnce(() => ({ + body: { + tree: { + sha: '5678', + }, + }, + })); + return github.initRepo(...args); + } + const config = await mergeInitRepo('some/repo', 'token'); + expect(config).toMatchSnapshot(); + }); }); describe('findFilePaths(fileName)', () => { it('should return the files matching the fileName', async () => { @@ -394,6 +464,20 @@ describe('api/github', () => { expect(ghGot.patch.mock.calls).toMatchSnapshot(); }); }); + describe('mergePr(prNo)', () => { + it('should merge the PR', async () => { + await initRepo('some/repo', 'token'); + const pr = { + number: 1234, + head: { + ref: 'someref', + }, + }; + await github.mergePr(pr); + expect(ghGot.put.mock.calls).toMatchSnapshot(); + expect(ghGot.delete.mock.calls).toMatchSnapshot(); + }); + }); describe('getFile(filePatch, branchName)', () => { it('should return the encoded file content', async () => { await initRepo('some/repo', 'token'); diff --git a/test/helpers/__snapshots__/versions.spec.js.snap b/test/helpers/__snapshots__/versions.spec.js.snap index 21b5eb1adc48bdf09cbe02696b44d70e97229cc4..b06d296211433024807eff9dfc6b4e25b72335f0 100644 --- a/test/helpers/__snapshots__/versions.spec.js.snap +++ b/test/helpers/__snapshots__/versions.spec.js.snap @@ -1,5 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`helpers/versions .determineUpgrades(dep, currentVersion, defaultConfig) returns both updates if automerging minor 1`] = ` +Array [ + Object { + "changeLogFromVersion": "0.4.4", + "changeLogToVersion": "0.9.7", + "newVersion": "0.9.7", + "newVersionMajor": 0, + "upgradeType": "minor", + }, + Object { + "changeLogFromVersion": "0.4.4", + "changeLogToVersion": "1.4.1", + "newVersion": "1.4.1", + "newVersionMajor": 1, + "upgradeType": "major", + }, +] +`; + +exports[`helpers/versions .determineUpgrades(dep, currentVersion, defaultConfig) returns only one update if automerging any 1`] = ` +Array [ + Object { + "changeLogFromVersion": "0.4.4", + "changeLogToVersion": "1.4.1", + "newVersion": "1.4.1", + "newVersionMajor": 1, + "upgradeType": "major", + }, +] +`; + exports[`helpers/versions .determineUpgrades(dep, currentVersion, defaultConfig) returns only one update if grouping 1`] = ` Array [ Object { diff --git a/test/helpers/versions.spec.js b/test/helpers/versions.spec.js index 17bee80d39de20ef23fb0665739a3cb8923d6ba8..c5f23210bfd91db080622af6fb864290096cae3a 100644 --- a/test/helpers/versions.spec.js +++ b/test/helpers/versions.spec.js @@ -48,6 +48,14 @@ describe('helpers/versions', () => { defaultConfig.groupName = 'somegroup'; expect(versionsHelper.determineUpgrades(qJson, '^0.4.0', defaultConfig)).toMatchSnapshot(); }); + it('returns only one update if automerging any', () => { + defaultConfig.automerge = 'any'; + expect(versionsHelper.determineUpgrades(qJson, '^0.4.0', defaultConfig)).toMatchSnapshot(); + }); + it('returns both updates if automerging minor', () => { + defaultConfig.automerge = 'minor'; + expect(versionsHelper.determineUpgrades(qJson, '^0.4.0', defaultConfig)).toMatchSnapshot(); + }); it('disables major release separation (major)', () => { const config = Object.assign({}, defaultConfig, { separateMajorReleases: false }); const upgradeVersions = [ diff --git a/test/workers/pr.spec.js b/test/workers/pr.spec.js index 2ec90424d56c1eee3ca023559b73173bcdb4ce3d..3411fa864d8cc22b91165c5f11e0a885142cc1ce 100644 --- a/test/workers/pr.spec.js +++ b/test/workers/pr.spec.js @@ -8,6 +8,68 @@ const getChangeLog = jest.fn(); getChangeLog.mockReturnValue('Mocked changelog'); describe('workers/pr', () => { + describe('checkAutoMerge(pr, config)', () => { + let config; + let pr; + beforeEach(() => { + config = Object.assign({}, defaultConfig); + pr = { + head: { + ref: 'somebranch', + }, + }; + config.api = { + mergePr: jest.fn(), + getBranchStatus: jest.fn(), + }; + }); + it('should not automerge if not configured', async () => { + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(0); + }); + it('should automerge if any and pr is mergeable', async () => { + config.automerge = 'any'; + pr.mergeable = true; + config.api.getBranchStatus.mockReturnValueOnce('success'); + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(1); + }); + it('should not automerge if any and pr is mergeable but branch status is not success', async () => { + config.automerge = 'any'; + pr.mergeable = true; + config.api.getBranchStatus.mockReturnValueOnce('pending'); + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(0); + }); + it('should not automerge if any and pr is mergeable but unstable', async () => { + config.automerge = 'any'; + pr.mergeable = true; + pr.mergeable_state = 'unstable'; + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(0); + }); + it('should not automerge if any and pr is unmergeable', async () => { + config.automerge = 'any'; + pr.mergeable = false; + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(0); + }); + it('should automerge if minor and upgradeType is minor', async () => { + config.automerge = 'minor'; + config.upgradeType = 'minor'; + pr.mergeable = true; + config.api.getBranchStatus.mockReturnValueOnce('success'); + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(1); + }); + it('should not automerge if minor and upgradeType is major', async () => { + config.automerge = 'minor'; + config.upgradeType = 'major'; + pr.mergeable = true; + await prWorker.checkAutoMerge(pr, config); + expect(config.api.mergePr.mock.calls.length).toBe(0); + }); + }); describe('ensurePr(config)', () => { let config; let existingPr;