Skip to content

Commit 1aa2972

Browse files
zharinovrarkinsHonkingGoose
authored
feat: Display abandoned packages (#35868)
Co-authored-by: Rhys Arkins <[email protected]> Co-authored-by: HonkingGoose <[email protected]>
1 parent 7aeef48 commit 1aa2972

File tree

6 files changed

+204
-4
lines changed

6 files changed

+204
-4
lines changed

docs/usage/configuration-options.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,6 +1411,15 @@ This feature is independent of the `osvVulnerabilityAlerts` option.
14111411

14121412
The source of these CVEs is [OSV.dev](https://osv.dev/).
14131413

1414+
## dependencyDashboardReportAbandonment
1415+
1416+
Controls whether abandoned packages are reported in the dependency dashboard.
1417+
1418+
When enabled (default), Renovate will display a collapsible section in the dependency dashboard listing packages that have been identified as abandoned based on the `abandonmentThreshold` configuration.
1419+
This helps you identify dependencies that may need attention due to lack of maintenance.
1420+
1421+
Set this to `false` if you prefer not to see abandoned packages in your dependency dashboard.
1422+
14141423
## dependencyDashboardTitle
14151424

14161425
Configure this option if you prefer a different title for the Dependency Dashboard.

lib/config/options/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,6 +1897,13 @@ const options: RenovateOptions[] = [
18971897
type: 'string',
18981898
default: null,
18991899
},
1900+
{
1901+
name: 'dependencyDashboardReportAbandonment',
1902+
description:
1903+
'Controls whether abandoned packages are reported in the dependency dashboard.',
1904+
type: 'boolean',
1905+
default: true,
1906+
},
19001907
{
19011908
name: 'internalChecksAsSuccess',
19021909
description:

lib/config/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export interface RenovateConfig
268268
dependencyDashboardFooter?: string;
269269
dependencyDashboardLabels?: string[];
270270
dependencyDashboardOSVVulnerabilitySummary?: 'none' | 'all' | 'unresolved';
271+
dependencyDashboardReportAbandonment?: boolean;
271272
packageFile?: string;
272273
packageRules?: PackageRule[];
273274
postUpdateOptions?: string[];

lib/modules/platform/github/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,10 +1947,10 @@ export function massageMarkdown(input: string): string {
19471947
regEx(/]: https:\/\/github\.com\//g),
19481948
']: https://redirect.github.com/',
19491949
)
1950-
.replace('> ℹ **Note**\n> \n', '> [!NOTE]\n')
1951-
.replace('> ⚠ **Warning**\n> \n', '> [!WARNING]\n')
1952-
.replace('> ⚠️ **Warning**\n> \n', '> [!WARNING]\n')
1953-
.replace('> ❗ **Important**\n> \n', '> [!IMPORTANT]\n');
1950+
.replaceAll('> ℹ **Note**\n> \n', '> [!NOTE]\n')
1951+
.replaceAll('> ⚠ **Warning**\n> \n', '> [!WARNING]\n')
1952+
.replaceAll('> ⚠️ **Warning**\n> \n', '> [!WARNING]\n')
1953+
.replaceAll('> ❗ **Important**\n> \n', '> [!IMPORTANT]\n');
19541954
return smartTruncate(massagedInput, maxBodyLength());
19551955
}
19561956

lib/workers/repository/dependency-dashboard.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { Platform } from '../../modules/platform';
1313
import { massageMarkdown } from '../../modules/platform/github';
1414
import { clone } from '../../util/clone';
1515
import { regEx } from '../../util/regex';
16+
import { asTimestamp } from '../../util/timestamp';
1617
import type { BranchConfig, BranchUpgradeConfig } from '../types';
1718
import * as dependencyDashboard from './dependency-dashboard';
1819
import { getDashboardMarkdownVulnerabilities } from './dependency-dashboard';
@@ -1656,4 +1657,126 @@ See [\`osvVulnerabilityAlerts\`](https://docs.renovatebot.com/configuration-opti
16561657
</details>`);
16571658
});
16581659
});
1660+
1661+
describe('getAbandonedPackagesMd()', () => {
1662+
it('returns empty string when no abandoned packages exist', () => {
1663+
const packageFiles: Record<string, PackageFile[]> = {
1664+
npm: [
1665+
{
1666+
packageFile: 'package.json',
1667+
deps: [
1668+
{ depName: 'lodash', isAbandoned: false },
1669+
{ depName: 'express', isAbandoned: false },
1670+
],
1671+
},
1672+
],
1673+
};
1674+
1675+
const result = dependencyDashboard.getAbandonedPackagesMd(packageFiles);
1676+
expect(result).toEqual('');
1677+
});
1678+
1679+
it('returns formatted markdown when abandoned packages exist', () => {
1680+
const packageFiles: Record<string, PackageFile[]> = {
1681+
npm: [
1682+
{
1683+
packageFile: 'package.json',
1684+
deps: [
1685+
{
1686+
depName: 'abandoned-pkg',
1687+
isAbandoned: true,
1688+
mostRecentTimestamp: asTimestamp('2020-05-15T12:00:00.000Z')!,
1689+
},
1690+
],
1691+
},
1692+
],
1693+
};
1694+
1695+
const result = dependencyDashboard.getAbandonedPackagesMd(packageFiles);
1696+
1697+
expect(result).toContain('> ℹ **Note**');
1698+
expect(result).toContain('| Datasource | Name | Last Updated |');
1699+
expect(result).toContain('| npm | `abandoned-pkg` | `2020-05-15` |');
1700+
expect(result).toContain('abandonmentThreshold');
1701+
});
1702+
1703+
it('handles multiple abandoned packages across different managers', () => {
1704+
const packageFiles: Record<string, PackageFile[]> = {
1705+
npm: [
1706+
{
1707+
packageFile: 'package.json',
1708+
deps: [
1709+
{
1710+
depName: 'pkg1',
1711+
isAbandoned: true,
1712+
mostRecentTimestamp: asTimestamp('2021-01-10T10:00:00.000Z')!,
1713+
},
1714+
{ depName: 'pkg2', isAbandoned: false },
1715+
{
1716+
depName: 'pkg3',
1717+
isAbandoned: true,
1718+
mostRecentTimestamp: asTimestamp('2020-11-05T15:30:00.000Z')!,
1719+
},
1720+
],
1721+
},
1722+
],
1723+
gradle: [
1724+
{
1725+
packageFile: 'build.gradle',
1726+
deps: [
1727+
{
1728+
depName: 'org.example:lib',
1729+
isAbandoned: true,
1730+
mostRecentTimestamp: asTimestamp('2019-07-22T08:15:00.000Z')!,
1731+
},
1732+
],
1733+
},
1734+
],
1735+
};
1736+
1737+
const result = dependencyDashboard.getAbandonedPackagesMd(packageFiles);
1738+
1739+
expect(result).toContain('| gradle | `org.example:lib` | `2019-07-22` |');
1740+
expect(result).toContain('| npm | `pkg1` | `2021-01-10` |');
1741+
expect(result).toContain('| npm | `pkg3` | `2020-11-05` |');
1742+
expect(result).not.toContain('pkg2');
1743+
});
1744+
1745+
it('displays "unknown" when mostRecentTimestamp is missing', () => {
1746+
const packageFiles: Record<string, PackageFile[]> = {
1747+
npm: [
1748+
{
1749+
packageFile: 'package.json',
1750+
deps: [
1751+
{
1752+
depName: 'pkg-with-date',
1753+
isAbandoned: true,
1754+
mostRecentTimestamp: asTimestamp('2021-03-17T14:30:00.000Z')!,
1755+
},
1756+
{ depName: 'pkg-no-date', isAbandoned: true },
1757+
],
1758+
},
1759+
],
1760+
};
1761+
1762+
const result = dependencyDashboard.getAbandonedPackagesMd(packageFiles);
1763+
1764+
expect(result).toContain('| npm | `pkg-with-date` | `2021-03-17` |');
1765+
expect(result).toContain('| npm | `pkg-no-date` | `unknown` |');
1766+
});
1767+
1768+
it('handles empty deps array', () => {
1769+
const packageFiles: Record<string, PackageFile[]> = {
1770+
npm: [
1771+
{
1772+
packageFile: 'package.json',
1773+
deps: [],
1774+
},
1775+
],
1776+
};
1777+
1778+
const result = dependencyDashboard.getAbandonedPackagesMd(packageFiles);
1779+
expect(result).toEqual('');
1780+
});
1781+
});
16591782
});

