feat(config): custom status checks (#26047)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
This commit is contained in:
RahulGautamSingh 2023-12-14 22:05:50 +05:45 committed by GitHub
parent 376fefd159
commit 3ed295cf94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 350 additions and 46 deletions

View file

@ -3584,6 +3584,23 @@ Configure this to `true` if you wish to get one PR for every separate major vers
e.g. if you are on webpack@v1 currently then default behavior is a PR for upgrading to webpack@v3 and not for webpack@v2. e.g. if you are on webpack@v1 currently then default behavior is a PR for upgrading to webpack@v3 and not for webpack@v2.
If this setting is true then you would get one PR for webpack@v2 and one for webpack@v3. If this setting is true then you would get one PR for webpack@v2 and one for webpack@v3.
## statusCheckNames
You can customize the name/context of status checks that Renovate adds to commits/branches/PRs.
This option enables you to modify any existing status checks name/context, but adding new status checks this way is _not_ supported.
Setting the value to `null` or an empty string, effectively disables or skips that status check.
This option is mergeable, which means you only have to specify the status checks that you want to modify.
```json title="Example of overriding status check strings"
{
"statusCheckNames": {
"minimumReleaseAge": "custom/stability-days",
"mergeConfidence": "custom/merge-confidence-level"
}
}
```
## stopUpdatingLabel ## stopUpdatingLabel
This feature only works on supported platforms, check the table above. This feature only works on supported platforms, check the table above.

View file

@ -170,6 +170,19 @@ const options: RenovateOptions[] = [
type: 'string', type: 'string',
}, },
}, },
{
name: 'statusCheckNames',
description: 'Custom strings to use as status check names.',
type: 'object',
mergeable: true,
advancedUse: true,
default: {
artifactError: 'renovate/artifacts',
configValidation: 'renovate/config-validation',
mergeConfidence: 'renovate/merge-confidence',
minimumReleaseAge: 'renovate/stability-days',
},
},
{ {
name: 'extends', name: 'extends',
description: 'Configuration presets to use or extend.', description: 'Configuration presets to use or extend.',

View file

@ -190,6 +190,14 @@ export type RenovateRepository =
export type UseBaseBranchConfigType = 'merge' | 'none'; export type UseBaseBranchConfigType = 'merge' | 'none';
export type ConstraintsFilter = 'strict' | 'none'; export type ConstraintsFilter = 'strict' | 'none';
export const allowedStatusCheckStrings = [
'minimumReleaseAge',
'mergeConfidence',
'configValidation',
'artifactError',
] as const;
export type StatusCheckKey = (typeof allowedStatusCheckStrings)[number];
// TODO: Proper typings // TODO: Proper typings
export interface RenovateConfig export interface RenovateConfig
extends LegacyAdminConfig, extends LegacyAdminConfig,
@ -261,6 +269,8 @@ export interface RenovateConfig
checkedBranches?: string[]; checkedBranches?: string[];
customizeDashboard?: Record<string, string>; customizeDashboard?: Record<string, string>;
statusCheckNames?: Record<StatusCheckKey, string | null>;
} }
const CustomDatasourceFormats = ['json', 'plain', 'yaml', 'html'] as const; const CustomDatasourceFormats = ['json', 'plain', 'yaml', 'html'] as const;

View file

