@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.
@@ -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) {
@@ -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?.inherit !== false;
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?.inherit !== false;
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?.inherit !==
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 = raw.files[fileName];
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 global
267
- const prOptions = mergePROptions(raw.prOptions, rawRepo.prOptions);
268
- // Merge settings: per-repo deep merges with root settings
269
- const settings = mergeSettings(raw.settings, rawRepo.settings);
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,
@@ -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;
@@ -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
- if (!hasFiles && !hasSettings) {
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 (!config.files || !config.files[fileName]) {
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
- if (!config.files) {
430
- throw new Error("The 'sync' command requires a 'files' section with at least one file defined. " +
431
- "To manage repository settings instead, use 'xfg settings'.");
432
- }
433
- const fileNames = Object.keys(config.files);
434
- if (fileNames.length === 0) {
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 repo
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
- if (!hasRootSettings && !hasRepoSettings) {
470
- throw new Error("The 'settings' command requires a 'settings' section at root level or " +
471
- "in at least one repo. To sync files instead, use 'xfg sync'.");
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
- if (!rootActionable && !repoActionable) {
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
- constructor(executor?: ICommandExecutor);
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
- constructor(executor) {
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
- constructor(executor?: ICommandExecutor);
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
- constructor(executor) {
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
- constructor(executor?: ICommandExecutor);
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
- constructor(executor) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",