@aspruyt/xfg 4.0.0 → 4.0.2
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/README.md +1 -2
- package/dist/cli/index.d.ts +1 -2
- package/dist/cli/index.js +0 -1
- package/dist/cli/program.js +7 -2
- package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
- package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
- package/dist/cli/settings-report-builder.d.ts +1 -3
- package/dist/cli/sync-command.d.ts +2 -24
- package/dist/cli/sync-command.js +295 -301
- package/dist/cli/types.d.ts +60 -40
- package/dist/cli/types.js +1 -12
- package/dist/config/errors.d.ts +9 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/file-reference-resolver.d.ts +2 -1
- package/dist/config/file-reference-resolver.js +10 -8
- package/dist/config/formatter.d.ts +3 -2
- package/dist/config/index.d.ts +4 -6
- package/dist/config/index.js +4 -8
- package/dist/config/loader.js +4 -2
- package/dist/config/merge.d.ts +0 -9
- package/dist/config/merge.js +2 -7
- package/dist/config/normalizer.d.ts +4 -0
- package/dist/config/normalizer.js +61 -110
- package/dist/config/types.d.ts +15 -19
- package/dist/config/types.js +1 -1
- package/dist/config/validator.d.ts +0 -4
- package/dist/config/validator.js +286 -363
- package/dist/config/validators/file-validator.d.ts +2 -8
- package/dist/config/validators/file-validator.js +6 -17
- package/dist/config/validators/index.d.ts +3 -3
- package/dist/config/validators/index.js +3 -3
- package/dist/config/validators/repo-settings-validator.d.ts +0 -6
- package/dist/config/validators/repo-settings-validator.js +9 -9
- package/dist/config/validators/ruleset-validator.d.ts +0 -14
- package/dist/config/validators/ruleset-validator.js +28 -28
- package/dist/lifecycle/ado-migration-source.js +2 -1
- package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
- package/dist/lifecycle/github-lifecycle-provider.js +79 -90
- package/dist/lifecycle/index.d.ts +2 -6
- package/dist/lifecycle/index.js +0 -4
- package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
- package/dist/lifecycle/lifecycle-formatter.js +4 -0
- package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
- package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
- package/dist/lifecycle/types.d.ts +0 -8
- package/dist/output/github-summary.d.ts +5 -0
- package/dist/output/github-summary.js +9 -2
- package/dist/output/index.d.ts +2 -2
- package/dist/output/index.js +1 -1
- package/dist/output/lifecycle-report.js +5 -23
- package/dist/output/settings-report.d.ts +14 -3
- package/dist/output/settings-report.js +137 -197
- package/dist/output/summary-utils.d.ts +1 -1
- package/dist/output/summary-utils.js +2 -1
- package/dist/output/sync-report.js +5 -8
- package/dist/output/unified-summary.d.ts +2 -1
- package/dist/output/unified-summary.js +71 -133
- package/dist/settings/base-processor.d.ts +67 -0
- package/dist/settings/base-processor.js +91 -0
- package/dist/settings/index.d.ts +4 -3
- package/dist/settings/index.js +3 -3
- package/dist/settings/labels/converter.d.ts +2 -1
- package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
- package/dist/settings/labels/github-labels-strategy.js +17 -73
- package/dist/settings/labels/index.d.ts +2 -6
- package/dist/settings/labels/index.js +1 -9
- package/dist/settings/labels/processor.d.ts +6 -30
- package/dist/settings/labels/processor.js +62 -152
- package/dist/settings/labels/types.d.ts +5 -8
- package/dist/settings/repo-settings/formatter.d.ts +2 -2
- package/dist/settings/repo-settings/formatter.js +6 -6
- package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
- package/dist/settings/repo-settings/index.d.ts +2 -5
- package/dist/settings/repo-settings/index.js +1 -9
- package/dist/settings/repo-settings/processor.d.ts +6 -27
- package/dist/settings/repo-settings/processor.js +51 -104
- package/dist/settings/repo-settings/types.d.ts +7 -9
- package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
- package/dist/settings/rulesets/diff-algorithm.js +1 -10
- package/dist/settings/rulesets/diff.d.ts +1 -1
- package/dist/settings/rulesets/diff.js +2 -21
- package/dist/settings/rulesets/formatter.d.ts +1 -3
- package/dist/settings/rulesets/formatter.js +1 -8
- package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
- package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
- package/dist/settings/rulesets/index.d.ts +3 -6
- package/dist/settings/rulesets/index.js +5 -9
- package/dist/settings/rulesets/processor.d.ts +8 -33
- package/dist/settings/rulesets/processor.js +58 -151
- package/dist/settings/rulesets/types.d.ts +35 -6
- package/dist/shared/command-executor.d.ts +2 -22
- package/dist/shared/command-executor.js +8 -7
- package/dist/shared/env.d.ts +0 -8
- package/dist/shared/env.js +14 -70
- package/dist/shared/file-status.d.ts +2 -0
- package/dist/shared/file-status.js +13 -0
- package/dist/shared/gh-api-utils.d.ts +46 -0
- package/dist/shared/gh-api-utils.js +107 -0
- package/dist/shared/index.d.ts +5 -5
- package/dist/shared/index.js +3 -3
- package/dist/shared/interpolation-engine.d.ts +31 -0
- package/dist/shared/interpolation-engine.js +50 -0
- package/dist/shared/logger.d.ts +3 -7
- package/dist/shared/logger.js +4 -1
- package/dist/shared/repo-detector.d.ts +17 -2
- package/dist/shared/repo-detector.js +27 -0
- package/dist/shared/retry-utils.d.ts +9 -17
- package/dist/shared/retry-utils.js +22 -28
- package/dist/shared/sanitize-utils.d.ts +0 -7
- package/dist/shared/sanitize-utils.js +0 -7
- package/dist/shared/shell-utils.d.ts +1 -0
- package/dist/shared/shell-utils.js +3 -0
- package/dist/shared/string-utils.d.ts +4 -0
- package/dist/shared/string-utils.js +6 -0
- package/dist/shared/type-guards.d.ts +17 -0
- package/dist/shared/type-guards.js +26 -0
- package/dist/shared/workspace-utils.d.ts +0 -4
- package/dist/shared/workspace-utils.js +0 -4
- package/dist/{sync → shared}/xfg-template.d.ts +3 -2
- package/dist/{sync → shared}/xfg-template.js +13 -54
- package/dist/sync/auth-options-builder.d.ts +4 -5
- package/dist/sync/auth-options-builder.js +15 -26
- package/dist/sync/branch-manager.d.ts +5 -0
- package/dist/sync/branch-manager.js +12 -10
- package/dist/sync/commit-push-manager.d.ts +1 -1
- package/dist/sync/commit-push-manager.js +22 -18
- package/dist/sync/diff-utils.d.ts +4 -9
- package/dist/sync/diff-utils.js +2 -19
- package/dist/sync/file-sync-orchestrator.js +9 -8
- package/dist/sync/file-writer.d.ts +2 -1
- package/dist/sync/file-writer.js +3 -6
- package/dist/sync/index.d.ts +2 -15
- package/dist/sync/index.js +0 -19
- package/dist/sync/manifest-manager.d.ts +4 -0
- package/dist/sync/manifest-manager.js +5 -1
- package/dist/sync/manifest.d.ts +10 -41
- package/dist/sync/manifest.js +11 -56
- package/dist/sync/pr-merge-handler.d.ts +2 -6
- package/dist/sync/pr-merge-handler.js +6 -3
- package/dist/sync/repository-processor.d.ts +1 -2
- package/dist/sync/repository-processor.js +20 -12
- package/dist/sync/repository-session.js +5 -14
- package/dist/sync/sync-workflow.js +31 -38
- package/dist/sync/types.d.ts +43 -178
- package/dist/vcs/authenticated-git-ops.d.ts +27 -70
- package/dist/vcs/authenticated-git-ops.js +70 -96
- package/dist/vcs/azure-pr-strategy.d.ts +6 -4
- package/dist/vcs/azure-pr-strategy.js +34 -82
- package/dist/vcs/branch-utils.d.ts +6 -0
- package/dist/vcs/branch-utils.js +29 -0
- package/dist/vcs/commit-strategy-selector.d.ts +5 -0
- package/dist/vcs/commit-strategy-selector.js +10 -0
- package/dist/vcs/git-commit-strategy.js +1 -2
- package/dist/vcs/git-ops.d.ts +15 -59
- package/dist/vcs/git-ops.js +46 -110
- package/dist/vcs/github-app-token-manager.d.ts +0 -6
- package/dist/vcs/github-app-token-manager.js +5 -12
- package/dist/vcs/github-pr-strategy.d.ts +5 -5
- package/dist/vcs/github-pr-strategy.js +44 -122
- package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
- package/dist/vcs/gitlab-pr-strategy.js +39 -87
- package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
- package/dist/vcs/graphql-commit-strategy.js +31 -63
- package/dist/vcs/index.d.ts +3 -16
- package/dist/vcs/index.js +2 -33
- package/dist/vcs/pr-creator.d.ts +9 -9
- package/dist/vcs/pr-creator.js +11 -10
- package/dist/vcs/pr-strategy-factory.d.ts +5 -0
- package/dist/vcs/pr-strategy-factory.js +17 -0
- package/dist/vcs/pr-strategy.d.ts +13 -26
- package/dist/vcs/pr-strategy.js +20 -25
- package/dist/vcs/types.d.ts +87 -21
- package/package.json +2 -1
package/dist/config/validator.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
|
|
2
2
|
import { validateRepoSettings } from "./validators/repo-settings-validator.js";
|
|
3
3
|
import { validateRuleset } from "./validators/ruleset-validator.js";
|
|
4
|
+
import { escapeRegExp } from "../shared/shell-utils.js";
|
|
5
|
+
import { ValidationError } from "./errors.js";
|
|
4
6
|
// Pattern for valid config ID: alphanumeric, hyphens, underscores
|
|
5
7
|
const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
6
8
|
const CONFIG_ID_MAX_LENGTH = 64;
|
|
@@ -19,12 +21,6 @@ function isValidGitUrl(url) {
|
|
|
19
21
|
}
|
|
20
22
|
return false;
|
|
21
23
|
}
|
|
22
|
-
/**
|
|
23
|
-
* Escape special regex characters in a string.
|
|
24
|
-
*/
|
|
25
|
-
function escapeRegExp(str) {
|
|
26
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
-
}
|
|
28
24
|
/**
|
|
29
25
|
* Check if a git URL points to GitHub (github.com).
|
|
30
26
|
* Used to reject GitHub URLs as migration sources (not supported).
|
|
@@ -45,66 +41,136 @@ function getGitDisplayName(git) {
|
|
|
45
41
|
}
|
|
46
42
|
return git;
|
|
47
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate file config fields shared between root files and per-repo overrides.
|
|
46
|
+
*/
|
|
47
|
+
function validateFileConfigFields(fileConfig, fileName, context) {
|
|
48
|
+
if (fileConfig.content !== undefined) {
|
|
49
|
+
const hasText = isTextContent(fileConfig.content);
|
|
50
|
+
const hasObject = isObjectContent(fileConfig.content);
|
|
51
|
+
if (!hasText && !hasObject) {
|
|
52
|
+
throw new ValidationError(`${context} file '${fileName}' content must be an object, string, or array of strings`);
|
|
53
|
+
}
|
|
54
|
+
const isStructured = isStructuredFileExtension(fileName);
|
|
55
|
+
if (isStructured && hasText) {
|
|
56
|
+
throw new ValidationError(`${context} file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
57
|
+
}
|
|
58
|
+
if (!isStructured && hasObject) {
|
|
59
|
+
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.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (fileConfig.mergeStrategy !== undefined &&
|
|
63
|
+
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
64
|
+
throw new ValidationError(`${context} file '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
const booleanFields = [
|
|
67
|
+
"createOnly",
|
|
68
|
+
"executable",
|
|
69
|
+
"template",
|
|
70
|
+
"deleteOrphaned",
|
|
71
|
+
];
|
|
72
|
+
for (const field of booleanFields) {
|
|
73
|
+
if (fileConfig[field] !== undefined &&
|
|
74
|
+
typeof fileConfig[field] !== "boolean") {
|
|
75
|
+
throw new ValidationError(`${context} file '${fileName}' ${field} must be a boolean`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const stringFields = ["schemaUrl"];
|
|
79
|
+
for (const field of stringFields) {
|
|
80
|
+
if (fileConfig[field] !== undefined &&
|
|
81
|
+
typeof fileConfig[field] !== "string") {
|
|
82
|
+
throw new ValidationError(`${context} file '${fileName}' ${field} must be a string`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (fileConfig.header !== undefined) {
|
|
86
|
+
if (typeof fileConfig.header !== "string" &&
|
|
87
|
+
(!Array.isArray(fileConfig.header) ||
|
|
88
|
+
!fileConfig.header.every((h) => typeof h === "string"))) {
|
|
89
|
+
throw new ValidationError(`${context} file '${fileName}' header must be a string or array of strings`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (fileConfig.vars !== undefined) {
|
|
93
|
+
if (typeof fileConfig.vars !== "object" ||
|
|
94
|
+
fileConfig.vars === null ||
|
|
95
|
+
Array.isArray(fileConfig.vars)) {
|
|
96
|
+
throw new ValidationError(`${context} file '${fileName}' vars must be an object with string values`);
|
|
97
|
+
}
|
|
98
|
+
for (const [key, value] of Object.entries(fileConfig.vars)) {
|
|
99
|
+
if (typeof value !== "string") {
|
|
100
|
+
throw new ValidationError(`${context} file '${fileName}' vars.${key} must be a string`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
48
105
|
/**
|
|
49
106
|
* Validates a single label configuration.
|
|
50
107
|
*/
|
|
51
108
|
function validateLabel(label, name, context) {
|
|
52
109
|
if (typeof label !== "object" || label === null || Array.isArray(label)) {
|
|
53
|
-
throw new
|
|
110
|
+
throw new ValidationError(`${context}: label '${name}' must be an object`);
|
|
54
111
|
}
|
|
55
112
|
const l = label;
|
|
56
113
|
if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
|
|
57
|
-
throw new
|
|
114
|
+
throw new ValidationError(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
|
|
58
115
|
}
|
|
59
116
|
if (l.description !== undefined) {
|
|
60
117
|
if (typeof l.description !== "string") {
|
|
61
|
-
throw new
|
|
118
|
+
throw new ValidationError(`${context}: label '${name}' description must be a string`);
|
|
62
119
|
}
|
|
63
120
|
if (l.description.length > 100) {
|
|
64
|
-
throw new
|
|
121
|
+
throw new ValidationError(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
|
|
65
122
|
}
|
|
66
123
|
}
|
|
67
124
|
if (l.new_name !== undefined && typeof l.new_name !== "string") {
|
|
68
|
-
throw new
|
|
125
|
+
throw new ValidationError(`${context}: label '${name}' new_name must be a string`);
|
|
69
126
|
}
|
|
70
127
|
}
|
|
128
|
+
function buildRootSettingsContext(config) {
|
|
129
|
+
return {
|
|
130
|
+
rulesetNames: config.settings?.rulesets
|
|
131
|
+
? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
|
|
132
|
+
: [],
|
|
133
|
+
hasRepoSettings: config.settings?.repo !== undefined && config.settings.repo !== false,
|
|
134
|
+
labelNames: config.settings?.labels
|
|
135
|
+
? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
|
|
136
|
+
: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
71
139
|
/**
|
|
72
140
|
* Validates settings object containing rulesets, labels, and repo settings.
|
|
73
141
|
*/
|
|
74
|
-
|
|
142
|
+
function validateSettings(settings, context, rootRulesetNames, hasRootRepoSettings, rootLabelNames) {
|
|
75
143
|
if (typeof settings !== "object" ||
|
|
76
144
|
settings === null ||
|
|
77
145
|
Array.isArray(settings)) {
|
|
78
|
-
throw new
|
|
146
|
+
throw new ValidationError(`${context}: settings must be an object`);
|
|
79
147
|
}
|
|
80
148
|
const s = settings;
|
|
81
149
|
if (s.rulesets !== undefined) {
|
|
82
150
|
if (typeof s.rulesets !== "object" ||
|
|
83
151
|
s.rulesets === null ||
|
|
84
152
|
Array.isArray(s.rulesets)) {
|
|
85
|
-
throw new
|
|
153
|
+
throw new ValidationError(`${context}: rulesets must be an object`);
|
|
86
154
|
}
|
|
87
155
|
const rulesets = s.rulesets;
|
|
88
156
|
for (const [name, ruleset] of Object.entries(rulesets)) {
|
|
89
157
|
// Skip reserved key
|
|
90
158
|
if (name === "inherit")
|
|
91
159
|
continue;
|
|
92
|
-
// Check for opt-out of non-existent root ruleset
|
|
93
160
|
if (ruleset === false) {
|
|
94
161
|
if (rootRulesetNames && !rootRulesetNames.includes(name)) {
|
|
95
|
-
throw new
|
|
162
|
+
throw new ValidationError(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
|
|
96
163
|
}
|
|
97
164
|
continue; // Skip further validation for false entries
|
|
98
165
|
}
|
|
99
166
|
validateRuleset(ruleset, name, context);
|
|
100
167
|
}
|
|
101
168
|
}
|
|
102
|
-
// Validate labels
|
|
103
169
|
if (s.labels !== undefined) {
|
|
104
170
|
if (typeof s.labels !== "object" ||
|
|
105
171
|
s.labels === null ||
|
|
106
172
|
Array.isArray(s.labels)) {
|
|
107
|
-
throw new
|
|
173
|
+
throw new ValidationError(`${context}: labels must be an object`);
|
|
108
174
|
}
|
|
109
175
|
const labels = s.labels;
|
|
110
176
|
for (const [name, label] of Object.entries(labels)) {
|
|
@@ -112,7 +178,7 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
|
|
|
112
178
|
continue;
|
|
113
179
|
if (label === false) {
|
|
114
180
|
if (rootLabelNames && !rootLabelNames.includes(name)) {
|
|
115
|
-
throw new
|
|
181
|
+
throw new ValidationError(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
|
|
116
182
|
}
|
|
117
183
|
continue;
|
|
118
184
|
}
|
|
@@ -120,18 +186,17 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
|
|
|
120
186
|
}
|
|
121
187
|
}
|
|
122
188
|
if (s.deleteOrphaned !== undefined && typeof s.deleteOrphaned !== "boolean") {
|
|
123
|
-
throw new
|
|
189
|
+
throw new ValidationError(`${context}: settings.deleteOrphaned must be a boolean`);
|
|
124
190
|
}
|
|
125
|
-
// Validate repo settings
|
|
126
191
|
if (s.repo !== undefined) {
|
|
127
192
|
if (s.repo === false) {
|
|
128
193
|
if (!rootRulesetNames) {
|
|
129
194
|
// Root level — repo: false not valid here
|
|
130
|
-
throw new
|
|
195
|
+
throw new ValidationError(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
|
|
131
196
|
}
|
|
132
197
|
// Per-repo level — check root has repo settings to opt out of
|
|
133
198
|
if (!hasRootRepoSettings) {
|
|
134
|
-
throw new
|
|
199
|
+
throw new ValidationError(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
|
|
135
200
|
}
|
|
136
201
|
// Valid opt-out, skip further repo validation
|
|
137
202
|
}
|
|
@@ -140,389 +205,250 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
|
|
|
140
205
|
}
|
|
141
206
|
}
|
|
142
207
|
}
|
|
143
|
-
|
|
144
|
-
* Validates raw config structure before normalization.
|
|
145
|
-
* @throws Error if validation fails
|
|
146
|
-
*/
|
|
147
|
-
export function validateRawConfig(config) {
|
|
148
|
-
// Validate required id field
|
|
208
|
+
function validateConfigId(config) {
|
|
149
209
|
if (!config.id || typeof config.id !== "string") {
|
|
150
|
-
throw new
|
|
210
|
+
throw new ValidationError("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
|
|
151
211
|
}
|
|
152
212
|
if (!CONFIG_ID_PATTERN.test(config.id)) {
|
|
153
|
-
throw new
|
|
213
|
+
throw new ValidationError(`Config 'id' contains invalid characters: '${config.id}'. Use only alphanumeric characters, hyphens, and underscores.`);
|
|
154
214
|
}
|
|
155
215
|
if (config.id.length > CONFIG_ID_MAX_LENGTH) {
|
|
156
|
-
throw new
|
|
157
|
-
}
|
|
158
|
-
// Validate at least one of files or settings exists (including in groups)
|
|
159
|
-
const hasFiles = config.files &&
|
|
160
|
-
typeof config.files === "object" &&
|
|
161
|
-
Object.keys(config.files).length > 0;
|
|
162
|
-
const hasSettings = config.settings && typeof config.settings === "object";
|
|
163
|
-
const hasGroupFiles = config.groups &&
|
|
164
|
-
typeof config.groups === "object" &&
|
|
165
|
-
!Array.isArray(config.groups) &&
|
|
166
|
-
Object.values(config.groups).some((g) => g.files &&
|
|
167
|
-
Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
|
|
168
|
-
const hasGroupSettings = config.groups &&
|
|
169
|
-
typeof config.groups === "object" &&
|
|
170
|
-
!Array.isArray(config.groups) &&
|
|
171
|
-
Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
|
|
172
|
-
if (!hasFiles && !hasSettings && !hasGroupFiles && !hasGroupSettings) {
|
|
173
|
-
throw new Error("Config requires at least one of: 'files' or 'settings'. " +
|
|
174
|
-
"Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
|
|
216
|
+
throw new ValidationError(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
|
|
175
217
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
218
|
+
}
|
|
219
|
+
function validateRootFiles(config) {
|
|
220
|
+
if (!config.files || Object.keys(config.files).length === 0)
|
|
221
|
+
return;
|
|
222
|
+
if ("inherit" in config.files) {
|
|
223
|
+
throw new ValidationError("'inherit' is a reserved key and cannot be used as a filename");
|
|
180
224
|
}
|
|
181
|
-
|
|
182
|
-
for (const fileName of fileNames) {
|
|
225
|
+
for (const fileName of Object.keys(config.files)) {
|
|
183
226
|
validateFileName(fileName);
|
|
184
227
|
const fileConfig = config.files[fileName];
|
|
185
228
|
if (!fileConfig || typeof fileConfig !== "object") {
|
|
186
|
-
throw new
|
|
187
|
-
}
|
|
188
|
-
// Validate content type
|
|
189
|
-
if (fileConfig.content !== undefined) {
|
|
190
|
-
const hasText = isTextContent(fileConfig.content);
|
|
191
|
-
const hasObject = isObjectContent(fileConfig.content);
|
|
192
|
-
if (!hasText && !hasObject) {
|
|
193
|
-
throw new Error(`File '${fileName}' content must be an object, string, or array of strings`);
|
|
194
|
-
}
|
|
195
|
-
// Validate content type matches file extension
|
|
196
|
-
const isStructured = isStructuredFileExtension(fileName);
|
|
197
|
-
if (isStructured && hasText) {
|
|
198
|
-
throw new Error(`File '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
199
|
-
}
|
|
200
|
-
if (!isStructured && hasObject) {
|
|
201
|
-
throw new Error(`File '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (fileConfig.mergeStrategy !== undefined &&
|
|
205
|
-
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
206
|
-
throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
229
|
+
throw new ValidationError(`File '${fileName}' must have a configuration object`);
|
|
207
230
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
validateFileConfigFields(fileConfig, fileName, `File '${fileName}':`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validateRootSettings(config) {
|
|
235
|
+
if (config.settings === undefined)
|
|
236
|
+
return;
|
|
237
|
+
validateSettings(config.settings, "Root");
|
|
238
|
+
if (config.settings.rulesets && "inherit" in config.settings.rulesets) {
|
|
239
|
+
throw new ValidationError("'inherit' is a reserved key and cannot be used as a ruleset name");
|
|
240
|
+
}
|
|
241
|
+
if (config.settings.labels && "inherit" in config.settings.labels) {
|
|
242
|
+
throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function validateGithubHosts(config) {
|
|
246
|
+
if (config.githubHosts === undefined)
|
|
247
|
+
return;
|
|
248
|
+
if (!Array.isArray(config.githubHosts) ||
|
|
249
|
+
!config.githubHosts.every((h) => typeof h === "string")) {
|
|
250
|
+
throw new ValidationError("githubHosts must be an array of strings");
|
|
251
|
+
}
|
|
252
|
+
for (const host of config.githubHosts) {
|
|
253
|
+
if (!host) {
|
|
254
|
+
throw new ValidationError("githubHosts entries must be non-empty hostnames");
|
|
215
255
|
}
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
(!Array.isArray(fileConfig.header) ||
|
|
219
|
-
!fileConfig.header.every((h) => typeof h === "string"))) {
|
|
220
|
-
throw new Error(`File '${fileName}' header must be a string or array of strings`);
|
|
221
|
-
}
|
|
256
|
+
if (host.includes("://")) {
|
|
257
|
+
throw new ValidationError(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
|
|
222
258
|
}
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
throw new Error(`File '${fileName}' schemaUrl must be a string`);
|
|
259
|
+
if (host.includes("/")) {
|
|
260
|
+
throw new ValidationError(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
|
|
226
261
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function validatePrOptions(config) {
|
|
265
|
+
if (config.prOptions?.labels === undefined)
|
|
266
|
+
return;
|
|
267
|
+
if (!Array.isArray(config.prOptions.labels)) {
|
|
268
|
+
throw new ValidationError("prOptions.labels must be an array of strings");
|
|
269
|
+
}
|
|
270
|
+
for (const label of config.prOptions.labels) {
|
|
271
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
272
|
+
throw new ValidationError("prOptions.labels entries must be non-empty strings");
|
|
230
273
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function validateGroups(config) {
|
|
277
|
+
if (config.groups === undefined)
|
|
278
|
+
return;
|
|
279
|
+
if (typeof config.groups !== "object" ||
|
|
280
|
+
config.groups === null ||
|
|
281
|
+
Array.isArray(config.groups)) {
|
|
282
|
+
throw new ValidationError("groups must be an object");
|
|
283
|
+
}
|
|
284
|
+
const rootCtx = buildRootSettingsContext(config);
|
|
285
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
286
|
+
if (groupName === "inherit") {
|
|
287
|
+
throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
|
|
288
|
+
}
|
|
289
|
+
if (group.files) {
|
|
290
|
+
for (const [fileName, fileConfig] of Object.entries(group.files)) {
|
|
291
|
+
if (fileName === "inherit")
|
|
292
|
+
continue;
|
|
293
|
+
if (fileConfig === false)
|
|
294
|
+
continue;
|
|
295
|
+
if (fileConfig === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
validateFileConfigFields(fileConfig, fileName, `groups.${groupName}:`);
|
|
241
298
|
}
|
|
242
299
|
}
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
throw new Error(`File '${fileName}' deleteOrphaned must be a boolean`);
|
|
300
|
+
if (group.settings !== undefined) {
|
|
301
|
+
validateSettings(group.settings, `groups.${groupName}`, rootCtx.rulesetNames, rootCtx.hasRepoSettings, rootCtx.labelNames);
|
|
246
302
|
}
|
|
247
303
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
throw new
|
|
304
|
+
}
|
|
305
|
+
function validateRepoEntry(config, repo, index) {
|
|
306
|
+
if (!repo.git) {
|
|
307
|
+
throw new ValidationError(`Repo at index ${index} missing required field: git`);
|
|
252
308
|
}
|
|
253
|
-
if (
|
|
254
|
-
throw new
|
|
309
|
+
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
310
|
+
throw new ValidationError(`Repo at index ${index} has empty git array`);
|
|
255
311
|
}
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
312
|
+
const repoLabel = getGitDisplayName(repo.git);
|
|
313
|
+
if (repo.upstream !== undefined && repo.source !== undefined) {
|
|
314
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' and 'source' are mutually exclusive. ` +
|
|
315
|
+
`Use 'upstream' to fork, or 'source' to migrate, not both.`);
|
|
316
|
+
}
|
|
317
|
+
if (repo.upstream !== undefined) {
|
|
318
|
+
if (typeof repo.upstream !== "string") {
|
|
319
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a string`);
|
|
262
320
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
321
|
+
if (!isValidGitUrl(repo.upstream)) {
|
|
322
|
+
throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a valid git URL ` +
|
|
323
|
+
`(SSH: git@host:path or HTTPS: https://host/path)`);
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
!config.githubHosts.every((h) => typeof h === "string")) {
|
|
272
|
-
throw new Error("githubHosts must be an array of strings");
|
|
326
|
+
if (repo.source !== undefined) {
|
|
327
|
+
if (typeof repo.source !== "string") {
|
|
328
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' must be a string`);
|
|
273
329
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
if (host.includes("://")) {
|
|
279
|
-
throw new Error(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
|
|
280
|
-
}
|
|
281
|
-
if (host.includes("/")) {
|
|
282
|
-
throw new Error(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
|
|
283
|
-
}
|
|
330
|
+
if (!isValidGitUrl(repo.source)) {
|
|
331
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' must be a valid git URL ` +
|
|
332
|
+
`(SSH: git@host:path or HTTPS: https://host/path)`);
|
|
284
333
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (!Array.isArray(config.prOptions.labels)) {
|
|
289
|
-
throw new Error("prOptions.labels must be an array of strings");
|
|
334
|
+
if (isGitHubUrl(repo.source, config.githubHosts)) {
|
|
335
|
+
throw new ValidationError(`Repo ${repoLabel}: 'source' cannot be a GitHub URL. ` +
|
|
336
|
+
`Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
|
|
290
337
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
338
|
+
}
|
|
339
|
+
if (repo.groups !== undefined) {
|
|
340
|
+
if (!Array.isArray(repo.groups) ||
|
|
341
|
+
!repo.groups.every((g) => typeof g === "string")) {
|
|
342
|
+
throw new ValidationError(`Repo at index ${index}: groups must be an array of strings`);
|
|
343
|
+
}
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
for (const groupName of repo.groups) {
|
|
346
|
+
if (!config.groups || !config.groups[groupName]) {
|
|
347
|
+
throw new ValidationError(`Repo at index ${index}: group '${groupName}' is not defined in root 'groups'`);
|
|
294
348
|
}
|
|
349
|
+
if (seen.has(groupName)) {
|
|
350
|
+
throw new ValidationError(`Repo at index ${index}: duplicate group '${groupName}'`);
|
|
351
|
+
}
|
|
352
|
+
seen.add(groupName);
|
|
295
353
|
}
|
|
296
354
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
config.groups === null ||
|
|
301
|
-
Array.isArray(config.groups)) {
|
|
302
|
-
throw new Error("groups must be an object");
|
|
355
|
+
if (repo.files) {
|
|
356
|
+
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
357
|
+
throw new ValidationError(`Repo at index ${index}: files must be an object`);
|
|
303
358
|
}
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (groupName === "inherit") {
|
|
313
|
-
throw new Error("'inherit' is a reserved key and cannot be used as a group name");
|
|
314
|
-
}
|
|
315
|
-
// Validate group files
|
|
316
|
-
if (group.files) {
|
|
317
|
-
for (const [fileName, fileConfig] of Object.entries(group.files)) {
|
|
318
|
-
if (fileName === "inherit")
|
|
319
|
-
continue;
|
|
320
|
-
if (fileConfig === false)
|
|
321
|
-
continue;
|
|
322
|
-
if (fileConfig === undefined)
|
|
323
|
-
continue;
|
|
324
|
-
const fc = fileConfig;
|
|
325
|
-
if (fc.content !== undefined) {
|
|
326
|
-
const hasText = isTextContent(fc.content);
|
|
327
|
-
const hasObject = isObjectContent(fc.content);
|
|
328
|
-
if (!hasText && !hasObject) {
|
|
329
|
-
throw new Error(`groups.${groupName}: file '${fileName}' content must be an object, string, or array of strings`);
|
|
330
|
-
}
|
|
331
|
-
const isStructured = isStructuredFileExtension(fileName);
|
|
332
|
-
if (isStructured && hasText) {
|
|
333
|
-
throw new Error(`groups.${groupName}: file '${fileName}' has JSON/YAML extension but string content`);
|
|
334
|
-
}
|
|
335
|
-
if (!isStructured && hasObject) {
|
|
336
|
-
throw new Error(`groups.${groupName}: file '${fileName}' has text extension but object content`);
|
|
337
|
-
}
|
|
359
|
+
const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
|
|
360
|
+
if (repo.groups && config.groups) {
|
|
361
|
+
for (const groupName of repo.groups) {
|
|
362
|
+
const group = config.groups[groupName];
|
|
363
|
+
if (group?.files) {
|
|
364
|
+
for (const fn of Object.keys(group.files)) {
|
|
365
|
+
if (fn !== "inherit")
|
|
366
|
+
knownFiles.add(fn);
|
|
338
367
|
}
|
|
339
368
|
}
|
|
340
369
|
}
|
|
341
|
-
// Validate group settings
|
|
342
|
-
if (group.settings !== undefined) {
|
|
343
|
-
validateSettings(group.settings, `groups.${groupName}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
// Validate each repo
|
|
348
|
-
for (let i = 0; i < config.repos.length; i++) {
|
|
349
|
-
const repo = config.repos[i];
|
|
350
|
-
if (!repo.git) {
|
|
351
|
-
throw new Error(`Repo at index ${i} missing required field: git`);
|
|
352
|
-
}
|
|
353
|
-
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
354
|
-
throw new Error(`Repo at index ${i} has empty git array`);
|
|
355
370
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a string`);
|
|
364
|
-
}
|
|
365
|
-
if (!isValidGitUrl(repo.upstream)) {
|
|
366
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a valid git URL ` +
|
|
367
|
-
`(SSH: git@host:path or HTTPS: https://host/path)`);
|
|
371
|
+
for (const fileName of Object.keys(repo.files)) {
|
|
372
|
+
if (fileName === "inherit") {
|
|
373
|
+
const inheritValue = repo.files.inherit;
|
|
374
|
+
if (typeof inheritValue !== "boolean") {
|
|
375
|
+
throw new ValidationError(`Repo at index ${index}: files.inherit must be a boolean`);
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
368
378
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (typeof repo.source !== "string") {
|
|
372
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' must be a string`);
|
|
379
|
+
if (!knownFiles.has(fileName)) {
|
|
380
|
+
throw new ValidationError(`Repo at index ${index} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group.`);
|
|
373
381
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
382
|
+
const fileOverride = repo.files[fileName];
|
|
383
|
+
if (fileOverride === false) {
|
|
384
|
+
continue;
|
|
377
385
|
}
|
|
378
|
-
if (
|
|
379
|
-
throw new
|
|
380
|
-
`
|
|
386
|
+
if (fileOverride.override && !fileOverride.content) {
|
|
387
|
+
throw new ValidationError(`Repo ${repoLabel} has override: true for file '${fileName}' but no content defined. ` +
|
|
388
|
+
`Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
|
|
381
389
|
}
|
|
390
|
+
validateFileConfigFields(fileOverride, fileName, `Repo ${repoLabel}:`);
|
|
382
391
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
throw new Error(`Repo at index ${i}: groups must be an array of strings`);
|
|
388
|
-
}
|
|
389
|
-
const seen = new Set();
|
|
392
|
+
}
|
|
393
|
+
if (repo.settings !== undefined) {
|
|
394
|
+
const rootCtx = buildRootSettingsContext(config);
|
|
395
|
+
if (repo.groups && config.groups) {
|
|
390
396
|
for (const groupName of repo.groups) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
seen.add(groupName);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
// Validate per-repo file overrides
|
|
401
|
-
if (repo.files) {
|
|
402
|
-
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
403
|
-
throw new Error(`Repo at index ${i}: files must be an object`);
|
|
404
|
-
}
|
|
405
|
-
// Build the set of known files once per repo (root + referenced groups)
|
|
406
|
-
const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
|
|
407
|
-
if (repo.groups && config.groups) {
|
|
408
|
-
for (const groupName of repo.groups) {
|
|
409
|
-
const group = config.groups[groupName];
|
|
410
|
-
if (group?.files) {
|
|
411
|
-
for (const fn of Object.keys(group.files)) {
|
|
412
|
-
if (fn !== "inherit")
|
|
413
|
-
knownFiles.add(fn);
|
|
414
|
-
}
|
|
397
|
+
const group = config.groups[groupName];
|
|
398
|
+
if (group?.settings?.rulesets) {
|
|
399
|
+
for (const name of Object.keys(group.settings.rulesets)) {
|
|
400
|
+
if (name !== "inherit")
|
|
401
|
+
rootCtx.rulesetNames.push(name);
|
|
415
402
|
}
|
|
416
403
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const inheritValue = repo.files.inherit;
|
|
422
|
-
if (typeof inheritValue !== "boolean") {
|
|
423
|
-
throw new Error(`Repo at index ${i}: files.inherit must be a boolean`);
|
|
424
|
-
}
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
// Ensure the file is defined at root level or in a referenced group
|
|
428
|
-
if (!knownFiles.has(fileName)) {
|
|
429
|
-
throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group.`);
|
|
430
|
-
}
|
|
431
|
-
const fileOverride = repo.files[fileName];
|
|
432
|
-
// false means exclude this file for this repo - no further validation needed
|
|
433
|
-
if (fileOverride === false) {
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
if (fileOverride.override && !fileOverride.content) {
|
|
437
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined. ` +
|
|
438
|
-
`Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
|
|
439
|
-
}
|
|
440
|
-
// Validate content type
|
|
441
|
-
if (fileOverride.content !== undefined) {
|
|
442
|
-
const hasText = isTextContent(fileOverride.content);
|
|
443
|
-
const hasObject = isObjectContent(fileOverride.content);
|
|
444
|
-
if (!hasText && !hasObject) {
|
|
445
|
-
throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object, string, or array of strings`);
|
|
446
|
-
}
|
|
447
|
-
// Validate content type matches file extension
|
|
448
|
-
const isStructured = isStructuredFileExtension(fileName);
|
|
449
|
-
if (isStructured && hasText) {
|
|
450
|
-
throw new Error(`Repo at index ${i}: file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
451
|
-
}
|
|
452
|
-
if (!isStructured && hasObject) {
|
|
453
|
-
throw new Error(`Repo at index ${i}: file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
if (fileOverride.createOnly !== undefined &&
|
|
457
|
-
typeof fileOverride.createOnly !== "boolean") {
|
|
458
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
|
|
459
|
-
}
|
|
460
|
-
if (fileOverride.executable !== undefined &&
|
|
461
|
-
typeof fileOverride.executable !== "boolean") {
|
|
462
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' executable must be a boolean`);
|
|
463
|
-
}
|
|
464
|
-
if (fileOverride.header !== undefined) {
|
|
465
|
-
if (typeof fileOverride.header !== "string" &&
|
|
466
|
-
(!Array.isArray(fileOverride.header) ||
|
|
467
|
-
!fileOverride.header.every((h) => typeof h === "string"))) {
|
|
468
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
if (fileOverride.schemaUrl !== undefined &&
|
|
472
|
-
typeof fileOverride.schemaUrl !== "string") {
|
|
473
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
|
|
474
|
-
}
|
|
475
|
-
if (fileOverride.template !== undefined &&
|
|
476
|
-
typeof fileOverride.template !== "boolean") {
|
|
477
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' template must be a boolean`);
|
|
478
|
-
}
|
|
479
|
-
if (fileOverride.vars !== undefined) {
|
|
480
|
-
if (typeof fileOverride.vars !== "object" ||
|
|
481
|
-
fileOverride.vars === null ||
|
|
482
|
-
Array.isArray(fileOverride.vars)) {
|
|
483
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars must be an object with string values`);
|
|
484
|
-
}
|
|
485
|
-
for (const [key, value] of Object.entries(fileOverride.vars)) {
|
|
486
|
-
if (typeof value !== "string") {
|
|
487
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars.${key} must be a string`);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
if (fileOverride.deleteOrphaned !== undefined &&
|
|
492
|
-
typeof fileOverride.deleteOrphaned !== "boolean") {
|
|
493
|
-
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' deleteOrphaned must be a boolean`);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
// Validate per-repo settings
|
|
498
|
-
if (repo.settings !== undefined) {
|
|
499
|
-
const rootRulesetNames = config.settings?.rulesets
|
|
500
|
-
? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
|
|
501
|
-
: [];
|
|
502
|
-
const hasRootRepoSettings = config.settings?.repo !== undefined && config.settings.repo !== false;
|
|
503
|
-
const rootLabelNames = config.settings?.labels
|
|
504
|
-
? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
|
|
505
|
-
: [];
|
|
506
|
-
// Augment known names with those from the repo's referenced groups
|
|
507
|
-
if (repo.groups && config.groups) {
|
|
508
|
-
for (const groupName of repo.groups) {
|
|
509
|
-
const group = config.groups[groupName];
|
|
510
|
-
if (group?.settings?.rulesets) {
|
|
511
|
-
for (const name of Object.keys(group.settings.rulesets)) {
|
|
512
|
-
if (name !== "inherit")
|
|
513
|
-
rootRulesetNames.push(name);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
if (group?.settings?.labels) {
|
|
517
|
-
for (const name of Object.keys(group.settings.labels)) {
|
|
518
|
-
if (name !== "inherit")
|
|
519
|
-
rootLabelNames.push(name);
|
|
520
|
-
}
|
|
404
|
+
if (group?.settings?.labels) {
|
|
405
|
+
for (const name of Object.keys(group.settings.labels)) {
|
|
406
|
+
if (name !== "inherit")
|
|
407
|
+
rootCtx.labelNames.push(name);
|
|
521
408
|
}
|
|
522
409
|
}
|
|
523
410
|
}
|
|
524
|
-
validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
|
|
525
411
|
}
|
|
412
|
+
validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx.rulesetNames, rootCtx.hasRepoSettings, rootCtx.labelNames);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Validates raw config structure before normalization.
|
|
417
|
+
* @throws Error if validation fails
|
|
418
|
+
*/
|
|
419
|
+
export function validateRawConfig(config) {
|
|
420
|
+
validateConfigId(config);
|
|
421
|
+
const hasFiles = config.files &&
|
|
422
|
+
typeof config.files === "object" &&
|
|
423
|
+
Object.keys(config.files).length > 0;
|
|
424
|
+
const hasSettings = config.settings && typeof config.settings === "object";
|
|
425
|
+
const hasGroupFiles = config.groups &&
|
|
426
|
+
typeof config.groups === "object" &&
|
|
427
|
+
!Array.isArray(config.groups) &&
|
|
428
|
+
Object.values(config.groups).some((g) => g.files &&
|
|
429
|
+
Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
|
|
430
|
+
const hasGroupSettings = config.groups &&
|
|
431
|
+
typeof config.groups === "object" &&
|
|
432
|
+
!Array.isArray(config.groups) &&
|
|
433
|
+
Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
|
|
434
|
+
if (!hasFiles && !hasSettings && !hasGroupFiles && !hasGroupSettings) {
|
|
435
|
+
throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
|
|
436
|
+
"Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
|
|
437
|
+
}
|
|
438
|
+
validateRootFiles(config);
|
|
439
|
+
if (config.deleteOrphaned !== undefined &&
|
|
440
|
+
typeof config.deleteOrphaned !== "boolean") {
|
|
441
|
+
throw new ValidationError("Global deleteOrphaned must be a boolean");
|
|
442
|
+
}
|
|
443
|
+
if (!config.repos || !Array.isArray(config.repos)) {
|
|
444
|
+
throw new ValidationError("Config missing required field: repos (must be an array)");
|
|
445
|
+
}
|
|
446
|
+
validateRootSettings(config);
|
|
447
|
+
validateGithubHosts(config);
|
|
448
|
+
validatePrOptions(config);
|
|
449
|
+
validateGroups(config);
|
|
450
|
+
for (let i = 0; i < config.repos.length; i++) {
|
|
451
|
+
validateRepoEntry(config, config.repos[i], i);
|
|
526
452
|
}
|
|
527
453
|
}
|
|
528
454
|
// =============================================================================
|
|
@@ -548,7 +474,7 @@ export function validateForSync(config) {
|
|
|
548
474
|
!hasSettings &&
|
|
549
475
|
!hasRepoSettings &&
|
|
550
476
|
!hasGroupSettings) {
|
|
551
|
-
throw new
|
|
477
|
+
throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
|
|
552
478
|
"Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
|
|
553
479
|
}
|
|
554
480
|
}
|
|
@@ -558,16 +484,13 @@ export function validateForSync(config) {
|
|
|
558
484
|
export function hasActionableSettings(settings) {
|
|
559
485
|
if (!settings)
|
|
560
486
|
return false;
|
|
561
|
-
// Check for rulesets (filter out inherit key)
|
|
562
487
|
if (settings.rulesets &&
|
|
563
488
|
Object.keys(settings.rulesets).filter((k) => k !== "inherit").length > 0) {
|
|
564
489
|
return true;
|
|
565
490
|
}
|
|
566
|
-
// Check for repo settings
|
|
567
491
|
if (settings.repo && Object.keys(settings.repo).length > 0) {
|
|
568
492
|
return true;
|
|
569
493
|
}
|
|
570
|
-
// Check for labels (filter out inherit key)
|
|
571
494
|
if (settings.labels &&
|
|
572
495
|
Object.keys(settings.labels).filter((k) => k !== "inherit").length > 0) {
|
|
573
496
|
return true;
|