UNPKG

renovate

Version:

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

416 lines (415 loc) 23.7 kB
import { regEx } from "../../util/regex.js"; import { GlobalConfig } from "../../config/global.js"; import { coerceString } from "../../util/string.js"; import { logger } from "../../logger/index.js"; import { coerceArray } from "../../util/array.js"; import { compile } from "../../util/template/index.js"; import { emojify } from "../../util/emoji.js"; import { platform } from "../../modules/platform/index.js"; import { extractRepoProblems } from "./common.js"; import { getDepWarningsDashboard } from "./errors-warnings.js"; import { PackageFiles } from "./package-files.js"; import { Vulnerabilities } from "./process/vulnerabilities.js"; import { isNonEmptyArray, isNonEmptyObject, isNonEmptyString, isNullOrUndefined, isTruthy } from "@sindresorhus/is"; import { DateTime } from "luxon"; //#region lib/workers/repository/dependency-dashboard.ts const rateLimitedRe = regEx(` - \\[ \\] ${getMarkdownComment("unlimit-branch=([^\\s]+)")}`, "g"); const pendingApprovalRe = regEx(` - \\[ \\] ${getMarkdownComment("approve-branch=([^\\s]+)")}`, "g"); const awaitingScheduleRe = regEx(` - \\[ \\] ${getMarkdownComment("unschedule-branch=([^\\s]+)")}`, "g"); const generalBranchRe = regEx(` ${getMarkdownComment("([a-zA-Z]+)-branch=([^\\s]+)")}`); const markedBranchesRe = regEx(` - \\[x\\] ${getMarkdownComment("([a-zA-Z]+)-branch=([^\\s]+)")}`, "g"); const approveAllPendingPrs = "approve-all-pending-prs"; const createAllRateLimitedPrs = "create-all-rate-limited-prs"; const createAllAwaitingSchedulePrs = "create-all-awaiting-schedule-prs"; const createConfigMigrationPr = "create-config-migration-pr"; const configMigrationPrInfo = "config-migration-pr-info"; const rebaseAllOpenPrs = "rebase-all-open-prs"; function getMarkdownComment(comment) { return `<!-- ${comment} -->`; } function isBoxChecked(issueBody, type) { return issueBody.includes(getCheckbox(type, true)); } function isBoxUnchecked(issueBody, type) { return issueBody.includes(getCheckbox(type)); } function getCheckbox(type, checked = false) { return ` - [${checked ? "x" : " "}] ${getMarkdownComment(type)}`; } function checkOpenAllRateLimitedPR(issueBody) { return isBoxChecked(issueBody, createAllRateLimitedPrs); } function checkOpenAllAwaitingSchedulePR(issueBody) { return isBoxChecked(issueBody, createAllAwaitingSchedulePrs); } function checkApproveAllPendingPR(issueBody) { return isBoxChecked(issueBody, approveAllPendingPrs); } function checkRebaseAll(issueBody) { return isBoxChecked(issueBody, rebaseAllOpenPrs); } function getConfigMigrationCheckboxState(issueBody) { if (issueBody.includes(getMarkdownComment(configMigrationPrInfo))) return "migration-pr-exists"; if (isBoxChecked(issueBody, createConfigMigrationPr)) return "checked"; if (isBoxUnchecked(issueBody, createConfigMigrationPr)) return "unchecked"; return "no-checkbox"; } function selectAllRelevantBranches(issueBody) { const checkedBranches = []; if (checkOpenAllRateLimitedPR(issueBody)) for (const match of issueBody.matchAll(rateLimitedRe)) checkedBranches.push(match[0]); if (checkOpenAllAwaitingSchedulePR(issueBody)) for (const match of issueBody.matchAll(awaitingScheduleRe)) checkedBranches.push(match[0]); if (checkApproveAllPendingPR(issueBody)) for (const match of issueBody.matchAll(pendingApprovalRe)) checkedBranches.push(match[0]); return checkedBranches; } function getAllSelectedBranches(issueBody, dependencyDashboardChecks) { const allRelevantBranches = selectAllRelevantBranches(issueBody); for (const branch of allRelevantBranches) { const [, type, branchName] = generalBranchRe.exec(branch); dependencyDashboardChecks[branchName] = type; } return dependencyDashboardChecks; } function getCheckedBranches(issueBody) { let dependencyDashboardChecks = {}; for (const [, type, branchName] of issueBody.matchAll(markedBranchesRe)) dependencyDashboardChecks[branchName] = type; dependencyDashboardChecks = getAllSelectedBranches(issueBody, dependencyDashboardChecks); return dependencyDashboardChecks; } function parseDashboardIssue(issueBody) { const dependencyDashboardChecks = getCheckedBranches(issueBody); const dependencyDashboardRebaseAllOpen = checkRebaseAll(issueBody); const dependencyDashboardAllAwaitingSchedule = checkOpenAllAwaitingSchedulePR(issueBody); const dependencyDashboardAllPending = checkApproveAllPendingPR(issueBody); const dependencyDashboardAllRateLimited = checkOpenAllRateLimitedPR(issueBody); dependencyDashboardChecks.configMigrationCheckboxState = getConfigMigrationCheckboxState(issueBody); return { dependencyDashboardChecks, dependencyDashboardRebaseAllOpen, dependencyDashboardAllAwaitingSchedule, dependencyDashboardAllPending, dependencyDashboardAllRateLimited }; } async function readDashboardBody(config) { let dashboardChecks = { dependencyDashboardChecks: {}, dependencyDashboardRebaseAllOpen: false, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false }; const stringifiedConfig = JSON.stringify(config); if (config.dependencyDashboard === true || stringifiedConfig.includes("\"dependencyDashboardApproval\":true") || stringifiedConfig.includes("\"prCreation\":\"approval\"")) { config.dependencyDashboardTitle = config.dependencyDashboardTitle ?? `Dependency Dashboard`; const issue = await platform.findIssue(config.dependencyDashboardTitle); if (issue) { config.dependencyDashboardIssue = issue.number; dashboardChecks = parseDashboardIssue(issue.body ?? ""); } } if (config.checkedBranches) { const checkedBranchesRec = Object.fromEntries(config.checkedBranches.map((branchName) => [branchName, "global-config"])); dashboardChecks.dependencyDashboardChecks = { ...dashboardChecks.dependencyDashboardChecks, ...checkedBranchesRec }; } Object.assign(config, dashboardChecks); } function formatAsMarkdownLink(name, url) { return url ? `[${name}](${url})` : `\`${name}\``; } function getListItem(branch, type) { let item = getCheckbox(`${type}-branch=${branch.branchName}`); if (branch.prNo) item += `[${branch.prTitle}](../pull/${branch.prNo})`; else item += branch.prTitle; const uniquePackages = [...new Set(branch.upgrades.map((upgrade) => `\`${upgrade.depName}\``))]; if (uniquePackages.length < 2) return `${item}\n`; return `${item} (${uniquePackages.join(", ")})\n`; } function splitBranchesByCategory(filteredBranches) { const categories = {}; const uncategorized = []; let hasCategorized = false; let hasUncategorized = false; for (const branch of filteredBranches) { if (branch.dependencyDashboardCategory) { categories[branch.dependencyDashboardCategory] ??= []; categories[branch.dependencyDashboardCategory].push(branch); hasCategorized = true; continue; } uncategorized.push(branch); hasUncategorized = true; } return { categories, uncategorized, hasCategorized, hasUncategorized }; } function getBranchList(branches, listItemType) { return branches.map((branch) => getListItem(branch, listItemType)).join(""); } function getBranchesListMd(branches, predicate, title, description, listItemType = "approvePr", bulkComment, bulkMessage, bulkIcon) { const filteredBranches = branches.filter(predicate); if (filteredBranches.length === 0) return ""; let result = `## ${title}\n\n${description}\n\n`; const { categories, uncategorized, hasCategorized, hasUncategorized } = splitBranchesByCategory(filteredBranches); if (hasCategorized) { for (const [category, branches] of Object.entries(categories).sort(([keyA], [keyB]) => keyA.localeCompare(keyB, void 0, { numeric: true }))) { result = `${result.trimEnd()}\n\n`; result += `### ${category}\n\n`; result += getBranchList(branches, listItemType); } if (hasUncategorized) { result = `${result.trimEnd()}\n\n`; result += `### Others`; } } result = `${result.trimEnd()}\n\n`; result += getBranchList(uncategorized, listItemType); if (bulkComment && bulkMessage && filteredBranches.length > 1) { if (hasCategorized) { result = `${result.trimEnd()}\n\n`; result += "### All\n\n"; } result += getCheckbox(bulkComment); result += `${bulkIcon ? `${bulkIcon} ` : ""}**${bulkMessage}**${bulkIcon ? ` ${bulkIcon}` : ""}`; } return `${result.trimEnd()}\n\n`; } function appendRepoProblems(config, issueBody) { let newIssueBody = issueBody; const repoProblems = extractRepoProblems(config.repository); if (repoProblems.size) { newIssueBody += "## Repository Problems\n\n"; const repoProblemsHeader = config.customizeDashboard?.repoProblemsHeader ?? "Renovate tried to run on this repository, but found these problems."; newIssueBody += `${compile(repoProblemsHeader, config)}\n\n`; for (const repoProblem of repoProblems) newIssueBody += ` - ${repoProblem}\n`; newIssueBody += "\n"; } return newIssueBody; } async function ensureDependencyDashboard(config, allBranches, packageFiles = {}, configMigrationRes) { logger.debug("ensureDependencyDashboard()"); if (config.mode === "silent") { logger.debug("Dependency Dashboard issue is not created, updated or closed when mode=silent"); return; } const reuseTitle = "Update Dependencies (Renovate Bot)"; const branches = allBranches.filter((branch) => branch.result !== "automerged" && !branch.upgrades?.every((upgrade) => upgrade.remediationNotPossible)); if (!(config.dependencyDashboard === true || config.dependencyDashboardApproval === true || config.packageRules?.some((rule) => rule.dependencyDashboardApproval) === true || branches.some((branch) => !!branch.dependencyDashboardApproval || !!branch.dependencyDashboardPrApproval))) { if (GlobalConfig.get("dryRun")) logger.info({ title: config.dependencyDashboardTitle }, "DRY-RUN: Would close Dependency Dashboard"); else { logger.debug("Closing Dependency Dashboard"); await platform.ensureIssueClosing(config.dependencyDashboardTitle); } return; } // istanbul ignore if if (config.repoIsOnboarded === false) { logger.debug("Repo is onboarding - skipping dependency dashboard"); return; } logger.debug("Ensuring Dependency Dashboard"); let hasDeprecationsOrReplacements = false; const deprecatedPackages = {}; logger.debug("Checking packageFiles for deprecated or replacement packages"); if (isNonEmptyObject(packageFiles)) for (const [manager, fileNames] of Object.entries(packageFiles)) for (const fileName of fileNames) for (const dep of fileName.deps) { const name = dep.packageName ?? dep.depName; const hasReplacement = !!dep.updates?.find((updates) => updates.updateType === "replacement"); if (name && (dep.deprecationMessage ?? hasReplacement)) { hasDeprecationsOrReplacements = true; deprecatedPackages[manager] ??= {}; deprecatedPackages[manager][name] ??= { hasReplacement, sourceUrl: dep.sourceUrl }; } } const hasBranches = isNonEmptyArray(branches); if (config.dependencyDashboardAutoclose && !hasBranches && !hasDeprecationsOrReplacements) { if (GlobalConfig.get("dryRun")) logger.info({ title: config.dependencyDashboardTitle }, "DRY-RUN: Would close Dependency Dashboard"); else { logger.debug("Closing Dependency Dashboard"); await platform.ensureIssueClosing(config.dependencyDashboardTitle); } return; } let issueBody = ""; if (config.dependencyDashboardHeader?.length) issueBody += `${compile(config.dependencyDashboardHeader, config)}\n\n`; if (configMigrationRes.result === "pr-exists") issueBody += `## Config Migration Needed\n\n${getMarkdownComment(configMigrationPrInfo)} See Config Migration PR: #${configMigrationRes.prNumber}.\n\n`; else if (configMigrationRes?.result === "pr-modified") issueBody += `## Config Migration Needed (Blocked)\n\n${getMarkdownComment(configMigrationPrInfo)} The Config Migration branch exists but has been modified by another user. Renovate will not push to this branch unless it is first deleted. \n\n See Config Migration PR: #${configMigrationRes.prNumber}.\n\n`; else if (configMigrationRes?.result === "add-checkbox") issueBody += `## Config Migration Needed\n\n${getCheckbox(createConfigMigrationPr)} Select this checkbox to let Renovate create an automated Config Migration PR.\n\n`; issueBody = appendRepoProblems(config, issueBody); if (hasDeprecationsOrReplacements) { issueBody += "## Deprecations / Replacements\n"; issueBody += emojify("> :warning: **Warning**\n> \n"); issueBody += "The following dependencies are either deprecated or have replacements available.\n\n"; issueBody += "| Datasource | Package | Replacement PR? |\n"; issueBody += "|------------|------|--------------|\n"; for (const manager of Object.keys(deprecatedPackages).sort()) { const deps = deprecatedPackages[manager]; for (const depName of Object.keys(deps).sort()) { const { hasReplacement, sourceUrl } = deps[depName]; const packageName = formatAsMarkdownLink(depName, sourceUrl); issueBody += `| ${manager} | ${packageName} | ${hasReplacement ? "![Available](https://img.shields.io/badge/available-green?style=flat-square)" : "![Unavailable](https://img.shields.io/badge/unavailable-orange?style=flat-square)"} |\n`; } } issueBody += "\n"; } if (config.dependencyDashboardReportAbandonment) issueBody += getAbandonedPackagesMd(packageFiles); issueBody += getBranchesListMd(branches, (branch) => branch.result === "needs-approval", "Pending Approval", "The following branches are pending approval. To create them, click on a checkbox below.", "approve", approveAllPendingPrs, "Create all pending approval PRs at once", "🔐"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "minimum-group-size-not-met", "Group Size Not Met", "The following branches have not met their minimum group size. To create them, click on a checkbox below.", "approveGroup"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "not-scheduled", "Awaiting Schedule", "The following updates are awaiting their schedule. To get an update now, click on a checkbox below.", "unschedule", createAllAwaitingSchedulePrs, "Create all awaiting schedule PRs at once", "🔐"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "branch-limit-reached" || branch.result === "pr-limit-reached" || branch.result === "commit-per-run-limit-reached" || branch.result === "commit-hourly-limit-reached", "Rate-Limited", "The following updates are currently rate-limited. To force their creation now, click on a checkbox below.", "unlimit", createAllRateLimitedPrs, "Create all rate-limited PRs at once", "🔐"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "error", "Errored", "The following updates encountered an error and will be retried. To force a retry now, click on a checkbox below.", "retry"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "needs-pr-approval", "PR Creation Approval Required", "The following branches exist but PR creation requires approval. To approve PR creation, click on a checkbox below."); issueBody += getBranchesListMd(branches, (branch) => branch.result === "pr-edited", "PR Edited (Blocked)", "The following updates have been manually edited so Renovate will no longer make changes. To discard all commits and start over, click on a checkbox below.", "rebase"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "pending", "Pending Status Checks", "The following updates await pending status checks. To force their creation now, click on a checkbox below."); issueBody += getBranchesListMd(branches, (branch) => branch.prBlockedBy === "BranchAutomerge", "Pending Branch Automerge", "The following updates await pending status checks before automerging. To abort the branch automerge and create a PR instead, click on a checkbox below."); const warn = getDepWarningsDashboard(packageFiles, config); if (warn) { issueBody += warn; issueBody += "\n"; } const otherRes = [ "pending", "needs-approval", "needs-pr-approval", "not-scheduled", "pr-limit-reached", "commit-per-run-limit-reached", "commit-hourly-limit-reached", "branch-limit-reached", "already-existed", "error", "automerged", "pr-edited", "minimum-group-size-not-met" ]; const inProgress = branches.filter((branch) => !otherRes.includes(branch.result) && branch.prBlockedBy !== "BranchAutomerge"); issueBody += getBranchesListMd(inProgress, (branch) => !!branch.prBlockedBy || !branch.prNo, "Other Branches", "The following updates are pending. To force the creation of a PR, click on a checkbox below.", "other"); issueBody += getBranchesListMd(inProgress, (branch) => branch.prNo && !branch.prBlockedBy, "Open", "The following updates have all been created. To force a retry/rebase of any, click on a checkbox below.", "rebase", rebaseAllOpenPrs, "Click on this checkbox to rebase all open PRs at once"); issueBody += getBranchesListMd(branches, (branch) => branch.result === "already-existed", "PR Closed (Blocked)", "The following updates are blocked by an existing closed PR. To recreate the PR, click on a checkbox below.", "recreate"); if (!hasBranches) issueBody += "This repository currently has no open or pending branches.\n\n"; issueBody += await getDashboardMarkdownVulnerabilities(config, packageFiles); const footer = getFooter(config); issueBody += PackageFiles.getDashboardMarkdown(platform.maxBodyLength() - issueBody.length - footer.length); issueBody += footer; if (config.dependencyDashboardIssue) { if ((await platform.getIssue?.(config.dependencyDashboardIssue))?.body === issueBody) { logger.debug("No changes to dependency dashboard issue needed"); return; } const updatedIssue = await platform.getIssue?.(config.dependencyDashboardIssue, false); if (updatedIssue) { const { dependencyDashboardChecks } = parseDashboardIssue(coerceString(updatedIssue.body)); for (const branchName of Object.keys(config.dependencyDashboardChecks)) delete dependencyDashboardChecks[branchName]; for (const branchName of Object.keys(dependencyDashboardChecks)) { const checkText = getCheckbox(`${dependencyDashboardChecks[branchName]}-branch=${branchName}`); issueBody = issueBody.replace(checkText, checkText.replace("[ ]", "[x]")); } } } if (GlobalConfig.get("dryRun")) logger.info({ title: config.dependencyDashboardTitle }, "DRY-RUN: Would ensure Dependency Dashboard"); else await platform.ensureIssue({ title: config.dependencyDashboardTitle, reuseTitle, body: platform.massageMarkdown(issueBody, config.rebaseLabel), labels: config.dependencyDashboardLabels, confidential: config.confidential }); } function getAbandonedPackagesMd(packageFiles) { const abandonedPackages = {}; let abandonedCount = 0; for (const [manager, managerPackageFiles] of Object.entries(packageFiles)) for (const packageFile of managerPackageFiles) for (const dep of coerceArray(packageFile.deps)) if (dep.depName && dep.isAbandoned) { abandonedCount++; abandonedPackages[manager] = abandonedPackages[manager] || {}; abandonedPackages[manager][dep.depName] = { mostRecentTimestamp: dep.mostRecentTimestamp, sourceUrl: dep.sourceUrl }; } if (abandonedCount === 0) return ""; let abandonedMd = "## Abandoned Dependencies\n\n"; abandonedMd += "The following dependencies have not received updates for an extended period and may be unmaintained.\n\n"; abandonedMd += "<details>\n"; abandonedMd += `<summary>View abandoned dependencies (${abandonedCount})</summary>\n\n`; abandonedMd += emojify("> :information_source: **Note**\n> \n"); abandonedMd += "Packages are marked as abandoned when they exceed the [`abandonmentThreshold`](https://docs.renovatebot.com/configuration-options/#abandonmentthreshold) since their last release. "; abandonedMd += "Unlike deprecated packages with official notices, abandonment is detected by release inactivity.\n> \n"; abandonedMd += "| Datasource | Package | Last Updated |\n"; abandonedMd += "|------------|------|-------------|\n"; for (const manager of Object.keys(abandonedPackages).sort()) { const deps = abandonedPackages[manager]; for (const depName of Object.keys(deps).sort()) { const { mostRecentTimestamp, sourceUrl } = deps[depName]; const formattedDate = mostRecentTimestamp ? DateTime.fromISO(mostRecentTimestamp).toFormat("yyyy-MM-dd") : "unknown"; const packageName = formatAsMarkdownLink(depName, sourceUrl); abandonedMd += `| ${manager} | ${packageName} | \`${formattedDate}\` |\n`; } } abandonedMd += "\n</details>\n\n\n"; return abandonedMd; } function getFooter(config) { let footer = ""; if (config.dependencyDashboardFooter?.length) footer += `---\n${compile(config.dependencyDashboardFooter, config)}\n`; return footer; } async function getDashboardMarkdownVulnerabilities(config, packageFiles) { let result = ""; if (isNullOrUndefined(config.dependencyDashboardOSVVulnerabilitySummary) || config.dependencyDashboardOSVVulnerabilitySummary === "none") return result; result += "## Vulnerabilities\n\n"; const vulnerabilities = await (await Vulnerabilities.create()).fetchVulnerabilities(config, packageFiles); if (vulnerabilities.length === 0) { result += "Renovate has not found any CVEs on [osv.dev](https://osv.dev).\n\n"; return result; } const unresolvedVulnerabilities = vulnerabilities.filter((value) => isNullOrUndefined(value.fixedVersion)); const resolvedVulnerabilitiesLength = vulnerabilities.length - unresolvedVulnerabilities.length; result += emojify("> :exclamation: **Important**\n> \n"); result += `> \`${resolvedVulnerabilitiesLength}\`/\`${vulnerabilities.length}\``; if (isTruthy(config.osvVulnerabilityAlerts)) result += " CVEs have Renovate fixes.\n\n"; else result += " CVEs have possible Renovate fixes.\n> See [`osvVulnerabilityAlerts`](https://docs.renovatebot.com/configuration-options/#osvvulnerabilityalerts) to allow Renovate to supply fixes.\n\n"; let renderedVulnerabilities; switch (config.dependencyDashboardOSVVulnerabilitySummary) { case "unresolved": renderedVulnerabilities = unresolvedVulnerabilities; break; default: renderedVulnerabilities = vulnerabilities; } const managerRecords = {}; for (const vulnerability of renderedVulnerabilities) { const { manager, packageFile } = vulnerability.packageFileConfig; if (isNullOrUndefined(managerRecords[manager])) managerRecords[manager] = {}; if (isNullOrUndefined(managerRecords[manager][packageFile])) managerRecords[manager][packageFile] = {}; if (isNullOrUndefined(managerRecords[manager][packageFile][vulnerability.packageName])) managerRecords[manager][packageFile][vulnerability.packageName] = []; managerRecords[manager][packageFile][vulnerability.packageName].push(vulnerability); } for (const [manager, packageFileRecords] of Object.entries(managerRecords)) { result += `<details><summary>${manager}</summary>\n<blockquote>\n\n`; for (const [packageFile, packageNameRecords] of Object.entries(packageFileRecords)) { result += `<details><summary>${packageFile}</summary>\n<blockquote>\n\n`; for (const [packageName, cves] of Object.entries(packageNameRecords)) { result += `<details><summary>${packageName}</summary>\n<blockquote>\n\n`; for (const vul of cves) { const id = vul.vulnerability.id; const suffix = isNonEmptyString(vul.fixedVersion) ? ` (fixed in ${vul.fixedVersion})` : ""; result += `- [${id}](https://osv.dev/vulnerability/${id})${suffix}\n`; } result += `</blockquote>\n</details>\n\n`; } result += `</blockquote>\n</details>\n\n`; } result += `</blockquote>\n</details>\n\n`; } return result; } //#endregion export { ensureDependencyDashboard, readDashboardBody }; //# sourceMappingURL=dependency-dashboard.js.map