From de289bb4090f7affd0d2d5c6c9fc39a882aff890 Mon Sep 17 00:00:00 2001 From: Gabriel-Ladzaretti <97394622+Gabriel-Ladzaretti@users.noreply.github.com> Date: Wed, 11 Jan 2023 15:59:08 +0200 Subject: [PATCH] feat(core/onboarding): support manual rebase/retry (#17633) Co-authored-by: Michael Kriese --- docs/usage/self-hosted-configuration.md | 2 + lib/config/options/index.ts | 11 + lib/config/types.ts | 1 + lib/util/hasha.ts | 5 + lib/workers/repository/index.ts | 26 ++- .../onboarding/branch/index.spec.ts | 121 +++++++++-- .../repository/onboarding/branch/index.ts | 29 ++- .../onboarding/branch/rebase.spec.ts | 201 ++++++++++++++---- .../repository/onboarding/branch/rebase.ts | 44 +++- lib/workers/repository/onboarding/common.ts | 30 +++ .../pr/__snapshots__/index.spec.ts.snap | 122 ++++++++++- .../repository/onboarding/pr/index.spec.ts | 190 +++++++++++------ lib/workers/repository/onboarding/pr/index.ts | 41 +++- 13 files changed, 675 insertions(+), 148 deletions(-) create mode 100644 lib/util/hasha.ts create mode 100644 lib/workers/repository/onboarding/common.ts diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index 03fb8d3490224e..47b3c2a5b2544d 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -519,6 +519,8 @@ Otherwise, Renovate skips onboarding a repository if it finds no dependencies in Similarly to `onboardingBranch`, if you have an existing Renovate installation and you change `onboardingPrTitle` then it's possible that you'll get onboarding PRs for repositories that had previously closed the onboarding PR unmerged. +## onboardingRebaseCheckbox + ## optimizeForDisabled When this option is `true`, Renovate will do the following during repository initialization: diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 1ef1e0ddd45aaf..7d8c7aa990e1b9 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -399,6 +399,17 @@ const options: RenovateOptions[] = [ globalOnly: true, mergeable: true, }, + { + name: 'onboardingRebaseCheckbox', + description: + 'Set to enable rebase/retry markdown checkbox for onboarding PRs.', + type: 'boolean', + default: false, + supportedPlatforms: ['github', 'gitlab', 'gitea'], + globalOnly: true, + experimental: true, + experimentalIssues: [17633], + }, { name: 'includeForks', description: diff --git a/lib/config/types.ts b/lib/config/types.ts index d21dbe2b9cfc61..1312571d410b79 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -142,6 +142,7 @@ export interface LegacyAdminConfig { onboardingBranch?: string; onboardingCommitMessage?: string; onboardingNoDeps?: boolean; + onboardingRebaseCheckbox?: boolean; onboardingPrTitle?: string; onboardingConfig?: RenovateSharedConfig; onboardingConfigFileName?: string; diff --git a/lib/util/hasha.ts b/lib/util/hasha.ts new file mode 100644 index 00000000000000..a9d9f22b3e3c45 --- /dev/null +++ b/lib/util/hasha.ts @@ -0,0 +1,5 @@ +import hasha from 'hasha'; + +export function toSha256(input: string): string { + return hasha(input, { algorithm: 'sha256' }); +} diff --git a/lib/workers/repository/index.ts b/lib/workers/repository/index.ts index 6489d08c09b689..0d6f4dd0e7af91 100644 --- a/lib/workers/repository/index.ts +++ b/lib/workers/repository/index.ts @@ -19,8 +19,10 @@ import { ensureDependencyDashboard } from './dependency-dashboard'; import handleError from './error'; import { finaliseRepo } from './finalise'; import { initRepo } from './init'; +import { OnboardingState } from './onboarding/common'; import { ensureOnboardingPr } from './onboarding/pr'; import { extractDependencies, updateRepo } from './process'; +import type { ExtractResult } from './process/extract-update'; import { ProcessResult, processResult } from './result'; import { printRequestStats } from './stats'; @@ -46,10 +48,13 @@ export async function renovateRepository( logger.debug('Using localDir: ' + localDir); config = await initRepo(config); addSplit('init'); - const { branches, branchList, packageFiles } = await instrument( - 'extract', - () => extractDependencies(config) - ); + const performExtract = + config.repoIsOnboarded! || + !config.onboardingRebaseCheckbox || + OnboardingState.prUpdateRequested; + const { branches, branchList, packageFiles } = performExtract + ? await instrument('extract', () => extractDependencies(config)) + : emptyExtract(config); if (config.semanticCommits === 'auto') { config.semanticCommits = await detectSemanticCommits(); } @@ -67,7 +72,9 @@ export async function renovateRepository( ); setMeta({ repository: config.repository }); addSplit('update'); - await setBranchCache(branches); + if (performExtract) { + await setBranchCache(branches); // update branch cache if performed extraction + } if (res === 'automerged') { if (canRetry) { logger.info('Renovating repository again after automerge result'); @@ -109,3 +116,12 @@ export async function renovateRepository( logger.info({ cloned, durationMs: splits.total }, 'Repository finished'); return repoResult; } + +// istanbul ignore next: renovateRepository is ignored +function emptyExtract(config: RenovateConfig): ExtractResult { + return { + branches: [], + branchList: [config.onboardingBranch!], // to prevent auto closing + packageFiles: {}, + }; +} diff --git a/lib/workers/repository/onboarding/branch/index.spec.ts b/lib/workers/repository/onboarding/branch/index.spec.ts index 14b20070a1e23b..54f45225beea68 100644 --- a/lib/workers/repository/onboarding/branch/index.spec.ts +++ b/lib/workers/repository/onboarding/branch/index.spec.ts @@ -8,14 +8,17 @@ import { platform, } from '../../../../../test/util'; import { configFileNames } from '../../../../config/app-strings'; +import { GlobalConfig } from '../../../../config/global'; import { REPOSITORY_FORKED, REPOSITORY_NO_PACKAGE_FILES, } from '../../../../constants/error-messages'; import { logger } from '../../../../logger'; import type { Pr } from '../../../../modules/platform'; +import * as memCache from '../../../../util/cache/memory'; import * as _cache from '../../../../util/cache/repository'; import type { FileAddition } from '../../../../util/git/types'; +import { OnboardingState } from '../common'; import * as _config from './config'; import * as _rebase from './rebase'; import { checkOnboardingBranch } from '.'; @@ -36,9 +39,11 @@ describe('workers/repository/onboarding/branch/index', () => { let config: RenovateConfig; beforeEach(() => { + memCache.init(); jest.resetAllMocks(); config = getConfig(); config.repository = 'some/repo'; + OnboardingState.prUpdateRequested = false; git.getFileList.mockResolvedValue([]); cache.getCache.mockReturnValue({}); }); @@ -63,26 +68,36 @@ describe('workers/repository/onboarding/branch/index', () => { ); }); - it('has default onboarding config', async () => { - configModule.getOnboardingConfig.mockResolvedValue( - config.onboardingConfig - ); - configModule.getOnboardingConfigContents.mockResolvedValue( - '{\n' + - ' "$schema": "https://docs.renovatebot.com/renovate-schema.json"\n' + - '}\n' - ); - git.getFileList.mockResolvedValue(['package.json']); - fs.readLocalFile.mockResolvedValue('{}'); - await checkOnboardingBranch(config); - const file = git.commitFiles.mock.calls[0][0].files[0] as FileAddition; - const contents = file.contents?.toString(); - expect(contents).toBeJsonString(); - // TODO #7154 - expect(JSON.parse(contents!)).toEqual({ - $schema: 'https://docs.renovatebot.com/renovate-schema.json', - }); - }); + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'has default onboarding config' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + config.onboardingRebaseCheckbox = checkboxEnabled; + configModule.getOnboardingConfig.mockResolvedValue( + config.onboardingConfig + ); + configModule.getOnboardingConfigContents.mockResolvedValue( + '{\n' + + ' "$schema": "https://docs.renovatebot.com/renovate-schema.json"\n' + + '}\n' + ); + git.getFileList.mockResolvedValue(['package.json']); + fs.readLocalFile.mockResolvedValue('{}'); + await checkOnboardingBranch(config); + const file = git.commitFiles.mock.calls[0][0].files[0] as FileAddition; + const contents = file.contents?.toString(); + expect(contents).toBeJsonString(); + // TODO #7154 + expect(JSON.parse(contents!)).toEqual({ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + }); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } + ); it('uses discovered onboarding config', async () => { configModule.getOnboardingConfig.mockResolvedValue({ @@ -244,5 +259,71 @@ describe('workers/repository/onboarding/branch/index', () => { expect(git.checkoutBranch).toHaveBeenCalledTimes(1); expect(git.commitFiles).toHaveBeenCalledTimes(0); }); + + describe('tests onboarding rebase/retry checkbox handling', () => { + beforeEach(() => { + GlobalConfig.set({ platform: 'github' }); + config.onboardingRebaseCheckbox = true; + OnboardingState.prUpdateRequested = false; + git.getFileList.mockResolvedValueOnce(['package.json']); + platform.findPr.mockResolvedValueOnce(null); + rebase.rebaseOnboardingBranch.mockResolvedValueOnce(null); + }); + + it('detects unsupported platfom', async () => { + const pl = 'bitbucket'; + GlobalConfig.set({ platform: pl }); + platform.getBranchPr.mockResolvedValueOnce(mock({})); + + await checkOnboardingBranch(config); + + expect(logger.trace).toHaveBeenCalledWith( + `Platform '${pl}' does not support extended markdown` + ); + expect(OnboardingState.prUpdateRequested).toBeTrue(); + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('detects missing rebase checkbox', async () => { + const pr = { bodyStruct: { rebaseRequested: undefined } }; + platform.getBranchPr.mockResolvedValueOnce(mock(pr)); + + await checkOnboardingBranch(config); + + expect(logger.debug).toHaveBeenCalledWith( + `No rebase checkbox was found in the onboarding PR` + ); + expect(OnboardingState.prUpdateRequested).toBeTrue(); + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('detects manual pr update requested', async () => { + const pr = { bodyStruct: { rebaseRequested: true } }; + platform.getBranchPr.mockResolvedValueOnce(mock(pr)); + + await checkOnboardingBranch(config); + + expect(logger.debug).toHaveBeenCalledWith( + `Manual onboarding PR update requested` + ); + expect(OnboardingState.prUpdateRequested).toBeTrue(); + ``; + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + + it('handles unchecked rebase checkbox', async () => { + const pr = { bodyStruct: { rebaseRequested: false } }; + platform.getBranchPr.mockResolvedValueOnce(mock(pr)); + + await checkOnboardingBranch(config); + + expect(OnboardingState.prUpdateRequested).toBeFalse(); + expect(git.checkoutBranch).toHaveBeenCalledTimes(1); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + }); + }); }); }); diff --git a/lib/workers/repository/onboarding/branch/index.ts b/lib/workers/repository/onboarding/branch/index.ts index 3707f16426f526..0489d7e1c7adce 100644 --- a/lib/workers/repository/onboarding/branch/index.ts +++ b/lib/workers/repository/onboarding/branch/index.ts @@ -1,3 +1,4 @@ +import is from '@sindresorhus/is'; import { mergeChildConfig } from '../../../../config'; import { GlobalConfig } from '../../../../config/global'; import type { RenovateConfig } from '../../../../config/types'; @@ -6,10 +7,11 @@ import { REPOSITORY_NO_PACKAGE_FILES, } from '../../../../constants/error-messages'; import { logger } from '../../../../logger'; -import { platform } from '../../../../modules/platform'; +import { Pr, platform } from '../../../../modules/platform'; import { checkoutBranch, setGitAuthor } from '../../../../util/git'; import { extractAllDependencies } from '../../extract'; import { mergeRenovateConfig } from '../../init/merge'; +import { OnboardingState } from '../common'; import { getOnboardingPr, isOnboarded } from './check'; import { getOnboardingConfig } from './config'; import { createOnboardingBranch } from './create'; @@ -34,8 +36,12 @@ export async function checkOnboardingBranch( setGitAuthor(config.gitAuthor); const onboardingPr = await getOnboardingPr(config); if (onboardingPr) { + if (config.onboardingRebaseCheckbox) { + handleOnboardingManualRebase(onboardingPr); + } logger.debug('Onboarding PR already exists'); - const commit = await rebaseOnboardingBranch(config); + const { rawConfigHash } = onboardingPr.bodyStruct ?? {}; + const commit = await rebaseOnboardingBranch(config, rawConfigHash); if (commit) { logger.info( { branch: config.onboardingBranch, commit, onboarding: true }, @@ -44,7 +50,6 @@ export async function checkOnboardingBranch( } // istanbul ignore if if (platform.refreshPr) { - // TODO #7154 await platform.refreshPr(onboardingPr.number); } } else { @@ -62,6 +67,9 @@ export async function checkOnboardingBranch( } } logger.debug('Need to create onboarding PR'); + if (config.onboardingRebaseCheckbox) { + OnboardingState.prUpdateRequested = true; + } const commit = await createOnboardingBranch(mergedConfig); // istanbul ignore if if (commit) { @@ -80,3 +88,18 @@ export async function checkOnboardingBranch( const branchList = [onboardingBranch!]; return { ...config, repoIsOnboarded, onboardingBranch, branchList }; } + +function handleOnboardingManualRebase(onboardingPr: Pr): void { + const pl = GlobalConfig.get('platform')!; + const { rebaseRequested } = onboardingPr.bodyStruct ?? {}; + if (!['github', 'gitlab', 'gitea'].includes(pl)) { + logger.trace(`Platform '${pl}' does not support extended markdown`); + OnboardingState.prUpdateRequested = true; + } else if (is.nullOrUndefined(rebaseRequested)) { + logger.debug('No rebase checkbox was found in the onboarding PR'); + OnboardingState.prUpdateRequested = true; + } else if (rebaseRequested) { + logger.debug('Manual onboarding PR update requested'); + OnboardingState.prUpdateRequested = true; + } +} diff --git a/lib/workers/repository/onboarding/branch/rebase.spec.ts b/lib/workers/repository/onboarding/branch/rebase.spec.ts index eeeaf88e11b70b..be1f80cf80cdb1 100644 --- a/lib/workers/repository/onboarding/branch/rebase.spec.ts +++ b/lib/workers/repository/onboarding/branch/rebase.spec.ts @@ -5,6 +5,9 @@ import { platform, } from '../../../../../test/util'; import { GlobalConfig } from '../../../../config/global'; +import * as memCache from '../../../../util/cache/memory'; +import { toSha256 } from '../../../../util/hasha'; +import { OnboardingState } from '../common'; import { rebaseOnboardingBranch } from './rebase'; jest.mock('../../../../util/git'); @@ -18,9 +21,12 @@ describe('workers/repository/onboarding/branch/rebase', () => { describe('rebaseOnboardingBranch()', () => { let config: RenovateConfig; + const hash = ''; beforeEach(() => { + memCache.init(); jest.resetAllMocks(); + OnboardingState.prUpdateRequested = false; config = { ...getConfig(), repository: 'some/repo', @@ -29,61 +35,168 @@ describe('workers/repository/onboarding/branch/rebase', () => { it('does not rebase modified branch', async () => { git.isBranchModified.mockResolvedValueOnce(true); - await rebaseOnboardingBranch(config); + await rebaseOnboardingBranch(config, hash); expect(git.commitFiles).toHaveBeenCalledTimes(0); }); - it('does nothing if branch is up to date', async () => { + it.each` + checkboxEnabled + ${true} + ${false} + `( + 'does nothing if branch is up to date ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled }) => { + config.onboardingRebaseCheckbox = checkboxEnabled; + const contents = + JSON.stringify(getConfig().onboardingConfig, null, 2) + '\n'; + git.getFile + .mockResolvedValueOnce(contents) // package.json + .mockResolvedValueOnce(contents); // renovate.json + await rebaseOnboardingBranch(config, toSha256(contents)); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + expect(OnboardingState.prUpdateRequested).toBeFalse(); + } + ); + + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'rebases onboarding branch ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + config.onboardingRebaseCheckbox = checkboxEnabled; + git.isBranchBehindBase.mockResolvedValueOnce(true); + await rebaseOnboardingBranch(config, hash); + expect(git.commitFiles).toHaveBeenCalledTimes(1); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } + ); + + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'rebases via platform ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + platform.commitFiles = jest.fn(); + config.platformCommit = true; + config.onboardingRebaseCheckbox = checkboxEnabled; + git.isBranchBehindBase.mockResolvedValueOnce(true); + await rebaseOnboardingBranch(config, hash); + expect(platform.commitFiles).toHaveBeenCalledTimes(1); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } + ); + + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'uses the onboardingConfigFileName if set ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + git.isBranchBehindBase.mockResolvedValueOnce(true); + await rebaseOnboardingBranch({ + ...config, + onboardingConfigFileName: '.github/renovate.json', + onboardingRebaseCheckbox: checkboxEnabled, + }); + expect(git.commitFiles).toHaveBeenCalledTimes(1); + expect(git.commitFiles.mock.calls[0][0].message).toContain( + '.github/renovate.json' + ); + expect(git.commitFiles.mock.calls[0][0].files[0].path).toBe( + '.github/renovate.json' + ); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } + ); + + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'falls back to "renovate.json" if onboardingConfigFileName is not set ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + git.isBranchBehindBase.mockResolvedValueOnce(true); + await rebaseOnboardingBranch({ + ...config, + onboardingConfigFileName: undefined, + onboardingRebaseCheckbox: checkboxEnabled, + }); + expect(git.commitFiles).toHaveBeenCalledTimes(1); + expect(git.commitFiles.mock.calls[0][0].message).toContain( + 'renovate.json' + ); + expect(git.commitFiles.mock.calls[0][0].files[0].path).toBe( + 'renovate.json' + ); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } + ); + + describe('handle onboarding config hashes', () => { const contents = JSON.stringify(getConfig().onboardingConfig, null, 2) + '\n'; - git.getFile - .mockResolvedValueOnce(contents) // package.json - .mockResolvedValueOnce(contents); // renovate.json - await rebaseOnboardingBranch(config); - expect(git.commitFiles).toHaveBeenCalledTimes(0); - }); - it('rebases onboarding branch', async () => { - git.isBranchBehindBase.mockResolvedValueOnce(true); - await rebaseOnboardingBranch(config); - expect(git.commitFiles).toHaveBeenCalledTimes(1); - }); + beforeEach(() => { + git.isBranchModified.mockResolvedValueOnce(true); + git.getFile.mockResolvedValueOnce(contents); + }); - it('rebases via platform', async () => { - platform.commitFiles = jest.fn(); - config.platformCommit = true; - git.isBranchBehindBase.mockResolvedValueOnce(true); - await rebaseOnboardingBranch(config); - expect(platform.commitFiles).toHaveBeenCalledTimes(1); - }); + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'handles a missing previous config hash ' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + config.onboardingRebaseCheckbox = checkboxEnabled; + await rebaseOnboardingBranch(config, undefined); - it('uses the onboardingConfigFileName if set', async () => { - git.isBranchBehindBase.mockResolvedValueOnce(true); - await rebaseOnboardingBranch({ - ...config, - onboardingConfigFileName: '.github/renovate.json', - }); - expect(git.commitFiles).toHaveBeenCalledTimes(1); - expect(git.commitFiles.mock.calls[0][0].message).toContain( - '.github/renovate.json' - ); - expect(git.commitFiles.mock.calls[0][0].files[0].path).toBe( - '.github/renovate.json' + expect(OnboardingState.prUpdateRequested).toBe(expected); + } ); - }); - it('falls back to "renovate.json" if onboardingConfigFileName is not set', async () => { - git.isBranchBehindBase.mockResolvedValueOnce(true); - await rebaseOnboardingBranch({ - ...config, - onboardingConfigFileName: undefined, - }); - expect(git.commitFiles).toHaveBeenCalledTimes(1); - expect(git.commitFiles.mock.calls[0][0].message).toContain( - 'renovate.json' + it.each` + checkboxEnabled + ${true} + ${false} + `( + 'does nothing if config hashes match' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + git.getFile.mockResolvedValueOnce(contents); // package.json + config.onboardingRebaseCheckbox = checkboxEnabled; + await rebaseOnboardingBranch(config, toSha256(contents)); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + expect(OnboardingState.prUpdateRequested).toBeFalse(); + } ); - expect(git.commitFiles.mock.calls[0][0].files[0].path).toBe( - 'renovate.json' + + it.each` + checkboxEnabled | expected + ${true} | ${true} + ${false} | ${false} + `( + 'requests update if config hashes mismatch' + + '(config.onboardingRebaseCheckbox="$checkboxEnabled")', + async ({ checkboxEnabled, expected }) => { + git.getFile.mockResolvedValueOnce(contents); // package.json + config.onboardingRebaseCheckbox = checkboxEnabled; + await rebaseOnboardingBranch(config, hash); + expect(git.commitFiles).toHaveBeenCalledTimes(0); + expect(OnboardingState.prUpdateRequested).toBe(expected); + } ); }); }); diff --git a/lib/workers/repository/onboarding/branch/rebase.ts b/lib/workers/repository/onboarding/branch/rebase.ts index 94ca8f1798c93b..2de5ce54387774 100644 --- a/lib/workers/repository/onboarding/branch/rebase.ts +++ b/lib/workers/repository/onboarding/branch/rebase.ts @@ -1,4 +1,4 @@ -import { configFileNames } from '../../../../config/app-strings'; +import is from '@sindresorhus/is'; import { GlobalConfig } from '../../../../config/global'; import type { RenovateConfig } from '../../../../config/types'; import { logger } from '../../../../logger'; @@ -8,27 +8,32 @@ import { isBranchBehindBase, isBranchModified, } from '../../../../util/git'; +import { toSha256 } from '../../../../util/hasha'; +import { OnboardingState, defaultConfigFile } from '../common'; import { OnboardingCommitMessageFactory } from './commit-message'; import { getOnboardingConfigContents } from './config'; -function defaultConfigFile(config: RenovateConfig): string { - return configFileNames.includes(config.onboardingConfigFileName!) - ? config.onboardingConfigFileName! - : configFileNames[0]; -} - export async function rebaseOnboardingBranch( - config: RenovateConfig + config: RenovateConfig, + previousConfigHash?: string ): Promise { logger.debug('Checking if onboarding branch needs rebasing'); + const configFile = defaultConfigFile(config); + const existingContents = + (await getFile(configFile, config.onboardingBranch)) ?? ''; + const currentConfigHash = toSha256(existingContents); + const contents = await getOnboardingConfigContents(config, configFile); + + if (config.onboardingRebaseCheckbox) { + handleOnboardingManualRebase(previousConfigHash, currentConfigHash); + } + // TODO #7154 if (await isBranchModified(config.onboardingBranch!)) { logger.debug('Onboarding branch has been edited and cannot be rebased'); return null; } - const configFile = defaultConfigFile(config); - const existingContents = await getFile(configFile, config.onboardingBranch); - const contents = await getOnboardingConfigContents(config, configFile); + // TODO: fix types (#7154) if ( contents === existingContents && @@ -37,7 +42,11 @@ export async function rebaseOnboardingBranch( logger.debug('Onboarding branch is up to date'); return null; } + logger.debug('Rebasing onboarding branch'); + if (config.onboardingRebaseCheckbox) { + OnboardingState.prUpdateRequested = true; + } // istanbul ignore next const commitMessageFactory = new OnboardingCommitMessageFactory( config, @@ -65,3 +74,16 @@ export async function rebaseOnboardingBranch( platformCommit: !!config.platformCommit, }); } + +function handleOnboardingManualRebase( + previousConfigHash: string | undefined, + currentConfigHash: string +): void { + if (is.nullOrUndefined(previousConfigHash)) { + logger.debug('Missing previousConfigHash bodyStruct prop in onboarding PR'); + OnboardingState.prUpdateRequested = true; + } else if (previousConfigHash !== currentConfigHash) { + logger.debug('Onboarding config has been modified by the user'); + OnboardingState.prUpdateRequested = true; + } +} diff --git a/lib/workers/repository/onboarding/common.ts b/lib/workers/repository/onboarding/common.ts new file mode 100644 index 00000000000000..5738c3d09d33af --- /dev/null +++ b/lib/workers/repository/onboarding/common.ts @@ -0,0 +1,30 @@ +import { configFileNames } from '../../../config/app-strings'; +import type { RenovateConfig } from '../../../config/types'; +import { logger } from '../../../logger'; +import * as memCache from '../../../util/cache/memory'; + +export function defaultConfigFile(config: RenovateConfig): string { + return configFileNames.includes(config.onboardingConfigFileName!) + ? config.onboardingConfigFileName! + : configFileNames[0]; +} + +export class OnboardingState { + private static readonly cacheKey = 'OnboardingState'; + + static get prUpdateRequested(): boolean { + const updateRequested = !!memCache.get( + OnboardingState.cacheKey + ); + logger.trace( + { value: updateRequested }, + 'Get OnboardingState.prUpdateRequested' + ); + return updateRequested; + } + + static set prUpdateRequested(value: boolean) { + logger.trace({ value }, 'Set OnboardingState.prUpdateRequested'); + memCache.set(OnboardingState.cacheKey, value); + } +} diff --git a/lib/workers/repository/onboarding/pr/__snapshots__/index.spec.ts.snap b/lib/workers/repository/onboarding/pr/__snapshots__/index.spec.ts.snap index 59b7ac89742b7d..f8041b7fcb64dd 100644 --- a/lib/workers/repository/onboarding/pr/__snapshots__/index.spec.ts.snap +++ b/lib/workers/repository/onboarding/pr/__snapshots__/index.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with empty footer and header 1`] = ` +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with empty footer and header(onboardingRebaseCheckbox="false") 1`] = ` " Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. @@ -30,7 +30,44 @@ If you need any further assistance then you can also [request help here](https:/ " `; -exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header using templating 1`] = ` +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with empty footer and header(onboardingRebaseCheckbox="true") 1`] = ` +" + +Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. + +🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged. + + + +--- +### Detected Package Files + + * \`package.json\` (npm) + +### What to Expect + +It looks like your repository dependencies are already up-to-date and no Pull Requests will be necessary right away. + +--- + +❓ 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). + + +--- + + - [ ] If you want to rebase/retry this PR, click this checkbox. + + +--- + + + + +" +`; + +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header using templating(onboardingRebaseCheckbox="false") 1`] = ` "This is a header for platform:github Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. @@ -60,7 +97,44 @@ And this is a footer for repository:test baseBranch:some-branch " `; -exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header with trailing and leading newlines 1`] = ` +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header using templating(onboardingRebaseCheckbox="true") 1`] = ` +"This is a header for platform:github + +Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. + +🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged. + + + +--- +### Detected Package Files + + * \`package.json\` (npm) + +### What to Expect + +It looks like your repository dependencies are already up-to-date and no Pull Requests will be necessary right away. + +--- + +❓ 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). + + +--- + + - [ ] If you want to rebase/retry this PR, click this checkbox. + + +--- + +And this is a footer for repository:test baseBranch:some-branch + + +" +`; + +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header with trailing and leading newlines(onboardingRebaseCheckbox="false") 1`] = ` " This should not be the first line of the PR @@ -92,5 +166,47 @@ There should be several empty lines at the end of the PR +" +`; + +exports[`workers/repository/onboarding/pr/index ensureOnboardingPr() creates PR with footer and header with trailing and leading newlines(onboardingRebaseCheckbox="true") 1`] = ` +" + +This should not be the first line of the PR + +Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. + +🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged. + + + +--- +### Detected Package Files + + * \`package.json\` (npm) + +### What to Expect + +It looks like your repository dependencies are already up-to-date and no Pull Requests will be necessary right away. + +--- + +❓ 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). + + +--- + + - [ ] If you want to rebase/retry this PR, click this checkbox. + + +--- + +There should be several empty lines at the end of the PR + + + + + " `; diff --git a/lib/workers/repository/onboarding/pr/index.spec.ts b/lib/workers/repository/onboarding/pr/index.spec.ts index cb81683a1dcf9b..8ba376a8800361 100644 --- a/lib/workers/repository/onboarding/pr/index.spec.ts +++ b/lib/workers/repository/onboarding/pr/index.spec.ts @@ -10,7 +10,9 @@ import { GlobalConfig } from '../../../../config/global'; import { logger } from '../../../../logger'; import type { PackageFile } from '../../../../modules/manager/types'; import type { Pr } from '../../../../modules/platform'; +import * as memCache from '../../../../util/cache/memory'; import type { BranchConfig } from '../../../types'; +import { OnboardingState } from '../common'; import { ensureOnboardingPr } from '.'; jest.mock('../../../../util/git'); @@ -22,10 +24,11 @@ describe('workers/repository/onboarding/pr/index', () => { let branches: BranchConfig[]; const bodyStruct = { - hash: '8d5d8373c3fc54803f573ea57ded60686a9df8eb0430ad25da281472eed9ce4e', + hash: '6aa71f8cb7b1503b883485c8f5bd564b31923b9c7fa765abe2a7338af40e03b1', }; beforeEach(() => { + memCache.init(); jest.resetAllMocks(); config = { ...getConfig(), @@ -45,8 +48,31 @@ describe('workers/repository/onboarding/pr/index', () => { await expect( ensureOnboardingPr(config, packageFiles, branches) ).resolves.not.toThrow(); + expect(platform.createPr).toHaveBeenCalledTimes(0); + expect(platform.updatePr).toHaveBeenCalledTimes(0); }); + it.each` + onboardingRebaseCheckbox | prUpdateRequested | expected + ${false} | ${false} | ${1} + ${false} | ${true} | ${1} + ${true} | ${false} | ${0} + ${true} | ${true} | ${1} + `( + 'breaks early when onboarding ' + + '(onboardingRebaseCheckbox="$onboardingRebaseCheckbox", prUpdateRequeste="$prUpdateRequested" )', + async ({ onboardingRebaseCheckbox, prUpdateRequested, expected }) => { + config.repoIsOnboarded = false; + config.onboardingRebaseCheckbox = onboardingRebaseCheckbox; + OnboardingState.prUpdateRequested = prUpdateRequested; + await expect( + ensureOnboardingPr(config, packageFiles, branches) + ).resolves.not.toThrow(); + expect(platform.updatePr).toHaveBeenCalledTimes(0); + expect(platform.createPr).toHaveBeenCalledTimes(expected); + } + ); + it('creates PR', async () => { await ensureOnboardingPr(config, packageFiles, branches); expect(platform.createPr).toHaveBeenCalledTimes(1); @@ -69,69 +95,111 @@ describe('workers/repository/onboarding/pr/index', () => { ]); }); - it('creates PR with empty footer and header', async () => { - await ensureOnboardingPr( - { - ...config, - prHeader: '', - prFooter: '', - }, - packageFiles, - branches - ); - expect(platform.createPr).toHaveBeenCalledTimes(1); - expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); - }); + it.each` + onboardingRebaseCheckbox + ${false} + ${true} + `( + 'creates PR with empty footer and header' + + '(onboardingRebaseCheckbox="$onboardingRebaseCheckbox")', + async ({ onboardingRebaseCheckbox }) => { + config.onboardingRebaseCheckbox = onboardingRebaseCheckbox; + OnboardingState.prUpdateRequested = true; // case 'false' is tested in "breaks early when onboarding" + await ensureOnboardingPr( + { + ...config, + prHeader: '', + prFooter: '', + }, + packageFiles, + branches + ); + 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 ensureOnboardingPr( - { - ...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', - }, - packageFiles, - branches - ); - expect(platform.createPr).toHaveBeenCalledTimes(1); - expect(platform.createPr.mock.calls[0][0].prBody).toMatchSnapshot(); - }); + it.each` + onboardingRebaseCheckbox + ${false} + ${true} + `( + 'creates PR with footer and header with trailing and leading newlines' + + '(onboardingRebaseCheckbox="$onboardingRebaseCheckbox")', + async ({ onboardingRebaseCheckbox }) => { + config.onboardingRebaseCheckbox = onboardingRebaseCheckbox; + OnboardingState.prUpdateRequested = true; // case 'false' is tested in "breaks early when onboarding" + await ensureOnboardingPr( + { + ...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', + }, + packageFiles, + branches + ); + 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 ensureOnboardingPr( - { - ...config, - prHeader: 'This is a header for platform:{{platform}}', - prFooter: - 'And this is a footer for repository:{{repository}} baseBranch:{{baseBranch}}', - }, - packageFiles, - branches - ); - 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).toMatchSnapshot(); - }); + it.each` + onboardingRebaseCheckbox + ${false} + ${true} + `( + 'creates PR with footer and header using templating' + + '(onboardingRebaseCheckbox="$onboardingRebaseCheckbox")', + async ({ onboardingRebaseCheckbox }) => { + config.baseBranch = 'some-branch'; + config.repository = 'test'; + config.onboardingRebaseCheckbox = onboardingRebaseCheckbox; + OnboardingState.prUpdateRequested = true; // case 'false' is tested in "breaks early when onboarding" + await ensureOnboardingPr( + { + ...config, + prHeader: 'This is a header for platform:{{platform}}', + prFooter: + 'And this is a footer for repository:{{repository}} baseBranch:{{baseBranch}}', + }, + packageFiles, + branches + ); + 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).toMatchSnapshot(); + } + ); - it('returns if PR does not need updating', async () => { - platform.getBranchPr.mockResolvedValue( - partial({ - title: 'Configure Renovate', - bodyStruct, - }) - ); - await ensureOnboardingPr(config, packageFiles, branches); - expect(platform.createPr).toHaveBeenCalledTimes(0); - expect(platform.updatePr).toHaveBeenCalledTimes(0); - }); + it.each` + onboardingRebaseCheckbox + ${false} + ${true} + `( + 'returns if PR does not need updating' + + '(onboardingRebaseCheckbox="$onboardingRebaseCheckbox")', + async ({ onboardingRebaseCheckbox }) => { + const hash = + '8d5d8373c3fc54803f573ea57ded60686a9df8eb0430ad25da281472eed9ce4e'; // no rebase checkbox PR hash + config.onboardingRebaseCheckbox = onboardingRebaseCheckbox; + OnboardingState.prUpdateRequested = true; // case 'false' is tested in "breaks early when onboarding" + platform.getBranchPr.mockResolvedValue( + partial({ + title: 'Configure Renovate', + bodyStruct: onboardingRebaseCheckbox ? bodyStruct : { hash }, + }) + ); + await ensureOnboardingPr(config, packageFiles, branches); + expect(platform.createPr).toHaveBeenCalledTimes(0); + expect(platform.updatePr).toHaveBeenCalledTimes(0); + } + ); it('updates PR when conflicted', async () => { config.baseBranch = 'some-branch'; diff --git a/lib/workers/repository/onboarding/pr/index.ts b/lib/workers/repository/onboarding/pr/index.ts index 1c1c41f35e2ee5..e562fe95ea5cf6 100644 --- a/lib/workers/repository/onboarding/pr/index.ts +++ b/lib/workers/repository/onboarding/pr/index.ts @@ -8,9 +8,11 @@ import { hashBody } from '../../../../modules/platform/pr-body'; import { emojify } from '../../../../util/emoji'; import { deleteBranch, + getFile, isBranchConflicted, isBranchModified, } from '../../../../util/git'; +import { toSha256 } from '../../../../util/hasha'; import * as template from '../../../../util/template'; import type { BranchConfig } from '../../../types'; import { @@ -21,6 +23,7 @@ import { import { getPlatformPrOptions } from '../../update/pr'; import { prepareLabels } from '../../update/pr/labels'; import { addParticipants } from '../../update/pr/participants'; +import { OnboardingState, defaultConfigFile } from '../common'; import { getBaseBranchDesc } from './base-branch'; import { getConfigDesc } from './config-description'; import { getPrList } from './pr-list'; @@ -30,13 +33,18 @@ export async function ensureOnboardingPr( packageFiles: Record | null, branches: BranchConfig[] ): Promise { - if (config.repoIsOnboarded) { + if ( + config.repoIsOnboarded || + (config.onboardingRebaseCheckbox && !OnboardingState.prUpdateRequested) + ) { return; } logger.debug('ensureOnboardingPr()'); logger.trace({ config }); // TODO #7154 const existingPr = await platform.getBranchPr(config.onboardingBranch!); + const { rebaseCheckBox, renovateConfigHashComment } = + await getRebaseCheckboxComponents(config); logger.debug('Filling in onboarding PR template'); let prTemplate = `Welcome to [Renovate](${ config.productLinks!.homepage @@ -71,6 +79,7 @@ If you need any further assistance then you can also [request help here](${ }). ` ); + prTemplate += rebaseCheckBox; let prBody = prTemplate; if (packageFiles && Object.entries(packageFiles).length) { let files: string[] = []; @@ -126,6 +135,9 @@ If you need any further assistance then you can also [request help here](${ if (is.string(config.prFooter)) { prBody = `${prBody}\n---\n\n${template.compile(config.prFooter, config)}\n`; } + + prBody += renovateConfigHashComment; + logger.trace('prBody:\n' + prBody); prBody = platform.massageMarkdown(prBody); @@ -186,3 +198,30 @@ If you need any further assistance then you can also [request help here](${ throw err; } } + +interface RebaseCheckboxComponents { + rebaseCheckBox: string; + renovateConfigHashComment: string; +} + +async function getRebaseCheckboxComponents( + config: RenovateConfig +): Promise { + let rebaseCheckBox = ''; + let renovateConfigHashComment = ''; + if (!config.onboardingRebaseCheckbox) { + return { rebaseCheckBox, renovateConfigHashComment }; + } + + // Create markdown checkbox + rebaseCheckBox = `\n\n---\n\n - [ ] If you want to rebase/retry this PR, click this checkbox.\n`; + + // Create hashMeta + const configFile = defaultConfigFile(config); + const existingContents = + (await getFile(configFile, config.onboardingBranch)) ?? ''; + const hash = toSha256(existingContents); + renovateConfigHashComment = `\n\n`; + + return { rebaseCheckBox, renovateConfigHashComment }; +}