diff --git a/lib/modules/platform/bitbucket/comments.spec.ts b/lib/modules/platform/bitbucket/comments.spec.ts index c8cd3edb7a21b80aece18af25feb5a7d33bf5979..7b6ed8f0cd345c43e3161f7f1f9f3b0eeed24765 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 106a638971530b66e59553004f065387cea31609..f601701d891f3738f7c0394d0abd8692aae5ec49 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 44d4cc8afc6b48aff7b4612c88210cde067cf482..603bb2b5e91c861e0d82bcb189514edaa1be8dfc 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 81bd8c2341f1d417472113fdba6557dc0f8e3c86..26532a19d161b4ff893621788af46f7663fd8593 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 b3b95bb0f16cf736a42fb6270d509bd16d4f45f4..2bd692ca6b26801e64deb2696b30f829a94e7a9f 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 a5665bd2f7a9cbd6eaee41b230ad911de2ba7e13..d4a1e6c147c76a2373b9d86f040ca012adc3ed35 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, }; }