mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-02 10:33:39 +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>
1437 lines
42 KiB
TypeScript
1437 lines
42 KiB
TypeScript
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<PlatformResult> {
|
|
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<string[]> {
|
|
logger.debug('Autodiscovering Bitbucket Server repositories');
|
|
try {
|
|
const repos = (
|
|
await bitbucketServerHttp.getJsonUnchecked<BbsRestRepo[]>(
|
|
`./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<string | null> {
|
|
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<FileData>(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<any> {
|
|
// 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<RepoResult> {
|
|
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<number, number>(),
|
|
username: opts.username,
|
|
ignorePrAuthor: GlobalConfig.get('ignorePrAuthor'),
|
|
} as any;
|
|
|
|
try {
|
|
const info = (
|
|
await bitbucketServerHttp.getJsonUnchecked<BbsRestRepo>(
|
|
`./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<BbsRestBranch>(
|
|
`./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<boolean> {
|
|
// 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<BbsPr | null> {
|
|
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<BbsRestPr>(
|
|
`./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<Pr[]> {
|
|
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<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 searchParams: Record<string, string> = {
|
|
state: 'OPEN',
|
|
};
|
|
searchParams.direction = 'outgoing';
|
|
searchParams.at = `refs/heads/${branchName}`;
|
|
|
|
const query = getQueryString(searchParams);
|
|
const prs = (
|
|
await bitbucketServerHttp.getJsonUnchecked<BbsRestPr[]>(
|
|
`./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<BbsPr | null> {
|
|
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<void> {
|
|
// wait for pr change propagation
|
|
await setTimeout(1000);
|
|
// refresh cache
|
|
await getPr(number, true);
|
|
}
|
|
|
|
async function getStatus(
|
|
branchName: string,
|
|
memCache = true,
|
|
): Promise<BitbucketCommitStatus> {
|
|
const branchCommit = git.getBranchCommit(branchName);
|
|
|
|
/* v8 ignore next: temporary code */
|
|
const opts: HttpOptions = memCache
|
|
? { cacheProvider: memCacheProvider }
|
|
: { memCache: false };
|
|
|
|
return (
|
|
await bitbucketServerHttp.getJsonUnchecked<BitbucketCommitStatus>(
|
|
// 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<BranchStatus> {
|
|
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<BitbucketStatus[]> {
|
|
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<BitbucketStatus[]>(
|
|
`./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<BranchStatus | null> {
|
|
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<void> {
|
|
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<Issue | null> {
|
|
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<EnsureIssueResult | null> {
|
|
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<Issue[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${prNo}`);
|
|
|
|
const reviewerNames = new Set<string>();
|
|
|
|
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<string[]> {
|
|
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<void> {
|
|
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<T extends (...arg0: any[]) => Promise<any>>(
|
|
fn: T,
|
|
args: Parameters<T>,
|
|
maxTries: number,
|
|
retryErrorMessages: string[],
|
|
): Promise<Awaited<ReturnType<T>>> {
|
|
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<void> {
|
|
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<Comment[]> {
|
|
// GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/activities
|
|
const activities = (
|
|
await bitbucketServerHttp.getJsonUnchecked<PullRequestActivity[]>(
|
|
`./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<void> {
|
|
// 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<number> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<Pr> {
|
|
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<BbsRestPr> = {
|
|
title,
|
|
description,
|
|
fromRef: {
|
|
id: `refs/heads/${sourceBranch}`,
|
|
},
|
|
toRef: {
|
|
id: `refs/heads/${base}`,
|
|
},
|
|
reviewers,
|
|
};
|
|
let prInfoRes: HttpResponse<BbsRestPr>;
|
|
try {
|
|
prInfoRes = await bitbucketServerHttp.postJson<BbsRestPr>(
|
|
`./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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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.*?<!-- rebase-check -->.*?(\n|$)`), '')
|
|
.replace(regEx(/<!--.*?-->/gs), '')
|
|
.replace(
|
|
regEx(
|
|
/(?<badge>!\[.+?\]\(https:\/\/developer\.mend\.io\/api\/mc\/badges\/.+?\))/g,
|
|
),
|
|
'$<badge>{height=20}',
|
|
);
|
|
}
|
|
|
|
export function maxBodyLength(): number {
|
|
return 30000;
|
|
}
|