mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 10:09:50 +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>
1137 lines
31 KiB
TypeScript
1137 lines
31 KiB
TypeScript
import { setTimeout } from 'node:timers/promises';
|
|
import { isString } from '@sindresorhus/is';
|
|
import type {
|
|
GitItem,
|
|
GitPullRequest,
|
|
GitPullRequestCommentThread,
|
|
GitStatus,
|
|
GitVersionDescriptor,
|
|
} from 'azure-devops-node-api/interfaces/GitInterfaces.js';
|
|
import {
|
|
GitPullRequestMergeStrategy,
|
|
GitStatusState,
|
|
GitVersionType,
|
|
PullRequestStatus,
|
|
} from 'azure-devops-node-api/interfaces/GitInterfaces.js';
|
|
import type { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces.js';
|
|
import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces.js';
|
|
import { getConfig } from '../../../config/defaults.ts';
|
|
import { GlobalConfig } from '../../../config/global.ts';
|
|
import {
|
|
REPOSITORY_ARCHIVED,
|
|
REPOSITORY_EMPTY,
|
|
REPOSITORY_NOT_FOUND,
|
|
} from '../../../constants/error-messages.ts';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
|
|
import type { BranchStatus } from '../../../types/index.ts';
|
|
import { parseJson } from '../../../util/common.ts';
|
|
import * as git from '../../../util/git/index.ts';
|
|
import * as hostRules from '../../../util/host-rules.ts';
|
|
import { regEx } from '../../../util/regex.ts';
|
|
import { sanitize } from '../../../util/sanitize.ts';
|
|
import { ensureTrailingSlash } from '../../../util/url.ts';
|
|
import type {
|
|
BranchStatusConfig,
|
|
CreatePRConfig,
|
|
EnsureCommentConfig,
|
|
EnsureCommentRemovalConfig,
|
|
EnsureIssueConfig,
|
|
EnsureIssueResult,
|
|
FindPRConfig,
|
|
Issue,
|
|
MergePRConfig,
|
|
PlatformParams,
|
|
PlatformResult,
|
|
Pr,
|
|
RepoParams,
|
|
RepoResult,
|
|
UpdatePrConfig,
|
|
} from '../types.ts';
|
|
import { getNewBranchName, repoFingerprint } from '../util.ts';
|
|
import { smartTruncate } from '../utils/pr-body.ts';
|
|
import { readOnlyIssueBody } from '../utils/read-only-issue-body.ts';
|
|
import * as azureApi from './azure-got-wrapper.ts';
|
|
import * as azureHelper from './azure-helper.ts';
|
|
import { IssueService } from './issue.ts';
|
|
import type { AzurePr, Config } from './types.ts';
|
|
import { AzurePrVote } from './types.ts';
|
|
import {
|
|
getBranchNameWithoutRefsheadsPrefix,
|
|
getGitStatusContextCombinedName,
|
|
getGitStatusContextFromCombinedName,
|
|
getRenovatePRFormat,
|
|
getRepoByName,
|
|
getStorageExtraCloneOpts,
|
|
mapMergeStrategy,
|
|
max4000Chars,
|
|
} from './util.ts';
|
|
|
|
interface User {
|
|
id: string;
|
|
name: string;
|
|
isRequired: boolean;
|
|
}
|
|
|
|
let config: Config = {} as any;
|
|
let issueService: IssueService;
|
|
let renovateUserId: string | undefined;
|
|
|
|
const defaults: {
|
|
endpoint?: string;
|
|
hostType: string;
|
|
} = {
|
|
hostType: 'azure',
|
|
};
|
|
|
|
export const id = 'azure';
|
|
|
|
export function initPlatform({
|
|
endpoint,
|
|
token,
|
|
username,
|
|
password,
|
|
}: PlatformParams): Promise<PlatformResult> {
|
|
if (!endpoint) {
|
|
throw new Error('Init: You must configure an Azure DevOps endpoint');
|
|
}
|
|
if (!token && !(username && password)) {
|
|
throw new Error(
|
|
'Init: You must configure an Azure DevOps token, or a username and password',
|
|
);
|
|
}
|
|
// TODO: Add a connection check that endpoint/token combination are valid (#9593)
|
|
const res = {
|
|
endpoint: ensureTrailingSlash(endpoint),
|
|
};
|
|
defaults.endpoint = res.endpoint;
|
|
azureApi.setEndpoint(res.endpoint);
|
|
const credentials = token
|
|
? { token }
|
|
: { username: username!, password: password! };
|
|
return azureApi.getAuthenticatedUserId(credentials).then((userId) => {
|
|
renovateUserId = userId;
|
|
return res;
|
|
});
|
|
}
|
|
|
|
export async function getRepos(): Promise<string[]> {
|
|
logger.debug('Autodiscovering Azure DevOps repositories');
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const repos = await azureApiGit.getRepositories();
|
|
return repos
|
|
.filter((repo) => repo.isDisabled !== true)
|
|
.map((repo) => `${repo.project?.name}/${repo.name}`);
|
|
}
|
|
|
|
export async function getRawFile(
|
|
fileName: string,
|
|
repoName?: string,
|
|
branchOrTag?: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const azureApiGit = await azureApi.gitApi();
|
|
|
|
let repoId: string | undefined;
|
|
if (repoName) {
|
|
const repos = await azureApiGit.getRepositories();
|
|
const repo = getRepoByName(repoName, repos);
|
|
repoId = repo?.id;
|
|
} else {
|
|
repoId = config.repoId;
|
|
}
|
|
|
|
if (!repoId) {
|
|
logger.debug('No repoId so cannot getRawFile');
|
|
return null;
|
|
}
|
|
|
|
let item: GitItem | undefined;
|
|
const versionDescriptor: GitVersionDescriptor = {
|
|
version: branchOrTag,
|
|
} satisfies GitVersionDescriptor;
|
|
// Try to get file from repo with tag first, if not found, then try with branch #36835
|
|
for (const versionType of [GitVersionType.Tag, GitVersionType.Branch]) {
|
|
versionDescriptor.versionType = versionType;
|
|
|
|
item = await azureApiGit.getItem(
|
|
repoId, // repositoryId
|
|
fileName, // path
|
|
undefined, // project
|
|
undefined, // scopePath
|
|
undefined, // recursionLevel
|
|
undefined, // includeContentMetadata
|
|
undefined, // latestProcessedChange
|
|
undefined, // download
|
|
branchOrTag ? versionDescriptor : undefined, // versionDescriptor
|
|
true, // includeContent
|
|
);
|
|
if (item) {
|
|
break; // exit loop if item is found
|
|
} else {
|
|
logger.debug(
|
|
`File: ${fileName} not found in ${repoName} with ${versionType}: ${branchOrTag}`,
|
|
);
|
|
}
|
|
}
|
|
return item?.content ?? null;
|
|
} catch (err) /* v8 ignore next -- Azure API failure modes (service unavailable, malformed items) are not mocked in specs */ {
|
|
if (
|
|
err.message?.includes('<title>Azure DevOps Services Unavailable</title>')
|
|
) {
|
|
logger.debug(
|
|
'Azure DevOps is currently unavailable when attempting to fetch file - throwing ExternalHostError',
|
|
);
|
|
throw new ExternalHostError(err, id);
|
|
}
|
|
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
|
|
throw new ExternalHostError(err, id);
|
|
}
|
|
if (err.statusCode && err.statusCode >= 500 && err.statusCode < 600) {
|
|
throw new ExternalHostError(err, id);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function getJsonFile(
|
|
fileName: string,
|
|
repoName?: string,
|
|
branchOrTag?: string,
|
|
): Promise<any> {
|
|
const raw = await getRawFile(fileName, repoName, branchOrTag);
|
|
return parseJson(raw, fileName);
|
|
}
|
|
|
|
export async function initRepo({
|
|
repository,
|
|
cloneSubmodules,
|
|
cloneSubmodulesFilter,
|
|
azureWorkItemType,
|
|
}: RepoParams): Promise<RepoResult> {
|
|
logger.debug(`initRepo("${repository}")`);
|
|
config = {
|
|
ignorePrAuthor: GlobalConfig.get('ignorePrAuthor'),
|
|
repository,
|
|
} as Config;
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const repos = await azureApiGit.getRepositories();
|
|
const repo = getRepoByName(repository, repos);
|
|
if (!repo) {
|
|
logger.error({ repos, repo }, 'Could not find repo in repo list');
|
|
throw new Error(REPOSITORY_NOT_FOUND);
|
|
}
|
|
logger.debug({ repositoryDetails: repo }, 'Repository details');
|
|
if (repo.isDisabled) {
|
|
logger.debug('Repository is disabled- throwing error to abort renovation');
|
|
throw new Error(REPOSITORY_ARCHIVED);
|
|
}
|
|
/* v8 ignore next -- defensive: Azure omits defaultBranch only for empty repos, which abort earlier in specs */
|
|
if (!repo.defaultBranch) {
|
|
logger.debug('Repo is empty');
|
|
throw new Error(REPOSITORY_EMPTY);
|
|
}
|
|
// TODO #22198
|
|
config.repoId = repo.id!;
|
|
|
|
config.project = repo.project!.name!;
|
|
config.projectId = repo.project!.id!;
|
|
config.workItemType = azureWorkItemType ?? getConfig().azureWorkItemType!;
|
|
issueService = new IssueService(config);
|
|
config.owner = '?owner?';
|
|
logger.debug(`${repository} owner = ${config.owner}`);
|
|
const defaultBranch = repo.defaultBranch.replace('refs/heads/', '');
|
|
config.defaultBranch = defaultBranch;
|
|
logger.debug(`${repository} default branch = ${defaultBranch}`);
|
|
config.mergeMethods = {};
|
|
config.repoForceRebase = false;
|
|
|
|
const [projectName, repoName] = repository.split('/');
|
|
const opts = hostRules.find({
|
|
hostType: defaults.hostType,
|
|
url: defaults.endpoint,
|
|
});
|
|
// TODO: types (#22198)
|
|
const manualUrl = `${defaults.endpoint!}${encodeURIComponent(
|
|
projectName,
|
|
)}/_git/${encodeURIComponent(repoName)}`;
|
|
const url = repo.remoteUrl ?? manualUrl;
|
|
await git.initRepo({
|
|
...config,
|
|
url,
|
|
extraCloneOpts: getStorageExtraCloneOpts(opts),
|
|
cloneSubmodules,
|
|
cloneSubmodulesFilter,
|
|
});
|
|
const repoConfig: RepoResult = {
|
|
defaultBranch,
|
|
isFork: false,
|
|
repoFingerprint: repoFingerprint(repo.id!, defaults.endpoint),
|
|
};
|
|
return repoConfig;
|
|
}
|
|
|
|
export async function getPrList(): Promise<AzurePr[]> {
|
|
logger.debug('getPrList()');
|
|
if (!config.prList) {
|
|
const azureApiGit = await azureApi.gitApi();
|
|
|
|
let prs: GitPullRequest[] = [];
|
|
let fetchedPrs: GitPullRequest[];
|
|
let skip = 0;
|
|
do {
|
|
fetchedPrs = await azureApiGit.getPullRequests(
|
|
config.repoId,
|
|
{
|
|
status: PullRequestStatus.All,
|
|
// fetch only prs directly created on the repo and not by forks
|
|
sourceRepositoryId: config.repoId,
|
|
...(!config.ignorePrAuthor &&
|
|
renovateUserId && { creatorId: renovateUserId }),
|
|
},
|
|
config.project,
|
|
0,
|
|
skip,
|
|
100,
|
|
);
|
|
prs = prs.concat(fetchedPrs);
|
|
skip += 100;
|
|
} while (fetchedPrs.length > 0);
|
|
|
|
config.prList = prs.map(getRenovatePRFormat);
|
|
logger.debug(`Retrieved Pull Requests count: ${config.prList.length}`);
|
|
}
|
|
return config.prList;
|
|
}
|
|
|
|
export async function getPr(pullRequestId: number): Promise<Pr | null> {
|
|
logger.debug(`getPr(${pullRequestId})`);
|
|
if (!pullRequestId) {
|
|
return null;
|
|
}
|
|
const azurePr = (await getPrList()).find(
|
|
(item) => item.number === pullRequestId,
|
|
);
|
|
|
|
if (!azurePr) {
|
|
return null;
|
|
}
|
|
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const labels = await azureApiGit.getPullRequestLabels(
|
|
config.repoId,
|
|
pullRequestId,
|
|
);
|
|
|
|
azurePr.labels = labels
|
|
.filter((label) => label.active)
|
|
.map((label) => label.name)
|
|
.filter(isString);
|
|
return azurePr;
|
|
}
|
|
|
|
export async function findPr({
|
|
branchName,
|
|
prTitle,
|
|
state = 'all',
|
|
targetBranch,
|
|
includeOtherAuthors,
|
|
}: FindPRConfig): Promise<Pr | null> {
|
|
let prsFiltered: Pr[] = [];
|
|
try {
|
|
if (includeOtherAuthors) {
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const [pr] = await azureApiGit.getPullRequests(
|
|
config.repoId,
|
|
{
|
|
sourceRefName: getNewBranchName(branchName),
|
|
sourceRepositoryId: config.repoId,
|
|
status: PullRequestStatus.Active,
|
|
...(targetBranch && {
|
|
targetRefName: getNewBranchName(targetBranch),
|
|
}),
|
|
},
|
|
config.project,
|
|
0,
|
|
0,
|
|
1,
|
|
);
|
|
return pr ? getRenovatePRFormat(pr) : null;
|
|
}
|
|
|
|
const prs = await getPrList();
|
|
|
|
prsFiltered = prs.filter(
|
|
(item) => item.sourceRefName === getNewBranchName(branchName),
|
|
);
|
|
|
|
if (prTitle) {
|
|
prsFiltered = prsFiltered.filter(
|
|
(item) => item.title.toUpperCase() === prTitle.toUpperCase(),
|
|
);
|
|
}
|
|
|
|
switch (state) {
|
|
case 'all':
|
|
// no more filter needed, we can go further...
|
|
break;
|
|
case '!open':
|
|
prsFiltered = prsFiltered.filter((item) => item.state !== 'open');
|
|
break;
|
|
default:
|
|
prsFiltered = prsFiltered.filter((item) => item.state === state);
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, 'findPr error');
|
|
}
|
|
if (prsFiltered.length === 0) {
|
|
return null;
|
|
}
|
|
if (targetBranch && prsFiltered.length > 1) {
|
|
const pr = prsFiltered.find((item) => item.targetBranch === targetBranch);
|
|
if (pr) {
|
|
return pr;
|
|
}
|
|
}
|
|
return prsFiltered[0];
|
|
}
|
|
|
|
export async function getBranchPr(
|
|
branchName: string,
|
|
targetBranch?: string,
|
|
): Promise<Pr | null> {
|
|
logger.debug(`getBranchPr(${branchName}, ${targetBranch})`);
|
|
const existingPr = await findPr({
|
|
branchName,
|
|
state: 'open',
|
|
targetBranch,
|
|
});
|
|
return existingPr ? getPr(existingPr.number) : null;
|
|
}
|
|
|
|
async function getStatusCheck(branchName: string): Promise<GitStatus[]> {
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const branch = await azureApiGit.getBranch(
|
|
config.repoId,
|
|
|
|
// TODO: fix undefined (#22198)
|
|
getBranchNameWithoutRefsheadsPrefix(branchName)!,
|
|
);
|
|
// only grab the latest statuses, it will group any by context
|
|
return azureApiGit.getStatuses(
|
|
// TODO #22198
|
|
branch.commit!.commitId!,
|
|
config.repoId,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
);
|
|
}
|
|
|
|
const azureToRenovateStatusMapping: Record<GitStatusState, BranchStatus> = {
|
|
[GitStatusState.Succeeded]: 'green',
|
|
[GitStatusState.NotApplicable]: 'green',
|
|
[GitStatusState.NotSet]: 'yellow',
|
|
[GitStatusState.Pending]: 'yellow',
|
|
[GitStatusState.PartiallySucceeded]: 'yellow',
|
|
[GitStatusState.Error]: 'red',
|
|
[GitStatusState.Failed]: 'red',
|
|
};
|
|
|
|
export async function getBranchStatusCheck(
|
|
branchName: string,
|
|
context: string,
|
|
): Promise<BranchStatus | null> {
|
|
const res = await getStatusCheck(branchName);
|
|
for (const check of res) {
|
|
if (getGitStatusContextCombinedName(check.context) === context) {
|
|
// TODO #22198
|
|
return azureToRenovateStatusMapping[check.state!] ?? 'yellow';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function getBranchStatus(
|
|
branchName: string,
|
|
internalChecksAsSuccess: boolean,
|
|
): Promise<BranchStatus> {
|
|
logger.debug(`getBranchStatus(${branchName})`);
|
|
const statuses = await getStatusCheck(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) =>
|
|
status.state === GitStatusState.Error ||
|
|
status.state === GitStatusState.Failed,
|
|
).length;
|
|
if (noOfFailures) {
|
|
return 'red';
|
|
}
|
|
const noOfPending = statuses.filter(
|
|
(status) =>
|
|
status.state === GitStatusState.NotSet ||
|
|
status.state === GitStatusState.Pending,
|
|
).length;
|
|
if (noOfPending) {
|
|
return 'yellow';
|
|
}
|
|
if (
|
|
!internalChecksAsSuccess &&
|
|
statuses.every(
|
|
(status) =>
|
|
status.state === GitStatusState.Succeeded &&
|
|
status.context?.genre === 'renovate',
|
|
)
|
|
) {
|
|
logger.debug(
|
|
'Successful checks are all internal renovate/ checks, so returning "pending" branch status',
|
|
);
|
|
return 'yellow';
|
|
}
|
|
return 'green';
|
|
}
|
|
|
|
async function getMergeStrategy(
|
|
targetRefName: string,
|
|
): Promise<GitPullRequestMergeStrategy> {
|
|
return (
|
|
config.mergeMethods[targetRefName] ??
|
|
(config.mergeMethods[targetRefName] = await azureHelper.getMergeMethod(
|
|
config.repoId,
|
|
config.project,
|
|
targetRefName,
|
|
config.defaultBranch,
|
|
))
|
|
);
|
|
}
|
|
|
|
async function getPendingBlockingPolicyEvaluations(
|
|
pullRequestId: number,
|
|
): Promise<PolicyEvaluationRecord[]> {
|
|
const artifactId = `vstfs:///CodeReview/CodeReviewId/${config.projectId}/${pullRequestId}`;
|
|
const policyEvaluations = await azureHelper.getPolicyEvaluations(
|
|
config.project,
|
|
artifactId,
|
|
);
|
|
logger.debug(
|
|
{ pullRequestId, artifactId, policyEvaluations },
|
|
'Retrieved policy evaluations for PR',
|
|
);
|
|
return policyEvaluations.filter(
|
|
(evaluation) =>
|
|
evaluation.configuration?.isBlocking &&
|
|
evaluation.status !== PolicyEvaluationStatus.Approved &&
|
|
evaluation.status !== PolicyEvaluationStatus.NotApplicable,
|
|
);
|
|
}
|
|
|
|
export async function createPr({
|
|
sourceBranch,
|
|
targetBranch,
|
|
prTitle: title,
|
|
prBody: body,
|
|
labels,
|
|
draftPR = false,
|
|
platformPrOptions,
|
|
}: CreatePRConfig): Promise<Pr> {
|
|
const sourceRefName = getNewBranchName(sourceBranch);
|
|
const targetRefName = getNewBranchName(targetBranch);
|
|
const description = max4000Chars(sanitize(body));
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const workItemRefs = [
|
|
{
|
|
id: platformPrOptions?.azureWorkItemId?.toString(),
|
|
},
|
|
];
|
|
let pr: GitPullRequest = await azureApiGit.createPullRequest(
|
|
{
|
|
sourceRefName,
|
|
targetRefName,
|
|
title,
|
|
description,
|
|
workItemRefs,
|
|
isDraft: draftPR,
|
|
},
|
|
config.repoId,
|
|
);
|
|
if (platformPrOptions?.usePlatformAutomerge) {
|
|
const mergeStrategy =
|
|
platformPrOptions.automergeStrategy === 'auto'
|
|
? await getMergeStrategy(pr.targetRefName!)
|
|
: mapMergeStrategy(platformPrOptions.automergeStrategy);
|
|
const prOptions: GitPullRequest = {
|
|
autoCompleteSetBy: {
|
|
// TODO #22198
|
|
id: pr.createdBy!.id,
|
|
},
|
|
completionOptions: {
|
|
mergeStrategy,
|
|
deleteSourceBranch: true,
|
|
mergeCommitMessage: title,
|
|
},
|
|
};
|
|
|
|
logger.debug(
|
|
{
|
|
prOptions,
|
|
repoId: config.repoId,
|
|
pullRequestId: pr.pullRequestId!,
|
|
},
|
|
// TODO #22198
|
|
`Updating PR ${pr.pullRequestId!} to specify platformAutomerge settings`,
|
|
);
|
|
|
|
pr = await azureApiGit.updatePullRequest(
|
|
prOptions,
|
|
config.repoId,
|
|
// TODO #22198
|
|
pr.pullRequestId!,
|
|
);
|
|
}
|
|
if (platformPrOptions?.autoApprove) {
|
|
const approver = {
|
|
reviewerUrl: pr.createdBy!.url,
|
|
vote: AzurePrVote.Approved,
|
|
isFlagged: false,
|
|
isRequired: false,
|
|
};
|
|
|
|
logger.debug(
|
|
{
|
|
approver,
|
|
|
|
repoId: config.repoId,
|
|
// TODO #22198
|
|
pullRequestId: pr.pullRequestId!,
|
|
prCreatedBy: {
|
|
id: pr.createdBy!.id!,
|
|
},
|
|
},
|
|
// TODO #22198
|
|
`Auto-approving PR ${pr.pullRequestId!}`,
|
|
);
|
|
|
|
await azureApiGit.createPullRequestReviewer(
|
|
approver,
|
|
config.repoId,
|
|
// TODO #22198
|
|
pr.pullRequestId!,
|
|
pr.createdBy!.id!,
|
|
);
|
|
}
|
|
await Promise.all(
|
|
labels!.map((label) =>
|
|
azureApiGit.createPullRequestLabel(
|
|
{
|
|
name: label,
|
|
},
|
|
config.repoId,
|
|
// TODO #22198
|
|
pr.pullRequestId!,
|
|
),
|
|
),
|
|
);
|
|
|
|
const result = getRenovatePRFormat(pr);
|
|
if (config.prList) {
|
|
config.prList.push(result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function updatePr({
|
|
number: prNo,
|
|
prTitle: title,
|
|
prBody: body,
|
|
state,
|
|
platformPrOptions,
|
|
targetBranch,
|
|
}: UpdatePrConfig): Promise<void> {
|
|
logger.debug(`updatePr(${prNo}, ${title}, body)`);
|
|
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const objToUpdate: GitPullRequest = {
|
|
title,
|
|
};
|
|
|
|
if (targetBranch) {
|
|
objToUpdate.targetRefName = getNewBranchName(targetBranch);
|
|
}
|
|
|
|
if (body) {
|
|
objToUpdate.description = max4000Chars(sanitize(body));
|
|
}
|
|
|
|
if (state === 'open') {
|
|
await azureApiGit.updatePullRequest(
|
|
{
|
|
status: PullRequestStatus.Active,
|
|
},
|
|
config.repoId,
|
|
prNo,
|
|
);
|
|
} else if (state === 'closed') {
|
|
objToUpdate.status = PullRequestStatus.Abandoned;
|
|
}
|
|
if (platformPrOptions?.autoApprove) {
|
|
const pr = await azureApiGit.getPullRequestById(prNo, config.project);
|
|
await azureApiGit.createPullRequestReviewer(
|
|
{
|
|
reviewerUrl: pr.createdBy!.url,
|
|
vote: AzurePrVote.Approved,
|
|
isFlagged: false,
|
|
isRequired: false,
|
|
},
|
|
config.repoId,
|
|
// TODO #22198
|
|
pr.pullRequestId!,
|
|
pr.createdBy!.id!,
|
|
);
|
|
}
|
|
|
|
const updatedPr = await azureApiGit.updatePullRequest(
|
|
objToUpdate,
|
|
config.repoId,
|
|
prNo,
|
|
);
|
|
if (config.prList) {
|
|
const prToCache = getRenovatePRFormat(updatedPr);
|
|
// We need to update the cached entry for this PR
|
|
const existingIndex = config.prList.findIndex(
|
|
(item) => item.number === prNo,
|
|
);
|
|
/* v8 ignore next: should not happen */
|
|
if (existingIndex === -1) {
|
|
logger.warn({ prNo }, 'PR not found in cache');
|
|
// Add to cache
|
|
config.prList.push(prToCache);
|
|
} else {
|
|
// overwrite existing PR in cache
|
|
config.prList[existingIndex] = prToCache;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function ensureComment({
|
|
number,
|
|
topic,
|
|
content,
|
|
}: EnsureCommentConfig): Promise<boolean> {
|
|
logger.debug(`ensureComment(${number}, ${topic!}, content)`);
|
|
const header = topic ? `### ${topic}\n\n` : '';
|
|
const body = `${header}${sanitize(massageMarkdown(content))}`;
|
|
const azureApiGit = await azureApi.gitApi();
|
|
|
|
const threads = await azureApiGit.getThreads(config.repoId, number);
|
|
let threadIdFound: number | undefined;
|
|
let commentIdFound: number | undefined;
|
|
let commentNeedsUpdating = false;
|
|
threads.forEach((thread) => {
|
|
const firstCommentContent = thread.comments?.[0].content;
|
|
if (
|
|
(topic && firstCommentContent?.startsWith(header)) === true ||
|
|
(!topic && firstCommentContent === body)
|
|
) {
|
|
threadIdFound = thread.id;
|
|
commentIdFound = thread.comments?.[0].id;
|
|
commentNeedsUpdating = firstCommentContent !== body;
|
|
}
|
|
});
|
|
|
|
if (!threadIdFound) {
|
|
await azureApiGit.createThread(
|
|
{
|
|
comments: [{ content: body, commentType: 1, parentCommentId: 0 }],
|
|
status: 1,
|
|
},
|
|
config.repoId,
|
|
number,
|
|
);
|
|
logger.info(
|
|
{ repository: config.repository, issueNo: number, topic },
|
|
'Comment added',
|
|
);
|
|
} else if (commentNeedsUpdating) {
|
|
await azureApiGit.updateComment(
|
|
{
|
|
content: body,
|
|
},
|
|
config.repoId,
|
|
number,
|
|
threadIdFound,
|
|
// TODO #22198
|
|
commentIdFound!,
|
|
);
|
|
logger.debug(
|
|
{ repository: config.repository, issueNo: number, topic },
|
|
'Comment updated',
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
{ repository: config.repository, issueNo: number, topic },
|
|
'Comment is already up-to-date',
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export async function ensureCommentRemoval(
|
|
removeConfig: EnsureCommentRemovalConfig,
|
|
): Promise<void> {
|
|
const { number: issueNo } = removeConfig;
|
|
const key =
|
|
removeConfig.type === 'by-topic'
|
|
? removeConfig.topic
|
|
: removeConfig.content;
|
|
logger.debug(`Ensuring comment "${key}" in #${issueNo} is removed`);
|
|
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const threads = await azureApiGit.getThreads(config.repoId, issueNo);
|
|
|
|
let threadIdFound: number | null | undefined = null;
|
|
if (removeConfig.type === 'by-topic') {
|
|
const thread = threads.find(
|
|
(thread: GitPullRequestCommentThread): boolean =>
|
|
!!thread.comments?.[0].content?.startsWith(
|
|
`### ${removeConfig.topic}\n\n`,
|
|
),
|
|
);
|
|
threadIdFound = thread?.id;
|
|
} else {
|
|
const thread = threads.find(
|
|
(thread: GitPullRequestCommentThread): boolean =>
|
|
thread.comments?.[0].content?.trim() === removeConfig.content,
|
|
);
|
|
threadIdFound = thread?.id;
|
|
}
|
|
|
|
if (threadIdFound) {
|
|
await azureApiGit.updateThread(
|
|
{
|
|
status: 4, // close
|
|
},
|
|
config.repoId,
|
|
issueNo,
|
|
threadIdFound,
|
|
);
|
|
}
|
|
}
|
|
|
|
const renovateToAzureStatusMapping: Record<BranchStatus, GitStatusState> = {
|
|
['green']: GitStatusState.Succeeded,
|
|
['yellow']: GitStatusState.Pending,
|
|
['red']: GitStatusState.Failed,
|
|
};
|
|
|
|
export async function setBranchStatus({
|
|
branchName,
|
|
context,
|
|
description,
|
|
state,
|
|
url: targetUrl,
|
|
}: BranchStatusConfig): Promise<void> {
|
|
logger.debug(
|
|
`setBranchStatus(${branchName}, ${context}, ${description}, ${state}, ${targetUrl!})`,
|
|
);
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const branch = await azureApiGit.getBranch(
|
|
config.repoId,
|
|
getBranchNameWithoutRefsheadsPrefix(branchName)!,
|
|
);
|
|
const statusToCreate: GitStatus = {
|
|
description,
|
|
context: getGitStatusContextFromCombinedName(context),
|
|
state: renovateToAzureStatusMapping[state],
|
|
targetUrl,
|
|
};
|
|
await azureApiGit.createCommitStatus(
|
|
statusToCreate,
|
|
// TODO #22198
|
|
branch.commit!.commitId!,
|
|
config.repoId,
|
|
);
|
|
logger.trace(`Created commit status of ${state} on branch ${branchName}`);
|
|
}
|
|
|
|
export async function mergePr({
|
|
branchName,
|
|
id: pullRequestId,
|
|
strategy,
|
|
}: MergePRConfig): Promise<boolean> {
|
|
logger.debug(`mergePr(${pullRequestId}, ${branchName!})`);
|
|
|
|
const pendingPolicyEvaluations =
|
|
await getPendingBlockingPolicyEvaluations(pullRequestId);
|
|
if (pendingPolicyEvaluations.length) {
|
|
logger.debug(
|
|
{
|
|
pullRequestId,
|
|
pendingPolicies: pendingPolicyEvaluations.map((evaluation) => ({
|
|
name: evaluation.configuration?.type?.displayName,
|
|
status: PolicyEvaluationStatus[evaluation.status!],
|
|
})),
|
|
},
|
|
'Not completing PR because branch policies have not been satisfied yet',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const azureApiGit = await azureApi.gitApi();
|
|
|
|
let pr = await azureApiGit.getPullRequestById(pullRequestId, config.project);
|
|
|
|
const mergeStrategy =
|
|
strategy === 'auto'
|
|
? await getMergeStrategy(pr.targetRefName!)
|
|
: mapMergeStrategy(strategy);
|
|
const objToUpdate: GitPullRequest = {
|
|
status: PullRequestStatus.Completed,
|
|
lastMergeSourceCommit: pr.lastMergeSourceCommit,
|
|
completionOptions: {
|
|
mergeStrategy,
|
|
deleteSourceBranch: true,
|
|
mergeCommitMessage: pr.title,
|
|
},
|
|
};
|
|
|
|
logger.trace(
|
|
`Updating PR ${pullRequestId} to status ${PullRequestStatus.Completed} (${
|
|
PullRequestStatus[PullRequestStatus.Completed]
|
|
}) with lastMergeSourceCommit ${
|
|
// TODO: types (#22198)
|
|
pr.lastMergeSourceCommit?.commitId
|
|
} using mergeStrategy ${mergeStrategy} (${
|
|
GitPullRequestMergeStrategy[mergeStrategy]
|
|
})`,
|
|
);
|
|
|
|
try {
|
|
const response = await azureApiGit.updatePullRequest(
|
|
objToUpdate,
|
|
config.repoId,
|
|
pullRequestId,
|
|
);
|
|
|
|
let retries = 0;
|
|
let isClosed = response.status === PullRequestStatus.Completed;
|
|
while (!isClosed && retries < 5) {
|
|
retries += 1;
|
|
const sleepMs = retries * 1000;
|
|
logger.trace(
|
|
{ pullRequestId, status: pr.status, retries },
|
|
`Updated PR to closed status but change has not taken effect yet. Retrying...`,
|
|
);
|
|
|
|
await setTimeout(sleepMs);
|
|
pr = await azureApiGit.getPullRequestById(pullRequestId, config.project);
|
|
isClosed = pr.status === PullRequestStatus.Completed;
|
|
}
|
|
|
|
if (!isClosed) {
|
|
logger.warn(
|
|
{
|
|
pullRequestId,
|
|
status: pr.status,
|
|
expectedPRStatus: PullRequestStatus[PullRequestStatus.Completed],
|
|
actualPRStatus: PullRequestStatus[pr.status!],
|
|
},
|
|
'Expected PR to have completed status. However, the PR has a different status',
|
|
);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
logger.debug({ err }, 'Failed to set the PR as completed.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function massageMarkdown(input: string): string {
|
|
// Remove any HTML we use
|
|
return (
|
|
smartTruncate(readOnlyIssueBody(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(`\n---\n\n.*?<!-- rebase-check -->.*?\n`), '')
|
|
.replace(regEx(/<!--renovate-(?:debug|config-hash):.*?-->/g), '')
|
|
// Replace GitHub-style PR links with Azure DevOps format
|
|
.replace(regEx(/\]\(\.\.\/pull\//g), '](!')
|
|
// Replace GitHub-style PR references (#123) with Azure DevOps format, needed for text linking config migration PR.
|
|
// Only match a standalone reference (preceded by start, whitespace or `(`) so we don't corrupt
|
|
// HTML entities like `​` or URL anchors like `CHANGELOG.md#4780`.
|
|
.replace(regEx(/(?<lead>^|[\s(])#(?<num>\d+)/g), '$<lead>!$<num>')
|
|
);
|
|
}
|
|
|
|
export function maxBodyLength(): number {
|
|
return 4000;
|
|
}
|
|
|
|
export async function findIssue(title: string): Promise<Issue | null> {
|
|
return await issueService.findIssue(title);
|
|
}
|
|
|
|
export async function ensureIssue(
|
|
issueConfig: EnsureIssueConfig,
|
|
): Promise<EnsureIssueResult | null> {
|
|
return await issueService.ensureIssue(issueConfig);
|
|
}
|
|
|
|
export async function ensureIssueClosing(title: string): Promise<void> {
|
|
return await issueService.ensureIssueClosing(title);
|
|
}
|
|
|
|
export async function getIssueList(titleFilter?: string): Promise<Issue[]> {
|
|
return await issueService.getIssueList(titleFilter);
|
|
}
|
|
|
|
async function getUserIds(users: string[]): Promise<User[]> {
|
|
const azureApiGit = await azureApi.gitApi();
|
|
const azureApiCore = await azureApi.coreApi();
|
|
const repos = await azureApiGit.getRepositories();
|
|
const repo = repos.find((c) => c.id === config.repoId)!;
|
|
const requiredReviewerPrefix = 'required:';
|
|
const validReviewers = new Set<string>();
|
|
|
|
// TODO #22198
|
|
const teams = await azureHelper.getAllProjectTeams(repo.project!.id!);
|
|
const members = await Promise.all(
|
|
teams.map(
|
|
async (t) =>
|
|
await azureApiCore.getTeamMembersWithExtendedProperties(
|
|
// TODO #22198
|
|
repo.project!.id!,
|
|
t.id!,
|
|
),
|
|
),
|
|
);
|
|
|
|
const ids: { id: string; name: string; isRequired: boolean }[] = [];
|
|
members.forEach((listMembers) => {
|
|
listMembers.forEach((m) => {
|
|
users.forEach((r) => {
|
|
let reviewer = r;
|
|
let isRequired = false;
|
|
if (reviewer.startsWith(requiredReviewerPrefix)) {
|
|
reviewer = reviewer.replace(requiredReviewerPrefix, '');
|
|
isRequired = true;
|
|
}
|
|
if (
|
|
(reviewer.toLowerCase() === m.identity?.displayName?.toLowerCase() ||
|
|
reviewer.toLowerCase() === m.identity?.uniqueName?.toLowerCase()) &&
|
|
ids.filter((c) => c.id === m.identity?.id).length === 0
|
|
) {
|
|
// TODO #22198
|
|
ids.push({
|
|
id: m.identity.id!,
|
|
name: reviewer,
|
|
isRequired,
|
|
});
|
|
|
|
validReviewers.add(reviewer);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
teams.forEach((t) => {
|
|
users.forEach((r) => {
|
|
let reviewer = r;
|
|
let isRequired = false;
|
|
if (reviewer.startsWith(requiredReviewerPrefix)) {
|
|
reviewer = reviewer.replace(requiredReviewerPrefix, '');
|
|
isRequired = true;
|
|
}
|
|
// v8 ignore else -- TODO: add test #40625
|
|
if (
|
|
reviewer.toLowerCase() === t.name?.toLowerCase() &&
|
|
ids.filter((c) => c.id === t.id).length === 0
|
|
) {
|
|
// TODO #22198
|
|
ids.push({ id: t.id!, name: reviewer, isRequired });
|
|
|
|
validReviewers.add(reviewer);
|
|
}
|
|
});
|
|
});
|
|
|
|
for (const u of users) {
|
|
const reviewer = u.replace(requiredReviewerPrefix, '');
|
|
if (!validReviewers.has(reviewer)) {
|
|
logger.once.info(
|
|
`${reviewer} is neither an Azure DevOps Team nor a user associated with a Team`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {number} issueNo
|
|
* @param {string[]} assignees
|
|
*/
|
|
export async function addAssignees(
|
|
issueNo: number,
|
|
assignees: string[],
|
|
): Promise<void> {
|
|
logger.trace(`addAssignees(${issueNo}, [${assignees.join(', ')}])`);
|
|
const ids = await getUserIds(assignees);
|
|
await ensureComment({
|
|
number: issueNo,
|
|
topic: 'Add Assignees',
|
|
content: ids.map((a) => `@<${a.id}>`).join(', '),
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {number} prNo
|
|
* @param {string[]} reviewers
|
|
*/
|
|
export async function addReviewers(
|
|
prNo: number,
|
|
reviewers: string[],
|
|
): Promise<void> {
|
|
logger.trace(`addReviewers(${prNo}, [${reviewers.join(', ')}])`);
|
|
const azureApiGit = await azureApi.gitApi();
|
|
|
|
const ids = await getUserIds(reviewers);
|
|
|
|
await Promise.all(
|
|
ids.map(async (obj) => {
|
|
await azureApiGit.createPullRequestReviewer(
|
|
{
|
|
isRequired: obj.isRequired,
|
|
},
|
|
config.repoId,
|
|
prNo,
|
|
obj.id,
|
|
);
|
|
logger.debug(`Reviewer added: ${obj.name}`);
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function deleteLabel(
|
|
prNumber: number,
|
|
label: string,
|
|
): Promise<void> {
|
|
logger.debug(`Deleting label ${label} from #${prNumber}`);
|
|
const azureApiGit = await azureApi.gitApi();
|
|
await azureApiGit.deletePullRequestLabels(config.repoId, prNumber, label);
|
|
}
|