diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index d70a386391d..2d51fddf849 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1772,6 +1772,32 @@ Renovate can fetch changelogs when they are hosted on one of these platforms: If you are running on any platform except `github.com`, you need to [configure a Personal Access Token](./getting-started/running.md#githubcom-token-for-changelogs-and-tools) to allow Renovate to fetch changelogs notes from `github.com`. +You may use [package rules](#packagerules) to override the value of `fetchChangeLogs` for matching depeendencies, with later rules overriding earlier ones. + +The following re-enables fetching of changelogs when creating pull-requests for lodash updates. + +```json +{ + "fetchChangeLogs": "off", + "packageRules": { + "matchSourceUrls": ["https://github.com/lodash/lodash"], + "fetchChangeLogs": "pr" + } +} +``` + +The following disables fetching of changelogs for any package in aws-sdk-go-v2, +which can be time-consuming due to the repository's large number of tags: + +```json +{ + "packageRules": { + "matchSourceUrls": ["https://github.com/aws/aws-sdk-go-v2{/**,}"], + "fetchChangeLogs": "off" + } +} +``` + !!! note Renovate can only show changelogs from some platforms and some package managers. diff --git a/docs/usage/key-concepts/changelogs.md b/docs/usage/key-concepts/changelogs.md index 87fb0907437..0833346c133 100644 --- a/docs/usage/key-concepts/changelogs.md +++ b/docs/usage/key-concepts/changelogs.md @@ -30,6 +30,10 @@ For more details, see [Private packages, looking up changelogs](../getting-start ### [`fetchChangeLogs`](../configuration-options.md#fetchchangelogs) +Top-level configuration that controls when to fetch changelogs for addition to pull-request and branch commit-messages. +If you want more finely-grained control over changelog fetching (disabling for specific packages, source repositories, etc), +use [packageRules](../configuration-options.md#packagerules) to override the `fetchChangeLogs` value for matching packages. + Set to `off` if changelog fetching is causing a problem. Set to `branch` if you have an advanced use case where you're embedding changelogs in the Git commit itself, we don't recommend this due to its potential size. @@ -40,7 +44,7 @@ This doesn't help with _fetching_ the changelogs, but if you configure it then R ## Platforms that Renovate can fetch changelogs from -See the list of platforms in the [`fetchChangelogs` config option docs](../configuration-options.md#fetchchangelogs). +See the list of platforms in the [`fetchChangeLogs` config option docs](../configuration-options.md#fetchchangelogs). ### Running Renovate on a non-GitHub platform diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 43e95964b80..9baa8ae1650 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -3183,6 +3183,7 @@ const options: Readonly[] = [ allowedValues: ['off', 'branch', 'pr'], default: 'pr', cli: false, + parents: ['.', 'packageRules'], }, { name: 'cloneSubmodules', diff --git a/lib/types/index.ts b/lib/types/index.ts index 84407e4c225..a27dbad103b 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -41,3 +41,16 @@ type Val = NonNullable; export type Nullish = T | null | undefined; export type MaybePromise = T | Promise; + +/** + * The Extends utility type ensures that U is a subset of T (typically a union). + * This helps ensure that we get a typescript error should anything ever be removed + * from T but still remain in U. + * + * @example + * type MyType = Extends; // works + * + * @example + * type IsBroken = Extends; // This will give a typescript error + */ +export type Extends = U; diff --git a/lib/util/package-rules/index.spec.ts b/lib/util/package-rules/index.spec.ts index 43c346dea70..3f5d7b20038 100644 --- a/lib/util/package-rules/index.spec.ts +++ b/lib/util/package-rules/index.spec.ts @@ -1351,6 +1351,36 @@ describe('util/package-rules/index', () => { expect(res.packageName).toBe('docker.io/library/node'); }); + it('propagates fetchChangeLogs from matching packageRule', async () => { + const config: TestConfig = { + datasource: 'npm', + depName: 'some-dep', + packageRules: [ + { + matchDatasources: ['npm'], + fetchChangeLogs: 'off', + }, + ], + }; + const res = await applyPackageRules(config); + expect(res.fetchChangeLogs).toBe('off'); + }); + + it('does not set fetchChangeLogs when packageRule does not match', async () => { + const config: TestConfig = { + datasource: 'npm', + depName: 'some-dep', + packageRules: [ + { + matchDatasources: ['pypi'], + fetchChangeLogs: 'off', + }, + ], + }; + const res = await applyPackageRules(config); + expect(res.fetchChangeLogs).toBeUndefined(); + }); + it('compiles sourceUrl with template helper functions', async () => { const config: TestConfig = { datasource: 'terraform-provider', diff --git a/lib/workers/repository/changelog/index.spec.ts b/lib/workers/repository/changelog/index.spec.ts index 447d63f24cf..7a7ba54e7b8 100644 --- a/lib/workers/repository/changelog/index.spec.ts +++ b/lib/workers/repository/changelog/index.spec.ts @@ -1,7 +1,9 @@ import { partial } from '~test/util.ts'; +import type { FetchChangeLogsOptions } from '../../../config/types.ts'; import type { BranchUpgradeConfig } from '../../types.ts'; import { getChangeLogJSON } from '../update/pr/changelog/index.ts'; import { embedChangelogs } from './index.ts'; +import type { SupportedChangelogStages } from './types.ts'; vi.mock('../update/pr/changelog/index.ts'); @@ -21,7 +23,13 @@ describe('workers/repository/changelog/index', () => { partial(), partial({ changelogContent: 'testContent' }), ]; - await expect(embedChangelogs(branches)).toResolve(); + await expect( + embedChangelogs({ + upgrades: branches, + stage: 'pr', + fetchChangeLogs: 'pr', + }), + ).toResolve(); expect(branches).toEqual([ { logJSON: null }, { logJSON: { hasReleaseNotes: true } }, @@ -42,4 +50,148 @@ describe('workers/repository/changelog/index', () => { }, ]); }); + + interface FetchChangeLogsTestCase { + fetchChangeLogs?: FetchChangeLogsOptions; + stage: SupportedChangelogStages; + expectations: () => void; + } + + const testCases: FetchChangeLogsTestCase[] = [ + { + stage: 'pr', + fetchChangeLogs: undefined, // should default to 'pr' + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(2); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ + groupName: 'fetchChangeLogs is undefined', + }), + ); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is pr' }), + ); + }, + }, + { + stage: 'pr', + fetchChangeLogs: 'pr', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(2); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ + groupName: 'fetchChangeLogs is undefined', + }), + ); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is pr' }), + ); + }, + }, + { + stage: 'pr', + fetchChangeLogs: 'branch', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(1); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is pr' }), + ); + }, + }, + { + stage: 'pr', + fetchChangeLogs: 'off', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(1); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is pr' }), + ); + }, + }, + { + stage: 'branch', + fetchChangeLogs: undefined, // should default to 'pr' + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(1); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is branch' }), + ); + }, + }, + { + stage: 'branch', + fetchChangeLogs: 'branch', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(2); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ + groupName: 'fetchChangeLogs is undefined', + }), + ); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is branch' }), + ); + }, + }, + { + stage: 'branch', + fetchChangeLogs: 'pr', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(1); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is branch' }), + ); + }, + }, + { + stage: 'branch', + fetchChangeLogs: 'off', + expectations: () => { + expect(getChangeLogJSON).toHaveBeenCalledTimes(1); + expect(getChangeLogJSON).toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is branch' }), + ); + }, + }, + ]; + + test.each(testCases)( + 'stage is "$stage", top-level fetchChangeLogs is "$fetchChangeLogs"', + async ({ fetchChangeLogs, stage, expectations }) => { + vi.mocked(getChangeLogJSON).mockResolvedValue({ + hasReleaseNotes: true, + }); + const branches = [ + partial({ + groupName: 'fetchChangeLogs is undefined', + }), + partial({ + groupName: 'fetchChangeLogs is pr', + fetchChangeLogs: 'pr', + }), + partial({ + groupName: 'fetchChangeLogs is branch', + fetchChangeLogs: 'branch', + }), + partial({ + groupName: 'fetchChangeLogs is off', + fetchChangeLogs: 'off', + }), + ]; + await expect( + embedChangelogs({ + upgrades: branches, + stage, + fetchChangeLogs, + }), + ).toResolve(); + + expectations(); + + // When fetchChangeLogs is explicitly set to 'off', no changelogs should be fetched. + expect(getChangeLogJSON).not.toHaveBeenCalledWith( + expect.objectContaining({ groupName: 'fetchChangeLogs is off' }), + ); + }, + ); }); diff --git a/lib/workers/repository/changelog/index.ts b/lib/workers/repository/changelog/index.ts index 6defd783bf7..2e3c0a2a3d8 100644 --- a/lib/workers/repository/changelog/index.ts +++ b/lib/workers/repository/changelog/index.ts @@ -1,6 +1,8 @@ +import type { FetchChangeLogsOptions } from '../../../config/types.ts'; import * as p from '../../../util/promises.ts'; import type { BranchUpgradeConfig } from '../../types.ts'; import { getChangeLogJSON } from '../update/pr/changelog/index.ts'; +import type { EmbedChangelogsOptions } from './types.ts'; export async function embedChangelog( upgrade: BranchUpgradeConfig, @@ -43,8 +45,30 @@ export async function embedChangelog( } } -export async function embedChangelogs( - branches: BranchUpgradeConfig[], -): Promise { - await p.map(branches, embedChangelog, { concurrency: 10 }); +// Merges the top-level fetchChangeLogs value with the upgrade's fetchChangeLogs value (prioritizing the latter, if defined). +function resolveFetchChangeLogs( + fetchChangeLogs?: FetchChangeLogsOptions, + upgradeFetchChangeLogs?: FetchChangeLogsOptions, +): FetchChangeLogsOptions { + // Default to 'pr' if top-level fetchChangeLogs is undefined. + let fetchChangeLogsResolved: FetchChangeLogsOptions = fetchChangeLogs ?? 'pr'; + if (upgradeFetchChangeLogs) { + // Override using the upgrade's fetchChangeLogs value. + fetchChangeLogsResolved = upgradeFetchChangeLogs; + } + return fetchChangeLogsResolved; +} + +export async function embedChangelogs({ + upgrades, + stage, + fetchChangeLogs, +}: EmbedChangelogsOptions): Promise { + // Filter down to branch upgrades that match the stage and fetchChangeLogs configuration. + const filteredUpgrades = upgrades.filter( + (upgrade) => + resolveFetchChangeLogs(fetchChangeLogs, upgrade.fetchChangeLogs) === + stage, + ); + await p.map(filteredUpgrades, embedChangelog, { concurrency: 10 }); } diff --git a/lib/workers/repository/changelog/types.ts b/lib/workers/repository/changelog/types.ts new file mode 100644 index 00000000000..37a178d72c6 --- /dev/null +++ b/lib/workers/repository/changelog/types.ts @@ -0,0 +1,14 @@ +import type { FetchChangeLogsOptions } from '../../../config/types.ts'; +import type { Extends } from '../../../types/index.ts'; +import type { BranchUpgradeConfig } from '../../types.ts'; + +export type SupportedChangelogStages = Extends< + FetchChangeLogsOptions, + 'branch' | 'pr' +>; + +export interface EmbedChangelogsOptions { + upgrades: BranchUpgradeConfig[]; + stage: SupportedChangelogStages; + fetchChangeLogs?: FetchChangeLogsOptions; +} diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts index 55d2abf25bd..558a28ce41c 100644 --- a/lib/workers/repository/update/branch/index.spec.ts +++ b/lib/workers/repository/update/branch/index.spec.ts @@ -36,6 +36,7 @@ import type { BranchUpgradeConfig, CacheFingerprintMatchResult, } from '../../../types.ts'; +import * as _changelog from '../../changelog/index.ts'; import * as _prAutomerge from '../pr/automerge.ts'; import type { ResultWithPr } from '../pr/index.ts'; import * as _prWorker from '../pr/index.ts'; @@ -79,6 +80,7 @@ const exec = vi.mocked(_exec); const sanitize = vi.mocked(_sanitize); const limits = vi.mocked(_limits); const repoCache = vi.mocked(_repoCache); +const changelog = vi.mocked(_changelog); const adminConfig: RepoGlobalConfig = { localDir: '', cacheDir: '' }; @@ -1223,6 +1225,26 @@ describe('workers/repository/update/branch/index', () => { }); }); + it('fetches changelogs for the "branch" stage', async () => { + getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce( + partial({ + updatedPackageFiles: [partial()], + }), + ); + npmPostExtract.getAdditionalFiles.mockResolvedValueOnce({ + artifactErrors: [], + updatedArtifacts: [partial()], + }); + + await branchWorker.processBranch({ ...config }); + + expect(changelog.embedChangelogs).toHaveBeenCalledExactlyOnceWith({ + upgrades: config.upgrades, + stage: 'branch', + fetchChangeLogs: config.fetchChangeLogs, + }); + }); + it('ensures PR and tries automerge', async () => { getUpdated.getUpdatedPackageFiles.mockResolvedValueOnce( partial({ diff --git a/lib/workers/repository/update/branch/index.ts b/lib/workers/repository/update/branch/index.ts index 15a2eda02f0..3e13064ba8e 100644 --- a/lib/workers/repository/update/branch/index.ts +++ b/lib/workers/repository/update/branch/index.ts @@ -648,9 +648,12 @@ export async function processBranch( } else { logger.debug('No updated lock files in branch'); } - if (config.fetchChangeLogs === 'branch') { - await embedChangelogs(config.upgrades); - } + + await embedChangelogs({ + upgrades: config.upgrades, + stage: 'branch', + fetchChangeLogs: config.fetchChangeLogs, + }); const postUpgradeCommandResults = await executePostUpgradeCommands(config); diff --git a/lib/workers/repository/update/pr/index.spec.ts b/lib/workers/repository/update/pr/index.spec.ts index ab21bc2ff7f..08af0e95c24 100644 --- a/lib/workers/repository/update/pr/index.spec.ts +++ b/lib/workers/repository/update/pr/index.spec.ts @@ -95,6 +95,18 @@ describe('workers/repository/update/pr/index', () => { expect(prCache.setPrCache).toHaveBeenCalled(); }); + it('fetches changelogs for the "pr" stage', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + await ensurePr(config); + + expect(embedChangelogs).toHaveBeenCalledExactlyOnceWith({ + upgrades: config.upgrades, + stage: 'pr', + fetchChangeLogs: config.fetchChangeLogs, + }); + }); + it('aborts PR creation once limit is exceeded', async () => { platform.createPr.mockResolvedValueOnce(pr); limits.isLimitReached.mockReturnValueOnce(true); diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index 7be0c2c83d9..ad21ca5daa0 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -285,10 +285,12 @@ export async function ensurePr( }`; } - if (config.fetchChangeLogs === 'pr') { - // fetch changelogs when not already done; - await embedChangelogs(upgrades); - } + // fetch changelogs for matching upgrades. + await embedChangelogs({ + upgrades: upgrades, + stage: 'pr', + fetchChangeLogs: config.fetchChangeLogs, + }); // Get changelog and then generate template strings for (const upgrade of upgrades) { diff --git a/test/docs/documentation.spec.ts b/test/docs/documentation.spec.ts index 8bf43c17d8f..ef1b9bfb8d5 100644 --- a/test/docs/documentation.spec.ts +++ b/test/docs/documentation.spec.ts @@ -48,19 +48,25 @@ describe('docs/documentation', () => { const matches = content.match(/\n## (.*?)\n/g) ?? []; return matches .map((match) => match.substring(4, match.length - 1)) - .filter( - (header) => - header !== 'managerFilePatterns' && header !== 'enabled', - ); + .filter((header) => header !== 'managerFilePatterns'); } function getRequiredConfigOptions(): string[] { - return options - .filter((option) => !option.globalOnly) - .filter((option) => !option.parents) - .filter((option) => !option.autogenerated) - .map((option) => option.name) - .sort(); + return ( + options + .filter((option) => !option.globalOnly) + // Only include top-level options, which have no parents (implicit root) or explicitly define the + // root ('.') as their parent. + .filter( + (option) => + !option.parents || + option.parents.length === 0 || + option.parents.includes('.'), + ) + .filter((option) => !option.autogenerated) + .map((option) => option.name) + .sort() + ); } it('has doc headers sorted alphabetically', async () => { @@ -84,21 +90,25 @@ describe('docs/documentation', () => { } function getRequiredConfigSubOptions(): string[] { - return options - .filter((option) => option.stage !== 'global') - .filter((option) => !option.globalOnly) - .filter((option) => option.parents) - .filter( - (option) => - option.name !== 'managerFilePatterns' && - option.name !== 'enabled', - ) - .flatMap((option) => - (option.parents ?? []) - .filter((parent) => parent !== '.') - .map((parent) => `${parent}.${option.name}`), - ) - .sort(); + return ( + options + .filter((option) => option.stage !== 'global') + .filter((option) => !option.globalOnly) + // Only include true sub-options: options which have parents but none of those are explicitly the root ('.'). + .filter( + (option) => + option.parents && + option.parents.length > 0 && + !option.parents.includes('.'), + ) + .filter((option) => option.name !== 'managerFilePatterns') + .flatMap((option) => + (option.parents ?? []) + .filter((parent) => parent !== '.') + .map((parent) => `${parent}.${option.name}`), + ) + .sort() + ); } function getParentNames(): Set {