diff --git a/lib/platform/bitbucket/__snapshots__/index.spec.ts.snap b/lib/platform/bitbucket/__snapshots__/index.spec.ts.snap index 8589269d259d1e4fe5159ccf512cfb2abbf69cd1..7eb8c8f3c7d579738e49d4bb351c9df8930c4538 100644 --- a/lib/platform/bitbucket/__snapshots__/index.spec.ts.snap +++ b/lib/platform/bitbucket/__snapshots__/index.spec.ts.snap @@ -1713,7 +1713,7 @@ Array [ "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", }, "method": "GET", - "url": "https://api.bitbucket.org/2.0/users/456", + "url": "https://api.bitbucket.org/2.0/users/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D", }, Object { "headers": Object { @@ -1724,7 +1724,7 @@ Array [ "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", }, "method": "GET", - "url": "https://api.bitbucket.org/2.0/users/123", + "url": "https://api.bitbucket.org/2.0/users/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D", }, Object { "body": Object { @@ -1753,9 +1753,9 @@ Array [ ] `; -exports[`platform/bitbucket/index updatePr() rethrows exception when PR update error not due to inactive reviewers 1`] = `"Response code 400 (Bad Request)"`; +exports[`platform/bitbucket/index updatePr() rethrows exception when PR update error due to unknown reviewers error 1`] = `"Response code 400 (Bad Request)"`; -exports[`platform/bitbucket/index updatePr() rethrows exception when PR update error not due to inactive reviewers 2`] = ` +exports[`platform/bitbucket/index updatePr() rethrows exception when PR update error due to unknown reviewers error 2`] = ` Array [ Object { "headers": Object { diff --git a/lib/platform/bitbucket/index.spec.ts b/lib/platform/bitbucket/index.spec.ts index 4b464b96236052d7270356b7a1cf74768b1a925c..734429fcbc51626b6eb74cb1553d394207810b6a 100644 --- a/lib/platform/bitbucket/index.spec.ts +++ b/lib/platform/bitbucket/index.spec.ts @@ -678,6 +678,214 @@ describe('platform/bitbucket/index', () => { expect(number).toBe(5); expect(httpMock.getTrace()).toMatchSnapshot(); }); + it('removes inactive reviewers when updating pr', async () => { + const inactiveReviewer = { + display_name: 'Bob Smith', + uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', + account_id: '123', + }; + const activeReviewer = { + display_name: 'Jane Smith', + uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', + account_id: '456', + }; + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/default-reviewers') + .reply(200, { + values: [activeReviewer, inactiveReviewer], + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(400, { + type: 'error', + error: { + fields: { + reviewers: ['Malformed reviewers list'], + }, + message: 'reviewers: Malformed reviewers list', + }, + }) + .get('/2.0/users/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D') + .reply(200, { + account_status: 'inactive', + }) + .get('/2.0/users/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D') + .reply(200, { + account_status: 'active', + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(200, { id: 5 }); + const { number } = await bitbucket.createPr({ + sourceBranch: 'branch', + targetBranch: 'master', + prTitle: 'title', + prBody: 'body', + platformOptions: { + bbUseDefaultReviewers: true, + }, + }); + expect(number).toBe(5); + }); + it('removes default reviewers no longer member of the workspace when creating pr', async () => { + const notMemberReviewer = { + display_name: 'Bob Smith', + uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', + account_id: '123', + }; + const memberReviewer = { + display_name: 'Jane Smith', + uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', + account_id: '456', + }; + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/default-reviewers') + .reply(200, { + values: [memberReviewer, notMemberReviewer], + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(400, { + type: 'error', + error: { + fields: { + reviewers: [ + 'Bob Smith is not a member of this workspace and cannot be added to this pull request', + ], + }, + message: + 'reviewers: Bob Smith is not a member of this workspace and cannot be added to this pull request', + }, + }) + .head( + '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D' + ) + .reply(404) + .head( + '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D' + ) + .reply(200) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(200, { id: 5 }); + const { number } = await bitbucket.createPr({ + sourceBranch: 'branch', + targetBranch: 'master', + prTitle: 'title', + prBody: 'body', + platformOptions: { + bbUseDefaultReviewers: true, + }, + }); + expect(number).toBe(5); + }); + it('throws exception when unable to check default reviewers workspace membership', async () => { + const reviewer = { + display_name: 'Bob Smith', + uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', + account_id: '123', + }; + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/default-reviewers') + .reply(200, { + values: [reviewer], + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(400, { + type: 'error', + error: { + fields: { + reviewers: [ + 'Bob Smith is not a member of this workspace and cannot be added to this pull request', + ], + }, + message: + 'reviewers: Bob Smith is not a member of this workspace and cannot be added to this pull request', + }, + }) + .head( + '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D' + ) + .reply(401); + await expect(() => + bitbucket.createPr({ + sourceBranch: 'branch', + targetBranch: 'master', + prTitle: 'title', + prBody: 'body', + platformOptions: { + bbUseDefaultReviewers: true, + }, + }) + ).rejects.toThrow(new Error('Response code 401 (Unauthorized)')); + }); + it('rethrows exception when PR create error due to unknown reviewers error', async () => { + const reviewer = { + display_name: 'Jane Smith', + uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', + }; + + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/default-reviewers') + .reply(200, { + values: [reviewer], + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(400, { + type: 'error', + error: { + fields: { + reviewers: ['Some other unhandled error'], + }, + message: 'Some other unhandled error', + }, + }); + await expect(() => + bitbucket.createPr({ + sourceBranch: 'branch', + targetBranch: 'master', + prTitle: 'title', + prBody: 'body', + platformOptions: { + bbUseDefaultReviewers: true, + }, + }) + ).rejects.toThrow(new Error('Response code 400 (Bad Request)')); + }); + it('rethrows exception when PR create error not due to reviewers field', async () => { + const reviewer = { + display_name: 'Jane Smith', + uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', + }; + + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/default-reviewers') + .reply(200, { + values: [reviewer], + }) + .post('/2.0/repositories/some/repo/pullrequests') + .reply(400, { + type: 'error', + error: { + fields: { + description: ['Some other unhandled error'], + }, + message: 'Some other unhandled error', + }, + }); + await expect(() => + bitbucket.createPr({ + sourceBranch: 'branch', + targetBranch: 'master', + prTitle: 'title', + prBody: 'body', + platformOptions: { + bbUseDefaultReviewers: true, + }, + }) + ).rejects.toThrow(new Error('Response code 400 (Bad Request)')); + }); }); describe('getPr()', () => { @@ -765,11 +973,11 @@ describe('platform/bitbucket/index', () => { message: 'reviewers: Malformed reviewers list', }, }) - .get('/2.0/users/123') + .get('/2.0/users/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D') .reply(200, { account_status: 'inactive', }) - .get('/2.0/users/456') + .get('/2.0/users/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D') .reply(200, { account_status: 'active', }) @@ -806,9 +1014,13 @@ describe('platform/bitbucket/index', () => { 'reviewers: Bob Smith is not a member of this workspace and cannot be added to this pull request', }, }) - .head('/2.0/workspaces/some/members/123') + .head( + '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D' + ) .reply(404) - .head('/2.0/workspaces/some/members/456') + .head( + '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D' + ) .reply(200) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200); @@ -843,13 +1055,15 @@ describe('platform/bitbucket/index', () => { 'reviewers: Bob Smith is not a member of this workspace and cannot be added to this pull request', }, }) - .head('/2.0/workspaces/some/members/123') + .head( + '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D' + ) .reply(401); await expect(() => bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }) ).rejects.toThrow(new Error('Response code 401 (Unauthorized)')); }); - it('rethrows exception when PR update error not due to inactive reviewers', async () => { + it('rethrows exception when PR update error due to unknown reviewers error', async () => { const reviewer = { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', @@ -874,6 +1088,30 @@ describe('platform/bitbucket/index', () => { ).rejects.toThrowErrorMatchingSnapshot(); expect(httpMock.getTrace()).toMatchSnapshot(); }); + it('rethrows exception when PR create error not due to reviewers field', async () => { + const reviewer = { + display_name: 'Jane Smith', + uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', + }; + + const scope = await initRepoMock(); + scope + .get('/2.0/repositories/some/repo/pullrequests/5') + .reply(200, { reviewers: [reviewer] }) + .put('/2.0/repositories/some/repo/pullrequests/5') + .reply(400, { + type: 'error', + error: { + fields: { + description: ['Some other unhandled error'], + }, + message: 'Some other unhandled error', + }, + }); + await expect(() => + bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }) + ).rejects.toThrow(new Error('Response code 400 (Bad Request)')); + }); it('throws an error on failure to get current list of reviewers', async () => { const scope = await initRepoMock(); scope diff --git a/lib/platform/bitbucket/index.ts b/lib/platform/bitbucket/index.ts index eb1998d15626382c754adefe7ce2ca67ccc0daab..5237540f680ca9e0da2c511bce9666b064f1b243 100644 --- a/lib/platform/bitbucket/index.ts +++ b/lib/platform/bitbucket/index.ts @@ -32,10 +32,9 @@ import { readOnlyIssueBody } from '../utils/read-only-issue-body'; import * as comments from './comments'; import * as utils from './utils'; import { + Account, PrResponse, - PrReviewer, RepoInfoBody, - UserResponse, mergeBodyTransformer, } from './utils'; @@ -51,10 +50,6 @@ const pathSeparator = '/'; let renovateUserUuid: string; -const inactiveReviewersMessage = 'reviewers: Malformed reviewers list'; -const notMemberReviewersMessage = - 'is not a member of this workspace and cannot be added to this pull request'; - export async function initPlatform({ endpoint, username, @@ -75,7 +70,7 @@ export async function initPlatform({ renovateUserUuid = null; try { const { uuid } = ( - await bitbucketHttp.getJson<{ uuid: string }>('/2.0/user', { + await bitbucketHttp.getJson<Account>('/2.0/user', { username, password, useCache: false, @@ -639,6 +634,71 @@ export function ensureCommentRemoval( return comments.ensureCommentRemoval(config, deleteConfig); } +async function sanitizeReviewers( + reviewers: Account[], + err: any +): Promise<Account[]> { + if (err.statusCode === 400 && err.body?.error?.fields?.reviewers) { + const sanitizedReviewers: Account[] = []; + + for (const msg of err.body.error.fields.reviewers) { + // Bitbucket returns a 400 if any of the PR reviewer accounts are now inactive (ie: disabled/suspended) + if (msg === 'Malformed reviewers list') { + logger.debug( + { err }, + 'PR contains inactive reviewer accounts. Will try setting only active reviewers' + ); + + // Validate that each previous PR reviewer account is still active + for (const reviewer of reviewers) { + const reviewerUser = ( + await bitbucketHttp.getJson<Account>(`/2.0/users/${reviewer.uuid}`) + ).body; + + if (reviewerUser.account_status === 'active') { + sanitizedReviewers.push(reviewer); + } + } + + // Bitbucket returns a 400 if any of the PR reviewer accounts are no longer members of this workspace + } else if ( + msg.endsWith( + 'is not a member of this workspace and cannot be added to this pull request' + ) + ) { + logger.debug( + { err }, + 'PR contains reviewer accounts which are no longer member of this workspace. Will try setting only member reviewers' + ); + + const workspace = config.repository.split('/')[0]; + + // Validate that each previous PR reviewer account is still a member of this workspace + for (const reviewer of reviewers) { + try { + await bitbucketHttp.head( + `/2.0/workspaces/${workspace}/members/${reviewer.uuid}` + ); + + sanitizedReviewers.push(reviewer); + } catch (err) { + // HTTP 404: User cannot be found, or the user is not a member of this workspace. + if (err.response?.statusCode !== 404) { + throw err; + } + } + } + } else { + return undefined; + } + } + + return sanitizedReviewers; + } + + return undefined; +} + // Creates PR and returns PR number export async function createPr({ sourceBranch, @@ -653,15 +713,15 @@ export async function createPr({ logger.debug({ repository: config.repository, title, base }, 'Creating PR'); - let reviewers: { uuid: { raw: string } }[] = []; + let reviewers: Account[] = []; if (platformOptions?.bbUseDefaultReviewers) { const reviewersResponse = ( - await bitbucketHttp.getJson<utils.PagedResult<Reviewer>>( + await bitbucketHttp.getJson<utils.PagedResult<Account>>( `/2.0/repositories/${config.repository}/default-reviewers` ) ).body; - reviewers = reviewersResponse.values.map((reviewer: Reviewer) => ({ + reviewers = reviewersResponse.values.map((reviewer: Account) => ({ uuid: reviewer.uuid, })); } @@ -699,13 +759,32 @@ export async function createPr({ } return pr; } catch (err) /* istanbul ignore next */ { - logger.warn({ err }, 'Error creating pull request'); - throw err; - } -} + // Try sanitizing reviewers + const sanitizedReviewers = await sanitizeReviewers(reviewers, err); -interface Reviewer { - uuid: { raw: string }; + if (sanitizedReviewers === undefined) { + logger.warn({ err }, 'Error creating pull request'); + throw err; + } else { + const prRes = ( + await bitbucketHttp.postJson<PrResponse>( + `/2.0/repositories/${config.repository}/pullrequests`, + { + body: { + ...body, + reviewers: sanitizedReviewers, + }, + } + ) + ).body; + const pr = utils.prInfo(prRes); + // istanbul ignore if + if (config.prList) { + config.prList.push(pr); + } + return pr; + } + } } export async function updatePr({ @@ -734,61 +813,12 @@ export async function updatePr({ } ); } catch (err) { - if ( - err.statusCode === 400 && - [inactiveReviewersMessage, notMemberReviewersMessage].some((m) => - err.body?.error?.message.includes(m) - ) - ) { - const sanitizedReviewers: PrReviewer[] = []; - - // Bitbucket returns a 400 if any of the PR reviewer accounts are now inactive (ie: disabled/suspended) - if (err.body?.error?.message.includes(inactiveReviewersMessage)) { - logger.warn( - { err }, - 'PR contains inactive reviewer accounts. Will try setting only active reviewers' - ); - - // Validate that each previous PR reviewer account is still active - for (const reviewer of pr.reviewers) { - const reviewerUser = ( - await bitbucketHttp.getJson<UserResponse>( - `/2.0/users/${reviewer.account_id}` - ) - ).body; - - if (reviewerUser.account_status === 'active') { - sanitizedReviewers.push(reviewer); - } - } - } - - // Bitbucket returns a 400 if any of the PR reviewer accounts are no longer members of this workspace - if (err.body?.error?.message.includes(notMemberReviewersMessage)) { - logger.warn( - { err }, - 'PR contains reviewer accounts which are no longer member of this workspace. Will try setting only member reviewers' - ); - - const workspace = config.repository.split('/')[0]; - - // Validate that each previous PR reviewer account is still a member of this workspace - for (const reviewer of pr.reviewers) { - try { - await bitbucketHttp.head( - `/2.0/workspaces/${workspace}/members/${reviewer.account_id}` - ); - - sanitizedReviewers.push(reviewer); - } catch (err) { - // HTTP 404: User cannot be found, or the user is not a member of this workspace. - if (err.response?.statusCode !== 404) { - throw err; - } - } - } - } + // Try sanitizing reviewers + const sanitizedReviewers = await sanitizeReviewers(pr.reviewers, err); + if (sanitizedReviewers === undefined) { + throw err; + } else { await bitbucketHttp.putJson( `/2.0/repositories/${config.repository}/pullrequests/${prNo}`, { @@ -799,8 +829,6 @@ export async function updatePr({ }, } ); - } else { - throw err; } } diff --git a/lib/platform/bitbucket/utils.ts b/lib/platform/bitbucket/utils.ts index e74338854e7e71f0d8cf497021ff8016223bf6cb..fbdbdeb808aa529c985bb0b55d7aa48f968a0dcc 100644 --- a/lib/platform/bitbucket/utils.ts +++ b/lib/platform/bitbucket/utils.ts @@ -172,7 +172,7 @@ export interface PrResponse { name: string; }; }; - reviewers: Array<PrReviewer>; + reviewers: Array<Account>; created_on: string; } @@ -191,15 +191,9 @@ export function prInfo(pr: PrResponse): Pr { }; } -export interface UserResponse { - display_name: string; - account_id: string; - nickname: string; - account_status: string; -} - -export interface PrReviewer { - display_name: string; - account_id: string; - nickname: string; +export interface Account { + display_name?: string; + uuid: string; + nickname?: string; + account_status?: string; }