lib/workers/repository/dependency-dashboard.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import is from '@sindresorhus/is';
2+
import { DateTime } from 'luxon';
23
import { GlobalConfig } from '../../config/global';
34
import type { RenovateConfig } from '../../config/types';
45
import { logger } from '../../logger';
56
import type { PackageFile } from '../../modules/manager/types';
67
import { platform } from '../../modules/platform';
8+
import { coerceArray } from '../../util/array';
79
import { regEx } from '../../util/regex';
810
import { coerceString } from '../../util/string';
911
import * as template from '../../util/template';
@@ -324,6 +326,10 @@ export async function ensureDependencyDashboard(
324326
issueBody += '\n';
325327
}
326328

329+
if (config.dependencyDashboardReportAbandonment) {
330+
issueBody += getAbandonedPackagesMd(packageFiles);
331+
}
332+
327333
const pendingApprovals = branches.filter(
328334
(branch) => branch.result === 'needs-approval',
329335
);
@@ -557,6 +563,60 @@ export async function ensureDependencyDashboard(
557563
}
558564
}
559565

566+
export function getAbandonedPackagesMd(
567+
packageFiles: Record<string, PackageFile[]>,
568+
): string {
569+
const abandonedPackages: Record<
570+
string,
571+
Record<string, string | undefined | null>
572+
> = {};
573+
let abandonedCount = 0;
574+
575+
for (const [manager, managerPackageFiles] of Object.entries(packageFiles)) {
576+
for (const packageFile of managerPackageFiles) {
577+
for (const dep of coerceArray(packageFile.deps)) {
578+
if (dep.depName && dep.isAbandoned) {
579+
abandonedCount++;
580+
abandonedPackages[manager] = abandonedPackages[manager] || {};
581+
abandonedPackages[manager][dep.depName] = dep.mostRecentTimestamp;
582+
}
583+
}
584+
}
585+
}
586+
587+
if (abandonedCount === 0) {
588+
return '';
589+
}
590+
591+
let abandonedMd = '> ℹ **Note**\n> \n';
592+
abandonedMd +=
593+
'These dependencies have not received updates for an extended period and may be unmaintained:\n\n';
594+
595+
abandonedMd += '<details>\n';
596+
abandonedMd += `<summary>View abandoned dependencies (${abandonedCount})</summary>\n\n`;
597+
abandonedMd += '| Datasource | Name | Last Updated |\n';
598+
abandonedMd += '|------------|------|-------------|\n';
599+
600+
for (const manager of Object.keys(abandonedPackages).sort()) {
601+
const deps = abandonedPackages[manager];
602+
for (const depName of Object.keys(deps).sort()) {
603+
const mostRecentTimestamp = deps[depName];
604+
const formattedDate = mostRecentTimestamp
605+
? DateTime.fromISO(mostRecentTimestamp).toFormat('yyyy-MM-dd')
606+
: 'unknown';
607+
abandonedMd += `| ${manager} | \`${depName}\` | \`${formattedDate}\` |\n`;
608+
}
609+
}
610+
611+
abandonedMd += '\n</details>\n\n';
612+
abandonedMd +=
613+
'Packages are marked as abandoned when they exceed the [`abandonmentThreshold`](https://docs.renovatebot.com/configuration-options/#abandonmentthreshold) since their last release.\n';
614+
abandonedMd +=
615+
'Unlike deprecated packages with official notices, abandonment is detected by release inactivity.\n\n';
616+
617+
return abandonedMd + '\n';
618+
}
619+
560620
function getFooter(config: RenovateConfig): string {
561621
let footer = '';
562622
if (config.dependencyDashboardFooter?.length) {

0 commit comments

Comments
 (0)