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.
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
This feature only works on supported platforms, check the table above.

View file

@ -170,6 +170,19 @@ const options: RenovateOptions[] = [
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',
description: 'Configuration presets to use or extend.',

View file

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

View file

@ -16,11 +16,13 @@ import {
import { migrateConfig } from './migration';
import { getOptions } from './options';
import { resolveConfigPresets } from './presets';
import type {
RenovateConfig,
RenovateOptions,
ValidationMessage,
ValidationResult,
import {
type RenovateConfig,
type RenovateOptions,
type StatusCheckKey,
type ValidationMessage,
type ValidationResult,
allowedStatusCheckStrings,
} from './types';
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`,
});
}
} 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') {
const allowedKeys = [
'description',

View file

@ -4,6 +4,7 @@ import {
fs,
git,
mocked,
partial,
platform,
scm,
} from '../../../../test/util';
@ -26,6 +27,9 @@ describe('workers/repository/reconfigure/index', () => {
const config: RenovateConfig = {
branchPrefix: 'prefix/',
baseBranch: 'base',
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
configValidation: 'renovate/config-validation',
}),
};
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 () => {
cache.getCache.mockReturnValueOnce({
reconfigureBranchCache: {
@ -174,7 +218,7 @@ describe('workers/repository/reconfigure/index', () => {
platform.getBranchStatusCheck.mockResolvedValueOnce('green');
await validateReconfigureBranch(config);
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 { ensureComment } from '../../../modules/platform/comment';
import { scm } from '../../../modules/platform/scm';
import type { BranchStatus } from '../../../types';
import { getCache } from '../../../util/cache/repository';
import { readLocalFile } from '../../../util/fs';
import { getBranchCommit } from '../../../util/git';
@ -16,6 +17,25 @@ import {
setReconfigureBranchCache,
} 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 {
return `${prefix}reconfigure`;
}
@ -23,7 +43,7 @@ export async function validateReconfigureBranch(
config: RenovateConfig,
): Promise<void> {
logger.debug('validateReconfigureBranch()');
const context = `renovate/config-validation`;
const context = config.statusCheckNames?.configValidation;
const branchName = getReconfigureBranchName(config.branchPrefix!);
const branchExists = await scm.branchExists(branchName);
@ -48,14 +68,23 @@ export async function validateReconfigureBranch(
return;
}
const validationStatus = await platform.getBranchStatusCheck(
branchName,
'renovate/config-validation',
);
// if old status check is present skip validation
if (is.nonEmptyString(validationStatus)) {
logger.debug('Skipping validation check as status check already exists');
return;
if (context) {
const validationStatus = await platform.getBranchStatusCheck(
branchName,
context,
);
// if old status check is present skip validation
if (is.nonEmptyString(validationStatus)) {
logger.debug(
'Skipping validation check because status check already exists.',
);
return;
}
} else {
logger.debug(
'Status check is null or an empty string, skipping status check addition.',
);
}
try {
@ -70,12 +99,12 @@ export async function validateReconfigureBranch(
if (!is.nonEmptyString(configFileName)) {
logger.warn('No config file found in reconfigure branch');
await platform.setBranchStatus({
await setBranchStatus(
branchName,
'Validation Failed - No config file found',
'red',
context,
description: 'Validation Failed - No config file found',
state: 'red',
});
);
setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.defaultBranch!);
return;
@ -90,12 +119,12 @@ export async function validateReconfigureBranch(
if (!is.nonEmptyString(configFileRaw)) {
logger.warn('Empty or invalid config file');
await platform.setBranchStatus({
await setBranchStatus(
branchName,
'Validation Failed - Empty/Invalid config file',
'red',
context,
description: 'Validation Failed - Empty/Invalid config file',
state: 'red',
});
);
setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!);
return;
@ -110,12 +139,12 @@ export async function validateReconfigureBranch(
}
} catch (err) {
logger.error({ err }, 'Error while parsing config file');
await platform.setBranchStatus({
await setBranchStatus(
branchName,
'Validation Failed - Unparsable config file',
'red',
context,
description: 'Validation Failed - Unparsable config file',
state: 'red',
});
);
setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!);
return;
@ -150,24 +179,14 @@ export async function validateReconfigureBranch(
content: body,
});
}
await platform.setBranchStatus({
branchName,
context,
description: 'Validation Failed',
state: 'red',
});
await setBranchStatus(branchName, 'Validation Failed', 'red', context);
setReconfigureBranchCache(branchSha, false);
await scm.checkoutBranch(config.baseBranch!);
return;
}
// passing check
await platform.setBranchStatus({
branchName,
context,
description: 'Validation Successful',
state: 'green',
});
await setBranchStatus(branchName, 'Validation Successful', 'green', context);
setReconfigureBranchCache(branchSha, true);
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 { logger } from '../../../../logger';
import type { BranchConfig } from '../../../types';
import { setArtifactErrorStatus } from './artifacts';
@ -14,6 +15,9 @@ describe('workers/repository/update/branch/artifacts', () => {
branchName: 'renovate/pin',
upgrades: [],
artifactErrors: [{ lockFile: 'some' }],
statusCheckNames: partial<RenovateConfig['statusCheckNames']>({
artifactError: 'renovate/artifact',
}),
} satisfies BranchConfig;
});
@ -30,15 +34,50 @@ describe('workers/repository/update/branch/artifacts', () => {
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 () => {
GlobalConfig.set({ dryRun: 'full' });
platform.getBranchStatusCheck.mockResolvedValueOnce(null);
await setArtifactErrorStatus(config);
expect(platform.setBranchStatus).not.toHaveBeenCalled();
});
it('skips status (no errors)', async () => {
platform.getBranchStatusCheck.mockResolvedValueOnce(null);
config.artifactErrors = [];
await setArtifactErrorStatus(config);
expect(platform.setBranchStatus).not.toHaveBeenCalled();

View file

@ -11,7 +11,14 @@ export async function setArtifactErrorStatus(
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 state = 'red';
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 {
ConfidenceConfig,
StabilityConfig,
@ -14,6 +15,9 @@ describe('workers/repository/update/branch/status-checks', () => {
beforeEach(() => {
config = partial<StabilityConfig>({
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.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', () => {
@ -53,6 +97,9 @@ describe('workers/repository/update/branch/status-checks', () => {
beforeEach(() => {
config = {
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.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', () => {

View file

@ -62,7 +62,15 @@ export async function setStability(config: StabilityConfig): Promise<void> {
if (!config.stabilityStatus) {
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 =
config.stabilityStatus === 'green'
? 'Updates have met minimum release age requirement'
@ -90,7 +98,14 @@ export async function setConfidence(config: ConfidenceConfig): Promise<void> {
) {
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 =
config.confidenceStatus === 'green'
? 'Updates have met Merge Confidence requirement'