mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-10 14:06:30 +00:00
Merge 37886613b8
into c12c57b2a8
This commit is contained in:
commit
e4f5fef3da
14 changed files with 907 additions and 32 deletions
|
@ -706,22 +706,22 @@ You can define custom managers to handle:
|
|||
- Proprietary file formats or conventions
|
||||
- Popular file formats not yet supported as a manager by Renovate
|
||||
|
||||
Currently we only have one custom manager.
|
||||
Currently we have two custom managers.
|
||||
The `regex` manager which is based on using Regular Expression named capture groups.
|
||||
The `jsonata` manager which is based on using JSONata queries.
|
||||
|
||||
You must have a named capture group matching (e.g. `(?<depName>.*)`) _or_ configure its corresponding template (e.g. `depNameTemplate`) for these fields:
|
||||
You must capture/extract the following three fields _or_ configure its corresponding template (e.g. `depNameTemplate`) for these fields:
|
||||
|
||||
- `datasource`
|
||||
- `depName` and / or `packageName`
|
||||
- `currentValue`
|
||||
|
||||
Use named capture group matching _or_ set a corresponding template.
|
||||
We recommend you use only _one_ of these methods, or you'll get confused.
|
||||
|
||||
We recommend that you also tell Renovate what `versioning` to use.
|
||||
If the `versioning` field is missing, then Renovate defaults to using `semver` versioning.
|
||||
|
||||
For more details and examples about it, see our [documentation for the `regex` manager](modules/manager/regex/index.md).
|
||||
For more details and examples about it, see our documentation for the [`regex` manager](modules/manager/regex/index.md) and the [`JSONata` manager](modules/manager/jsonata/index.md).
|
||||
For template fields, use the triple brace `{{{ }}}` notation to avoid Handlebars escaping any special characters.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
|
@ -763,6 +763,10 @@ This will lead to following update where `1.21-alpine` is the newest version of
|
|||
image: my.new.registry/aRepository/andImage:1.21-alpine
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
Can only be used with the custom regex maanger.
|
||||
|
||||
### currentValueTemplate
|
||||
|
||||
If the `currentValue` for a dependency is not captured with a named group then it can be defined in config using this field.
|
||||
|
@ -786,6 +790,21 @@ Example:
|
|||
}
|
||||
```
|
||||
|
||||
```json title="Parsing a JSON file with a custom manager"
|
||||
{
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "jsonata",
|
||||
"fileFormat": "json",
|
||||
"fileMatch": ["file.json"],
|
||||
"matchStrings": [
|
||||
"packages.{ \"depName\": package, \"currentValue\": version }"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### datasourceTemplate
|
||||
|
||||
If the `datasource` for a dependency is not captured with a named group then it can be defined in config using this field.
|
||||
|
@ -806,13 +825,40 @@ It will be compiled using Handlebars and the regex `groups` result.
|
|||
If `extractVersion` cannot be captured with a named capture group in `matchString` then it can be defined manually using this field.
|
||||
It will be compiled using Handlebars and the regex `groups` result.
|
||||
|
||||
### fileFormat
|
||||
|
||||
It specifies the syntax of the package file being managed by the custom JSONata manager.
|
||||
This setting helps the system correctly parse and interpret the configuration file's contents.
|
||||
|
||||
Currently, only the `json` format is supported.
|
||||
|
||||
```json title="Parsing a JSON file with a custom manager"
|
||||
{
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "jsonata",
|
||||
"fileFormat": "json",
|
||||
"fileMatch": [".renovaterc"],
|
||||
"matchStrings": [
|
||||
"packages.{ \"depName\": package, \"currentValue\": version }"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### matchStrings
|
||||
|
||||
Each `matchStrings` must be a valid regular expression, optionally with named capture groups.
|
||||
Each `matchStrings` must be one of the two:
|
||||
|
||||
1. a valid regular expression, optionally with named capture groups (if using `customType=regex`)
|
||||
2. a valid, escaped [JSONata](https://docs.jsonata.org/overview.html) query (if using `customType=json`)
|
||||
|
||||
See [`customType`](#customtype) docs, to know more them.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
```json title="matchStrings with a valid regular expression"
|
||||
{
|
||||
"matchStrings": [
|
||||
"ENV .*?_VERSION=(?<currentValue>.*) # (?<datasource>.*?)/(?<depName>.*?)\\s"
|
||||
|
@ -820,6 +866,14 @@ Example:
|
|||
}
|
||||
```
|
||||
|
||||
```json title="matchStrings with a valid JSONata query"
|
||||
{
|
||||
"matchStrings": [
|
||||
"packages.{ \"depName\": package, \"currentValue\": version }"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### matchStringsStrategy
|
||||
|
||||
`matchStringsStrategy` controls behavior when multiple `matchStrings` values are provided.
|
||||
|
@ -829,6 +883,10 @@ Three options are available:
|
|||
- `recursive`
|
||||
- `combination`
|
||||
|
||||
<!--prettier-ignore-->
|
||||
!!! note
|
||||
Only to be used with custom regex manager.
|
||||
|
||||
#### any
|
||||
|
||||
Each provided `matchString` will be matched individually to the content of the `packageFile`.
|
||||
|
|
|
@ -2735,18 +2735,26 @@ const options: RenovateOptions[] = [
|
|||
description:
|
||||
'Custom manager to use. Valid only within a `customManagers` object.',
|
||||
type: 'string',
|
||||
allowedValues: ['regex'],
|
||||
allowedValues: ['jsonata', 'regex'],
|
||||
parents: ['customManagers'],
|
||||
cli: false,
|
||||
env: false,
|
||||
},
|
||||
{
|
||||
name: 'fileFormat',
|
||||
description:
|
||||
'It specifies the syntax of the package file being managed by the custom JSONata manager.',
|
||||
type: 'string',
|
||||
allowedValues: ['json'],
|
||||
parents: ['customManagers'],
|
||||
cli: false,
|
||||
env: false,
|
||||
},
|
||||
{
|
||||
name: 'matchStrings',
|
||||
description:
|
||||
'Regex capture rule to use. Valid only within a `customManagers` object.',
|
||||
description: 'Queries to use. Valid only within a `customManagers` object.',
|
||||
type: 'array',
|
||||
subType: 'string',
|
||||
format: 'regex',
|
||||
parents: ['customManagers'],
|
||||
cli: false,
|
||||
env: false,
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import is from '@sindresorhus/is';
|
||||
import jsonata from 'jsonata';
|
||||
import { logger } from '../../logger';
|
||||
import type {
|
||||
RegexManagerConfig,
|
||||
RegexManagerTemplates,
|
||||
} from '../../modules/manager/custom/regex/types';
|
||||
import type { RegexManagerTemplates } from '../../modules/manager/custom/regex/types';
|
||||
import type { CustomManager } from '../../modules/manager/custom/types';
|
||||
import { regEx } from '../../util/regex';
|
||||
import type { ValidationMessage } from '../types';
|
||||
|
||||
|
@ -78,21 +77,20 @@ export function isFalseGlobal(
|
|||
return false;
|
||||
}
|
||||
|
||||
function hasField(
|
||||
customManager: Partial<RegexManagerConfig>,
|
||||
field: string,
|
||||
): boolean {
|
||||
function hasField(customManager: CustomManager, field: string): boolean {
|
||||
const templateField = `${field}Template` as keyof RegexManagerTemplates;
|
||||
const fieldStr =
|
||||
customManager.customType === 'regex' ? `(?<${field}>` : field;
|
||||
return !!(
|
||||
customManager[templateField] ??
|
||||
customManager.matchStrings?.some((matchString) =>
|
||||
matchString.includes(`(?<${field}>`),
|
||||
matchString.includes(fieldStr),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateRegexManagerFields(
|
||||
customManager: Partial<RegexManagerConfig>,
|
||||
customManager: CustomManager,
|
||||
currentPath: string,
|
||||
errors: ValidationMessage[],
|
||||
): void {
|
||||
|
@ -136,3 +134,56 @@ export function validateRegexManagerFields(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function validateJSONataManagerFields(
|
||||
customManager: CustomManager,
|
||||
currentPath: string,
|
||||
errors: ValidationMessage[],
|
||||
): void {
|
||||
if (!is.nonEmptyString(customManager.fileFormat)) {
|
||||
errors.push({
|
||||
topic: 'Configuration Error',
|
||||
message: 'Each JSONata manager must contain a fileFormat field.',
|
||||
});
|
||||
}
|
||||
|
||||
if (is.nonEmptyArray(customManager.matchStrings)) {
|
||||
for (const matchString of customManager.matchStrings) {
|
||||
try {
|
||||
jsonata(matchString);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
'customManager.matchStrings JSONata query validation error',
|
||||
);
|
||||
errors.push({
|
||||
topic: 'Configuration Error',
|
||||
message: `Invalid JSONata query for ${currentPath}: \`${matchString}\``,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
topic: 'Configuration Error',
|
||||
message: `Each Custom Manager must contain a non-empty matchStrings array`,
|
||||
});
|
||||
}
|
||||
|
||||
const mandatoryFields = ['currentValue', 'datasource'];
|
||||
for (const field of mandatoryFields) {
|
||||
if (!hasField(customManager, field)) {
|
||||
errors.push({
|
||||
topic: 'Configuration Error',
|
||||
message: `JSONata Managers must contain ${field}Template configuration or ${field} in the query `,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nameFields = ['depName', 'packageName'];
|
||||
if (!nameFields.some((field) => hasField(customManager, field))) {
|
||||
errors.push({
|
||||
topic: 'Configuration Error',
|
||||
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -706,7 +706,8 @@ describe('config/validation', () => {
|
|||
currentValueTemplate: 'baz',
|
||||
},
|
||||
{
|
||||
customType: 'regex',
|
||||
customType: 'jsonata',
|
||||
fileFormat: 'json',
|
||||
fileMatch: ['foo'],
|
||||
depNameTemplate: 'foo',
|
||||
datasourceTemplate: 'bar',
|
||||
|
@ -776,6 +777,60 @@ describe('config/validation', () => {
|
|||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('error if no fileFormat in custom JSONata manager', async () => {
|
||||
const config: RenovateConfig = {
|
||||
customManagers: [
|
||||
{
|
||||
customType: 'jsonata',
|
||||
fileMatch: ['package.json'],
|
||||
matchStrings: [
|
||||
'packages.{"depName": name, "currentValue": version, "datasource": "npm"}',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { warnings, errors } = await configValidation.validateConfig(
|
||||
'repo',
|
||||
config,
|
||||
true,
|
||||
);
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(errors).toMatchObject([
|
||||
{
|
||||
topic: 'Configuration Error',
|
||||
message: 'Each JSONata manager must contain a fileFormat field.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates JSONata query for each matchStrings', async () => {
|
||||
const config: RenovateConfig = {
|
||||
customManagers: [
|
||||
{
|
||||
customType: 'jsonata',
|
||||
fileFormat: 'json',
|
||||
fileMatch: ['package.json'],
|
||||
matchStrings: ['packages.{'],
|
||||
depNameTemplate: 'foo',
|
||||
datasourceTemplate: 'bar',
|
||||
currentValueTemplate: 'baz',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { warnings, errors } = await configValidation.validateConfig(
|
||||
'repo',
|
||||
config,
|
||||
true,
|
||||
);
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(errors).toMatchObject([
|
||||
{
|
||||
topic: 'Configuration Error',
|
||||
message: `Invalid JSONata query for customManagers: \`packages.{\``,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// testing if we get all errors at once or not (possible), this does not include customType or fileMatch
|
||||
// since they are common to all custom managers
|
||||
it('validates all possible regex manager options', async () => {
|
||||
|
@ -811,14 +866,12 @@ describe('config/validation', () => {
|
|||
depTypeTemplate: 'apple',
|
||||
},
|
||||
{
|
||||
customType: 'regex',
|
||||
fileMatch: ['Dockerfile'],
|
||||
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
|
||||
packageNameTemplate: 'foo',
|
||||
datasourceTemplate: 'bar',
|
||||
registryUrlTemplate: 'foobar',
|
||||
extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
|
||||
depTypeTemplate: 'apple',
|
||||
customType: 'jsonata',
|
||||
fileFormat: 'json',
|
||||
fileMatch: ['package.json'],
|
||||
matchStrings: [
|
||||
'packages.{"depName": depName, "currentValue": version, "datasource": "npm"}',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -876,6 +929,39 @@ describe('config/validation', () => {
|
|||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('errors if customManager fields are missing: JSONataManager', async () => {
|
||||
const config: RenovateConfig = {
|
||||
customManagers: [
|
||||
{
|
||||
customType: 'jsonata',
|
||||
fileFormat: 'json',
|
||||
fileMatch: ['package.json'],
|
||||
matchStrings: ['packages'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { warnings, errors } = await configValidation.validateConfig(
|
||||
'repo',
|
||||
config,
|
||||
true,
|
||||
);
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(errors).toMatchObject([
|
||||
{
|
||||
topic: 'Configuration Error',
|
||||
message: `JSONata Managers must contain currentValueTemplate configuration or currentValue in the query `,
|
||||
},
|
||||
{
|
||||
topic: 'Configuration Error',
|
||||
message: `JSONata Managers must contain datasourceTemplate configuration or datasource in the query `,
|
||||
},
|
||||
{
|
||||
topic: 'Configuration Error',
|
||||
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignore keys', async () => {
|
||||
const config = {
|
||||
$schema: 'renovate.json',
|
||||
|
|
|
@ -37,6 +37,7 @@ import * as regexOrGlobValidator from './validation-helpers/regex-glob-matchers'
|
|||
import {
|
||||
getParentName,
|
||||
isFalseGlobal,
|
||||
validateJSONataManagerFields,
|
||||
validateNumber,
|
||||
validatePlainObject,
|
||||
validateRegexManagerFields,
|
||||
|
@ -486,6 +487,7 @@ export async function validateConfig(
|
|||
const allowedKeys = [
|
||||
'customType',
|
||||
'description',
|
||||
'fileFormat',
|
||||
'fileMatch',
|
||||
'matchStrings',
|
||||
'matchStringsStrategy',
|
||||
|
@ -527,6 +529,13 @@ export async function validateConfig(
|
|||
errors,
|
||||
);
|
||||
break;
|
||||
case 'jsonata':
|
||||
validateJSONataManagerFields(
|
||||
customManager,
|
||||
currentPath,
|
||||
errors,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type { ManagerApi } from '../types';
|
||||
import * as jsonata from './jsonata';
|
||||
import * as regex from './regex';
|
||||
|
||||
const api = new Map<string, ManagerApi>();
|
||||
export default api;
|
||||
|
||||
api.set('regex', regex);
|
||||
api.set('jsonata', jsonata);
|
||||
|
|
|
@ -10,6 +10,8 @@ describe('modules/manager/custom/index', () => {
|
|||
expect(customManager.isCustomManager('npm')).toBe(false);
|
||||
expect(customManager.isCustomManager('regex')).toBe(true);
|
||||
expect(customManager.isCustomManager('custom.regex')).toBe(false);
|
||||
expect(customManager.isCustomManager('jsonata')).toBe(true);
|
||||
expect(customManager.isCustomManager('custom.jsonata')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
278
lib/modules/manager/custom/jsonata/index.spec.ts
Normal file
278
lib/modules/manager/custom/jsonata/index.spec.ts
Normal file
|
@ -0,0 +1,278 @@
|
|||
import { codeBlock } from 'common-tags';
|
||||
import { logger } from '../../../../../test/util';
|
||||
import type { JsonataExtractConfig } from './types';
|
||||
import { defaultConfig, extractPackageFile } from '.';
|
||||
|
||||
describe('modules/manager/custom/jsonata/index', () => {
|
||||
it('has default config', () => {
|
||||
expect(defaultConfig).toEqual({
|
||||
pinDigests: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when content does not match specified file format', async () => {
|
||||
const res = await extractPackageFile('not-json', 'foo-file', {
|
||||
fileFormat: 'json',
|
||||
} as JsonataExtractConfig);
|
||||
expect(res).toBeNull();
|
||||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Error while parsing file',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no content', async () => {
|
||||
const res = await extractPackageFile('', 'foo-file', {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
|
||||
],
|
||||
} as JsonataExtractConfig);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts data when no templates are used', async () => {
|
||||
const json = codeBlock`
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"dep_name": "foo",
|
||||
"package_name": "fii",
|
||||
"current_value": "1.2.3",
|
||||
"current_digest": "1234",
|
||||
"data_source": "nuget",
|
||||
"versioning": "maven",
|
||||
"extract_version": "custom-extract-version",
|
||||
"registry_url": "https://registry.npmjs.org",
|
||||
"dep_type": "dev"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
`packages.{
|
||||
"depName": dep_name,
|
||||
"packageName": package_name,
|
||||
"currentValue": current_value,
|
||||
"currentDigest": current_digest,
|
||||
"datasource": data_source,
|
||||
"versioning": versioning,
|
||||
"extractVersion": extract_version,
|
||||
"registryUrl": registry_url,
|
||||
"depType": dep_type
|
||||
}`,
|
||||
],
|
||||
};
|
||||
const res = await extractPackageFile(json, 'unused', config);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
deps: [
|
||||
{
|
||||
depName: 'foo',
|
||||
packageName: 'fii',
|
||||
currentValue: '1.2.3',
|
||||
currentDigest: '1234',
|
||||
datasource: 'nuget',
|
||||
versioning: 'maven',
|
||||
extractVersion: 'custom-extract-version',
|
||||
registryUrls: ['https://registry.npmjs.org/'],
|
||||
depType: 'dev',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('applies templates', async () => {
|
||||
const json = codeBlock`
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"dep_name": "foo",
|
||||
"package_name": "fii",
|
||||
"current_value": "1.2.3",
|
||||
"current_digest": "1234",
|
||||
"data_source": "nuget",
|
||||
"versioning": "maven",
|
||||
"extract_version": "custom-extract-version",
|
||||
"registry_url": "https://registry.npmjs.org",
|
||||
"dep_type": "dev"
|
||||
},
|
||||
{
|
||||
}]
|
||||
}`;
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
`packages.{
|
||||
"depName": dep_name,
|
||||
"packageName": package_name,
|
||||
"currentValue": current_value,
|
||||
"currentDigest": current_digest,
|
||||
"datasource": data_source,
|
||||
"versioning": versioning,
|
||||
"extractVersion": extract_version,
|
||||
"registryUrl": registry_url,
|
||||
"depType": dep_type
|
||||
}`,
|
||||
],
|
||||
depNameTemplate:
|
||||
'{{#if depName}}{{depName}}{{else}}default-dep-name{{/if}}',
|
||||
packageNameTemplate:
|
||||
'{{#if packageName}}{{packageName}}{{else}}default-package-name{{/if}}',
|
||||
currentValueTemplate:
|
||||
'{{#if currentValue}}{{currentValue}}{{else}}default-current-value{{/if}}',
|
||||
currentDigestTemplate:
|
||||
'{{#if currentDigest}}{{currentDigest}}{{else}}default-current-digest{{/if}}',
|
||||
datasourceTemplate:
|
||||
'{{#if datasource}}{{datasource}}{{else}}default-datasource{{/if}}',
|
||||
versioningTemplate:
|
||||
'{{#if versioning}}{{versioning}}{{else}}default-versioning{{/if}}',
|
||||
extractVersionTemplate:
|
||||
'{{#if extractVersion}}{{extractVersion}}{{else}}default-extract-version{{/if}}',
|
||||
registryUrlTemplate:
|
||||
'{{#if registryUrl}}{{registryUrl}}{{else}}https://default.registry.url{{/if}}',
|
||||
depTypeTemplate:
|
||||
'{{#if depType}}{{depType}}{{else}}default-dep-type{{/if}}',
|
||||
};
|
||||
const res = await extractPackageFile(json, 'unused', config);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
deps: [
|
||||
{
|
||||
depName: 'foo',
|
||||
packageName: 'fii',
|
||||
currentValue: '1.2.3',
|
||||
currentDigest: '1234',
|
||||
datasource: 'nuget',
|
||||
versioning: 'maven',
|
||||
extractVersion: 'custom-extract-version',
|
||||
registryUrls: ['https://registry.npmjs.org/'],
|
||||
depType: 'dev',
|
||||
},
|
||||
{
|
||||
depName: 'default-dep-name',
|
||||
packageName: 'default-package-name',
|
||||
currentValue: 'default-current-value',
|
||||
currentDigest: 'default-current-digest',
|
||||
datasource: 'default-datasource',
|
||||
versioning: 'default-versioning',
|
||||
extractVersion: 'default-extract-version',
|
||||
registryUrls: ['https://default.registry.url/'],
|
||||
depType: 'default-dep-type',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('logs warning if query result does not match schema', async () => {
|
||||
const json = codeBlock`
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"dep_name": "foo",
|
||||
"package_name": "fii",
|
||||
"current_value": 1,
|
||||
"current_digest": "1234",
|
||||
"data_source": "nuget",
|
||||
"versioning": "maven",
|
||||
"extract_version": "custom-extract-version",
|
||||
"registry_url": "https://registry.npmjs.org",
|
||||
"dep_type": "dev"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
`packages.{
|
||||
"depName": dep_name,
|
||||
"currentValue": current_value,
|
||||
"datasource": data_source
|
||||
}`,
|
||||
],
|
||||
};
|
||||
const res = await extractPackageFile(json, 'unused', config);
|
||||
|
||||
expect(res).toBeNull();
|
||||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Query results failed schema validation',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null if no dependencies found', async () => {
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
|
||||
],
|
||||
};
|
||||
const res = await extractPackageFile('{}', 'unused', config);
|
||||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
packageFile: 'unused',
|
||||
jsonataQuery:
|
||||
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
|
||||
},
|
||||
'The jsonata query returned no matches. Possible error, please check your query. Skipping',
|
||||
);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if invalid template', async () => {
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
`{"depName": "foo", "currentValue": "1.0.0", "datasource": "npm"}`,
|
||||
],
|
||||
versioningTemplate: '{{#if versioning}}{{versioning}}{{else}}semver', // invalid template
|
||||
};
|
||||
const res = await extractPackageFile('{}', 'unused', config);
|
||||
expect(res).toBeNull();
|
||||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Error compiling template for JSONata manager',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts and does not apply a registryUrlTemplate if the result is an invalid url', async () => {
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [
|
||||
`{"depName": "foo", "currentValue": "1.0.0", "datasource": "npm"}`,
|
||||
],
|
||||
registryUrlTemplate: 'this-is-not-a-valid-url-{{depName}}',
|
||||
};
|
||||
const res = await extractPackageFile('{}', 'unused', config);
|
||||
expect(res).not.toBeNull();
|
||||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||||
{ url: 'this-is-not-a-valid-url-foo' },
|
||||
'Invalid JSONata manager registryUrl',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts multiple dependencies with multiple matchStrings', async () => {
|
||||
const config = {
|
||||
fileFormat: 'json',
|
||||
matchStrings: [`{"depName": "foo"}`, `{"depName": "bar"}`],
|
||||
currentValueTemplate: '1.0.0',
|
||||
datasourceTemplate: 'npm',
|
||||
};
|
||||
const res = await extractPackageFile('{}', 'unused', config);
|
||||
expect(res).toMatchObject({
|
||||
deps: [
|
||||
{
|
||||
depName: 'foo',
|
||||
currentValue: '1.0.0',
|
||||
datasource: 'npm',
|
||||
},
|
||||
{
|
||||
depName: 'bar',
|
||||
currentValue: '1.0.0',
|
||||
datasource: 'npm',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
49
lib/modules/manager/custom/jsonata/index.ts
Normal file
49
lib/modules/manager/custom/jsonata/index.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import is from '@sindresorhus/is';
|
||||
import type { Category } from '../../../../constants';
|
||||
import { logger } from '../../../../logger';
|
||||
import { parseJson } from '../../../../util/common';
|
||||
import type { PackageFileContent } from '../../types';
|
||||
import type { JsonataExtractConfig } from './types';
|
||||
import { handleMatching } from './utils';
|
||||
|
||||
export const categories: Category[] = ['custom'];
|
||||
|
||||
export const defaultConfig = {
|
||||
pinDigests: false,
|
||||
};
|
||||
export const supportedDatasources = ['*'];
|
||||
export const displayName = 'JSONata';
|
||||
|
||||
export async function extractPackageFile(
|
||||
content: string,
|
||||
packageFile: string,
|
||||
config: JsonataExtractConfig,
|
||||
): Promise<PackageFileContent | null> {
|
||||
let json: unknown;
|
||||
try {
|
||||
switch (config.fileFormat) {
|
||||
case 'json':
|
||||
json = parseJson(content, packageFile);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, fileName: packageFile, fileFormat: config.fileFormat },
|
||||
'Error while parsing file',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is.nullOrUndefined(json)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deps = await handleMatching(json, packageFile, config);
|
||||
if (!deps.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
deps,
|
||||
};
|
||||
}
|
182
lib/modules/manager/custom/jsonata/readme.md
Normal file
182
lib/modules/manager/custom/jsonata/readme.md
Normal file
|
@ -0,0 +1,182 @@
|
|||
With `customManagers` using `JSONata` queries you can configure Renovate so it finds dependencies in JSON files, that are not detected by its other built-in package managers.
|
||||
|
||||
Renovate uses the `jsonata` package to process the `json` file content. Read about the [jsonata query language](https://docs.jsonata.org/overview.html) in their readme.
|
||||
|
||||
The JSONata manager is unique in Renovate in because:
|
||||
|
||||
- It is configurable via [JSONata](https://jsonata.org/) queries
|
||||
- It can extract any `datasource`
|
||||
- By using the `customManagers` config, you can create multiple "JSONata managers" the same repository
|
||||
|
||||
### Required Fields
|
||||
|
||||
The first two required fields are `fileMatch` and `matchStrings`:
|
||||
|
||||
- `fileMatch` works the same as any manager
|
||||
- `matchStrings` is a `JSONata` custom manager concept and is used for configuring a jsonata queries
|
||||
|
||||
#### Information that Renovate needs about the dependency
|
||||
|
||||
Before Renovate can look up a dependency and decide about updates, it must have this info about each dependency:
|
||||
|
||||
| Info type | Required | Notes | Docs |
|
||||
| :--------------------------------------------------- | :------- | :-------------------------------------------------------- | :----------------------------------------------------------------------------- |
|
||||
| Name of the dependency | Yes | | |
|
||||
| `datasource` | Yes | Example datasources: npm, Docker, GitHub tags, and so on. | [Supported datasources](../../datasource/index.md#supported-datasources) |
|
||||
| Version scheme to use. Defaults to `semver-coerced`. | Yes | You may set another version scheme, like `pep440`. | [Supported versioning schemes](../../versioning/index.md#supported-versioning) |
|
||||
|
||||
#### Required fields to be present in the resulting structure returned by the jsonata query
|
||||
|
||||
You must:
|
||||
|
||||
- Capture the `currentValue` of the dependency
|
||||
- Capture the `depName` or `packageName`. Or use a template field: `depNameTemplate` and `packageNameTemplate`
|
||||
- Capture the `datasource`, or a use `datasourceTemplate` config field
|
||||
|
||||
#### Optional fields you can include in the resulting structure
|
||||
|
||||
You may use any of these items:
|
||||
|
||||
- `depType`, or a use `depTypeTemplate` config field
|
||||
- `versioning`, or a use `versioningTemplate` config field. If neither are present, Renovate defaults to `semver-coerced`
|
||||
- `extractVersion`, or use an `extractVersionTemplate` config field
|
||||
- `currentDigest`
|
||||
- `registryUrl`, or a use `registryUrlTemplate` config field. If it's a valid URL, it will be converted to the `registryUrls` field as a single-length array
|
||||
- `indentation`. It must be either empty, or whitespace only (otherwise `indentation` will be reset to an empty string)
|
||||
|
||||
### Usage
|
||||
|
||||
To configure it, use the following syntax:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "jsonata",
|
||||
"fileFormat": "json",
|
||||
"fileMatch": ["<file match pattern>"],
|
||||
"matchStrings": ['<query>'],
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Where `<query>` is a [JSONata](https://docs.jsonata.org/overview.html) query that transform the contents into a JSON object with the following schema:
|
||||
|
||||
To be effective with the JSONata manager, you should understand jsonata queries. But enough examples may compensate for lack of experience.
|
||||
|
||||
#### Example queries
|
||||
|
||||
Below are some example queries for the generic JSON manager.
|
||||
You can also use the [JSONata test website](https://try.jsonata.org) to experiment with queries.
|
||||
|
||||
```json title="Dependencies spread in different nodes, and we want to limit the extraction to a particular node"
|
||||
{
|
||||
"production": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"package": "foo"
|
||||
}
|
||||
],
|
||||
"development": [
|
||||
{
|
||||
"version": "4.5.6",
|
||||
"package": "bar"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
```
|
||||
production.{ "depName": package, "currentValue": version }
|
||||
```
|
||||
|
||||
```json title="Dependencies spread in different nodes, and we want to extract all of them as if they were in the same node"
|
||||
{
|
||||
"production": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"package": "foo"
|
||||
}
|
||||
],
|
||||
"development": [
|
||||
{
|
||||
"version": "4.5.6",
|
||||
"package": "bar"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
```
|
||||
*.{ "depName": package, "currentValue": version }
|
||||
```
|
||||
|
||||
```json title="The dependency name is in a JSON node name and the version is in a child leaf to that node"
|
||||
{
|
||||
"foo": {
|
||||
"version": "1.2.3"
|
||||
},
|
||||
"bar": {
|
||||
"version": "4.5.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
```
|
||||
$each(function($v, $n) { { "depName": $n, "currentValue": $v.version } })
|
||||
```
|
||||
|
||||
```json title="The name of the dependency and the version are both value nodes of the same parent node"
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"package": "foo"
|
||||
},
|
||||
{
|
||||
"version": "4.5.6",
|
||||
"package": "bar"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
```
|
||||
packages.{ "depName": package, "currentValue": version }
|
||||
```
|
||||
|
||||
```json title="The name of the dependency and the version are in the same string"
|
||||
{
|
||||
"packages": ["foo@1.2.3", "bar@4.5.6"]
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
```
|
||||
$map($map(packages, function ($v) { $split($v, "@") }), function ($v) { { "depName": $v[0], "currentVersion": $v[1] } })
|
||||
```
|
||||
|
||||
```json title="JSONata manager config to extract deps from package.json file in the renovate repository"
|
||||
{
|
||||
"customType": "jsonata",
|
||||
"fileMatch": ["package.json"],
|
||||
"matchStrings": [
|
||||
"$each(dependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"dependencies\"}})",
|
||||
"$each(devDependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"devDependencies\"}})",
|
||||
"$each(optionalDependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"optionalDependencies\"}})",
|
||||
"{ \"depName\": \"pnpm\", \"currentValue\": $substring(packageManager, 5), \"depType\": \"packageManager\"}"
|
||||
],
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
```
|
16
lib/modules/manager/custom/jsonata/schema.ts
Normal file
16
lib/modules/manager/custom/jsonata/schema.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const DepObjectSchema = z.object({
|
||||
currentValue: z.string().optional(),
|
||||
datasource: z.string().optional(),
|
||||
depName: z.string().optional(),
|
||||
packageName: z.string().optional(),
|
||||
currentDigest: z.string().optional(),
|
||||
versioning: z.string().optional(),
|
||||
depType: z.string().optional(),
|
||||
registryUrl: z.string().optional(),
|
||||
extractVersion: z.string().optional(),
|
||||
indentation: z.string().optional(),
|
||||
});
|
||||
|
||||
export const QueryResultZodSchema = z.array(DepObjectSchema);
|
25
lib/modules/manager/custom/jsonata/types.ts
Normal file
25
lib/modules/manager/custom/jsonata/types.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { ExtractConfig } from '../../types';
|
||||
|
||||
export interface JSONataManagerTemplates {
|
||||
depNameTemplate?: string;
|
||||
packageNameTemplate?: string;
|
||||
datasourceTemplate?: string;
|
||||
versioningTemplate?: string;
|
||||
depTypeTemplate?: string;
|
||||
currentValueTemplate?: string;
|
||||
currentDigestTemplate?: string;
|
||||
extractVersionTemplate?: string;
|
||||
registryUrlTemplate?: string;
|
||||
}
|
||||
|
||||
export interface JSONataManagerConfig extends JSONataManagerTemplates {
|
||||
fileFormat: string;
|
||||
matchStrings: string[];
|
||||
}
|
||||
|
||||
export interface JsonataExtractConfig
|
||||
extends ExtractConfig,
|
||||
JSONataManagerTemplates {
|
||||
fileFormat: string;
|
||||
matchStrings: string[];
|
||||
}
|
104
lib/modules/manager/custom/jsonata/utils.ts
Normal file
104
lib/modules/manager/custom/jsonata/utils.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import is from '@sindresorhus/is';
|
||||
import jsonata from 'jsonata';
|
||||
import { logger } from '../../../../logger';
|
||||
import * as template from '../../../../util/template';
|
||||
import { parseUrl } from '../../../../util/url';
|
||||
import type { PackageDependency } from '../../types';
|
||||
import type { ValidMatchFields } from '../utils';
|
||||
import { checkIsValidDependency, validMatchFields } from '../utils';
|
||||
import { QueryResultZodSchema } from './schema';
|
||||
import type { JSONataManagerTemplates, JsonataExtractConfig } from './types';
|
||||
|
||||
export async function handleMatching(
|
||||
json: unknown,
|
||||
packageFile: string,
|
||||
config: JsonataExtractConfig,
|
||||
): Promise<PackageDependency[]> {
|
||||
let results: Record<string, string>[] = [];
|
||||
const { matchStrings: jsonataQueries } = config;
|
||||
for (const query of jsonataQueries) {
|
||||
// won't fail as this is verified during config validation
|
||||
const jsonataExpression = jsonata(query);
|
||||
// this does not throw error, just returns undefined if no matches
|
||||
let queryResult = (await jsonataExpression.evaluate(json)) ?? [];
|
||||
|
||||
if (is.emptyObject(queryResult) || is.emptyArray(queryResult)) {
|
||||
logger.warn(
|
||||
{
|
||||
jsonataQuery: query,
|
||||
packageFile,
|
||||
},
|
||||
'The jsonata query returned no matches. Possible error, please check your query. Skipping',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
queryResult = is.array(queryResult) ? queryResult : [queryResult];
|
||||
const parsed = QueryResultZodSchema.safeParse(queryResult);
|
||||
if (parsed.success) {
|
||||
results = results.concat(parsed.data);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err: parsed.error, jsonataQuery: query, packageFile, queryResult },
|
||||
'Query results failed schema validation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.map((dep) => createDependency(dep, config))
|
||||
.filter(is.truthy)
|
||||
.filter((dep) =>
|
||||
checkIsValidDependency(dep, packageFile, 'custom.jsonata'),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDependency(
|
||||
queryResult: Record<string, string>,
|
||||
config: JsonataExtractConfig,
|
||||
): PackageDependency | null {
|
||||
const dependency: PackageDependency = {};
|
||||
|
||||
for (const field of validMatchFields) {
|
||||
const fieldTemplate = `${field}Template` as keyof JSONataManagerTemplates;
|
||||
const tmpl = config[fieldTemplate];
|
||||
if (tmpl) {
|
||||
try {
|
||||
const compiled = template.compile(tmpl, queryResult, false);
|
||||
updateDependency(field, compiled, dependency);
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ template: tmpl },
|
||||
'Error compiling template for JSONata manager',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else if (queryResult[field]) {
|
||||
updateDependency(field, queryResult[field], dependency);
|
||||
}
|
||||
}
|
||||
return dependency;
|
||||
}
|
||||
|
||||
function updateDependency(
|
||||
field: ValidMatchFields,
|
||||
value: string,
|
||||
dependency: PackageDependency,
|
||||
): PackageDependency {
|
||||
switch (field) {
|
||||
case 'registryUrl': {
|
||||
const url = parseUrl(value)?.toString();
|
||||
if (!url) {
|
||||
logger.warn({ url: value }, 'Invalid JSONata manager registryUrl');
|
||||
break;
|
||||
}
|
||||
dependency.registryUrls = [url];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
dependency[field] = value;
|
||||
break;
|
||||
}
|
||||
|
||||
return dependency;
|
||||
}
|
|
@ -1,10 +1,15 @@
|
|||
import type { JSONataManagerConfig } from './jsonata/types';
|
||||
import type { RegexManagerConfig } from './regex/types';
|
||||
|
||||
export interface CustomExtractConfig extends Partial<RegexManagerConfig> {}
|
||||
export interface CustomExtractConfig
|
||||
extends Partial<RegexManagerConfig>,
|
||||
Partial<JSONataManagerConfig> {}
|
||||
|
||||
export type CustomManagerName = 'regex';
|
||||
export type CustomManagerName = 'jsonata' | 'regex';
|
||||
|
||||
export interface CustomManager extends Partial<RegexManagerConfig> {
|
||||
export interface CustomManager
|
||||
extends Partial<RegexManagerConfig>,
|
||||
Partial<JSONataManagerConfig> {
|
||||
customType: CustomManagerName;
|
||||
fileMatch: string[];
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue