mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
9d74625d93
* build(deps): update dependency prettier to v3.9.6 | datasource | package | from | to | | ---------- | -------- | ----- | ----- | | npm | prettier | 3.8.1 | 3.9.6 | * chore: prettier fixes --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Michael Kriese <michael.kriese@mend.io>
864 lines
29 KiB
TypeScript
864 lines
29 KiB
TypeScript
import {
|
|
isNonEmptyArray,
|
|
isNonEmptyObject,
|
|
isNonEmptyString,
|
|
isNullOrUndefined,
|
|
isTruthy,
|
|
} from '@sindresorhus/is';
|
|
import { DateTime } from 'luxon';
|
|
import { GlobalConfig } from '../../config/global.ts';
|
|
import type { RenovateConfig } from '../../config/types.ts';
|
|
import { logger } from '../../logger/index.ts';
|
|
import type { PackageFile } from '../../modules/manager/types.ts';
|
|
import { platform } from '../../modules/platform/index.ts';
|
|
import { coerceArray } from '../../util/array.ts';
|
|
import { emojify } from '../../util/emoji.ts';
|
|
import { regEx } from '../../util/regex.ts';
|
|
import { coerceString } from '../../util/string.ts';
|
|
import * as template from '../../util/template/index.ts';
|
|
import type { BranchConfig, SelectAllConfig } from '../types.ts';
|
|
import { extractRepoProblems, replacementAlreadyExists } from './common.ts';
|
|
import type { ConfigMigrationResult } from './config-migration/index.ts';
|
|
import { getDepWarningsDashboard } from './errors-warnings.ts';
|
|
import { PackageFiles } from './package-files.ts';
|
|
import type { Vulnerability } from './process/types.ts';
|
|
import { Vulnerabilities } from './process/vulnerabilities.ts';
|
|
import type {
|
|
DependencyDashboardCheck,
|
|
DependencyDashboardListItemType,
|
|
} from './types.ts';
|
|
|
|
interface DependencyDashboard {
|
|
dependencyDashboardChecks: Record<string, string>;
|
|
dependencyDashboardRebaseAllOpen: boolean;
|
|
dependencyDashboardAllPending: boolean;
|
|
dependencyDashboardAllRateLimited: boolean;
|
|
dependencyDashboardAllAwaitingSchedule: boolean;
|
|
}
|
|
|
|
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: string): string {
|
|
return `<!-- ${comment} -->`;
|
|
}
|
|
|
|
function isBoxChecked(issueBody: string, type: string): boolean {
|
|
return issueBody.includes(getCheckbox(type, true));
|
|
}
|
|
|
|
function isBoxUnchecked(issueBody: string, type: string): boolean {
|
|
return issueBody.includes(getCheckbox(type));
|
|
}
|
|
|
|
function getCheckbox(type: string, checked = false): string {
|
|
return ` - [${checked ? 'x' : ' '}] ${getMarkdownComment(type)}`;
|
|
}
|
|
|
|
function checkOpenAllRateLimitedPR(issueBody: string): boolean {
|
|
return isBoxChecked(issueBody, createAllRateLimitedPrs);
|
|
}
|
|
|
|
function checkOpenAllAwaitingSchedulePR(issueBody: string): boolean {
|
|
return isBoxChecked(issueBody, createAllAwaitingSchedulePrs);
|
|
}
|
|
|
|
function checkApproveAllPendingPR(issueBody: string): boolean {
|
|
return isBoxChecked(issueBody, approveAllPendingPrs);
|
|
}
|
|
|
|
function checkRebaseAll(issueBody: string): boolean {
|
|
return isBoxChecked(issueBody, rebaseAllOpenPrs);
|
|
}
|
|
|
|
function getConfigMigrationCheckboxState(
|
|
issueBody: string,
|
|
): 'no-checkbox' | 'checked' | 'unchecked' | 'migration-pr-exists' {
|
|
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: string): string[] {
|
|
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: string,
|
|
dependencyDashboardChecks: Record<string, string>,
|
|
): Record<string, string> {
|
|
const allRelevantBranches = selectAllRelevantBranches(issueBody);
|
|
for (const branch of allRelevantBranches) {
|
|
const [, type, branchName] = generalBranchRe.exec(branch)!;
|
|
dependencyDashboardChecks[branchName] = type;
|
|
}
|
|
return dependencyDashboardChecks;
|
|
}
|
|
|
|
function getCheckedBranches(issueBody: string): Record<string, string> {
|
|
let dependencyDashboardChecks: Record<string, string> = {};
|
|
for (const [, type, branchName] of issueBody.matchAll(markedBranchesRe)) {
|
|
dependencyDashboardChecks[branchName] = type;
|
|
}
|
|
dependencyDashboardChecks = getAllSelectedBranches(
|
|
issueBody,
|
|
dependencyDashboardChecks,
|
|
);
|
|
return dependencyDashboardChecks;
|
|
}
|
|
|
|
function parseDashboardIssue(issueBody: string): DependencyDashboard {
|
|
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,
|
|
};
|
|
}
|
|
|
|
export async function readDashboardBody(
|
|
config: SelectAllConfig,
|
|
): Promise<void> {
|
|
let dashboardChecks: DependencyDashboard = {
|
|
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 ?? '');
|
|
}
|
|
}
|
|
|
|
const checkedBranches = GlobalConfig.get('checkedBranches');
|
|
if (isNonEmptyArray(checkedBranches)) {
|
|
const checkedBranchesRec: Record<string, DependencyDashboardCheck> =
|
|
Object.fromEntries(
|
|
checkedBranches.map((branchName) => [branchName, 'global-config']),
|
|
);
|
|
dashboardChecks.dependencyDashboardChecks = {
|
|
...dashboardChecks.dependencyDashboardChecks,
|
|
...checkedBranchesRec,
|
|
};
|
|
}
|
|
|
|
if (GlobalConfig.get('rebaseAllOpenBranches')) {
|
|
dashboardChecks.dependencyDashboardRebaseAllOpen = true;
|
|
}
|
|
|
|
Object.assign(config, dashboardChecks);
|
|
}
|
|
|
|
function formatAsMarkdownLink(name: string, url?: string | null): string {
|
|
return url ? `[${name}](${url})` : `\`${name}\``;
|
|
}
|
|
|
|
function getListItem(
|
|
branch: BranchConfig,
|
|
type: DependencyDashboardListItemType,
|
|
): string {
|
|
let item = getCheckbox(`${type}-branch=${branch.branchName}`);
|
|
if (branch.prNo) {
|
|
// TODO: types (#22198)
|
|
item += `[${branch.prTitle!}](../pull/${branch.prNo})`;
|
|
} else {
|
|
item += branch.prTitle;
|
|
}
|
|
const uniquePackages = [
|
|
// TODO: types (#22198)
|
|
...new Set(branch.upgrades.map((upgrade) => `\`${upgrade.depName!}\``)),
|
|
];
|
|
if (uniquePackages.length < 2) {
|
|
return `${item}\n`;
|
|
}
|
|
return `${item} (${uniquePackages.join(', ')})\n`;
|
|
}
|
|
|
|
function splitBranchesByCategory(filteredBranches: BranchConfig[]): {
|
|
categories: Record<string, BranchConfig[]>;
|
|
uncategorized: BranchConfig[];
|
|
hasCategorized: boolean;
|
|
hasUncategorized: boolean;
|
|
} {
|
|
const categories: Record<string, BranchConfig[]> = {};
|
|
const uncategorized: BranchConfig[] = [];
|
|
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: BranchConfig[],
|
|
listItemType: DependencyDashboardListItemType,
|
|
): string {
|
|
return branches
|
|
.map((branch: BranchConfig): string => getListItem(branch, listItemType))
|
|
.join('');
|
|
}
|
|
|
|
function getBranchesListMd(
|
|
branches: BranchConfig[],
|
|
predicate: (
|
|
value: BranchConfig,
|
|
index: number,
|
|
array: BranchConfig[],
|
|
) => unknown,
|
|
title: string,
|
|
description: string,
|
|
listItemType: DependencyDashboardListItemType = 'approvePr',
|
|
bulkComment?: string,
|
|
bulkMessage?: string,
|
|
bulkIcon?: '🔐',
|
|
): string {
|
|
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, undefined, { 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: RenovateConfig, issueBody: string): string {
|
|
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 += `${template.compile(repoProblemsHeader, config)}\n\n`;
|
|
|
|
for (const repoProblem of repoProblems) {
|
|
newIssueBody += ` - ${repoProblem}\n`;
|
|
}
|
|
newIssueBody += '\n';
|
|
}
|
|
return newIssueBody;
|
|
}
|
|
|
|
export async function ensureDependencyDashboard(
|
|
config: SelectAllConfig,
|
|
allBranches: BranchConfig[],
|
|
packageFiles: Record<string, PackageFile[]> = {},
|
|
configMigrationRes: ConfigMigrationResult,
|
|
): Promise<void> {
|
|
logger.debug('ensureDependencyDashboard()');
|
|
if (config.mode === 'silent') {
|
|
logger.debug(
|
|
'Dependency Dashboard issue is not created, updated or closed when mode=silent',
|
|
);
|
|
return;
|
|
}
|
|
// legacy/migrated issue
|
|
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');
|
|
|
|
// Check packageFiles for any deprecations or replacements
|
|
let hasDeprecationsOrReplacements = false;
|
|
const deprecatedPackages: Record<
|
|
string,
|
|
Record<
|
|
string,
|
|
{ hasActionableReplacement: boolean; sourceUrl?: string | null }
|
|
>
|
|
> = {};
|
|
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 replacementUpdate = dep.updates?.find(
|
|
(update) => update.updateType === 'replacement',
|
|
);
|
|
const hasAnyReplacement = !!replacementUpdate;
|
|
const hasActionableReplacement =
|
|
hasAnyReplacement &&
|
|
!(
|
|
replacementUpdate.newName &&
|
|
replacementAlreadyExists(
|
|
fileName.deps,
|
|
dep,
|
|
replacementUpdate.newName,
|
|
)
|
|
);
|
|
if (name && (dep.deprecationMessage ?? hasAnyReplacement)) {
|
|
hasDeprecationsOrReplacements = true;
|
|
deprecatedPackages[manager] ??= {};
|
|
deprecatedPackages[manager][name] ??= {
|
|
hasActionableReplacement,
|
|
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 += `${template.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 { hasActionableReplacement, sourceUrl } = deps[depName];
|
|
const packageName = formatAsMarkdownLink(depName, sourceUrl);
|
|
|
|
issueBody += `| ${manager} | ${packageName} | ${
|
|
hasActionableReplacement
|
|
? ''
|
|
: ''
|
|
} |\n`;
|
|
}
|
|
}
|
|
issueBody += '\n';
|
|
}
|
|
|
|
if (config.dependencyDashboardReportAbandonment) {
|
|
issueBody += getAbandonedPackagesMd(config, 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.',
|
|
'unpend',
|
|
);
|
|
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';
|
|
}
|
|
|
|
// add CVE section
|
|
issueBody += await getDashboardMarkdownVulnerabilities(config, packageFiles);
|
|
|
|
// fit the detected dependencies section
|
|
const footer = getFooter(config);
|
|
issueBody += PackageFiles.getDashboardMarkdown(
|
|
platform.maxBodyLength() - issueBody.length - footer.length,
|
|
);
|
|
|
|
issueBody += footer;
|
|
|
|
if (config.dependencyDashboardIssue) {
|
|
// If we're not changing the dashboard issue, then we can skip checking if the user changed it.
|
|
// The cached issue we get back here will reflect its state at the _start_ of our run
|
|
const cachedIssue = await platform.getIssue?.(
|
|
config.dependencyDashboardIssue,
|
|
);
|
|
if (cachedIssue?.body === issueBody) {
|
|
logger.debug('No changes to dependency dashboard issue needed');
|
|
return;
|
|
}
|
|
|
|
// Skip cache when getting the issue to ensure we get the latest body,
|
|
// including any updates the user made after we started the run
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getAbandonedPackagesMd(
|
|
config: RenovateConfig,
|
|
packageFiles: Record<string, PackageFile[]>,
|
|
): string {
|
|
const abandonedPackages: Record<
|
|
string,
|
|
Record<
|
|
string,
|
|
{ mostRecentTimestamp?: string | null; sourceUrl?: string | null }
|
|
>
|
|
> = {};
|
|
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\`](${GlobalConfig.get('productLinks').documentation}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: RenovateConfig): string {
|
|
let footer = '';
|
|
if (config.dependencyDashboardFooter?.length) {
|
|
footer += `---\n${template.compile(config.dependencyDashboardFooter, config)}\n`;
|
|
}
|
|
|
|
return footer;
|
|
}
|
|
|
|
export async function getDashboardMarkdownVulnerabilities(
|
|
config: RenovateConfig,
|
|
packageFiles: Record<string, PackageFile[]>,
|
|
): Promise<string> {
|
|
let result = '';
|
|
|
|
if (
|
|
isNullOrUndefined(config.dependencyDashboardOSVVulnerabilitySummary) ||
|
|
config.dependencyDashboardOSVVulnerabilitySummary === 'none'
|
|
) {
|
|
return result;
|
|
}
|
|
|
|
result += '## Vulnerabilities\n\n';
|
|
|
|
const vulnerabilityFetcher = await Vulnerabilities.create();
|
|
const vulnerabilities = await vulnerabilityFetcher.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\`](${GlobalConfig.get('productLinks').documentation}configuration-options/#osvvulnerabilityalerts) to allow Renovate to supply fixes.\n\n`;
|
|
}
|
|
|
|
let renderedVulnerabilities: Vulnerability[];
|
|
switch (config.dependencyDashboardOSVVulnerabilitySummary) {
|
|
// filter vulnerabilities to display based on configuration
|
|
case 'unresolved':
|
|
renderedVulnerabilities = unresolvedVulnerabilities;
|
|
break;
|
|
default:
|
|
renderedVulnerabilities = vulnerabilities;
|
|
}
|
|
|
|
const managerRecords: Record<
|
|
string,
|
|
Record<string, Record<string, Vulnerability[]>>
|
|
> = {};
|
|
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;
|
|
}
|