Files
renovate/lib/modules/platform/azure/index.ts
Sebastian Poxhofer 3412de84ce refactor(linters): enable oxlint rule prefer-named-capture-group (#44714)
* 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>
2026-09-03 09:54:40 +00:00

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 `&#8203;` 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);
}