UNPKG

renovate

Version:

Automated dependency updates. Flexible so you don't need to be.

1,313 lines • 55.1 kB
import { __exportAll } from "../../../_virtual/_rolldown/runtime.js"; import { PLATFORM_RATE_LIMIT_EXCEEDED, PLATFORM_UNKNOWN_ERROR, REPOSITORY_ACCESS_FORBIDDEN, REPOSITORY_ARCHIVED, REPOSITORY_BLOCKED, REPOSITORY_CANNOT_FORK, REPOSITORY_CHANGED, REPOSITORY_EMPTY, REPOSITORY_FORKED, REPOSITORY_FORK_MISSING, REPOSITORY_NOT_FOUND, REPOSITORY_RENAMED } from "../../../constants/error-messages.js"; import { getEnv } from "../../../util/env.js"; import { regEx } from "../../../util/regex.js"; import { GlobalConfig } from "../../../config/global.js"; import { fromBase64, looseEquals } from "../../../util/string.js"; import { sanitize } from "../../../util/sanitize.js"; import { logger } from "../../../logger/index.js"; import { ensureTrailingSlash, isHttpUrl, parseUrl } from "../../../util/url.js"; import { find } from "../../../util/host-rules.js"; import { parseJson } from "../../../util/common.js"; import { ExternalHostError } from "../../../types/errors/external-host-error.js"; import { instrument } from "../../../instrumentation/index.js"; import { setBaseUrl } from "../../../util/http/github.js"; import { GithubBranchProtection, GithubBranchRulesets, GithubVulnerabilityAlerts } from "./schema.js"; import { memCacheProvider } from "../../../util/http/cache/memory-http-cache-provider.js"; import { incLimitedValue } from "../../../workers/global/limits.js"; import { diffCommitTree, fetchBranch, forcePushToRemote, getBranchCommit, getCommitTreeSha, initRepo as initRepo$1, prepareCommit, pushCommitToRenovateRef, resetToCommit } from "../../../util/git/index.js"; import { coerceObject } from "../../../util/object.js"; import { normalizePythonDepName } from "../../datasource/pypi/common.js"; import { isGithubFineGrainedPersonalAccessToken } from "../../../util/check-token.js"; import { coerceToNull } from "../../../util/coerce.js"; import { repoCacheProvider } from "../../../util/http/cache/repository-http-cache-provider.js"; import { repoFingerprint } from "../util.js"; import { smartTruncate } from "../utils/pr-body.js"; import { coerceRestPr, githubApi, mapMergeStartegy } from "./common.js"; import { remoteBranchExists } from "./branch.js"; import { enableAutoMergeMutation, getIssuesQuery, repoInfoQuery } from "./graphql.js"; import { GithubIssue, GithubIssueCache } from "./issue.js"; import { massageMarkdownLinks } from "./massage-markdown-links.js"; import { getPrCache, updatePrCache } from "./pr.js"; import { getAppDetails, getUserDetails, getUserEmail } from "./user.js"; import { getRepoUrl, warnIfDefaultGitAuthorEmail } from "./utils.js"; import { isArray, isNonEmptyObject, isNonEmptyString } from "@sindresorhus/is"; import semver from "semver"; import { setTimeout } from "node:timers/promises"; //#region lib/modules/platform/github/index.ts var github_exports = /* @__PURE__ */ __exportAll({ addAssignees: () => addAssignees, addLabels: () => addLabels, addReviewers: () => addReviewers, commitFiles: () => commitFiles, createFork: () => createFork, createPr: () => createPr, deleteLabel: () => deleteLabel, detectGhe: () => detectGhe, ensureComment: () => ensureComment, ensureCommentRemoval: () => ensureCommentRemoval, ensureIssue: () => ensureIssue, ensureIssueClosing: () => ensureIssueClosing, findFork: () => findFork, findIssue: () => findIssue, findPr: () => findPr, getBranchForceRebase: () => getBranchForceRebase, getBranchPr: () => getBranchPr, getBranchStatus: () => getBranchStatus, getBranchStatusCheck: () => getBranchStatusCheck, getIssue: () => getIssue, getIssueList: () => getIssueList, getJsonFile: () => getJsonFile, getPr: () => getPr, getPrList: () => getPrList, getRawFile: () => getRawFile, getRepos: () => getRepos, getVulnerabilityAlerts: () => getVulnerabilityAlerts, id: () => id, initPlatform: () => initPlatform, initRepo: () => initRepo, isGHApp: () => isGHApp, listForks: () => listForks, massageMarkdown: () => massageMarkdown, maxBodyLength: () => maxBodyLength, mergePr: () => mergePr, reattemptPlatformAutomerge: () => reattemptPlatformAutomerge, resetConfigs: () => resetConfigs, setBranchStatus: () => setBranchStatus, tryReuseAutoclosedPr: () => tryReuseAutoclosedPr, updatePr: () => updatePr }); const id = "github"; let config; let platformConfig; const GitHubMaxPrBodyLen = 58e3; function resetConfigs() { config = {}; platformConfig = { hostType: "github", endpoint: "https://api.github.com/" }; } resetConfigs(); function escapeHash(input) { return input?.replace(regEx(/#/g), "%23"); } function isGHApp() { return !!platformConfig.isGHApp; } async function detectGhe(token) { 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 gheHeaders = coerceObject((await githubApi.headJson("/", { token }))?.headers); const [, gheVersion] = Object.entries(gheHeaders).find(([k]) => k.toLowerCase() === gheHeaderKey) ?? []; platformConfig.gheVersion = semver.valid(gheVersion) ?? null; logger.debug(`Detected GitHub Enterprise Server, version: ${platformConfig.gheVersion}`); } } async function initPlatform({ endpoint, token: originalToken, username, gitAuthor }) { let token = originalToken; if (!token) throw new Error("Init: You must configure a GitHub token"); token = token.replace(/^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); 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; 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 discoveredGitAuthor; if (!gitAuthor) if (platformConfig.isGHApp) { platformConfig.userDetails ??= await getAppDetails(token); let ghHostname; /* v8 ignore next -- false negative due to V8/source-map artifact */ if (platformConfig.isGheCloud) ghHostname = "ghe.com"; else if (platformConfig.isGhe) ghHostname = parseUrl(platformConfig.endpoint).hostname; else ghHostname = "github.com"; 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}>`; } logger.debug({ platformConfig, renovateUsername }, "Platform config"); const 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(/^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(/^x-access-token:/, "") }); for (const hostType of [ "rubygems", "maven", "nuget" ]) { 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(/^x-access-token:/, "") }); } } return platformResult; } async function fetchRepositories() { try { if (isGHApp()) return (await githubApi.getJsonUnchecked(`installation/repositories?per_page=100`, { paginationField: "repositories", paginate: "all" })).body.repositories; else return (await githubApi.getJsonUnchecked(`user/repos?per_page=100`, { paginate: "all" })).body; } catch (err) /* v8 ignore next */ { logger.error({ err }, `GitHub getRepos error`); throw err; } } async function getRepos(config) { 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) { if (config.parentRepo) return {}; return (await githubApi.getJson(`repos/${config.repository}/branches/${escapeHash(branchName)}/protection`, { cacheProvider: repoCacheProvider }, GithubBranchProtection)).body; } async function getBranchRulesets(branchName) { if (config.parentRepo) return []; try { return (await githubApi.getJson(`repos/${config.repository}/rules/branches/${escapeHash(branchName)}`, { cacheProvider: repoCacheProvider }, GithubBranchRulesets)).body; } catch (err) { if (err.statusCode === 404) { logger.debug(`No branch rulesets found for ${branchName}`); return []; } throw err; } } async function getRawFile(fileName, repoName, branchOrTag) { const repo = repoName ?? config.repository; const httpOptions = {}; // v8 ignore else -- TODO: add test #40625 if (repo?.split("/")?.[0] === config.repositoryOwner) httpOptions.cacheProvider = repoCacheProvider; let url = `repos/${repo}/contents/${fileName}`; if (branchOrTag) url += `?ref=${branchOrTag}`; const buf = (await githubApi.getJsonUnchecked(url, httpOptions)).body.content; return fromBase64(buf); } async function getJsonFile(fileName, repoName, branchOrTag) { return parseJson(await getRawFile(fileName, repoName, branchOrTag), fileName); } async function listForks(token, repository) { try { 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); } } async function findFork(token, repository, forkOrg) { 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; } async function createFork(token, repository, forkOrg) { let forkedRepo; try { forkedRepo = (await githubApi.postJson(`repos/${repository}/forks`, { token, body: { organization: forkOrg ?? void 0, name: config.parentRepo.replace("/", "-_-"), default_branch_only: true } })).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(3e4); return forkedRepo; } async function initRepo({ repository, forkCreation, forkOrg, forkToken, gitUrl, renovateUsername, cloneSubmodules, cloneSubmodulesFilter }) { logger.debug(`initRepo("${repository}")`); config = { repository, cloneSubmodules, cloneSubmodulesFilter, ignorePrAuthor: GlobalConfig.get("ignorePrAuthor") }; const opts = find({ hostType: "github", url: platformConfig.endpoint, readOnly: true }); config.renovateUsername = renovateUsername; [config.repositoryOwner, config.repositoryName] = repository.split("/"); let repo; let forkSshUrl = null; try { let infoQuery = repoInfoQuery; if (platformConfig.isGhe && semver.satisfies(platformConfig.gheVersion, "<3.3.0")) { infoQuery = infoQuery.replace(/\n\s*autoMergeAllowed\s*\n/, "\n"); infoQuery = infoQuery.replace(/\n\s*hasIssuesEnabled\s*\n/, "\n"); } if (platformConfig.isGhe && semver.satisfies(platformConfig.gheVersion, "<3.9.0")) infoQuery = infoQuery.replace(/\n\s*hasVulnerabilityAlertsEnabled\s*\n/, "\n"); const res = await githubApi.requestGraphql(infoQuery, { variables: { owner: config.repositoryOwner, name: config.repositoryName, ...!config.ignorePrAuthor && { user: renovateUsername } }, readOnly: true, count: 1 }); if (res?.errors) { if (res.errors.find((err) => err.type === "RATE_LIMITED")) { logger.debug({ res }, "GraphQL rate limit exceeded."); throw new Error(PLATFORM_RATE_LIMIT_EXCEEDED); } logger.debug({ res }, "Unexpected GraphQL errors"); throw new Error(PLATFORM_UNKNOWN_ERROR); } repo = res?.data?.repository; /* v8 ignore next */ if (!repo) { logger.debug({ res }, "No repository returned"); throw new Error(REPOSITORY_NOT_FOUND); } /* v8 ignore next */ 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); } config.defaultBranch = repo.defaultBranchRef.name; logger.debug(`${repository} default branch = ${config.defaultBranch}`); if (repo.squashMergeAllowed) config.mergeMethod = "squash"; else if (repo.mergeCommitAllowed) config.mergeMethod = "merge"; else if (repo.rebaseMergeAllowed) config.mergeMethod = "rebase"; else logger.debug("Could not find allowed merge methods for repo"); config.autoMergeAllowed = repo.autoMergeAllowed; config.hasIssuesEnabled = repo.hasIssuesEnabled; config.hasVulnerabilityAlertsEnabled = repo.hasVulnerabilityAlertsEnabled; const recentIssues = GithubIssue.array().catch([]).parse(res?.data?.repository?.issues?.nodes); GithubIssueCache.addIssuesToReconcile(recentIssues); } catch (err) /* v8 ignore next */ { logger.debug({ err }, "Caught initRepo error"); if (err.message === "archived" || err.message === "renamed" || err.message === "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 === "fork-mode-forked") throw err; if (err.message === "fork") throw err; if (err.message === "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; } 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; 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 */ { 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 */ { 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; if (forkToken) { logger.debug("Using forkToken for git init"); authToken = coerceToNull(config.forkToken); } else { const tokenType = opts.token?.startsWith("x-access-token:") ? "app" : "personal access"; logger.debug(`Using ${tokenType} token for git init`); authToken = opts.token ?? null; } const parsedEndpoint = parseUrl(platformConfig.endpoint); const workingSshUrl = forkToken ? forkSshUrl : repo.sshUrl; const url = getRepoUrl(config.repository, gitUrl, workingSshUrl, parsedEndpoint, authToken); let upstreamUrl; if (forkCreation && config.parentRepo) upstreamUrl = getRepoUrl(config.parentRepo, gitUrl, repo.sshUrl, parsedEndpoint, authToken); await initRepo$1({ ...config, url, upstreamUrl }); return { defaultBranch: config.defaultBranch, isFork: repo.isFork === true, repoFingerprint: repoFingerprint(repo.id, platformConfig.endpoint) }; } async function checkRulesetsForForceRebase(branchName) { 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) { try { const branchProtection = await getBranchProtection(branchName); logger.trace(`Found branch protection for branch ${branchName}`); if (branchProtection?.required_status_checks?.strict) { 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; } } async function getBranchForceRebase(branchName) { config.branchForceRebase ??= {}; const cachedResult = config.branchForceRebase[branchName]; if (cachedResult !== void 0) return cachedResult; config.branchForceRebase[branchName] = false; if (await checkRulesetsForForceRebase(branchName)) { config.branchForceRebase[branchName] = true; return true; } if (await checkBranchProtectionForForceRebase(branchName)) config.branchForceRebase[branchName] = true; return config.branchForceRebase[branchName]; } function handleBranchProtectionError(protection, err, branchName) { if (err.statusCode === 404) { logger.debug(`No ${protection} found for ${branchName}`); return; } if (err.message === "integration-unauthorized" || err.statusCode === 403) { logger.once.debug(`Branch protection: Do not have permissions to detect ${protection} for ${branchName}`); return; } throw err; } function cachePr(pr) { config.prList ??= []; // v8 ignore else -- TODO: add test #40625 if (pr) { updatePrCache(pr); for (let idx = 0; idx < config.prList.length; idx += 1) if (config.prList[idx].number === pr.number) { config.prList[idx] = pr; return; } config.prList.push(pr); } } async function fetchPr(prNo) { 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; } } async function getPr(prNo) { if (!prNo) return null; let pr = (await getPrList()).find(({ number }) => number === prNo) ?? null; if (pr) logger.debug("Returning PR from cache"); pr ??= await fetchPr(prNo); return pr; } function matchesState(state, desiredState) { if (desiredState === "all") return true; if (desiredState.startsWith("!")) return state !== desiredState.substring(1); return state === desiredState; } async function getPrList() { if (!config.prList) { const repo = config.parentRepo ?? config.repository; let username = config.renovateUsername; if (config.forkToken || config.ignorePrAuthor) username = void 0; const prCache = await instrument("getPrCache", () => getPrCache(githubApi, repo, username)); config.prList = Object.values(prCache).sort(({ number: a }, { number: b }) => b - a); } return config.prList; } async function findPr({ branchName, prTitle, state = "all", includeOtherAuthors }) { logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`); if (includeOtherAuthors) { const repo = config.parentRepo ?? config.repository; const org = repo?.split("/")[0]; 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 pr = (await getPrList()).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, sha) { 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}`; if (await remoteBranchExists(repository, branchName)) 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}` } }); } async function getBranchPr(branchName) { logger.debug(`getBranchPr(${branchName})`); const openPr = await findPr({ branchName, state: "open" }); if (openPr) return openPr; return null; } async function tryReuseAutoclosedPr(autoclosedPr, newTitle) { 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 = getBranchCommit(branchName); // v8 ignore else -- TODO: add test #40625 if (localSha && localSha !== sha) { await forcePushToRemote(branchName, "origin"); result.sha = localSha; } cachePr(result); return result; } catch { logger.debug("Could not reopen autoclosed PR"); return null; } } async function getStatus(branchName, useCache = true) { 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; } async function getBranchStatus(branchName, internalChecksAsSuccess) { logger.debug(`getBranchStatus(${branchName})`); let commitStatus; try { commitStatus = await getStatus(branchName); } catch (err) /* v8 ignore next */ { 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 = []; 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(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"); } else logger.debug({ result: checkRunsRaw }, "No check runs found"); } catch (err) /* v8 ignore next */ { if (err instanceof ExternalHostError) throw err; if (err.statusCode === 403 || err.message === "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, useCache = true) { const branchCommit = getBranchCommit(branchName); const url = `repos/${config.repository}/commits/${branchCommit}/statuses`; const opts = useCache ? { cacheProvider: memCacheProvider } : { memCache: false }; return (await githubApi.getJsonUnchecked(url, opts)).body; } const githubToRenovateStatusMapping = { success: "green", error: "red", failure: "red", pending: "yellow" }; async function getBranchStatusCheck(branchName, context) { 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 */ { if (err.statusCode === 404) { logger.debug("Commit not found when checking statuses"); throw new Error(REPOSITORY_CHANGED); } throw err; } } async function setBranchStatus({ branchName, context, description, state, url: targetUrl }) { /* v8 ignore next */ if (config.parentRepo) { logger.debug("Cannot set branch status when in forking mode"); return; } if (await getBranchStatusCheck(branchName, context) === state) return; logger.debug({ branch: branchName, context, state }, "Setting branch status"); let url; try { const branchCommit = getBranchCommit(branchName); url = `repos/${config.repository}/statuses/${branchCommit}`; const options = { state: { green: "success", yellow: "pending", red: "failure" }[state], description, context }; // v8 ignore else -- TODO: add test #40625 if (targetUrl) options.target_url = targetUrl; await githubApi.postJson(url, { body: options }); await getStatus(branchName, false); await getStatusCheck(branchName, false); } catch (err) /* v8 ignore next */ { logger.debug({ err, url }, "Caught error setting branch status - aborting"); throw new Error(REPOSITORY_CHANGED); } } async function getIssues() { 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 GithubIssue.array().parse(result); } async function getIssueList() { /* v8 ignore next */ 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; } async function getIssue(number) { 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 }, GithubIssue); 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; } } async function findIssue(title) { 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) { logger.debug(`closeIssue(${issueNumber})`); const repo = config.parentRepo ?? config.repository; try { const { body: closedIssue } = await githubApi.patchJson(`repos/${repo}/issues/${issueNumber}`, { body: { state: "closed" } }, GithubIssue); 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; } } async function ensureIssue({ title, reuseTitle, body: rawBody, labels, once = false, shouldReOpen = true }) { logger.debug(`ensureIssue(${title})`); /* v8 ignore next */ 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[issues.length - 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 }, GithubIssue); 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 = { 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 }, GithubIssue); 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: labels ?? [] } }, GithubIssue); logger.info("Issue created"); GithubIssueCache.updateIssue(createdIssue); return "created"; } catch (err) /* v8 ignore next */ { 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; } async function ensureIssueClosing(title) { logger.trace(`ensureIssueClosing(${title})`); /* v8 ignore next */ 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, milestoneNo) { 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 } }, GithubIssue); GithubIssueCache.updateIssue(updatedIssue); } catch (err) { /* v8 ignore next */ const actualError = err.response?.body ?? err; logger.warn({ milestone: milestoneNo, pr: issueNo, err: actualError }, "Unable to add milestone to PR"); } } async function addAssignees(issueNo, assignees) { logger.debug(`Adding assignees '${assignees.join(", ")}' to #${issueNo}`); const url = `repos/${config.parentRepo ?? config.repository}/issues/${issueNo}/assignees`; let lastErr; for (let attempt = 0; attempt < 3; attempt += 1) try { const { body: updatedIssue } = await githubApi.postJson(url, { body: { assignees } }, GithubIssue); 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(1e3); } throw lastErr; } async function addReviewers(prNo, reviewers) { 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 */ { logger.warn({ err }, "Failed to assign reviewer"); } } async function addLabels(issueNo, labels) { 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 */ { logger.warn({ err, issueNo, labels }, "Error while adding labels. Skipping"); } } async function deleteLabel(issueNo, label) { 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 */ { logger.warn({ err, issueNo, label }, "Failed to delete label"); } } async function addComment(issueNo, body) { await githubApi.postJson(`repos/${config.parentRepo ?? config.repository}/issues/${issueNo}/comments`, { body: { body } }); } async function editComment(commentId, body) { await githubApi.patchJson(`repos/${config.parentRepo ?? config.repository}/issues/comments/${commentId}`, { body: { body } }); } async function deleteComment(commentId) { await githubApi.deleteJson(`repos/${config.parentRepo ?? config.repository}/issues/comments/${commentId}`); } async function getComments(issueNo) { logger.debug(`Getting comments for #${issueNo}`); const url = `repos/${config.parentRepo ?? config.repository}/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 */ { if (err.statusCode === 404) { logger.debug("404 response when retrieving comments"); throw new ExternalHostError(err, "github"); } throw err; } } async function ensureComment({ number, topic, content }) { const sanitizedContent = sanitize(content); try { const comments = await getComments(number); let body; let commentId = 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 */ { 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; } } async function ensureCommentRemoval(deleteConfig) { 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 = null; // v8 ignore else -- TODO: add test #40625 if (deleteConfig.type === "by-topic") { const byTopic = (comment) => comment.body.startsWith(`### ${deleteConfig.topic}\n\n`); commentId = comments.find(byTopic)?.id; } else if (deleteConfig.type === "by-content") { const byContent = (comment) => comment.body.trim() === deleteConfig.content; commentId = comments.find(byContent)?.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 */ { logger.warn({ err }, "Error deleting comment"); } } async function tryPrAutomerge(prNumber, prNodeId, platformPrOptions) { if (!platformPrOptions?.usePlatformAutomerge) return; if (platformConfig.isGhe) { if (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 = config.mergeMethod?.toUpperCase() || "MERGE"; let commitHeadline; let commitBody; 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(); } commitHeadline = `${commitHeadline} (#${prNumber})`; } const queryOptions = { variables: { pullRequestId: prNodeId, mergeMethod, commitHeadline, commitBody }, 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) /* v8 ignore next: missing test #22198 */ { logger.warn({ prNumber, err }, "GitHub-native automerge: REST API error"); } } async function createPr({ sourceBranch, targetBranch, prTitle: title, prBody: rawBody, labels, draftPR = false, platformPrOptions, milestone }) { const body = sanitize(rawBody); const base = targetBranch; const head = `${config.repository.split("/")[0]}:${sourceBranch}`; const options = { body: { title, head, base, body, draft: draftPR } }; /* v8 ignore next */ 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 updatePr({ number: prNo, prTitle: title, prBody: rawBody, addLabels: labelsToAdd, removeLabels, state, targetBranch }) { logger.debug(`updatePr(${prNo}, ${title}, body)`); const body = sanitize(rawBody); const patchBody = { 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 = { body: patchBody }; /* v8 ignore next */ if (config.forkToken) options.token = config.forkToken; 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); cachePr(coerceRestPr(ghPr)); logger.debug(`PR updated...prNo: ${prNo}`); } catch (err) /* v8 ignore next */ { if (err instanceof ExternalHostError) throw err; logger.warn({ err }, "Error updating PR"); } } async function reattemptPlatformAutomerge({ number, platformPrOptions }) { try { const { node_id } = await getPr(number); await tryPrAutomerge(number, node_id, platformPrOptions); logger.debug(`PR platform automerge re-attempted...prNo: ${number}`); } catch (err) /* v8 ignore next */ { logger.warn({ err }, "Error re-attempting PR platform automerge"); } } async function mergePr({ branchName, id: prNo, strategy }) { logger.debug(`mergePr(${prNo}, ${branchName})`); const url = `repos/${config.parentRepo ?? config.repository}/pulls/${prNo}/merge`; const options = { body: {} }; /* v8 ignore next */ if (config.forkToken) options.token = config.forkToken; let automerged = false; let automergeResult; const mergeStrategy = mapMergeStartegy(strategy) ?? config.mergeMethod; // v8 ignore else -- TODO: add test #40625 if (mergeStrategy) { options.body.merge_method = mergeStrategy; try { logger.debug({ options, url }, `mergePr`); automergeResult = await githubApi.putJson(url, options); automerged = true; } catch (err) /* v8 ignore next */ { 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"))) { 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) { 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 gi