From ed182aa67b305e8cdbba08bec886c0f71c7ec751 Mon Sep 17 00:00:00 2001
From: Adam Setch <adam.setch@outlook.com>
Date: Sun, 2 Jul 2023 12:19:09 -0400
Subject: [PATCH] feat(platform/bitbucket): support reopening declined PRs via
 comments (#22984)

---
 .../platform/bitbucket/comments.spec.ts       |  46 +++++
 lib/modules/platform/bitbucket/comments.ts    |  31 +++-
 lib/modules/platform/bitbucket/index.spec.ts  | 164 ++++++++++++++++++
 lib/modules/platform/bitbucket/index.ts       |  29 ++++
 lib/modules/platform/bitbucket/types.ts       |   3 +
 lib/modules/platform/bitbucket/utils.ts       |   1 +
 6 files changed, 269 insertions(+), 5 deletions(-)

diff --git a/lib/modules/platform/bitbucket/comments.spec.ts b/lib/modules/platform/bitbucket/comments.spec.ts
index c8cd3edb7a..7b6ed8f0cd 100644
--- a/lib/modules/platform/bitbucket/comments.spec.ts
+++ b/lib/modules/platform/bitbucket/comments.spec.ts
@@ -47,6 +47,52 @@ describe('modules/platform/bitbucket/comments', () => {
       ).toBeTrue();
     });
 
+    it('finds reopen comment', async () => {
+      const prComment = {
+        content: {
+          raw: 'reopen! comment',
+        },
+        user: {
+          display_name: 'Bob Smith',
+          uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}',
+          account_id: '123',
+        },
+      };
+
+      expect.assertions(1);
+      httpMock
+        .scope(baseUrl)
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, {
+          values: [prComment],
+        });
+
+      expect(await comments.reopenComments(config, 5)).toEqual([prComment]);
+    });
+
+    it('finds no reopen comment', async () => {
+      const prComment = {
+        content: {
+          raw: 'comment',
+        },
+        user: {
+          display_name: 'Bob Smith',
+          uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}',
+          account_id: '123',
+        },
+      };
+
+      expect.assertions(1);
+      httpMock
+        .scope(baseUrl)
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, {
+          values: [prComment],
+        });
+
+      expect(await comments.reopenComments(config, 5)).toBeEmptyArray();
+    });
+
     it('add updates comment if necessary', async () => {
       expect.assertions(1);
       httpMock
diff --git a/lib/modules/platform/bitbucket/comments.ts b/lib/modules/platform/bitbucket/comments.ts
index 106a638971..f601701d89 100644
--- a/lib/modules/platform/bitbucket/comments.ts
+++ b/lib/modules/platform/bitbucket/comments.ts
@@ -1,13 +1,16 @@
 import { logger } from '../../../logger';
 import { BitbucketHttp } from '../../../util/http/bitbucket';
 import type { EnsureCommentConfig, EnsureCommentRemovalConfig } from '../types';
-import type { Config, PagedResult } from './types';
+import type { Account, Config, PagedResult } from './types';
+
+export const REOPEN_PR_COMMENT_KEYWORD = 'reopen!';
 
 const bitbucketHttp = new BitbucketHttp();
 
 interface Comment {
   content: { raw: string };
   id: number;
+  user: Account;
 }
 
 export type CommentsConfig = Pick<Config, 'repository'>;
@@ -123,6 +126,19 @@ export async function ensureComment({
   }
 }
 
+export async function reopenComments(
+  config: CommentsConfig,
+  prNo: number
+): Promise<Comment[]> {
+  const comments = await getComments(config, prNo);
+
+  const reopenComments = comments.filter((comment) =>
+    comment.content.raw.startsWith(REOPEN_PR_COMMENT_KEYWORD)
+  );
+
+  return reopenComments;
+}
+
 export async function ensureCommentRemoval(
   config: CommentsConfig,
   deleteConfig: EnsureCommentRemovalConfig
@@ -157,8 +173,13 @@ export async function ensureCommentRemoval(
 }
 
 function sanitizeCommentBody(body: string): string {
-  return body.replace(
-    'checking the rebase/retry box above',
-    'renaming this PR to start with "rebase!"'
-  );
+  return body
+    .replace(
+      'checking the rebase/retry box above',
+      'renaming this PR to start with "rebase!"'
+    )
+    .replace(
+      'rename this PR to get a fresh replacement',
+      'add a comment starting with "reopen!" to get a fresh replacement'
+    );
 }
diff --git a/lib/modules/platform/bitbucket/index.spec.ts b/lib/modules/platform/bitbucket/index.spec.ts
index 44d4cc8afc..603bb2b5e9 100644
--- a/lib/modules/platform/bitbucket/index.spec.ts
+++ b/lib/modules/platform/bitbucket/index.spec.ts
@@ -789,6 +789,170 @@ describe('modules/platform/bitbucket/index', () => {
         })
       ).toMatchSnapshot();
     });
