diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 970493195146df7566adbe61067e592d9a7b6309..ac52d11e3fc88e338601bd52801ae33fe3a37172 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -727,6 +727,7 @@ const options = [ enabled: true, groupName: null, schedule: [], + masterIssueApproval: false, commitMessageSuffix: '[SECURITY]', }, cli: false, diff --git a/lib/config/validation.js b/lib/config/validation.js index 19362acc8eaf7211a48b73e62902a2b6d9ebc40b..35587d9e0901d485a0b82186f46e87d5b55cf799 100644 --- a/lib/config/validation.js +++ b/lib/config/validation.js @@ -44,6 +44,10 @@ async function validateConfig(config, isPreset, parentPath) { 'vulnerabilityAlertsOnly', 'copyLocalLibs', // deprecated - functinoality is now enabled by default 'prBody', // deprecated + 'masterIssue', + 'masterIssueTitle', + 'masterIssueApproval', + 'masterIssueAutoclose', ]; return ignoredNodes.includes(key); } diff --git a/lib/workers/branch/index.js b/lib/workers/branch/index.js index 91da9b59058b1e2879e0c72ecee4d5b7a164a01e..6e7183733cc889e8ca45dfe58e06b94b20cb421a 100644 --- a/lib/workers/branch/index.js +++ b/lib/workers/branch/index.js @@ -34,6 +34,20 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { const branchExists = await platform.branchExists(config.branchName); const branchPr = await platform.getBranchPr(config.branchName); logger.debug(`branchExists=${branchExists}`); + const masterIssueCheck = (config.masterIssueChecks || {})[config.branchName]; + // istanbul ignore if + if (masterIssueCheck) { + logger.info('Branch has been checked in master issue: ' + masterIssueCheck); + } + // istanbul ignore if + if (!branchExists && config.masterIssueApproval) { + if (masterIssueCheck) { + logger.info(`Branch ${config.branchName} is approved for creation`); + } else { + logger.info(`Branch ${config.branchName} needs approval`); + return 'needs-approval'; + } + } try { logger.debug( `Branch has ${dependencies ? dependencies.length : 0} upgrade(s)` @@ -41,7 +55,7 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { // Check if branch already existed const existingPr = branchPr ? undefined : await prAlreadyExisted(config); - if (existingPr) { + if (existingPr && !masterIssueCheck) { logger.debug( { prTitle: config.prTitle }, 'Closed PR already exists. Skipping branch.' @@ -80,7 +94,7 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { } return 'already-existed'; } - if (!branchExists && prHourlyLimitReached) { + if (!branchExists && prHourlyLimitReached && !masterIssueCheck) { logger.info('Reached PR creation limit - skipping branch creation'); return 'pr-hourly-limit-reached'; } @@ -117,7 +131,7 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { // Check schedule config.isScheduledNow = isScheduledNow(config); - if (!config.isScheduledNow) { + if (!config.isScheduledNow && !masterIssueCheck) { if (!branchExists) { logger.info('Skipping branch creation as not within schedule'); return 'not-scheduled'; @@ -148,8 +162,13 @@ async function processBranch(branchConfig, prHourlyLimitReached, packageFiles) { ); return 'pending'; } - - Object.assign(config, await getParentBranch(config)); + // istanbul ignore if + if (masterIssueCheck === 'rebase') { + logger.info('Manual rebase requested via master issue'); + delete config.parentBranch; + } else { + Object.assign(config, await getParentBranch(config)); + } logger.debug(`Using parentBranch: ${config.parentBranch}`); const res = await getUpdatedPackageFiles(config); // istanbul ignore if diff --git a/lib/workers/repository/index.js b/lib/workers/repository/index.js index 95b8322080a9890deebf785a005eaaf5b30b87e6..ac8e98df42fb125c631b8d1c4151dbdfb487cbcc 100644 --- a/lib/workers/repository/index.js +++ b/lib/workers/repository/index.js @@ -8,6 +8,7 @@ const { handleError } = require('./error'); const { processResult } = require('./result'); const { processRepo } = require('./process'); const { finaliseRepo } = require('./finalise'); +const { ensureMasterIssue } = require('./master-issue'); module.exports = { renovateRepository, @@ -34,6 +35,7 @@ async function renovateRepository(repoConfig) { config ); await ensureOnboardingPr(config, packageFiles, branches); + await ensureMasterIssue(config, branches); await finaliseRepo(config, branchList); return processResult(config, res); } catch (err) /* istanbul ignore next */ { diff --git a/lib/workers/repository/master-issue.js b/lib/workers/repository/master-issue.js new file mode 100644 index 0000000000000000000000000000000000000000..936c3ad41b343b355de3a2976b08e6db46e7542f --- /dev/null +++ b/lib/workers/repository/master-issue.js @@ -0,0 +1,127 @@ +module.exports = { + ensureMasterIssue, +}; + +// istanbul ignore next +function getListItem(branch, type) { + let item = ' - [ ] '; + item += `<!-- ${type}-branch=${branch.branchName} -->`; + if (branch.prNo) { + item += `[${branch.prTitle}](../pull/${branch.prNo})`; + } else { + item += branch.prTitle; + } + if (branch.upgrades.length < 2) { + return item + '\n'; + } + return ( + item + + ' (' + + branch.upgrades.map(upgrade => '`' + upgrade.depName + '`').join(', ') + + ')\n' + ); +} + +// istanbul ignore next +async function ensureMasterIssue(config, branches) { + if (!(config.masterIssue || config.masterIssueApproval)) { + return; + } + if ( + !branches.length || + branches.every(branch => branch.res === 'automerged') + ) { + if (config.masterIssueAutoclose) { + await platform.ensureIssueClosing(config.masterIssueTitle); + return; + } + await platform.ensureIssue( + config.masterIssueTitle, + 'This repository is up-to-date and has no outstanding updates open or pending.' + ); + return; + } + let issueBody = + 'This issue contains a list of Renovate updates and their statuses.\n\n'; + const pendingApprovals = branches.filter( + branch => branch.res === 'needs-approval' + ); + if (pendingApprovals.length) { + issueBody += '## Pending Approval\n\n'; + issueBody += + 'These PRs will be created by Renovate only once you click their checkbox below.\n\n'; + for (const branch of pendingApprovals) { + issueBody += getListItem(branch, 'approve'); + } + issueBody += '\n'; + } + const awaitingSchedule = branches.filter( + branch => branch.res === 'not-scheduled' + ); + if (awaitingSchedule.length) { + issueBody += '## Awaiting Schedule\n\n'; + issueBody += + 'These updates are awaiting their schedule. Click on a checkbox to ignore the schedule.\n'; + for (const branch of awaitingSchedule) { + issueBody += getListItem(branch, 'unschedule'); + } + issueBody += '\n'; + } + const rateLimited = branches.filter(branch => + branch.res.endsWith('pr-hourly-limit-reached') + ); + if (rateLimited.length) { + issueBody += '## Rate Limited\n\n'; + issueBody += + 'These updates are currently rate limited. Click on a checkbox below to override.\n\n'; + for (const branch of rateLimited) { + issueBody += getListItem(branch, 'unlimit'); + } + issueBody += '\n'; + } + const errorList = branches.filter(branch => branch.res.endsWith('error')); + if (errorList.length) { + issueBody += '## Errored\n\n'; + issueBody += + 'These updates encountered an error and will be retried. Click a checkbox below to force a retry now.\n\n'; + for (const branch of errorList) { + issueBody += getListItem(branch, 'retry'); + } + issueBody += '\n'; + } + const otherRes = [ + 'needs-approval', + 'not-scheduled', + 'pr-hourly-limit-reached', + 'already-existed', + 'error', + 'automerged', + ]; + const inProgress = branches.filter(branch => !otherRes.includes(branch.res)); + if (inProgress.length) { + issueBody += '## Open\n\n'; + issueBody += + 'These updates have all been created already. Click a checkbox below to force a retry/rebase of any.\n\n'; + for (const branch of inProgress) { + const pr = await platform.getBranchPr(branch.branchName); + if (pr) { + branch.prNo = pr.number; + } + issueBody += getListItem(branch, 'rebase'); + } + issueBody += '\n'; + } + const alreadyExisted = branches.filter(branch => + branch.res.endsWith('already-existed') + ); + if (alreadyExisted.length) { + issueBody += '## Closed/Ignored\n\n'; + issueBody += + 'These updates were closed unmerged and will not be recreated unless you click a checkbox below.\n\n'; + for (const branch of alreadyExisted) { + issueBody += getListItem(branch, 'recreate'); + } + issueBody += '\n'; + } + await platform.ensureIssue(config.masterIssueTitle, issueBody); +} diff --git a/lib/workers/repository/process/index.js b/lib/workers/repository/process/index.js index 459d2c3d28b870b58a83d026d8f7a16c8eb99f6e..f4eddec6fb34585bb1f4704e35c12ab54a46c02a 100644 --- a/lib/workers/repository/process/index.js +++ b/lib/workers/repository/process/index.js @@ -7,6 +7,25 @@ module.exports = { async function processRepo(config) { logger.debug('processRepo()'); + /* eslint-disable no-param-reassign */ + config.masterIssueChecks = {}; + // istanbul ignore if + if (config.masterIssue || config.masterIssueApproval) { + config.masterIssueTitle = + config.masterIssueTitle || 'Update Dependencies (Renovate Bot)'; + const issue = await platform.findIssue(config.masterIssueTitle); + if (issue) { + const checkMatch = ' - \\[x\\] <!-- ([a-z]+)-branch=([^\\s]+) -->'; + const checked = issue.body.match(new RegExp(checkMatch, 'g')); + if (checked && checked.length) { + checked.forEach(check => { + const [, type, branchName] = check.match(new RegExp(checkMatch)); + config.masterIssueChecks[branchName] = type; + }); + /* eslint-enable no-param-reassign */ + } + } + } if (config.baseBranches && config.baseBranches.length) { logger.info({ baseBranches: config.baseBranches }, 'baseBranches'); let res; diff --git a/lib/workers/repository/updates/generate.js b/lib/workers/repository/updates/generate.js index 79383667b0da0e04ea95bc0933ac636681daa938..70fa54d6e456e99cc88cfcc39f245509be8a3b3a 100644 --- a/lib/workers/repository/updates/generate.js +++ b/lib/workers/repository/updates/generate.js @@ -166,6 +166,9 @@ function generateBranchConfig(branchUpgrades) { config.reuseLockFiles = config.upgrades.every( upgrade => upgrade.updateType !== 'lockFileMaintenance' ); + config.masterIssueApproval = config.upgrades.some( + upgrade => upgrade.masterIssueApproval + ); config.automerge = config.upgrades.every(upgrade => upgrade.automerge); config.blockedByPin = config.upgrades.every(upgrade => upgrade.blockedByPin); if (config.upgrades.every(upgrade => upgrade.updateType === 'pin')) { diff --git a/test/workers/repository/init/__snapshots__/vulnerability.spec.js.snap b/test/workers/repository/init/__snapshots__/vulnerability.spec.js.snap index eeaafc8e18894f1a131f59d9ebe2a58929956c17..fb5801b2fbdca0b7520367dbcba9fa43b722c5c3 100644 --- a/test/workers/repository/init/__snapshots__/vulnerability.spec.js.snap +++ b/test/workers/repository/init/__snapshots__/vulnerability.spec.js.snap @@ -7,6 +7,7 @@ Array [ "force": Object { "commitMessageSuffix": "[SECURITY]", "groupName": null, + "masterIssueApproval": false, "schedule": Array [], "vulnerabilityAlert": true, }, diff --git a/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap b/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap index d27e526bb403a25e8ba7b20a85c75d7ce4011e0d..4715ef96c4fc139eeb8f8d872e4537d18077d284 100644 --- a/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap +++ b/test/workers/repository/updates/__snapshots__/flatten.spec.js.snap @@ -87,6 +87,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -177,6 +178,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -267,6 +269,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -357,6 +360,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -447,6 +451,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -537,6 +542,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -627,6 +633,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [], @@ -717,6 +724,7 @@ Array [ "commitMessageSuffix": "[SECURITY]", "enabled": true, "groupName": null, + "masterIssueApproval": false, "schedule": Array [], }, "warnings": Array [],