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