From 95d01e7ab1d27c5c80765a4dd7e6fe5e73395f4b Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Thu, 4 Oct 2018 10:07:59 +0200
Subject: [PATCH] feat: master issue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds undocumented/experimental “master issue” feature.

Setting `config.masterIssue = true` will result in Renovate opening and maintaining an issue that contains a list of all PRs both pending and open, and allowing some control over them (e.g. bypass schedule, force retry, etc).

Setting `config.masterIssueApproval` in addition will mean that branches are not created automatically and instead await approval in that master issue.

Closes #2595
---
 lib/config/definitions.js                     |   1 +
 lib/config/validation.js                      |   4 +
 lib/workers/branch/index.js                   |  29 +++-
 lib/workers/repository/index.js               |   2 +
 lib/workers/repository/master-issue.js        | 127 ++++++++++++++++++
 lib/workers/repository/process/index.js       |  19 +++
 lib/workers/repository/updates/generate.js    |   3 +
 .../__snapshots__/vulnerability.spec.js.snap  |   1 +
 .../__snapshots__/flatten.spec.js.snap        |   8 ++
 9 files changed, 189 insertions(+), 5 deletions(-)
 create mode 100644 lib/workers/repository/master-issue.js

diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index 9704931951..ac52d11e3f 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 19362acc8e..35587d9e09 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 91da9b5905..6e7183733c 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 95b8322080..ac8e98df42 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 0000000000..936c3ad41b
--- /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 459d2c3d28..f4eddec6fb 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 79383667b0..70fa54d6e4 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 eeaafc8e18..fb5801b2fb 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 d27e526bb4..4715ef96c4 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 [],
-- 
GitLab