import { setTimeout } from 'node:timers/promises'; import { isArray, isNonEmptyObject, isNonEmptyString } from '@sindresorhus/is'; import semver from 'semver'; import { GlobalConfig } from '../../../config/global.ts'; import { PLATFORM_INTEGRATION_UNAUTHORIZED, PLATFORM_RATE_LIMIT_EXCEEDED, PLATFORM_UNKNOWN_ERROR, PR_ALREADY_IN_MERGE_QUEUE, REPOSITORY_ACCESS_FORBIDDEN, REPOSITORY_ARCHIVED, REPOSITORY_BLOCKED, REPOSITORY_CANNOT_FORK, REPOSITORY_CHANGED, REPOSITORY_DISABLED, REPOSITORY_EMPTY, REPOSITORY_FORKED, REPOSITORY_FORK_MISSING, REPOSITORY_FORK_MODE_FORKED, REPOSITORY_NOT_FOUND, REPOSITORY_RENAMED, } from '../../../constants/error-messages.ts'; import { instrument } from '../../../instrumentation/index.ts'; import { logger } from '../../../logger/index.ts'; import { ExternalHostError } from '../../../types/errors/external-host-error.ts'; import type { BranchStatus } from '../../../types/index.ts'; import { coerceArray } from '../../../util/array.ts'; import { isGithubFineGrainedPersonalAccessToken } from '../../../util/check-token.ts'; import { coerceToNull } from '../../../util/coerce.ts'; import { parseJson } from '../../../util/common.ts'; import { getEnv } from '../../../util/env.ts'; import { formatCommitMessage } from '../../../util/git/commit-trailers.ts'; import * as git from '../../../util/git/index.ts'; import { diffCommitTree, getCommitTreeSha, pushCommitToRenovateRef, } from '../../../util/git/index.ts'; import type { CommitFilesConfig, CommitResult, } from '../../../util/git/types.ts'; import * as hostRules from '../../../util/host-rules.ts'; import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts'; import { repoCacheProvider } from '../../../util/http/cache/repository-http-cache-provider.ts'; import type { GithubHttpOptions } from '../../../util/http/github.ts'; import * as githubHttp from '../../../util/http/github.ts'; import type { HttpResponse } from '../../../util/http/types.ts'; import { coerceObject } from '../../../util/object.ts'; import { regEx } from '../../../util/regex.ts'; import { sanitize } from '../../../util/sanitize.ts'; import type { LongCommitSha } from '../../../util/schema-utils/git.ts'; import { toLongCommitSha } from '../../../util/schema-utils/git.ts'; import { fromBase64, looseEquals } from '../../../util/string.ts'; import { ensureTrailingSlash, isHttpUrl, parseUrl } from '../../../util/url.ts'; import { incLimitedValue } from '../../../workers/global/limits.ts'; import { normalizePythonDepName } from '../../datasource/pypi/common.ts'; import type { AutodiscoverConfig, BranchStatusConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfig, EnsureIssueConfig, EnsureIssueResult, FindPRConfig, MergePRConfig, PlatformParams, PlatformPrOptions, PlatformResult, Pr, ReattemptPlatformAutomergeConfig, RepoParams, RepoResult, UpdatePrConfig, } from '../types.ts'; import { repoFingerprint } from '../util.ts'; import { smartTruncate } from '../utils/pr-body.ts'; import { remoteBranchExists } from './branch.ts'; import { coerceRestPr, githubApi, mapMergeStartegy } from './common.ts'; import { enableAutoMergeMutation, getIssuesQuery, repoInfoQuery, repoMergeQueueQuery, } from './graphql.ts'; import { GithubIssueCache } from './issue.ts'; import { massageMarkdownLinks } from './massage-markdown-links.ts'; import { getPrCache, isPrInMergeQueue, updatePrCache } from './pr.ts'; import { GithubBranchProtection, GithubBranchRulesets, GithubVulnerabilityAlerts, GithubIssue as Issue, } from './schema.ts'; import type { AggregatedVulnerabilities, CombinedBranchStatus, Comment, GhAutomergeResponse, GhBranchStatus, GhPr, GhRepo, GhRestPr, GhRestRepo, LocalRepoConfig, PlatformConfig, } from './types.ts'; import { getAppDetails, getUserDetails, getUserEmail } from './user.ts'; import { getRepoUrl, warnIfDefaultGitAuthorEmail } from './utils.ts'; export const id = 'github'; let config: LocalRepoConfig; let platformConfig: PlatformConfig; // GitHub's max is 60k but in the hosted app we've observed that content-length is ~1k longer const GitHubMaxPrBodyLen = 58000; export function resetConfigs(): void { config = {} as never; platformConfig = { hostType: 'github', endpoint: 'https://api.github.com/', }; } resetConfigs(); function escapeHash(input: string): string { return input?.replace(regEx(/#/g), '%23'); } export function isGHApp(): boolean { return !!platformConfig.isGHApp; } export async function detectGhe(token: string): Promise { const parsedEndpoint = parseUrl(platformConfig.endpoint); /* v8 ignore next -- endpoint is validated in initPlatform before detectGhe is called */ if (!parsedEndpoint) { throw new Error(`Invalid GitHub endpoint: ${platformConfig.endpoint}`); } const host = parsedEndpoint.host; platformConfig.isGhe = host !== 'api.github.com'; platformConfig.isGheCloud = host.endsWith('.ghe.com'); if (platformConfig.isGhe) { const gheHeaderKey = 'x-github-enterprise-version'; const gheQueryRes = await githubApi.headJson('/', { token }); const gheHeaders = coerceObject(gheQueryRes?.headers); const gheVersionHeader = Object.entries(gheHeaders).find( ([k]) => k.toLowerCase() === gheHeaderKey, ); platformConfig.gheVersion = semver.valid(gheVersionHeader?.[1] as string) ?? null; logger.debug( `Detected GitHub Enterprise Server, version: ${platformConfig.gheVersion}`, ); } } export async function initPlatform({ endpoint, token: originalToken, username, gitAuthor, }: PlatformParams): Promise { let token = originalToken; if (!token) { throw new Error('Init: You must configure a GitHub token'); } token = token.replace(regEx(/^ghs_/), 'x-access-token:ghs_'); platformConfig.isGHApp = token.startsWith('x-access-token:'); if (endpoint) { if (!isHttpUrl(endpoint)) { throw new Error(`Init: Invalid GitHub endpoint URL: ${endpoint}`); } platformConfig.endpoint = ensureTrailingSlash(endpoint); githubHttp.setBaseUrl(platformConfig.endpoint); } else { logger.debug(`Using default github endpoint: ${platformConfig.endpoint}`); } await detectGhe(token); /** * GHE requires version >=3.10 to support fine-grained access tokens * https://docs.github.com/en/enterprise-server@3.10/admin/release-notes#authentication */ if ( isGithubFineGrainedPersonalAccessToken(token) && platformConfig.isGhe && (!platformConfig.gheVersion || semver.lt(platformConfig.gheVersion, '3.10.0')) ) { throw new Error( 'Init: Fine-grained Personal Access Tokens do not support GitHub Enterprise Server API version <3.10 and cannot be used with Renovate.', ); } let renovateUsername: string; if (username) { renovateUsername = username; } else if (platformConfig.isGHApp) { platformConfig.userDetails ??= await getAppDetails(token); renovateUsername = platformConfig.userDetails.username; } else { platformConfig.userDetails ??= await getUserDetails( platformConfig.endpoint, token, ); renovateUsername = platformConfig.userDetails.username; } let ghHostname: string; /* v8 ignore next -- false negative due to V8/source-map artifact */ if (platformConfig.isGheCloud) { ghHostname = 'ghe.com'; } else if (platformConfig.isGhe) { // valid url ensured at the function start const parsedEndpoint = parseUrl(platformConfig.endpoint)!; ghHostname = parsedEndpoint.hostname; } else { ghHostname = 'github.com'; } let discoveredGitAuthor: string | undefined; if (!gitAuthor) { if (platformConfig.isGHApp) { platformConfig.userDetails ??= await getAppDetails(token); discoveredGitAuthor = `${platformConfig.userDetails.name} <${platformConfig.userDetails.id}+${platformConfig.userDetails.username}@users.noreply.${ghHostname}>`; } else { platformConfig.userDetails ??= await getUserDetails( platformConfig.endpoint, token, ); // v8 ignore next -- TODO: coverage error #40625 platformConfig.userEmail = platformConfig.userDetails.email ?? (await getUserEmail(platformConfig.endpoint, token)); if (platformConfig.userEmail) { discoveredGitAuthor = `${platformConfig.userDetails.name} <${platformConfig.userEmail}>`; } } } git.setPlatformIgnoredAuthors([`noreply@${ghHostname}`]); logger.debug({ platformConfig, renovateUsername }, 'Platform config'); const platformResult: PlatformResult = { endpoint: platformConfig.endpoint, gitAuthor: gitAuthor ?? discoveredGitAuthor, renovateUsername, token, }; warnIfDefaultGitAuthorEmail(platformResult.gitAuthor, platformConfig.isGhe); if ( getEnv().RENOVATE_X_GITHUB_HOST_RULES && platformResult.endpoint === 'https://api.github.com/' ) { logger.debug('Adding GitHub token as GHCR password'); platformResult.hostRules = [ { matchHost: 'ghcr.io', hostType: 'docker', username: 'USERNAME', password: token.replace(regEx(/^x-access-token:/), ''), }, ]; logger.debug('Adding GitHub token as npm.pkg.github.com Basic token'); platformResult.hostRules.push({ matchHost: 'npm.pkg.github.com', hostType: 'npm', token: token.replace(regEx(/^x-access-token:/), ''), }); const usernamePasswordHostTypes = ['rubygems', 'maven', 'nuget']; for (const hostType of usernamePasswordHostTypes) { logger.debug( `Adding GitHub token as ${hostType}.pkg.github.com password`, ); platformResult.hostRules.push({ hostType, matchHost: `${hostType}.pkg.github.com`, username: renovateUsername, password: token.replace(regEx(/^x-access-token:/), ''), }); } } return platformResult; } async function fetchRepositories(): Promise { try { if (isGHApp()) { const res = await githubApi.getJsonUnchecked<{ repositories: GhRestRepo[]; }>(`installation/repositories?per_page=100`, { paginationField: 'repositories', paginate: 'all', }); return res.body.repositories; } const res = await githubApi.getJsonUnchecked( `user/repos?per_page=100`, { paginate: 'all' }, ); return res.body; } catch (err) /* v8 ignore next -- defensive: repo listing failures are logged and rethrown, not simulated in specs */ { logger.error({ err }, `GitHub getRepos error`); throw err; } } // Get all repositories that the user has access to export async function getRepos(config?: AutodiscoverConfig): Promise { logger.debug('Autodiscovering GitHub repositories'); const nonEmptyRepositories = (await fetchRepositories()).filter( isNonEmptyObject, ); const nonArchivedRepositories = nonEmptyRepositories.filter( (repo) => !repo.archived, ); if (nonArchivedRepositories.length < nonEmptyRepositories.length) { logger.debug( `Filtered out ${ nonEmptyRepositories.length - nonArchivedRepositories.length } archived repositories`, ); } if (!config?.topics) { return nonArchivedRepositories.map((repo) => repo.full_name); } logger.debug({ topics: config.topics }, 'Filtering by topics'); const topicRepositories = nonArchivedRepositories.filter((repo) => repo.topics?.some((topic) => config?.topics?.includes(topic)), ); // v8 ignore else -- TODO: add test #40625 if (topicRepositories.length < nonArchivedRepositories.length) { logger.debug( `Filtered out ${ nonArchivedRepositories.length - topicRepositories.length } repositories not matching topic filters`, ); } return topicRepositories.map((repo) => repo.full_name); } async function getBranchProtection( branchName: string, ): Promise { if (config.parentRepo) { return {}; } const res = await githubApi.getJson( `repos/${config.repository}/branches/${escapeHash(branchName)}/protection`, { cacheProvider: repoCacheProvider }, GithubBranchProtection, ); return res.body; } async function getBranchRulesets( branchName: string, ): Promise { if (config.parentRepo) { return []; } try { const res = await githubApi.getJson( `repos/${config.repository}/rules/branches/${escapeHash(branchName)}`, { cacheProvider: repoCacheProvider }, GithubBranchRulesets, ); return res.body; } catch (err) { if (err.statusCode === 404) { logger.debug(`No branch rulesets found for ${branchName}`); return []; } throw err; } } export async function getRawFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { const repo = repoName ?? config.repository; // only use cache for the same org const httpOptions: GithubHttpOptions = {}; const isSameOrg = repo?.split('/')?.[0] === config.repositoryOwner; // v8 ignore else -- TODO: add test #40625 if (isSameOrg) { httpOptions.cacheProvider = repoCacheProvider; } let url = `repos/${repo}/contents/${fileName}`; if (branchOrTag) { url += `?ref=${branchOrTag}`; } const res = await githubApi.getJsonUnchecked<{ content: string }>( url, httpOptions, ); const buf = res.body.content; const str = fromBase64(buf); return str; } export async function getJsonFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { const raw = await getRawFile(fileName, repoName, branchOrTag); return parseJson(raw, fileName); } export async function listForks( token: string, repository: string, ): Promise { try { // Get list of existing repos const url = `repos/${repository}/forks?per_page=100`; const repos = ( await githubApi.getJsonUnchecked(url, { token, paginate: true, pageLimit: 100, }) ).body; logger.debug(`Found ${repos.length} forked repo(s)`); return repos; } catch (err) { if (err.statusCode === 404) { logger.debug('Cannot list repo forks - it is likely private'); } else { logger.debug({ err }, 'Unknown error listing repository forks'); } throw new Error(REPOSITORY_CANNOT_FORK); } } export async function findFork( token: string, repository: string, forkOrg?: string, ): Promise { const forks = await listForks(token, repository); if (forkOrg) { logger.debug(`Searching for forked repo in forkOrg (${forkOrg})`); const forkedRepo = forks.find((repo) => repo.owner.login === forkOrg); if (forkedRepo) { logger.debug(`Found repo in forkOrg: ${forkedRepo.full_name}`); return forkedRepo; } logger.debug(`No repo found in forkOrg`); } logger.debug(`Searching for forked repo in user account`); try { const { username } = await getUserDetails(platformConfig.endpoint, token); const forkedRepo = forks.find((repo) => repo.owner.login === username); if (forkedRepo) { logger.debug(`Found repo in user account: ${forkedRepo.full_name}`); return forkedRepo; } } catch { throw new Error(REPOSITORY_CANNOT_FORK); } logger.debug(`No repo found in user account`); return null; } export async function createFork( token: string, repository: string, forkOrg?: string, ): Promise { let forkedRepo: GhRestRepo | undefined; try { forkedRepo = ( await githubApi.postJson(`repos/${repository}/forks`, { token, body: { organization: forkOrg ?? undefined, name: config.parentRepo!.replace('/', '-_-'), default_branch_only: true, // no baseBranchPatterns support yet }, }) ).body; } catch (err) { logger.debug({ err }, 'Error creating fork'); } if (!forkedRepo) { throw new Error(REPOSITORY_CANNOT_FORK); } logger.info({ forkedRepo: forkedRepo.full_name }, 'Created forked repo'); logger.debug(`Sleeping 30s after creating fork`); await setTimeout(30000); return forkedRepo; } // Initialize GitHub by getting base branch and SHA export async function initRepo({ repository, forkCreation, forkOrg, forkToken, gitUrl, renovateUsername, cloneSubmodules, cloneSubmodulesFilter, }: RepoParams): Promise { logger.debug(`initRepo("${repository}")`); // config is used by the platform api itself, not necessary for the app layer to know config = { repository, cloneSubmodules, cloneSubmodulesFilter, ignorePrAuthor: GlobalConfig.get('ignorePrAuthor'), mergeQueueEnabled: {}, } as any; const opts = hostRules.find({ hostType: 'github', url: platformConfig.endpoint, readOnly: true, }); config.renovateUsername = renovateUsername; [config.repositoryOwner, config.repositoryName] = repository.split('/'); let repo: GhRepo | undefined; let forkSshUrl: string | null = null; try { let infoQuery = repoInfoQuery; // GitHub Enterprise Server <3.3.0 doesn't support autoMergeAllowed and hasIssuesEnabled objects // TODO #22198 if ( platformConfig.isGhe && // semver not null safe, accepts null and undefined semver.satisfies(platformConfig.gheVersion!, '<3.3.0') ) { infoQuery = infoQuery.replace(regEx(/\n\s*autoMergeAllowed\s*\n/), '\n'); infoQuery = infoQuery.replace(regEx(/\n\s*hasIssuesEnabled\s*\n/), '\n'); } // GitHub Enterprise Server <3.9.0 doesn't support hasVulnerabilityAlertsEnabled objects if ( platformConfig.isGhe && // semver not null safe, accepts null and undefined semver.satisfies(platformConfig.gheVersion!, '<3.9.0') ) { infoQuery = infoQuery.replace( regEx(/\n\s*hasVulnerabilityAlertsEnabled\s*\n/), '\n', ); } // GitHub Enterprise Server <3.12.0 doesn't support merge queues if ( platformConfig.isGhe && // semver not null safe, accepts null and undefined semver.satisfies(platformConfig.gheVersion!, '<3.12.0') ) { infoQuery = infoQuery.replace( regEx(/\n\s*mergeQueue\s*\{\s*id\s*\}\s*\n/), '\n', ); } const res = await githubApi.requestGraphql<{ repository: GhRepo; }>(infoQuery, { variables: { owner: config.repositoryOwner, name: config.repositoryName, ...(!config.ignorePrAuthor && { user: renovateUsername }), }, readOnly: true, count: 1, // bypass graphql check }); if (res?.errors) { logger.debug({ res }, 'Unexpected GraphQL errors'); throw new Error(PLATFORM_UNKNOWN_ERROR); } repo = res?.data?.repository; /* v8 ignore next -- defensive: GraphQL errors are handled above, a null repository is not mocked in specs */ if (!repo) { logger.debug({ res }, 'No repository returned'); throw new Error(REPOSITORY_NOT_FOUND); } /* v8 ignore next -- empty-repo detection via missing defaultBranchRef is not mocked in specs */ if (!repo.defaultBranchRef?.name) { logger.debug( { res }, 'No default branch returned - treating repo as empty', ); throw new Error(REPOSITORY_EMPTY); } if ( repo.nameWithOwner && repo.nameWithOwner.toUpperCase() !== repository.toUpperCase() ) { logger.debug( { desiredRepo: repository, foundRepo: repo.nameWithOwner }, 'Repository has been renamed', ); throw new Error(REPOSITORY_RENAMED); } if (repo.isArchived) { logger.debug( 'Repository is archived - throwing error to abort renovation', ); throw new Error(REPOSITORY_ARCHIVED); } // Use default branch as PR target unless later overridden. config.defaultBranch = repo.defaultBranchRef.name; // Base branch may be configured but defaultBranch is always fixed logger.debug(`${repository} default branch = ${config.defaultBranch}`); // GitHub allows administrators to block certain types of merge, so we need to check it if (repo.squashMergeAllowed) { config.mergeMethod = 'squash'; } else if (repo.mergeCommitAllowed) { config.mergeMethod = 'merge'; } else if (repo.rebaseMergeAllowed) { config.mergeMethod = 'rebase'; } else { // This happens if we don't have Administrator read access, it is not a critical error logger.debug('Could not find allowed merge methods for repo'); } config.autoMergeAllowed = repo.autoMergeAllowed; config.hasIssuesEnabled = repo.hasIssuesEnabled; config.hasVulnerabilityAlertsEnabled = repo.hasVulnerabilityAlertsEnabled; config.mergeQueueEnabled[config.defaultBranch] = isNonEmptyObject( repo.mergeQueue, ); const recentIssues = Issue.array() .catch([]) .parse(res?.data?.repository?.issues?.nodes); GithubIssueCache.addIssuesToReconcile(recentIssues); } catch (err) /* v8 ignore next -- initRepo error mapping needs failure shapes not mocked in specs */ { logger.debug({ err }, 'Caught initRepo error'); if ( err.message === REPOSITORY_ARCHIVED || err.message === REPOSITORY_RENAMED || err.message === REPOSITORY_NOT_FOUND ) { throw err; } if (err.statusCode === 403) { throw new Error(REPOSITORY_ACCESS_FORBIDDEN); } if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } if (err.message.startsWith('Repository access blocked')) { throw new Error(REPOSITORY_BLOCKED); } if (err.message === REPOSITORY_FORK_MODE_FORKED) { throw err; } if (err.message === REPOSITORY_FORKED) { throw err; } if (err.message === REPOSITORY_DISABLED) { throw err; } if (err.message === 'Response code 451 (Unavailable for Legal Reasons)') { throw new Error(REPOSITORY_ACCESS_FORBIDDEN); } logger.debug({ err }, 'Unknown GitHub initRepo error'); throw err; } // This shouldn't be necessary, but occasional strange errors happened until it was added config.prList = null; if (forkToken) { logger.debug('Bot is in fork mode'); if (repo.isFork) { logger.debug( `Forked repos cannot be processed when running with a forkToken, so this repo will be skipped`, ); logger.debug( `Parent repo for this forked repo is ${repo.parent?.nameWithOwner}`, ); throw new Error(REPOSITORY_FORKED); } config.forkOrg = forkOrg; config.forkToken = forkToken; // save parent name then delete config.parentRepo = config.repository; config.repository = null; let forkedRepo = await findFork(forkToken, repository, forkOrg); if (forkedRepo) { config.repository = forkedRepo.full_name; forkSshUrl = forkedRepo.ssh_url; const forkDefaultBranch = forkedRepo.default_branch; if (forkDefaultBranch !== config.defaultBranch) { const body = { ref: `refs/heads/${config.defaultBranch}`, sha: repo.defaultBranchRef.target.oid, }; logger.debug( { defaultBranch: config.defaultBranch, forkDefaultBranch, body, }, 'Fork has different default branch to parent, attempting to create branch', ); try { await githubApi.postJson(`repos/${config.repository}/git/refs`, { body, token: forkToken, }); logger.debug('Created new default branch in fork'); } catch (err) /* v8 ignore next -- fork default-branch creation failures are not mocked in specs */ { if (err.response?.body?.message === 'Reference already exists') { logger.debug( `Branch ${config.defaultBranch} already exists in the fork`, ); } else { logger.warn( { err, body: err.response?.body }, 'Could not create parent defaultBranch in fork', ); } } logger.debug( `Setting ${config.defaultBranch} as default branch for ${config.repository}`, ); try { await githubApi.patchJson(`repos/${config.repository}`, { body: { name: config.repository.split('/')[1], default_branch: config.defaultBranch, }, token: forkToken, }); logger.debug('Successfully changed default branch for fork'); } catch (err) /* v8 ignore next -- defensive: fork default-branch update failures are logged and swallowed, not simulated in specs */ { logger.warn({ err }, 'Could not set default branch'); } } } else if (forkCreation) { logger.debug('Forked repo is not found - attempting to create it'); forkedRepo = await createFork(forkToken, repository, forkOrg); config.repository = forkedRepo.full_name; forkSshUrl = forkedRepo.ssh_url; } else { logger.debug('Forked repo is not found and forkCreation is disabled'); throw new Error(REPOSITORY_FORK_MISSING); } } let authToken: string | null; if (forkToken) { logger.debug('Using forkToken for git init'); authToken = coerceToNull(config.forkToken); } /* v8 ignore next -- token-type detection depends on opts.token shapes not varied in specs */ else { const tokenType = opts.token?.startsWith('x-access-token:') ? 'app' : 'personal access'; logger.debug(`Using ${tokenType} token for git init`); authToken = opts.token ?? null; } // endpoint is validated during initPlatform const parsedEndpoint = parseUrl(platformConfig.endpoint)!; const workingSshUrl = forkToken ? forkSshUrl : repo.sshUrl; const url = getRepoUrl( config.repository!, gitUrl, workingSshUrl, parsedEndpoint, authToken, ); let upstreamUrl: string | undefined; if (forkCreation && config.parentRepo) { upstreamUrl = getRepoUrl( config.parentRepo, gitUrl, repo.sshUrl, parsedEndpoint, authToken, ); } await git.initRepo({ ...config, url, upstreamUrl, }); const repoConfig: RepoResult = { defaultBranch: config.defaultBranch, isFork: repo.isFork === true, repoFingerprint: repoFingerprint(repo.id, platformConfig.endpoint), }; return repoConfig; } async function checkRulesetsForForceRebase( branchName: string, ): Promise { try { const rulesets = await getBranchRulesets(branchName); logger.trace( `Ruleset: Found ${rulesets.length} rulesets for branch ${branchName}`, ); return rulesets.some((rule) => { if ( rule.type === 'required_status_checks' && rule.parameters?.strict_required_status_checks_policy === true ) { logger.debug( `Ruleset: strict required status checks found for ${branchName}`, ); return true; } return false; }); } catch (err) { handleBranchProtectionError('rulesets', err, branchName); return false; } } async function checkBranchProtectionForForceRebase( branchName: string, ): Promise { try { const branchProtection = await getBranchProtection(branchName); logger.trace(`Found branch protection for branch ${branchName}`); const strictStatusChecks = branchProtection?.required_status_checks?.strict; if (strictStatusChecks) { logger.debug( `Branch protection: PRs must be up-to-date before merging for ${branchName}`, ); return true; } return false; } catch (err) { handleBranchProtectionError('branch-protection', err, branchName); return false; } } export async function getBranchForceRebase( branchName: string, ): Promise { config.branchForceRebase ??= {}; const cachedResult = config.branchForceRebase[branchName]; if (cachedResult !== undefined) { return cachedResult; } // Initialize to false before checking branch protection config.branchForceRebase[branchName] = false; // Check rulesets first (newer API) const hasRulesetForceRebase = await checkRulesetsForForceRebase(branchName); if (hasRulesetForceRebase) { config.branchForceRebase[branchName] = true; return true; } // Fall back to legacy branch protection const hasBranchProtectionForceRebase = await checkBranchProtectionForForceRebase(branchName); if (hasBranchProtectionForceRebase) { config.branchForceRebase[branchName] = true; } return config.branchForceRebase[branchName]; } function handleBranchProtectionError( protection: 'branch-protection' | 'rulesets', err: any, branchName: string, ): void { if (err.statusCode === 404) { logger.debug(`No ${protection} found for ${branchName}`); return; } const isUnauthorized = err.message === PLATFORM_INTEGRATION_UNAUTHORIZED || err.statusCode === 403; if (isUnauthorized) { logger.once.debug( `Branch protection: Do not have permissions to detect ${protection} for ${branchName}`, ); return; } throw err; } function cachePr(pr?: GhPr | null): void { config.prList ??= []; // v8 ignore else -- TODO: add test #40625 if (pr) { updatePrCache(pr); for (let idx = 0; idx < config.prList.length; idx += 1) { const cachedPr = config.prList[idx]; if (cachedPr.number === pr.number) { config.prList[idx] = pr; return; } } config.prList.push(pr); } } // Fetch fresh Pull Request and cache it when possible async function fetchPr(prNo: number): Promise { try { const { body: ghRestPr } = await githubApi.getJsonUnchecked( `repos/${config.parentRepo ?? config.repository}/pulls/${prNo}`, ); const result = coerceRestPr(ghRestPr); cachePr(result); return result; } catch (err) { logger.warn({ err, prNo }, `GitHub fetchPr error`); return null; } } // Gets details for a PR export async function getPr(prNo: number): Promise { if (!prNo) { return null; } const prList = await getPrList(); let pr = prList.find(({ number }) => number === prNo) ?? null; if (pr) { logger.debug('Returning PR from cache'); } pr ??= await fetchPr(prNo); return pr; } function matchesState(state: string, desiredState: string): boolean { if (desiredState === 'all') { return true; } if (desiredState.startsWith('!')) { return state !== desiredState.substring(1); } return state === desiredState; } export async function getPrList(): Promise { if (!config.prList) { const repo = config.parentRepo ?? config.repository; let username = config.renovateUsername; if (config.forkToken || config.ignorePrAuthor) { username = undefined; } // TODO: check null `repo` (#22198) const prCache = await instrument('getPrCache', () => getPrCache(githubApi, repo!, username), ); config.prList = Object.values(prCache).sort( ({ number: a }, { number: b }) => b - a, ); } return config.prList; } export async function findPr({ branchName, prTitle, state = 'all', includeOtherAuthors, }: FindPRConfig): Promise { logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`); if (includeOtherAuthors) { const repo = config.parentRepo ?? config.repository; const org = repo?.split('/')[0]; // PR might have been created by anyone, so don't use the cached Renovate PR list const { body: prList } = await githubApi.getJsonUnchecked( `repos/${repo}/pulls?head=${org}:${branchName}&state=open`, { cacheProvider: repoCacheProvider }, ); if (!prList.length) { logger.debug(`No PR found for branch ${branchName}`); return null; } return coerceRestPr(prList[0]); } const prList = await getPrList(); const pr = prList.find((p) => { if (p.sourceBranch !== branchName) { return false; } if (prTitle && prTitle.toUpperCase() !== p.title.toUpperCase()) { return false; } if (!matchesState(p.state, state)) { return false; } if (!config.forkToken && !looseEquals(config.repository, p.sourceRepo)) { return false; } return true; }); if (pr) { logger.debug(`Found PR #${pr.number}`); } return pr ?? null; } async function ensureBranchSha( branchName: string, sha: LongCommitSha, ): Promise { const repository = config.repository!; try { const commitUrl = `/repos/${repository}/git/commits/${sha}`; await githubApi.head(commitUrl, { memCache: false }); } catch (err) { logger.error({ err, sha, branchName }, 'Commit not found'); throw err; } const refUrl = `/repos/${config.repository}/git/refs/heads/${branchName}`; const branchExists = await remoteBranchExists(repository, branchName); if (branchExists) { try { await githubApi.patchJson(refUrl, { body: { sha, force: true } }); return; } catch (err) { if (err.err?.response?.statusCode === 422) { logger.debug( { err }, 'Branch update failed due to reference not existing - will try to create', ); } else { logger.warn({ refUrl, err }, 'Error updating branch'); throw err; } } } await githubApi.postJson(`/repos/${repository}/git/refs`, { body: { sha, ref: `refs/heads/${branchName}` }, }); } // Returns the Pull Request for a branch. Null if not exists. export async function getBranchPr(branchName: string): Promise { logger.debug(`getBranchPr(${branchName})`); const openPr = await findPr({ branchName, state: 'open', }); if (openPr) { return openPr; } return null; } export async function tryReuseAutoclosedPr( autoclosedPr: Pr, newTitle: string, ): Promise { const { sha, number, sourceBranch: branchName } = autoclosedPr; try { await ensureBranchSha(branchName, sha!); logger.debug(`Recreated autoclosed branch ${branchName} with sha ${sha}`); } catch (err) { logger.debug( { err, branchName, sha, autoclosedPr }, 'Could not recreate autoclosed branch - skipping reopen', ); return null; } try { const { body: ghPr } = await githubApi.patchJson( `repos/${config.repository}/pulls/${number}`, { body: { state: 'open', title: newTitle, }, }, ); logger.info( { branchName, oldTitle: autoclosedPr.title, newTitle, number }, 'Successfully reopened autoclosed PR', ); const result = coerceRestPr(ghPr); const localSha = git.getBranchCommit(branchName); // v8 ignore else -- TODO: add test #40625 if (localSha && localSha !== sha) { await git.forcePushToRemote(branchName, 'origin'); result.sha = localSha; } cachePr(result); return result; } catch { logger.debug('Could not reopen autoclosed PR'); return null; } } async function getStatus( branchName: string, useCache = true, ): Promise { const branch = escapeHash(branchName); const url = `repos/${config.repository}/commits/${branch}/status`; const { body: status } = await githubApi.getJsonUnchecked(url, { memCache: useCache, cacheProvider: repoCacheProvider, }); return status; } // Returns the combined status for a branch. export async function getBranchStatus( branchName: string, internalChecksAsSuccess: boolean, ): Promise { logger.debug(`getBranchStatus(${branchName})`); let commitStatus: CombinedBranchStatus; try { commitStatus = await getStatus(branchName); } catch (err) /* v8 ignore next -- 404-to-REPOSITORY_CHANGED mapping for deleted branches is not mocked in specs */ { if (err.statusCode === 404) { logger.debug( 'Received 404 when checking branch status, assuming that branch has been deleted', ); throw new Error(REPOSITORY_CHANGED); } logger.debug('Unknown error when checking branch status'); throw err; } logger.debug( { state: commitStatus.state, statuses: commitStatus.statuses }, 'branch status check result', ); if (commitStatus.statuses && !internalChecksAsSuccess) { commitStatus.statuses = commitStatus.statuses.filter( (status) => status.state !== 'success' || !status.context?.startsWith('renovate/'), ); // v8 ignore else -- TODO: add test #40625 if (!commitStatus.statuses.length) { logger.debug( 'Successful checks are all internal renovate/ checks, so returning "pending" branch status', ); commitStatus.state = 'pending'; } } let checkRuns: { name: string; status: string; conclusion: string }[] = []; // API is supported in oldest available GHE version 2.19 try { const checkRunsUrl = `repos/${config.repository}/commits/${escapeHash( branchName, )}/check-runs?per_page=100`; const opts = { headers: { accept: 'application/vnd.github.antiope-preview+json', }, paginate: true, paginationField: 'check_runs', cacheProvider: memCacheProvider, }; const checkRunsRaw = ( await githubApi.getJsonUnchecked<{ check_runs: { name: string; status: string; conclusion: string }[]; }>(checkRunsUrl, opts) ).body; if (checkRunsRaw.check_runs?.length) { checkRuns = checkRunsRaw.check_runs.map((run) => ({ name: run.name, status: run.status, conclusion: run.conclusion, })); logger.debug({ checkRuns }, 'check runs result'); } /* v8 ignore next -- specs always mock a non-empty check_runs response */ else { logger.debug({ result: checkRunsRaw }, 'No check runs found'); } } catch (err) /* v8 ignore next -- check-run permission errors (403) are mapped to empty results, not mocked in specs */ { if (err instanceof ExternalHostError) { throw err; } if ( err.statusCode === 403 || err.message === PLATFORM_INTEGRATION_UNAUTHORIZED ) { logger.debug('No permission to view check runs'); } else { logger.warn({ err }, 'Error retrieving check runs'); } } if (checkRuns.length === 0) { if (commitStatus.state === 'success') { return 'green'; } if (commitStatus.state === 'failure') { return 'red'; } return 'yellow'; } if ( commitStatus.state === 'failure' || checkRuns.some((run) => run.conclusion === 'failure') ) { return 'red'; } if ( (commitStatus.state === 'success' || commitStatus.statuses.length === 0) && checkRuns.every((run) => ['skipped', 'neutral', 'success'].includes(run.conclusion), ) ) { return 'green'; } return 'yellow'; } async function getStatusCheck( branchName: string, useCache = true, ): Promise { const branchCommit = git.getBranchCommit(branchName); const url = `repos/${config.repository}/commits/${branchCommit}/statuses`; const opts: GithubHttpOptions = useCache ? { cacheProvider: memCacheProvider } : { memCache: false }; return (await githubApi.getJsonUnchecked(url, opts)).body; } type GithubToRenovateStatusMapping = Record; const githubToRenovateStatusMapping: GithubToRenovateStatusMapping = { success: 'green', error: 'red', failure: 'red', pending: 'yellow', }; export async function getBranchStatusCheck( branchName: string, context: string, ): Promise { try { const res = await getStatusCheck(branchName); for (const check of res) { if (check.context === context) { return githubToRenovateStatusMapping[check.state] || 'yellow'; } } return null; } catch (err) /* v8 ignore next -- 404-to-REPOSITORY_CHANGED mapping for missing commits is not mocked in specs */ { if (err.statusCode === 404) { logger.debug('Commit not found when checking statuses'); throw new Error(REPOSITORY_CHANGED); } throw err; } } export async function setBranchStatus({ branchName, context, description, state, url: targetUrl, }: BranchStatusConfig): Promise { /* v8 ignore next -- specs do not run setBranchStatus in forking mode */ if (config.parentRepo) { logger.debug('Cannot set branch status when in forking mode'); return; } const existingStatus = await getBranchStatusCheck(branchName, context); if (existingStatus === state) { return; } logger.debug({ branch: branchName, context, state }, 'Setting branch status'); let url: string | undefined; try { const branchCommit = git.getBranchCommit(branchName); url = `repos/${config.repository}/statuses/${branchCommit}`; const renovateToGitHubStateMapping = { green: 'success', yellow: 'pending', red: 'failure', }; const options: any = { state: renovateToGitHubStateMapping[state], description, context, }; // v8 ignore else -- TODO: add test #40625 if (targetUrl) { options.target_url = targetUrl; } await githubApi.postJson(url, { body: options }); // update status cache await getStatus(branchName, false); await getStatusCheck(branchName, false); } catch (err) /* v8 ignore next -- defensive: status POST failures abort with REPOSITORY_CHANGED, not simulated in specs */ { logger.debug({ err, url }, 'Caught error setting branch status - aborting'); throw new Error(REPOSITORY_CHANGED); } } // Issue async function getIssues(): Promise { const result = await githubApi.queryRepoField( getIssuesQuery, 'issues', { variables: { owner: config.repositoryOwner, name: config.repositoryName, ...(!config.ignorePrAuthor && { user: config.renovateUsername }), }, readOnly: true, }, ); logger.debug(`Retrieved ${result.length} issues`); return Issue.array().parse(result); } export async function getIssueList(): Promise { /* v8 ignore next -- specs initialize repos with issues enabled */ if (config.hasIssuesEnabled === false) { return []; } let issueList = GithubIssueCache.getIssues(); // v8 ignore else -- TODO: add test #40625 if (!issueList) { logger.debug('Retrieving issueList'); issueList = await getIssues(); GithubIssueCache.setIssues(issueList); } return issueList; } export async function getIssue(number: number): Promise { if (config.hasIssuesEnabled === false) { return null; } try { const repo = config.parentRepo ?? config.repository; const { body: issue } = await githubApi.getJson( `repos/${repo}/issues/${number}`, { cacheProvider: repoCacheProvider, }, Issue, ); GithubIssueCache.updateIssue(issue); return issue; } catch (err) { logger.debug({ err, number }, 'Error getting issue'); if (err.response?.statusCode === 410) { logger.debug(`Issue #${number} has been deleted`); GithubIssueCache.deleteIssue(number); } return null; } } export async function findIssue(title: string): Promise { logger.debug(`findIssue(${title})`); const [issue] = (await getIssueList()).filter( (i) => i.state === 'open' && i.title === title, ); if (!issue) { return null; } logger.debug(`Found issue ${issue.number}`); return getIssue(issue.number); } async function closeIssue(issueNumber: number): Promise { logger.debug(`closeIssue(${issueNumber})`); const repo = config.parentRepo ?? config.repository; try { const { body: closedIssue } = await githubApi.patchJson( `repos/${repo}/issues/${issueNumber}`, { body: { state: 'closed' } }, Issue, ); GithubIssueCache.updateIssue(closedIssue); } catch (err) { const statusCode = err.response?.statusCode; if (statusCode === 404 || statusCode === 410) { logger.debug( `Issue #${issueNumber} no longer exists on the platform, removing from cache`, ); GithubIssueCache.deleteIssue(issueNumber); return; } throw err; } } export async function ensureIssue({ title, reuseTitle, body: rawBody, labels, once = false, shouldReOpen = true, }: EnsureIssueConfig): Promise { logger.debug(`ensureIssue(${title})`); /* v8 ignore next -- specs initialize repos with issues enabled */ if (config.hasIssuesEnabled === false) { logger.info( 'Cannot ensure issue because issues are disabled in this repository', ); return null; } const body = sanitize(rawBody); try { const issueList = await getIssueList(); let issues = issueList.filter((i) => i.title === title); if (!issues.length) { issues = issueList.filter((i) => i.title === reuseTitle); if (issues.length) { logger.debug(`Reusing issue title: "${reuseTitle}"`); } } if (issues.length) { let issue = issues.find((i) => i.state === 'open'); if (!issue) { if (once) { logger.debug('Issue already closed - skipping recreation'); return null; } if (shouldReOpen) { logger.debug('Reopening previously closed issue'); } issue = issues.at(-1)!; } for (const i of issues) { if (i.state === 'open' && i.number !== issue.number) { logger.warn({ issueNo: i.number }, 'Closing duplicate issue'); await closeIssue(i.number); } } const repo = config.parentRepo ?? config.repository; const { body: serverIssue } = await githubApi.getJson( `repos/${repo}/issues/${issue.number}`, { cacheProvider: repoCacheProvider }, Issue, ); GithubIssueCache.updateIssue(serverIssue); if ( issue.title === title && serverIssue.body === body && issue.state === 'open' ) { logger.debug('Issue is open and up to date - nothing to do'); return null; } if (shouldReOpen || issue.state === 'open') { logger.debug('Patching issue'); const data: Record = { body, state: 'open', title }; if (labels) { data.labels = labels; } const repo = config.parentRepo ?? config.repository; const { body: updatedIssue } = await githubApi.patchJson( `repos/${repo}/issues/${issue.number}`, { body: data }, Issue, ); GithubIssueCache.updateIssue(updatedIssue); logger.debug('Issue updated'); return 'updated'; } } const { body: createdIssue } = await githubApi.postJson( `repos/${config.parentRepo ?? config.repository}/issues`, { body: { title, body, labels: coerceArray(labels), }, }, Issue, ); logger.info('Issue created'); // reset issueList so that it will be fetched again as-needed GithubIssueCache.updateIssue(createdIssue); return 'created'; } catch (err) { if (err instanceof Error && err.message === PLATFORM_RATE_LIMIT_EXCEEDED) { throw err; } if (err.body?.message?.startsWith('Issues are disabled for this repo')) { logger.debug(`Issues are disabled, so could not create issue: ${title}`); } else { logger.warn({ err }, 'Could not ensure issue'); } } return null; } export async function ensureIssueClosing(title: string): Promise { logger.trace(`ensureIssueClosing(${title})`); /* v8 ignore next -- specs initialize repos with issues enabled */ if (config.hasIssuesEnabled === false) { return; } const issueList = await getIssueList(); for (const issue of issueList) { if (issue.state === 'open' && issue.title === title) { await closeIssue(issue.number); logger.debug(`Issue closed, issueNo: ${issue.number}`); } } } async function tryAddMilestone( issueNo: number, milestoneNo: number | undefined, ): Promise { if (!milestoneNo) { return; } logger.debug( { milestone: milestoneNo, pr: issueNo, }, 'Adding milestone to PR', ); try { const repo = config.parentRepo ?? config.repository; const { body: updatedIssue } = await githubApi.patchJson( `repos/${repo}/issues/${issueNo}`, { body: { milestone: milestoneNo } }, Issue, ); GithubIssueCache.updateIssue(updatedIssue); } catch (err) { /* v8 ignore next -- defensive: the raw-error fallback is for non-HTTP failures not seen in specs */ const actualError = err.response?.body ?? err; logger.warn( { milestone: milestoneNo, pr: issueNo, err: actualError, }, 'Unable to add milestone to PR', ); } } export async function addAssignees( issueNo: number, assignees: string[], ): Promise { logger.debug(`Adding assignees '${assignees.join(', ')}' to #${issueNo}`); const repository = config.parentRepo ?? config.repository; const url = `repos/${repository}/issues/${issueNo}/assignees`; let lastErr: Error | undefined; for (let attempt = 0; attempt < 3; attempt += 1) { try { const { body: updatedIssue } = await githubApi.postJson( url, { body: { assignees } }, Issue, ); GithubIssueCache.updateIssue(updatedIssue); return; } catch (err) { if (err.statusCode !== 404) { throw err; } lastErr = err; logger.debug( { attempt: attempt + 1 }, `Retrying addAssignees for #${issueNo} after 404`, ); await setTimeout(1000); } } throw lastErr!; } export async function addReviewers( prNo: number, reviewers: string[], ): Promise { logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${prNo}`); const userReviewers = reviewers.filter((e) => !e.startsWith('team:')); const teamReviewers = reviewers .filter((e) => e.startsWith('team:')) .map((e) => e.replace(regEx(/^team:/), '')); try { await githubApi.postJson( `repos/${ config.parentRepo ?? config.repository }/pulls/${prNo}/requested_reviewers`, { body: { reviewers: userReviewers, team_reviewers: teamReviewers, }, }, ); } catch (err) /* v8 ignore next -- defensive: reviewer assignment failures are logged and swallowed, not simulated in specs */ { logger.warn({ err }, 'Failed to assign reviewer'); } } export async function addLabels( issueNo: number, labels: string[] | null | undefined, ): Promise { logger.debug(`Adding labels '${labels?.join(', ')}' to #${issueNo}`); try { const repository = config.parentRepo ?? config.repository; if (isArray(labels) && labels.length) { await githubApi.postJson(`repos/${repository}/issues/${issueNo}/labels`, { body: labels, }); } } catch (err) /* v8 ignore next -- defensive: label-adding failures are logged and swallowed, not simulated in specs */ { logger.warn( { err, issueNo, labels }, 'Error while adding labels. Skipping', ); } } export async function deleteLabel( issueNo: number, label: string, ): Promise { logger.debug(`Deleting label ${label} from #${issueNo}`); const repository = config.parentRepo ?? config.repository; try { await githubApi.deleteJson( `repos/${repository}/issues/${issueNo}/labels/${label}`, ); } catch (err) /* v8 ignore next -- defensive: label deletion failures are logged and swallowed, not simulated in specs */ { logger.warn({ err, issueNo, label }, 'Failed to delete label'); } } async function addComment(issueNo: number, body: string): Promise { // POST /repos/:owner/:repo/issues/:number/comments await githubApi.postJson( `repos/${ config.parentRepo ?? config.repository }/issues/${issueNo}/comments`, { body: { body }, }, ); } async function editComment(commentId: number, body: string): Promise { // PATCH /repos/:owner/:repo/issues/comments/:id await githubApi.patchJson( `repos/${ config.parentRepo ?? config.repository }/issues/comments/${commentId}`, { body: { body }, }, ); } async function deleteComment(commentId: number): Promise { // DELETE /repos/:owner/:repo/issues/comments/:id await githubApi.deleteJson( `repos/${ config.parentRepo ?? config.repository }/issues/comments/${commentId}`, ); } async function getComments(issueNo: number): Promise { // GET /repos/:owner/:repo/issues/:number/comments logger.debug(`Getting comments for #${issueNo}`); const repo = config.parentRepo ?? config.repository; const url = `repos/${repo}/issues/${issueNo}/comments?per_page=100`; try { const { body: comments } = await githubApi.getJsonUnchecked( url, { paginate: true, cacheProvider: repoCacheProvider, }, ); logger.debug(`Found ${comments.length} comments`); return comments; } catch (err) /* v8 ignore next -- comment-fetch 404s are wrapped as ExternalHostError, not mocked in specs */ { if (err.statusCode === 404) { logger.debug('404 response when retrieving comments'); throw new ExternalHostError(err, 'github'); } throw err; } } export async function ensureComment({ number, topic, content, }: EnsureCommentConfig): Promise { const sanitizedContent = sanitize(content); try { const comments = await getComments(number); let body: string; let commentId: number | null = null; let commentNeedsUpdating = false; if (topic) { logger.debug(`Ensuring comment "${topic}" in #${number}`); body = `### ${topic}\n\n${sanitizedContent}`; comments.forEach((comment) => { if (comment.body.startsWith(`### ${topic}\n\n`)) { commentId = comment.id; commentNeedsUpdating = comment.body !== body; } }); } else { logger.debug(`Ensuring content-only comment in #${number}`); body = `${sanitizedContent}`; comments.forEach((comment) => { // v8 ignore else -- TODO: add test #40625 if (comment.body === body) { commentId = comment.id; commentNeedsUpdating = false; } }); } if (!commentId) { await addComment(number, body); logger.info( { repository: config.repository, issueNo: number, topic }, 'Comment added', ); } else if (commentNeedsUpdating) { await editComment(commentId, body); logger.debug( { repository: config.repository, issueNo: number }, 'Comment updated', ); } else { logger.debug('Comment is already up-to-date'); } return true; } catch (err) /* v8 ignore next -- comment API failure handling (locked issues) is not mocked in specs */ { if (err instanceof ExternalHostError) { throw err; } if (err.body?.message?.includes('is locked')) { logger.debug('Issue is locked - cannot add comment'); } else { logger.warn({ err }, 'Error ensuring comment'); } return false; } } function byTopic(comment: Comment, topic: string): boolean { return comment.body.startsWith(`### ${topic}\n\n`); } function byContent(comment: Comment, content: string): boolean { return comment.body.trim() === content; } export async function ensureCommentRemoval( deleteConfig: EnsureCommentRemovalConfig, ): Promise { const { number: issueNo } = deleteConfig; const key = deleteConfig.type === 'by-topic' ? deleteConfig.topic : deleteConfig.content; logger.trace(`Ensuring comment "${key}" in #${issueNo} is removed`); const comments = await getComments(issueNo); 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; } try { // v8 ignore else -- TODO: add test #40625 if (commentId) { logger.debug(`Removing comment from issueNo: ${issueNo}`); await deleteComment(commentId); } } catch (err) /* v8 ignore next -- defensive: comment deletion failures are logged and swallowed, not simulated in specs */ { logger.warn({ err }, 'Error deleting comment'); } } // Pull Request async function tryPrAutomerge( prNumber: number, prNodeId: string, platformPrOptions: PlatformPrOptions | undefined, ): Promise { if (!platformPrOptions?.usePlatformAutomerge) { return; } // If GitHub Enterprise Server <3.3.0 it doesn't support automerge // TODO #22198 // semver not null safe, accepts null and undefined if ( platformConfig.isGhe && semver.satisfies(platformConfig.gheVersion!, '<3.3.0') ) { logger.debug( { prNumber }, 'GitHub-native automerge: not supported on this version of GHE. Use 3.3.0 or newer.', ); return; } if (!config.autoMergeAllowed) { logger.debug( { prNumber }, 'GitHub-native automerge: not enabled in repo settings', ); return; } try { const mergeMethod = ( mapMergeStartegy(platformPrOptions.automergeStrategy) ?? config.mergeMethod )?.toUpperCase() || 'MERGE'; let commitHeadline: string | undefined; let commitBody: string | undefined; // For SQUASH and MERGE methods, pass the commit message explicitly to avoid // GitHub using the PR description as the commit body when "Use PR title and // body as commit message" is enabled in repository settings. const automergeCommitMessage = platformPrOptions?.automergeCommitMessage; if (mergeMethod !== 'REBASE' && automergeCommitMessage) { const newlineIndex = automergeCommitMessage.indexOf('\n'); if (newlineIndex === -1) { commitHeadline = automergeCommitMessage; } else { commitHeadline = automergeCommitMessage.slice(0, newlineIndex); commitBody = automergeCommitMessage.slice(newlineIndex + 1).trim(); } // Add PR number to the commit headline to match the default GitHub behavior commitHeadline = `${commitHeadline} (#${prNumber})`; } const variables = { pullRequestId: prNodeId, mergeMethod, commitHeadline, commitBody, }; // set count to one bypass graphql check const queryOptions = { variables, count: 1 }; const res = await githubApi.requestGraphql( enableAutoMergeMutation, queryOptions, ); if (res?.errors) { logger.debug( { prNumber, errors: res.errors }, 'GitHub-native automerge: fail', ); return; } logger.debug(`GitHub-native automerge: success...PrNo: ${prNumber}`); } catch (err) { if (err instanceof Error && err.message === PLATFORM_RATE_LIMIT_EXCEEDED) { throw err; } logger.warn({ prNumber, err }, 'GitHub-native automerge: REST API error'); } } // Creates PR and returns PR number export async function createPr({ sourceBranch, targetBranch, prTitle: title, prBody: rawBody, labels, draftPR = false, platformPrOptions, milestone, }: CreatePRConfig): Promise { const body = sanitize(rawBody); const base = targetBranch; // Include the repository owner to handle forkToken and regular mode // TODO: can `repository` be null? (#22198) const head = `${config.repository!.split('/')[0]}:${sourceBranch}`; const options: any = { body: { title, head, base, body, draft: draftPR, }, }; /* v8 ignore next -- fork mode is not exercised in createPr specs */ if (config.forkToken) { options.token = config.forkToken; options.body.maintainer_can_modify = !config.forkOrg && platformPrOptions?.forkModeDisallowMaintainerEdits !== true; } logger.debug({ title, head, base, draft: draftPR }, 'Creating PR'); const ghPr = ( await githubApi.postJson( `repos/${config.parentRepo ?? config.repository}/pulls`, options, ) ).body; logger.debug( { branch: sourceBranch, pr: ghPr.number, draft: draftPR }, 'PR created', ); const result = coerceRestPr(ghPr); const { number, node_id } = result; await addLabels(number, labels); await tryAddMilestone(number, milestone); await tryPrAutomerge(number, node_id, platformPrOptions); cachePr(result); return result; } async function isMergeQueueEnabled(baseBranch: string): Promise { const cachedResult = config.mergeQueueEnabled[baseBranch]; if (cachedResult !== undefined) { return cachedResult; } // TODO #22198 // semver not null safe, accepts null and undefined if ( platformConfig.isGhe && semver.satisfies(platformConfig.gheVersion!, '<3.12.0') ) { // Merge queues are only supported on GHES >=3.12.0 config.mergeQueueEnabled[baseBranch] = false; return false; } // Assume enabled unless proven otherwise, so the merge queue check is not // skipped by mistake let result = true; try { const res = await githubApi.requestGraphql<{ repository: { mergeQueue: { id: string } | null }; }>(repoMergeQueueQuery, { variables: { owner: config.repositoryOwner, name: config.repositoryName, branch: baseBranch, }, readOnly: true, count: 1, // bypass graphql check }); if (res?.errors) { logger.debug( { baseBranch, errors: res.errors }, 'Failed to fetch merge queue status - assuming merge queue is enabled', ); } else { result = isNonEmptyObject(res?.data?.repository?.mergeQueue); } } catch (err) { if (err instanceof Error && err.message === PLATFORM_RATE_LIMIT_EXCEEDED) { throw err; } logger.debug( { baseBranch, err }, 'Error fetching merge queue status - assuming merge queue is enabled', ); } config.mergeQueueEnabled[baseBranch] = result; return result; } export async function assertPrNotInMergeQueue( branchName: string, baseBranch?: string, ): Promise { if (!(await isMergeQueueEnabled(baseBranch ?? config.defaultBranch))) { return; } const pr = await findPr({ branchName, state: 'open' }); if (!pr) { return; } if ( await isPrInMergeQueue( githubApi, config.repositoryOwner, config.repositoryName, pr.number, ) ) { logger.debug(`PR #${pr.number} is in the merge queue - aborting push`); throw new Error(PR_ALREADY_IN_MERGE_QUEUE); } } export async function updatePr({ number: prNo, prTitle: title, prBody: rawBody, addLabels: labelsToAdd, removeLabels, state, targetBranch, }: UpdatePrConfig): Promise { logger.debug(`updatePr(${prNo}, ${title}, body)`); const body = sanitize(rawBody); const patchBody: any = { title }; // v8 ignore else -- TODO: add test #40625 if (body) { patchBody.body = body; } if (targetBranch) { patchBody.base = targetBranch; } if (state) { patchBody.state = state; } const options: any = { body: patchBody, }; /* v8 ignore next -- fork mode is not exercised in updatePr specs */ if (config.forkToken) { options.token = config.forkToken; } // Update PR labels try { if (labelsToAdd) { await addLabels(prNo, labelsToAdd); } if (removeLabels) { for (const label of removeLabels) { await deleteLabel(prNo, label); } } const { body: ghPr } = await githubApi.patchJson( `repos/${config.parentRepo ?? config.repository}/pulls/${prNo}`, options, ); const result = coerceRestPr(ghPr); cachePr(result); logger.debug(`PR updated...prNo: ${prNo}`); } catch (err) /* v8 ignore next -- non-host update failures are logged and swallowed, not mocked in specs */ { if (err instanceof ExternalHostError) { throw err; } logger.warn({ err }, 'Error updating PR'); } } export async function reattemptPlatformAutomerge({ number, platformPrOptions, }: ReattemptPlatformAutomergeConfig): Promise { try { const result = (await getPr(number))!; const { node_id } = result; await tryPrAutomerge(number, node_id, platformPrOptions); logger.debug(`PR platform automerge re-attempted...prNo: ${number}`); } catch (err) { if (err instanceof Error && err.message === PLATFORM_RATE_LIMIT_EXCEEDED) { throw err; } logger.warn({ err }, 'Error re-attempting PR platform automerge'); } } export async function mergePr({ branchName, id: prNo, strategy, }: MergePRConfig): Promise { logger.debug(`mergePr(${prNo}, ${branchName})`); const url = `repos/${ config.parentRepo ?? config.repository }/pulls/${prNo}/merge`; const options: GithubHttpOptions = { body: {}, }; /* v8 ignore next -- fork mode is not exercised in mergePr specs */ if (config.forkToken) { options.token = config.forkToken; } let automerged = false; let automergeResult: HttpResponse; const mergeStrategy = mapMergeStartegy(strategy) ?? config.mergeMethod; // v8 ignore else -- TODO: add test #40625 if (mergeStrategy) { // This path is taken if we have auto-detected the allowed merge types from the repo or // automergeStrategy is configured by user options.body.merge_method = mergeStrategy; try { logger.debug({ options, url }, `mergePr`); automergeResult = await githubApi.putJson(url, options); automerged = true; } catch (err) /* v8 ignore next -- merge rejection handling (404/405 status-check bodies) is not fully mocked in specs */ { if (err.statusCode === 404 || err.statusCode === 405) { const body = err.response?.body; if ( isNonEmptyString(body?.message) && regEx(/^Required status check ".+" is expected\.$/).test(body.message) ) { logger.debug( { response: body }, `GitHub blocking PR merge -- Missing required status check(s)`, ); return false; } if ( isNonEmptyString(body?.message) && (body.message.includes('approving review') || body.message.includes('code owner review') || body.message.includes( 'New changes require approval from someone other than the last pusher', )) ) { logger.debug( { response: body }, `GitHub blocking PR merge -- Needs approving review(s)`, ); return false; } logger.debug( { response: body }, 'GitHub blocking PR merge -- will keep trying', ); } else { logger.warn( { mergeMethod: config.mergeMethod, err }, 'Failed to merge PR', ); return false; } } } if (!automerged) { // We need to guess the merge method and try squash -> merge -> rebase options.body.merge_method = 'squash'; try { logger.debug({ options, url }, `mergePr`); automergeResult = await githubApi.putJson(url, options); } catch (err1) { logger.debug({ err: err1 }, `Failed to squash merge PR`); try { options.body.merge_method = 'merge'; logger.debug({ options, url }, `mergePr`); automergeResult = await githubApi.putJson(url, options); } catch (err2) { logger.debug({ err: err2 }, `Failed to merge commit PR`); try { options.body.merge_method = 'rebase'; logger.debug({ options, url }, `mergePr`); automergeResult = await githubApi.putJson(url, options); } catch (err3) { logger.debug({ err: err3 }, `Failed to rebase merge PR`); logger.info({ pr: prNo }, 'All merge attempts failed'); return false; } } } } logger.debug( { automergeResult: automergeResult!.body, pr: prNo }, 'PR merged', ); const cachedPr = config.prList?.find(({ number }) => number === prNo); if (cachedPr) { cachePr({ ...cachedPr, state: 'merged' }); } return true; } export function massageMarkdown(input: string): string { if (platformConfig.isGhe) { return smartTruncate(input, maxBodyLength()); } const massagedInput = massageMarkdownLinks(input) // to be safe, replace all github.com links with redirect.github.com .replace( regEx(/href="https?:\/\/github.com\//g), 'href="https://redirect.github.com/', ) .replace( regEx(/]\(https:\/\/github\.com\//g), '](https://redirect.github.com/', ) .replace( regEx(/]: https:\/\/github\.com\//g), ']: https://redirect.github.com/', ) .replaceAll('> ℹ **Note**\n> \n', '> [!NOTE]\n') .replaceAll('> ℹ️ **Note**\n> \n', '> [!NOTE]\n') .replaceAll('> ⚠ **Warning**\n> \n', '> [!WARNING]\n') .replaceAll('> ⚠️ **Warning**\n> \n', '> [!WARNING]\n') .replaceAll('> ❗ **Caution**\n> \n', '> [!CAUTION]\n') .replaceAll('> ❗ **Important**\n> \n', '> [!IMPORTANT]\n'); return smartTruncate(massagedInput, maxBodyLength()); } export function maxBodyLength(): number { return GitHubMaxPrBodyLen; } export async function getVulnerabilityAlerts(): Promise { /* v8 ignore next -- specs initialize repos with vulnerability alerts enabled */ if (config.hasVulnerabilityAlertsEnabled === false) { logger.debug('No vulnerability alerts enabled for repo'); return []; } let vulnerabilityAlerts: GithubVulnerabilityAlerts | undefined; try { vulnerabilityAlerts = ( await githubApi.getJson( `/repos/${config.repositoryOwner}/${config.repositoryName}/dependabot/alerts?state=open&direction=asc&per_page=100`, { paginate: true, headers: { accept: 'application/vnd.github+json' }, cacheProvider: repoCacheProvider, }, GithubVulnerabilityAlerts, ) ).body; } catch (err) /* v8 ignore next -- alert-permission failures are logged and swallowed, not mocked in specs */ { logger.debug({ err }, 'Error retrieving vulnerability alerts'); logger.warn( { url: `${GlobalConfig.get('productLinks').documentation}configuration-options/#vulnerabilityalerts`, }, 'Cannot access vulnerability alerts. Please ensure permissions have been granted.', ); } try { if (vulnerabilityAlerts?.length) { const shortAlerts: AggregatedVulnerabilities = {}; logger.trace( { alerts: vulnerabilityAlerts }, 'GitHub vulnerability details', ); for (const alert of vulnerabilityAlerts) { // v8 ignore if -- TODO: can never happen but makes typescript happy #40625 if (alert.security_vulnerability === null) { // As described in the documentation, there are cases in which // GitHub API responds with `"securityVulnerability": null`. // But it's may be faulty, so skip processing it here. continue; } const { package: { name, ecosystem }, vulnerable_version_range: vulnerableVersionRange, first_patched_version: firstPatchedVersion, } = alert.security_vulnerability; const patch = firstPatchedVersion?.identifier; const normalizedName = ecosystem === 'pip' ? normalizePythonDepName(name) : name; alert.security_vulnerability.package.name = normalizedName; const key = `${ecosystem.toLowerCase()}/${normalizedName}`; const range = vulnerableVersionRange; const elem = shortAlerts[key] || {}; elem[range] = coerceToNull(patch); shortAlerts[key] = elem; } logger.debug({ alerts: shortAlerts }, 'GitHub vulnerability details'); } else { logger.debug('No vulnerability alerts found'); } } catch (err) /* v8 ignore next -- defensive: processing already-parsed alerts does not throw in specs */ { logger.error({ err }, 'Error processing vulnerabity alerts'); } return coerceArray(vulnerabilityAlerts); } async function pushFiles( { branchName, message, trailers }: CommitFilesConfig, { parentCommitSha, commitSha }: CommitResult, ): Promise { try { // Hybrid git/REST commit strategy (see #13824, #14271): // 1. The git push below uploads blobs to GitHub via a custom ref // (refs/renovate/branches/*) which does NOT trigger CI/Actions. // 2. We then recreate the tree+commit via REST API so that: // - The commit is signed by GitHub ("committed via GitHub" badge) // - Force-push and file mode bits are supported (GraphQL can't do this) // - We can use base_tree to send only changed files, avoiding org // ruleset file-path restrictions on unchanged files (#42554) // Reusing the pushed commit/tree SHAs directly does not work because // the branch ref must point to an API-created commit for signing. await pushCommitToRenovateRef(commitSha, branchName); const baseTreeSha = await getCommitTreeSha(parentCommitSha); const treeItems = await diffCommitTree(parentCommitSha, commitSha); if (treeItems.length === 0) { logger.debug( { branchName }, 'Platform-native commit: no changed files between commits', ); return null; } const treeRes = await githubApi.postJson<{ sha: string }>( `/repos/${config.repository}/git/trees`, { body: { base_tree: baseTreeSha, tree: treeItems } }, ); const treeSha = treeRes.body.sha; const commitMessage = formatCommitMessage(message, trailers); // Now we recreate the commit using the tree we recreated the step before const commitRes = await githubApi.postJson<{ sha: string }>( `/repos/${config.repository}/git/commits`, { body: { message: commitMessage, tree: treeSha, parents: [parentCommitSha], }, }, ); incLimitedValue('Commits'); const remoteCommitSha = toLongCommitSha(commitRes.body.sha); await ensureBranchSha(branchName, remoteCommitSha); return remoteCommitSha; } catch (err) { logger.debug({ branchName, err }, 'Platform-native commit: unknown error'); return null; } } export async function commitFiles( config: CommitFilesConfig, ): Promise { const commitResult = await git.prepareCommit(config); // Commit locally and don't push const { branchName, files } = config; if (!commitResult) { logger.debug( { branchName, files: files.map(({ path }) => path) }, `Platform-native commit: unable to prepare for commit`, ); return null; } // Perform the commits using REST API const pushResult = await pushFiles(config, commitResult); if (!pushResult) { return null; } // Replace locally created branch with the remotely created one // and return the remote commit SHA await git.resetToCommit(commitResult.parentCommitSha); const commitSha = await git.fetchBranch(branchName); return commitSha; }