import { setTimeout } from 'node:timers/promises'; import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import ignore from 'ignore'; import semver from 'semver'; import type { PartialDeep } from 'type-fest'; import { GlobalConfig } from '../../../config/global.ts'; import { REPOSITORY_CHANGED, REPOSITORY_EMPTY, REPOSITORY_NOT_FOUND, } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import type { BranchStatus } from '../../../types/index.ts'; import type { FileData } from '../../../types/platform/bitbucket-server/index.ts'; import { parseJson } from '../../../util/common.ts'; import { getEnv } from '../../../util/env.ts'; import * as git from '../../../util/git/index.ts'; import { deleteBranch } from '../../../util/git/index.ts'; import * as hostRules from '../../../util/host-rules.ts'; import { BitbucketServerHttp, type BitbucketServerHttpOptions, setBaseUrl, } from '../../../util/http/bitbucket-server.ts'; import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts'; import type { HttpOptions, HttpResponse } from '../../../util/http/types.ts'; import { newlineRegex, regEx } from '../../../util/regex.ts'; import { sampleSize } from '../../../util/sample.ts'; import { sanitize } from '../../../util/sanitize.ts'; import { isEmailAdress } from '../../../util/schema-utils/index.ts'; import { ensureTrailingSlash, getQueryString } from '../../../util/url.ts'; import type { BranchStatusConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfig, EnsureIssueConfig, EnsureIssueResult, FileOwnerRule, FindPRConfig, Issue, MergePRConfig, PlatformParams, PlatformPrOptions, PlatformResult, Pr, ReattemptPlatformAutomergeConfig, RepoParams, RepoResult, UpdatePrConfig, } from '../types.ts'; import { getNewBranchName, repoFingerprint } from '../util.ts'; import { smartTruncate } from '../utils/pr-body.ts'; import { BbsPrCache } from './pr-cache.ts'; import type { Comment, PullRequestActivity, PullRequestCommentActivity, PullRequestMerge, } from './schema.ts'; import { ReviewerGroups, User, Users } from './schema.ts'; import type { BbsConfig, BbsPr, BbsRestBranch, BbsRestPr, BbsRestRepo, BbsRestUserRef, BitbucketCommitStatus, BitbucketStatus, } from './types.ts'; import * as utils from './utils.ts'; import { getExtraCloneOpts, parseModifier, splitEscapedSpaces, } from './utils.ts'; /* * Version: 5.3 (EOL Date: 15 Aug 2019) * See following docs for api information: * https://docs.atlassian.com/bitbucket-server/rest/5.3.0/bitbucket-rest.html * https://docs.atlassian.com/bitbucket-server/rest/5.3.0/bitbucket-build-rest.html * * See following page for uptodate supported versions * https://confluence.atlassian.com/support/atlassian-support-end-of-life-policy-201851003.html#AtlassianSupportEndofLifePolicy-BitbucketServer */ export const id = 'bitbucket-server'; let config: BbsConfig = {} as any; const bitbucketServerHttp = new BitbucketServerHttp(); const defaults: { endpoint?: string; hostType: string; version: string; } = { hostType: 'bitbucket-server', version: '0.0.0', }; /* v8 ignore next -- exercised only via mocked call sites, direct coverage tracked in #40625 */ function updatePrVersion(pr: number, version: number): number { const res = Math.max(config.prVersions.get(pr) ?? 0, version); config.prVersions.set(pr, res); return res; } export async function initPlatform({ endpoint, token, username, password, gitAuthor, }: PlatformParams): Promise { if (!endpoint) { throw new Error('Init: You must configure a Bitbucket Server endpoint'); } if (!(username && password) && !token) { throw new Error( 'Init: You must either configure a Bitbucket Server username/password or a HTTP access token', ); } else if (password && token) { throw new Error( 'Init: You must configure either a Bitbucket Server password or a HTTP access token, not both', ); } // TODO: Add a connection check that endpoint/username/password combination are valid (#9595) defaults.endpoint = ensureTrailingSlash(endpoint); setBaseUrl(defaults.endpoint); const platformConfig: PlatformResult = { endpoint: defaults.endpoint, }; try { const env = getEnv(); let bitbucketServerVersion = env.RENOVATE_X_PLATFORM_VERSION; const { body, headers } = await bitbucketServerHttp.getJsonUnchecked<{ version: string; }>(`./rest/api/1.0/application-properties`, { ...(token && { token }) }); bitbucketServerVersion ??= body.version; if (isNonEmptyStringAndNotWhitespace(headers['x-ausername']) && !username) { logger.debug( { 'x-ausername': headers['x-ausername'] }, 'Platform: No username configured using headers["x-ausername"]', ); config.username = headers['x-ausername']; } logger.debug(`Bitbucket Server version is: ${bitbucketServerVersion}`); // v8 ignore else -- TODO: add test #40625 if (semver.valid(bitbucketServerVersion)) { defaults.version = bitbucketServerVersion; } } catch (err) { logger.debug( { err }, 'Error authenticating with Bitbucket. Check that your token includes "api" permissions', ); } if (!gitAuthor && username) { logger.debug(`Attempting to confirm gitAuthor from username`); const options: HttpOptions = { memCache: false, }; if (token) { options.token = token; } else { options.username = username; options.password = password; } try { const { displayName, emailAddress } = ( await bitbucketServerHttp.getJson( `./rest/api/1.0/users/${username}`, options, User, ) ).body; if (!emailAddress?.length) { throw new Error(`No email address configured for username ${username}`); } platformConfig.gitAuthor = `${displayName} <${emailAddress}>`; logger.debug(`Detected gitAuthor: ${platformConfig.gitAuthor}`); } catch (err) { logger.debug( { err }, 'Failed to get user info, fallback gitAuthor will be used', ); } } return platformConfig; } // Get all repositories that the user has access to export async function getRepos(): Promise { logger.debug('Autodiscovering Bitbucket Server repositories'); try { const repos = ( await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/repos?permission=REPO_WRITE&state=AVAILABLE`, { paginate: true }, ) ).body; const result = repos.map((repo) => `${repo.project.key}/${repo.slug}`); logger.debug({ result }, 'result of getRepos()'); return result; } 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 { const repo = repoName ?? config.repository; const [project, slug] = repo.split('/'); const fileUrl = `./rest/api/1.0/projects/${project}/repos/${slug}/browse/${fileName}?limit=20000${branchOrTag ? `&at=${branchOrTag}` : ''}`; const res = await bitbucketServerHttp.getJsonUnchecked(fileUrl); const { isLastPage, lines, size } = res.body; if (isLastPage) { return lines.map(({ text }) => text).join('\n'); } logger.warn({ size }, 'The file is too big'); throw new Error(`The file is too big (${size}B)`); } 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 Server by getting base branch export async function initRepo({ repository, cloneSubmodules, cloneSubmodulesFilter, gitUrl, }: RepoParams): Promise { logger.debug(`initRepo("${JSON.stringify({ repository }, null, 2)}")`); const opts = hostRules.find({ hostType: defaults.hostType, url: defaults.endpoint, }); const [projectKey, repositorySlug] = repository.split('/'); config = { projectKey, repositorySlug, repository, prVersions: new Map(), username: opts.username, ignorePrAuthor: GlobalConfig.get('ignorePrAuthor'), } as any; try { const info = ( await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}`, ) ).body; config.owner = info.project.key; logger.debug(`${repository} owner = ${config.owner}`); const branchRes = await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/branches/default`, ); // 204 means empty, 404 means repo not found or missing default branch. repo must exist here. if ([204, 404].includes(branchRes.statusCode)) { throw new Error(REPOSITORY_EMPTY); } const url = utils.getRepoGitUrl( config.repositorySlug, // TODO #22198 defaults.endpoint!, gitUrl, info, opts, ); await git.initRepo({ ...config, url, extraCloneOpts: getExtraCloneOpts(opts), cloneSubmodules, cloneSubmodulesFilter, fullClone: semver.lte(defaults.version, '8.0.0'), }); config.mergeMethod = 'merge'; const repoConfig: RepoResult = { defaultBranch: branchRes.body.displayId, isFork: !!info.origin, repoFingerprint: repoFingerprint(info.id, defaults.endpoint), }; return repoConfig; } catch (err) /* v8 ignore next -- initRepo error mapping (404, empty repo) is not mocked in specs */ { if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } if (err.message === REPOSITORY_EMPTY) { throw err; } logger.debug({ err }, 'Unknown Bitbucket initRepo error'); throw err; } } export async function getBranchForceRebase( _branchName: string, ): Promise { // https://docs.atlassian.com/bitbucket-server/rest/7.0.1/bitbucket-rest.html#idp342 const res = await bitbucketServerHttp.getJsonUnchecked<{ mergeConfig: { defaultStrategy: { id: string } }; }>( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/settings/pull-requests`, ); // If the default merge strategy contains `ff-only` the PR can only be merged // if it is up to date with the base branch. // The current options for id are: // no-ff, ff, ff-only, rebase-no-ff, rebase-ff-only, squash, squash-ff-only return Boolean( res.body?.mergeConfig?.defaultStrategy?.id.includes('ff-only'), ); } // Gets details for a PR export async function getPr( prNo: number, refreshCache?: boolean, ): Promise { logger.debug(`getPr(${prNo})`); if (!prNo) { return null; } // Disables memCache (which is enabled by default) to be replaced by // memCacheProvider. const opts: HttpOptions = { memCache: false }; // TODO: should refresh the cache rather than just ignore it if (!refreshCache) { opts.cacheProvider = memCacheProvider; } const res = await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}`, opts, ); const pr: BbsPr = { ...utils.prInfo(res.body), reviewers: res.body.reviewers.map((r) => r.user.name), }; // TODO #22198 pr.version = updatePrVersion(pr.number, pr.version!); return pr; } // TODO: coverage (#40625) /* v8 ignore next -- covered only through findPr callers, direct coverage tracked in #40625 */ function matchesState(state: string, desiredState: string): boolean { if (desiredState === 'all') { return true; } if (desiredState.startsWith('!')) { return state !== desiredState.substring(1); } return state === desiredState; } // TODO: coverage (#40625) /* v8 ignore next -- covered only through findPr callers, direct coverage tracked in #40625 */ function isRelevantPr( branchName: string, prTitle: string | null | undefined, state: string, ) { return (p: Pr): boolean => p.sourceBranch === branchName && (!prTitle || p.title.toUpperCase() === prTitle.toUpperCase()) && matchesState(p.state, state); } // TODO: coverage (#40625) export async function getPrList(): Promise { logger.debug(`getPrList()`); return await BbsPrCache.getPrs( bitbucketServerHttp, config.projectKey, config.repositorySlug, config.ignorePrAuthor, config.username, ); } // TODO: coverage (#40625) /* v8 ignore next -- pr-cache-backed lookup paths lack direct specs, tracked in #40625 */ 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 searchParams: Record = { state: 'OPEN', }; searchParams.direction = 'outgoing'; searchParams.at = `refs/heads/${branchName}`; const query = getQueryString(searchParams); const prs = ( await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests?${query}`, { paginate: true, limit: 1, // only fetch the latest pr }, ) ).body; if (!prs.length) { logger.debug(`No PR found for branch ${branchName}`); return null; } return utils.prInfo(prs[0]); } const prList = await getPrList(); const pr = prList.find(isRelevantPr(branchName, prTitle, state)); if (pr) { logger.debug(`Found PR #${pr.number}`); } else { logger.debug(`Renovate did not find a PR for branch ${branchName}`); } return pr ?? null; } // 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; } /* v8 ignore next -- real-time propagation delay helper, sleeping 1s in specs is not worthwhile */ export async function refreshPr(number: number): Promise { // wait for pr change propagation await setTimeout(1000); // refresh cache await getPr(number, true); } async function getStatus( branchName: string, memCache = true, ): Promise { const branchCommit = git.getBranchCommit(branchName); /* v8 ignore next: temporary code */ const opts: HttpOptions = memCache ? { cacheProvider: memCacheProvider } : { memCache: false }; return ( await bitbucketServerHttp.getJsonUnchecked( // TODO: types (#22198) `./rest/build-status/1.0/commits/stats/${branchCommit!}`, opts, ) ).body; } // Returns the combined status for a branch. // umbrella for status checks // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-build-rest.html#idp2 export async function getBranchStatus( branchName: string, ): Promise { logger.debug(`getBranchStatus(${branchName})`); if (!git.branchExists(branchName)) { logger.debug('Branch does not exist - cannot fetch status'); throw new Error(REPOSITORY_CHANGED); } try { const commitStatus = await getStatus(branchName); logger.debug({ commitStatus }, 'branch status check result'); if (commitStatus.failed > 0) { return 'red'; } if (commitStatus.inProgress > 0) { return 'yellow'; } return commitStatus.successful > 0 ? 'green' : 'yellow'; } catch (err) { logger.warn({ err }, `Failed to get branch status`); return 'red'; } } async function getStatusCheck( branchName: string, memCache = true, ): Promise { const branchCommit = git.getBranchCommit(branchName); const opts: BitbucketServerHttpOptions = { paginate: true }; /* v8 ignore next: temporary code */ if (memCache) { opts.cacheProvider = memCacheProvider; } else { opts.memCache = false; } return ( await bitbucketServerHttp.getJsonUnchecked( `./rest/build-status/1.0/commits/${branchCommit!}`, opts, ) ).body; } // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-build-rest.html#idp2 export async function getBranchStatusCheck( branchName: string, context: string, ): Promise { logger.debug(`getBranchStatusCheck(${branchName}, context=${context})`); try { const states = await getStatusCheck(branchName); for (const state of states) { if (state.key === context) { switch (state.state) { case 'SUCCESSFUL': return 'green'; case 'INPROGRESS': return 'yellow'; case 'FAILED': default: return 'red'; } } } } catch (err) { logger.warn({ err }, `Failed to check branch status`); } return null; } export async function setBranchStatus({ branchName, context, description, state, url: targetUrl, }: BranchStatusConfig): Promise { logger.debug(`setBranchStatus(${branchName})`); const existingStatus = await getBranchStatusCheck(branchName, context); if (existingStatus === state) { return; } logger.debug({ branch: branchName, context, state }, 'Setting branch status'); const branchCommit = git.getBranchCommit(branchName); try { const body: any = { key: context, description, url: targetUrl ?? 'https://renovatebot.com', }; switch (state) { case 'green': body.state = 'SUCCESSFUL'; break; case 'yellow': body.state = 'INPROGRESS'; break; case 'red': default: body.state = 'FAILED'; break; } await bitbucketServerHttp.postJson( // TODO: types (#22198) `./rest/build-status/1.0/commits/${branchCommit!}`, { body }, ); // update status cache await getStatus(branchName, false); await getStatusCheck(branchName, false); } catch (err) { logger.warn({ err }, `Failed to set branch status`); } } // Issue /* v8 ignore next -- no-op stub: Bitbucket Server has no issues */ export function findIssue(title: string): Promise { logger.debug(`findIssue(${title})`); // This is used by Renovate when creating its own issues, // e.g. for deprecated package warnings, // config error notifications, or "dependencyDashboard" // // Bitbucket Server does not have issues return Promise.resolve(null); } /* v8 ignore next -- no-op stub: Bitbucket Server has no issues */ export function ensureIssue({ title, }: EnsureIssueConfig): Promise { logger.warn({ title }, 'Cannot ensure issue'); // This is used by Renovate when creating its own issues, // e.g. for deprecated package warnings, // config error notifications, or "dependencyDashboard" // // Bitbucket Server does not have issues return Promise.resolve(null); } /* v8 ignore next -- no-op stub: Bitbucket Server has no issues */ export function getIssueList(): Promise { logger.debug(`getIssueList()`); // This is used by Renovate when creating its own issues, // e.g. for deprecated package warnings, // config error notifications, or "dependencyDashboard" // // Bitbucket Server does not have issues return Promise.resolve([]); } /* v8 ignore next -- no-op stub: Bitbucket Server has no issues */ export function ensureIssueClosing(title: string): Promise { logger.debug(`ensureIssueClosing(${title})`); // This is used by Renovate when creating its own issues, // e.g. for deprecated package warnings, // config error notifications, or "dependencyDashboard" // // Bitbucket Server does not have issues return Promise.resolve(); } export function addAssignees(iid: number, assignees: string[]): Promise { logger.debug(`addAssignees(${iid}, [${assignees.join(', ')}])`); // This is used by Renovate when creating its own issues, // e.g. for deprecated package warnings, // config error notifications, or "dependencyDashboard" // // Bitbucket Server does not have issues return Promise.resolve(); } export async function addReviewers( prNo: number, reviewers: string[], ): Promise { logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${prNo}`); const reviewerNames = new Set(); for (const entry of reviewers) { // If entry is an email address, resolve username if (isEmailAdress(entry)) { const names = await getUsernamesByEmail(entry); for (const name of names) { reviewerNames.add(name); } } else { reviewerNames.add(entry); } } await retry(updatePRAndAddReviewers, [prNo, Array.from(reviewerNames)], 3, [ REPOSITORY_CHANGED, ]); } /** * Resolves Bitbucket users by email address, * restricted to users who have REPO_READ permission on the target repository. * * @param emailAddress - A string that could be the user's email address. * @returns List of usernames for active, matched users. */ export async function getUsernamesByEmail( emailAddress: string, ): Promise { try { const filterUrl = `./rest/api/1.0/users?filter=${emailAddress}` + `&permission.1=REPO_READ` + `&permission.1.projectKey=${config.projectKey}` + `&permission.1.repositorySlug=${config.repositorySlug}`; const users = await bitbucketServerHttp.getJson( filterUrl, { paginate: true, limit: 100 }, Users, ); if (users.body.length) { return users.body .filter((u) => u.active && u.emailAddress === emailAddress) .map((u) => u.name); } } catch (err) { logger.warn( { err, emailAddress }, `Failed to resolve email address to username`, ); throw err; } logger.debug({ userinfo: emailAddress }, 'No users found for email-address'); return []; } async function updatePRAndAddReviewers( prNo: number, reviewers: string[], ): Promise { try { const pr = await getPr(prNo); if (!pr) { throw new Error(REPOSITORY_NOT_FOUND); } // TODO: can `reviewers` be undefined? (#40625) const reviewersSet = new Set([...pr.reviewers!, ...reviewers]); await bitbucketServerHttp.putJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}`, { body: { title: pr.title, version: pr.version, reviewers: Array.from(reviewersSet).map((name) => ({ user: { name }, })), }, }, ); await getPr(prNo, true); } catch (err) { logger.warn({ err, reviewers, prNo }, `Failed to add reviewers`); if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } else if (err.statusCode === 409) { if (utils.isInvalidReviewersResponse(err)) { // Retry again with invalid reviewers being removed const invalidReviewers = utils.getInvalidReviewers(err); const filteredReviewers = reviewers.filter( (name) => !invalidReviewers.includes(name), ); if (filteredReviewers.length < reviewers.length) { await updatePRAndAddReviewers(prNo, filteredReviewers); } else { logger.warn( { invalidReviewers, reviewers }, 'Could not filter invalid reviewers from list, aborting to prevent infinite recursion', ); throw err; } } else { logger.debug( '409 response to adding reviewers - has repository changed?', ); throw new Error(REPOSITORY_CHANGED); } } else { throw err; } } } async function retry Promise>( fn: T, args: Parameters, maxTries: number, retryErrorMessages: string[], ): Promise>> { const maxAttempts = Math.max(maxTries, 1); let lastError: Error | undefined; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn(...args); } catch (e) { lastError = e; if ( retryErrorMessages.length !== 0 && !retryErrorMessages.includes(e.message) ) { logger.debug(`Error not marked for retry`); throw e; } } } logger.debug(`All ${maxAttempts} retry attempts exhausted`); throw lastError!; } export function deleteLabel(issueNo: number, label: string): Promise { logger.debug(`deleteLabel(${issueNo}, ${label})`); // Only used for the "request Renovate to rebase a PR using a label" feature // // Bitbucket Server does not have issues return Promise.resolve(); } async function getComments(prNo: number): Promise { // GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/activities const activities = ( await bitbucketServerHttp.getJsonUnchecked( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/activities`, { paginate: true }, ) ).body; const comments = activities .filter( (a): a is PullRequestCommentActivity => a.action === 'COMMENTED' && 'comment' in a && 'commentAction' in a, ) .filter((a) => a.commentAction === 'ADDED') .map((a) => a.comment); logger.debug(`Found ${comments.length} comments`); return comments; } async function addComment(prNo: number, text: string): Promise { // POST /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments await bitbucketServerHttp.postJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments`, { body: { text }, }, ); } async function getCommentVersion( prNo: number, commentId: number, ): Promise { // GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId} const { version } = ( await bitbucketServerHttp.getJsonUnchecked<{ version: number }>( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}`, ) ).body; return version; } async function editComment( prNo: number, commentId: number, text: string, ): Promise { const version = await getCommentVersion(prNo, commentId); // PUT /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId} await bitbucketServerHttp.putJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}`, { body: { text, version }, }, ); } async function deleteComment(prNo: number, commentId: number): Promise { const version = await getCommentVersion(prNo, commentId); // DELETE /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId} await bitbucketServerHttp.deleteJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}?version=${version}`, ); } export async function ensureComment({ number, topic, content, }: EnsureCommentConfig): Promise { const sanitizedContent = sanitize(content); try { const comments = await getComments(number); let body: string; let commentId: number | undefined; let commentNeedsUpdating: boolean | undefined; if (topic) { logger.debug(`Ensuring comment "${topic}" in #${number}`); body = `### ${topic}\n\n${sanitizedContent}`; comments.forEach((comment) => { if (comment.text.startsWith(`### ${topic}\n\n`)) { commentId = comment.id; commentNeedsUpdating = comment.text !== body; } }); } else { logger.debug(`Ensuring content-only comment in #${number}`); body = `${sanitizedContent}`; comments.forEach((comment) => { if (comment.text === body) { commentId = comment.id; commentNeedsUpdating = false; } }); } if (!commentId) { await addComment(number, body); logger.info( { repository: config.repository, prNo: number, topic }, 'Comment added', ); } else if (commentNeedsUpdating) { await editComment(number, commentId, body); logger.debug( { repository: config.repository, prNo: number }, 'Comment updated', ); } else { logger.debug('Comment is already up-to-date'); } return true; } catch (err) /* v8 ignore next -- defensive: comment API failures are logged and swallowed, not simulated in specs */ { logger.warn({ err }, 'Error ensuring comment'); return false; } } function byTopic(comment: Comment, topic: string): boolean { return comment.text.startsWith(`### ${topic}\n\n`); } function byContent(comment: Comment, content: string): boolean { return comment.text.trim() === content; } export async function ensureCommentRemoval( deleteConfig: EnsureCommentRemovalConfig, ): Promise { try { const { number: prNo } = deleteConfig; const key = deleteConfig.type === 'by-topic' ? deleteConfig.topic : deleteConfig.content; logger.debug(`Ensuring comment "${key}" in #${prNo} is removed`); const comments = await getComments(prNo); let commentId: number | null | undefined = null; // v8 ignore else -- TODO: add test #40625 if (deleteConfig.type === 'by-topic') { const topic = deleteConfig.topic; commentId = comments.find((comment) => byTopic(comment, topic))?.id; } else if (deleteConfig.type === 'by-content') { const content = deleteConfig.content; commentId = comments.find((comment) => byContent(comment, content))?.id; } if (commentId) { await deleteComment(prNo, commentId); } } catch (err) /* v8 ignore next -- defensive: comment API failures are logged and swallowed, not simulated in specs */ { logger.warn({ err }, 'Error ensuring comment removal'); } } // Pull Request function escapeHash(input: string): string { return input?.replace(regEx(/#/g), '%23'); } export async function createPr({ sourceBranch, targetBranch, prTitle: title, prBody: rawDescription, platformPrOptions, }: CreatePRConfig): Promise { const description = sanitize(rawDescription); logger.debug(`createPr(${sourceBranch}, title=${title})`); const base = targetBranch; let reviewers: BbsRestUserRef[] = []; if (platformPrOptions?.bbUseDefaultReviewers) { logger.debug(`fetching default reviewers`); const { id } = ( await bitbucketServerHttp.getJsonUnchecked<{ id: number }>( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}`, ) ).body; const defReviewers = ( await bitbucketServerHttp.getJsonUnchecked<{ name: string }[]>( `./rest/default-reviewers/1.0/projects/${config.projectKey}/repos/${ config.repositorySlug }/reviewers?sourceRefId=refs/heads/${escapeHash( sourceBranch, )}&targetRefId=refs/heads/${base}&sourceRepoId=${id}&targetRepoId=${id}`, ) ).body; reviewers = defReviewers.map((u) => ({ user: { name: u.name }, })); } const body: PartialDeep = { title, description, fromRef: { id: `refs/heads/${sourceBranch}`, }, toRef: { id: `refs/heads/${base}`, }, reviewers, }; let prInfoRes: HttpResponse; try { prInfoRes = await bitbucketServerHttp.postJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests`, { body }, ); } catch (err) /* v8 ignore next -- EmptyPullRequestException handling is not mocked in specs */ { if ( err.body?.errors?.[0]?.exceptionName === 'com.atlassian.bitbucket.pull.EmptyPullRequestException' ) { logger.debug( 'Empty pull request - deleting branch so it can be recreated next run', ); await deleteBranch(sourceBranch); throw new Error(REPOSITORY_CHANGED); } throw err; } const pr: BbsPr = { ...utils.prInfo(prInfoRes.body), }; // TODO #22198 updatePrVersion(pr.number, pr.version!); await BbsPrCache.setPr( bitbucketServerHttp, config.projectKey, config.repositorySlug, config.ignorePrAuthor, config.username, pr, ); await tryPrAutomerge(pr.number, pr.version!, platformPrOptions); return pr; } export async function reattemptPlatformAutomerge({ number, platformPrOptions, }: ReattemptPlatformAutomergeConfig): Promise { logger.debug(`reattemptPlatformAutomerge(${number})`); try { const pr = await getPr(number, true); if (!pr) { throw new Error(REPOSITORY_NOT_FOUND); } await tryPrAutomerge(pr.number, pr.version!, platformPrOptions); logger.debug(`PR platform automerge re-attempted...prNo: ${number}`); } catch (err) { logger.warn({ err }, 'Error re-attempting PR platform automerge'); } } export async function updatePr({ number: prNo, prTitle: title, prBody: rawDescription, state, bitbucketInvalidReviewers, targetBranch, }: UpdatePrConfig & { bitbucketInvalidReviewers?: string[] | undefined; }): Promise { const description = sanitize(rawDescription); logger.debug(`updatePr(${prNo}, title=${title})`); try { const pr = await getPr(prNo); if (!pr) { throw Object.assign(new Error(REPOSITORY_NOT_FOUND), { statusCode: 404 }); } const body: any = { title, description, version: pr.version, reviewers: pr.reviewers ?.filter((name: string) => !bitbucketInvalidReviewers?.includes(name)) .map((name: string) => ({ user: { name } })), }; if (targetBranch) { body.toRef = { id: getNewBranchName(targetBranch), }; } const { body: updatedPr } = await bitbucketServerHttp.putJson< BbsRestPr & { version: number; } >( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}`, { body }, ); updatePrVersion(prNo, updatedPr.version); const currentState = updatedPr.state; // TODO #22198 const newState = { ['open']: 'OPEN', ['closed']: 'DECLINED', }[state!]; let finalState: 'open' | 'closed' = currentState === 'OPEN' ? 'open' : 'closed'; if ( newState && ['OPEN', 'DECLINED'].includes(currentState) && currentState !== newState ) { const command = state === 'open' ? 'reopen' : 'decline'; const { body: updatedStatePr } = await bitbucketServerHttp.postJson<{ version: number; }>( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${pr.number}/${command}?version=${updatedPr.version}`, ); finalState = state!; updatePrVersion(pr.number, updatedStatePr.version); } const bbsPr = utils.prInfo(updatedPr); await BbsPrCache.setPr( bitbucketServerHttp, config.projectKey, config.repositorySlug, config.ignorePrAuthor, config.username, { ...bbsPr, state: finalState }, ); } catch (err) { logger.debug({ err, prNo }, `Failed to update PR`); if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } else if (err.statusCode === 409) { if (utils.isInvalidReviewersResponse(err) && !bitbucketInvalidReviewers) { // Retry again with invalid reviewers being removed const invalidReviewers = utils.getInvalidReviewers(err); await updatePr({ number: prNo, prTitle: title, prBody: rawDescription, state, bitbucketInvalidReviewers: invalidReviewers, }); } else { throw new Error(REPOSITORY_CHANGED); } } else { throw err; } } } // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-rest.html#idp261 export async function mergePr({ branchName, id: prNo, }: MergePRConfig): Promise { logger.debug(`mergePr(${prNo}, ${branchName!})`); // Used for "automerge" feature try { const pr = await getPr(prNo); if (!pr) { throw Object.assign(new Error(REPOSITORY_NOT_FOUND), { statusCode: 404 }); } const { body } = await bitbucketServerHttp.postJson<{ version: number }>( // TODO: types (#22198) `./rest/api/1.0/projects/${config.projectKey}/repos/${ config.repositorySlug }/pull-requests/${prNo}/merge?version=${pr.version!}`, ); updatePrVersion(prNo, body.version); } catch (err) { if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } else if (err.statusCode === 409) { logger.warn({ err }, `Failed to merge PR`); return false; } else { logger.warn({ err }, `Failed to merge PR`); return false; } } logger.debug(`PR merged, PrNo:${prNo}`); return true; } /** * Enables Bitbucket Server-native automerge for the given PR. * https://confluence.atlassian.com/bitbucketserver094/merge-a-pull-request-1489802114.html#Mergeapullrequest-Auto-mergeapullrequest */ async function tryPrAutomerge( prNumber: number, prVersion: number, platformPrOptions?: PlatformPrOptions, ): Promise { if (!platformPrOptions?.usePlatformAutomerge) { return; } logger.debug(`tryPrAutomerge(${prNumber})`); if (semver.lt(defaults.version, '8.15.0')) { logger.debug( { prNumber }, 'Bitbucket Server-native automerge: not supported on this version of Bitbucket. Use 8.15.0 or newer.', ); return; } try { const body: PullRequestMerge = { autoMerge: true }; await bitbucketServerHttp.postJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNumber}/merge?version=${prVersion}`, { body }, ); // enabling auto-merge doesn't increase PR version, so we omit updating the cache logger.debug({ prNumber }, 'Bitbucket Server-native automerge: success'); } catch (err) { logger.warn({ err, prNumber }, 'Bitbucket Server-native automerge: fail'); } } export async function expandGroupMembers( reviewers: string[], ): Promise { logger.debug(`expandGroupMembers(${reviewers.join(', ')})`); const expandedUsers: string[] = []; const reviewerGroupPrefix = '@reviewer-group/'; for (const reviewer of reviewers) { const [baseEntry, modifier] = reviewer.split(':'); if (baseEntry.startsWith(reviewerGroupPrefix)) { const groupName = baseEntry.replace(reviewerGroupPrefix, ''); const groupUsers = await getUsersFromReviewerGroup(groupName); if (!groupUsers.length) { continue; } if (modifier) { const randomCount = parseModifier(modifier); if (randomCount) { expandedUsers.push(...sampleSize(groupUsers, randomCount)); continue; } } expandedUsers.push(...groupUsers); } else { expandedUsers.push(baseEntry); // Add the user entry } } return [...new Set(expandedUsers)]; } export function extractRulesFromCodeOwnersLines( cleanedLines: string[], ): FileOwnerRule[] { const results: FileOwnerRule[] = []; const reversedLines = cleanedLines .filter((line) => line.trim() !== '' && !line.trim().startsWith('#')) .reverse(); for (const line of reversedLines) { const [pattern, ...entries] = splitEscapedSpaces(line); const matcher = ignore().add(pattern); results.push({ pattern, usernames: [...new Set(entries)], score: pattern.length, match: (path: string) => matcher.ignores(path), }); } return results; } // Gets active users by name, from a reviewer group // Returns an empty array if the group is not found or has no active users // As there is no direct API to get group by name, we get all reviewer groups per repo and filter them // This is not efficient, but it is the only way to get users from a group by name // Supports both repository-scoped and project-scoped groups following the BitBucket server logic described here: // https://confluence.atlassian.com/bitbucketserver/code-owners-1296171116.html#Codeowners-Whatifaprojectandrepositorycontainareviewergroupwiththesamename? async function getUsersFromReviewerGroup(groupName: string): Promise { const allGroups = []; try { const reviewerGroups = await bitbucketServerHttp.getJson( `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/settings/reviewer-groups`, { paginate: true }, ReviewerGroups, ); allGroups.push(...reviewerGroups.body); } catch (err) { logger.debug({ err, groupName }, 'Failed to get reviewer groups for repo'); return []; } // First, try to find a repo-scoped group with this name const repoGroup = allGroups.find( (group) => group.name === groupName && group.scope?.type === 'REPOSITORY', ); if (repoGroup) { return repoGroup.users .filter((user) => user.active) .map((user) => user.name); } // If no repo-level group, fall back to project-level group const projectGroup = allGroups.find( (group) => group.name === groupName && group.scope?.type === 'PROJECT', ); if (projectGroup) { return projectGroup.users .filter((user) => user.active) .map((user) => user.name); } // Group not found at either level logger.warn( { groupName }, 'Reviewer group not found at repo or project level', ); return []; } export function massageMarkdown(input: string): string { logger.debug(`massageMarkdown(${input.split(newlineRegex)[0]})`); // Remove any HTML we use return smartTruncate(input, maxBodyLength()) .replace( 'you tick the rebase/retry checkbox', 'PR is renamed to start with "rebase!"', ) .replace( 'checking the rebase/retry box above', 'renaming the PR to start with "rebase!"', ) .replace(regEx(/<\/?summary>/g), '**') .replace(regEx(/<\/?details>/g), '') .replace(regEx(`\n---\n\n.*?.*?(\n|$)`), '') .replace(regEx(//gs), '') .replace( regEx( /(?!\[.+?\]\(https:\/\/developer\.mend\.io\/api\/mc\/badges\/.+?\))/g, ), '${height=20}', ); } export function maxBodyLength(): number { return 30000; }