mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-03 19:14:36 +00:00
3412de84ce
* refactor(lint): enable oxlint prefer-named-capture-group at error Numbered capture groups (m[1], $1) break silently when a regex is edited and group order shifts. Named groups (?<name>...) make intent explicit and accesses (m.groups.name) resilient to reordering. This enables the built-in eslint rule prefer-named-capture-group, surfacing 321 pre-existing unnamed-group violations across ~121 files to be burned down incrementally in follow-up commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(versioning): name capture groups for prefer-named-capture-group Burns down all prefer-named-capture-group violations in lib/modules/versioning/. Unused capture groups (only reachable via .test() or whole-match) become non-capturing (?:...); groups whose value is read become named (?<name>...), with accesses rewritten to match.groups!.<name> (or destructured from .groups!) instead of positional match[n]/$n so a future edit that reorders groups cannot silently break the reader. Two cases needed extra care to preserve behavior: - semver/common.ts and poetry/patterns.ts split() on a regex whose capture group is relied upon by String.prototype.split to keep the separator in the output array; the group is kept capturing and named, not converted to non-capturing. - hashicorp/convertor.ts's semverRegex is only ever embedded via .source into a named `(?<version>...)` group elsewhere and never matched directly, so its internal groups are safe to make non-capturing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(datasource): name capture groups for prefer-named-capture-group Burns down all prefer-named-capture-group violations under lib/modules/datasource/. Same treatment as the versioning module: unused groups become non-capturing, used groups get names, and positional match[n] reads move to match.groups!.<name>. docker/ecr.ts and pypi/common.ts keep their positional destructuring (`const [, region] = ...`, `!!m &&`) working unchanged since a named capture group is still addressable by its numeric index in JavaScript — only pypi/common.ts's access was rewritten to m.groups!.owner for clarity since it read a single group directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(platform): name capture groups for prefer-named-capture-group Burns down all prefer-named-capture-group violations under lib/modules/platform/. Uses the repo's established idiom of `match.groups?.<name>` after an optional-chained exec()/match() (see bitbucket/index.ts hostnameWithoutApiPrefix, gerrit/utils.ts extractSourceBranch) and `$<name>` in String.replace replacement strings. Unused groups (markdown/HTML massaging regexes matched only to be stripped) become non-capturing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(manager): name capture groups in ansible-galaxy for prefer-named-capture-group Burns down prefer-named-capture-group violations in lib/modules/manager/ansible-galaxy/. newBlockRegEx and blockLineRegEx dropped their unused outer wrapping group (only the inner key/value groups were ever read) and named the key/value groups; nameMatchRegex's remaining unused groups (protocol/user-info/port alternatives) become non-capturing since only source/hostname/depName/version are read via .groups. Accesses in collections.ts and roles.ts move from lineMatch[2]/lineMatch[3] to lineMatch.groups!.key/value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(manager): name capture groups for prefer-named-capture-group (batch 1) Burns down prefer-named-capture-group violations across the first alphabetical slice of lib/modules/manager/ (ansible through mix). Used groups get names with accesses rewritten to match.groups!.<name> (or $<name> in replacement strings); groups whose value is never read become non-capturing (?:...). Two String.split() call sites (git-submodules/extract.ts, meteor/extract.ts) keep the established "named group still readable positionally from the split() result array" idiom instead of restructuring the destructuring. bazel/rules/go.ts and buildpacks/extract.ts dockerRef verified byte-for-byte equivalent before/after via a quick node script since several nested groups collapsed to non-capturing at once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(manager): name capture groups for prefer-named-capture-group (batch 2) Burns down prefer-named-capture-group violations across the remaining alphabetical slice of lib/modules/manager/ (npm through terragrunt). Same treatment as batch 1: used groups get names with accesses moved to match.groups!.<name> or $<name> replacement strings; unused groups (wrapping alternations, quantifier repetition groups only consumed via .test(), or values never read from a match) become non-capturing. terraform/extractors/others/modules.ts and terragrunt/modules.ts share near-identical URL-parsing regexes (github/bitbucket/git/azure-devops ref matchers) — the same set of always-unused `depth=`/`&depth=` query-string groups is converted to non-capturing in both files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(config): name capture groups for prefer-named-capture-group Burns down prefer-named-capture-group violations in lib/config/. Used groups get names with replacement strings switched from $1/$2 to $<name>; the unused separator group in schedule-migration.ts's afterBeforeRegex becomes non-capturing. presets/parse.ts keeps assigning through the already-declared `repo` variable rather than array-destructuring the exec() result, matching this file's existing style for the other named-group regexes just above it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(util): name capture groups for prefer-named-capture-group Burns down prefer-named-capture-group violations across lib/util/ and lib/logger/. Same treatment as previous batches: named groups for values that are read (via $<name> replacement strings, .groups!.<name>, or destructured from .groups), non-capturing groups where nothing reads the captured value (e.g. semantic.ts's angular-commit .test()-only regex). www-authenticate.ts's tokenizer entries each get their own named group but keep accessing the match generically via match[1], since a positionally-indexed named group is still readable by index. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(workers): name capture groups for prefer-named-capture-group Burns down prefer-named-capture-group violations in lib/workers/. Used groups get names with $<name> replacement strings; unused wrapper/alternation groups (e.g. semantic-commit-message.ts's optional scope/issue wrappers, release-notes.ts's date-heading and horizontal-rule matchers that are only probed with .search()/replaced wholesale) become non-capturing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * refactor(test,tools): name capture groups for prefer-named-capture-group Finishes the prefer-named-capture-group burndown: test/, tools/, and vitest.config.mts. Groups whose value is read get names ($<name> replacement strings or .groups.<name> access); groups only used via .test()/.search() or that feed a .match(/…/g) call (whose return value is the array of whole matches, not captured groups) become non-capturing. This is the last remaining slice of pre-existing violations, so the rule (already enabled at error repo-wide) now passes with zero findings across the whole codebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RxHFNsSworLcv23xXk9Hk * fix prettier failure * refactor(lint): use a named capture group in `v8-ignore-no-count` New rule from main trips the newly enabled `prefer-named-capture-group` oxlint rule. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1154 lines
33 KiB
TypeScript
1154 lines
33 KiB
TypeScript
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<PlatformResult> {
|
|
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<Account>('/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<string[]> {
|
|
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<string | null> {
|
|
// 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<any> {
|
|
// 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<RepoResult> {
|
|
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<RepoBranchingModel>(
|
|
`/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.<host>` to `staging.<host>`
|
|
// TODO #22198
|
|
const hostnameWithoutApiPrefix =
|
|
regEx(/api[.|-](?<host>.+)/).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<Pr[]> {
|
|
logger.trace('getPrList()');
|
|
return await BitbucketPrCache.getPrs(
|
|
bitbucketHttp,
|
|
config.repository,
|
|
renovateUserUuid,
|
|
);
|
|
}
|
|
|
|
export async function findPr({
|
|
branchName,
|
|
prTitle,
|
|
state = 'all',
|
|
includeOtherAuthors,
|
|
}: FindPRConfig): Promise<Pr | null> {
|
|
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<PagedResult<PrResponse>>(
|
|
`/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<Pr | null> {
|
|
const pr = (
|
|
await bitbucketHttp.getJsonUnchecked<PrResponse>(
|
|
`/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<string | undefined> {
|
|
try {
|
|
const branch = (
|
|
await bitbucketHttp.getJsonUnchecked<BranchResponse>(
|
|
`/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<Pr | null> {
|
|
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<BitbucketStatus[]> {
|
|
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<PagedResult<BitbucketStatus>>(
|
|
`/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<BranchStatus> {
|
|
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<string, BranchStatus> = {
|
|
SUCCESSFUL: 'green',
|
|
INPROGRESS: 'yellow',
|
|
FAILED: 'red',
|
|
};
|
|
|
|
export async function getBranchStatusCheck(
|
|
branchName: string,
|
|
context: string,
|
|
): Promise<BranchStatus | null> {
|
|
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<void> {
|
|
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(
|
|
/<details>\n(?:<summary>View abandoned dependencies.*<\/summary>\n\n)(?<body>[\s\S]*?)<\/details>/,
|
|
),
|
|
'$<body>',
|
|
)
|
|
.replace(regEx(`\n---\n\n.*?<!-- rebase-check -->.*?\n`), '')
|
|
.replace(regEx(/\]\(\.\.\/issues\//g), '](../../issues/')
|
|
.replace(regEx(/\]\(\.\.\/pull\//g), '](../../pull-requests/')
|
|
.replace(regEx(/<!--renovate-(?:debug|config-hash):.*?-->/g), '');
|
|
|
|
massaged = massageDetailSummaryHtmlToNestedLists(massaged);
|
|
|
|
return massageCodeblockMarkdown(massaged);
|
|
}
|
|
|
|
/**
|
|
* Massage codeblocks indentation to ensure correct rendering in Bitbucket.
|
|
*/
|
|
function massageCodeblockMarkdown(body: string): string {
|
|
const codeBlockRegex = regEx(
|
|
/^(?<indent>[ \t]*)```(?<lang>\w*)[^\n]*\n(?<code>[\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('<details>').map((raw) => {
|
|
const partDepth = depth;
|
|
|
|
depth += 1;
|
|
const countClosingDetailsTags = raw.split('</details>').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('<blockquote>');
|
|
|
|
t = t.replace(regEx(/<\/?summary>/g), partDepth === 1 ? '**' : '`');
|
|
|
|
if (partDepth > 1) {
|
|
t = t.replace(
|
|
regEx(/^(?<prefix>[ \t]*- [`[])/gm),
|
|
`${nestedListItemIndentation}$<prefix>`,
|
|
);
|
|
}
|
|
|
|
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<Issue | null> {
|
|
logger.debug(`findIssue(${title})`);
|
|
logIssuesRemoved();
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
export function ensureIssue({
|
|
title,
|
|
}: EnsureIssueConfig): Promise<EnsureIssueResult | null> {
|
|
logger.once.warn({ title }, 'Cannot ensure issue');
|
|
logIssuesRemoved();
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
export function getIssueList(): Promise<Issue[]> {
|
|
logger.debug(`getIssueList()`);
|
|
logIssuesRemoved();
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
export function ensureIssueClosing(title: string): Promise<void> {
|
|
logger.debug(`ensureIssueClosing(${title})`);
|
|
logIssuesRemoved();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
export function addAssignees(
|
|
_prNr: number,
|
|
_assignees: string[],
|
|
): Promise<void> {
|
|
// 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<void> {
|
|
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<boolean> {
|
|
// 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<void> {
|
|
return comments.ensureCommentRemoval(config, deleteConfig);
|
|
}
|
|
|
|
async function sanitizeReviewers(
|
|
reviewers: Account[],
|
|
err: any,
|
|
): Promise<Account[] | undefined> {
|
|
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<Account>(
|
|
`/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<boolean> {
|
|
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<Pr> {
|
|
// 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<PagedResult<EffectiveReviewer>>(
|
|
`/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<PrResponse>(
|
|
`/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<PrResponse>(
|
|
`/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<void> {
|
|
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<void> {
|
|
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<PrResponse>(
|
|
`/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<PrResponse>(
|
|
`/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<PrResponse>(
|
|
`/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<boolean> {
|
|
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;
|
|
}
|