import { isTruthy } from '@sindresorhus/is'; import { codeBlock } from 'common-tags'; import semver from 'semver'; import { hostRules } from '~test/host-rules.ts'; import * as httpMock from '~test/http-mock.ts'; import { fakeSha, git, logger } from '~test/util.ts'; import { REPOSITORY_CHANGED, REPOSITORY_EMPTY, REPOSITORY_NOT_FOUND, } from '../../../constants/error-messages.ts'; import * as repoCache from '../../../util/cache/repository/index.ts'; import { ensureTrailingSlash, parseUrl } from '../../../util/url.ts'; import type { ReattemptPlatformAutomergeConfig } from '../types.ts'; import * as bitbucket from './index.ts'; vi.mock('timers/promises'); const commitSha = fakeSha('commitSha'); function sshLink(projectKey: string, repositorySlug: string): string { return `ssh://git@stash.renovatebot.com:7999/${projectKey.toLowerCase()}/${repositorySlug}.git`; } function httpLink( endpointStr: string, projectKey: string, repositorySlug: string, ): string { return `${ensureTrailingSlash(endpointStr)}scm/${projectKey.toLowerCase()}/${repositorySlug}.git`; } function repoMock( endpoint: URL | string, projectKey: string, repositorySlug: string, options: { cloneUrl: { https: boolean; ssh: boolean } } = { cloneUrl: { https: true, ssh: true }, }, ) { const endpointStr = endpoint.toString(); const links: { self: { href: string }[]; clone?: { href: string; name: string }[]; } = { self: [ { href: `${endpointStr}projects/${projectKey}/repos/${repositorySlug}/browse`, }, ], }; if (options.cloneUrl.https || options.cloneUrl.ssh) { // This mimics the behavior of bb-server which does not include the clone property at all // if ssh and https are both turned off links.clone = [ options.cloneUrl.https ? { href: httpLink(endpointStr, projectKey, repositorySlug), name: 'http', } : null, options.cloneUrl.ssh ? { href: sshLink(projectKey, repositorySlug), name: 'ssh', } : null, ].filter(isTruthy); } return { slug: repositorySlug, id: 13076, name: repositorySlug, scmId: 'git', state: 'AVAILABLE', statusMessage: 'Available', forkable: true, project: { key: projectKey, id: 2900, name: `${repositorySlug}'s name`, public: false, type: 'NORMAL', links: { self: [ { href: `https://stash.renovatebot.com/projects/${projectKey}` }, ], }, }, public: false, links, }; } function prMock( endpoint: URL | string, projectKey: string, repositorySlug: string, ) { const endpointStr = endpoint.toString(); return { id: 5, version: 1, title: 'title', description: '* Line 1\r\n* Line 2', state: 'OPEN', open: true, closed: false, createdDate: 1547853840016, updatedDate: 1547853840016, fromRef: { id: 'refs/heads/userName1/pullRequest5', displayId: 'userName1/pullRequest5', latestCommit: '55efc02b2ab13a43a66cf705f5faacfcc6a762b4', // Removed this with the idea it's not needed // repository: {}, }, toRef: { id: 'refs/heads/master', displayId: 'master', latestCommit: commitSha, // Removed this with the idea it's not needed // repository: {}, }, locked: false, author: { user: { name: 'userName1', emailAddress: 'userName1@renovatebot.com', id: 144846, displayName: 'Renovate Bot', active: true, slug: 'userName1', type: 'NORMAL', links: { self: [{ href: `${endpointStr}/users/userName1` }], }, }, role: 'AUTHOR', approved: false, status: 'UNAPPROVED', }, reviewers: [ { user: { name: 'userName2', emailAddress: 'userName2@renovatebot.com', id: 71155, displayName: 'Renovate bot 2', active: true, slug: 'userName2', type: 'NORMAL', links: { self: [{ href: `${endpointStr}/users/userName2` }], }, }, role: 'REVIEWER', approved: false, status: 'UNAPPROVED', }, ], participants: [], links: { self: [ { href: `${endpointStr}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/5`, }, ], }, }; } const endpointWithNoPath = parseUrl('https://stash.renovatebot.com')!; const endpointWithPath = parseUrl('https://stash.renovatebot.com/vcs')!; describe('modules/platform/bitbucket-server/index', () => { describe('endpoint with path', () => { const url = endpointWithPath; const urlHost = url.origin; const urlPath = url.pathname === '/' ? '' : url.pathname; const username = 'abc'; const password = '123'; const userInfo = { name: username, emailAddress: 'abc@def.com', displayName: 'Abc Def', active: true, slug: 'username', }; async function initRepo(config = {}): Promise { const scope = httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, repoMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); await bitbucket.initRepo({ repository: 'SOME/repo', ...config, }); return scope; } beforeEach(async () => { git.branchExists.mockReturnValue(true); git.isBranchBehindBase.mockResolvedValue(false); git.getBranchCommit.mockReturnValue(commitSha); hostRules.add({ username, password, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/users/${username}`) .reply(200, userInfo); repoCache.resetCache(); await bitbucket.initPlatform({ endpoint: 'https://stash.renovatebot.com/vcs/', username, password, }); }); describe('initPlatform()', () => { it('should throw if no endpoint', async () => { expect.assertions(1); await expect(bitbucket.initPlatform({})).rejects.toThrow( 'Init: You must configure a Bitbucket Server endpoint', ); }); it('should throw if no username/password/token', async () => { expect.assertions(1); await expect( bitbucket.initPlatform({ endpoint: 'endpoint' }), ).rejects.toThrow( 'Init: You must either configure a Bitbucket Server username/password', ); }); it('should throw if password and token is set', async () => { expect.assertions(1); await expect( bitbucket.initPlatform({ endpoint: 'endpoint', username: 'abc', password: '123', token: 'abc', }), ).rejects.toThrow( 'Init: You must configure either a Bitbucket Server password or a HTTP', ); }); it('should not throw if username/password', async () => { expect.assertions(1); await expect( bitbucket.initPlatform({ endpoint: 'endpoint', username: 'abc', password: '123', }), ).resolves.not.toThrow(); }); it('should not throw if token', async () => { expect.assertions(1); await expect( bitbucket.initPlatform({ endpoint: 'endpoint', token: 'abc', }), ).resolves.not.toThrow(); }); it('should throw if version could not be fetched', async () => { httpMock .scope('https://stash.renovatebot.com') .get('/rest/api/1.0/application-properties') .reply(403); httpMock .scope('https://stash.renovatebot.com') .get(`/rest/api/1.0/users/${username}`) .reply(200, userInfo); await bitbucket.initPlatform({ endpoint: 'https://stash.renovatebot.com', username: 'abc', password: '123', }); expect(logger.logger.debug).toHaveBeenCalledWith( expect.any(Object), 'Error authenticating with Bitbucket. Check that your token includes "api" permissions', ); }); it('should not throw if user info fetch fails', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/users/${username}`) .reply(404); await expect( bitbucket.initPlatform({ endpoint: url.href, username, password, }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), }); expect(logger.logger.debug).toHaveBeenCalledWith( expect.any(Object), 'Failed to get user info, fallback gitAuthor will be used', ); }); it('should skip users api call when gitAuthor is configured', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); await expect( bitbucket.initPlatform({ endpoint: url.href, username: 'def', password: '123', gitAuthor: `Def Abc `, }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), }); }); it('should skip users api call when no username', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); await expect( bitbucket.initPlatform({ endpoint: url.href, token: '123', }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), }); }); it('should fetch user info if token with username', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/users/${username}`) .reply(200, userInfo); await expect( bitbucket.initPlatform({ endpoint: url.href, token: '123', username, }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), gitAuthor: `${userInfo.displayName} <${userInfo.emailAddress}>`, }); }); it('should collect username from headers if token with no username', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }, { 'x-ausername': 'user_name' }); await expect( bitbucket.initPlatform({ endpoint: url.href, token: '123', }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), }); expect(logger.logger.debug).toHaveBeenCalledWith( { 'x-ausername': 'user_name', }, 'Platform: No username configured using headers["x-ausername"]', ); }); it('should use fallback gitAuthor if user info has empty email address', async () => { httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/application-properties`) .reply(200, { version: '8.0.0' }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/users/${username}`) .reply(200, { ...userInfo, emailAddress: '', }); await expect( bitbucket.initPlatform({ endpoint: url.href, token: '123', username, }), ).resolves.toEqual({ endpoint: ensureTrailingSlash(url.href), }); expect(logger.logger.debug).toHaveBeenCalledWith( { err: new Error('No email address configured for username abc'), }, 'Failed to get user info, fallback gitAuthor will be used', ); }); it('should init', async () => { httpMock .scope('https://stash.renovatebot.com') .get('/rest/api/1.0/application-properties') .reply(200, { version: '8.0.0' }); httpMock .scope('https://stash.renovatebot.com') .get(`/rest/api/1.0/users/${username}`) .reply(200, userInfo); await expect( bitbucket.initPlatform({ endpoint: 'https://stash.renovatebot.com', username: 'abc', password: '123', }), ).resolves.toEqual({ endpoint: 'https://stash.renovatebot.com/', gitAuthor: 'Abc Def ', }); }); }); describe('getRepos()', () => { it('returns repos', async () => { expect.assertions(1); httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/repos?permission=REPO_WRITE&state=AVAILABLE&limit=100`, ) .reply(200, { size: 1, limit: 100, isLastPage: true, values: [repoMock(url, 'SOME', 'repo')], start: 0, }); await expect(bitbucket.getRepos()).resolves.toEqual(['SOME/repo']); }); }); describe('initRepo()', () => { it('works', async () => { expect.assertions(1); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, repoMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); await expect( bitbucket.initRepo({ repository: 'SOME/repo', }), ).resolves.toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: '5cf9fcad2424fb392c571e8c68ae75132e5db7a1f74d89235bda8dbac1c6c40c45997bace756898176a8b898aa1580b0345dc6272284ecf18a055536402ffcf7', }); }); it('no git url', async () => { expect.assertions(1); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, repoMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); await expect( bitbucket.initRepo({ repository: 'SOME/repo', }), ).resolves.toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('gitUrl ssh returns ssh url', async () => { expect.assertions(2); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: false, ssh: true }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); const res = await bitbucket.initRepo({ repository: 'SOME/repo', gitUrl: 'ssh', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: sshLink('SOME', 'repo') }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('gitURL endpoint returns generates endpoint URL', async () => { expect.assertions(2); const link = httpLink(url.toString(), 'SOME', 'repo'); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: false, ssh: false }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); git.getUrl.mockReturnValueOnce(link); const res = await bitbucket.initRepo({ repository: 'SOME/repo', gitUrl: 'endpoint', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: link, }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('gitUrl default returns http from API with injected auth', async () => { expect.assertions(2); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: true, ssh: true }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); const res = await bitbucket.initRepo({ repository: 'SOME/repo', gitUrl: 'default', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: httpLink(url.toString(), 'SOME', 'repo').replace( 'https://', `https://${username}:${password}@`, ), }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: expect.any(String), }); }); it('uses ssh url from API if http not in API response', async () => { expect.assertions(2); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: false, ssh: true }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); const res = await bitbucket.initRepo({ repository: 'SOME/repo', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: sshLink('SOME', 'repo') }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: '5cf9fcad2424fb392c571e8c68ae75132e5db7a1f74d89235bda8dbac1c6c40c45997bace756898176a8b898aa1580b0345dc6272284ecf18a055536402ffcf7', }); }); it('uses http url from API with injected auth if http url in API response', async () => { expect.assertions(2); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: true, ssh: true }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); const res = await bitbucket.initRepo({ repository: 'SOME/repo', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: httpLink(url.toString(), 'SOME', 'repo').replace( 'https://', `https://${username}:${password}@`, ), }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: '5cf9fcad2424fb392c571e8c68ae75132e5db7a1f74d89235bda8dbac1c6c40c45997bace756898176a8b898aa1580b0345dc6272284ecf18a055536402ffcf7', }); }); it('generates URL if API does not contain clone links', async () => { expect.assertions(2); const link = httpLink(url.toString(), 'SOME', 'repo'); const responseMock = repoMock(url, 'SOME', 'repo', { cloneUrl: { https: false, ssh: false }, }); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, responseMock) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(200, { displayId: 'master', }); git.getUrl.mockReturnValueOnce(link); const res = await bitbucket.initRepo({ repository: 'SOME/repo', }); expect(git.initRepo).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ url: link, }), ); expect(res).toEqual({ defaultBranch: 'master', isFork: false, repoFingerprint: '5cf9fcad2424fb392c571e8c68ae75132e5db7a1f74d89235bda8dbac1c6c40c45997bace756898176a8b898aa1580b0345dc6272284ecf18a055536402ffcf7', }); }); it('throws REPOSITORY_EMPTY if there is no default branch', async () => { expect.assertions(1); httpMock .scope(urlHost) .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, repoMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/branches/default`, ) .reply(204); await expect( bitbucket.initRepo({ repository: 'SOME/repo', }), ).rejects.toThrow(REPOSITORY_EMPTY); }); }); describe('repoForceRebase()', () => { it('returns false on missing mergeConfig', async () => { expect.assertions(1); httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/settings/pull-requests`, ) .reply(200, { mergeConfig: null, }); const actual = await bitbucket.getBranchForceRebase('main'); expect(actual).toBeFalse(); }); it('returns false on missing defaultStrategy', async () => { expect.assertions(1); httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/settings/pull-requests`, ) .reply(200, { mergeConfig: { defaultStrategy: null, }, }); const actual = await bitbucket.getBranchForceRebase('main'); expect(actual).toBeFalse(); }); it.each(['ff-only', 'rebase-ff-only', 'squash-ff-only'])( 'return true if %s strategy is enabled', async (id) => { expect.assertions(1); httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/settings/pull-requests`, ) .reply(200, { mergeConfig: { defaultStrategy: { id, }, }, }); const actual = await bitbucket.getBranchForceRebase('main'); expect(actual).toBeTrue(); }, ); it.each(['no-ff', 'ff', 'rebase-no-ff', 'squash'])( 'return false if %s strategy is enabled', async (id) => { expect.assertions(1); httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/settings/pull-requests`, ) .reply(200, { mergeConfig: { defaultStrategy: { id, }, }, }); const actual = await bitbucket.getBranchForceRebase('main'); expect(actual).toBeFalse(); }, ); }); describe('addAssignees()', () => { it('does not throw', async () => { await initRepo(); await expect( bitbucket.addAssignees(3, ['some']), ).resolves.toBeUndefined(); }); }); describe('addReviewers', () => { it('does not throw', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .twice() .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect( bitbucket.addReviewers(5, ['name']), ).resolves.toBeUndefined(); }); it('sends the reviewer name as a reviewer', async () => { expect.assertions(1); const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .twice() .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect(bitbucket.addReviewers(5, ['name'])).toResolve(); }); it('throws not-found 1', async () => { await initRepo(); await expect( bitbucket.addReviewers(null as any, ['name']), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws not-found 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/4`, ) .reply(404); await expect(bitbucket.addReviewers(4, ['name'])).rejects.toThrow( REPOSITORY_NOT_FOUND, ); }); it('throws not-found 3', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(404); await expect(bitbucket.addReviewers(5, ['name'])).rejects.toThrow( REPOSITORY_NOT_FOUND, ); }); it('does not throws repository-changed after 1 try', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .thrice() .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(409) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect(bitbucket.addReviewers(5, ['name'])).toResolve(); }); it('does not throws repository-changed after 2 tries', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .times(4) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .twice() .reply(409) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect(bitbucket.addReviewers(5, ['name'])).toResolve(); }); it('throws repository-changed after 3 tries', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .thrice() .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .thrice() .reply(409); await expect(bitbucket.addReviewers(5, ['name'])).rejects.toThrow( REPOSITORY_CHANGED, ); }); it('deals with invalid reviewers correctly', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .times(3) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, (body) => { return ( Array.isArray(body.reviewers) && body.reviewers.some( (r: { user: { name: string } }) => r?.user?.name === 'name', ) ); }, ) .once() .reply(409, { errors: [ { context: 'reviewers', message: 'Errors encountered while adding some reviewers to this pull request.', exceptionName: 'com.atlassian.bitbucket.pull.InvalidPullRequestReviewersException', reviewerErrors: [ { context: 'name', message: 'name is not a user.', exceptionName: null, }, ], validReviewers: ['userName2'], }, ], }) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, (body) => { return ( Array.isArray(body.reviewers) && body.reviewers.every( (r: { user: { name: string } }) => r?.user?.name !== 'name', ) ); }, ) .once() .reply(200); await expect(bitbucket.addReviewers(5, ['name'])).toResolve(); }); it('aborts instead of infinite recursion when invalid reviewers cannot be filtered', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .once() .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .once() .reply(409, { errors: [ { context: 'reviewers', message: 'Errors encountered while adding some reviewers to this pull request.', exceptionName: 'com.atlassian.bitbucket.pull.InvalidPullRequestReviewersException', reviewerErrors: [{ context: '', message: ' is not a user.' }], validReviewers: [ { user: { name: 'user1' } }, { user: { name: 'user2' } }, ], }, ], }); await expect( bitbucket.addReviewers(5, ['user1', 'user2', '']), ).rejects.toThrow('Request failed with status code 409 (Conflict)'); expect(logger.logger.warn).toHaveBeenCalledWith( expect.anything(), 'Could not filter invalid reviewers from list, aborting to prevent infinite recursion', ); }); it('deals correctly with resolving reviewers', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .twice() .reply(200, prMock(url, 'SOME', 'repo')); scope .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, (body) => { const reviewers = body.reviewers.map( (r: { user: { name: any } }) => r.user.name, ); return ( Array.isArray(reviewers) && reviewers.length === 3 && reviewers.includes('name') && reviewers.includes('userName2') && reviewers.includes('usernamefoundbyemail') ); }, ) .reply(200); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'test@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(200, [ { name: 'usernamefoundbyemail', active: true, displayName: 'Not relevant', emailAddress: 'test@test.com', }, ]); await expect( bitbucket.addReviewers(5, ['name', 'userName2', 'test@test.com']), ).toResolve(); }); it('throws', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(405); await expect(bitbucket.addReviewers(5, ['name'])).rejects.toThrow( 'Request failed with status code 405 (Method Not Allowed): PUT https://stash.renovatebot.com/vcs/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5', ); }); }); describe('getUsernamesByEmail', () => { it('throws when lookup fails', async () => { const scope = await initRepo(); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'e-mail@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(500, []); await expect( bitbucket.getUsernamesByEmail('e-mail@test.com'), ).rejects.toThrow( /^Request failed with status code 500 \(Internal Server Error\):/, ); }); it('return empty array when no results found', async () => { const scope = await initRepo(); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'e-mail@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(200, []); const actual = await bitbucket.getUsernamesByEmail('e-mail@test.com'); expect(actual).toBeEmptyArray(); }); it('return only active users', async () => { const scope = await initRepo(); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'e-mail@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(200, [ { name: 'usernamefoundbyemail', active: false, displayName: 'Not relevant', emailAddress: 'e-mail@test.com', }, ]); const actual = await bitbucket.getUsernamesByEmail('e-mail@test.com'); expect(actual).toBeEmptyArray(); }); it('only returns exact matches', async () => { const scope = await initRepo(); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'mail@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(200, [ { name: 'usernamefoundbyemail', active: true, displayName: 'Not relevant', emailAddress: 'e-mail@test.com', }, { name: 'usernamefoundbyemailtoo', active: true, displayName: 'Not relevant', emailAddress: 'e-mail@test.com', }, ]); const actual = await bitbucket.getUsernamesByEmail('mail@test.com'); expect(actual).toBeEmptyArray(); }); it('returns multiple exact matches', async () => { const scope = await initRepo(); scope // User by email .get(`${urlPath}/rest/api/1.0/users`) .query( (q) => q.filter === 'e-mail@test.com' && q['permission.1'] === 'REPO_READ' && q['permission.1.repositorySlug'] === 'repo' && q['permission.1.projectKey'] === 'SOME', ) .reply(200, [ { name: 'usernamefoundbyemail', active: true, displayName: 'Not relevant', emailAddress: 'e-mail@test.com', }, { name: 'e-mail@test.com', active: true, displayName: 'Not relevant', emailAddress: 'e-mail@test.com', }, ]); const actual = await bitbucket.getUsernamesByEmail('e-mail@test.com'); expect(actual).toStrictEqual([ 'usernamefoundbyemail', 'e-mail@test.com', ]); }); }); describe('deleteLAbel()', () => { it('does not throw', async () => { await expect( bitbucket.deleteLabel(5, 'renovate'), ).resolves.toBeUndefined(); }); }); describe('ensureComment()', () => { it('does not throw', async () => { httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/3/activities?limit=100`, ) .reply(200); const res = await bitbucket.ensureComment({ number: 3, topic: 'topic', content: 'content', }); expect(res).toBeFalse(); }); it('add comment if not found 1', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments`, ) .reply(200); await expect( bitbucket.ensureComment({ number: 5, topic: 'topic', content: 'content', }), ).resolves.toBeTrue(); }); it('add comment if not found 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments`, ) .reply(200); await expect( bitbucket.ensureComment({ number: 5, topic: null, content: 'content', }), ).resolves.toBeTrue(); }); it('add updates comment if necessary 1', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/21`, ) .reply(200, { version: 1, }) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/21`, ) .reply(200); await expect( bitbucket.ensureComment({ number: 5, topic: 'some-subject', content: 'some\ncontent', }), ).resolves.toBeTrue(); }); it('add updates comment if necessary 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments`, ) .reply(200); await expect( bitbucket.ensureComment({ number: 5, topic: null, content: 'some\ncontent', }), ).resolves.toBeTrue(); }); it('skips comment 1', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }); await expect( bitbucket.ensureComment({ number: 5, topic: 'some-subject', content: 'blablabla', }), ).resolves.toBeTrue(); }); it('skips comment 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }); const res = await bitbucket.ensureComment({ number: 5, topic: null, content: '!merge', }); expect(res).toBeTrue(); }); }); describe('ensureCommentRemoval()', () => { it('does not throw', async () => { httpMock .scope(urlHost) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }); await expect( bitbucket.ensureCommentRemoval({ type: 'by-topic', number: 5, topic: 'topic', }), ).toResolve(); }); it('deletes comment by topic if found', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/21`, ) .reply(200, { version: 1, }) .delete( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/21?version=1`, ) .reply(200); await expect( bitbucket.ensureCommentRemoval({ type: 'by-topic', number: 5, topic: 'some-subject', }), ).toResolve(); }); it('deletes comment by content if found', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/22`, ) .reply(200, { version: 1, }) .delete( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/comments/22?version=1`, ) .reply(200); await expect( bitbucket.ensureCommentRemoval({ type: 'by-content', number: 5, content: '!merge', }), ).toResolve(); }); it('deletes nothing', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100`, ) .reply(200, { isLastPage: false, nextPageStart: 1, values: [ { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 21, text: '### some-subject\n\nblablabla' }, }, { action: 'COMMENTED', commentAction: 'ADDED', comment: { id: 22, text: '!merge' }, }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/activities?limit=100&start=1`, ) .reply(200, { isLastPage: true, values: [{ action: 'OTHER' }], }); await expect( bitbucket.ensureCommentRemoval({ type: 'by-topic', number: 5, topic: 'topic', }), ).toResolve(); }); }); describe('getPrList()', () => { it('has pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect(bitbucket.getPrList()).resolves.toMatchObject([ { number: 5, sourceBranch: 'userName1/pullRequest5', state: 'open', targetBranch: 'master', title: 'title', }, ]); }); }); describe('getBranchPr()', () => { it('has pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect( bitbucket.getBranchPr('userName1/pullRequest5'), ).resolves.toMatchObject({ number: 5, sourceBranch: 'userName1/pullRequest5', state: 'open', targetBranch: 'master', title: 'title', }); }); it('has no pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.getBranchPr('userName1/pullRequest1'), ).resolves.toBeNull(); }); it('has no existing pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [], }); await expect( bitbucket.getBranchPr('userName1/pullRequest1'), ).resolves.toBeNull(); }); }); describe('findPr()', () => { it('has pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.findPr({ branchName: 'userName1/pullRequest5', prTitle: 'title', state: 'open', }), ).resolves.toMatchObject({ number: 5, sourceBranch: 'userName1/pullRequest5', state: 'open', title: 'title', }); }); it('has no pr', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.findPr({ branchName: 'userName1/pullRequest5', prTitle: 'title', state: 'closed', }), ).resolves.toBeNull(); }); it('finds pr from other authors', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=OPEN&direction=outgoing&at=refs/heads/branch&limit=1`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.findPr({ branchName: 'branch', state: 'open', includeOtherAuthors: true, }), ).resolves.toMatchObject({ number: 5, sourceBranch: 'userName1/pullRequest5', targetBranch: 'master', title: 'title', state: 'open', }); }); it('returns null if no pr found - (includeOtherAuthors)', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=OPEN&direction=outgoing&at=refs/heads/branch&limit=1`, ) .reply(200, { isLastPage: true, values: [], }); const pr = await bitbucket.findPr({ branchName: 'branch', state: 'open', includeOtherAuthors: true, }); expect(pr).toBeNull(); }); }); describe('createPr()', () => { it('posts PR', async () => { const scope = await initRepo(); scope .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/default-reviewers/1.0/projects/SOME/repos/repo/reviewers?sourceRefId=refs/heads/branch&targetRefId=refs/heads/master&sourceRepoId=5&targetRepoId=5`, ) .reply(200, [{ name: 'jcitizen' }]) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { bbUseDefaultReviewers: true, }, }); expect(pr?.number).toBe(5); }); it('posts PR default branch', async () => { const scope = await initRepo(); scope .get(`${urlPath}/rest/api/1.0/projects/SOME/repos/repo`) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/default-reviewers/1.0/projects/SOME/repos/repo/reviewers?sourceRefId=refs/heads/branch&targetRefId=refs/heads/master&sourceRepoId=5&targetRepoId=5`, ) .reply(200, [{ name: 'jcitizen' }]) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&role.1=AUTHOR&username.1=abc&limit=100`, ) .reply(200, { isLastPage: true, values: [], }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', labels: null, platformPrOptions: { bbUseDefaultReviewers: true, }, }); expect(pr?.number).toBe(5); }); it('should use platform automerge', async () => { const scope = await initRepo(); scope .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [], }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(200); // Simulate version >= 8.15.0 vi.spyOn(semver, 'lt').mockReturnValue(false); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { usePlatformAutomerge: true, }, }); expect(pr?.number).toBe(5); expect(logger.logger.debug).toHaveBeenCalledWith( { prNumber: 5 }, expect.stringContaining('automerge: success'), ); }); it('platform-native automerge returns early if usePlatformAutomerge is false', async () => { const scope = await initRepo(); scope .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [], }); await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { usePlatformAutomerge: false, }, }); expect(logger.logger.debug).not.toHaveBeenCalledWith( expect.anything(), expect.stringContaining('tryPrAutomerge'), ); }); it('platform-native automerge returns early if Bitbucket Server <= 8.15.0 is used', async () => { const scope = await initRepo(); scope .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [], }); // Assert version < 8.15.0 vi.spyOn(semver, 'lt').mockReturnValue(true); await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { usePlatformAutomerge: true, }, }); expect(logger.logger.debug).toHaveBeenCalledWith( { prNumber: 5 }, expect.stringContaining('automerge: not supported'), ); }); it('platform-native automerge catches errors gracefully', async () => { const scope = await initRepo(); scope .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [], }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(404); // Simulate version >= 8.15.0 vi.spyOn(semver, 'lt').mockReturnValue(false); await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', prTitle: 'title', prBody: 'body', platformPrOptions: { usePlatformAutomerge: true, }, }); expect(logger.logger.warn).toHaveBeenCalledWith( { err: expect.any(Error), prNumber: 5 }, expect.stringContaining('automerge: fail'), ); }); }); describe('reattemptPlatformAutomerge()', () => { const pr: ReattemptPlatformAutomergeConfig = { number: 123, platformPrOptions: { usePlatformAutomerge: true }, }; it('should reattempt automerge', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/123`, ) .reply(200, prMock(url, 'SOME', 'repo')) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(200); // Simulate version >= 8.15.0 vi.spyOn(semver, 'lt').mockReturnValue(false); await bitbucket.reattemptPlatformAutomerge(pr); expect(logger.logger.debug).toHaveBeenLastCalledWith( 'PR platform automerge re-attempted...prNo: 123', ); }); it('handles unknown error', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/123`, ) .replyWithError('unknown error'); await bitbucket.reattemptPlatformAutomerge(pr); expect(logger.logger.warn).toHaveBeenCalledWith( { err: expect.any(Error) }, 'Error re-attempting PR platform automerge', ); }); it('handles missing prNo', async () => { await initRepo(); await bitbucket.reattemptPlatformAutomerge({ ...pr, number: null as any, }); expect(logger.logger.warn).toHaveBeenCalledWith( { err: expect.objectContaining({ message: REPOSITORY_NOT_FOUND }) }, 'Error re-attempting PR platform automerge', ); }); }); describe('getPr()', () => { it('returns null for no prNo', async () => { httpMock.scope(urlHost); await expect(bitbucket.getPr(undefined as any)).resolves.toBeNull(); }); it('gets a PR', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')); await expect(bitbucket.getPr(5)).resolves.toMatchObject({ number: 5, sourceBranch: 'userName1/pullRequest5', state: 'open', targetBranch: 'master', title: 'title', }); }); it('canRebase', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/3`, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .twice() .reply(200, prMock(url, 'SOME', 'repo')); const expectedPr = { number: 5, sourceBranch: 'userName1/pullRequest5', state: 'open', targetBranch: 'master', title: 'title', }; await expect(bitbucket.getPr(3)).resolves.toMatchObject(expectedPr); await expect(bitbucket.getPr(5)).resolves.toMatchObject(expectedPr); await expect(bitbucket.getPr(5)).resolves.toMatchObject(expectedPr); }); it('gets a closed PR', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, { version: 0, number: 5, state: 'MERGED', reviewers: [], fromRef: {}, toRef: {}, }); await expect(bitbucket.getPr(5)).resolves.toMatchObject({ state: 'merged', }); }); }); describe('updatePr()', () => { it('puts PR', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, { ...prMock(url, 'SOME', 'repo'), toRef: { id: 'refs/heads/new_base', displayId: 'new_base', latestCommit: commitSha, }, }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body', targetBranch: 'new_base', }), ).toResolve(); }); it('closes PR', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, { ...prMock(url, 'SOME', 'repo'), state: 'OPEN', version: 42, }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/decline?version=42`, ) .reply(200, { status: 'DECLINED' }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body', state: 'closed', }), ).toResolve(); }); it('re-opens PR', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, { ...prMock(url, 'SOME', 'repo'), state: 'DECLINED', version: 42, }) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/reopen?version=42`, ) .reply(200, { status: 'OPEN' }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body', state: 'open', }), ).toResolve(); }); it('throws not-found 1', async () => { await initRepo(); await expect( bitbucket.updatePr({ number: null as any, prTitle: 'title', prBody: 'body', }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws not-found 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/4`, ) .reply(404); await expect( bitbucket.updatePr({ number: 4, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws not-found 3', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(404); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('handles invalid users gracefully by retrying without invalid reviewers', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(409, { errors: [ { context: 'reviewers', message: 'Errors encountered while adding some reviewers to this pull request.', exceptionName: 'com.atlassian.bitbucket.pull.InvalidPullRequestReviewersException', reviewerErrors: [ { context: 'userName2', message: 'userName2 is not a user.', exceptionName: null, }, ], validReviewers: [], }, ], }) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, (body) => body.reviewers.length === 0, ) .reply(200, prMock(url, 'SOME', 'repo')) .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests?state=ALL&limit=100&role.1=AUTHOR&username.1=abc`, ) .reply(200, { isLastPage: true, values: [prMock(url, 'SOME', 'repo')], }); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body', state: 'open', }), ).toResolve(); }); it('throws repository-changed', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(409); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow(REPOSITORY_CHANGED); }); it('throws', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .put( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(405); await expect( bitbucket.updatePr({ number: 5, prTitle: 'title', prBody: 'body' }), ).rejects.toThrow( 'Request failed with status code 405 (Method Not Allowed): PUT https://stash.renovatebot.com/vcs/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5', ); }); }); describe('mergePr()', () => { it('posts Merge', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(200); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, }), ).resolves.toBeTrue(); }); it('throws not-found 1', async () => { await initRepo(); const res = bitbucket.mergePr({ branchName: 'branch', id: null as any, }); await expect(res).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws not-found 2', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/4`, ) .reply(404); await expect( bitbucket.mergePr({ branchName: 'branch', id: 4, }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws not-found 3', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(404); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); it('throws conflicted', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(409); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, }), ).resolves.toBeFalsy(); }); it('unknown error', async () => { const scope = await initRepo(); scope .get( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5`, ) .reply(200, prMock(url, 'SOME', 'repo')) .post( `${urlPath}/rest/api/1.0/projects/SOME/repos/repo/pull-requests/5/merge?version=1`, ) .reply(405); await expect( bitbucket.mergePr({ branchName: 'branch', id: 5, }), ).resolves.toBeFalse(); }); }); describe('massageMarkdown()', () => { it('returns diff files', () => { expect( bitbucket.massageMarkdown( '
foobar
text
', ), ).toBe('**foo**bartext'); }); it('sanitizes HTML comments in the body', () => { const prBody = bitbucket.massageMarkdown(codeBlock` --- - [ ] If you want to rebase/retry this PR, click this checkbox - [ ] Update renovate/renovate to 16.1.2 --- Empty comment. Followed by some information. `); expect(prBody).not.toContain('