@aspruyt/xfg 6.0.3 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/lifecycle-report-builder.d.ts +2 -2
- package/dist/cli/lifecycle-report-builder.js +3 -11
- package/dist/cli/program.d.ts +2 -1
- package/dist/cli/program.js +2 -3
- package/dist/cli/repo-sync-runner.d.ts +24 -0
- package/dist/cli/repo-sync-runner.js +156 -0
- package/dist/cli/results-collector.d.ts +1 -1
- package/dist/cli/results-collector.js +2 -2
- package/dist/cli/settings-factories.d.ts +7 -0
- package/dist/cli/settings-factories.js +27 -0
- package/dist/cli/settings-report-builder.d.ts +1 -1
- package/dist/cli/settings-report-builder.js +12 -23
- package/dist/cli/settings-runner.d.ts +2 -0
- package/dist/cli/settings-runner.js +87 -0
- package/dist/cli/sync-command.d.ts +1 -1
- package/dist/cli/sync-command.js +31 -372
- package/dist/cli/sync-report-builder.d.ts +1 -1
- package/dist/cli/sync-utils.d.ts +8 -0
- package/dist/cli/sync-utils.js +36 -0
- package/dist/cli/types.d.ts +5 -7
- package/dist/cli/unified-summary.d.ts +1 -3
- package/dist/cli/unified-summary.js +7 -5
- package/dist/cli.js +2 -1
- package/dist/{shared → config}/env.js +2 -2
- package/dist/config/extends-resolver.js +4 -3
- package/dist/config/file-reference-resolver.js +4 -2
- package/dist/config/formatter.js +18 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/loader.js +30 -6
- package/dist/config/merge.d.ts +11 -1
- package/dist/config/merge.js +78 -6
- package/dist/config/normalizer.js +53 -38
- package/dist/config/validator.d.ts +1 -4
- package/dist/config/validator.js +13 -599
- package/dist/config/validators/file-validator.d.ts +2 -1
- package/dist/config/validators/file-validator.js +9 -1
- package/dist/config/validators/group-validator.d.ts +3 -0
- package/dist/config/validators/group-validator.js +167 -0
- package/dist/config/validators/repo-entry-validator.d.ts +2 -0
- package/dist/config/validators/repo-entry-validator.js +165 -0
- package/dist/config/validators/repo-settings-validator.js +18 -7
- package/dist/config/validators/ruleset-validator.js +2 -5
- package/dist/config/validators/shared.d.ts +11 -0
- package/dist/config/validators/shared.js +242 -0
- package/dist/lifecycle/ado-migration-source.js +2 -4
- package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
- package/dist/lifecycle/github-lifecycle-provider.js +125 -136
- package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
- package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
- package/dist/lifecycle/index.d.ts +2 -2
- package/dist/lifecycle/index.js +1 -1
- package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
- package/dist/output/github-summary.js +2 -3
- package/dist/output/index.d.ts +4 -0
- package/dist/output/index.js +4 -0
- package/dist/output/lifecycle-report.d.ts +1 -1
- package/dist/output/lifecycle-report.js +5 -0
- package/dist/output/sync-report.d.ts +25 -3
- package/dist/output/sync-report.js +11 -11
- package/dist/settings/base-processor.d.ts +18 -7
- package/dist/settings/base-processor.js +26 -5
- package/dist/settings/code-scanning/diff.js +2 -2
- package/dist/settings/code-scanning/formatter.d.ts +2 -6
- package/dist/settings/code-scanning/formatter.js +2 -25
- package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
- package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
- package/dist/settings/code-scanning/processor.js +6 -4
- package/dist/settings/code-scanning/types.d.ts +10 -8
- package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
- package/dist/settings/labels/types.d.ts +12 -10
- package/dist/settings/repo-settings/diff.d.ts +1 -1
- package/dist/settings/repo-settings/diff.js +1 -1
- package/dist/settings/repo-settings/formatter.d.ts +2 -6
- package/dist/settings/repo-settings/formatter.js +4 -23
- package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
- package/dist/settings/repo-settings/processor.js +11 -11
- package/dist/settings/repo-settings/types.d.ts +2 -2
- package/dist/settings/rulesets/diff-algorithm.js +4 -2
- package/dist/settings/rulesets/diff.js +2 -51
- package/dist/settings/rulesets/formatter.js +4 -0
- package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
- package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
- package/dist/settings/rulesets/index.d.ts +1 -1
- package/dist/settings/rulesets/index.js +0 -2
- package/dist/settings/rulesets/processor.js +1 -1
- package/dist/settings/rulesets/types.d.ts +6 -2
- package/dist/shared/command-executor.d.ts +4 -4
- package/dist/shared/command-executor.js +9 -7
- package/dist/shared/diff-format.d.ts +1 -0
- package/dist/shared/diff-format.js +10 -0
- package/dist/shared/errors.d.ts +7 -4
- package/dist/shared/errors.js +8 -8
- package/dist/shared/gh-api-utils.d.ts +3 -34
- package/dist/shared/gh-api-utils.js +23 -53
- package/dist/shared/gh-token-utils.d.ts +26 -0
- package/dist/shared/gh-token-utils.js +32 -0
- package/dist/shared/json-utils.js +1 -1
- package/dist/shared/regex-utils.d.ts +1 -0
- package/dist/shared/regex-utils.js +3 -0
- package/dist/shared/retry-utils.d.ts +1 -0
- package/dist/shared/retry-utils.js +13 -7
- package/dist/sync/auth-options-builder.js +1 -1
- package/dist/sync/branch-manager.js +5 -3
- package/dist/sync/commit-push-manager.js +2 -3
- package/dist/sync/diff-utils.d.ts +0 -1
- package/dist/sync/diff-utils.js +5 -10
- package/dist/sync/file-sync-orchestrator.js +0 -2
- package/dist/sync/file-writer.d.ts +3 -0
- package/dist/sync/file-writer.js +84 -81
- package/dist/sync/index.d.ts +0 -1
- package/dist/sync/index.js +0 -1
- package/dist/sync/manifest.js +1 -1
- package/dist/sync/pr-merge-handler.js +6 -6
- package/dist/sync/sync-workflow.js +1 -1
- package/dist/sync/types.d.ts +2 -2
- package/dist/vcs/ado-pr-strategy.d.ts +3 -5
- package/dist/vcs/ado-pr-strategy.js +131 -33
- package/dist/vcs/authenticated-git-ops.js +45 -23
- package/dist/vcs/git-commit-strategy.js +10 -6
- package/dist/vcs/git-ops.js +30 -24
- package/dist/vcs/github-pr-strategy.d.ts +3 -2
- package/dist/vcs/github-pr-strategy.js +80 -30
- package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
- package/dist/vcs/gitlab-pr-strategy.js +88 -87
- package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
- package/dist/vcs/graphql-commit-strategy.js +21 -37
- package/dist/vcs/pr-creator.js +9 -2
- package/dist/vcs/pr-strategy.d.ts +2 -3
- package/dist/vcs/pr-strategy.js +0 -1
- package/dist/vcs/types.d.ts +9 -5
- package/package.json +5 -5
- package/dist/config/validators/index.d.ts +0 -3
- package/dist/config/validators/index.js +0 -6
- package/dist/output/types.d.ts +0 -20
- package/dist/output/types.js +0 -1
- package/dist/shared/shell-utils.d.ts +0 -6
- package/dist/shared/shell-utils.js +0 -17
- /package/dist/{shared → config}/env.d.ts +0 -0
- /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
- /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
- /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
- /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
|
@@ -4,7 +4,15 @@ import { ValidationError } from "../../shared/errors.js";
|
|
|
4
4
|
import { isPlainObject } from "../../shared/type-guards.js";
|
|
5
5
|
export { isTextContent };
|
|
6
6
|
export { isPlainObject as isObjectContent };
|
|
7
|
-
|
|
7
|
+
export function validValues(values) {
|
|
8
|
+
return values;
|
|
9
|
+
}
|
|
10
|
+
const VALID_STRATEGIES = validValues([
|
|
11
|
+
"replace",
|
|
12
|
+
"append",
|
|
13
|
+
"prepend",
|
|
14
|
+
"merge",
|
|
15
|
+
]);
|
|
8
16
|
/**
|
|
9
17
|
* Check if file extension is for structured output (JSON/YAML).
|
|
10
18
|
*/
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { resolveExtendsChain } from "../extends-resolver.js";
|
|
2
|
+
import { isPlainObject } from "../../shared/type-guards.js";
|
|
3
|
+
import { ValidationError } from "../../shared/errors.js";
|
|
4
|
+
import { validateFileConfigFields, validateSettings, buildRootSettingsContext, } from "./shared.js";
|
|
5
|
+
function validateGroupExtends(groupName, extends_, groupNames) {
|
|
6
|
+
if (typeof extends_ === "string") {
|
|
7
|
+
if (extends_.length === 0) {
|
|
8
|
+
throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
|
|
9
|
+
}
|
|
10
|
+
validateExtendsEntries(groupName, [extends_], groupNames);
|
|
11
|
+
}
|
|
12
|
+
else if (Array.isArray(extends_)) {
|
|
13
|
+
if (extends_.length === 0) {
|
|
14
|
+
throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
|
|
15
|
+
}
|
|
16
|
+
validateExtendsEntries(groupName, extends_, groupNames);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function validateExtendsEntries(groupName, entries, groupNames) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (typeof entry !== "string") {
|
|
26
|
+
throw new ValidationError(`groups.${groupName}: 'extends' array entries must be strings`);
|
|
27
|
+
}
|
|
28
|
+
if (entry.length === 0) {
|
|
29
|
+
throw new ValidationError(`groups.${groupName}: 'extends' array entries must be non-empty strings`);
|
|
30
|
+
}
|
|
31
|
+
if (entry === groupName) {
|
|
32
|
+
throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
|
|
33
|
+
}
|
|
34
|
+
if (!groupNames.has(entry)) {
|
|
35
|
+
throw new ValidationError(`groups.${groupName}: extends references undefined group '${entry}'`);
|
|
36
|
+
}
|
|
37
|
+
if (seen.has(entry)) {
|
|
38
|
+
throw new ValidationError(`groups.${groupName}: duplicate '${entry}' in extends`);
|
|
39
|
+
}
|
|
40
|
+
seen.add(entry);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function validateNoCircularExtends(groups) {
|
|
44
|
+
for (const name of Object.keys(groups)) {
|
|
45
|
+
if (!groups[name].extends)
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
resolveExtendsChain(name, groups);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new ValidationError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function validateGroupRefArray(arr, fieldName, ctx, groupNames) {
|
|
56
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
57
|
+
throw new ValidationError(`${ctx}: '${fieldName}' must be a non-empty array of strings`);
|
|
58
|
+
}
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const name of arr) {
|
|
61
|
+
if (typeof name !== "string") {
|
|
62
|
+
throw new ValidationError(`${ctx}: '${fieldName}' entries must be strings`);
|
|
63
|
+
}
|
|
64
|
+
if (!groupNames.includes(name)) {
|
|
65
|
+
throw new ValidationError(`${ctx}: group '${name}' in ${fieldName} is not defined in root 'groups'`);
|
|
66
|
+
}
|
|
67
|
+
if (seen.has(name)) {
|
|
68
|
+
throw new ValidationError(`${ctx}: duplicate group '${name}' in ${fieldName}`);
|
|
69
|
+
}
|
|
70
|
+
seen.add(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function validateGroups(config) {
|
|
74
|
+
if (config.groups === undefined)
|
|
75
|
+
return;
|
|
76
|
+
if (!isPlainObject(config.groups)) {
|
|
77
|
+
throw new ValidationError("groups must be an object");
|
|
78
|
+
}
|
|
79
|
+
const rootCtx = buildRootSettingsContext(config);
|
|
80
|
+
const groupNames = new Set(Object.keys(config.groups));
|
|
81
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
82
|
+
if (groupName === "inherit") {
|
|
83
|
+
throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
|
|
84
|
+
}
|
|
85
|
+
if (groupName === "extends") {
|
|
86
|
+
throw new ValidationError("'extends' is a reserved key and cannot be used as a group name");
|
|
87
|
+
}
|
|
88
|
+
if (group.extends !== undefined) {
|
|
89
|
+
validateGroupExtends(groupName, group.extends, groupNames);
|
|
90
|
+
}
|
|
91
|
+
if (group.files) {
|
|
92
|
+
for (const [fileName, fileConfig] of Object.entries(group.files)) {
|
|
93
|
+
if (fileName === "inherit")
|
|
94
|
+
continue;
|
|
95
|
+
if (fileConfig === false)
|
|
96
|
+
continue;
|
|
97
|
+
if (fileConfig === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
validateFileConfigFields(fileConfig, fileName, `groups.${groupName}:`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (group.settings !== undefined) {
|
|
103
|
+
validateSettings(group.settings, `groups.${groupName}`, rootCtx);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
validateNoCircularExtends(config.groups);
|
|
107
|
+
}
|
|
108
|
+
export function validateConditionalGroups(config) {
|
|
109
|
+
if (config.conditionalGroups === undefined)
|
|
110
|
+
return;
|
|
111
|
+
if (!Array.isArray(config.conditionalGroups)) {
|
|
112
|
+
throw new ValidationError("conditionalGroups must be an array");
|
|
113
|
+
}
|
|
114
|
+
const rootCtx = buildRootSettingsContext(config);
|
|
115
|
+
const groupNames = config.groups ? Object.keys(config.groups) : [];
|
|
116
|
+
for (let i = 0; i < config.conditionalGroups.length; i++) {
|
|
117
|
+
const entry = config.conditionalGroups[i];
|
|
118
|
+
const ctx = `conditionalGroups[${i}]`;
|
|
119
|
+
if (!entry.when || !isPlainObject(entry.when)) {
|
|
120
|
+
throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
|
|
121
|
+
}
|
|
122
|
+
const { allOf, anyOf, noneOf } = entry.when;
|
|
123
|
+
if (!allOf && !anyOf && !noneOf) {
|
|
124
|
+
throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf', 'anyOf', or 'noneOf'`);
|
|
125
|
+
}
|
|
126
|
+
if (allOf !== undefined) {
|
|
127
|
+
validateGroupRefArray(allOf, "allOf", ctx, groupNames);
|
|
128
|
+
}
|
|
129
|
+
if (anyOf !== undefined) {
|
|
130
|
+
validateGroupRefArray(anyOf, "anyOf", ctx, groupNames);
|
|
131
|
+
}
|
|
132
|
+
if (noneOf !== undefined) {
|
|
133
|
+
validateGroupRefArray(noneOf, "noneOf", ctx, groupNames);
|
|
134
|
+
}
|
|
135
|
+
if (noneOf !== undefined) {
|
|
136
|
+
const noneOfSet = new Set(noneOf);
|
|
137
|
+
if (allOf !== undefined) {
|
|
138
|
+
for (const g of allOf) {
|
|
139
|
+
if (noneOfSet.has(g)) {
|
|
140
|
+
throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with allOf (contradictory condition)`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (anyOf !== undefined) {
|
|
145
|
+
for (const g of anyOf) {
|
|
146
|
+
if (noneOfSet.has(g)) {
|
|
147
|
+
throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with anyOf (contradictory condition)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (entry.files) {
|
|
153
|
+
for (const [fileName, fileConfig] of Object.entries(entry.files)) {
|
|
154
|
+
if (fileName === "inherit")
|
|
155
|
+
continue;
|
|
156
|
+
if (fileConfig === false)
|
|
157
|
+
continue;
|
|
158
|
+
if (fileConfig === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
validateFileConfigFields(fileConfig, fileName, `${ctx}:`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (entry.settings !== undefined) {
|
|
164
|
+
validateSettings(entry.settings, ctx, rootCtx);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { expandRepoGroups } from "../extends-resolver.js";
|
|
2
|
+
import { validateFileName } from "./file-validator.js";
|
|
3
|
+
import { isPlainObject } from "../../shared/type-guards.js";
|
|
4
|
+
import { escapeRegExp } from "../../shared/regex-utils.js";
|
|
5
|
+
import { ValidationError } from "../../shared/errors.js";
|
|
6
|
+
import { validateFileConfigFields, validateSettings, buildRootSettingsContext, enrichSettingsContext, } from "./shared.js";
|
|
7
|
+
function isValidGitUrl(url) {
|
|
8
|
+
return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
|
|
9
|
+
}
|
|
10
|
+
function isGitHubUrl(url, githubHosts) {
|
|
11
|
+
const hosts = ["github.com", ...(githubHosts ?? [])];
|
|
12
|
+
for (const host of hosts) {
|
|
13
|
+
if (url.startsWith(`git@${host}:`) ||
|
|
14
|
+
url.match(new RegExp(`^https?://${escapeRegExp(host)}/`))) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function getGitDisplayName(git) {
|
|
21
|
+
if (Array.isArray(git)) {
|
|
22
|
+
return git.length > 0 ? git[0] : "(empty git array)";
|
|
23
|
+
}
|
|
24
|
+
return git;
|
|
25
|
+
}
|
|
26
|
+
function validateRepoGitField(repo, index) {
|
|
27
|
+
if (!repo.git) {
|
|
28
|
+
throw new ValidationError(`Repo at index ${index} missing required field: git`);
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
31
|
+
throw new ValidationError(`Repo at index ${index} has empty git array`);
|
|
32
|
+
}
|
|
33
|
+
return getGitDisplayName(repo.git);
|
|
34
|
+
}
|
|
35
|
+
function validateRepoOrigins(config, repo, repoLabel) {
|
|
36
|
+
if (repo.upstream !== undefined && repo.source !== undefined) {
|
|
37
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' and 'source' are mutually exclusive. ` +
|
|
38
|
+
`Use 'upstream' to fork, or 'source' to migrate, not both.`);
|
|
39
|
+
}
|
|
40
|
+
if (repo.upstream !== undefined) {
|
|
41
|
+
if (typeof repo.upstream !== "string") {
|
|
42
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a string`);
|
|
43
|
+
}
|
|
44
|
+
if (!isValidGitUrl(repo.upstream)) {
|
|
45
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a valid git URL ` +
|
|
46
|
+
`(SSH: git@host:path or HTTPS: https://host/path)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (repo.source !== undefined) {
|
|
50
|
+
if (typeof repo.source !== "string") {
|
|
51
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' must be a string`);
|
|
52
|
+
}
|
|
53
|
+
if (!isValidGitUrl(repo.source)) {
|
|
54
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' must be a valid git URL ` +
|
|
55
|
+
`(SSH: git@host:path or HTTPS: https://host/path)`);
|
|
56
|
+
}
|
|
57
|
+
if (isGitHubUrl(repo.source, config.githubHosts)) {
|
|
58
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' cannot be a GitHub URL. ` +
|
|
59
|
+
`Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function validateRepoGroups(config, repo, index) {
|
|
64
|
+
if (repo.groups === undefined)
|
|
65
|
+
return;
|
|
66
|
+
if (!Array.isArray(repo.groups) ||
|
|
67
|
+
!repo.groups.every((g) => typeof g === "string")) {
|
|
68
|
+
throw new ValidationError(`Repo at index ${index}: groups must be an array of strings`);
|
|
69
|
+
}
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const groupName of repo.groups) {
|
|
72
|
+
if (!config.groups || !config.groups[groupName]) {
|
|
73
|
+
throw new ValidationError(`Repo at index ${index}: group '${groupName}' is not defined in root 'groups'`);
|
|
74
|
+
}
|
|
75
|
+
if (seen.has(groupName)) {
|
|
76
|
+
throw new ValidationError(`Repo at index ${index}: duplicate group '${groupName}'`);
|
|
77
|
+
}
|
|
78
|
+
seen.add(groupName);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function validateRepoFiles(config, repo, index, repoLabel) {
|
|
82
|
+
if (!repo.files)
|
|
83
|
+
return;
|
|
84
|
+
if (!isPlainObject(repo.files)) {
|
|
85
|
+
throw new ValidationError(`Repo at index ${index}: files must be an object`);
|
|
86
|
+
}
|
|
87
|
+
const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
|
|
88
|
+
if (repo.groups && config.groups) {
|
|
89
|
+
const expandedGroups = expandRepoGroups(repo.groups, config.groups);
|
|
90
|
+
for (const groupName of expandedGroups) {
|
|
91
|
+
const group = config.groups[groupName];
|
|
92
|
+
if (group?.files) {
|
|
93
|
+
for (const fileName of Object.keys(group.files)) {
|
|
94
|
+
if (fileName !== "inherit")
|
|
95
|
+
knownFiles.add(fileName);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (config.conditionalGroups) {
|
|
101
|
+
for (const cg of config.conditionalGroups) {
|
|
102
|
+
if (cg.files) {
|
|
103
|
+
for (const fileName of Object.keys(cg.files)) {
|
|
104
|
+
if (fileName !== "inherit")
|
|
105
|
+
knownFiles.add(fileName);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const fileName of Object.keys(repo.files)) {
|
|
111
|
+
if (fileName === "inherit") {
|
|
112
|
+
const inheritValue = repo.files.inherit;
|
|
113
|
+
if (typeof inheritValue !== "boolean") {
|
|
114
|
+
throw new ValidationError(`Repo at index ${index}: files.inherit must be a boolean`);
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!knownFiles.has(fileName)) {
|
|
119
|
+
const fileEntry = repo.files[fileName];
|
|
120
|
+
const isStandaloneDefinition = fileEntry != null &&
|
|
121
|
+
fileEntry !== false &&
|
|
122
|
+
typeof fileEntry === "object" &&
|
|
123
|
+
"content" in fileEntry &&
|
|
124
|
+
fileEntry.content !== undefined &&
|
|
125
|
+
fileEntry.content !== null;
|
|
126
|
+
if (!isStandaloneDefinition) {
|
|
127
|
+
throw new ValidationError(`Repo at index ${index} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group, or provide content inline.`);
|
|
128
|
+
}
|
|
129
|
+
validateFileName(fileName);
|
|
130
|
+
}
|
|
131
|
+
const fileOverride = repo.files[fileName];
|
|
132
|
+
if (fileOverride === false) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (fileOverride.override && !fileOverride.content) {
|
|
136
|
+
throw new ValidationError(`Repo ${repoLabel} has override: true for file '${fileName}' but no content defined. ` +
|
|
137
|
+
`Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
|
|
138
|
+
}
|
|
139
|
+
validateFileConfigFields(fileOverride, fileName, `Repo ${repoLabel}:`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function validateRepoSettingsEntry(config, repo, repoLabel) {
|
|
143
|
+
if (repo.settings === undefined)
|
|
144
|
+
return;
|
|
145
|
+
const rootCtx = buildRootSettingsContext(config);
|
|
146
|
+
if (repo.groups && config.groups) {
|
|
147
|
+
const expandedGroups = expandRepoGroups(repo.groups, config.groups);
|
|
148
|
+
for (const groupName of expandedGroups) {
|
|
149
|
+
enrichSettingsContext(rootCtx, config.groups[groupName]?.settings);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (config.conditionalGroups) {
|
|
153
|
+
for (const cg of config.conditionalGroups) {
|
|
154
|
+
enrichSettingsContext(rootCtx, cg.settings);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
|
|
158
|
+
}
|
|
159
|
+
export function validateRepoEntry(config, repo, index) {
|
|
160
|
+
const repoLabel = validateRepoGitField(repo, index);
|
|
161
|
+
validateRepoOrigins(config, repo, repoLabel);
|
|
162
|
+
validateRepoGroups(config, repo, index);
|
|
163
|
+
validateRepoFiles(config, repo, index, repoLabel);
|
|
164
|
+
validateRepoSettingsEntry(config, repo, repoLabel);
|
|
165
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { ValidationError } from "../../shared/errors.js";
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
2
|
+
import { validValues } from "./file-validator.js";
|
|
3
|
+
const VALID_VISIBILITY = validValues([
|
|
4
|
+
"public",
|
|
5
|
+
"private",
|
|
6
|
+
"internal",
|
|
7
|
+
]);
|
|
8
|
+
const VALID_SQUASH_MERGE_COMMIT_TITLE = validValues([
|
|
9
|
+
"PR_TITLE",
|
|
10
|
+
"COMMIT_OR_PR_TITLE",
|
|
11
|
+
]);
|
|
12
|
+
const VALID_SQUASH_MERGE_COMMIT_MESSAGE = validValues(["PR_BODY", "COMMIT_MESSAGES", "BLANK"]);
|
|
13
|
+
const VALID_MERGE_COMMIT_TITLE = validValues([
|
|
14
|
+
"PR_TITLE",
|
|
15
|
+
"MERGE_MESSAGE",
|
|
16
|
+
]);
|
|
17
|
+
const VALID_MERGE_COMMIT_MESSAGE = validValues([
|
|
5
18
|
"PR_BODY",
|
|
6
|
-
"
|
|
19
|
+
"PR_TITLE",
|
|
7
20
|
"BLANK",
|
|
8
|
-
];
|
|
9
|
-
const VALID_MERGE_COMMIT_TITLE = ["PR_TITLE", "MERGE_MESSAGE"];
|
|
10
|
-
const VALID_MERGE_COMMIT_MESSAGE = ["PR_BODY", "PR_TITLE", "BLANK"];
|
|
21
|
+
]);
|
|
11
22
|
/**
|
|
12
23
|
* Validates GitHub repository settings.
|
|
13
24
|
*/
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { ValidationError } from "../../shared/errors.js";
|
|
2
2
|
import { isPlainObject } from "../../shared/type-guards.js";
|
|
3
|
-
|
|
4
|
-
function validValues(values) {
|
|
5
|
-
return values;
|
|
6
|
-
}
|
|
3
|
+
import { validValues } from "./file-validator.js";
|
|
7
4
|
const VALID_RULESET_TARGETS = validValues(["branch", "tag"]);
|
|
8
5
|
const VALID_ENFORCEMENT_LEVELS = validValues([
|
|
9
6
|
"active",
|
|
@@ -64,7 +61,7 @@ const VALID_RULE_TYPES = validValues([
|
|
|
64
61
|
"max_file_size",
|
|
65
62
|
]);
|
|
66
63
|
// Intentionally duplicated from merge.ts — validator should not depend on merge internals
|
|
67
|
-
const VALID_MERGE_STRATEGIES = ["replace", "append", "prepend"];
|
|
64
|
+
const VALID_MERGE_STRATEGIES = ["replace", "append", "prepend", "merge"];
|
|
68
65
|
/**
|
|
69
66
|
* Checks if a value is an $arrayMerge directive: { $arrayMerge: strategy, $values: [...] }
|
|
70
67
|
*/
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RawConfig, RawRootSettings, RawRepoSettings } from "../types.js";
|
|
2
|
+
export interface RootSettingsContext {
|
|
3
|
+
rulesetNames: string[];
|
|
4
|
+
hasRepoSettings: boolean;
|
|
5
|
+
hasCodeScanningSettings: boolean;
|
|
6
|
+
labelNames: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function buildRootSettingsContext(config: RawConfig): RootSettingsContext;
|
|
9
|
+
export declare function validateFileConfigFields(fileConfig: Record<string, unknown>, fileName: string, context: string): void;
|
|
10
|
+
export declare function validateSettings(settings: unknown, context: string, rootCtx?: RootSettingsContext): void;
|
|
11
|
+
export declare function enrichSettingsContext(rootCtx: RootSettingsContext, settings: RawRepoSettings | RawRootSettings | undefined): void;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { isTextContent, isObjectContent, isStructuredFileExtension, validValues, VALID_STRATEGIES, } from "./file-validator.js";
|
|
2
|
+
import { validateRuleset } from "./ruleset-validator.js";
|
|
3
|
+
import { validateRepoSettings } from "./repo-settings-validator.js";
|
|
4
|
+
import { isPlainObject } from "../../shared/type-guards.js";
|
|
5
|
+
import { ValidationError } from "../../shared/errors.js";
|
|
6
|
+
export function buildRootSettingsContext(config) {
|
|
7
|
+
return {
|
|
8
|
+
rulesetNames: config.settings?.rulesets
|
|
9
|
+
? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
|
|
10
|
+
: [],
|
|
11
|
+
hasRepoSettings: config.settings?.repo !== undefined && config.settings.repo !== false,
|
|
12
|
+
hasCodeScanningSettings: config.settings?.codeScanning !== undefined &&
|
|
13
|
+
config.settings.codeScanning !== false,
|
|
14
|
+
labelNames: config.settings?.labels
|
|
15
|
+
? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
|
|
16
|
+
: [],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function validateFileConfigFields(fileConfig, fileName, context) {
|
|
20
|
+
if (fileConfig.content !== undefined) {
|
|
21
|
+
const hasText = isTextContent(fileConfig.content);
|
|
22
|
+
const hasObject = isObjectContent(fileConfig.content);
|
|
23
|
+
if (!hasText && !hasObject) {
|
|
24
|
+
throw new ValidationError(`${context} file '${fileName}' content must be an object, string, or array of strings`);
|
|
25
|
+
}
|
|
26
|
+
const isStructured = isStructuredFileExtension(fileName);
|
|
27
|
+
if (isStructured && hasText) {
|
|
28
|
+
throw new ValidationError(`${context} file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
29
|
+
}
|
|
30
|
+
if (!isStructured && hasObject) {
|
|
31
|
+
throw new ValidationError(`${context} file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (fileConfig.mergeStrategy !== undefined &&
|
|
35
|
+
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
36
|
+
throw new ValidationError(`${context} file '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
const booleanFields = [
|
|
39
|
+
"createOnly",
|
|
40
|
+
"executable",
|
|
41
|
+
"template",
|
|
42
|
+
"deleteOrphaned",
|
|
43
|
+
];
|
|
44
|
+
for (const field of booleanFields) {
|
|
45
|
+
if (fileConfig[field] !== undefined &&
|
|
46
|
+
typeof fileConfig[field] !== "boolean") {
|
|
47
|
+
throw new ValidationError(`${context} file '${fileName}' ${field} must be a boolean`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const stringFields = ["schemaUrl"];
|
|
51
|
+
for (const field of stringFields) {
|
|
52
|
+
if (fileConfig[field] !== undefined &&
|
|
53
|
+
typeof fileConfig[field] !== "string") {
|
|
54
|
+
throw new ValidationError(`${context} file '${fileName}' ${field} must be a string`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (fileConfig.header !== undefined) {
|
|
58
|
+
if (typeof fileConfig.header !== "string" &&
|
|
59
|
+
(!Array.isArray(fileConfig.header) ||
|
|
60
|
+
!fileConfig.header.every((h) => typeof h === "string"))) {
|
|
61
|
+
throw new ValidationError(`${context} file '${fileName}' header must be a string or array of strings`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (fileConfig.vars !== undefined) {
|
|
65
|
+
if (!isPlainObject(fileConfig.vars)) {
|
|
66
|
+
throw new ValidationError(`${context} file '${fileName}' vars must be an object with string values`);
|
|
67
|
+
}
|
|
68
|
+
for (const [key, value] of Object.entries(fileConfig.vars)) {
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
throw new ValidationError(`${context} file '${fileName}' vars.${key} must be a string`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function validateLabel(label, name, context) {
|
|
76
|
+
if (!isPlainObject(label)) {
|
|
77
|
+
throw new ValidationError(`${context}: label '${name}' must be an object`);
|
|
78
|
+
}
|
|
79
|
+
const l = label;
|
|
80
|
+
if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
|
|
81
|
+
throw new ValidationError(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
|
|
82
|
+
}
|
|
83
|
+
if (l.description !== undefined) {
|
|
84
|
+
if (typeof l.description !== "string") {
|
|
85
|
+
throw new ValidationError(`${context}: label '${name}' description must be a string`);
|
|
86
|
+
}
|
|
87
|
+
if (l.description.length > 100) {
|
|
88
|
+
throw new ValidationError(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (l.new_name !== undefined && typeof l.new_name !== "string") {
|
|
92
|
+
throw new ValidationError(`${context}: label '${name}' new_name must be a string`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const VALID_CODE_SCANNING_STATES = validValues([
|
|
96
|
+
"configured",
|
|
97
|
+
"not-configured",
|
|
98
|
+
]);
|
|
99
|
+
const VALID_CODE_SCANNING_QUERY_SUITES = validValues([
|
|
100
|
+
"default",
|
|
101
|
+
"extended",
|
|
102
|
+
]);
|
|
103
|
+
const VALID_CODE_SCANNING_LANGUAGES = validValues([
|
|
104
|
+
"actions",
|
|
105
|
+
"c-cpp",
|
|
106
|
+
"csharp",
|
|
107
|
+
"go",
|
|
108
|
+
"java-kotlin",
|
|
109
|
+
"javascript-typescript",
|
|
110
|
+
"python",
|
|
111
|
+
"ruby",
|
|
112
|
+
"swift",
|
|
113
|
+
]);
|
|
114
|
+
function validateCodeScanningSettings(settings, context) {
|
|
115
|
+
if (!isPlainObject(settings)) {
|
|
116
|
+
throw new ValidationError(`${context}: must be an object`);
|
|
117
|
+
}
|
|
118
|
+
if (settings.state === undefined) {
|
|
119
|
+
throw new ValidationError(`${context}: state is required`);
|
|
120
|
+
}
|
|
121
|
+
if (!VALID_CODE_SCANNING_STATES.includes(settings.state)) {
|
|
122
|
+
throw new ValidationError(`${context}: state must be one of: ${VALID_CODE_SCANNING_STATES.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
if (settings.querySuite !== undefined &&
|
|
125
|
+
!VALID_CODE_SCANNING_QUERY_SUITES.includes(settings.querySuite)) {
|
|
126
|
+
throw new ValidationError(`${context}: querySuite must be one of: ${VALID_CODE_SCANNING_QUERY_SUITES.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
if (settings.languages !== undefined) {
|
|
129
|
+
if (!Array.isArray(settings.languages)) {
|
|
130
|
+
throw new ValidationError(`${context}: languages must be an array`);
|
|
131
|
+
}
|
|
132
|
+
for (const lang of settings.languages) {
|
|
133
|
+
if (!VALID_CODE_SCANNING_LANGUAGES.includes(lang)) {
|
|
134
|
+
throw new ValidationError(`${context}: invalid language "${lang}". Valid languages: ${VALID_CODE_SCANNING_LANGUAGES.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function validateSettingsRulesets(settings, context, rootCtx) {
|
|
140
|
+
if (settings.rulesets === undefined)
|
|
141
|
+
return;
|
|
142
|
+
if (!isPlainObject(settings.rulesets)) {
|
|
143
|
+
throw new ValidationError(`${context}: rulesets must be an object`);
|
|
144
|
+
}
|
|
145
|
+
const rulesets = settings.rulesets;
|
|
146
|
+
for (const [name, ruleset] of Object.entries(rulesets)) {
|
|
147
|
+
if (name === "inherit")
|
|
148
|
+
continue;
|
|
149
|
+
if (ruleset === false) {
|
|
150
|
+
if (rootCtx && !rootCtx.rulesetNames.includes(name)) {
|
|
151
|
+
throw new ValidationError(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
validateRuleset(ruleset, name, context);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateSettingsLabels(settings, context, rootCtx) {
|
|
159
|
+
if (settings.labels === undefined)
|
|
160
|
+
return;
|
|
161
|
+
if (!isPlainObject(settings.labels)) {
|
|
162
|
+
throw new ValidationError(`${context}: labels must be an object`);
|
|
163
|
+
}
|
|
164
|
+
const labels = settings.labels;
|
|
165
|
+
for (const [name, label] of Object.entries(labels)) {
|
|
166
|
+
if (name === "inherit")
|
|
167
|
+
continue;
|
|
168
|
+
if (label === false) {
|
|
169
|
+
if (rootCtx && !rootCtx.labelNames.includes(name)) {
|
|
170
|
+
throw new ValidationError(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
validateLabel(label, name, context);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function validateSettingsDeleteOrphaned(settings, context) {
|
|
178
|
+
if (settings.deleteOrphaned !== undefined &&
|
|
179
|
+
typeof settings.deleteOrphaned !== "boolean") {
|
|
180
|
+
throw new ValidationError(`${context}: settings.deleteOrphaned must be a boolean`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function validateSettingsRepo(settings, context, rootCtx) {
|
|
184
|
+
if (settings.repo === undefined)
|
|
185
|
+
return;
|
|
186
|
+
if (settings.repo === false) {
|
|
187
|
+
if (!rootCtx) {
|
|
188
|
+
throw new ValidationError(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
|
|
189
|
+
}
|
|
190
|
+
if (!rootCtx.hasRepoSettings) {
|
|
191
|
+
throw new ValidationError(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
validateRepoSettings(settings.repo, context);
|
|
196
|
+
}
|
|
197
|
+
function validateSettingsCodeScanning(settings, context, rootCtx) {
|
|
198
|
+
if (settings.codeScanning === undefined)
|
|
199
|
+
return;
|
|
200
|
+
if (settings.codeScanning === false) {
|
|
201
|
+
if (!rootCtx) {
|
|
202
|
+
throw new ValidationError(`${context}: codeScanning: false is not valid at root level. Define codeScanning settings or remove the field.`);
|
|
203
|
+
}
|
|
204
|
+
if (!rootCtx.hasCodeScanningSettings) {
|
|
205
|
+
throw new ValidationError(`${context}: Cannot opt out of code scanning settings — not defined in root settings.codeScanning`);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
validateCodeScanningSettings(settings.codeScanning, `${context} codeScanning`);
|
|
210
|
+
}
|
|
211
|
+
export function validateSettings(settings, context, rootCtx) {
|
|
212
|
+
if (!isPlainObject(settings)) {
|
|
213
|
+
throw new ValidationError(`${context}: settings must be an object`);
|
|
214
|
+
}
|
|
215
|
+
validateSettingsRulesets(settings, context, rootCtx);
|
|
216
|
+
validateSettingsLabels(settings, context, rootCtx);
|
|
217
|
+
validateSettingsDeleteOrphaned(settings, context);
|
|
218
|
+
validateSettingsRepo(settings, context, rootCtx);
|
|
219
|
+
validateSettingsCodeScanning(settings, context, rootCtx);
|
|
220
|
+
}
|
|
221
|
+
export function enrichSettingsContext(rootCtx, settings) {
|
|
222
|
+
if (!settings)
|
|
223
|
+
return;
|
|
224
|
+
if (settings.rulesets) {
|
|
225
|
+
for (const name of Object.keys(settings.rulesets)) {
|
|
226
|
+
if (name !== "inherit")
|
|
227
|
+
rootCtx.rulesetNames.push(name);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (settings.labels) {
|
|
231
|
+
for (const name of Object.keys(settings.labels)) {
|
|
232
|
+
if (name !== "inherit")
|
|
233
|
+
rootCtx.labelNames.push(name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (settings.repo !== undefined && settings.repo !== false) {
|
|
237
|
+
rootCtx.hasRepoSettings = true;
|
|
238
|
+
}
|
|
239
|
+
if (settings.codeScanning !== undefined && settings.codeScanning !== false) {
|
|
240
|
+
rootCtx.hasCodeScanningSettings = true;
|
|
241
|
+
}
|
|
242
|
+
}
|