@aspruyt/xfg 3.12.0 → 3.13.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/config/file-reference-resolver.js +20 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/normalizer.js +192 -10
- package/dist/config/types.d.ts +9 -0
- package/dist/config/validator.js +129 -17
- package/dist/settings/labels/github-labels-strategy.d.ts +5 -1
- package/dist/settings/labels/github-labels-strategy.js +9 -3
- package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +5 -1
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +9 -3
- package/dist/settings/rulesets/github-ruleset-strategy.d.ts +5 -1
- package/dist/settings/rulesets/github-ruleset-strategy.js +9 -3
- package/package.json +1 -1
|
@@ -120,6 +120,26 @@ export function resolveFileReferencesInConfig(raw, options) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
// Resolve group-level file content
|
|
124
|
+
if (result.groups) {
|
|
125
|
+
for (const [groupName, group] of Object.entries(result.groups)) {
|
|
126
|
+
if (group.files) {
|
|
127
|
+
for (const [fileName, fileConfig] of Object.entries(group.files)) {
|
|
128
|
+
if (fileConfig &&
|
|
129
|
+
typeof fileConfig === "object" &&
|
|
130
|
+
"content" in fileConfig) {
|
|
131
|
+
const resolved = resolveContentValue(fileConfig.content, configDir);
|
|
132
|
+
if (resolved !== undefined) {
|
|
133
|
+
result.groups[groupName].files[fileName] = {
|
|
134
|
+
...fileConfig,
|
|
135
|
+
content: resolved,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
123
143
|
// Resolve per-repo file content
|
|
124
144
|
if (result.repos) {
|
|
125
145
|
for (const repo of result.repos) {
|
package/dist/config/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { MergeMode, MergeStrategy, PRMergeOptions, RulesetTarget, RulesetEnforcement, BypassActorType, BypassMode, PatternOperator, MergeMethod, AlertsThreshold, SecurityAlertsThreshold, BypassActor, RefNameCondition, RulesetConditions, StatusCheckConfig, RequiredReviewer, CodeScanningTool, WorkflowConfig, PullRequestRuleParameters, RequiredStatusChecksParameters, UpdateRuleParameters, RequiredDeploymentsParameters, CodeScanningParameters, CodeQualityParameters, WorkflowsParameters, PatternRuleParameters, FilePathRestrictionParameters, FileExtensionRestrictionParameters, MaxFilePathLengthParameters, MaxFileSizeParameters, PullRequestRule, RequiredStatusChecksRule, RequiredSignaturesRule, RequiredLinearHistoryRule, NonFastForwardRule, CreationRule, UpdateRule, DeletionRule, RequiredDeploymentsRule, CodeScanningRule, CodeQualityRule, WorkflowsRule, CommitAuthorEmailPatternRule, CommitMessagePatternRule, CommitterEmailPatternRule, BranchNamePatternRule, TagNamePatternRule, FilePathRestrictionRule, FileExtensionRestrictionRule, MaxFilePathLengthRule, MaxFileSizeRule, RulesetRule, Ruleset, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, RepoVisibility, GitHubRepoSettings, Label, RepoSettings, ContentValue, RawFileConfig, RawRepoFileOverride, RawRootSettings, RawRepoSettings, RawRepoConfig, RawConfig, FileContent, RepoConfig, Config, } from "./types.js";
|
|
1
|
+
export type { MergeMode, MergeStrategy, PRMergeOptions, RulesetTarget, RulesetEnforcement, BypassActorType, BypassMode, PatternOperator, MergeMethod, AlertsThreshold, SecurityAlertsThreshold, BypassActor, RefNameCondition, RulesetConditions, StatusCheckConfig, RequiredReviewer, CodeScanningTool, WorkflowConfig, PullRequestRuleParameters, RequiredStatusChecksParameters, UpdateRuleParameters, RequiredDeploymentsParameters, CodeScanningParameters, CodeQualityParameters, WorkflowsParameters, PatternRuleParameters, FilePathRestrictionParameters, FileExtensionRestrictionParameters, MaxFilePathLengthParameters, MaxFileSizeParameters, PullRequestRule, RequiredStatusChecksRule, RequiredSignaturesRule, RequiredLinearHistoryRule, NonFastForwardRule, CreationRule, UpdateRule, DeletionRule, RequiredDeploymentsRule, CodeScanningRule, CodeQualityRule, WorkflowsRule, CommitAuthorEmailPatternRule, CommitMessagePatternRule, CommitterEmailPatternRule, BranchNamePatternRule, TagNamePatternRule, FilePathRestrictionRule, FileExtensionRestrictionRule, MaxFilePathLengthRule, MaxFileSizeRule, RulesetRule, Ruleset, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, RepoVisibility, GitHubRepoSettings, Label, RepoSettings, ContentValue, RawFileConfig, RawRepoFileOverride, RawGroupConfig, RawRootSettings, RawRepoSettings, RawRepoConfig, RawConfig, FileContent, RepoConfig, Config, } from "./types.js";
|
|
2
2
|
export { RULESET_FIELD_MAP, RULESET_COMPARABLE_FIELDS } from "./types.js";
|
|
3
3
|
export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
|
|
4
4
|
export { convertContentToString, detectOutputFormat, type OutputFormat, type ConvertOptions, } from "./formatter.js";
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
|
|
2
2
|
import { interpolateContent } from "../shared/env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Checks whether an object's `inherit` property is not explicitly set to false.
|
|
5
|
+
* Replaces the repeated `(x )?.inherit !== false` pattern.
|
|
6
|
+
*/
|
|
7
|
+
function shouldInherit(obj) {
|
|
8
|
+
return obj?.inherit !== false;
|
|
9
|
+
}
|
|
3
10
|
/**
|
|
4
11
|
* Normalizes header to array format.
|
|
5
12
|
*/
|
|
@@ -67,7 +74,7 @@ function mergeLabels(rootLabels, repoLabels) {
|
|
|
67
74
|
return undefined;
|
|
68
75
|
const root = rootLabels ?? {};
|
|
69
76
|
const repo = repoLabels ?? {};
|
|
70
|
-
const inheritLabels = repo
|
|
77
|
+
const inheritLabels = shouldInherit(repo);
|
|
71
78
|
const allLabelNames = new Set([
|
|
72
79
|
...Object.keys(root).filter((name) => name !== "inherit"),
|
|
73
80
|
...Object.keys(repo).filter((name) => name !== "inherit"),
|
|
@@ -100,7 +107,7 @@ export function mergeSettings(root, perRepo) {
|
|
|
100
107
|
const rootRulesets = root?.rulesets ?? {};
|
|
101
108
|
const repoRulesets = perRepo?.rulesets ?? {};
|
|
102
109
|
// Check if repo opts out of all inherited rulesets
|
|
103
|
-
const inheritRulesets = repoRulesets
|
|
110
|
+
const inheritRulesets = shouldInherit(repoRulesets);
|
|
104
111
|
const allRulesetNames = new Set([
|
|
105
112
|
...Object.keys(rootRulesets).filter((name) => name !== "inherit"),
|
|
106
113
|
...Object.keys(repoRulesets).filter((name) => name !== "inherit"),
|
|
@@ -152,21 +159,196 @@ export function mergeSettings(root, perRepo) {
|
|
|
152
159
|
}
|
|
153
160
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
154
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Merges group file layers onto root files, producing an effective root file map.
|
|
164
|
+
* Each group layer is processed in order: inherit:false clears accumulated,
|
|
165
|
+
* file:false removes a file, otherwise deep-merge content.
|
|
166
|
+
*/
|
|
167
|
+
function mergeGroupFiles(rootFiles, groupNames, groupDefs) {
|
|
168
|
+
let accumulated = structuredClone(rootFiles);
|
|
169
|
+
for (const groupName of groupNames) {
|
|
170
|
+
const group = groupDefs[groupName];
|
|
171
|
+
if (!group?.files)
|
|
172
|
+
continue;
|
|
173
|
+
const inheritFiles = shouldInherit(group.files);
|
|
174
|
+
if (!inheritFiles) {
|
|
175
|
+
// Intentionally clear: "discard everything above me"
|
|
176
|
+
accumulated = {};
|
|
177
|
+
}
|
|
178
|
+
for (const [fileName, fileConfig] of Object.entries(group.files)) {
|
|
179
|
+
if (fileName === "inherit")
|
|
180
|
+
continue;
|
|
181
|
+
// file: false removes from accumulated set
|
|
182
|
+
if (fileConfig === false) {
|
|
183
|
+
delete accumulated[fileName];
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (fileConfig === undefined)
|
|
187
|
+
continue;
|
|
188
|
+
const existing = accumulated[fileName];
|
|
189
|
+
if (existing) {
|
|
190
|
+
// Deep-merge content if both sides have object content
|
|
191
|
+
const overlay = fileConfig;
|
|
192
|
+
let mergedContent;
|
|
193
|
+
if (overlay.override || !existing.content || !overlay.content) {
|
|
194
|
+
// override:true or one side missing content — use overlay content
|
|
195
|
+
mergedContent = overlay.content ?? existing.content;
|
|
196
|
+
}
|
|
197
|
+
else if (isTextContent(existing.content) &&
|
|
198
|
+
isTextContent(overlay.content)) {
|
|
199
|
+
mergedContent = mergeTextContent(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
|
|
200
|
+
}
|
|
201
|
+
else if (!isTextContent(existing.content) &&
|
|
202
|
+
!isTextContent(overlay.content)) {
|
|
203
|
+
const ctx = createMergeContext(existing.mergeStrategy ?? "replace");
|
|
204
|
+
mergedContent = deepMerge(structuredClone(existing.content), overlay.content, ctx);
|
|
205
|
+
mergedContent = stripMergeDirectives(mergedContent);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// Type mismatch — overlay wins
|
|
209
|
+
mergedContent = overlay.content;
|
|
210
|
+
}
|
|
211
|
+
const { override: _override, ...restFileConfig } = fileConfig;
|
|
212
|
+
accumulated[fileName] = {
|
|
213
|
+
...existing,
|
|
214
|
+
...restFileConfig,
|
|
215
|
+
content: mergedContent,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// New file introduced by group
|
|
220
|
+
accumulated[fileName] = structuredClone(fileConfig);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return accumulated;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Merges group PR options layers onto root PR options.
|
|
228
|
+
*/
|
|
229
|
+
function mergeGroupPROptions(rootPR, groupNames, groupDefs) {
|
|
230
|
+
let accumulated = rootPR;
|
|
231
|
+
for (const name of groupNames) {
|
|
232
|
+
const group = groupDefs[name];
|
|
233
|
+
if (group?.prOptions) {
|
|
234
|
+
accumulated = mergePROptions(accumulated, group.prOptions);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return accumulated;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Merges two raw settings layers (root/group into accumulated).
|
|
241
|
+
* Unlike mergeSettings(), this operates on raw types and returns raw types,
|
|
242
|
+
* preserving false values and inherit keys for downstream processing.
|
|
243
|
+
* The final accumulated result feeds into the existing mergeSettings(accumulated, repoSettings).
|
|
244
|
+
*/
|
|
245
|
+
function mergeRawSettings(base, overlay) {
|
|
246
|
+
if (!base && !overlay)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (!overlay)
|
|
249
|
+
return structuredClone(base);
|
|
250
|
+
const result = base ? structuredClone(base) : {};
|
|
251
|
+
// Merge rulesets
|
|
252
|
+
if (overlay.rulesets) {
|
|
253
|
+
const inheritRulesets = shouldInherit(overlay.rulesets);
|
|
254
|
+
if (!inheritRulesets) {
|
|
255
|
+
// Discard accumulated rulesets, start fresh with overlay's own
|
|
256
|
+
result.rulesets = {};
|
|
257
|
+
}
|
|
258
|
+
if (!result.rulesets)
|
|
259
|
+
result.rulesets = {};
|
|
260
|
+
for (const [name, ruleset] of Object.entries(overlay.rulesets)) {
|
|
261
|
+
if (name === "inherit")
|
|
262
|
+
continue;
|
|
263
|
+
if (ruleset === false) {
|
|
264
|
+
result.rulesets[name] = false;
|
|
265
|
+
}
|
|
266
|
+
else if (typeof ruleset === "object") {
|
|
267
|
+
const existing = result.rulesets[name];
|
|
268
|
+
result.rulesets[name] = existing
|
|
269
|
+
? mergeRuleset(existing, ruleset)
|
|
270
|
+
: structuredClone(ruleset);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Merge repo settings: overlay replaces base (shallow merge, same as mergeSettings)
|
|
275
|
+
if (overlay.repo !== undefined) {
|
|
276
|
+
if (overlay.repo === false) {
|
|
277
|
+
result.repo = false;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
result.repo = {
|
|
281
|
+
...(result.repo === false ? {} : result.repo),
|
|
282
|
+
...overlay.repo,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Merge labels
|
|
287
|
+
if (overlay.labels) {
|
|
288
|
+
const inheritLabels = shouldInherit(overlay.labels);
|
|
289
|
+
if (!inheritLabels) {
|
|
290
|
+
result.labels = {};
|
|
291
|
+
}
|
|
292
|
+
if (!result.labels)
|
|
293
|
+
result.labels = {};
|
|
294
|
+
for (const [name, label] of Object.entries(overlay.labels)) {
|
|
295
|
+
if (name === "inherit")
|
|
296
|
+
continue;
|
|
297
|
+
if (label === false) {
|
|
298
|
+
result.labels[name] = false;
|
|
299
|
+
}
|
|
300
|
+
else if (typeof label === "object") {
|
|
301
|
+
const existing = result.labels[name];
|
|
302
|
+
result.labels[name] = {
|
|
303
|
+
...(existing && typeof existing === "object" ? existing : {}),
|
|
304
|
+
...label,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// deleteOrphaned: overlay wins
|
|
310
|
+
if (overlay.deleteOrphaned !== undefined) {
|
|
311
|
+
result.deleteOrphaned = overlay.deleteOrphaned;
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Merges group settings layers onto root settings.
|
|
317
|
+
*/
|
|
318
|
+
function mergeGroupSettings(rootSettings, groupNames, groupDefs) {
|
|
319
|
+
let accumulated = rootSettings;
|
|
320
|
+
for (const name of groupNames) {
|
|
321
|
+
const group = groupDefs[name];
|
|
322
|
+
if (group?.settings) {
|
|
323
|
+
accumulated = mergeRawSettings(accumulated, group.settings);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return accumulated;
|
|
327
|
+
}
|
|
155
328
|
/**
|
|
156
329
|
* Normalizes raw config into expanded, merged config.
|
|
157
330
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
158
331
|
*/
|
|
159
332
|
export function normalizeConfig(raw) {
|
|
160
333
|
const expandedRepos = [];
|
|
161
|
-
const fileNames = raw.files ? Object.keys(raw.files) : [];
|
|
162
334
|
for (const rawRepo of raw.repos) {
|
|
163
335
|
// Step 1: Expand git arrays
|
|
164
336
|
const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
|
|
337
|
+
// Resolve groups: build effective root files/prOptions/settings by merging group layers
|
|
338
|
+
const effectiveRootFiles = rawRepo.groups?.length
|
|
339
|
+
? mergeGroupFiles(raw.files ?? {}, rawRepo.groups, raw.groups ?? {})
|
|
340
|
+
: (raw.files ?? {});
|
|
341
|
+
const effectivePROptions = rawRepo.groups?.length
|
|
342
|
+
? mergeGroupPROptions(raw.prOptions, rawRepo.groups, raw.groups ?? {})
|
|
343
|
+
: raw.prOptions;
|
|
344
|
+
const effectiveSettings = rawRepo.groups?.length
|
|
345
|
+
? mergeGroupSettings(raw.settings, rawRepo.groups, raw.groups ?? {})
|
|
346
|
+
: raw.settings;
|
|
347
|
+
const fileNames = Object.keys(effectiveRootFiles);
|
|
165
348
|
for (const gitUrl of gitUrls) {
|
|
166
349
|
const files = [];
|
|
167
350
|
// Check if repo opts out of all inherited files
|
|
168
|
-
const inheritFiles = rawRepo.files
|
|
169
|
-
false;
|
|
351
|
+
const inheritFiles = shouldInherit(rawRepo.files);
|
|
170
352
|
// Step 2: Process each file definition
|
|
171
353
|
for (const fileName of fileNames) {
|
|
172
354
|
// Skip reserved key
|
|
@@ -181,7 +363,7 @@ export function normalizeConfig(raw) {
|
|
|
181
363
|
if (!inheritFiles && !repoOverride) {
|
|
182
364
|
continue;
|
|
183
365
|
}
|
|
184
|
-
const fileConfig =
|
|
366
|
+
const fileConfig = effectiveRootFiles[fileName];
|
|
185
367
|
const fileStrategy = fileConfig.mergeStrategy ?? "replace";
|
|
186
368
|
// Step 3: Compute merged content for this file
|
|
187
369
|
let mergedContent;
|
|
@@ -263,10 +445,10 @@ export function normalizeConfig(raw) {
|
|
|
263
445
|
deleteOrphaned,
|
|
264
446
|
});
|
|
265
447
|
}
|
|
266
|
-
// Merge PR options: per-repo overrides
|
|
267
|
-
const prOptions = mergePROptions(
|
|
268
|
-
// Merge settings: per-repo deep merges with root
|
|
269
|
-
const settings = mergeSettings(
|
|
448
|
+
// Merge PR options: per-repo overrides effective (root + groups)
|
|
449
|
+
const prOptions = mergePROptions(effectivePROptions, rawRepo.prOptions);
|
|
450
|
+
// Merge settings: per-repo deep merges with effective (root + groups)
|
|
451
|
+
const settings = mergeSettings(effectiveSettings, rawRepo.settings);
|
|
270
452
|
expandedRepos.push({
|
|
271
453
|
git: gitUrl,
|
|
272
454
|
files,
|
package/dist/config/types.d.ts
CHANGED
|
@@ -305,6 +305,13 @@ export interface RawRepoFileOverride {
|
|
|
305
305
|
vars?: Record<string, string>;
|
|
306
306
|
deleteOrphaned?: boolean;
|
|
307
307
|
}
|
|
308
|
+
export interface RawGroupConfig {
|
|
309
|
+
files?: Record<string, RawFileConfig | RawRepoFileOverride | false> & {
|
|
310
|
+
inherit?: boolean;
|
|
311
|
+
};
|
|
312
|
+
prOptions?: PRMergeOptions;
|
|
313
|
+
settings?: RawRepoSettings;
|
|
314
|
+
}
|
|
308
315
|
export interface RawRootSettings {
|
|
309
316
|
rulesets?: Record<string, Ruleset | false>;
|
|
310
317
|
repo?: GitHubRepoSettings | false;
|
|
@@ -326,6 +333,7 @@ export interface RawRepoConfig {
|
|
|
326
333
|
files?: Record<string, RawRepoFileOverride | false> & {
|
|
327
334
|
inherit?: boolean;
|
|
328
335
|
};
|
|
336
|
+
groups?: string[];
|
|
329
337
|
prOptions?: PRMergeOptions;
|
|
330
338
|
settings?: RawRepoSettings;
|
|
331
339
|
/** Fork upstream repo if target doesn't exist */
|
|
@@ -336,6 +344,7 @@ export interface RawRepoConfig {
|
|
|
336
344
|
export interface RawConfig {
|
|
337
345
|
id: string;
|
|
338
346
|
files?: Record<string, RawFileConfig>;
|
|
347
|
+
groups?: Record<string, RawGroupConfig>;
|
|
339
348
|
repos: RawRepoConfig[];
|
|
340
349
|
prOptions?: PRMergeOptions;
|
|
341
350
|
prTemplate?: string;
|
package/dist/config/validator.js
CHANGED
|
@@ -155,12 +155,21 @@ export function validateRawConfig(config) {
|
|
|
155
155
|
if (config.id.length > CONFIG_ID_MAX_LENGTH) {
|
|
156
156
|
throw new Error(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
|
|
157
157
|
}
|
|
158
|
-
// Validate at least one of files or settings exists
|
|
158
|
+
// Validate at least one of files or settings exists (including in groups)
|
|
159
159
|
const hasFiles = config.files &&
|
|
160
160
|
typeof config.files === "object" &&
|
|
161
161
|
Object.keys(config.files).length > 0;
|
|
162
162
|
const hasSettings = config.settings && typeof config.settings === "object";
|
|
163
|
-
|
|
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) {
|
|
164
173
|
throw new Error("Config requires at least one of: 'files' or 'settings'. " +
|
|
165
174
|
"Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
|
|
166
175
|
}
|
|
@@ -285,6 +294,56 @@ export function validateRawConfig(config) {
|
|
|
285
294
|
}
|
|
286
295
|
}
|
|
287
296
|
}
|
|
297
|
+
// Validate groups
|
|
298
|
+
if (config.groups !== undefined) {
|
|
299
|
+
if (typeof config.groups !== "object" ||
|
|
300
|
+
config.groups === null ||
|
|
301
|
+
Array.isArray(config.groups)) {
|
|
302
|
+
throw new Error("groups must be an object");
|
|
303
|
+
}
|
|
304
|
+
const rootRulesetNames = config.settings?.rulesets
|
|
305
|
+
? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
|
|
306
|
+
: [];
|
|
307
|
+
const hasRootRepoSettings = config.settings?.repo !== undefined && config.settings.repo !== false;
|
|
308
|
+
const rootLabelNames = config.settings?.labels
|
|
309
|
+
? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
|
|
310
|
+
: [];
|
|
311
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
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
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Validate group settings
|
|
342
|
+
if (group.settings !== undefined) {
|
|
343
|
+
validateSettings(group.settings, `groups.${groupName}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
288
347
|
// Validate each repo
|
|
289
348
|
for (let i = 0; i < config.repos.length; i++) {
|
|
290
349
|
const repo = config.repos[i];
|
|
@@ -321,11 +380,41 @@ export function validateRawConfig(config) {
|
|
|
321
380
|
`Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
|
|
322
381
|
}
|
|
323
382
|
}
|
|
383
|
+
// Validate per-repo groups
|
|
384
|
+
if (repo.groups !== undefined) {
|
|
385
|
+
if (!Array.isArray(repo.groups) ||
|
|
386
|
+
!repo.groups.every((g) => typeof g === "string")) {
|
|
387
|
+
throw new Error(`Repo at index ${i}: groups must be an array of strings`);
|
|
388
|
+
}
|
|
389
|
+
const seen = new Set();
|
|
390
|
+
for (const groupName of repo.groups) {
|
|
391
|
+
if (!config.groups || !config.groups[groupName]) {
|
|
392
|
+
throw new Error(`Repo at index ${i}: group '${groupName}' is not defined in root 'groups'`);
|
|
393
|
+
}
|
|
394
|
+
if (seen.has(groupName)) {
|
|
395
|
+
throw new Error(`Repo at index ${i}: duplicate group '${groupName}'`);
|
|
396
|
+
}
|
|
397
|
+
seen.add(groupName);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
324
400
|
// Validate per-repo file overrides
|
|
325
401
|
if (repo.files) {
|
|
326
402
|
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
327
403
|
throw new Error(`Repo at index ${i}: files must be an object`);
|
|
328
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
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
329
418
|
for (const fileName of Object.keys(repo.files)) {
|
|
330
419
|
// Skip reserved key 'inherit'
|
|
331
420
|
if (fileName === "inherit") {
|
|
@@ -335,9 +424,9 @@ export function validateRawConfig(config) {
|
|
|
335
424
|
}
|
|
336
425
|
continue;
|
|
337
426
|
}
|
|
338
|
-
// Ensure the file is defined at root level
|
|
339
|
-
if (!
|
|
340
|
-
throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
|
|
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.`);
|
|
341
430
|
}
|
|
342
431
|
const fileOverride = repo.files[fileName];
|
|
343
432
|
// false means exclude this file for this repo - no further validation needed
|
|
@@ -414,6 +503,24 @@ export function validateRawConfig(config) {
|
|
|
414
503
|
const rootLabelNames = config.settings?.labels
|
|
415
504
|
? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
|
|
416
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
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
417
524
|
validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
|
|
418
525
|
}
|
|
419
526
|
}
|
|
@@ -426,13 +533,12 @@ export function validateRawConfig(config) {
|
|
|
426
533
|
* @throws Error if files section is missing or empty
|
|
427
534
|
*/
|
|
428
535
|
export function validateForSync(config) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
throw new Error("The 'sync' command requires a 'files' section with at least one file defined. " +
|
|
536
|
+
const hasRootFiles = config.files && Object.keys(config.files).length > 0;
|
|
537
|
+
const hasGroupFiles = config.groups &&
|
|
538
|
+
Object.values(config.groups).some((g) => g.files &&
|
|
539
|
+
Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
|
|
540
|
+
if (!hasRootFiles && !hasGroupFiles) {
|
|
541
|
+
throw new Error("The 'sync' command requires files defined in root 'files' or in at least one group. " +
|
|
436
542
|
"To manage repository settings instead, use 'xfg settings'.");
|
|
437
543
|
}
|
|
438
544
|
}
|
|
@@ -463,17 +569,23 @@ export function hasActionableSettings(settings) {
|
|
|
463
569
|
* @throws Error if no settings are defined or no actionable settings exist
|
|
464
570
|
*/
|
|
465
571
|
export function validateForSettings(config) {
|
|
466
|
-
// Check if settings exist at root or in any
|
|
572
|
+
// Check if settings exist at root, in any repo, or in any group
|
|
467
573
|
const hasRootSettings = config.settings !== undefined;
|
|
468
574
|
const hasRepoSettings = config.repos.some((repo) => repo.settings !== undefined);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
575
|
+
const hasGroupSettings = config.groups &&
|
|
576
|
+
typeof config.groups === "object" &&
|
|
577
|
+
!Array.isArray(config.groups) &&
|
|
578
|
+
Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
|
|
579
|
+
if (!hasRootSettings && !hasRepoSettings && !hasGroupSettings) {
|
|
580
|
+
throw new Error("The 'settings' command requires a 'settings' section at root level, " +
|
|
581
|
+
"in at least one repo, or in at least one group. To sync files instead, use 'xfg sync'.");
|
|
472
582
|
}
|
|
473
583
|
// Check if there's at least one actionable setting
|
|
474
584
|
const rootActionable = hasActionableSettings(config.settings);
|
|
475
585
|
const repoActionable = config.repos.some((repo) => hasActionableSettings(repo.settings));
|
|
476
|
-
|
|
586
|
+
const groupActionable = config.groups &&
|
|
587
|
+
Object.values(config.groups).some((g) => hasActionableSettings(g.settings));
|
|
588
|
+
if (!rootActionable && !repoActionable && !groupActionable) {
|
|
477
589
|
throw new Error("No actionable settings configured. Currently supported: rulesets, repo, labels. " +
|
|
478
590
|
"To sync files instead, use 'xfg sync'. " +
|
|
479
591
|
"See docs: https://anthony-spruyt.github.io/xfg/settings");
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ICommandExecutor } from "../../shared/command-executor.js";
|
|
2
2
|
import { RepoInfo } from "../../shared/repo-detector.js";
|
|
3
3
|
import type { ILabelsStrategy, GitHubLabel, LabelsStrategyOptions } from "./types.js";
|
|
4
|
+
export interface GitHubLabelsStrategyOptions {
|
|
5
|
+
retries?: number;
|
|
6
|
+
}
|
|
4
7
|
/**
|
|
5
8
|
* GitHub Labels Strategy for managing repository labels via GitHub REST API.
|
|
6
9
|
* Uses `gh api` CLI for authentication and API calls.
|
|
@@ -10,7 +13,8 @@ import type { ILabelsStrategy, GitHubLabel, LabelsStrategyOptions } from "./type
|
|
|
10
13
|
*/
|
|
11
14
|
export declare class GitHubLabelsStrategy implements ILabelsStrategy {
|
|
12
15
|
private executor;
|
|
13
|
-
|
|
16
|
+
private retries;
|
|
17
|
+
constructor(executor?: ICommandExecutor, options?: GitHubLabelsStrategyOptions);
|
|
14
18
|
/**
|
|
15
19
|
* Lists all labels for a repository.
|
|
16
20
|
* Uses --paginate to retrieve all labels.
|
|
@@ -11,8 +11,10 @@ import { withRetry } from "../../shared/retry-utils.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export class GitHubLabelsStrategy {
|
|
13
13
|
executor;
|
|
14
|
-
|
|
14
|
+
retries;
|
|
15
|
+
constructor(executor, options) {
|
|
15
16
|
this.executor = executor ?? defaultExecutor;
|
|
17
|
+
this.retries = options?.retries ?? 3;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Lists all labels for a repository.
|
|
@@ -93,10 +95,14 @@ export class GitHubLabelsStrategy {
|
|
|
93
95
|
if (payload && (method === "POST" || method === "PATCH")) {
|
|
94
96
|
const payloadJson = JSON.stringify(payload);
|
|
95
97
|
const command = `echo ${escapeShellArg(payloadJson)} | ${tokenPrefix}${baseCommand} --input -`;
|
|
96
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
98
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
99
|
+
retries: this.retries,
|
|
100
|
+
});
|
|
97
101
|
}
|
|
98
102
|
// For GET/DELETE, run command directly
|
|
99
103
|
const command = `${tokenPrefix}${baseCommand}`;
|
|
100
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
104
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
105
|
+
retries: this.retries,
|
|
106
|
+
});
|
|
101
107
|
}
|
|
102
108
|
}
|
|
@@ -2,6 +2,9 @@ import { ICommandExecutor } from "../../shared/command-executor.js";
|
|
|
2
2
|
import { RepoInfo } from "../../shared/repo-detector.js";
|
|
3
3
|
import type { GitHubRepoSettings } from "../../config/index.js";
|
|
4
4
|
import type { IRepoSettingsStrategy, RepoSettingsStrategyOptions, CurrentRepoSettings } from "./types.js";
|
|
5
|
+
export interface GitHubRepoSettingsStrategyOptions {
|
|
6
|
+
retries?: number;
|
|
7
|
+
}
|
|
5
8
|
/**
|
|
6
9
|
* GitHub Repository Settings Strategy.
|
|
7
10
|
* Manages repository settings via GitHub REST API using `gh api` CLI.
|
|
@@ -10,7 +13,8 @@ import type { IRepoSettingsStrategy, RepoSettingsStrategyOptions, CurrentRepoSet
|
|
|
10
13
|
*/
|
|
11
14
|
export declare class GitHubRepoSettingsStrategy implements IRepoSettingsStrategy {
|
|
12
15
|
private executor;
|
|
13
|
-
|
|
16
|
+
private retries;
|
|
17
|
+
constructor(executor?: ICommandExecutor, options?: GitHubRepoSettingsStrategyOptions);
|
|
14
18
|
getSettings(repoInfo: RepoInfo, options?: RepoSettingsStrategyOptions): Promise<CurrentRepoSettings>;
|
|
15
19
|
updateSettings(repoInfo: RepoInfo, settings: GitHubRepoSettings, options?: RepoSettingsStrategyOptions): Promise<void>;
|
|
16
20
|
setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
|
|
@@ -70,8 +70,10 @@ function configToGitHubPayload(settings) {
|
|
|
70
70
|
*/
|
|
71
71
|
export class GitHubRepoSettingsStrategy {
|
|
72
72
|
executor;
|
|
73
|
-
|
|
73
|
+
retries;
|
|
74
|
+
constructor(executor, options) {
|
|
74
75
|
this.executor = executor ?? defaultExecutor;
|
|
76
|
+
this.retries = options?.retries ?? 3;
|
|
75
77
|
}
|
|
76
78
|
async getSettings(repoInfo, options) {
|
|
77
79
|
this.validateGitHub(repoInfo);
|
|
@@ -194,9 +196,13 @@ export class GitHubRepoSettingsStrategy {
|
|
|
194
196
|
(method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
195
197
|
const payloadJson = JSON.stringify(payload);
|
|
196
198
|
const command = `echo ${escapeShellArg(payloadJson)} | ${tokenPrefix}${baseCommand} --input -`;
|
|
197
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
199
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
200
|
+
retries: this.retries,
|
|
201
|
+
});
|
|
198
202
|
}
|
|
199
203
|
const command = `${tokenPrefix}${baseCommand}`;
|
|
200
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
204
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
205
|
+
retries: this.retries,
|
|
206
|
+
});
|
|
201
207
|
}
|
|
202
208
|
}
|
|
@@ -47,13 +47,17 @@ export interface RulesetStrategyOptions {
|
|
|
47
47
|
token?: string;
|
|
48
48
|
host?: string;
|
|
49
49
|
}
|
|
50
|
+
export interface GitHubRulesetStrategyOptions {
|
|
51
|
+
retries?: number;
|
|
52
|
+
}
|
|
50
53
|
/**
|
|
51
54
|
* GitHub Ruleset Strategy for managing repository rulesets via GitHub REST API.
|
|
52
55
|
* Uses `gh api` CLI for authentication and API calls.
|
|
53
56
|
*/
|
|
54
57
|
export declare class GitHubRulesetStrategy implements IRulesetStrategy {
|
|
55
58
|
private executor;
|
|
56
|
-
|
|
59
|
+
private retries;
|
|
60
|
+
constructor(executor?: ICommandExecutor, options?: GitHubRulesetStrategyOptions);
|
|
57
61
|
/**
|
|
58
62
|
* Lists all rulesets for a repository.
|
|
59
63
|
*/
|
|
@@ -113,8 +113,10 @@ function camelToSnake(str) {
|
|
|
113
113
|
*/
|
|
114
114
|
export class GitHubRulesetStrategy {
|
|
115
115
|
executor;
|
|
116
|
-
|
|
116
|
+
retries;
|
|
117
|
+
constructor(executor, options) {
|
|
117
118
|
this.executor = executor ?? defaultExecutor;
|
|
119
|
+
this.retries = options?.retries ?? 3;
|
|
118
120
|
}
|
|
119
121
|
/**
|
|
120
122
|
* Lists all rulesets for a repository.
|
|
@@ -202,10 +204,14 @@ export class GitHubRulesetStrategy {
|
|
|
202
204
|
if (payload && (method === "POST" || method === "PUT")) {
|
|
203
205
|
const payloadJson = JSON.stringify(payload);
|
|
204
206
|
const command = `echo ${escapeShellArg(payloadJson)} | ${tokenPrefix}${baseCommand} --input -`;
|
|
205
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
207
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
208
|
+
retries: this.retries,
|
|
209
|
+
});
|
|
206
210
|
}
|
|
207
211
|
// For GET/DELETE, run command directly
|
|
208
212
|
const command = `${tokenPrefix}${baseCommand}`;
|
|
209
|
-
return await withRetry(() => this.executor.exec(command, process.cwd())
|
|
213
|
+
return await withRetry(() => this.executor.exec(command, process.cwd()), {
|
|
214
|
+
retries: this.retries,
|
|
215
|
+
});
|
|
210
216
|
}
|
|
211
217
|
}
|
package/package.json
CHANGED