From dd3598c5b88a67032a40ad6a34f399274095ce3d Mon Sep 17 00:00:00 2001 From: Gabriel-Ladzaretti <97394622+Gabriel-Ladzaretti@users.noreply.github.com> Date: Sat, 18 Jun 2022 15:12:31 +0300 Subject: [PATCH] feat(config/migration): migrate config with a PR (#15122) Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com> Co-authored-by: Michael Kriese Co-authored-by: Rhys Arkins --- docs/usage/configuration-options.md | 25 ++ lib/config/options/index.ts | 7 + lib/config/types.ts | 5 + lib/util/template/index.ts | 3 + .../branch/__fixtures__/migrated-data.json | 4 + .../branch/__fixtures__/migrated-data.json5 | 4 + .../branch/__fixtures__/migrated.json | 20 ++ .../branch/__fixtures__/renovate.json | 19 ++ .../branch/__fixtures__/renovate.json5 | 19 ++ .../config-migration/branch/commit-message.ts | 46 ++++ .../config-migration/branch/create.spec.ts | 179 +++++++++++++ .../config-migration/branch/create.ts | 43 +++ .../config-migration/branch/index.spec.ts | 98 +++++++ .../config-migration/branch/index.ts | 46 ++++ .../branch/migrated-data.spec.ts | 113 ++++++++ .../config-migration/branch/migrated-data.ts | 76 ++++++ .../config-migration/branch/rebase.spec.ts | 76 ++++++ .../config-migration/branch/rebase.ts | 52 ++++ .../repository/config-migration/common.ts | 8 + .../pr/__fixtures__/migrated-data.json | 4 + .../pr/__snapshots__/index.spec.ts.snap | 92 +++++++ .../pr/errors-warnings.spec.ts | 57 ++++ .../config-migration/pr/errors-warnings.ts | 28 ++ .../config-migration/pr/index.spec.ts | 244 ++++++++++++++++++ .../repository/config-migration/pr/index.ts | 151 +++++++++++ lib/workers/repository/finalise/index.ts | 15 ++ tsconfig.strict.json | 1 + 27 files changed, 1435 insertions(+) create mode 100644 lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json create mode 100644 lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5 create mode 100644 lib/workers/repository/config-migration/branch/__fixtures__/migrated.json create mode 100644 lib/workers/repository/config-migration/branch/__fixtures__/renovate.json create mode 100644 lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5 create mode 100644 lib/workers/repository/config-migration/branch/commit-message.ts create mode 100644 lib/workers/repository/config-migration/branch/create.spec.ts create mode 100644 lib/workers/repository/config-migration/branch/create.ts create mode 100644 lib/workers/repository/config-migration/branch/index.spec.ts create mode 100644 lib/workers/repository/config-migration/branch/index.ts create mode 100644 lib/workers/repository/config-migration/branch/migrated-data.spec.ts create mode 100644 lib/workers/repository/config-migration/branch/migrated-data.ts create mode 100644 lib/workers/repository/config-migration/branch/rebase.spec.ts create mode 100644 lib/workers/repository/config-migration/branch/rebase.ts create mode 100644 lib/workers/repository/config-migration/common.ts create mode 100644 lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json create mode 100644 lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap create mode 100644 lib/workers/repository/config-migration/pr/errors-warnings.spec.ts create mode 100644 lib/workers/repository/config-migration/pr/errors-warnings.ts create mode 100644 lib/workers/repository/config-migration/pr/index.spec.ts create mode 100644 lib/workers/repository/config-migration/pr/index.ts diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 008fdbc6945275..c7857be4a46835 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -417,6 +417,31 @@ If enabled, all issues created by Renovate are set as confidential, even in a pu !!! note This option is applicable to GitLab only. +## configMigration + +If enabled, Renovate will raise a pull request if config file migration is needed. + +We're adding new features to Renovate bot often. +Most times you can keep using your Renovate config and benefit from the new features right away. +But sometimes you need to change your Renovate configuration. +To help you with this, Renovate will create config migration pull requests. + +Example: + +After we changed the [`baseBranches`](https://docs.renovatebot.com/configuration-options/#basebranches) feature, the Renovate configuration migration pull request would make this change: + +```diff +{ +- "baseBranch": "main" ++ "baseBranches": ["main"] +} +``` + + +!!! info + This feature writes plain JSON for `.json` files, and JSON5 for `.json5` files. + JSON5 content can potentially be down leveled (`.json` files) and all comments will be removed. + ## configWarningReuseIssue Renovate's default behavior is to reuse/reopen a single Config Warning issue in each repository so as to keep the "noise" down. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 9b1692ed1fde63..af5c1e2f3eaeaf 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -123,6 +123,13 @@ const options: RenovateOptions[] = [ globalOnly: true, cli: false, }, + { + name: 'configMigration', + description: 'Enable this to get config migration PRs when needed.', + stage: 'repository', + type: 'boolean', + default: false, + }, { name: 'productLinks', description: 'Links which are used in PRs, issues and comments.', diff --git a/lib/config/types.ts b/lib/config/types.ts index 9d8e742462cd3f..d21e6292a9469e 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -186,6 +186,7 @@ export interface RenovateConfig RenovateSharedConfig, UpdateConfig, AssigneesAndReviewersConfig, + ConfigMigration, Record { depName?: string; baseBranches?: string[]; @@ -431,6 +432,10 @@ export interface PackageRuleInputConfig extends Record { packageRules?: (PackageRule & PackageRuleInputConfig)[]; } +export interface ConfigMigration { + configMigration?: boolean; +} + export interface MigratedConfig { isMigrated: boolean; migratedConfig: RenovateConfig; diff --git a/lib/util/template/index.ts b/lib/util/template/index.ts index c4717f6fe3aa5f..6fedb3617249dd 100644 --- a/lib/util/template/index.ts +++ b/lib/util/template/index.ts @@ -143,6 +143,9 @@ const prBodyFields = [ 'table', 'notes', 'changelogs', + 'hasWarningsErrors', + 'errors', + 'warnings', 'configDescription', 'controls', 'footer', diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json new file mode 100644 index 00000000000000..9febe12a31cb7f --- /dev/null +++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json @@ -0,0 +1,4 @@ +{ + "filename": "renovate.json", + "content": "{\n \"extends\": [\n \":separateMajorReleases\",\n \":prImmediately\",\n \":renovatePrefix\",\n \":semanticPrefixFixDepsChoreOthers\",\n \":updateNotScheduled\",\n \":automergeDisabled\",\n \":maintainLockFilesDisabled\",\n \":autodetectPinVersions\",\n \"group:monorepos\"\n ],\n \"onboarding\": false,\n \"rangeStrategy\": \"replace\",\n \"semanticCommits\": \"enabled\",\n \"timezone\": \"US/Central\",\n \"baseBranches\": [\n \"main\"\n ]\n}\n" +} diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5 b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5 new file mode 100644 index 00000000000000..0540d1f8ccb642 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated-data.json5 @@ -0,0 +1,4 @@ +{ + "filename": "renovate.json5", + "content": "{\n extends: [\n ':separateMajorReleases',\n ':prImmediately',\n ':renovatePrefix',\n ':semanticPrefixFixDepsChoreOthers',\n ':updateNotScheduled',\n ':automergeDisabled',\n ':maintainLockFilesDisabled',\n ':autodetectPinVersions',\n 'group:monorepos',\n ],\n onboarding: false,\n rangeStrategy: 'replace',\n semanticCommits: 'enabled',\n timezone: 'US/Central',\n baseBranches: [\n 'main',\n ],\n}\n" +} diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json b/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json new file mode 100644 index 00000000000000..2fd26a80d99e64 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/__fixtures__/migrated.json @@ -0,0 +1,20 @@ +{ + "extends": [ + ":separateMajorReleases", + ":prImmediately", + ":renovatePrefix", + ":semanticPrefixFixDepsChoreOthers", + ":updateNotScheduled", + ":automergeDisabled", + ":maintainLockFilesDisabled", + ":autodetectPinVersions", + "group:monorepos" + ], + "onboarding": false, + "rangeStrategy": "replace", + "semanticCommits": "enabled", + "timezone": "US/Central", + "baseBranches": [ + "main" + ] +} diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json new file mode 100644 index 00000000000000..c7a91ea389e3b2 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json @@ -0,0 +1,19 @@ +{ + "extends": [ + ":separateMajorReleases", + ":prImmediately", + ":renovatePrefix", + ":semanticPrefixFixDepsChoreOthers", + ":updateNotScheduled", + ":automergeDisabled", + ":maintainLockFilesDisabled", + ":autodetectPinVersions", + "group:monorepos", + "helpers:oddIsUnstablePackages" + ], + "onboarding": false, + "pinVersions": false, + "semanticCommits": true, + "timezone": "US/Central", + "baseBranch": "main" +} diff --git a/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5 b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5 new file mode 100644 index 00000000000000..4bcc234e4a0669 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/__fixtures__/renovate.json5 @@ -0,0 +1,19 @@ +{ + extends: [ + ':separateMajorReleases', + ':prImmediately', + ':renovatePrefix', + ':semanticPrefixFixDepsChoreOthers', + ':updateNotScheduled', + ':automergeDisabled', + ':maintainLockFilesDisabled', + ':autodetectPinVersions', + 'group:monorepos', + 'helpers:oddIsUnstablePackages' + ], + onboarding: false, + pinVersions: false, + semanticCommits: true, + timezone: 'US/Central', + baseBranch: 'main' // thats a comment +} diff --git a/lib/workers/repository/config-migration/branch/commit-message.ts b/lib/workers/repository/config-migration/branch/commit-message.ts new file mode 100644 index 00000000000000..0046bac9a0a463 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/commit-message.ts @@ -0,0 +1,46 @@ +import type { RenovateConfig } from '../../../../config/types'; +import * as template from '../../../../util/template'; +import type { CommitMessage } from '../../model/commit-message'; +import { CommitMessageFactory } from '../../model/commit-message-factory'; + +export class ConfigMigrationCommitMessageFactory { + private readonly config: RenovateConfig; + + private readonly configFile: string; + + constructor(config: RenovateConfig, configFile: string) { + this.config = config; + this.configFile = configFile; + } + + create(): CommitMessage { + const { commitMessage } = this.config; + + this.config.commitMessageAction = + this.config.commitMessageAction === 'Update' + ? '' + : this.config.commitMessageAction; + + this.config.commitMessageTopic = + this.config.commitMessageTopic === 'dependency {{depName}}' + ? `Migrate config ${this.configFile}` + : this.config.commitMessageTopic; + + this.config.commitMessageExtra = ''; + this.config.semanticCommitScope = 'config'; + + const commitMessageFactory = new CommitMessageFactory(this.config); + const commit = commitMessageFactory.create(); + + if (commitMessage) { + commit.subject = template.compile(commitMessage, { + ...this.config, + commitMessagePrefix: '', + }); + } else { + commit.subject = `Migrate config ${this.configFile}`; + } + + return commit; + } +} diff --git a/lib/workers/repository/config-migration/branch/create.spec.ts b/lib/workers/repository/config-migration/branch/create.spec.ts new file mode 100644 index 00000000000000..9b599b34d380d6 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/create.spec.ts @@ -0,0 +1,179 @@ +import { Fixtures } from '../../../../../test/fixtures'; +import { RenovateConfig, getConfig, platform } from '../../../../../test/util'; +import { commitFiles } from '../../../../util/git'; +import { createConfigMigrationBranch } from './create'; +import type { MigratedData } from './migrated-data'; + +jest.mock('../../../../util/git'); + +describe('workers/repository/config-migration/branch/create', () => { + const raw = Fixtures.getJson('./renovate.json'); + const indent = ' '; + const renovateConfig = JSON.stringify(raw, undefined, indent) + '\n'; + const filename = 'renovate.json'; + + let config: RenovateConfig; + let migratedConfigData: MigratedData; + + beforeEach(() => { + jest.clearAllMocks(); + config = getConfig(); + migratedConfigData = { content: renovateConfig, filename }; + }); + + describe('createConfigMigrationBranch', () => { + it('applies the default commit message', async () => { + await createConfigMigrationBranch(config, migratedConfigData); + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: 'Migrate config renovate.json', + platformCommit: false, + }); + }); + + it('commits via platform', async () => { + config.platformCommit = true; + + await createConfigMigrationBranch(config, migratedConfigData); + + expect(platform.commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: 'Migrate config renovate.json', + platformCommit: true, + }); + }); + + it('applies supplied commit message', async () => { + const message = 'We can migrate config if we want to, or we can not'; + + config.commitMessage = message; + + await createConfigMigrationBranch(config, migratedConfigData); + + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: message, + platformCommit: false, + }); + }); + + describe('applies the commitMessagePrefix value', () => { + it('to the default commit message', async () => { + config.commitMessagePrefix = 'PREFIX:'; + config.commitMessage = ''; + + const message = `PREFIX: migrate config renovate.json`; + await createConfigMigrationBranch(config, migratedConfigData); + + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: message, + platformCommit: false, + }); + }); + + it('to the supplied commit message prefix, topic & action', async () => { + const prefix = 'PREFIX:'; + const topic = 'thats a topic'; + const action = 'action'; + + const message = `${prefix} ${action} ${topic}`; + + config.commitMessagePrefix = prefix; + config.commitMessageTopic = topic; + config.commitMessageAction = action; + + await createConfigMigrationBranch(config, migratedConfigData); + + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: message, + platformCommit: false, + }); + }); + }); + + describe('applies semanticCommit prefix', () => { + it('to the default commit message', async () => { + const prefix = 'chore(config)'; + const message = `${prefix}: migrate config renovate.json`; + + config.semanticCommits = 'enabled'; + + await createConfigMigrationBranch(config, migratedConfigData); + + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: message, + platformCommit: false, + }); + }); + + it('to the supplied commit message topic', async () => { + const prefix = 'chore(config)'; + const topic = 'supplied topic'; + const message = `${prefix}: ${topic}`; + + config.semanticCommits = 'enabled'; + config.commitMessageTopic = topic; + + await createConfigMigrationBranch(config, migratedConfigData); + + expect(commitFiles).toHaveBeenCalledWith({ + branchName: 'renovate/migrate-config', + files: [ + { + type: 'addition', + path: 'renovate.json', + contents: renovateConfig, + }, + ], + message: message, + platformCommit: false, + }); + }); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/branch/create.ts b/lib/workers/repository/config-migration/branch/create.ts new file mode 100644 index 00000000000000..92d684a2f72c9c --- /dev/null +++ b/lib/workers/repository/config-migration/branch/create.ts @@ -0,0 +1,43 @@ +import { GlobalConfig } from '../../../../config/global'; +import type { RenovateConfig } from '../../../../config/types'; +import { logger } from '../../../../logger'; +import { commitAndPush } from '../../../../modules/platform/commit'; +import { getMigrationBranchName } from '../common'; +import { ConfigMigrationCommitMessageFactory } from './commit-message'; +import type { MigratedData } from './migrated-data'; + +export function createConfigMigrationBranch( + config: Partial, + migratedConfigData: MigratedData +): Promise { + logger.debug('createConfigMigrationBranch()'); + const contents = migratedConfigData.content; + const configFileName = migratedConfigData.filename; + logger.debug('Creating config migration branch'); + + const commitMessageFactory = new ConfigMigrationCommitMessageFactory( + config, + configFileName + ); + + const commitMessage = commitMessageFactory.create(); + + // istanbul ignore if + if (GlobalConfig.get('dryRun')) { + logger.info('DRY-RUN: Would commit files to config migration branch'); + return Promise.resolve(null); + } + + return commitAndPush({ + branchName: getMigrationBranchName(config), + files: [ + { + type: 'addition', + path: configFileName, + contents, + }, + ], + message: commitMessage.toString(), + platformCommit: !!config.platformCommit, + }); +} diff --git a/lib/workers/repository/config-migration/branch/index.spec.ts b/lib/workers/repository/config-migration/branch/index.spec.ts new file mode 100644 index 00000000000000..d7b21af6e20b8c --- /dev/null +++ b/lib/workers/repository/config-migration/branch/index.spec.ts @@ -0,0 +1,98 @@ +import { mock } from 'jest-mock-extended'; +import { Fixtures } from '../../../../../test/fixtures'; +import { + RenovateConfig, + getConfig, + git, + mockedFunction, + platform, +} from '../../../../../test/util'; +import { GlobalConfig } from '../../../../config/global'; +import { logger } from '../../../../logger'; +import type { Pr } from '../../../../modules/platform'; +import { createConfigMigrationBranch } from './create'; +import type { MigratedData } from './migrated-data'; +import { rebaseMigrationBranch } from './rebase'; +import { checkConfigMigrationBranch } from '.'; + +jest.mock('./migrated-data'); +jest.mock('./rebase'); +jest.mock('./create'); +jest.mock('../../../../util/git'); + +const migratedData: MigratedData = Fixtures.getJson('./migrated-data.json'); + +describe('workers/repository/config-migration/branch/index', () => { + describe('checkConfigMigrationBranch', () => { + let config: RenovateConfig; + + beforeEach(() => { + GlobalConfig.set({ + dryRun: null, + }); + jest.resetAllMocks(); + config = getConfig(); + config.branchPrefix = 'some/'; + }); + + it('Exits when Migration is not needed', async () => { + await expect( + checkConfigMigrationBranch(config, null) + ).resolves.toBeNull(); + expect(logger.debug).toHaveBeenCalledWith( + 'checkConfigMigrationBranch() Config does not need migration' + ); + }); + + it('Updates migration branch & refresh PR', async () => { + platform.getBranchPr.mockResolvedValue(mock()); + // platform.refreshPr is undefined as it is an optional function + // declared as: refreshPr?(number: number): Promise; + platform.refreshPr = jest.fn().mockResolvedValueOnce(null); + mockedFunction(rebaseMigrationBranch).mockResolvedValueOnce('committed'); + const res = await checkConfigMigrationBranch(config, migratedData); + expect(res).toBe(`${config.branchPrefix}migrate-config`); + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + expect(logger.debug).toHaveBeenCalledWith( + 'Config Migration PR already exists' + ); + }); + + it('Dry runs update migration branch', async () => { + GlobalConfig.set({ + dryRun: 'full', + }); + platform.getBranchPr.mockResolvedValueOnce(mock()); + mockedFunction(rebaseMigrationBranch).mockResolvedValueOnce('committed'); + const res = await checkConfigMigrationBranch(config, migratedData); + expect(res).toBe(`${config.branchPrefix}migrate-config`); + expect(git.checkoutBranch).toHaveBeenCalledTimes(0); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('Creates migration PR', async () => { + mockedFunction(createConfigMigrationBranch).mockResolvedValueOnce( + 'committed' + ); + const res = await checkConfigMigrationBranch(config, migratedData); + expect(res).toBe(`${config.branchPrefix}migrate-config`); + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + expect(logger.debug).toHaveBeenCalledWith('Need to create migration PR'); + }); + + it('Dry runs create migration PR', async () => { + GlobalConfig.set({ + dryRun: 'full', + }); + mockedFunction(createConfigMigrationBranch).mockResolvedValueOnce( + 'committed' + ); + const res = await checkConfigMigrationBranch(config, migratedData); + expect(res).toBe(`${config.branchPrefix}migrate-config`); + expect(git.checkoutBranch).toHaveBeenCalledTimes(0); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/branch/index.ts b/lib/workers/repository/config-migration/branch/index.ts new file mode 100644 index 00000000000000..8e66b83e361a84 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/index.ts @@ -0,0 +1,46 @@ +import { GlobalConfig } from '../../../../config/global'; +import type { RenovateConfig } from '../../../../config/types'; +import { logger } from '../../../../logger'; +import { platform } from '../../../../modules/platform'; +import { checkoutBranch } from '../../../../util/git'; +import { getMigrationBranchName } from '../common'; +import { createConfigMigrationBranch } from './create'; +import type { MigratedData } from './migrated-data'; +import { rebaseMigrationBranch } from './rebase'; + +export async function checkConfigMigrationBranch( + config: RenovateConfig, + migratedConfigData: MigratedData +): Promise { + logger.debug('checkConfigMigrationBranch()'); + if (!migratedConfigData) { + logger.debug('checkConfigMigrationBranch() Config does not need migration'); + return null; + } + const configMigrationBranch = getMigrationBranchName(config); + if (await migrationPrExists(configMigrationBranch)) { + logger.debug('Config Migration PR already exists'); + await rebaseMigrationBranch(config, migratedConfigData); + + if (platform.refreshPr) { + const configMigrationPr = await platform.getBranchPr( + configMigrationBranch + ); + if (configMigrationPr) { + await platform.refreshPr(configMigrationPr.number); + } + } + } else { + logger.debug('Config Migration PR does not exist'); + logger.debug('Need to create migration PR'); + await createConfigMigrationBranch(config, migratedConfigData); + } + if (!GlobalConfig.get('dryRun')) { + await checkoutBranch(configMigrationBranch); + } + return configMigrationBranch; +} + +export async function migrationPrExists(branchName: string): Promise { + return !!(await platform.getBranchPr(branchName)); +} diff --git a/lib/workers/repository/config-migration/branch/migrated-data.spec.ts b/lib/workers/repository/config-migration/branch/migrated-data.spec.ts new file mode 100644 index 00000000000000..e0aee9ab318266 --- /dev/null +++ b/lib/workers/repository/config-migration/branch/migrated-data.spec.ts @@ -0,0 +1,113 @@ +import detectIndent from 'detect-indent'; +import { Fixtures } from '../../../../../test/fixtures'; +import { mockedFunction } from '../../../../../test/util'; + +import { migrateConfig } from '../../../../config/migration'; +import { readLocalFile } from '../../../../util/fs'; +import { detectRepoFileConfig } from '../../init/merge'; +import { MigratedDataFactory } from './migrated-data'; + +jest.mock('../../../../config/migration'); +jest.mock('../../../../util/fs'); +jest.mock('../../init/merge'); +jest.mock('detect-indent'); + +const rawNonMigrated = Fixtures.get('./renovate.json'); +const rawNonMigratedJson5 = Fixtures.get('./renovate.json5'); +const migratedData = Fixtures.getJson('./migrated-data.json'); +const migratedDataJson5 = Fixtures.getJson('./migrated-data.json5'); +const migratedConfigObj = Fixtures.getJson('./migrated.json'); + +describe('workers/repository/config-migration/branch/migrated-data', () => { + describe('MigratedDataFactory.getAsync', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedFunction(detectIndent).mockReturnValue({ + type: 'space', + amount: 2, + indent: ' ', + }); + mockedFunction(detectRepoFileConfig).mockResolvedValue({ + configFileName: 'renovate.json', + }); + mockedFunction(readLocalFile).mockResolvedValue(rawNonMigrated); + mockedFunction(migrateConfig).mockReturnValue({ + isMigrated: true, + migratedConfig: migratedConfigObj, + }); + }); + + it('Calls getAsync a first when migration not needed', async () => { + mockedFunction(migrateConfig).mockReturnValueOnce({ + isMigrated: false, + migratedConfig: null, + }); + await expect(MigratedDataFactory.getAsync()).resolves.toBeNull(); + }); + + it('Calls getAsync a first time to initialize the factory', async () => { + await expect(MigratedDataFactory.getAsync()).resolves.toEqual( + migratedData + ); + expect(detectRepoFileConfig).toHaveBeenCalledTimes(1); + }); + + it('Calls getAsync a second time to get the saved data from before', async () => { + await expect(MigratedDataFactory.getAsync()).resolves.toEqual( + migratedData + ); + expect(detectRepoFileConfig).toHaveBeenCalledTimes(0); + }); + + describe('MigratedData class', () => { + it('gets the filename from the class instance', async () => { + const data = await MigratedDataFactory.getAsync(); + expect(data.filename).toBe('renovate.json'); + }); + + it('gets the content from the class instance', async () => { + const data = await MigratedDataFactory.getAsync(); + expect(data.content).toBe(migratedData.content); + }); + }); + + it('Resets the factory and gets a new value', async () => { + MigratedDataFactory.reset(); + await expect(MigratedDataFactory.getAsync()).resolves.toEqual( + migratedData + ); + }); + + it('Resets the factory and gets a new value with default indentation', async () => { + mockedFunction(detectIndent).mockReturnValueOnce({ + type: null, + amount: 0, + indent: null, + }); + MigratedDataFactory.reset(); + await expect(MigratedDataFactory.getAsync()).resolves.toEqual( + migratedData + ); + }); + + it('Migrate a JSON5 config file', async () => { + mockedFunction(detectRepoFileConfig).mockResolvedValueOnce({ + configFileName: 'renovate.json5', + }); + mockedFunction(readLocalFile).mockResolvedValueOnce(rawNonMigratedJson5); + MigratedDataFactory.reset(); + await expect(MigratedDataFactory.getAsync()).resolves.toEqual( + migratedDataJson5 + ); + }); + + it('Returns nothing due to fs error', async () => { + mockedFunction(detectRepoFileConfig).mockResolvedValueOnce({ + configFileName: null, + }); + mockedFunction(readLocalFile).mockRejectedValueOnce(null); + MigratedDataFactory.reset(); + await expect(MigratedDataFactory.getAsync()).resolves.toBeNull(); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/branch/migrated-data.ts b/lib/workers/repository/config-migration/branch/migrated-data.ts new file mode 100644 index 00000000000000..9739d940f586ed --- /dev/null +++ b/lib/workers/repository/config-migration/branch/migrated-data.ts @@ -0,0 +1,76 @@ +import detectIndent from 'detect-indent'; +import JSON5 from 'json5'; +import { migrateConfig } from '../../../../config/migration'; +import { logger } from '../../../../logger'; +import { readLocalFile } from '../../../../util/fs'; +import { detectRepoFileConfig } from '../../init/merge'; + +export interface MigratedData { + content: string; + filename: string; +} + +export class MigratedDataFactory { + // singleton + private static data: MigratedData | null; + + public static async getAsync(): Promise { + if (this.data) { + return this.data; + } + const migrated = await this.build(); + + if (!migrated) { + return null; + } + + this.data = migrated; + return this.data; + } + + public static reset(): void { + this.data = null; + } + + private static async build(): Promise { + let res: MigratedData | null = null; + try { + const rc = await detectRepoFileConfig(); + const configFileParsed = rc?.configFileParsed || {}; + + // get migrated config + const { isMigrated, migratedConfig } = migrateConfig(configFileParsed); + if (!isMigrated) { + return null; + } + + delete migratedConfig.errors; + delete migratedConfig.warnings; + + const filename = rc.configFileName ?? ''; + const raw = await readLocalFile(filename, 'utf8'); + + // indent defaults to 2 spaces + const indent = detectIndent(raw).indent ?? ' '; + let content: string; + + if (filename.endsWith('.json5')) { + content = JSON5.stringify(migratedConfig, undefined, indent); + } else { + content = JSON.stringify(migratedConfig, undefined, indent); + } + + if (!content.endsWith('\n')) { + content += '\n'; + } + + res = { content, filename }; + } catch (err) { + logger.debug( + err, + 'MigratedDataFactory.getAsync() Error initializing renovate MigratedData' + ); + } + return res; + } +} diff --git a/lib/workers/repository/config-migration/branch/rebase.spec.ts b/lib/workers/repository/config-migration/branch/rebase.spec.ts new file mode 100644 index 00000000000000..ccdbf3a5f9272d --- /dev/null +++ b/lib/workers/repository/config-migration/branch/rebase.spec.ts @@ -0,0 +1,76 @@ +import { Fixtures } from '../../../../../test/fixtures'; +import { + RenovateConfig, + defaultConfig, + git, + platform, +} from '../../../../../test/util'; +import { GlobalConfig } from '../../../../config/global'; +import type { MigratedData } from './migrated-data'; +import { rebaseMigrationBranch } from './rebase'; + +jest.mock('../../../../util/git'); + +describe('workers/repository/config-migration/branch/rebase', () => { + beforeAll(() => { + GlobalConfig.set({ + localDir: '', + }); + }); + + describe('rebaseMigrationBranch()', () => { + const raw = Fixtures.getJson('./renovate.json'); + const indent = ' '; + const renovateConfig = JSON.stringify(raw, undefined, indent) + '\n'; + const filename = 'renovate.json'; + + let config: RenovateConfig; + let migratedConfigData: MigratedData; + + beforeEach(() => { + jest.resetAllMocks(); + GlobalConfig.reset(); + migratedConfigData = { content: renovateConfig, filename }; + config = { + ...defaultConfig, + repository: 'some/repo', + }; + }); + + it('does not rebase modified branch', async () => { + git.isBranchModified.mockResolvedValueOnce(true); + await rebaseMigrationBranch(config, migratedConfigData); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('does nothing if branch is up to date', async () => { + git.getFile + .mockResolvedValueOnce(renovateConfig) + .mockResolvedValueOnce(renovateConfig); + await rebaseMigrationBranch(config, migratedConfigData); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('rebases migration branch', async () => { + git.isBranchStale.mockResolvedValueOnce(true); + await rebaseMigrationBranch(config, migratedConfigData); + expect(git.commitFiles).toHaveBeenCalledTimes(1); + }); + + it('does not rebases migration branch when in dryRun is on', async () => { + GlobalConfig.set({ + dryRun: 'full', + }); + git.isBranchStale.mockResolvedValueOnce(true); + await rebaseMigrationBranch(config, migratedConfigData); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('rebases via platform', async () => { + config.platformCommit = true; + git.isBranchStale.mockResolvedValueOnce(true); + await rebaseMigrationBranch(config, migratedConfigData); + expect(platform.commitFiles).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/branch/rebase.ts b/lib/workers/repository/config-migration/branch/rebase.ts new file mode 100644 index 00000000000000..b5a3a320f34d9d --- /dev/null +++ b/lib/workers/repository/config-migration/branch/rebase.ts @@ -0,0 +1,52 @@ +import { GlobalConfig } from '../../../../config/global'; +import type { RenovateConfig } from '../../../../config/types'; +import { logger } from '../../../../logger'; +import { commitAndPush } from '../../../../modules/platform/commit'; +import { getFile, isBranchModified, isBranchStale } from '../../../../util/git'; +import { getMigrationBranchName } from '../common'; +import { ConfigMigrationCommitMessageFactory } from './commit-message'; +import type { MigratedData } from './migrated-data'; + +export async function rebaseMigrationBranch( + config: RenovateConfig, + migratedConfigData: MigratedData +): Promise { + logger.debug('Checking if migration branch needs rebasing'); + const branchName = getMigrationBranchName(config); + if (await isBranchModified(branchName)) { + logger.debug('Migration branch has been edited and cannot be rebased'); + return null; + } + const configFileName = migratedConfigData.filename; + const contents = migratedConfigData.content; + const existingContents = await getFile(configFileName, branchName); + if (contents === existingContents && !(await isBranchStale(branchName))) { + logger.debug('Migration branch is up to date'); + return null; + } + logger.debug('Rebasing migration branch'); + + if (GlobalConfig.get('dryRun')) { + logger.info('DRY-RUN: Would rebase files in migration branch'); + return null; + } + + const commitMessageFactory = new ConfigMigrationCommitMessageFactory( + config, + configFileName + ); + const commitMessage = commitMessageFactory.create(); + + return commitAndPush({ + branchName, + files: [ + { + type: 'addition', + path: configFileName, + contents, + }, + ], + message: commitMessage.toString(), + platformCommit: !!config.platformCommit, + }); +} diff --git a/lib/workers/repository/config-migration/common.ts b/lib/workers/repository/config-migration/common.ts new file mode 100644 index 00000000000000..62704c3dbdb508 --- /dev/null +++ b/lib/workers/repository/config-migration/common.ts @@ -0,0 +1,8 @@ +import type { RenovateConfig } from '../../../config/types'; +import * as template from '../../../util/template'; + +const migrationBranchTemplate = '{{{branchPrefix}}}migrate-config'; + +export function getMigrationBranchName(config: RenovateConfig): string { + return template.compile(migrationBranchTemplate, config); +} diff --git a/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json b/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json new file mode 100644 index 00000000000000..a3b883a2f5ff97 --- /dev/null +++ b/lib/workers/repository/config-migration/pr/__fixtures__/migrated-data.json @@ -0,0 +1,4 @@ +{ + "configFileName": "renovate.json", + "migratedContent": "{\n \"extends\": [\n \":separateMajorReleases\",\n \":prImmediately\",\n \":renovatePrefix\",\n \":semanticPrefixFixDepsChoreOthers\",\n \":updateNotScheduled\",\n \":automergeDisabled\",\n \":maintainLockFilesDisabled\",\n \":autodetectPinVersions\",\n \"group:monorepos\"\n ],\n \"onboarding\": false,\n \"rangeStrategy\": \"replace\",\n \"semanticCommits\": \"enabled\",\n \"timezone\": \"US/Central\",\n \"baseBranches\": [\n \"main\"\n ]\n}\n" +} diff --git a/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap b/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap new file mode 100644 index 00000000000000..dcbcd3daed29ac --- /dev/null +++ b/lib/workers/repository/config-migration/pr/__snapshots__/index.spec.ts.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR for JSON5 config file 1`] = ` +"Config migration needed, merge this PR to update your Renovate configuration file. + + + +#### [PLEASE NOTE](https://docs.renovatebot.com/configuration-options#configmigration): JSON5 config file migrated! All comments & trailing commas were removed. +--- +#### Migration completed successfully, No errors or warnings found. +--- + + +❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. +If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions). + +--- + +This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). +" +`; + +exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with empty footer and header 1`] = ` +" + +Config migration needed, merge this PR to update your Renovate configuration file. + + + + +--- +#### Migration completed successfully, No errors or warnings found. +--- + + +❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. +If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions). + +--- + + +" +`; + +exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with footer and header using templating 1`] = ` +"This is a header for platform:github + +Config migration needed, merge this PR to update your Renovate configuration file. + + + + +--- +#### Migration completed successfully, No errors or warnings found. +--- + + +❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. +If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions). + +--- + +And this is a footer for repository:test baseBranch:some-branch +" +`; + +exports[`workers/repository/config-migration/pr/index ensureConfigMigrationPr() creates PR with footer and header with trailing and leading newlines 1`] = ` +" + +This should not be the first line of the PR + +Config migration needed, merge this PR to update your Renovate configuration file. + + + + +--- +#### Migration completed successfully, No errors or warnings found. +--- + + +❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. +If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions). + +--- + +There should be several empty lines at the end of the PR + + + +" +`; diff --git a/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts b/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts new file mode 100644 index 00000000000000..3069e6461eb618 --- /dev/null +++ b/lib/workers/repository/config-migration/pr/errors-warnings.spec.ts @@ -0,0 +1,57 @@ +import { RenovateConfig, getConfig } from '../../../../../test/util'; +import { getErrors, getWarnings } from './errors-warnings'; + +describe('workers/repository/config-migration/pr/errors-warnings', () => { + let config: RenovateConfig; + + beforeEach(() => { + jest.resetAllMocks(); + config = getConfig(); + }); + + describe('getWarnings()', () => { + it('returns warning text', () => { + config.warnings = [ + { + topic: 'WARNING', + message: 'Something went wrong', + }, + ]; + const res = getWarnings(config); + expect(res).toMatchInlineSnapshot(` + " + # Warnings (1) + + Please correct - or verify that you can safely ignore - these warnings before you merge this PR. + + - \`WARNING\`: Something went wrong + + --- + " + `); + }); + }); + + describe('getErrors()', () => { + it('returns error text', () => { + config.errors = [ + { + topic: 'Error', + message: 'An error occurred', + }, + ]; + const res = getErrors(config); + expect(res).toMatchInlineSnapshot(` + " + # Errors (1) + + Renovate has found errors that you should fix (in this branch) before finishing this PR. + + - \`Error\`: An error occurred + + --- + " + `); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/pr/errors-warnings.ts b/lib/workers/repository/config-migration/pr/errors-warnings.ts new file mode 100644 index 00000000000000..6506ec784de290 --- /dev/null +++ b/lib/workers/repository/config-migration/pr/errors-warnings.ts @@ -0,0 +1,28 @@ +import type { RenovateConfig } from '../../../../config/types'; + +export function getWarnings(config: RenovateConfig): string { + if (!config.warnings?.length) { + return ''; + } + let warningText = `\n# Warnings (${config?.warnings.length})\n\n`; + warningText += `Please correct - or verify that you can safely ignore - these warnings before you merge this PR.\n\n`; + config.warnings.forEach((w) => { + warningText += `- \`${w.topic}\`: ${w.message}\n`; + }); + warningText += '\n---\n'; + return warningText; +} + +export function getErrors(config: RenovateConfig): string { + let errorText = ''; + if (!config.errors?.length) { + return ''; + } + errorText = `\n# Errors (${config.errors.length})\n\n`; + errorText += `Renovate has found errors that you should fix (in this branch) before finishing this PR.\n\n`; + config.errors.forEach((e) => { + errorText += `- \`${e.topic}\`: ${e.message}\n`; + }); + errorText += '\n---\n'; + return errorText; +} diff --git a/lib/workers/repository/config-migration/pr/index.spec.ts b/lib/workers/repository/config-migration/pr/index.spec.ts new file mode 100644 index 00000000000000..18abc84bb32808 --- /dev/null +++ b/lib/workers/repository/config-migration/pr/index.spec.ts @@ -0,0 +1,244 @@ +import type { RequestError, Response } from 'got'; +import { mock } from 'jest-mock-extended'; +import { Fixtures } from '../../../../../test/fixtures'; +import { + RenovateConfig, + getConfig, + git, + partial, + platform, +} from '../../../../../test/util'; +import { GlobalConfig } from '../../../../config/global'; +import { logger } from '../../../../logger'; +import type { Pr } from '../../../../modules/platform'; +import { hashBody } from '../../../../modules/platform/pr-body'; +import type { MigratedData } from '../branch/migrated-data'; +import { ensureConfigMigrationPr } from '.'; + +jest.mock('../../../../util/git'); + +describe('workers/repository/config-migration/pr/index', () => { + const spy = jest.spyOn(platform, 'massageMarkdown'); + const { configFileName, migratedContent } = Fixtures.getJson( + './migrated-data.json' + ); + const migratedData: MigratedData = { + content: migratedContent, + filename: configFileName, + }; + let config: RenovateConfig; + + beforeEach(() => { + GlobalConfig.set({ + dryRun: null, + }); + jest.resetAllMocks(); + config = { + ...getConfig(), + configMigration: true, + defaultBranch: 'main', + errors: [], + warnings: [], + description: [], + }; + }); + + describe('ensureConfigMigrationPr()', () => { + beforeEach(() => { + spy.mockImplementation((input) => input); + platform.createPr.mockResolvedValueOnce(partial({})); + }); + + let createPrBody: string; + let hash: string; + + it('creates PR', async () => { + await ensureConfigMigrationPr(config, migratedData); + expect(platform.getBranchPr).toHaveBeenCalledTimes(1); + expect(platform.createPr).toHaveBeenCalledTimes(1); + createPrBody = platform.createPr.mock.calls[0][0].prBody; + }); + + it('creates PR with default PR title', async () => { + await ensureConfigMigrationPr( + { ...config, onboardingPrTitle: null }, + migratedData + ); + expect(platform.getBranchPr).toHaveBeenCalledTimes(1); + expect(platform.createPr).toHaveBeenCalledTimes(1); + createPrBody = platform.createPr.mock.calls[0][0].prBody; + }); + + it('Founds an open PR and as it is up to date and returns', async () => { + hash = hashBody(createPrBody); + platform.getBranchPr.mockResolvedValueOnce( + mock({ bodyStruct: { hash } }) + ); + await ensureConfigMigrationPr(config, migratedData); + expect(platform.updatePr).toHaveBeenCalledTimes(0); + expect(platform.createPr).toHaveBeenCalledTimes(0); + }); + + it('Founds an open PR and updates it', async () => { + platform.getBranchPr.mockResolvedValueOnce( + mock({ bodyStruct: { hash: '' } }) + ); + await ensureConfigMigrationPr(config, migratedData); + expect(platform.updatePr).toHaveBeenCalledTimes(1); + expect(platform.createPr).toHaveBeenCalledTimes(0); + }); + + it('Founds a closed PR and exit', async () => { + platform.getBranchPr.mockResolvedValueOnce(null); + platform.findPr.mockResolvedValueOnce( + mock({ + title: 'Config Migration', + }) + ); + await ensureConfigMigrationPr(config, migratedData); + expect(platform.updatePr).toHaveBeenCalledTimes(0); + expect(platform.createPr).toHaveBeenCalledTimes(0); + expect(logger.debug).toHaveBeenCalledWith( + 'Found closed migration PR, exiting...' + ); + }); + + it('Dry runs and does not update out of date PR', async () => { + GlobalConfig.set({ + dryRun: 'full', + }); + platform.getBranchPr.mockResolvedValueOnce( + mock({ bodyStruct: { hash: '' } }) + ); + await ensureConfigMigrationPr(config, migratedData); + expect(platform.updatePr).toHaveBeenCalledTimes(0); + expect(platform.createPr).toHaveBeenCalledTimes(0); + expect(logger.debug).toHaveBeenCalledWith('Found open migration PR'); + expect(logger.debug).not.toHaveBeenLastCalledWith( + `does not need updating` + ); + expect(logger.info).toHaveBeenLastCalledWith( + 'DRY-RUN: Would update migration PR' + ); + }); + + it('Creates PR in dry run mode', async () => { + GlobalConfig.set({ + dryRun: 'full', + }); + await ensureConfigMigrationPr(config, migratedData); + expect(platform.getBranchPr).toHaveBeenCalledTimes(1); + expect(platform.createPr).toHaveBeenCalledTimes(0); + expect(logger.info).toHaveBeenLastCalledWith( + 'DRY-RUN: Would create migration PR' + ); + }); + + it('creates PR with labels', async () => { + await ensureConfigMigrationPr( + { + ...config, + labels: ['label'], + addLabels: ['label', 'additional-label'], + }, + migratedData + ); + expect(platform.createPr).toHaveBeenCalledTimes(1); + expect(platform.createPr.mock.calls[0][0].labels).toEqual([ + 'label', + 'additional-label', + ]); + }); + + it('creates PR with empty footer and header', async () => { + await ensureConfigMigrationPr( + { + ...config, + prHeader: '', + prFooter: '', + }, + migratedData + ); + expect(platform.createPr).toHaveBeenCalledTimes(1); + expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); + }); + + it('creates PR for JSON5 config file', async () => { + await ensureConfigMigrationPr(config, { + content: migratedContent, + filename: 'renovate.json5', + }); + expect(platform.createPr).toHaveBeenCalledTimes(1); + expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); + }); + + it('creates PR with footer and header with trailing and leading newlines', async () => { + await ensureConfigMigrationPr( + { + ...config, + prHeader: '\r\r\nThis should not be the first line of the PR', + prFooter: + 'There should be several empty lines at the end of the PR\r\n\n\n', + }, + migratedData + ); + expect(platform.createPr).toHaveBeenCalledTimes(1); + expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); + }); + + it('creates PR with footer and header using templating', async () => { + config.baseBranch = 'some-branch'; + config.repository = 'test'; + await ensureConfigMigrationPr( + { + ...config, + prHeader: 'This is a header for platform:{{platform}}', + prFooter: + 'And this is a footer for repository:{{repository}} baseBranch:{{baseBranch}}', + }, + migratedData + ); + expect(platform.createPr).toHaveBeenCalledTimes(1); + expect(platform.createPr.mock.calls[0][0].prBody).toMatch( + /platform:github/ + ); + expect(platform.createPr.mock.calls[0][0].prBody).toMatch( + /repository:test/ + ); + expect(platform.createPr.mock.calls[0][0].prBody).toMatch( + /baseBranch:some-branch/ + ); + expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); + }); + }); + + describe('ensureConfigMigrationPr() throws', () => { + const response = partial({ statusCode: 422 }); + const err = partial({ response }); + + beforeEach(() => { + jest.resetAllMocks(); + GlobalConfig.reset(); + git.deleteBranch.mockResolvedValue(); + }); + + it('throws when trying to create a new PR', async () => { + platform.createPr.mockRejectedValueOnce(err); + await expect(ensureConfigMigrationPr(config, migratedData)).toReject(); + expect(git.deleteBranch).toHaveBeenCalledTimes(0); + }); + + it('deletes branch when PR already exists but cannot find it', async () => { + err.response.body = { + errors: [{ message: 'A pull request already exists' }], + }; + platform.createPr.mockRejectedValue(err); + await expect(ensureConfigMigrationPr(config, migratedData)).toResolve(); + expect(logger.warn).toHaveBeenCalledWith( + { err }, + 'Migration PR already exists but cannot find it. It was probably created by a different user.' + ); + expect(git.deleteBranch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/workers/repository/config-migration/pr/index.ts b/lib/workers/repository/config-migration/pr/index.ts new file mode 100644 index 00000000000000..3c62ab5852cb37 --- /dev/null +++ b/lib/workers/repository/config-migration/pr/index.ts @@ -0,0 +1,151 @@ +import is from '@sindresorhus/is'; +import { GlobalConfig } from '../../../../config/global'; +import type { RenovateConfig } from '../../../../config/types'; +import { logger } from '../../../../logger'; +import { platform } from '../../../../modules/platform'; +import { hashBody } from '../../../../modules/platform/pr-body'; +import { PrState } from '../../../../types'; +import { emojify } from '../../../../util/emoji'; +import { deleteBranch } from '../../../../util/git'; +import * as template from '../../../../util/template'; +import { joinUrlParts } from '../../../../util/url'; +import { getPlatformPrOptions } from '../../update/pr'; +import { prepareLabels } from '../../update/pr/labels'; +import { addParticipants } from '../../update/pr/participants'; +import type { MigratedData } from '../branch/migrated-data'; +import { getMigrationBranchName } from '../common'; +import { getErrors, getWarnings } from './errors-warnings'; + +export async function ensureConfigMigrationPr( + config: RenovateConfig, + migratedConfigData: MigratedData +): Promise { + logger.debug('ensureConfigMigrationPr()'); + const docsLink = joinUrlParts( + config.productLinks?.documentation ?? '', + 'configuration-options/#configmigration' + ); + const branchName = getMigrationBranchName(config); + const prTitle = config.onboardingPrTitle ?? 'Config Migration'; + const existingPr = await platform.getBranchPr(branchName); + const closedPr = await platform.findPr({ + branchName, + prTitle, + state: PrState.Closed, + }); + const filename = migratedConfigData.filename; + logger.debug('Filling in config migration PR template'); + let prTemplate = `Config migration needed, merge this PR to update your Renovate configuration file.\n\n`; + prTemplate += emojify( + ` + +${ + filename.endsWith('.json5') + ? `#### [PLEASE NOTE](${docsLink}): ` + + `JSON5 config file migrated! All comments & trailing commas were removed.` + : '' +} +--- +{{#if hasWarningsErrors}} +{{{warnings}}} +{{{errors}}} +{{else}} +#### Migration completed successfully, No errors or warnings found. +{{/if}} +--- + + +:question: Got questions? Check out Renovate's [Docs](${ + config.productLinks?.documentation + }), particularly the Getting Started section. +If you need any further assistance then you can also [request help here](${ + config.productLinks?.help + }). +` + ); + const warnings = getWarnings(config); + const errors = getErrors(config); + const hasWarningsErrors = warnings || errors; + let prBody = prTemplate; + prBody = template.compile(prBody, { + warnings, + errors, + hasWarningsErrors, + }); + if (is.string(config.prHeader)) { + prBody = `${template.compile(config.prHeader, config)}\n\n${prBody}`; + } + if (is.string(config.prFooter)) { + prBody = `${prBody}\n---\n\n${template.compile(config.prFooter, config)}\n`; + } + logger.trace({ prBody }, 'prBody'); + + prBody = platform.massageMarkdown(prBody); + + if (existingPr) { + logger.debug('Found open migration PR'); + // Check if existing PR needs updating + const prBodyHash = hashBody(prBody); + if (existingPr.bodyStruct?.hash === prBodyHash) { + logger.debug({ pr: existingPr.number }, `Does not need updating`); + return; + } + // PR must need updating + if (GlobalConfig.get('dryRun')) { + logger.info('DRY-RUN: Would update migration PR'); + } else { + await platform.updatePr({ + number: existingPr.number, + prTitle: existingPr.title, + prBody, + }); + logger.info({ pr: existingPr.number }, 'Migration PR updated'); + } + return; + } + if ( + [config.onboardingPrTitle, 'Config Migration'].includes(closedPr?.title) + ) { + logger.debug('Found closed migration PR, exiting...'); + return; + } + logger.debug('Creating migration PR'); + const labels = prepareLabels(config); + try { + if (GlobalConfig.get('dryRun')) { + logger.info('DRY-RUN: Would create migration PR'); + } else { + const pr = await platform.createPr({ + sourceBranch: branchName, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + targetBranch: config.defaultBranch!, + prTitle, + prBody, + labels, + platformOptions: getPlatformPrOptions({ + ...config, + automerge: false, + }), + }); + logger.info({ pr: pr?.number }, 'Migration PR created'); + if (pr) { + await addParticipants(config, pr); + } + } + } catch (err) { + if ( + err.response?.statusCode === 422 && + err.response?.body?.errors?.[0]?.message?.startsWith( + 'A pull request already exists' + ) + ) { + logger.warn( + { err }, + 'Migration PR already exists but cannot find it. It was probably created by a different user.' + ); + await deleteBranch(branchName); + return; + } + throw err; + } +} diff --git a/lib/workers/repository/finalise/index.ts b/lib/workers/repository/finalise/index.ts index 1f239204c183e2..bcc6f79e0fc7f0 100644 --- a/lib/workers/repository/finalise/index.ts +++ b/lib/workers/repository/finalise/index.ts @@ -3,6 +3,9 @@ import { logger } from '../../../logger'; import { platform } from '../../../modules/platform'; import * as repositoryCache from '../../../util/cache/repository'; import { clearRenovateRefs } from '../../../util/git'; +import { checkConfigMigrationBranch } from '../config-migration/branch'; +import { MigratedDataFactory } from '../config-migration/branch/migrated-data'; +import { ensureConfigMigrationPr } from '../config-migration/pr'; import { PackageFiles } from '../package-files'; import { pruneStaleBranches } from './prune'; import { runRenovateRepoStats } from './repository-statistics'; @@ -12,6 +15,18 @@ export async function finaliseRepo( config: RenovateConfig, branchList: string[] ): Promise { + if (config.configMigration) { + const migratedConfigData = await MigratedDataFactory.getAsync(); + const migrationBranch = await checkConfigMigrationBranch( + config, + migratedConfigData + ); // null if migration not needed + if (migrationBranch) { + branchList.push(migrationBranch); + await ensureConfigMigrationPr(config, migratedConfigData); + } + MigratedDataFactory.reset(); + } await repositoryCache.saveCache(); await pruneStaleBranches(config, branchList); await platform.ensureIssueClosing( diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 2516c1b9392735..05ca0fd10bfe86 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -51,6 +51,7 @@ "lib/workers/repository/errors-warnings.ts", "lib/workers/repository/onboarding/pr/index.ts", "lib/workers/repository/onboarding/pr/pr-list.ts", + "lib/workers/repository/config-migration/pr/index.ts", "lib/workers/repository/process/deprecated.ts", "lib/workers/repository/process/extract-update.ts", "lib/workers/repository/process/fetch.ts",