@aspruyt/xfg 5.1.6 → 5.3.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.
@@ -44,7 +44,7 @@ export interface SyncResultEntry {
44
44
  mergeOutcome?: "manual" | "auto" | "force" | "direct";
45
45
  error?: string;
46
46
  }
47
- export interface SettingsResult extends Pick<BaseProcessorResult, "success" | "message" | "skipped"> {
47
+ export interface SettingsResult extends BaseProcessorResult {
48
48
  planOutput?: {
49
49
  lines?: string[];
50
50
  };
@@ -0,0 +1,13 @@
1
+ import type { RawGroupConfig } from "./types.js";
2
+ /**
3
+ * Resolves a single group's extends chain into an ordered list of group names.
4
+ * Parents appear before children (topological order). Detects circular extends
5
+ * and missing group references.
6
+ */
7
+ export declare function resolveExtendsChain(groupName: string, groupDefs: Record<string, RawGroupConfig>): string[];
8
+ /**
9
+ * Expands a repo's group list by resolving extends chains for each group.
10
+ * Returns the full ordered list with transitive parents, deduplicated.
11
+ * First occurrence wins for deduplication (preserves topological order).
12
+ */
13
+ export declare function expandRepoGroups(repoGroups: string[], groupDefs: Record<string, RawGroupConfig>): string[];
@@ -0,0 +1,63 @@
1
+ const MAX_EXTENDS_DEPTH = 100;
2
+ /**
3
+ * Resolves a single group's extends chain into an ordered list of group names.
4
+ * Parents appear before children (topological order). Detects circular extends
5
+ * and missing group references.
6
+ */
7
+ export function resolveExtendsChain(groupName, groupDefs) {
8
+ function walk(name, visited, depth) {
9
+ if (depth > MAX_EXTENDS_DEPTH) {
10
+ throw new Error(`Extends chain exceeds maximum depth of ${MAX_EXTENDS_DEPTH} — likely misconfigured`);
11
+ }
12
+ if (visited.has(name)) {
13
+ const cycle = [...visited, name].join(" -> ");
14
+ throw new Error(`Circular extends detected: ${cycle}`);
15
+ }
16
+ visited.add(name);
17
+ const group = groupDefs[name];
18
+ if (!group) {
19
+ throw new Error(`Group '${name}' referenced in extends chain does not exist`);
20
+ }
21
+ if (!group.extends) {
22
+ return [name];
23
+ }
24
+ const parents = Array.isArray(group.extends)
25
+ ? group.extends
26
+ : [group.extends];
27
+ const result = [];
28
+ const seen = new Set();
29
+ for (const parent of parents) {
30
+ const chain = walk(parent, new Set(visited), depth + 1);
31
+ for (const n of chain) {
32
+ if (!seen.has(n)) {
33
+ seen.add(n);
34
+ result.push(n);
35
+ }
36
+ }
37
+ }
38
+ if (!seen.has(name)) {
39
+ result.push(name);
40
+ }
41
+ return result;
42
+ }
43
+ return walk(groupName, new Set(), 0);
44
+ }
45
+ /**
46
+ * Expands a repo's group list by resolving extends chains for each group.
47
+ * Returns the full ordered list with transitive parents, deduplicated.
48
+ * First occurrence wins for deduplication (preserves topological order).
49
+ */
50
+ export function expandRepoGroups(repoGroups, groupDefs) {
51
+ const result = [];
52
+ const seen = new Set();
53
+ for (const groupName of repoGroups) {
54
+ const chain = resolveExtendsChain(groupName, groupDefs);
55
+ for (const name of chain) {
56
+ if (!seen.has(name)) {
57
+ seen.add(name);
58
+ result.push(name);
59
+ }
60
+ }
61
+ }
62
+ return result;
63
+ }
@@ -1,4 +1,4 @@
1
- export type { MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, RepoSettings, RawFileConfig, RawRepoFileOverride, RawRepoSettings, RawRepoConfig, RawConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
1
+ export type { MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, RepoSettings, RawFileConfig, RawRepoFileOverride, RawGroupConfig, RawRepoSettings, RawRepoConfig, RawConfig, RawConditionalGroupWhen, RawConditionalGroupConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
2
2
  export { RULESET_COMPARABLE_FIELDS } from "./types.js";
3
3
  export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
4
4
  export { convertContentToString } from "./formatter.js";
@@ -1,5 +1,6 @@
1
1
  import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
2
2
  import { interpolateContent } from "../shared/env.js";
3
+ import { expandRepoGroups } from "./extends-resolver.js";
3
4
  /**
4
5
  * Clone content, stripping merge directives from object content.
5
6
  * Text content is cloned as-is since it has no merge directives.
@@ -203,6 +204,47 @@ export function mergeSettings(root, perRepo) {
203
204
  }
204
205
  return Object.keys(result).length > 0 ? result : undefined;
205
206
  }
207
+ /**
208
+ * Applies a single file-layer onto an accumulated file map: inherit:false clears,
209
+ * file:false removes entries, otherwise deep-merges content.
210
+ */
211
+ function applyFileLayer(accumulated, layerFiles) {
212
+ const inheritFiles = shouldInherit(layerFiles);
213
+ if (!inheritFiles) {
214
+ accumulated = {};
215
+ }
216
+ for (const [fileName, fileConfig] of Object.entries(layerFiles)) {
217
+ if (fileName === "inherit")
218
+ continue;
219
+ if (fileConfig === false) {
220
+ delete accumulated[fileName];
221
+ continue;
222
+ }
223
+ if (fileConfig === undefined)
224
+ continue;
225
+ const existing = accumulated[fileName];
226
+ if (existing) {
227
+ const overlay = fileConfig;
228
+ let mergedContent;
229
+ if (overlay.override || !existing.content || !overlay.content) {
230
+ mergedContent = overlay.content ?? existing.content;
231
+ }
232
+ else {
233
+ mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
234
+ }
235
+ const { override: _override, ...restFileConfig } = fileConfig;
236
+ accumulated[fileName] = {
237
+ ...existing,
238
+ ...restFileConfig,
239
+ content: mergedContent,
240
+ };
241
+ }
242
+ else {
243
+ accumulated[fileName] = structuredClone(fileConfig);
244
+ }
245
+ }
246
+ return accumulated;
247
+ }
206
248
  /**
207
249
  * Merges group file layers onto root files, producing an effective root file map.
208
250
  * Each group layer is processed in order: inherit:false clears accumulated,
@@ -214,45 +256,7 @@ function mergeGroupFiles(rootFiles, groupNames, groupDefs) {
214
256
  const group = groupDefs[groupName];
215
257
  if (!group?.files)
216
258
  continue;
217
- const inheritFiles = shouldInherit(group.files);
218
- if (!inheritFiles) {
219
- // Intentionally clear: "discard everything above me"
220
- accumulated = {};
221
- }
222
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
223
- if (fileName === "inherit")
224
- continue;
225
- // file: false removes from accumulated set
226
- if (fileConfig === false) {
227
- delete accumulated[fileName];
228
- continue;
229
- }
230
- if (fileConfig === undefined)
231
- continue;
232
- const existing = accumulated[fileName];
233
- if (existing) {
234
- // Deep-merge content if both sides have object content
235
- const overlay = fileConfig;
236
- let mergedContent;
237
- if (overlay.override || !existing.content || !overlay.content) {
238
- // override:true or one side missing content — use overlay content
239
- mergedContent = overlay.content ?? existing.content;
240
- }
241
- else {
242
- mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
243
- }
244
- const { override: _override, ...restFileConfig } = fileConfig;
245
- accumulated[fileName] = {
246
- ...existing,
247
- ...restFileConfig,
248
- content: mergedContent,
249
- };
250
- }
251
- else {
252
- // New file introduced by group
253
- accumulated[fileName] = structuredClone(fileConfig);
254
- }
255
- }
259
+ accumulated = applyFileLayer(accumulated, group.files);
256
260
  }
257
261
  return accumulated;
258
262
  }
@@ -358,6 +362,47 @@ function mergeGroupSettings(rootSettings, groupNames, groupDefs) {
358
362
  }
359
363
  return accumulated;
360
364
  }
365
+ /**
366
+ * Evaluates a conditional group's `when` clause against a repo's effective groups.
367
+ * Both `allOf` (every listed group present) and `anyOf` (at least one present)
368
+ * must be satisfied. Absent conditions are treated as satisfied.
369
+ */
370
+ function evaluateWhenClause(when, effectiveGroups) {
371
+ // Defensive: if neither condition is specified, don't match
372
+ if (!when.allOf && !when.anyOf)
373
+ return false;
374
+ const allOfSatisfied = !when.allOf || when.allOf.every((g) => effectiveGroups.has(g));
375
+ const anyOfSatisfied = !when.anyOf || when.anyOf.some((g) => effectiveGroups.has(g));
376
+ return allOfSatisfied && anyOfSatisfied;
377
+ }
378
+ /**
379
+ * Merges matching conditional groups into the accumulated files/prOptions/settings.
380
+ * Each matching conditional group is applied in array order, using the same
381
+ * merge semantics as regular group layers (inherit:false, file:false, override:true).
382
+ */
383
+ function mergeConditionalGroups(accumulatedFiles, accumulatedPROptions, accumulatedSettings, effectiveGroups, conditionalGroups) {
384
+ let files = structuredClone(accumulatedFiles);
385
+ let prOptions = accumulatedPROptions
386
+ ? structuredClone(accumulatedPROptions)
387
+ : undefined;
388
+ let settings = accumulatedSettings;
389
+ for (const cg of conditionalGroups) {
390
+ if (!evaluateWhenClause(cg.when, effectiveGroups))
391
+ continue;
392
+ if (cg.files) {
393
+ files = applyFileLayer(files, cg.files);
394
+ }
395
+ // Merge prOptions
396
+ if (cg.prOptions) {
397
+ prOptions = mergePROptions(prOptions, cg.prOptions);
398
+ }
399
+ // Merge settings
400
+ if (cg.settings) {
401
+ settings = mergeRawSettings(settings, cg.settings);
402
+ }
403
+ }
404
+ return { files, prOptions, settings };
405
+ }
361
406
  /**
362
407
  * Resolves a single file entry by merging root config with repo overrides.
363
408
  * Returns null if the file should be skipped.
@@ -396,16 +441,28 @@ export function normalizeConfig(raw, env) {
396
441
  const expandedRepos = [];
397
442
  for (const rawRepo of raw.repos) {
398
443
  const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
399
- // Resolve groups: build effective root files/prOptions/settings by merging group layers
400
- const effectiveRootFiles = rawRepo.groups?.length
401
- ? mergeGroupFiles(raw.files ?? {}, rawRepo.groups, raw.groups ?? {})
444
+ // Phase 0: Expand extends chains
445
+ const expandedGroups = rawRepo.groups?.length
446
+ ? expandRepoGroups(rawRepo.groups, raw.groups ?? {})
447
+ : [];
448
+ // Phase 1: Resolve groups - build effective root files/prOptions/settings by merging group layers
449
+ let effectiveRootFiles = expandedGroups.length
450
+ ? mergeGroupFiles(raw.files ?? {}, expandedGroups, raw.groups ?? {})
402
451
  : (raw.files ?? {});
403
- const effectivePROptions = rawRepo.groups?.length
404
- ? mergeGroupPROptions(raw.prOptions, rawRepo.groups, raw.groups ?? {})
452
+ let effectivePROptions = expandedGroups.length
453
+ ? mergeGroupPROptions(raw.prOptions, expandedGroups, raw.groups ?? {})
405
454
  : raw.prOptions;
406
- const effectiveSettings = rawRepo.groups?.length
407
- ? mergeGroupSettings(raw.settings, rawRepo.groups, raw.groups ?? {})
455
+ let effectiveSettings = expandedGroups.length
456
+ ? mergeGroupSettings(raw.settings, expandedGroups, raw.groups ?? {})
408
457
  : raw.settings;
458
+ // Phase 2 + 3: Evaluate and merge conditional groups
459
+ if (raw.conditionalGroups?.length) {
460
+ const effectiveGroups = new Set(expandedGroups);
461
+ const merged = mergeConditionalGroups(effectiveRootFiles, effectivePROptions, effectiveSettings, effectiveGroups, raw.conditionalGroups);
462
+ effectiveRootFiles = merged.files;
463
+ effectivePROptions = merged.prOptions;
464
+ effectiveSettings = merged.settings;
465
+ }
409
466
  const fileNames = Object.keys(effectiveRootFiles);
410
467
  for (const gitUrl of gitUrls) {
411
468
  const files = [];
@@ -292,12 +292,33 @@ export interface RawRepoFileOverride {
292
292
  deleteOrphaned?: boolean;
293
293
  }
294
294
  export interface RawGroupConfig {
295
+ extends?: string | string[];
295
296
  files?: Record<string, RawFileConfig | RawRepoFileOverride | false> & {
296
297
  inherit?: boolean;
297
298
  };
298
299
  prOptions?: PRMergeOptions;
299
300
  settings?: RawRepoSettings;
300
301
  }
302
+ /** Condition for conditional group activation */
303
+ export interface RawConditionalGroupWhen {
304
+ /** All listed groups must be present in the repo's effective group set */
305
+ allOf?: string[];
306
+ /** At least one listed group must be present */
307
+ anyOf?: string[];
308
+ }
309
+ /** Conditional group: activates based on which groups a repo has */
310
+ export interface RawConditionalGroupConfig {
311
+ /** Condition that determines when this group activates */
312
+ when: RawConditionalGroupWhen;
313
+ /** File definitions or overrides (same capabilities as regular groups) */
314
+ files?: Record<string, RawFileConfig | RawRepoFileOverride | false> & {
315
+ inherit?: boolean;
316
+ };
317
+ /** PR merge options */
318
+ prOptions?: PRMergeOptions;
319
+ /** Repository settings (rulesets, labels, repo settings) */
320
+ settings?: RawRepoSettings;
321
+ }
301
322
  export interface RawRootSettings {
302
323
  rulesets?: Record<string, Ruleset | false>;
303
324
  repo?: GitHubRepoSettings | false;
@@ -331,6 +352,7 @@ export interface RawConfig {
331
352
  id: string;
332
353
  files?: Record<string, RawFileConfig>;
333
354
  groups?: Record<string, RawGroupConfig>;
355
+ conditionalGroups?: RawConditionalGroupConfig[];
334
356
  repos: RawRepoConfig[];
335
357
  prOptions?: PRMergeOptions;
336
358
  prTemplate?: string;
@@ -1,3 +1,4 @@
1
+ import { resolveExtendsChain, expandRepoGroups } from "./extends-resolver.js";
1
2
  import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
2
3
  import { validateRepoSettings } from "./validators/repo-settings-validator.js";
3
4
  import { validateRuleset } from "./validators/ruleset-validator.js";
@@ -259,6 +260,71 @@ function validatePrOptions(config) {
259
260
  }
260
261
  }
261
262
  }
263
+ /**
264
+ * Validates the extends field on a single group definition.
265
+ * Checks type, self-reference, and that all referenced groups exist.
266
+ */
267
+ function validateGroupExtends(groupName, extends_, groupNames) {
268
+ // Type check
269
+ if (typeof extends_ === "string") {
270
+ if (extends_.length === 0) {
271
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
272
+ }
273
+ // Self-reference
274
+ if (extends_ === groupName) {
275
+ throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
276
+ }
277
+ // Existence
278
+ if (!groupNames.has(extends_)) {
279
+ throw new ValidationError(`groups.${groupName}: extends references undefined group '${extends_}'`);
280
+ }
281
+ }
282
+ else if (Array.isArray(extends_)) {
283
+ if (extends_.length === 0) {
284
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
285
+ }
286
+ const seen = new Set();
287
+ for (const entry of extends_) {
288
+ if (typeof entry !== "string") {
289
+ throw new ValidationError(`groups.${groupName}: 'extends' array entries must be strings`);
290
+ }
291
+ if (entry.length === 0) {
292
+ throw new ValidationError(`groups.${groupName}: 'extends' array entries must be non-empty strings`);
293
+ }
294
+ if (entry === groupName) {
295
+ throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
296
+ }
297
+ if (!groupNames.has(entry)) {
298
+ throw new ValidationError(`groups.${groupName}: extends references undefined group '${entry}'`);
299
+ }
300
+ if (seen.has(entry)) {
301
+ throw new ValidationError(`groups.${groupName}: duplicate '${entry}' in extends`);
302
+ }
303
+ seen.add(entry);
304
+ }
305
+ }
306
+ else {
307
+ throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
308
+ }
309
+ }
310
+ /**
311
+ * Detects circular extends chains across all groups.
312
+ * Reuses resolveExtendsChain from extends-resolver.ts to avoid
313
+ * duplicating the chain-walking logic. Converts thrown errors
314
+ * to ValidationError.
315
+ */
316
+ function validateNoCircularExtends(groups) {
317
+ for (const name of Object.keys(groups)) {
318
+ if (!groups[name].extends)
319
+ continue;
320
+ try {
321
+ resolveExtendsChain(name, groups);
322
+ }
323
+ catch (error) {
324
+ throw new ValidationError(error instanceof Error ? error.message : String(error));
325
+ }
326
+ }
327
+ }
262
328
  function validateGroups(config) {
263
329
  if (config.groups === undefined)
264
330
  return;
@@ -266,10 +332,18 @@ function validateGroups(config) {
266
332
  throw new ValidationError("groups must be an object");
267
333
  }
268
334
  const rootCtx = buildRootSettingsContext(config);
335
+ const groupNames = new Set(Object.keys(config.groups));
269
336
  for (const [groupName, group] of Object.entries(config.groups)) {
270
337
  if (groupName === "inherit") {
271
338
  throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
272
339
  }
340
+ if (groupName === "extends") {
341
+ throw new ValidationError("'extends' is a reserved key and cannot be used as a group name");
342
+ }
343
+ // Validate extends field
344
+ if (group.extends !== undefined) {
345
+ validateGroupExtends(groupName, group.extends, groupNames);
346
+ }
273
347
  if (group.files) {
274
348
  for (const [fileName, fileConfig] of Object.entries(group.files)) {
275
349
  if (fileName === "inherit")
@@ -285,6 +359,81 @@ function validateGroups(config) {
285
359
  validateSettings(group.settings, `groups.${groupName}`, rootCtx);
286
360
  }
287
361
  }
362
+ // Validate no circular extends after individual validation
363
+ validateNoCircularExtends(config.groups);
364
+ }
365
+ function validateConditionalGroups(config) {
366
+ if (config.conditionalGroups === undefined)
367
+ return;
368
+ if (!Array.isArray(config.conditionalGroups)) {
369
+ throw new ValidationError("conditionalGroups must be an array");
370
+ }
371
+ const rootCtx = buildRootSettingsContext(config);
372
+ const groupNames = config.groups ? Object.keys(config.groups) : [];
373
+ for (let i = 0; i < config.conditionalGroups.length; i++) {
374
+ const entry = config.conditionalGroups[i];
375
+ const ctx = `conditionalGroups[${i}]`;
376
+ // Validate 'when' clause
377
+ if (!entry.when || !isPlainObject(entry.when)) {
378
+ throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
379
+ }
380
+ const { allOf, anyOf } = entry.when;
381
+ if (!allOf && !anyOf) {
382
+ throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf' or 'anyOf'`);
383
+ }
384
+ if (allOf !== undefined) {
385
+ if (!Array.isArray(allOf) || allOf.length === 0) {
386
+ throw new ValidationError(`${ctx}: 'allOf' must be a non-empty array of strings`);
387
+ }
388
+ const seen = new Set();
389
+ for (const name of allOf) {
390
+ if (typeof name !== "string") {
391
+ throw new ValidationError(`${ctx}: 'allOf' entries must be strings`);
392
+ }
393
+ if (!groupNames.includes(name)) {
394
+ throw new ValidationError(`${ctx}: group '${name}' in allOf is not defined in root 'groups'`);
395
+ }
396
+ if (seen.has(name)) {
397
+ throw new ValidationError(`${ctx}: duplicate group '${name}' in allOf`);
398
+ }
399
+ seen.add(name);
400
+ }
401
+ }
402
+ if (anyOf !== undefined) {
403
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
404
+ throw new ValidationError(`${ctx}: 'anyOf' must be a non-empty array of strings`);
405
+ }
406
+ const seen = new Set();
407
+ for (const name of anyOf) {
408
+ if (typeof name !== "string") {
409
+ throw new ValidationError(`${ctx}: 'anyOf' entries must be strings`);
410
+ }
411
+ if (!groupNames.includes(name)) {
412
+ throw new ValidationError(`${ctx}: group '${name}' in anyOf is not defined in root 'groups'`);
413
+ }
414
+ if (seen.has(name)) {
415
+ throw new ValidationError(`${ctx}: duplicate group '${name}' in anyOf`);
416
+ }
417
+ seen.add(name);
418
+ }
419
+ }
420
+ // Validate files
421
+ if (entry.files) {
422
+ for (const [fileName, fileConfig] of Object.entries(entry.files)) {
423
+ if (fileName === "inherit")
424
+ continue;
425
+ if (fileConfig === false)
426
+ continue;
427
+ if (fileConfig === undefined)
428
+ continue;
429
+ validateFileConfigFields(fileConfig, fileName, `${ctx}:`);
430
+ }
431
+ }
432
+ // Validate settings
433
+ if (entry.settings !== undefined) {
434
+ validateSettings(entry.settings, ctx, rootCtx);
435
+ }
436
+ }
288
437
  }
289
438
  function validateRepoGitField(repo, index) {
290
439
  if (!repo.git) {
@@ -349,7 +498,8 @@ function validateRepoFiles(config, repo, index, repoLabel) {
349
498
  }
350
499
  const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
351
500
  if (repo.groups && config.groups) {
352
- for (const groupName of repo.groups) {
501
+ const expandedGroups = expandRepoGroups(repo.groups, config.groups);
502
+ for (const groupName of expandedGroups) {
353
503
  const group = config.groups[groupName];
354
504
  if (group?.files) {
355
505
  for (const fn of Object.keys(group.files)) {
@@ -359,6 +509,16 @@ function validateRepoFiles(config, repo, index, repoLabel) {
359
509
  }
360
510
  }
361
511
  }
512
+ if (config.conditionalGroups) {
513
+ for (const cg of config.conditionalGroups) {
514
+ if (cg.files) {
515
+ for (const fn of Object.keys(cg.files)) {
516
+ if (fn !== "inherit")
517
+ knownFiles.add(fn);
518
+ }
519
+ }
520
+ }
521
+ }
362
522
  for (const fileName of Object.keys(repo.files)) {
363
523
  if (fileName === "inherit") {
364
524
  const inheritValue = repo.files.inherit;
@@ -386,7 +546,8 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
386
546
  return;
387
547
  const rootCtx = buildRootSettingsContext(config);
388
548
  if (repo.groups && config.groups) {
389
- for (const groupName of repo.groups) {
549
+ const expandedGroups = expandRepoGroups(repo.groups, config.groups);
550
+ for (const groupName of expandedGroups) {
390
551
  const group = config.groups[groupName];
391
552
  if (group?.settings?.rulesets) {
392
553
  for (const name of Object.keys(group.settings.rulesets)) {
@@ -400,6 +561,29 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
400
561
  rootCtx.labelNames.push(name);
401
562
  }
402
563
  }
564
+ if (group?.settings?.repo !== undefined &&
565
+ group.settings.repo !== false) {
566
+ rootCtx.hasRepoSettings = true;
567
+ }
568
+ }
569
+ }
570
+ if (config.conditionalGroups) {
571
+ for (const cg of config.conditionalGroups) {
572
+ if (cg.settings?.rulesets) {
573
+ for (const name of Object.keys(cg.settings.rulesets)) {
574
+ if (name !== "inherit")
575
+ rootCtx.rulesetNames.push(name);
576
+ }
577
+ }
578
+ if (cg.settings?.labels) {
579
+ for (const name of Object.keys(cg.settings.labels)) {
580
+ if (name !== "inherit")
581
+ rootCtx.labelNames.push(name);
582
+ }
583
+ }
584
+ if (cg.settings?.repo !== undefined && cg.settings.repo !== false) {
585
+ rootCtx.hasRepoSettings = true;
586
+ }
403
587
  }
404
588
  }
405
589
  validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
@@ -427,7 +611,20 @@ export function validateRawConfig(config) {
427
611
  const hasGrpFiles = hasGroupFiles(config);
428
612
  const hasGrpSettings = isPlainObject(config.groups) &&
429
613
  Object.values(config.groups).some((g) => g.settings && isPlainObject(g.settings));
430
- if (!hasFiles && !hasSettings && !hasGrpFiles && !hasGrpSettings) {
614
+ const hasCondGrpFiles = Array.isArray(config.conditionalGroups) &&
615
+ config.conditionalGroups.some((cg) => cg.files &&
616
+ Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0);
617
+ const hasCondGrpSettings = Array.isArray(config.conditionalGroups) &&
618
+ config.conditionalGroups.some((cg) => cg.settings && isPlainObject(cg.settings));
619
+ const hasCondGrpPR = Array.isArray(config.conditionalGroups) &&
620
+ config.conditionalGroups.some((cg) => cg.prOptions && isPlainObject(cg.prOptions));
621
+ if (!hasFiles &&
622
+ !hasSettings &&
623
+ !hasGrpFiles &&
624
+ !hasGrpSettings &&
625
+ !hasCondGrpFiles &&
626
+ !hasCondGrpSettings &&
627
+ !hasCondGrpPR) {
431
628
  throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
432
629
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
433
630
  }
@@ -443,6 +640,7 @@ export function validateRawConfig(config) {
443
640
  validateGithubHosts(config);
444
641
  validatePrOptions(config);
445
642
  validateGroups(config);
643
+ validateConditionalGroups(config);
446
644
  for (let i = 0; i < config.repos.length; i++) {
447
645
  validateRepoEntry(config, config.repos[i], i);
448
646
  }
@@ -461,11 +659,21 @@ export function validateForSync(config) {
461
659
  const hasRepoSettings = config.repos.some((repo) => hasActionableSettings(repo.settings));
462
660
  const hasGroupSettings = isPlainObject(config.groups) &&
463
661
  Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
662
+ const hasCondGrpFiles = Array.isArray(config.conditionalGroups) &&
663
+ config.conditionalGroups.some((cg) => cg.files &&
664
+ Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0);
665
+ const hasCondGrpSettings = Array.isArray(config.conditionalGroups) &&
666
+ config.conditionalGroups.some((cg) => cg.settings && hasActionableSettings(cg.settings));
667
+ const hasCondGrpPR = Array.isArray(config.conditionalGroups) &&
668
+ config.conditionalGroups.some((cg) => cg.prOptions && isPlainObject(cg.prOptions));
464
669
  if (!hasRootFiles &&
465
670
  !hasGrpFiles &&
466
671
  !hasSettings &&
467
672
  !hasRepoSettings &&
468
- !hasGroupSettings) {
673
+ !hasGroupSettings &&
674
+ !hasCondGrpFiles &&
675
+ !hasCondGrpSettings &&
676
+ !hasCondGrpPR) {
469
677
  throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
470
678
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
471
679
  }
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { writeGitHubStepSummary } from "./github-summary.js";
3
+ import { formatScalarValue } from "../shared/string-utils.js";
3
4
  /**
4
5
  * Shared recursive renderer for ruleset config objects.
5
6
  * The formatLine callback controls indentation style and coloring:
@@ -127,14 +128,9 @@ export function formatSettingsReportCLI(report) {
127
128
  return lines;
128
129
  }
129
130
  function formatValuePlain(val) {
130
- if (val === null)
131
- return "null";
132
- if (val === undefined)
133
- return "undefined";
134
- if (typeof val === "string")
135
- return `"${val}"`;
136
- if (typeof val === "boolean")
137
- return val ? "true" : "false";
131
+ const scalar = formatScalarValue(val);
132
+ if (scalar !== undefined)
133
+ return scalar;
138
134
  if (typeof val === "object")
139
135
  return JSON.stringify(val);
140
136
  return String(val);
@@ -1,40 +1,46 @@
1
1
  import { isGitHubRepo, getRepoDisplayName } from "../shared/repo-detector.js";
2
2
  import { toErrorMessage } from "../shared/type-guards.js";
3
+ /**
4
+ * Build a base result that satisfies TResult for guard early-returns.
5
+ * All TResult subtypes only extend BaseProcessorResult with optional fields,
6
+ * so a base-only object is structurally valid. If adding a new processor
7
+ * result type, ensure all extension fields are optional.
8
+ */
9
+ function baseResult(result) {
10
+ return result;
11
+ }
3
12
  /**
4
13
  * Common boilerplate for GitHub settings processors: GitHub-only gating,
5
14
  * empty settings check, token resolution, and error wrapping.
6
15
  */
7
16
  export async function withGitHubGuards(repoConfig, repoInfo, options, guards) {
8
17
  const repoName = getRepoDisplayName(repoInfo);
9
- // Safe cast: all TResult subtypes (RulesetProcessorResult, LabelsProcessorResult,
10
- // RepoSettingsProcessorResult) only extend BaseProcessorResult with optional fields.
11
- // If adding a new processor result type, ensure all extension fields are optional.
12
18
  if (!isGitHubRepo(repoInfo)) {
13
- return {
19
+ return baseResult({
14
20
  success: true,
15
21
  repoName,
16
22
  message: `Skipped: ${repoName} is not a GitHub repository`,
17
23
  skipped: true,
18
- };
24
+ });
19
25
  }
20
26
  if (!guards.hasDesiredSettings(repoConfig)) {
21
- return {
27
+ return baseResult({
22
28
  success: true,
23
29
  repoName,
24
30
  message: guards.emptySettingsMessage,
25
31
  skipped: true,
26
- };
32
+ });
27
33
  }
28
34
  try {
29
35
  return await guards.applySettings(repoInfo, repoConfig, options, options.token, repoName);
30
36
  }
31
37
  catch (error) {
32
38
  const message = toErrorMessage(error);
33
- return {
39
+ return baseResult({
34
40
  success: false,
35
41
  repoName,
36
42
  message: `Failed: ${message}`,
37
- };
43
+ });
38
44
  }
39
45
  }
40
46
  /** Type predicate that narrows entries with an active (non-"unchanged") action. */
@@ -29,6 +29,6 @@ export interface LabelChange {
29
29
  * @param deleteOrphaned - If true, delete current labels not in desired config
30
30
  * @param noDelete - If true, skip delete operations
31
31
  * @returns Array of changes to apply
32
- * @throws Error if rename collisions are detected
32
+ * @throws ValidationError if rename collisions are detected
33
33
  */
34
34
  export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, deleteOrphaned: boolean, noDelete: boolean): LabelChange[];
@@ -15,7 +15,7 @@ import { ValidationError } from "../../shared/errors.js";
15
15
  * @param deleteOrphaned - If true, delete current labels not in desired config
16
16
  * @param noDelete - If true, skip delete operations
17
17
  * @returns Array of changes to apply
18
- * @throws Error if rename collisions are detected
18
+ * @throws ValidationError if rename collisions are detected
19
19
  */
20
20
  export function diffLabels(current, desired, deleteOrphaned, noDelete) {
21
21
  const changes = [];
@@ -1,17 +1,10 @@
1
1
  import chalk from "chalk";
2
+ import { formatScalarValue } from "../../shared/string-utils.js";
2
3
  /**
3
4
  * Format a value for display.
4
5
  */
5
6
  function formatValue(val) {
6
- if (val === null)
7
- return "null";
8
- if (val === undefined)
9
- return "undefined";
10
- if (typeof val === "string")
11
- return `"${val}"`;
12
- if (typeof val === "boolean")
13
- return val ? "true" : "false";
14
- return String(val);
7
+ return formatScalarValue(val) ?? String(val);
15
8
  }
16
9
  /**
17
10
  * Get warning message for a property change.
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { projectToDesiredShape, normalizeRuleset, } from "./diff.js";
3
+ import { formatScalarValue } from "../../shared/string-utils.js";
3
4
  import { computePropertyDiffs, } from "./diff-algorithm.js";
4
5
  import { isPlainObject } from "../../shared/type-guards.js";
5
6
  /**
@@ -39,12 +40,9 @@ function buildTree(diffs) {
39
40
  * Format a value for inline display (scalars and simple arrays only).
40
41
  */
41
42
  function formatValue(val) {
42
- if (val === null)
43
- return "null";
44
- if (val === undefined)
45
- return "undefined";
46
- if (typeof val === "string")
47
- return `"${val}"`;
43
+ const scalar = formatScalarValue(val);
44
+ if (scalar !== undefined)
45
+ return scalar;
48
46
  if (Array.isArray(val)) {
49
47
  if (val.every((v) => typeof v !== "object" || v === null)) {
50
48
  return `[${val.map(formatValue).join(", ")}]`;
@@ -224,10 +222,10 @@ export function formatRulesetPlan(changes) {
224
222
  for (const c of changes) {
225
223
  grouped[c.action].push(c);
226
224
  }
225
+ if (grouped.create.length > 0) {
226
+ lines.push(chalk.bold(" Create:"));
227
+ }
227
228
  for (const change of grouped.create) {
228
- if (grouped.create.indexOf(change) === 0) {
229
- lines.push(chalk.bold(" Create:"));
230
- }
231
229
  lines.push(chalk.green(` + ruleset "${change.name}"`));
232
230
  if (change.desired) {
233
231
  lines.push(...formatFullConfig(change.desired, 2));
@@ -243,10 +241,10 @@ export function formatRulesetPlan(changes) {
243
241
  });
244
242
  lines.push("");
245
243
  }
244
+ if (grouped.update.length > 0) {
245
+ lines.push(chalk.bold(" Update:"));
246
+ }
246
247
  for (const change of grouped.update) {
247
- if (grouped.update.indexOf(change) === 0) {
248
- lines.push(chalk.bold(" Update:"));
249
- }
250
248
  lines.push(chalk.yellow(` ~ ruleset "${change.name}"`));
251
249
  if (change.current && change.desired) {
252
250
  const currentNorm = normalizeRuleset(change.current);
@@ -1,6 +1,6 @@
1
1
  export declare function sanitizeBranchName(fileName: string): string;
2
2
  /**
3
3
  * Validates a user-provided branch name against git's naming rules.
4
- * @throws Error if the branch name is invalid
4
+ * @throws ValidationError if the branch name is invalid
5
5
  */
6
6
  export declare function validateBranchName(branchName: string): void;
@@ -9,7 +9,7 @@ export function sanitizeBranchName(fileName) {
9
9
  }
10
10
  /**
11
11
  * Validates a user-provided branch name against git's naming rules.
12
- * @throws Error if the branch name is invalid
12
+ * @throws ValidationError if the branch name is invalid
13
13
  */
14
14
  export function validateBranchName(branchName) {
15
15
  if (!branchName || branchName.trim() === "") {
@@ -13,6 +13,8 @@ interface GhApiCallParams {
13
13
  payload?: unknown;
14
14
  options?: GhApiOptions;
15
15
  paginate?: boolean;
16
+ /** Override for delay function (test injection) */
17
+ _retryDelay?: (ms: number) => Promise<void>;
16
18
  }
17
19
  /**
18
20
  * Get the hostname flag for gh commands.
@@ -20,6 +22,20 @@ interface GhApiCallParams {
20
22
  */
21
23
  export declare function getHostnameFlag(repoInfo: GitHubRepoInfo): string;
22
24
  export declare function buildTokenEnv(token?: string): Record<string, string> | undefined;
25
+ /**
26
+ * Strips HTTP response headers from `gh api --include` output.
27
+ * Splits on the first blank line (LF or CRLF) and returns everything after it.
28
+ * If no blank line is found, returns the full string (no headers present).
29
+ */
30
+ export declare function parseResponseBody(raw: string): string;
31
+ /**
32
+ * Parses Retry-After header from an exec error's stdout and attaches it
33
+ * as error.retryAfter (number of seconds). Only extracts the numeric value
34
+ * to avoid leaking tokens from other headers.
35
+ *
36
+ * No-op if stdout is absent or does not contain a numeric Retry-After header.
37
+ */
38
+ export declare function attachRetryAfter(error: unknown): void;
23
39
  /**
24
40
  * Encapsulates executor + retries for GitHub API calls.
25
41
  * Strategies compose with this instead of duplicating ghApi wrappers.
@@ -14,6 +14,40 @@ export function getHostnameFlag(repoInfo) {
14
14
  export function buildTokenEnv(token) {
15
15
  return token ? { GH_TOKEN: token } : undefined;
16
16
  }
17
+ /**
18
+ * Strips HTTP response headers from `gh api --include` output.
19
+ * Splits on the first blank line (LF or CRLF) and returns everything after it.
20
+ * If no blank line is found, returns the full string (no headers present).
21
+ */
22
+ export function parseResponseBody(raw) {
23
+ // Try CRLF first, then LF
24
+ const crlfIndex = raw.indexOf("\r\n\r\n");
25
+ if (crlfIndex !== -1) {
26
+ return raw.slice(crlfIndex + 4);
27
+ }
28
+ const lfIndex = raw.indexOf("\n\n");
29
+ if (lfIndex !== -1) {
30
+ return raw.slice(lfIndex + 2);
31
+ }
32
+ return raw;
33
+ }
34
+ /**
35
+ * Parses Retry-After header from an exec error's stdout and attaches it
36
+ * as error.retryAfter (number of seconds). Only extracts the numeric value
37
+ * to avoid leaking tokens from other headers.
38
+ *
39
+ * No-op if stdout is absent or does not contain a numeric Retry-After header.
40
+ */
41
+ export function attachRetryAfter(error) {
42
+ const stdout = error.stdout;
43
+ if (!stdout)
44
+ return;
45
+ const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString();
46
+ const match = stdoutStr.match(/^retry-after:\s*(\d+)\s*$/im);
47
+ if (match) {
48
+ error.retryAfter = parseInt(match[1], 10);
49
+ }
50
+ }
17
51
  /**
18
52
  * Executes a GitHub API call using the gh CLI.
19
53
  * Shared by labels, rulesets, and repo-settings strategies.
@@ -30,23 +64,38 @@ async function ghApiCall(method, endpoint, opts) {
30
64
  if (paginate) {
31
65
  args.push("--paginate");
32
66
  }
67
+ else {
68
+ args.push("--include");
69
+ }
33
70
  if (apiOpts?.host && apiOpts.host !== "github.com") {
34
71
  args.push("--hostname", escapeShellArg(apiOpts.host));
35
72
  }
36
73
  args.push(escapeShellArg(endpoint));
37
74
  const baseCommand = args.join(" ");
38
75
  const env = buildTokenEnv(apiOpts?.token);
76
+ const execAndParse = async (command) => {
77
+ try {
78
+ const raw = await executor.exec(command, cwd, { env });
79
+ return paginate ? raw : parseResponseBody(raw);
80
+ }
81
+ catch (error) {
82
+ if (!paginate) {
83
+ attachRetryAfter(error);
84
+ }
85
+ throw error;
86
+ }
87
+ };
88
+ const retryOpts = {
89
+ retries,
90
+ ...(opts._retryDelay ? { _delay: opts._retryDelay } : {}),
91
+ };
39
92
  if (payload &&
40
93
  (method === "POST" || method === "PUT" || method === "PATCH")) {
41
94
  const payloadJson = JSON.stringify(payload);
42
95
  const command = `echo ${escapeShellArg(payloadJson)} | ${baseCommand} --input -`;
43
- return await withRetry(() => executor.exec(command, cwd, { env }), {
44
- retries,
45
- });
96
+ return await withRetry(() => execAndParse(command), retryOpts);
46
97
  }
47
- return await withRetry(() => executor.exec(baseCommand, cwd, { env }), {
48
- retries,
49
- });
98
+ return await withRetry(() => execAndParse(baseCommand), retryOpts);
50
99
  }
51
100
  /**
52
101
  * Encapsulates executor + retries for GitHub API calls.
@@ -61,7 +110,7 @@ export class GhApiClient {
61
110
  this.retries = retries;
62
111
  this.cwd = cwd;
63
112
  }
64
- async call(method, endpoint, params) {
113
+ call(method, endpoint, params) {
65
114
  return ghApiCall(method, endpoint, {
66
115
  executor: this.executor,
67
116
  retries: this.retries,
@@ -69,6 +118,7 @@ export class GhApiClient {
69
118
  apiOpts: params?.options,
70
119
  payload: params?.payload,
71
120
  paginate: params?.paginate,
121
+ _retryDelay: params?._retryDelay,
72
122
  });
73
123
  }
74
124
  }
@@ -8,6 +8,11 @@ export declare const CORE_PERMANENT_ERROR_PATTERNS: RegExp[];
8
8
  * Extends CORE_PERMANENT_ERROR_PATTERNS with git-CLI-specific patterns.
9
9
  */
10
10
  export declare const DEFAULT_PERMANENT_ERROR_PATTERNS: RegExp[];
11
+ /**
12
+ * Checks if an error specifically indicates a rate limit (not just any transient error).
13
+ * Rate limit errors need longer backoff (60s+) compared to network errors (1-4s).
14
+ */
15
+ export declare function isRateLimitError(error: unknown): boolean;
11
16
  interface RetryOptions {
12
17
  /** Maximum number of retries (default: 3) */
13
18
  retries?: number;
@@ -21,6 +26,8 @@ interface RetryOptions {
21
26
  log?: {
22
27
  info(msg: string): void;
23
28
  };
29
+ /** Override for delay function (test injection) */
30
+ _delay?: (ms: number) => Promise<void>;
24
31
  }
25
32
  /**
26
33
  * Classifies an error as permanent (should not retry) or transient (should retry).
@@ -65,6 +65,36 @@ const DEFAULT_TRANSIENT_ERROR_PATTERNS = [
65
65
  /could\s*not\s*resolve\s*host/i,
66
66
  /unable\s*to\s*access/i,
67
67
  ];
68
+ /**
69
+ * Patterns that specifically indicate rate limiting (a subset of transient errors).
70
+ * Used to apply longer backoff delays -- connection resets and 5xx errors
71
+ * should NOT get 60-second waits.
72
+ */
73
+ const RATE_LIMIT_PATTERNS = [
74
+ /rate\s*limit/i,
75
+ /too\s*many\s*requests/i,
76
+ /abuse\s*detection/i,
77
+ ];
78
+ /**
79
+ * Checks if an error specifically indicates a rate limit (not just any transient error).
80
+ * Rate limit errors need longer backoff (60s+) compared to network errors (1-4s).
81
+ */
82
+ export function isRateLimitError(error) {
83
+ const message = error instanceof Error ? error.message : String(error ?? "");
84
+ const stderr = error.stderr?.toString() ?? "";
85
+ const combined = `${message} ${stderr}`;
86
+ for (const pattern of RATE_LIMIT_PATTERNS) {
87
+ if (pattern.test(combined)) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ /** Default delay (seconds) for rate limit errors when no Retry-After header is available. */
94
+ const RATE_LIMIT_FALLBACK_DELAY_SECONDS = 60;
95
+ function delay(ms) {
96
+ return new Promise((resolve) => setTimeout(resolve, ms));
97
+ }
68
98
  /**
69
99
  * Classifies an error as permanent (should not retry) or transient (should retry).
70
100
  */
@@ -116,6 +146,7 @@ export async function withRetry(fn, options) {
116
146
  }
117
147
  catch (error) {
118
148
  if (error instanceof Error &&
149
+ !isTransientError(error, options?.transientErrorPatterns) &&
119
150
  isPermanentError(error, permanentPatterns)) {
120
151
  // Wrap in AbortError to stop retrying immediately
121
152
  throw new AbortError(error);
@@ -124,8 +155,15 @@ export async function withRetry(fn, options) {
124
155
  }
125
156
  }, {
126
157
  retries,
127
- onFailedAttempt: (context) => {
128
- // Only log if this isn't the last attempt
158
+ onFailedAttempt: async (context) => {
159
+ // Apply rate-limit-specific delay before the next retry
160
+ if (context.retriesLeft > 0 && isRateLimitError(context.error)) {
161
+ const retryAfterSeconds = context.error.retryAfter ??
162
+ RATE_LIMIT_FALLBACK_DELAY_SECONDS;
163
+ options?.log?.info(`Rate limited. Waiting ${retryAfterSeconds}s before retry...`);
164
+ await (options?._delay ?? delay)(retryAfterSeconds * 1000);
165
+ }
166
+ // Log the failure (existing behavior)
129
167
  if (context.retriesLeft > 0) {
130
168
  const msg = sanitizeCredentials(context.error.message) || "Unknown error";
131
169
  options?.log?.info(`Attempt ${context.attemptNumber}/${retries + 1} failed: ${msg}. Retrying...`);
@@ -2,3 +2,9 @@
2
2
  * Convert a camelCase string to snake_case.
3
3
  */
4
4
  export declare function camelToSnake(str: string): string;
5
+ /**
6
+ * Format a scalar value for display: null, undefined, string, boolean.
7
+ * Returns undefined for non-scalar types (arrays, objects) so callers
8
+ * can apply domain-specific formatting.
9
+ */
10
+ export declare function formatScalarValue(val: unknown): string | undefined;
@@ -4,3 +4,19 @@
4
4
  export function camelToSnake(str) {
5
5
  return str.replace(/([A-Z])/g, "_$1").toLowerCase();
6
6
  }
7
+ /**
8
+ * Format a scalar value for display: null, undefined, string, boolean.
9
+ * Returns undefined for non-scalar types (arrays, objects) so callers
10
+ * can apply domain-specific formatting.
11
+ */
12
+ export function formatScalarValue(val) {
13
+ if (val === null)
14
+ return "null";
15
+ if (val === undefined)
16
+ return "undefined";
17
+ if (typeof val === "string")
18
+ return `"${val}"`;
19
+ if (typeof val === "boolean")
20
+ return val ? "true" : "false";
21
+ return undefined;
22
+ }
@@ -1,5 +1,5 @@
1
1
  import { RepoInfo } from "../shared/repo-detector.js";
2
- import { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
2
+ import type { GitHubAppTokenManager } from "../vcs/index.js";
3
3
  import type { AuthResult, IAuthOptionsBuilder } from "./types.js";
4
4
  import type { ILogger } from "../shared/logger.js";
5
5
  export declare class AuthOptionsBuilder implements IAuthOptionsBuilder {
@@ -1,7 +1,7 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import type { ILogger } from "../shared/logger.js";
4
- import type { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
4
+ import type { GitHubAppTokenManager } from "../vcs/index.js";
5
5
  import type { IFileWriter, IManifestManager, IBranchManager, IAuthOptionsBuilder, IRepositorySession, ICommitPushManager, IFileSyncOrchestrator, IPRMergeHandler, ISyncWorkflow, IRepositoryProcessor, GitOpsFactory, ProcessorOptions, ProcessorResult } from "./types.js";
6
6
  /**
7
7
  * Thin facade that delegates to SyncWorkflow with FileSyncStrategy.
@@ -18,7 +18,7 @@ export declare class GitOps implements ILocalGitOps {
18
18
  /**
19
19
  * Validates that a file path doesn't escape the workspace directory.
20
20
  * @returns The resolved absolute file path
21
- * @throws Error if path traversal is detected
21
+ * @throws ValidationError if path traversal is detected
22
22
  */
23
23
  private validatePath;
24
24
  cleanWorkspace(): void;
@@ -70,7 +70,7 @@ export declare class GitOps implements ILocalGitOps {
70
70
  /**
71
71
  * Stage all changes and commit with the given message.
72
72
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
73
- * @returns true if a commit was made, false if there were no staged changes
73
+ * @returns true if a commit was made (or would be made in dry-run mode), false if there were no staged changes
74
74
  */
75
75
  commit(message: string): Promise<boolean>;
76
76
  /**
@@ -20,7 +20,7 @@ export class GitOps {
20
20
  /**
21
21
  * Validates that a file path doesn't escape the workspace directory.
22
22
  * @returns The resolved absolute file path
23
- * @throws Error if path traversal is detected
23
+ * @throws ValidationError if path traversal is detected
24
24
  */
25
25
  validatePath(fileName) {
26
26
  const filePath = join(this._workDir, fileName);
@@ -209,7 +209,7 @@ export class GitOps {
209
209
  /**
210
210
  * Stage all changes and commit with the given message.
211
211
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
212
- * @returns true if a commit was made, false if there were no staged changes
212
+ * @returns true if a commit was made (or would be made in dry-run mode), false if there were no staged changes
213
213
  */
214
214
  async commit(message) {
215
215
  if (this.dryRun) {
@@ -43,7 +43,8 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
43
43
  * Uses the createCommitOnBranch mutation for verified commits.
44
44
  *
45
45
  * @returns Commit result with SHA and verified: true
46
- * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
46
+ * @throws ValidationError if repo is not GitHub or payload exceeds 50MB
47
+ * @throws GraphQLApiError if the API call fails
47
48
  */
48
49
  commit(options: CommitOptions): Promise<CommitResult>;
49
50
  /**
@@ -70,7 +70,8 @@ export class GraphQLCommitStrategy {
70
70
  * Uses the createCommitOnBranch mutation for verified commits.
71
71
  *
72
72
  * @returns Commit result with SHA and verified: true
73
- * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
73
+ * @throws ValidationError if repo is not GitHub or payload exceeds 50MB
74
+ * @throws GraphQLApiError if the API call fails
74
75
  */
75
76
  async commit(options) {
76
77
  const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
@@ -1,5 +1,6 @@
1
1
  export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, IPRStrategy, GitAuthOptions, PRResult, ICommitStrategy, } from "./types.js";
2
2
  export type { GitOpsOptions } from "./git-ops.js";
3
3
  export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
4
+ export type { GitHubAppTokenManager } from "./github-app-token-manager.js";
4
5
  export { createPRStrategy } from "./pr-strategy-factory.js";
5
6
  export { createPR, mergePR } from "./pr-creator.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "5.1.6",
3
+ "version": "5.3.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",
@@ -78,6 +78,7 @@
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/node": "^24.0.0",
81
+ "bottleneck": "^2.19.5",
81
82
  "c8": "^11.0.0",
82
83
  "tsx": "^4.15.0",
83
84
  "typescript": "^5.4.5"