+
+    it('finds closed pr with no reopen comments', async () => {
+      const prComment = {
+        content: {
+          raw: 'some comment',
+        },
+        user: {
+          display_name: 'Bob Smith',
+          uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}',
+          account_id: '123',
+        },
+      };
+
+      const scope = await initRepoMock();
+      scope
+        .get(
+          '/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&pagelen=50'
+        )
+        .reply(200, {
+          values: [
+            {
+              id: 5,
+              source: { branch: { name: 'branch' } },
+              destination: { branch: { name: 'master' } },
+              title: 'title',
+              state: 'closed',
+            },
+          ],
+        })
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, { values: [prComment] });
+
+      const pr = await bitbucket.findPr({
+        branchName: 'branch',
+        prTitle: 'title',
+      });
+      expect(pr?.number).toBe(5);
+    });
+
+    it('finds closed pr with reopen comment on private repository', async () => {
+      const prComment = {
+        content: {
+          raw: 'reopen! comment',
+        },
+        user: {
+          display_name: 'Jane Smith',
+          uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}',
+          account_id: '456',
+        },
+      };
+
+      const scope = await initRepoMock({}, { is_private: true });
+      scope
+        .get(
+          '/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&pagelen=50'
+        )
+        .reply(200, {
+          values: [
+            {
+              id: 5,
+              source: { branch: { name: 'branch' } },
+              destination: { branch: { name: 'master' } },
+              title: 'title',
+              state: 'closed',
+            },
+          ],
+        })
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, { values: [prComment] });
+
+      const pr = await bitbucket.findPr({
+        branchName: 'branch',
+        prTitle: 'title',
+      });
+      expect(pr).toBeNull();
+    });
+
+    it('finds closed pr with reopen comment on public repository from workspace member', async () => {
+      const workspaceMember = {
+        display_name: 'Jane Smith',
+        uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}',
+        account_id: '456',
+      };
+
+      const prComment = {
+        content: {
+          raw: 'reopen! comment',
+        },
+        user: workspaceMember,
+      };
+
+      const scope = await initRepoMock({}, { is_private: false });
+      scope
+        .get(
+          '/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&pagelen=50'
+        )
+        .reply(200, {
+          values: [
+            {
+              id: 5,
+              source: { branch: { name: 'branch' } },
+              destination: { branch: { name: 'master' } },
+              title: 'title',
+              state: 'closed',
+            },
+          ],
+        })
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, { values: [prComment] })
+        .get(
+          '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D'
+        )
+        .reply(200, { values: [workspaceMember] });
+
+      const pr = await bitbucket.findPr({
+        branchName: 'branch',
+        prTitle: 'title',
+      });
+      expect(pr).toBeNull();
+    });
+
+    it('finds closed pr with reopen comment on public repository from non-workspace member', async () => {
+      const nonWorkspaceMember = {
+        display_name: 'Jane Smith',
+        uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}',
+        account_id: '456',
+      };
+
+      const prComment = {
+        content: {
+          raw: 'reopen! comment',
+        },
+        user: nonWorkspaceMember,
+      };
+
+      const scope = await initRepoMock({}, { is_private: false });
+      scope
+        .get(
+          '/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&pagelen=50'
+        )
+        .reply(200, {
+          values: [
+            {
+              id: 5,
+              source: { branch: { name: 'branch' } },
+              destination: { branch: { name: 'master' } },
+              title: 'title',
+              state: 'closed',
+            },
+          ],
+        })
+        .get('/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100')
+        .reply(200, { values: [prComment] })
+        .get(
+          '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D'
+        )
+        .reply(404);
+
+      const pr = await bitbucket.findPr({
+        branchName: 'branch',
+        prTitle: 'title',
+      });
+      expect(pr?.number).toBe(5);
+    });
   });
 
   describe('createPr()', () => {
diff --git a/lib/modules/platform/bitbucket/index.ts b/lib/modules/platform/bitbucket/index.ts
index 81bd8c2341..26532a19d1 100644
--- a/lib/modules/platform/bitbucket/index.ts
+++ b/lib/modules/platform/bitbucket/index.ts
@@ -211,6 +211,7 @@ export async function initRepo({
       owner: info.owner,
       mergeMethod: info.mergeMethod,
       has_issues: info.has_issues,
+      is_private: info.is_private,
     };
 
     logger.debug(`${repository} owner = ${config.owner}`);
@@ -309,6 +310,34 @@ export async function findPr({
   if (pr) {
     logger.debug(`Found PR #${pr.number}`);
   }
+
+  /**
+   * Bitbucket doesn't support renaming or reopening declined PRs.
+   * Instead, we have to use comment-driven signals.
+   */
+  if (pr?.state === 'closed') {
+    const reopenComments = await comments.reopenComments(config, pr.number);
+
+    if (is.nonEmptyArray(reopenComments)) {
+      if (config.is_private) {
+        // Only workspace members could have commented on a private repository
+        logger.debug(
+          `Found '${comments.REOPEN_PR_COMMENT_KEYWORD}' comment from workspace member. Renovate will reopen PR ${pr.number} as a new PR`
+        );
+        return null;
+      }
+
+      for (const comment of reopenComments) {
+        if (await isAccountMemberOfWorkspace(comment.user, config.repository)) {
+          logger.debug(
+            `Found '${comments.REOPEN_PR_COMMENT_KEYWORD}' comment from workspace member. Renovate will reopen PR ${pr.number} as a new PR`
+          );
+          return null;
+        }
+      }
+    }
+  }
+
   return pr ?? null;
 }
 
diff --git a/lib/modules/platform/bitbucket/types.ts b/lib/modules/platform/bitbucket/types.ts
index b3b95bb0f1..2bd692ca6b 100644
--- a/lib/modules/platform/bitbucket/types.ts
+++ b/lib/modules/platform/bitbucket/types.ts
@@ -16,6 +16,7 @@ export interface Config {
   prList: Pr[];
   repository: string;
   ignorePrAuthor: boolean;
+  is_private: boolean;
 }
 
 export interface PagedResult<T = any> {
@@ -33,6 +34,7 @@ export interface RepoInfo {
   mergeMethod: string;
   has_issues: boolean;
   uuid: string;
+  is_private: boolean;
 }
 
 export interface RepoBranchingModel {
@@ -64,6 +66,7 @@ export interface RepoInfoBody {
   has_issues: boolean;
   uuid: string;
   full_name: string;
+  is_private: boolean;
 }
 
 export interface PrResponse {
diff --git a/lib/modules/platform/bitbucket/utils.ts b/lib/modules/platform/bitbucket/utils.ts
index a5665bd2f7..d4a1e6c147 100644
--- a/lib/modules/platform/bitbucket/utils.ts
+++ b/lib/modules/platform/bitbucket/utils.ts
@@ -19,6 +19,7 @@ export function repoInfoTransformer(repoInfoBody: RepoInfoBody): RepoInfo {
     mergeMethod: 'merge',
     has_issues: repoInfoBody.has_issues,
     uuid: repoInfoBody.uuid,
+    is_private: repoInfoBody.is_private,
   };
 }
 
-- 
GitLab