import { hostRules } from '~test/host-rules.ts'; import * as httpMock from '~test/http-mock.ts'; import { git, logger } from '~test/util.ts'; import { GlobalConfig } from '../../../config/global.ts'; import { InheritConfig } from '../../../config/inherit.ts'; import * as memCache from '../../../util/cache/memory/index.ts'; import { setBaseUrl } from '../../../util/http/bitbucket.ts'; import type { PlatformResult, RepoParams } from '../types.ts'; import * as bitbucket from './index.ts'; import type { PrTask } from './schema.ts'; const baseUrl = 'https://api.bitbucket.org'; const pr = { id: 5, source: { branch: { name: 'branch' } }, destination: { branch: { name: 'master' } }, title: 'title', summary: { raw: 'summary' }, state: 'OPEN', created_on: '2018-07-02T07:02:25.275030+00:00', draft: false, }; describe('modules/platform/bitbucket/index', () => { beforeEach(() => { git.branchExists.mockReturnValue(true); git.isBranchBehindBase.mockResolvedValue(false); hostRules.add({ username: 'abc', password: '123', }); bitbucket.resetPlatform(); setBaseUrl(baseUrl); memCache.init(); GlobalConfig.reset(); InheritConfig.reset(); }); async function initRepoMock( config?: Partial, repoResp?: any, existingScope?: httpMock.Scope, ): Promise { const repository = config?.repository ?? 'some/repo'; const scope = existingScope ?? httpMock.scope(baseUrl); scope.get(`/2.0/repositories/${repository}`).reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', ...repoResp, }); await bitbucket.initRepo({ repository: 'some/repo', ...config, }); return scope; } describe('initPlatform()', () => { it('should throw if no token or username/password', async () => { expect.assertions(1); await expect(bitbucket.initPlatform({})).rejects.toThrow( 'Init: You must configure either a Bitbucket token or username and', ); }); it('should show warning message if custom endpoint', async () => { await bitbucket.initPlatform({ endpoint: 'endpoint', username: 'abc', password: '123', }); expect(logger.logger.warn).toHaveBeenCalledWith( { endpoint: 'endpoint', defaultEndpoint: 'https://api.bitbucket.org/', }, 'Init: Bitbucket Cloud endpoint should generally be the default but is being configured to a different value. Did you mean to use Bitbucket Server?', ); }); it('should init with username/password', async () => { const expectedResult: PlatformResult = { endpoint: baseUrl, }; httpMock.scope(baseUrl).get('/2.0/user').reply(200); await expect( bitbucket.initPlatform({ endpoint: baseUrl, username: 'abc', password: '123', }), ).resolves.toEqual(expectedResult); }); it('should init with only token', async () => { const expectedResult: PlatformResult = { endpoint: baseUrl, }; httpMock.scope(baseUrl).get('/2.0/user').reply(200); await expect( bitbucket.initPlatform({ endpoint: baseUrl, token: 'abc', }), ).resolves.toEqual(expectedResult); }); it('should warn for missing "profile" scope', async () => { const scope = httpMock.scope(baseUrl); scope .get('/2.0/user') .reply(403, { error: { detail: { required: ['account'] } } }); await bitbucket.initPlatform({ username: 'renovate', password: 'pass' }); expect(logger.logger.warn).toHaveBeenCalledWith( `Bitbucket: missing 'account' scope for password`, ); }); }); describe('getRepos()', () => { it('returns repos', async () => { httpMock .scope(baseUrl) .get('/2.0/user/workspaces?pagelen=100') .reply(200, { values: [ { workspace: { slug: 'foo' } }, { workspace: { slug: 'some' } }, ], }) .get('/2.0/repositories/foo?pagelen=100') .reply(200, { values: [ { mainbranch: { name: 'master' }, uuid: '111', full_name: 'foo/bar', }, ], }) .get('/2.0/repositories/some?pagelen=100') .reply(200, { values: [ { mainbranch: { name: 'master' }, uuid: '222', full_name: 'some/repo', }, ], }); const res = await bitbucket.getRepos({}); expect(res).toEqual(['foo/bar', 'some/repo']); }); it('uses configured namespaces directly without fetching workspaces', async () => { httpMock .scope(baseUrl) .get('/2.0/repositories/foo?pagelen=100') .reply(200, { values: [ { mainbranch: { name: 'master' }, uuid: '111', full_name: 'foo/bar', }, ], }); const res = await bitbucket.getRepos({ namespaces: ['foo'] }); expect(res).toEqual(['foo/bar']); }); it('filters repos based on autodiscoverProjects patterns', async () => { httpMock .scope(baseUrl) .get('/2.0/user/workspaces?pagelen=100') .reply(200, { values: [{ workspace: { slug: 'some' } }], }) .get('/2.0/repositories/some?pagelen=100') .reply(200, { values: [ { mainbranch: { name: 'master' }, uuid: '111', full_name: 'some/bar', project: { name: 'ignore' }, }, { mainbranch: { name: 'master' }, uuid: '222', full_name: 'some/repo', project: { name: 'allow' }, }, ], }); const res = await bitbucket.getRepos({ projects: ['allow'] }); expect(res).toEqual(['some/repo']); }); it('filters repos based on autodiscoverProjects patterns with negation', async () => { httpMock .scope(baseUrl) .get('/2.0/user/workspaces?pagelen=100') .reply(200, { values: [{ workspace: { slug: 'foo' } }], }) .get('/2.0/repositories/foo?pagelen=100') .reply(200, { values: [ { mainbranch: { name: 'master' }, uuid: '111', full_name: 'foo/bar', project: { name: 'ignore' }, }, { mainbranch: { name: 'master' }, uuid: '222', full_name: 'some/repo', project: { name: 'allow' }, }, ], }); const res = await bitbucket.getRepos({ projects: ['!ignore'] }); expect(res).toEqual(['some/repo']); }); }); describe('initRepo()', () => { it('works with username and password', async () => { httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }); await expect( bitbucket.initRepo({ repository: 'some/repo', }), ).resolves.toMatchObject({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('works with only API token', async () => { hostRules.clear(); hostRules.add({ password: 'ATATIAMACONTAINERTOKEN3407361359', }); httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }); await expect( bitbucket.initRepo({ repository: 'some/repo', }), ).resolves.toMatchObject({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('works with only access token', async () => { hostRules.clear(); hostRules.add({ token: 'abc', }); httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }); await expect( bitbucket.initRepo({ repository: 'some/repo', }), ).resolves.toMatchObject({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); }); describe('bbUseDevelopmentBranch', () => { it('not enabled: defaults to using main branch', async () => { GlobalConfig.set({ bbUseDevelopmentBranch: false, }); httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }); const res = await bitbucket.initRepo({ repository: 'some/repo', }); expect(res.defaultBranch).toBe('master'); }); it('enabled: uses development branch when development branch exists', async () => { GlobalConfig.set({ bbUseDevelopmentBranch: false, }); InheritConfig.set({ bbUseDevelopmentBranch: true, }); httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }) .get('/2.0/repositories/some/repo/effective-branching-model') .reply(200, { development: { name: 'develop' }, }); const res = await bitbucket.initRepo({ repository: 'some/repo', }); expect(res.defaultBranch).toBe('develop'); }); it('enabled: falls back to mainbranch if development branch does not exist', async () => { GlobalConfig.set({ bbUseDevelopmentBranch: true, }); httpMock .scope(baseUrl) .get('/2.0/repositories/some/repo') .reply(200, { mainbranch: { name: 'master' }, uuid: '123', full_name: 'some/repo', }) .get('/2.0/repositories/some/repo/effective-branching-model') .reply(200, {}); const res = await bitbucket.initRepo({ repository: 'some/repo', }); expect(res.defaultBranch).toBe('master'); }); }); describe('getBranchPr()', () => { it('bitbucket finds PR for branch', async () => { const scope = await initRepoMock(); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [pr] }) .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, pr); await expect(bitbucket.getBranchPr('branch')).resolves.toMatchObject({ number: 5, sourceBranch: 'branch', state: 'open', targetBranch: 'master', title: 'title', }); }); it('returns null if no PR for branch', async () => { const scope = await initRepoMock(); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [pr] }); const res = await bitbucket.getBranchPr('branch_without_pr'); expect(res).toBeNull(); }); }); describe('getBranchStatus()', () => { it('getBranchStatus 3', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/master') .reply(200, { name: 'master', target: { hash: 'master_hash' }, }) .get( '/2.0/repositories/some/repo/commit/master_hash/statuses?pagelen=100', ) .reply(200, { values: [ { key: 'foo', state: 'FAILED', }, ], }); await expect(bitbucket.getBranchStatus('master', true)).resolves.toBe( 'red', ); }); it('getBranchStatus 4', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/branch') .reply(200, { name: 'branch', target: { hash: 'branch_hash', parents: [{ hash: 'master_hash' }], }, }) .get( '/2.0/repositories/some/repo/commit/branch_hash/statuses?pagelen=100', ) .reply(200, { values: [ { key: 'renovate/stability-days', state: 'SUCCESSFUL', }, ], }); await expect(bitbucket.getBranchStatus('branch', true)).resolves.toBe( 'green', ); }); it('getBranchStatus 5', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/pending/branch') .reply(200, { name: 'pending/branch', target: { hash: 'pending/branch_hash', parents: [{ hash: 'master_hash' }], }, }) .get( '/2.0/repositories/some/repo/commit/pending/branch_hash/statuses?pagelen=100', ) .reply(200, { values: [ { key: 'foo', state: 'INPROGRESS', }, ], }); await expect( bitbucket.getBranchStatus('pending/branch', true), ).resolves.toBe('yellow'); }); it('getBranchStatus 6', async () => { const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/refs/branches/branch-with-empty-status', ) .reply(200, { name: 'branch-with-empty-status', target: { hash: 'branch-with-empty-status', parents: [{ hash: 'master_hash' }], }, }) .get( '/2.0/repositories/some/repo/commit/branch-with-empty-status/statuses?pagelen=100', ) .reply(200, { values: [], }); await expect( bitbucket.getBranchStatus('branch-with-empty-status', true), ).resolves.toBe('yellow'); }); it('getBranchStatus 7', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/branch') .reply(200, { name: 'branch', target: { hash: 'branch_hash', parents: [{ hash: 'master_hash' }], }, }) .get( '/2.0/repositories/some/repo/commit/branch_hash/statuses?pagelen=100', ) .reply(200, { values: [ { key: 'renovate/stability-days', state: 'SUCCESSFUL', }, ], }); await expect(bitbucket.getBranchStatus('branch', false)).resolves.toBe( 'yellow', ); }); }); describe('getBranchStatusCheck()', () => { beforeEach(async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/master') .reply(200, { name: 'master', target: { hash: 'master_hash' }, }) .get( '/2.0/repositories/some/repo/commit/master_hash/statuses?pagelen=100', ) .reply(200, { values: [ { key: 'foo', state: 'FAILED', }, ], }); }); it('getBranchStatusCheck 1', async () => { await expect( bitbucket.getBranchStatusCheck('master', ''), ).resolves.toBeNull(); }); it('getBranchStatusCheck 2', async () => { await expect( bitbucket.getBranchStatusCheck('master', 'foo'), ).resolves.toBe('red'); }); it('getBranchStatusCheck 3', async () => { await expect( bitbucket.getBranchStatusCheck('master', 'bar'), ).resolves.toBeNull(); }); }); describe('setBranchStatus()', () => { it('posts status', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/branch') .reply(200, { name: 'branch', target: { hash: 'branch_hash', parents: [{ hash: 'master_hash' }], }, }) .post('/2.0/repositories/some/repo/commit/branch_hash/statuses/build') .reply(200); await expect( bitbucket.setBranchStatus({ branchName: 'branch', context: 'context', description: 'description', state: 'red', url: 'targetUrl', }), ).toResolve(); }); }); describe('findIssue()', () => { it('returns null as issues are unsupported', async () => { await initRepoMock(); await expect(bitbucket.findIssue('title')).resolves.toBeNull(); expect(logger.logger.once.debug).toHaveBeenCalledWith( expect.stringContaining( 'Bitbucket Cloud has removed its issue tracker', ), ); }); }); describe('ensureIssue()', () => { it('returns null as issues are unsupported', async () => { await initRepoMock(); await expect( bitbucket.ensureIssue({ title: 'title', body: 'body' }), ).resolves.toBeNull(); expect(logger.logger.once.warn).toHaveBeenCalledWith( { title: 'title' }, 'Cannot ensure issue', ); }); }); describe('ensureIssueClosing()', () => { it('does not throw as issues are unsupported', async () => { await initRepoMock(); await expect(bitbucket.ensureIssueClosing('title')).toResolve(); }); }); describe('getIssueList()', () => { it('returns empty array as issues are unsupported', async () => { await initRepoMock(); await expect(bitbucket.getIssueList()).resolves.toEqual([]); }); }); describe('addAssignees()', () => { it('does not throw', async () => { await expect( bitbucket.addAssignees(3, ['some']), ).resolves.toBeUndefined(); }); }); describe('addReviewers', () => { it('should add the given reviewers to the PR', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, pr) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200); await expect( bitbucket.addReviewers(5, ['someuser', 'someotheruser']), ).toResolve(); }); it('should handle reviewers as username or UUID', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, pr) .put('/2.0/repositories/some/repo/pullrequests/5', { title: pr.title, reviewers: [ { username: 'someuser' }, { uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}' }, ], }) .reply(200); await expect( bitbucket.addReviewers(5, [ 'someuser', '{90b6646d-1724-4a64-9fd9-539515fe94e9}', ]), ).toResolve(); }); }); describe('ensureComment()', () => { it('does not throw', async () => { httpMock .scope(baseUrl) .get('/2.0/repositories/undefined/pullrequests/3/comments?pagelen=100') .reply(500); await expect( bitbucket.ensureComment({ number: 3, topic: 'topic', content: 'content', }), ).resolves.toBeFalse(); }); }); describe('ensureCommentRemoval()', () => { it('does not throw', async () => { httpMock .scope(baseUrl) .get('/2.0/repositories/undefined/pullrequests/3/comments?pagelen=100') .reply(500); await expect( bitbucket.ensureCommentRemoval({ type: 'by-topic', number: 3, topic: 'topic', }), ).resolves.toBeUndefined(); }); }); describe('getPrList()', () => { it('exists', () => { expect(bitbucket.getPrList).toBeDefined(); }); it('filters PR list by author', async () => { const scope = httpMock.scope(baseUrl); scope.get('/2.0/user').reply(200, { uuid: '12345' }); await bitbucket.initPlatform({ username: 'renovate', password: 'pass' }); await initRepoMock(undefined, null, scope); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [ { id: 1, author: { uuid: '12345' }, source: { branch: { name: 'branch-a' } }, destination: { branch: { name: 'branch-b' } }, state: 'OPEN', }, ], }); await expect(bitbucket.getPrList()).resolves.toMatchObject([ { number: 1, sourceBranch: 'branch-a', state: 'open', targetBranch: 'branch-b', }, ]); }); }); describe('findPr()', () => { it('exists', () => { expect(bitbucket.findPr).toBeDefined(); }); it('finds pr', async () => { const scope = await initRepoMock(); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [pr] }); await expect( bitbucket.findPr({ branchName: 'branch', prTitle: 'title', }), ).resolves.toMatchObject({ number: 5, sourceBranch: 'branch', state: 'open', title: 'title', }); }); 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`) .query(true) .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`) .query(true) .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`) .query(true) .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`) .query(true) .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); }); it('finds pr from other authors', async () => { const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/pullrequests?q=source.branch.name="branch"&state=open', ) .reply(200, { values: [pr] }); await expect( bitbucket.findPr({ branchName: 'branch', state: 'open', includeOtherAuthors: true, }), ).resolves.toMatchObject({ number: 5, sourceBranch: 'branch', targetBranch: 'master', title: 'title', state: 'open', }); }); it('returns null if no open pr exists - (includeOtherAuthors)', async () => { const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/pullrequests?q=source.branch.name="branch"&state=open', ) .reply(200, { values: [], }); const pr = await bitbucket.findPr({ branchName: 'branch', state: 'open', includeOtherAuthors: true, }); expect(pr).toBeNull(); }); }); describe('createPr()', () => { it('posts PR', async () => { const projectReviewer = { type: 'default_reviewer', reviewer_type: 'project', user: { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }, }; const repoReviewer = { type: 'default_reviewer', reviewer_type: 'repository', user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }, }; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .reply(200, { values: [projectReviewer, repoReviewer], }) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, draftPR: false, }); expect(pr?.number).toBe(5); }); it('posts draft PR', async () => { const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .reply(200, { values: [], }) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, draftPR: true, }); expect(pr?.number).toBe(5); }); it('removes inactive reviewers when creating pr', async () => { const inactiveReviewer = { user: { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }, }; const activeReviewerOutsideOfWorkspace = { user: { display_name: 'Alice Smith', uuid: '{a10e0228-ad84-11ed-afa1-0242ac120002}', account_id: '789', }, }; const activeReviewerWithinWorkspace = { user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }, }; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .reply(200, { values: [ activeReviewerWithinWorkspace, activeReviewerOutsideOfWorkspace, 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/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D') .reply(200, { account_status: 'active', }) .get( '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D', ) .reply(200) .get('/2.0/users/%7Ba10e0228-ad84-11ed-afa1-0242ac120002%7D') .reply(200, { account_status: 'active', }) .get( '/2.0/workspaces/some/members/%7Ba10e0228-ad84-11ed-afa1-0242ac120002%7D', ) .reply(404) .get('/2.0/users/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D') .reply(200, { account_status: 'inactive', }) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, }); expect(pr?.number).toBe(5); }); it('removes default reviewers no longer member of the workspace when creating pr', async () => { const notMemberReviewer = { user: { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }, }; const memberReviewer = { user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }, }; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .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', }, }) .get( '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D', ) .reply(404) .get( '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D', ) .reply(200) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, }); expect(pr?.number).toBe(5); }); it('throws exception when unable to check default reviewers workspace membership', async () => { const reviewer = { 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/effective-default-reviewers?pagelen=100', ) .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', }, }) .get( '/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', platformPrOptions: { bbUseDefaultReviewers: true, }, }), ).rejects.toThrow( 'Request failed with status code 401 (Unauthorized): GET https://api.bitbucket.org/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D', ); }); it('removes reviewer if they are also the author of the pr', async () => { const reviewers = [ { user: { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }, }, { user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }, }, ]; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .reply(200, { values: reviewers, }) .post('/2.0/repositories/some/repo/pullrequests') .reply(400, { type: 'error', error: { fields: { reviewers: [ 'Jane Smith is the author and cannot be included as a reviewer.', ], }, }, }) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, }); expect(pr?.number).toBe(5); }); it('rethrows exception when PR create error due to unknown reviewers error', async () => { const reviewer = { user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', }, }; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .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', platformPrOptions: { bbUseDefaultReviewers: true, }, }), ).rejects.toThrow( 'Request failed with status code 400 (Bad Request): POST https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests', ); }); it('rethrows exception when PR create error not due to reviewers field', async () => { const reviewer = { user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', }, }; const scope = await initRepoMock(); scope .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .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', platformPrOptions: { bbUseDefaultReviewers: true, }, }), ).rejects.toThrow( 'Request failed with status code 400 (Bad Request): POST https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests', ); }); it('lists PR tasks and resolves the unresolved tasks', async () => { const prTask1: PrTask = { id: 1, state: 'UNRESOLVED', content: { raw: 'task 1', }, }; const resolvedPrTask1: Partial = { state: 'RESOLVED', content: { raw: 'task 1', }, }; const prTask2: PrTask = { id: 2, state: 'UNRESOLVED', content: { raw: 'task 2', }, }; const resolvedPrTask2: Partial = { state: 'RESOLVED', content: { raw: 'task 2', }, }; const prTask3: PrTask = { id: 3, state: 'RESOLVED', content: { raw: 'task 3', }, }; const scope = await initRepoMock(); scope .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); scope .get('/2.0/repositories/some/repo/pullrequests/5/tasks') .query({ pagelen: 100, }) .reply(200, { values: [prTask1, prTask2, prTask3] }); scope .put( '/2.0/repositories/some/repo/pullrequests/5/tasks/1', resolvedPrTask1, ) .reply(200); scope .put( '/2.0/repositories/some/repo/pullrequests/5/tasks/2', resolvedPrTask2, ) .reply(200); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: false, bbAutoResolvePrTasks: true, }, }); expect(pr?.number).toBe(5); }); it('swallows list PR error and PR creation succeeds', async () => { const scope = await initRepoMock(); scope .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); scope .get('/2.0/repositories/some/repo/pullrequests/5/tasks') .query({ pagelen: 100, }) .reply(500); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: false, bbAutoResolvePrTasks: true, }, }); expect(pr?.number).toBe(5); }); it('swallows resolve PR task error and PR creation succeeds', async () => { const prTask1: PrTask = { id: 1, state: 'UNRESOLVED', content: { raw: 'task 1', }, }; const resolvedPrTask1: Partial = { state: 'RESOLVED', content: { raw: 'task 1', }, }; const scope = await initRepoMock(); scope .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); scope .get('/2.0/repositories/some/repo/pullrequests/5/tasks') .query({ pagelen: 100, }) .reply(200, { values: [prTask1] }); scope .put( '/2.0/repositories/some/repo/pullrequests/5/tasks/1', resolvedPrTask1, ) .reply(500); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: false, bbAutoResolvePrTasks: true, }, }); expect(pr?.number).toBe(5); }); }); describe('getPr()', () => { it('exists', async () => { const scope = await initRepoMock(); scope.get('/2.0/repositories/some/repo/pullrequests/5').reply(200, pr); await expect(bitbucket.getPr(5)).resolves.toMatchObject({ number: 5, sourceBranch: 'branch', state: 'open', targetBranch: 'master', title: 'title', }); }); it('canRebase', async () => { expect.assertions(3); const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/3') .reply(200, { id: 3, source: { branch: { name: 'branch' } }, destination: { branch: { name: 'master' } }, title: 'title', summary: { raw: 'summary' }, state: 'OPEN', created_on: '2018-07-02T07:02:25.275030+00:00', }) .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, pr); const expectedPr5 = { number: 5, sourceBranch: 'branch', state: 'open', targetBranch: 'master', title: 'title', }; await expect(bitbucket.getPr(3)).resolves.toMatchObject({ ...expectedPr5, number: 3, }); await expect(bitbucket.getPr(5)).resolves.toMatchObject(expectedPr5); await expect(bitbucket.getPr(5)).resolves.toMatchObject(expectedPr5); }); it('reviewers', async () => { const reviewer = { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }; const scope = await initRepoMock(); scope.get('/2.0/repositories/some/repo/pullrequests/5').reply(200, { ...pr, reviewers: [reviewer], }); await expect(bitbucket.getPr(5)).resolves.toEqual({ bodyStruct: { hash: '761b7ad8ad439b2855fcbb611331c646ef0870b0631247bba3f3025cb6df5a53', }, createdAt: '2018-07-02T07:02:25.275030+00:00', number: 5, reviewers: ['{90b6646d-1724-4a64-9fd9-539515fe94e9}'], sourceBranch: 'branch', state: 'open', targetBranch: 'master', title: 'title', }); }); }); describe('massageMarkdown()', () => { it('removes html tags', () => { const prBody = '
foo\n
\n\n
text' + '\n---\n\n - [ ] rebase\n' + '\n\n
\n\n
\n
'; expect(bitbucket.massageMarkdown(prBody)).toBe( ' - **foo**\n\n\n\t - `text`\n\n\n\n\n', ); }); it('updates pull request url links', () => { const prBody = '[Some pull request](../pull/123)'; expect(bitbucket.massageMarkdown(prBody)).toEqual( '[Some pull request](../../pull-requests/123)', ); }); it('updates issues url links', () => { const prBody = '[Some issue](../issues/123)'; expect(bitbucket.massageMarkdown(prBody)).toEqual( '[Some issue](../../issues/123)', ); }); it('dependency dashboard: updates abandoned dependencies heading and place note inside', () => { const dashboardBody = '## Abandoned Dependencies\n' + '
\nView abandoned dependencies (6)\n\n' + '| Datasource | Name | Last Updated |\n' + '|------------|------|-------------|\n' + '| npm | node | unknown |\n' + '\n
\n\n'; expect(bitbucket.massageMarkdown(dashboardBody)).toEqual( '## Abandoned Dependencies\n' + '| Datasource | Name | Last Updated |\n' + '|------------|------|-------------|\n' + '| npm | node | unknown |\n\n\n\n', ); }); it('dependency dashboard: updates vulnerabilities section with multiple collapsible details sections to nested list', () => { const dashboardBody = '## Vulnerabilities\n\n' + '`2`/`2` CVEs have Renovate fixes.\n' + '
maven\n
\n\n' + '
pom.xml\n
\n\n' + '
org.eclipse.jetty.http2:http2-common\n' + '
\n\n- [GHSA-mmxm-8w33-wc4h](https://osv.dev/vulnerability/GHSA-mmxm-8w33-wc4h) (fixed in [11.0.26,))\n
\n' + '
\n\n' + '
org.apache.commons:commons-lang3\n' + '
\n\n- [GHSA-j288-q9x7-2f5v](https://osv.dev/vulnerability/GHSA-j288-q9x7-2f5v) (fixed in [3.18.0,))\n
\n' + '
\n\n
\n
\n\n
\n
\n\n'; expect(bitbucket.massageMarkdown(dashboardBody)).toEqual( '## Vulnerabilities\n\n' + '`2`/`2` CVEs have Renovate fixes.\n' + ' - **maven**\n\n\n' + '\t - `pom.xml`\n\n\n' + '\t\t - `org.eclipse.jetty.http2:http2-common`\n\n\n' + '\t\t\t- [GHSA-mmxm-8w33-wc4h](https://osv.dev/vulnerability/GHSA-mmxm-8w33-wc4h) (fixed in [11.0.26,))\n\n\n\n' + '\t\t - `org.apache.commons:commons-lang3`\n\n\n' + '\t\t\t- [GHSA-j288-q9x7-2f5v](https://osv.dev/vulnerability/GHSA-j288-q9x7-2f5v) (fixed in [3.18.0,))\n\n\n\n\n\n\n\n\n\n', ); }); it('dependency dashboard: updates detected dependencies section with multiple collapsible details sections to nested list', () => { const dashboardBody = '## Detected Dependencies\n\n' + '
dockerfile\n
\n\n' + '
app1/Dockerfile\n - `node:24`\n - `temurin:27`\n
\n\n' + '
app2/Dockerfile\n - `node:20`\n - `python:3:14`\n
\n\n' + '
\n
\n\n' + '
npm\n
\n\n' + '
package.json\n - `@biomejs/biome:2.0.0`
\n\n' + '
\n
'; expect(bitbucket.massageMarkdown(dashboardBody)).toEqual( '## Detected Dependencies\n\n' + ' - **dockerfile**\n\n\n' + '\t - `app1/Dockerfile`\n' + '\t\t - `node:24`\n' + '\t\t - `temurin:27`\n\n\n' + '\t - `app2/Dockerfile`\n' + '\t\t - `node:20`\n' + '\t\t - `python:3:14`\n\n\n\n\n\n' + ' - **npm**\n\n\n' + '\t - `package.json`\n' + '\t\t - `@biomejs/biome:2.0.0`\n\n\n', ); }); it('updates release notes section', () => { const prBody = '## Release Notes\n\n' + '
biomejs/biome (@​biomejs/biome)\n\n\n' + '### [\\`v2.3.11\\`](https://github.com/biomejs/biome/blob/HEAD/packages/@​biomejs/biome/CHANGELOG.md#2311)\n\n' + '[Compare Source](https://github.com/biomejs/biome/compare/@biomejs/biome@2.3.10...@biomejs/biome@2.3.11)\n\n' + '- [#​8583](https://github.com/biomejs/biome/pull/8583) [`83be210`](https://github.com/biomejs/biome/commit/83be2101cb14969e3affda260773e33e50874df0) Thanks [@​dyc3](https://github.com/dyc3)! - Added the new nursery rule [`useVueValidTemplateRoot`](https://biomejs.dev/linter/rules/use-vue-valid-template-root/).' + '
'; expect(bitbucket.massageMarkdown(prBody)).toEqual( '## Release Notes\n\n' + '**biomejs/biome (@​biomejs/biome)**\n\n\n' + '### [\\`v2.3.11\\`](https://github.com/biomejs/biome/blob/HEAD/packages/@​biomejs/biome/CHANGELOG.md#2311)\n\n' + '[Compare Source](https://github.com/biomejs/biome/compare/@biomejs/biome@2.3.10...@biomejs/biome@2.3.11)\n\n' + '- [#​8583](https://github.com/biomejs/biome/pull/8583) [`83be210`](https://github.com/biomejs/biome/commit/83be2101cb14969e3affda260773e33e50874df0) Thanks [@​dyc3](https://github.com/dyc3)! - Added the new nursery rule [`useVueValidTemplateRoot`](https://biomejs.dev/linter/rules/use-vue-valid-template-root/).', ); }); it('updates codeblocks to correct indentation level', () => { const prBody = ' Examples:\n' + ' ```vue\n' + ' \n' + ' ```\n' + ' ```vue\n' + ' \n' + ' ```'; expect(bitbucket.massageMarkdown(prBody)).toEqual( ' Examples:\n' + '```vue\n' + '\n' + '```\n' + '```vue\n' + '\n' + '```', ); }); it('updates codeblocks to drop extra language data', () => { const prBody = ' Examples:\n' + ' ```vue,expect_diagnostic\n' + ' \n' + ' ```\n' + ' ```vue\n' + ' \n' + ' ```'; expect(bitbucket.massageMarkdown(prBody)).toEqual( ' Examples:\n' + '```vue\n' + '\n' + '```\n' + '```vue\n' + '\n' + '```', ); }); }); describe('updatePr()', () => { it('puts PR', 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(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body', targetBranch: 'new_base', }), ).toResolve(); }); it('removes inactive reviewers when updating pr', async () => { const inactiveReviewer = { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }; const activeReviewerOutsideOfWorkspace = { display_name: 'Alice Smith', uuid: '{a10e0228-ad84-11ed-afa1-0242ac120002}', account_id: '789', }; const activeReviewerWithinWorkspace = { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }; const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { reviewers: [ activeReviewerWithinWorkspace, activeReviewerOutsideOfWorkspace, inactiveReviewer, ], }) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(400, { type: 'error', error: { fields: { reviewers: ['Malformed reviewers list'], }, message: 'reviewers: Malformed reviewers list', }, }) .get('/2.0/users/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D') .reply(200, { account_status: 'active', }) .get( '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D', ) .reply(200) .get('/2.0/users/%7Ba10e0228-ad84-11ed-afa1-0242ac120002%7D') .reply(200, { account_status: 'active', }) .get( '/2.0/workspaces/some/members/%7Ba10e0228-ad84-11ed-afa1-0242ac120002%7D', ) .reply(404) .get('/2.0/users/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D') .reply(200, { account_status: 'inactive', }) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).toResolve(); }); it('removes reviewers no longer member of the workspace when updating 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/pullrequests/5') .reply(200, { reviewers: [memberReviewer, notMemberReviewer] }) .put('/2.0/repositories/some/repo/pullrequests/5') .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', }, }) .get( '/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D', ) .reply(404) .get( '/2.0/workspaces/some/members/%7B90b6646d-1724-4a64-9fd9-539515fe94e9%7D', ) .reply(200) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { id: 5 }) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).toResolve(); }); it('throws exception when unable to check 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/pullrequests/5') .reply(200, { reviewers: [reviewer] }) .put('/2.0/repositories/some/repo/pullrequests/5') .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', }, }) .get( '/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( 'Request failed with status code 401 (Unauthorized): GET https://api.bitbucket.org/2.0/workspaces/some/members/%7Bd2238482-2e9f-48b3-8630-de22ccb9e42f%7D', ); }); 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}', }; 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: { reviewers: ['Some other unhandled error'], }, message: 'Some other unhandled error', }, }); await expect(() => bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow( 'Request failed with status code 400 (Bad Request): PUT https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests/5', ); }); 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( 'Request failed with status code 400 (Bad Request): PUT https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests/5', ); }); it('throws an error on failure to get current list of reviewers', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/5') .reply(500, undefined); await expect(() => bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow( 'Request failed with status code 500 (Internal Server Error): GET https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests/5', ); }); it('closes PR', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { values: [pr] }) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { id: 5 }) .post('/2.0/repositories/some/repo/pullrequests/5/decline') .reply(200) .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [{ id: 5 }], }); await expect( bitbucket.updatePr({ number: pr.id, prTitle: pr.title, state: 'closed', }), ).resolves.toBeUndefined(); }); }); describe('maintains pr cache integrity at runtime', () => { it('pr cache gets updated after a pr is created', async () => { const projectReviewer = { type: 'default_reviewer', reviewer_type: 'project', user: { display_name: 'Bob Smith', uuid: '{d2238482-2e9f-48b3-8630-de22ccb9e42f}', account_id: '123', }, }; const repoReviewer = { type: 'default_reviewer', reviewer_type: 'repository', user: { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', account_id: '456', }, }; const scope = httpMock.scope(baseUrl); scope.get('/2.0/user').reply(200, { uuid: '12345' }); await bitbucket.initPlatform({ username: 'renovate', password: 'pass' }); await initRepoMock(undefined, null, scope); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [ { id: 1, author: { uuid: '12345' }, source: { branch: { name: 'branch-a' } }, destination: { branch: { name: 'branch-b' } }, state: 'OPEN', }, ], }) .get( '/2.0/repositories/some/repo/effective-default-reviewers?pagelen=100', ) .reply(200, { values: [projectReviewer, repoReviewer], }) .post('/2.0/repositories/some/repo/pullrequests') .reply(200, { id: 5 }); await bitbucket.getPrList(); // cache is now initialized await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, }); const newPrList = await bitbucket.getPrList(); expect(newPrList).toHaveLength(2); }); it('pr cache gets updated after a pr is updated', async () => { const reviewer = { display_name: 'Jane Smith', uuid: '{90b6646d-1724-4a64-9fd9-539515fe94e9}', }; const scope = httpMock.scope(baseUrl); scope.get('/2.0/user').reply(200, { uuid: '12345' }); await bitbucket.initPlatform({ username: 'renovate', password: 'pass' }); await initRepoMock(undefined, null, scope); scope .get(`/2.0/repositories/some/repo/pullrequests`) .query(true) .reply(200, { values: [ { id: 5, author: { uuid: '12345' }, source: { branch: { name: 'branch-a' } }, destination: { branch: { name: 'branch-b' } }, state: 'OPEN', title: 'title', }, ], }) .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { reviewers: [reviewer] }) .put('/2.0/repositories/some/repo/pullrequests/5') .reply(200, { id: 5, title: 'newTitle' }); const oldPrList = await bitbucket.getPrList(); // cache is now initialized expect(oldPrList.find((pr) => pr.title === 'title')).toBeDefined(); await bitbucket.updatePr({ number: 5, prTitle: 'newTitle', prBody: 'body', targetBranch: 'new_base', }); const newPrList = await bitbucket.getPrList(); expect(newPrList.find((pr) => pr.title === 'newTitle')).toBeDefined(); }); }); describe('mergePr()', () => { it('posts Merge with optional merge strategy', async () => { const scope = await initRepoMock(); scope.post('/2.0/repositories/some/repo/pullrequests/5/merge').reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, }), ).resolves.toBeTrue(); }); it('posts Merge with auto', async () => { const scope = await initRepoMock(); scope.post('/2.0/repositories/some/repo/pullrequests/5/merge').reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, strategy: 'auto', }), ).resolves.toBeTrue(); }); it('posts Merge with merge-commit', async () => { const scope = await initRepoMock(); scope.post('/2.0/repositories/some/repo/pullrequests/5/merge').reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, strategy: 'merge-commit', }), ).resolves.toBeTrue(); }); it('posts Merge with squash', async () => { const scope = await initRepoMock(); scope.post('/2.0/repositories/some/repo/pullrequests/5/merge').reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, strategy: 'squash', }), ).resolves.toBe(true); }); it('does not post Merge with rebase', async () => { await bitbucket.mergePr({ branchName: 'branch', id: 5, strategy: 'rebase', }); expect(httpMock.getTrace()).toBeEmptyArray(); }); it('posts Merge with fast-forward', async () => { const scope = await initRepoMock(); scope.post('/2.0/repositories/some/repo/pullrequests/5/merge').reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, strategy: 'fast-forward', }), ).resolves.toBeTrue(); }); }); describe('getJsonFile()', () => { it('returns file content', async () => { const data = { foo: 'bar' }; const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/src/HEAD/file.json') .reply(200, JSON.stringify(data)); const res = await bitbucket.getJsonFile('file.json'); expect(res).toEqual(data); }); it('returns file content in json5 format', async () => { const json5Data = ` { // json5 comment foo: 'bar' } `; const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/src/HEAD/file.json5') .reply(200, json5Data); const res = await bitbucket.getJsonFile('file.json5'); expect(res).toEqual({ foo: 'bar' }); }); it('returns file content from given repo', async () => { const data = { foo: 'bar' }; const scope = await initRepoMock(); scope .get('/2.0/repositories/different/repo/src/HEAD/file.json') .reply(200, JSON.stringify(data)); const res = await bitbucket.getJsonFile('file.json', 'different/repo'); expect(res).toEqual(data); }); it('returns file content from branch or tag', async () => { const data = { foo: 'bar' }; const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/src/dev/file.json') .reply(200, JSON.stringify(data)); const res = await bitbucket.getJsonFile('file.json', 'some/repo', 'dev'); expect(res).toEqual(data); }); it('returns file content from branch with a slash in its name', async () => { const data = { foo: 'bar' }; const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/refs/branches/feat/123-test') .reply(200, JSON.stringify({ target: { hash: '1234567890' } })); scope .get('/2.0/repositories/some/repo/src/1234567890/file.json') .reply(200, JSON.stringify(data)); const res = await bitbucket.getJsonFile( 'file.json', 'some/repo', 'feat/123-test', ); expect(res).toEqual(data); }); it('throws on malformed JSON', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/src/HEAD/file.json') .reply(200, '!@#'); await expect(bitbucket.getJsonFile('file.json')).rejects.toThrow( "JSON5: invalid character '!' at 1:1", ); }); it('throws on errors', async () => { const scope = await initRepoMock(); scope .get('/2.0/repositories/some/repo/src/HEAD/file.json') .replyWithError('some error'); await expect(bitbucket.getJsonFile('file.json')).rejects.toThrow( 'some error', ); }); }); });