import { isNonEmptyArray, isNonEmptyString } from '@sindresorhus/is'; import { GlobalConfig } from '../../../config/global.ts'; import { REPOSITORY_NOT_FOUND } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import type { BranchStatus } from '../../../types/index.ts'; import { getInheritedOrGlobal, parseJson } from '../../../util/common.ts'; import * as git from '../../../util/git/index.ts'; import * as hostRules from '../../../util/host-rules.ts'; import type { BitbucketHttpOptions } from '../../../util/http/bitbucket.ts'; import { BitbucketHttp, setBaseUrl } from '../../../util/http/bitbucket.ts'; import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts'; import { aggressiveRepoCacheProvider, repoCacheProvider, } from '../../../util/http/cache/repository-http-cache-provider.ts'; import type { HttpOptions } from '../../../util/http/types.ts'; import * as promises from '../../../util/promises.ts'; import { regEx } from '../../../util/regex.ts'; import { sanitize } from '../../../util/sanitize.ts'; import { UUIDRegex, matchRegexOrGlobList } from '../../../util/string-match.ts'; import { parseUrl } from '../../../util/url.ts'; import type { AutodiscoverConfig, BranchStatusConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfig, EnsureIssueConfig, EnsureIssueResult, FindPRConfig, Issue, MergePRConfig, PlatformParams, PlatformResult, Pr, RepoParams, RepoResult, UpdatePrConfig, } from '../types.ts'; import { repoFingerprint } from '../util.ts'; import { smartTruncate } from '../utils/pr-body.ts'; import * as comments from './comments.ts'; import { BitbucketPrCache } from './pr-cache.ts'; import { RepoInfo, Repositories, UnresolvedPrTasks, WorkspaceAccesses, } from './schema.ts'; import type { Account, BitbucketStatus, BranchResponse, Config, EffectiveReviewer, PagedResult, PrResponse, RepoBranchingModel, } from './types.ts'; import * as utils from './utils.ts'; import { mergeBodyTransformer } from './utils.ts'; export const id = 'bitbucket'; const bitbucketHttp = new BitbucketHttp(); const BITBUCKET_PROD_ENDPOINT = 'https://api.bitbucket.org/'; let config: Config = {} as any; export function resetPlatform(): void { config = {} as any; renovateUserUuid = null; } const defaults = { endpoint: BITBUCKET_PROD_ENDPOINT }; const pathSeparator = '/'; let renovateUserUuid: string | null = null; export async function initPlatform({ endpoint, username, password, token, }: PlatformParams): Promise { if (!(username && password) && !token) { throw new Error( 'Init: You must configure either a Bitbucket token or username and password', ); } if (endpoint && endpoint !== BITBUCKET_PROD_ENDPOINT) { logger.warn( { endpoint, defaultEndpoint: BITBUCKET_PROD_ENDPOINT }, 'Init: Bitbucket Cloud endpoint should generally be the default but is being configured to a different value. Did you mean to use Bitbucket Server?', ); defaults.endpoint = endpoint; } setBaseUrl(defaults.endpoint); renovateUserUuid = null; const options: HttpOptions = { memCache: false }; if (token) { options.token = token; } else { options.username = username; options.password = password; } try { const { uuid } = ( await bitbucketHttp.getJsonUnchecked('/2.0/user', options) ).body; renovateUserUuid = uuid; } catch (err) { if ( err.statusCode === 403 && err.body?.error?.detail?.required?.includes('account') ) { logger.warn(`Bitbucket: missing 'account' scope for password`); } else { logger.debug({ err }, 'Unknown error fetching Bitbucket user identity'); } } // TODO: Add a connection check that endpoint/username/password combination are valid (#9594) const platformConfig: PlatformResult = { endpoint: endpoint ?? BITBUCKET_PROD_ENDPOINT, }; return Promise.resolve(platformConfig); } // Get all repositories that the user has access to export async function getRepos(config: AutodiscoverConfig): Promise { logger.debug('Autodiscovering Bitbucket Cloud repositories'); try { // Determine which workspaces to query. // If the caller supplied explicit namespaces use them directly; // otherwise discover all workspaces the authenticated user belongs to. // The old cross-workspace endpoint GET /2.0/repositories?role=contributor // was removed by Bitbucket on 2026-03-31 (CHANGE-2770). let workspaceSlugs: string[]; const autodiscoverNamespaces = config.namespaces; if (isNonEmptyArray(autodiscoverNamespaces)) { logger.debug( { autodiscoverNamespaces }, 'Using configured namespaces as Bitbucket workspaces', ); workspaceSlugs = autodiscoverNamespaces; } else { logger.debug('Fetching Bitbucket workspaces for the current user'); const { body: slugs } = await bitbucketHttp.getJson( '/2.0/user/workspaces', { paginate: true }, WorkspaceAccesses, ); workspaceSlugs = slugs; logger.debug( { workspaceSlugs }, `Found ${workspaceSlugs.length} Bitbucket workspace(s)`, ); } // Fetch repositories for every workspace in parallel (concurrency-limited). const repoArrays = await promises.map(workspaceSlugs, (workspace) => bitbucketHttp .getJson( `/2.0/repositories/${workspace}`, { paginate: true }, Repositories, ) .then(({ body }) => body), ); let repos = repoArrays.flat(); // if autodiscoverProjects is configured, filter the repos list const autodiscoverProjects = config.projects; if (isNonEmptyArray(autodiscoverProjects)) { logger.debug( { autodiscoverProjects: config.projects }, 'Applying autodiscoverProjects filter', ); repos = repos.filter( (repo) => repo.projectName && matchRegexOrGlobList(repo.projectName, autodiscoverProjects), ); } return repos.map(({ owner, name }) => `${owner}/${name}`); } catch (err) /* v8 ignore next -- defensive: repo listing failures are logged and rethrown, not simulated in specs */ { logger.error({ err }, `bitbucket getRepos error`); throw err; } } export async function getRawFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { // See: https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/src/%7Bcommit%7D/%7Bpath%7D const repo = repoName ?? config.repository; const path = fileName; let finalBranchOrTag = branchOrTag; if (branchOrTag?.includes(pathSeparator)) { // Branch name contains slash, so we have to replace branch name with SHA1 of the head commit; otherwise the API will not work. finalBranchOrTag = await getBranchCommit(branchOrTag); } const url = `/2.0/repositories/${repo}/src/${finalBranchOrTag ?? `HEAD`}/${path}`; const res = await bitbucketHttp.getText(url, { cacheProvider: repoCacheProvider, }); return res.body; } export async function getJsonFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { // TODO #22198 const raw = await getRawFile(fileName, repoName, branchOrTag); return parseJson(raw, fileName); } // Initialize bitbucket by getting base branch and SHA export async function initRepo({ repository, cloneSubmodules, cloneSubmodulesFilter, }: RepoParams): Promise { logger.debug(`initRepo("${repository}")`); const opts = hostRules.find({ hostType: 'bitbucket', url: defaults.endpoint, }); config = { repository, ignorePrAuthor: GlobalConfig.get('ignorePrAuthor'), } as Config; let info: RepoInfo; let mainBranch: string; try { const { body: repoInfo } = await bitbucketHttp.getJson( `/2.0/repositories/${repository}`, RepoInfo, ); info = repoInfo; mainBranch = info.mainbranch; if (getInheritedOrGlobal('bbUseDevelopmentBranch')) { logger.debug( "bbUseDevelopmentBranch is true - Checking BitBucket's development branch", ); const developmentBranch = ( await bitbucketHttp.getJsonUnchecked( `/2.0/repositories/${repository}/effective-branching-model`, ) ).body.development?.name; if (developmentBranch) { mainBranch = developmentBranch; logger.debug( `${developmentBranch} is BitBucket's development branch - using it as default branch`, ); } } config.defaultBranch = mainBranch; config = { ...config, owner: info.owner, mergeMethod: info.mergeMethod, is_private: info.is_private, }; logger.debug(`${repository} owner = ${config.owner}`); } catch (err) /* v8 ignore next -- initRepo error mapping (404 to not-found) is not mocked in specs */ { if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } logger.debug({ err }, 'Unknown Bitbucket initRepo error'); throw err; } const parsedEndpoint = parseUrl(defaults.endpoint); // v8 ignore if: endpoint is a constant if (!parsedEndpoint) { throw new Error(`Invalid Bitbucket endpoint: ${defaults.endpoint}`); } const { hostname } = parsedEndpoint; // Converts API hostnames to their respective HTTP git hosts: // `api.bitbucket.org` to `bitbucket.org` // `api-staging.` to `staging.` // TODO #22198 const hostnameWithoutApiPrefix = regEx(/api[.|-](?.+)/).exec(hostname)?.groups?.host; let auth = ''; if (opts.token) { auth = `x-token-auth:${opts.token}`; } else if (opts.password?.startsWith('ATAT')) { auth = `x-bitbucket-api-token-auth:${opts.password}`; } else { auth = `${opts.username!}:${opts.password!}`; } const url = git.getUrl({ protocol: 'https', auth, hostname: hostnameWithoutApiPrefix, repository, }); await git.initRepo({ ...config, url, cloneSubmodules, cloneSubmodulesFilter, }); const repoConfig: RepoResult = { defaultBranch: mainBranch, isFork: info.isFork, repoFingerprint: repoFingerprint(info.uuid, defaults.endpoint), }; return repoConfig; } /* v8 ignore next -- covered only through findPr callers, never invoked directly in specs */ function matchesState(state: string, desiredState: string): boolean { if (desiredState === 'all') { return true; } if (desiredState.startsWith('!')) { return state !== desiredState.substring(1); } return state === desiredState; } export async function getPrList(): Promise { logger.trace('getPrList()'); return await BitbucketPrCache.getPrs( bitbucketHttp, config.repository, renovateUserUuid, ); } export async function findPr({ branchName, prTitle, state = 'all', includeOtherAuthors, }: FindPRConfig): Promise { logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`); if (includeOtherAuthors) { // PR might have been created by anyone, so don't use the cached Renovate PR list const prs = ( await bitbucketHttp.getJsonUnchecked>( `/2.0/repositories/${config.repository}/pullrequests?q=source.branch.name="${branchName}"&state=open`, { cacheProvider: memCacheProvider }, ) ).body.values; if (prs.length === 0) { logger.debug(`No PR found for branch ${branchName}`); return null; } return utils.prInfo(prs[0]); } const prList = await getPrList(); const pr = prList.find( (p) => p.sourceBranch === branchName && (!prTitle || p.title.toUpperCase() === prTitle.toUpperCase()) && matchesState(p.state, state), ); if (!pr) { return null; } logger.debug(`Found PR #${pr.number}`); /** * Bitbucket doesn't support renaming or reopening declined PRs. * Instead, we have to use comment-driven signals. */ if (pr.state === 'closed') { const reopenComments = await comments.reopenComments(config, pr.number); if (isNonEmptyArray(reopenComments)) { if (config.is_private) { // Only workspace members could have commented on a private repository logger.debug( `Found '${comments.REOPEN_PR_COMMENT_KEYWORD}' comment from workspace member. Renovate will reopen PR ${pr.number} as a new PR`, ); return null; } for (const comment of reopenComments) { if (await isAccountMemberOfWorkspace(comment.user, config.repository)) { logger.debug( `Found '${comments.REOPEN_PR_COMMENT_KEYWORD}' comment from workspace member. Renovate will reopen PR ${pr.number} as a new PR`, ); return null; } } } } return pr; } // Gets details for a PR export async function getPr(prNo: number): Promise { const pr = ( await bitbucketHttp.getJsonUnchecked( `/2.0/repositories/${config.repository}/pullrequests/${prNo}`, { cacheProvider: aggressiveRepoCacheProvider }, ) ).body; /* v8 ignore next -- defensive: the PR endpoint returns a body or throws, never an empty body */ if (!pr) { return null; } const res: Pr = { ...utils.prInfo(pr), }; if (isNonEmptyArray(pr.reviewers)) { res.reviewers = pr.reviewers .map(({ uuid }) => uuid) .filter(isNonEmptyString); } return res; } function escapeHash(input: string): string { return input?.replace(regEx(/#/g), '%23'); } // Return the commit SHA for a branch async function getBranchCommit( branchName: string, ): Promise { try { const branch = ( await bitbucketHttp.getJsonUnchecked( `/2.0/repositories/${config.repository}/refs/branches/${escapeHash( branchName, )}`, { cacheProvider: aggressiveRepoCacheProvider }, ) ).body; return branch.target.hash; } catch (err) /* v8 ignore next -- defensive: missing branch is logged and mapped to undefined, not mocked in specs */ { logger.debug({ err }, `getBranchCommit('${branchName}') failed'`); return undefined; } } // Returns the Pull Request for a branch. Null if not exists. export async function getBranchPr(branchName: string): Promise { logger.debug(`getBranchPr(${branchName})`); const existingPr = await findPr({ branchName, state: 'open', }); return existingPr ? getPr(existingPr.number) : null; } async function getStatus( branchName: string, memCache = true, ): Promise { const sha = await getBranchCommit(branchName); const opts: BitbucketHttpOptions = { paginate: true }; /* v8 ignore next: temporary code */ if (memCache) { opts.cacheProvider = aggressiveRepoCacheProvider; } else { opts.memCache = false; } return ( await bitbucketHttp.getJsonUnchecked>( `/2.0/repositories/${config.repository}/commit/${sha!}/statuses`, opts, ) ).body.values; } // Returns the combined status for a branch. export async function getBranchStatus( branchName: string, internalChecksAsSuccess: boolean, ): Promise { logger.debug(`getBranchStatus(${branchName})`); const statuses = await getStatus(branchName); logger.debug({ branch: branchName, statuses }, 'branch status check result'); if (!statuses.length) { logger.debug('empty branch status check result = returning "pending"'); return 'yellow'; } const noOfFailures = statuses.filter( (status: { state: string }) => status.state === 'FAILED' || status.state === 'STOPPED', ).length; if (noOfFailures) { return 'red'; } const noOfPending = statuses.filter( (status: { state: string }) => status.state === 'INPROGRESS', ).length; if (noOfPending) { return 'yellow'; } if ( !internalChecksAsSuccess && statuses.every( (status) => status.state === 'SUCCESSFUL' && status.key?.startsWith('renovate/'), ) ) { logger.debug( 'Successful checks are all internal renovate/ checks, so returning "pending" branch status', ); return 'yellow'; } return 'green'; } const bbToRenovateStatusMapping: Record = { SUCCESSFUL: 'green', INPROGRESS: 'yellow', FAILED: 'red', }; export async function getBranchStatusCheck( branchName: string, context: string, ): Promise { const statuses = await getStatus(branchName); const bbState = statuses.find((status) => status.key === context)?.state; // TODO #22198 return bbToRenovateStatusMapping[bbState!] || null; } export async function setBranchStatus({ branchName, context, description, state, url: targetUrl, }: BranchStatusConfig): Promise { const sha = await getBranchCommit(branchName); // TargetUrl can not be empty so default to bitbucket /* v8 ignore next -- specs always pass a targetUrl, fallback exists for direct API constraints */ const url = targetUrl ?? 'https://bitbucket.org'; const body = { name: context, state: utils.buildStates[state], key: context, description, url, }; await bitbucketHttp.postJson( `/2.0/repositories/${config.repository}/commit/${sha}/statuses/build`, { body }, ); // invalidate status cache const branchStatusesUrl = bitbucketHttp .resolveUrl(`/2.0/repositories/${config.repository}/commit/${sha}/statuses`) .toString(); aggressiveRepoCacheProvider.markSynced('get', branchStatusesUrl, false); } /** * Remove or transform markdown into Bitbucket supported syntax. * * See https://bitbucket.org/tutorials/markdowndemo/src for supported markdown syntax */ export function massageMarkdown(input: string): string { let massaged = smartTruncate(input, maxBodyLength()) .replace( 'you tick the rebase/retry checkbox', 'by renaming this PR to start with "rebase!"', ) .replace( 'checking the rebase/retry box above', 'renaming the PR to start with "rebase!"', ) .replace( regEx( /
\n(?:View abandoned dependencies.*<\/summary>\n\n)(?[\s\S]*?)<\/details>/, ), '$', ) .replace(regEx(`\n---\n\n.*?.*?\n`), '') .replace(regEx(/\]\(\.\.\/issues\//g), '](../../issues/') .replace(regEx(/\]\(\.\.\/pull\//g), '](../../pull-requests/') .replace(regEx(//g), ''); massaged = massageDetailSummaryHtmlToNestedLists(massaged); return massageCodeblockMarkdown(massaged); } /** * Massage codeblocks indentation to ensure correct rendering in Bitbucket. */ function massageCodeblockMarkdown(body: string): string { const codeBlockRegex = regEx( /^(?[ \t]*)```(?\w*)[^\n]*\n(?[\s\S]*?)\n[ \t]*```/gm, ); let codeMatch; let result = body; while ((codeMatch = codeBlockRegex.exec(body)) !== null) { const { indent, lang, code } = codeMatch.groups!; const indentLength = indent.length; const lines = code.split('\n'); const cleanedLines = lines.map((line) => // Remove `indentLength` characters from the start of each line line.slice(indentLength), ); const cleaned = cleanedLines.join('\n'); const replacement = `\`\`\`${lang}\n${cleaned}\n\`\`\``; result = result.replace(codeMatch[0], replacement); } return result; } /** * Massage collapsible html sections into nested unordered lists. * * Bitbucket doesn't currently support collapsible syntax; https://jira.atlassian.com/browse/BCLOUD-20231 */ function massageDetailSummaryHtmlToNestedLists(body: string): string { let depth = 0; // Parse detail parts to calculate correct list depth const detailsParts = body.split('
').map((raw) => { const partDepth = depth; depth += 1; const countClosingDetailsTags = raw.split('
').length - 1; depth = Math.max(0, depth - countClosingDetailsTags); return { raw, partDepth }; }); // Reassemble parts while replacing collapsible html elements with markdown list return detailsParts .map(({ raw, partDepth }) => { let t = raw; if (partDepth === 0) { return t; } const partIndentation = '\t'.repeat(partDepth - 1); const nestedListItemIndentation = '\t'.repeat(partDepth); const rawContainsBlockquote = raw.includes('
'); t = t.replace(regEx(/<\/?summary>/g), partDepth === 1 ? '**' : '`'); if (partDepth > 1) { t = t.replace( regEx(/^(?[ \t]*- [`[])/gm), `${nestedListItemIndentation}$`, ); } let result = partIndentation; if (rawContainsBlockquote || partDepth > 1) { result += ' - '; } result += t; return result; }) .join('') .replace(regEx(/<\/?(?:summary|details|blockquote)>/g), ''); } export function maxBodyLength(): number { return 250000; } function logIssuesRemoved(): void { logger.once.debug( 'Bitbucket Cloud has removed its issue tracker, so Renovate features which rely on issues, like the Dependency Dashboard, do not work. See https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-3071', ); } export function findIssue(title: string): Promise { logger.debug(`findIssue(${title})`); logIssuesRemoved(); return Promise.resolve(null); } export function ensureIssue({ title, }: EnsureIssueConfig): Promise { logger.once.warn({ title }, 'Cannot ensure issue'); logIssuesRemoved(); return Promise.resolve(null); } export function getIssueList(): Promise { logger.debug(`getIssueList()`); logIssuesRemoved(); return Promise.resolve([]); } export function ensureIssueClosing(title: string): Promise { logger.debug(`ensureIssueClosing(${title})`); logIssuesRemoved(); return Promise.resolve(); } export function addAssignees( _prNr: number, _assignees: string[], ): Promise { // Bitbucket supports "participants" and "reviewers" so does not seem to have the concept of "assignee" logger.warn('Cannot add assignees'); return Promise.resolve(); } export async function addReviewers( prId: number, reviewers: string[], ): Promise { logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${prId}`); // TODO #22198 const { title } = (await getPr(prId))!; const body = { title, reviewers: reviewers.map((username: string) => { const isUUID = username.startsWith('{') && username.endsWith('}') && UUIDRegex.test(username.slice(1, -1)); const key = isUUID ? 'uuid' : 'username'; return { [key]: username, }; }), }; await bitbucketHttp.putJson( `/2.0/repositories/${config.repository}/pullrequests/${prId}`, { body, }, ); } /* v8 ignore next -- stub: Bitbucket Cloud has no PR labels, callers never reach this */ export function deleteLabel(): never { throw new Error('deleteLabel not implemented'); } export function ensureComment({ number, topic, content, }: EnsureCommentConfig): Promise { // https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment return comments.ensureComment({ config, number, topic, content: sanitize(content), }); } export function ensureCommentRemoval( deleteConfig: EnsureCommentRemovalConfig, ): Promise { return comments.ensureCommentRemoval(config, deleteConfig); } async function sanitizeReviewers( reviewers: Account[], err: any, ): Promise { if (err.statusCode === 400 && err.body?.error?.fields?.reviewers) { const sanitizedReviewers: Account[] = []; const MSG_AUTHOR_AND_REVIEWER = 'is the author and cannot be included as a reviewer.'; const MSG_MALFORMED_REVIEWERS_LIST = 'Malformed reviewers list'; const MSG_NOT_WORKSPACE_MEMBER = 'is not a member of this workspace and cannot be added to this pull request'; for (const msg of err.body.error.fields.reviewers) { // Bitbucket returns a 400 if any of the PR reviewer accounts are now inactive (ie: disabled/suspended) if (msg === MSG_MALFORMED_REVIEWERS_LIST) { logger.debug( { err }, 'PR contains reviewers that may be either inactive or no longer a member of this workspace. Will try setting only active reviewers', ); // Validate that each previous PR reviewer account is still active for (const reviewer of reviewers) { const reviewerUser = ( await bitbucketHttp.getJsonUnchecked( `/2.0/users/${reviewer.uuid}`, { cacheProvider: aggressiveRepoCacheProvider }, ) ).body; // There are cases where an active user may still not be a member of a workspace if ( reviewerUser.account_status === 'active' && (await isAccountMemberOfWorkspace(reviewer, config.repository)) ) { sanitizedReviewers.push(reviewer); } } // Bitbucket returns a 400 if any of the PR reviewer accounts are no longer members of this workspace } else if (msg.endsWith(MSG_NOT_WORKSPACE_MEMBER)) { logger.debug( { err }, 'PR contains reviewer accounts which are no longer member of this workspace. Will try setting only member reviewers', ); // Validate that each previous PR reviewer account is still a member of this workspace for (const reviewer of reviewers) { if (await isAccountMemberOfWorkspace(reviewer, config.repository)) { sanitizedReviewers.push(reviewer); } } } else if (msg.endsWith(MSG_AUTHOR_AND_REVIEWER)) { logger.debug( { err }, 'PR contains reviewer accounts which are also the author. Will try setting only non-author reviewers', ); const author = msg.replace(MSG_AUTHOR_AND_REVIEWER, '').trim(); for (const reviewer of reviewers) { if (reviewer.display_name !== author) { sanitizedReviewers.push(reviewer); } } } else { return undefined; } } return sanitizedReviewers; } return undefined; } async function isAccountMemberOfWorkspace( reviewer: Account, repository: string, ): Promise { const workspace = repository.split('/')[0]; try { await bitbucketHttp.get( `/2.0/workspaces/${workspace}/members/${reviewer.uuid}`, { cacheProvider: aggressiveRepoCacheProvider }, ); return true; } catch (err) { // HTTP 404: User cannot be found, or the user is not a member of this workspace. if (err.statusCode === 404) { logger.debug( { err }, `User ${reviewer.display_name} is not a member of the workspace ${workspace}. Will be removed from the PR`, ); return false; } throw err; } } // Creates PR and returns PR number export async function createPr({ sourceBranch, targetBranch, prTitle: title, prBody: description, draftPR = false, platformPrOptions, }: CreatePRConfig): Promise { // labels is not supported in Bitbucket: https://bitbucket.org/site/master/issues/11976/ability-to-add-labels-to-pull-requests-bb const base = targetBranch; logger.debug({ repository: config.repository, title, base }, 'Creating PR'); let reviewers: Account[] = []; if (platformPrOptions?.bbUseDefaultReviewers) { const reviewersResponse = ( await bitbucketHttp.getJsonUnchecked>( `/2.0/repositories/${config.repository}/effective-default-reviewers`, { paginate: true, cacheProvider: aggressiveRepoCacheProvider, }, ) ).body; reviewers = reviewersResponse.values.map((reviewer: EffectiveReviewer) => ({ uuid: reviewer.user.uuid, display_name: reviewer.user.display_name, })); } const body = { title, description: sanitize(description), source: { branch: { name: sourceBranch, }, }, destination: { branch: { name: base, }, }, close_source_branch: true, reviewers, draft: draftPR, }; try { const prRes = ( await bitbucketHttp.postJson( `/2.0/repositories/${config.repository}/pullrequests`, { body, }, ) ).body; const pr = utils.prInfo(prRes); await BitbucketPrCache.setPr( bitbucketHttp, config.repository, renovateUserUuid, pr, ); if (platformPrOptions?.bbAutoResolvePrTasks) { await autoResolvePrTasks(pr); } return pr; } catch (err) /* v8 ignore next -- reviewer-sanitizing retry path depends on API error shapes not mocked in specs */ { // Try sanitizing reviewers const sanitizedReviewers = await sanitizeReviewers(reviewers, err); if (sanitizedReviewers === undefined) { logger.warn({ err }, 'Error creating pull request'); throw err; } else { const prRes = ( await bitbucketHttp.postJson( `/2.0/repositories/${config.repository}/pullrequests`, { body: { ...body, reviewers: sanitizedReviewers, }, }, ) ).body; const pr = utils.prInfo(prRes); await BitbucketPrCache.setPr( bitbucketHttp, config.repository, renovateUserUuid, pr, ); if (platformPrOptions?.bbAutoResolvePrTasks) { await autoResolvePrTasks(pr); } return pr; } } } async function autoResolvePrTasks(pr: Pr): Promise { logger.debug(`Auto resolve PR tasks in #${pr.number}`); try { const unResolvedTasks = ( await bitbucketHttp.getJson( `/2.0/repositories/${config.repository}/pullrequests/${pr.number}/tasks`, { paginate: true, pagelen: 100 }, UnresolvedPrTasks, ) ).body; logger.trace( { prId: pr.number, listTaskRes: unResolvedTasks, }, 'List PR tasks', ); for (const task of unResolvedTasks) { const res = await bitbucketHttp.putJson( `/2.0/repositories/${config.repository}/pullrequests/${pr.number}/tasks/${task.id}`, { body: { state: 'RESOLVED', content: { raw: task.content.raw, }, }, }, ); logger.trace( { prId: pr.number, updateTaskResponse: res, }, 'Put PR tasks - mark resolved', ); } } catch (err) { logger.warn({ prId: pr.number, err }, 'Error resolving PR tasks'); } } export async function updatePr({ number: prNo, prTitle: title, prBody: description, state, targetBranch, }: UpdatePrConfig): Promise { logger.debug(`updatePr(${prNo}, ${title}, body)`); // Updating a PR in Bitbucket will clear the reviewers if reviewers is not present const pr = ( await bitbucketHttp.getJsonUnchecked( `/2.0/repositories/${config.repository}/pullrequests/${prNo}`, ) ).body; let updatedPrRes: PrResponse; try { const body: any = { title, description: sanitize(description), reviewers: pr.reviewers, }; if (targetBranch) { body.destination = { branch: { name: targetBranch, }, }; } updatedPrRes = ( await bitbucketHttp.putJson( `/2.0/repositories/${config.repository}/pullrequests/${prNo}`, { body }, ) ).body; } catch (err) { // Try sanitizing reviewers const sanitizedReviewers = await sanitizeReviewers(pr.reviewers, err); if (sanitizedReviewers === undefined) { throw err; } else { updatedPrRes = ( await bitbucketHttp.putJson( `/2.0/repositories/${config.repository}/pullrequests/${prNo}`, { body: { title, description: sanitize(description), reviewers: sanitizedReviewers, }, }, ) ).body; } } if (state === 'closed' && pr) { await bitbucketHttp.postJson( `/2.0/repositories/${config.repository}/pullrequests/${prNo}/decline`, ); } // update pr cache await BitbucketPrCache.setPr( bitbucketHttp, config.repository, renovateUserUuid, utils.prInfo({ ...updatedPrRes, ...(state && { state }) }), ); } export async function mergePr({ branchName, id: prNo, strategy: mergeStrategy, }: MergePRConfig): Promise { logger.debug(`mergePr(${prNo}, ${branchName}, ${mergeStrategy})`); // Bitbucket Cloud does not support a rebase-alike; https://jira.atlassian.com/browse/BCLOUD-16610 if (mergeStrategy === 'rebase') { logger.warn('Bitbucket Cloud does not support a "rebase" strategy.'); return false; } try { await bitbucketHttp.postJson( `/2.0/repositories/${config.repository}/pullrequests/${prNo}/merge`, { body: mergeBodyTransformer(mergeStrategy), }, ); logger.debug('Automerging succeeded'); } catch (err) /* v8 ignore next -- defensive: merge failures are logged and mapped to false, not simulated in specs */ { logger.debug({ err }, `PR merge error`); logger.info({ pr: prNo }, 'PR automerge failed'); return false; } return true; }