From 8acfc0d801a1671b4a86cae6bb7cd0d3a8b97f30 Mon Sep 17 00:00:00 2001
From: Maron <98313426+MaronHatoum@users.noreply.github.com>
Date: Mon, 29 Aug 2022 23:36:14 +0300
Subject: [PATCH] feat(dependency dashboard): add option to open all prs
 (#16959)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 .../dependency-dashboard-with-8-PR.txt        |   6 +-
 .../dependency-dashboard.spec.ts.snap         |   1 +
 .../repository/dependency-dashboard.spec.ts   | 172 ++++++++++++++++++
 .../repository/dependency-dashboard.ts        |  79 +++++++-
 .../repository/update/branch/index.spec.ts    |  64 +++++++
 lib/workers/repository/update/branch/index.ts |  11 +-
 6 files changed, 325 insertions(+), 8 deletions(-)

diff --git a/lib/workers/repository/__fixtures__/dependency-dashboard-with-8-PR.txt b/lib/workers/repository/__fixtures__/dependency-dashboard-with-8-PR.txt
index 9402c5d054..a357ade8c8 100644
--- a/lib/workers/repository/__fixtures__/dependency-dashboard-with-8-PR.txt
+++ b/lib/workers/repository/__fixtures__/dependency-dashboard-with-8-PR.txt
@@ -6,6 +6,7 @@ These branches will be created by Renovate only once you click their checkbox be
 
  - [ ] <!-- approve-branch=branchName1 -->pr1
  - [ ] <!-- approve-branch=branchName2 -->pr2
+ - [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
 
 ## Awaiting Schedule
 
@@ -14,12 +15,13 @@ These updates are awaiting their schedule. Click on a checkbox to get an update
  - [ ] <!-- unschedule-branch=branchName3 -->pr3
  - [ ] <!-- unschedule-branch=branchName4 -->pr4
 
-## Rate Limited
+## Rate-Limited
 
-These updates are currently rate limited. Click on a checkbox below to force their creation now.
+These updates are currently rate-limited. Click on a checkbox below to force their creation now.
 
  - [ ] <!-- unlimit-branch=branchName5 -->pr5
  - [ ] <!-- unlimit-branch=branchName6 -->pr6
+ - [ ] <!-- create-all-rate-limited-prs -->🔐 **Create all rate-limited PRs at once** 🔐
 
 ## Errored
 
diff --git a/lib/workers/repository/__snapshots__/dependency-dashboard.spec.ts.snap b/lib/workers/repository/__snapshots__/dependency-dashboard.spec.ts.snap
index 2974a346ed..9bd3295445 100644
--- a/lib/workers/repository/__snapshots__/dependency-dashboard.spec.ts.snap
+++ b/lib/workers/repository/__snapshots__/dependency-dashboard.spec.ts.snap
@@ -509,6 +509,7 @@ These branches will be created by Renovate only once you click their checkbox be
 
  - [ ] <!-- approve-branch=branchName1 -->pr1
  - [ ] <!-- approve-branch=branchName2 -->pr2
+ - [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
 
 ## Awaiting Schedule
 
diff --git a/lib/workers/repository/dependency-dashboard.spec.ts b/lib/workers/repository/dependency-dashboard.spec.ts
index 3aa3921801..712f4a3659 100644
--- a/lib/workers/repository/dependency-dashboard.spec.ts
+++ b/lib/workers/repository/dependency-dashboard.spec.ts
@@ -19,6 +19,7 @@ import {
   GitHubMaxPrBodyLen,
   massageMarkdown,
 } from '../../modules/platform/github';
+import { regEx } from '../../util/regex';
 import { BranchConfig, BranchResult, BranchUpgradeConfig } from '../types';
 import * as dependencyDashboard from './dependency-dashboard';
 import { PackageFiles } from './package-files';
@@ -92,6 +93,8 @@ describe('workers/repository/dependency-dashboard', () => {
       });
       await dependencyDashboard.readDashboardBody(conf);
       expect(conf).toEqual({
+        dependencyDashboardAllPending: false,
+        dependencyDashboardAllRateLimited: false,
         dependencyDashboardChecks: {
           branchName1: 'approve',
         },
@@ -101,6 +104,58 @@ describe('workers/repository/dependency-dashboard', () => {
         prCreation: 'approval',
       });
     });
+
+    it('reads dashboard body all pending approval', async () => {
+      const conf: RenovateConfig = {};
+      conf.prCreation = 'approval';
+      platform.findIssue.mockResolvedValueOnce({
+        title: '',
+        number: 1,
+        body: Fixtures.get('dependency-dashboard-with-8-PR.txt').replace(
+          '- [ ] <!-- approve-all-pending-prs -->',
+          '- [x] <!-- approve-all-pending-prs -->'
+        ),
+      });
+      await dependencyDashboard.readDashboardBody(conf);
+      expect(conf).toEqual({
+        dependencyDashboardChecks: {
+          branchName1: 'approve',
+          branchName2: 'approve',
+        },
+        dependencyDashboardIssue: 1,
+        dependencyDashboardRebaseAllOpen: false,
+        dependencyDashboardTitle: 'Dependency Dashboard',
+        prCreation: 'approval',
+        dependencyDashboardAllPending: true,
+        dependencyDashboardAllRateLimited: false,
+      });
+    });
+
+    it('reads dashboard body open all rate-limited', async () => {
+      const conf: RenovateConfig = {};
+      conf.prCreation = 'approval';
+      platform.findIssue.mockResolvedValueOnce({
+        title: '',
+        number: 1,
+        body: Fixtures.get('dependency-dashboard-with-8-PR.txt').replace(
+          '- [ ] <!-- create-all-rate-limited-prs -->',
+          '- [x] <!-- create-all-rate-limited-prs -->'
+        ),
+      });
+      await dependencyDashboard.readDashboardBody(conf);
+      expect(conf).toEqual({
+        dependencyDashboardChecks: {
+          branchName5: 'unlimit',
+          branchName6: 'unlimit',
+        },
+        dependencyDashboardIssue: 1,
+        dependencyDashboardRebaseAllOpen: false,
+        dependencyDashboardTitle: 'Dependency Dashboard',
+        prCreation: 'approval',
+        dependencyDashboardAllPending: false,
+        dependencyDashboardAllRateLimited: true,
+      });
+    });
   });
 
   describe('ensureDependencyDashboard()', () => {
@@ -527,6 +582,123 @@ describe('workers/repository/dependency-dashboard', () => {
       expect(platform.ensureIssue.mock.calls[0][0].body).toMatchSnapshot();
     });
 
+    it('dependency Dashboard All Pending Approval', async () => {
+      const branches: BranchConfig[] = [
+        {
+          ...mock<BranchConfig>(),
+          prTitle: 'pr1',
+          upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
+          result: BranchResult.NeedsApproval,
+          branchName: 'branchName1',
+        },
+        {
+          ...mock<BranchConfig>(),
+          prTitle: 'pr2',
+          upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep2' }],
+          result: BranchResult.NeedsApproval,
+          branchName: 'branchName2',
+        },
+      ];
+      config.dependencyDashboard = true;
+      config.dependencyDashboardChecks = {
+        branchName1: 'approve-branch',
+        branchName2: 'approve-branch',
+      };
+      config.dependencyDashboardIssue = 1;
+      jest.spyOn(platform, 'getIssue').mockResolvedValueOnce({
+        title: 'Dependency Dashboard',
+        body: `This issue contains a list of Renovate updates and their statuses.
+
+        ## Pending Approval
+
+        These branches will be created by Renovate only once you click their checkbox below.
+
+         - [ ] <!-- approve-branch=branchName1 -->pr1
+         - [ ] <!-- approve-branch=branchName2 -->pr2
+         - [x] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐`,
+      });
+      await dependencyDashboard.ensureDependencyDashboard(config, branches);
+      const checkApprovePendingSelectAll = regEx(
+        / - \[ ] <!-- approve-all-pending-prs -->/g
+      );
+      const checkApprovePendingBranch1 = regEx(
+        / - \[ ] <!-- approve-branch=branchName1 -->pr1/g
+      );
+      const checkApprovePendingBranch2 = regEx(
+        / - \[ ] <!-- approve-branch=branchName2 -->pr2/g
+      );
+      expect(
+        checkApprovePendingSelectAll.test(
+          platform.ensureIssue.mock.calls[0][0].body
+        )
+      ).toBeTrue();
+      expect(
+        checkApprovePendingBranch1.test(
+          platform.ensureIssue.mock.calls[0][0].body
+        )
+      ).toBeTrue();
+      expect(
+        checkApprovePendingBranch2.test(
+          platform.ensureIssue.mock.calls[0][0].body
+        )
+      ).toBeTrue();
+    });
+
+    it('dependency Dashboard Open All rate-limited', async () => {
+      const branches: BranchConfig[] = [
+        {
+          ...mock<BranchConfig>(),
+          prTitle: 'pr1',
+          upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
+          result: BranchResult.BranchLimitReached,
+          branchName: 'branchName1',
+        },
+        {
+          ...mock<BranchConfig>(),
+          prTitle: 'pr2',
+          upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep2' }],
+          result: BranchResult.PrLimitReached,
+          branchName: 'branchName2',
+        },
+      ];
+      config.dependencyDashboard = true;
+      config.dependencyDashboardChecks = {
+        branchName1: 'unlimit-branch',
+        branchName2: 'unlimit-branch',
+      };
+      config.dependencyDashboardIssue = 1;
+      jest.spyOn(platform, 'getIssue').mockResolvedValueOnce({
+        title: 'Dependency Dashboard',
+        body: `This issue contains a list of Renovate updates and their statuses.
+        ## Rate-limited
+        These updates are currently rate-limited. Click on a checkbox below to force their creation now.
+         - [x] <!-- create-all-rate-limited-prs -->**Open all rate-limited PRs**
+         - [ ] <!-- unlimit-branch=branchName1 -->pr1
+         - [ ] <!-- unlimit-branch=branchName2 -->pr2`,
+      });
+      await dependencyDashboard.ensureDependencyDashboard(config, branches);
+      const checkRateLimitedSelectAll = regEx(
+        / - \[ ] <!-- create-all-rate-limited-prs -->/g
+      );
+      const checkRateLimitedBranch1 = regEx(
+        / - \[ ] <!-- unlimit-branch=branchName1 -->pr1/g
+      );
+      const checkRateLimitedBranch2 = regEx(
+        / - \[ ] <!-- unlimit-branch=branchName2 -->pr2/g
+      );
+      expect(
+        checkRateLimitedSelectAll.test(
+          platform.ensureIssue.mock.calls[0][0].body
+        )
+      ).toBeTrue();
+      expect(
+        checkRateLimitedBranch1.test(platform.ensureIssue.mock.calls[0][0].body)
+      ).toBeTrue();
+      expect(
+        checkRateLimitedBranch2.test(platform.ensureIssue.mock.calls[0][0].body)
+      ).toBeTrue();
+    });
+
     it('rechecks branches', async () => {
       const branches: BranchConfig[] = [
         {
diff --git a/lib/workers/repository/dependency-dashboard.ts b/lib/workers/repository/dependency-dashboard.ts
index ffa718a4d8..0d1919cd13 100644
--- a/lib/workers/repository/dependency-dashboard.ts
+++ b/lib/workers/repository/dependency-dashboard.ts
@@ -16,27 +16,86 @@ import { PackageFiles } from './package-files';
 interface DependencyDashboard {
   dependencyDashboardChecks: Record<string, string>;
   dependencyDashboardRebaseAllOpen: boolean;
+  dependencyDashboardAllPending: boolean;
+  dependencyDashboardAllRateLimited: boolean;
+}
+
+const rateLimitedRe = regEx(
+  ' - \\[ \\] <!-- unlimit-branch=([^\\s]+) -->',
+  'g'
+);
+const pendingApprovalRe = regEx(
+  ' - \\[ \\] <!-- approve-branch=([^\\s]+) -->',
+  'g'
+);
+const generalBranchRe = regEx(' <!-- ([a-zA-Z]+)-branch=([^\\s]+) -->');
+const markedBranchesRe = regEx(
+  ' - \\[x\\] <!-- ([a-zA-Z]+)-branch=([^\\s]+) -->',
+  'g'
+);
+
+function checkOpenAllRateLimitedPR(issueBody: string): boolean {
+  return issueBody.includes(' - [x] <!-- create-all-rate-limited-prs -->');
+}
+
+function checkApproveAllPendingPR(issueBody: string): boolean {
+  return issueBody.includes(' - [x] <!-- approve-all-pending-prs -->');
 }
 
 function checkRebaseAll(issueBody: string): boolean {
   return issueBody.includes(' - [x] <!-- rebase-all-open-prs -->');
 }
 
+function selectAllRelevantBranches(issueBody: string): string[] {
+  const checkedBranches = [];
+  if (checkOpenAllRateLimitedPR(issueBody)) {
+    for (const match of issueBody.matchAll(rateLimitedRe)) {
+      checkedBranches.push(match[0]);
+    }
+  }
+  if (checkApproveAllPendingPR(issueBody)) {
+    for (const match of issueBody.matchAll(pendingApprovalRe)) {
+      checkedBranches.push(match[0]);
+    }
+  }
+  return checkedBranches;
+}
+
+function getAllSelectedBranches(
+  issueBody: string,
+  dependencyDashboardChecks: Record<string, string>
+): Record<string, string> {
+  const allRelevantBranches = selectAllRelevantBranches(issueBody);
+  for (const branch of allRelevantBranches) {
+    const [, type, branchName] = generalBranchRe.exec(branch)!;
+    dependencyDashboardChecks[branchName] = type;
+  }
+  return dependencyDashboardChecks;
+}
+
 function getCheckedBranches(issueBody: string): Record<string, string> {
-  const checkMatch = /- \[x\] <!-- ([a-zA-Z]+)-branch=([^\s]+) -->/g;
-  const dependencyDashboardChecks: Record<string, string> = {};
-  for (const [, type, branchName] of issueBody.matchAll(regEx(checkMatch))) {
+  let dependencyDashboardChecks: Record<string, string> = {};
+  for (const [, type, branchName] of issueBody.matchAll(markedBranchesRe)) {
     dependencyDashboardChecks[branchName] = type;
   }
+  dependencyDashboardChecks = getAllSelectedBranches(
+    issueBody,
+    dependencyDashboardChecks
+  );
   return dependencyDashboardChecks;
 }
 
 function parseDashboardIssue(issueBody: string): DependencyDashboard {
   const dependencyDashboardChecks = getCheckedBranches(issueBody);
   const dependencyDashboardRebaseAllOpen = checkRebaseAll(issueBody);
+  const dependencyDashboardAllPending = checkApproveAllPendingPR(issueBody);
+  const dependencyDashboardAllRateLimited =
+    checkOpenAllRateLimitedPR(issueBody);
   return {
     dependencyDashboardChecks,
     dependencyDashboardRebaseAllOpen,
+    dependencyDashboardAllPending,
+    dependencyDashboardAllRateLimited,
   };
 }
 
@@ -175,6 +234,11 @@ export async function ensureDependencyDashboard(
     for (const branch of pendingApprovals) {
       issueBody += getListItem(branch, 'approve');
     }
+    if (pendingApprovals.length > 1) {
+      issueBody += ' - [ ] ';
+      issueBody += '<!-- approve-all-pending-prs -->';
+      issueBody += '🔐 **Create all pending approval PRs at once** 🔐\n';
+    }
     issueBody += '\n';
   }
   const awaitingSchedule = branches.filter(
@@ -196,12 +260,17 @@ export async function ensureDependencyDashboard(
       branch.result === BranchResult.CommitLimitReached
   );
   if (rateLimited.length) {
-    issueBody += '## Rate Limited\n\n';
+    issueBody += '## Rate-Limited\n\n';
     issueBody +=
-      'These updates are currently rate limited. Click on a checkbox below to force their creation now.\n\n';
+      'These updates are currently rate-limited. Click on a checkbox below to force their creation now.\n\n';
     for (const branch of rateLimited) {
       issueBody += getListItem(branch, 'unlimit');
     }
+    if (rateLimited.length > 1) {
+      issueBody += ' - [ ] ';
+      issueBody += '<!-- create-all-rate-limited-prs -->';
+      issueBody += '🔐 **Create all rate-limited PRs at once** 🔐\n';
+    }
     issueBody += '\n';
   }
   const errorList = branches.filter(
diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts
index 9f8d8e6f6d..75be7c8be6 100644
--- a/lib/workers/repository/update/branch/index.spec.ts
+++ b/lib/workers/repository/update/branch/index.spec.ts
@@ -1818,5 +1818,69 @@ describe('workers/repository/update/branch/index', () => {
         'No package files need updating'
       );
     });
+
+    it('Dependency Dashboard All Pending approval', async () => {
+      jest.spyOn(getUpdated, 'getUpdatedPackageFiles').mockResolvedValueOnce({
+        updatedPackageFiles: [{}],
+        artifactErrors: [{}],
+      } as PackageFilesResult);
+      npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({
+        artifactErrors: [],
+        updatedArtifacts: [partial<FileChange>({})],
+      } as WriteExistingFilesResult);
+      git.branchExists.mockReturnValue(true);
+      platform.getBranchPr.mockResolvedValueOnce({
+        title: 'pending!',
+        state: PrState.Open,
+        bodyStruct: {
+          hash: hashBody(`- [x] <!-- approve-all-pending-prs -->`),
+          rebaseRequested: false,
+        },
+      } as Pr);
+      git.getBranchCommit.mockReturnValue('123test');
+      expect(
+        await branchWorker.processBranch({
+          ...config,
+          dependencyDashboardAllPending: true,
+        })
+      ).toEqual({
+        branchExists: true,
+        commitSha: '123test',
+        prNo: undefined,
+        result: 'done',
+      });
+    });
+
+    it('Dependency Dashboard open all rate-limited', async () => {
+      jest.spyOn(getUpdated, 'getUpdatedPackageFiles').mockResolvedValueOnce({
+        updatedPackageFiles: [{}],
+        artifactErrors: [{}],
+      } as PackageFilesResult);
+      npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({
+        artifactErrors: [],
+        updatedArtifacts: [partial<FileChange>({})],
+      } as WriteExistingFilesResult);
+      git.branchExists.mockReturnValue(true);
+      platform.getBranchPr.mockResolvedValueOnce({
+        title: 'unlimited!',
+        state: PrState.Open,
+        bodyStruct: {
+          hash: hashBody(`- [x] <!-- create-all-rate-limited-prs -->`),
+          rebaseRequested: false,
+        },
+      } as Pr);
+      git.getBranchCommit.mockReturnValue('123test');
+      expect(
+        await branchWorker.processBranch({
+          ...config,
+          dependencyDashboardAllRateLimited: true,
+        })
+      ).toEqual({
+        branchExists: true,
+        commitSha: '123test',
+        prNo: undefined,
+        result: 'done',
+      });
+    });
   });
 });
diff --git a/lib/workers/repository/update/branch/index.ts b/lib/workers/repository/update/branch/index.ts
index 5b91b64490..d9d7082c49 100644
--- a/lib/workers/repository/update/branch/index.ts
+++ b/lib/workers/repository/update/branch/index.ts
@@ -365,10 +365,19 @@ export async function processBranch(
       dependencyDashboardCheck === 'rebase' ||
       !!config.dependencyDashboardRebaseAllOpen ||
       !!config.rebaseRequested;
-
+    const userApproveAllPendingPR = !!config.dependencyDashboardAllPending;
+    const userOpenAllRateLimtedPR = !!config.dependencyDashboardAllRateLimited;
     if (userRebaseRequested) {
       logger.debug('Manual rebase requested via Dependency Dashboard');
       config.reuseExistingBranch = false;
+    } else if (userApproveAllPendingPR) {
+      logger.debug(
+        'A user manually approved all pending PRs via the Dependency Dashboard.'
+      );
+    } else if (userOpenAllRateLimtedPR) {
+      logger.debug(
+        'A user manually approved all rate-limited PRs via the Dependency Dashboard.'
+      );
     } else if (branchExists && config.rebaseWhen === 'never') {
       logger.debug('rebaseWhen=never so skipping branch update check');
       return {
-- 
GitLab