@ -144,6 +144,30 @@ describe('config/validation', () => {
]); ]);
}); });
it('validates invalid statusCheckNames', async () => {
const config = {
statusCheckNames: {
randomKey: '',
mergeConfidence: 10,
configValidation: '',
artifactError: null,
},
};
// @ts-expect-error invalid options
const { errors } = await configValidation.validateConfig(config);
expect(errors).toMatchObject([
{
message:
'Invalid `statusCheckNames.mergeConfidence` configuration: status check is not a string.',
},
{
message:
'Invalid `statusCheckNames.statusCheckNames.randomKey` configuration: key is not allowed.',
},
]);
expect(errors).toHaveLength(2);
});
it('catches invalid customDatasources record type', async () => { it('catches invalid customDatasources record type', async () => {
const config = { const config = {
customDatasources: { customDatasources: {

View file

@ -16,11 +16,13 @@ import {
import { migrateConfig } from './migration'; import { migrateConfig } from './migration';
import { getOptions } from './options'; import { getOptions } from './options';
import { resolveConfigPresets } from './presets'; import { resolveConfigPresets } from './presets';
import type { import {
RenovateConfig, type RenovateConfig,
RenovateOptions, type RenovateOptions,
ValidationMessage, type StatusCheckKey,
ValidationResult, type ValidationMessage,
type ValidationResult,
allowedStatusCheckStrings,
} from './types'; } from './types';
import * as managerValidator from './validation-helpers/managers'; import * as managerValidator from './validation-helpers/managers';
@ -563,6 +565,30 @@ export async function validateConfig(
message: `Invalid \`${currentPath}.${key}.${res}\` configuration: value is not a string`, message: `Invalid \`${currentPath}.${key}.${res}\` configuration: value is not a string`,
}); });
} }
} else if (key === 'statusCheckNames') {
for (const [statusCheckKey, statusCheckValue] of Object.entries(
val,
)) {
if (
!allowedStatusCheckStrings.includes(
statusCheckKey as StatusCheckKey,
)
) {
errors.push({
topic: 'Configuration Error',
message: `Invalid \`${currentPath}.${key}.${statusCheckKey}\` configuration: key is not allowed.`,
});
}
if (
!(is.string(statusCheckValue) || is.null_(statusCheckValue))
) {
errors.push({
topic: 'Configuration Error',
message: `Invalid \`${currentPath}.${statusCheckKey}\` configuration: status check is not a string.`,
});
continue;
}
}
} else if (key === 'customDatasources') { } else if (key === 'customDatasources') {
const allowedKeys = [ const allowedKeys = [
'description', 'description',

View file

@ -4,6 +4,7 @@ import {
fs, fs,
git, git,
mocked, mocked,
partial,
platform, platform,
scm, scm,
} from '../../../../test/util'; } from '../../../../test/util';
@ -26,6 +27,9 @@ describe('workers/repository/reconfigure/index', () => {
const config: RenovateConfig = { const config: RenovateConfig = {
branchPrefix: 'prefix/', branchPrefix: 'prefix/',
baseBranch: 'base', baseBranch: 'base',
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
configValidation: 'renovate/config-validation',
}),
}; };
beforeEach(() => { beforeEach(() => {
@ -151,6 +155,46 @@ describe('workers/repository/reconfigure/index', () => {
}); });
}); });
it('skips adding status check if statusCheckNames.configValidation is null', async () => {
cache.getCache.mockReturnValueOnce({
reconfigureBranchCache: {
reconfigureBranchSha: 'new-sha',
isConfigValid: false,
},
});
await validateReconfigureBranch({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
configValidation: null,
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips adding status check if statusCheckNames.configValidation is empty string', async () => {
cache.getCache.mockReturnValueOnce({
reconfigureBranchCache: {
reconfigureBranchSha: 'new-sha',
isConfigValid: false,
},
});
await validateReconfigureBranch({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
configValidation: '',
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips validation if cache is valid', async () => { it('skips validation if cache is valid', async () => {
cache.getCache.mockReturnValueOnce({ cache.getCache.mockReturnValueOnce({
reconfigureBranchCache: { reconfigureBranchCache: {
@ -174,7 +218,7 @@ describe('workers/repository/reconfigure/index', () => {
platform.getBranchStatusCheck.mockResolvedValueOnce('green'); platform.getBranchStatusCheck.mockResolvedValueOnce('green');
await validateReconfigureBranch(config); await validateReconfigureBranch(config);
expect(logger.debug).toHaveBeenCalledWith( expect(logger.debug).toHaveBeenCalledWith(
'Skipping validation check as status check already exists', 'Skipping validation check because status check already exists.',
); );
}); });

View file

@ -6,6 +6,7 @@ import { logger } from '../../../logger';
import { platform } from '../../../modules/platform'; import { platform } from '../../../modules/platform';
import { ensureComment } from '../../../modules/platform/comment'; import { ensureComment } from '../../../modules/platform/comment';
import { scm } from '../../../modules/platform/scm'; import { scm } from '../../../modules/platform/scm';
import type { BranchStatus } from '../../../types';
import { getCache } from '../../../util/cache/repository'; import { getCache } from '../../../util/cache/repository';
import { readLocalFile } from '../../../util/fs'; import { readLocalFile } from '../../../util/fs';
import { getBranchCommit } from '../../../util/git'; import { getBranchCommit } from '../../../util/git';
@ -16,6 +17,25 @@ import {
setReconfigureBranchCache, setReconfigureBranchCache,
} from './reconfigure-cache'; } from './reconfigure-cache';
async function setBranchStatus(
branchName: string,
description: string,
state: BranchStatus,
context?: string | null,
): Promise<void> {
if (!is.nonEmptyString(context)) {
// already logged this case when validating the status check
return;
}
await platform.setBranchStatus({
branchName,
context,
description,
state,
});
}
export function getReconfigureBranchName(prefix: string): string { export function getReconfigureBranchName(prefix: string): string {
return `${prefix}reconfigure`; return `${prefix}reconfigure`;
} }
@ -23,7 +43,7 @@ export async function validateReconfigureBranch(
config: RenovateConfig, config: RenovateConfig,
): Promise<void> { ): Promise<void> {
logger.debug('validateReconfigureBranch()'); logger.debug('validateReconfigureBranch()');
const context = `renovate/config-validation`; const context = config.statusCheckNames?.configValidation;
const branchName = getReconfigureBranchName(config.branchPrefix!); const branchName = getReconfigureBranchName(config.branchPrefix!);
const branchExists = await scm.branchExists(branchName); const branchExists = await scm.branchExists(branchName);
@ -48,15 +68,24 @@ export async function validateReconfigureBranch(
return; return;
} }
if (context) {
const validationStatus = await platform.getBranchStatusCheck( const validationStatus = await platform.getBranchStatusCheck(
branchName, branchName,
'renovate/config-validation', context,
); );
// if old status check is present skip validation // if old status check is present skip validation
if (is.nonEmptyString(validationStatus)) { if (is.nonEmptyString(validationStatus)) {
logger.debug('Skipping validation check as status check already exists'); logger.debug(
'Skipping validation check because status check already exists.',
);
return; return;
} }
} else {
logger.debug(
'Status check is null or an empty string, skipping status check addition.',
);
}
try { try {
await scm.checkoutBranch(branchName); await scm.checkoutBranch(branchName);
@ -70,12 +99,12 @@ export async function validateReconfigureBranch(
if (!is.nonEmptyString(configFileName)) { if (!is.nonEmptyString(configFileName)) {
logger.warn('No config file found in reconfigure branch'); logger.warn('No config file found in reconfigure branch');
await platform.setBranchStatus({ await setBranchStatus(
branchName, branchName,
'Validation Failed - No config file found',
'red',
context, context,
description: 'Validation Failed - No config file found', );
state: 'red',
});
setReconfigureBranchCache(branchSha, false); setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.defaultBranch!); await scm.checkoutBranch(config.defaultBranch!);
return; return;
@ -90,12 +119,12 @@ export async function validateReconfigureBranch(
if (!is.nonEmptyString(configFileRaw)) { if (!is.nonEmptyString(configFileRaw)) {
logger.warn('Empty or invalid config file'); logger.warn('Empty or invalid config file');
await platform.setBranchStatus({ await setBranchStatus(
branchName, branchName,
'Validation Failed - Empty/Invalid config file',
'red',
context, context,
description: 'Validation Failed - Empty/Invalid config file', );
state: 'red',
});
setReconfigureBranchCache(branchSha, false); setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!); await scm.checkoutBranch(config.baseBranch!);
return; return;
@ -110,12 +139,12 @@ export async function validateReconfigureBranch(
} }
} catch (err) { } catch (err) {
logger.error({ err }, 'Error while parsing config file'); logger.error({ err }, 'Error while parsing config file');
await platform.setBranchStatus({ await setBranchStatus(
branchName, branchName,
'Validation Failed - Unparsable config file',
'red',
context, context,
description: 'Validation Failed - Unparsable config file', );
state: 'red',
});
setReconfigureBranchCache(branchSha, false); setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!); await scm.checkoutBranch(config.baseBranch!);
return; return;
@ -150,24 +179,14 @@ export async function validateReconfigureBranch(
content: body, content: body,
}); });
} }
await platform.setBranchStatus({ await setBranchStatus(branchName, 'Validation Failed', 'red', context);
branchName,
context,
description: 'Validation Failed',
state: 'red',
});
setReconfigureBranchCache(branchSha, false); setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!); await scm.checkoutBranch(config.baseBranch!);
return; return;
} }
// passing check // passing check
await platform.setBranchStatus({ await setBranchStatus(branchName, 'Validation Successful', 'green', context);
branchName,
context,
description: 'Validation Successful',
state: 'green',
});
setReconfigureBranchCache(branchSha, true); setReconfigureBranchCache(branchSha, true);
await scm.checkoutBranch(config.baseBranch!); await scm.checkoutBranch(config.baseBranch!);

View file

@ -1,5 +1,6 @@
import { platform } from '../../../../../test/util'; import { RenovateConfig, partial, platform } from '../../../../../test/util';
import { GlobalConfig } from '../../../../config/global'; import { GlobalConfig } from '../../../../config/global';
import { logger } from '../../../../logger';
import type { BranchConfig } from '../../../types'; import type { BranchConfig } from '../../../types';
import { setArtifactErrorStatus } from './artifacts'; import { setArtifactErrorStatus } from './artifacts';
@ -14,6 +15,9 @@ describe('workers/repository/update/branch/artifacts', () => {
branchName: 'renovate/pin', branchName: 'renovate/pin',
upgrades: [], upgrades: [],
artifactErrors: [{ lockFile: 'some' }], artifactErrors: [{ lockFile: 'some' }],
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
artifactError: 'renovate/artifact',
}),
} satisfies BranchConfig; } satisfies BranchConfig;
}); });
@ -30,15 +34,50 @@ describe('workers/repository/update/branch/artifacts', () => {
expect(platform.setBranchStatus).not.toHaveBeenCalled(); expect(platform.setBranchStatus).not.toHaveBeenCalled();
}); });
it('skips status if statusCheckNames.artifactError is null', async () => {
await setArtifactErrorStatus({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
artifactError: null,
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames.artifactError is empty string', async () => {
await setArtifactErrorStatus({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
artifactError: '',
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames is undefined', async () => {
await setArtifactErrorStatus({
...config,
statusCheckNames: undefined,
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status (dry-run)', async () => { it('skips status (dry-run)', async () => {
GlobalConfig.set({ dryRun: 'full' }); GlobalConfig.set({ dryRun: 'full' });
platform.getBranchStatusCheck.mockResolvedValueOnce(null);
await setArtifactErrorStatus(config); await setArtifactErrorStatus(config);
expect(platform.setBranchStatus).not.toHaveBeenCalled(); expect(platform.setBranchStatus).not.toHaveBeenCalled();
}); });
it('skips status (no errors)', async () => { it('skips status (no errors)', async () => {
platform.getBranchStatusCheck.mockResolvedValueOnce(null);
config.artifactErrors = []; config.artifactErrors = [];
await setArtifactErrorStatus(config); await setArtifactErrorStatus(config);
expect(platform.setBranchStatus).not.toHaveBeenCalled(); expect(platform.setBranchStatus).not.toHaveBeenCalled();

View file

@ -11,7 +11,14 @@ export async function setArtifactErrorStatus(
return; return;
} }
const context = `renovate/artifacts`; const context = config.statusCheckNames?.artifactError;
if (!context) {
logger.debug(
'Status check is null or an empty string, skipping status check addition.',
);
return;
}
const description = 'Artifact file update failure'; const description = 'Artifact file update failure';
const state = 'red'; const state = 'red';
const existingState = await platform.getBranchStatusCheck( const existingState = await platform.getBranchStatusCheck(

View file

@ -1,4 +1,5 @@
import { partial, platform } from '../../../../../test/util'; import { RenovateConfig, partial, platform } from '../../../../../test/util';
import { logger } from '../../../../logger';
import { import {
ConfidenceConfig, ConfidenceConfig,
StabilityConfig, StabilityConfig,
@ -14,6 +15,9 @@ describe('workers/repository/update/branch/status-checks', () => {
beforeEach(() => { beforeEach(() => {
config = partial<StabilityConfig>({ config = partial<StabilityConfig>({
branchName: 'renovate/some-branch', branchName: 'renovate/some-branch',
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
minimumReleaseAge: 'renovate/stability-days',
}),
}); });
}); });
@ -45,6 +49,46 @@ describe('workers/repository/update/branch/status-checks', () => {
expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1); expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1);
expect(platform.setBranchStatus).toHaveBeenCalledTimes(0); expect(platform.setBranchStatus).toHaveBeenCalledTimes(0);
}); });
it('skips status if statusCheckNames.minimumReleaseAge is null', async () => {
config.stabilityStatus = 'green';
await setStability({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
minimumReleaseAge: null,
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames.minimumReleaseAge is empty string', async () => {
config.stabilityStatus = 'green';
await setStability({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
minimumReleaseAge: '',
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames is undefined', async () => {
config.stabilityStatus = 'green';
await setStability({
...config,
statusCheckNames: undefined as never,
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
}); });
describe('setConfidence', () => { describe('setConfidence', () => {
@ -53,6 +97,9 @@ describe('workers/repository/update/branch/status-checks', () => {
beforeEach(() => { beforeEach(() => {
config = { config = {
branchName: 'renovate/some-branch', branchName: 'renovate/some-branch',
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
mergeConfidence: 'renovate/merge-confidence',
}),
}; };
}); });
@ -85,6 +132,49 @@ describe('workers/repository/update/branch/status-checks', () => {
expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1); expect(platform.getBranchStatusCheck).toHaveBeenCalledTimes(1);
expect(platform.setBranchStatus).toHaveBeenCalledTimes(0); expect(platform.setBranchStatus).toHaveBeenCalledTimes(0);
}); });
it('skips status if statusCheckNames.mergeConfidence is null', async () => {
config.minimumConfidence = 'high';
config.confidenceStatus = 'green';
await setConfidence({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
mergeConfidence: null,
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames.mergeConfidence is empty string', async () => {
config.minimumConfidence = 'high';
config.confidenceStatus = 'green';
await setConfidence({
...config,
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
mergeConfidence: '',
}),
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status if statusCheckNames is undefined', async () => {
config.minimumConfidence = 'high';
config.confidenceStatus = 'green';
await setConfidence({
...config,
statusCheckNames: undefined as never,
});
expect(logger.debug).toHaveBeenCalledWith(
'Status check is null or an empty string, skipping status check addition.',
);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
}); });
describe('getBranchStatus', () => { describe('getBranchStatus', () => {

View file

@ -62,7 +62,15 @@ export async function setStability(config: StabilityConfig): Promise<void> {
if (!config.stabilityStatus) { if (!config.stabilityStatus) {
return; return;
} }
const context = `renovate/stability-days`;
const context = config.statusCheckNames?.minimumReleaseAge;
if (!context) {
logger.debug(
'Status check is null or an empty string, skipping status check addition.',
);
return;
}
const description = const description =
config.stabilityStatus === 'green' config.stabilityStatus === 'green'
? 'Updates have met minimum release age requirement' ? 'Updates have met minimum release age requirement'
@ -90,7 +98,14 @@ export async function setConfidence(config: ConfidenceConfig): Promise<void> {
) { ) {
return; return;
} }
const context = `renovate/merge-confidence`; const context = config.statusCheckNames?.mergeConfidence;
if (!context) {
logger.debug(
'Status check is null or an empty string, skipping status check addition.',
);
return;
}
const description = const description =
config.confidenceStatus === 'green' config.confidenceStatus === 'green'
? 'Updates have met Merge Confidence requirement' ? 'Updates have met Merge Confidence requirement'