@aspruyt/xfg 5.1.5 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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";
@@ -358,6 +358,81 @@ function mergeGroupSettings(rootSettings, groupNames, groupDefs) {
358
358
  }
359
359
  return accumulated;
360
360
  }
361
+ /**
362
+ * Evaluates a conditional group's `when` clause against a repo's effective groups.
363
+ * Both `allOf` (every listed group present) and `anyOf` (at least one present)
364
+ * must be satisfied. Absent conditions are treated as satisfied.
365
+ */
366
+ function evaluateWhenClause(when, effectiveGroups) {
367
+ // Defensive: if neither condition is specified, don't match
368
+ if (!when.allOf && !when.anyOf)
369
+ return false;
370
+ const allOfSatisfied = !when.allOf || when.allOf.every((g) => effectiveGroups.has(g));
371
+ const anyOfSatisfied = !when.anyOf || when.anyOf.some((g) => effectiveGroups.has(g));
372
+ return allOfSatisfied && anyOfSatisfied;
373
+ }
374
+ /**
375
+ * Merges matching conditional groups into the accumulated files/prOptions/settings.
376
+ * Each matching conditional group is applied in array order, using the same
377
+ * merge semantics as regular group layers (inherit:false, file:false, override:true).
378
+ */
379
+ function mergeConditionalGroups(accumulatedFiles, accumulatedPROptions, accumulatedSettings, effectiveGroups, conditionalGroups) {
380
+ let files = structuredClone(accumulatedFiles);
381
+ let prOptions = accumulatedPROptions
382
+ ? structuredClone(accumulatedPROptions)
383
+ : undefined;
384
+ let settings = accumulatedSettings;
385
+ for (const cg of conditionalGroups) {
386
+ if (!evaluateWhenClause(cg.when, effectiveGroups))
387
+ continue;
388
+ // Merge files using same logic as mergeGroupFiles inner loop
389
+ if (cg.files) {
390
+ const inheritFiles = shouldInherit(cg.files);
391
+ if (!inheritFiles) {
392
+ files = {};
393
+ }
394
+ for (const [fileName, fileConfig] of Object.entries(cg.files)) {
395
+ if (fileName === "inherit")
396
+ continue;
397
+ if (fileConfig === false) {
398
+ delete files[fileName];
399
+ continue;
400
+ }
401
+ if (fileConfig === undefined)
402
+ continue;
403
+ const existing = files[fileName];
404
+ if (existing) {
405
+ const overlay = fileConfig;
406
+ let mergedContent;
407
+ if (overlay.override || !existing.content || !overlay.content) {
408
+ mergedContent = overlay.content ?? existing.content;
409
+ }
410
+ else {
411
+ mergedContent = mergeContentPair(existing.content, overlay.content, existing.mergeStrategy ?? "replace");
412
+ }
413
+ const { override: _override, ...restFileConfig } = fileConfig;
414
+ files[fileName] = {
415
+ ...existing,
416
+ ...restFileConfig,
417
+ content: mergedContent,
418
+ };
419
+ }
420
+ else {
421
+ files[fileName] = structuredClone(fileConfig);
422
+ }
423
+ }
424
+ }
425
+ // Merge prOptions
426
+ if (cg.prOptions) {
427
+ prOptions = mergePROptions(prOptions, cg.prOptions);
428
+ }
429
+ // Merge settings
430
+ if (cg.settings) {
431
+ settings = mergeRawSettings(settings, cg.settings);
432
+ }
433
+ }
434
+ return { files, prOptions, settings };
435
+ }
361
436
  /**
362
437
  * Resolves a single file entry by merging root config with repo overrides.
363
438
  * Returns null if the file should be skipped.
@@ -396,16 +471,24 @@ export function normalizeConfig(raw, env) {
396
471
  const expandedRepos = [];
397
472
  for (const rawRepo of raw.repos) {
398
473
  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
474
+ // Phase 1: Resolve groups - build effective root files/prOptions/settings by merging group layers
475
+ let effectiveRootFiles = rawRepo.groups?.length
401
476
  ? mergeGroupFiles(raw.files ?? {}, rawRepo.groups, raw.groups ?? {})
402
477
  : (raw.files ?? {});
403
- const effectivePROptions = rawRepo.groups?.length
478
+ let effectivePROptions = rawRepo.groups?.length
404
479
  ? mergeGroupPROptions(raw.prOptions, rawRepo.groups, raw.groups ?? {})
405
480
  : raw.prOptions;
406
- const effectiveSettings = rawRepo.groups?.length
481
+ let effectiveSettings = rawRepo.groups?.length
407
482
  ? mergeGroupSettings(raw.settings, rawRepo.groups, raw.groups ?? {})
408
483
  : raw.settings;
484
+ // Phase 2 + 3: Evaluate and merge conditional groups
485
+ if (raw.conditionalGroups?.length) {
486
+ const effectiveGroups = new Set(rawRepo.groups ?? []);
487
+ const merged = mergeConditionalGroups(effectiveRootFiles, effectivePROptions, effectiveSettings, effectiveGroups, raw.conditionalGroups);
488
+ effectiveRootFiles = merged.files;
489
+ effectivePROptions = merged.prOptions;
490
+ effectiveSettings = merged.settings;
491
+ }
409
492
  const fileNames = Object.keys(effectiveRootFiles);
410
493
  for (const gitUrl of gitUrls) {
411
494
  const files = [];
@@ -298,6 +298,26 @@ export interface RawGroupConfig {
298
298
  prOptions?: PRMergeOptions;
299
299
  settings?: RawRepoSettings;
300
300
  }
301
+ /** Condition for conditional group activation */
302
+ export interface RawConditionalGroupWhen {
303
+ /** All listed groups must be present in the repo's effective group set */
304
+ allOf?: string[];
305
+ /** At least one listed group must be present */
306
+ anyOf?: string[];
307
+ }
308
+ /** Conditional group: activates based on which groups a repo has */
309
+ export interface RawConditionalGroupConfig {
310
+ /** Condition that determines when this group activates */
311
+ when: RawConditionalGroupWhen;
312
+ /** File definitions or overrides (same capabilities as regular groups) */
313
+ files?: Record<string, RawFileConfig | RawRepoFileOverride | false> & {
314
+ inherit?: boolean;
315
+ };
316
+ /** PR merge options */
317
+ prOptions?: PRMergeOptions;
318
+ /** Repository settings (rulesets, labels, repo settings) */
319
+ settings?: RawRepoSettings;
320
+ }
301
321
  export interface RawRootSettings {
302
322
  rulesets?: Record<string, Ruleset | false>;
303
323
  repo?: GitHubRepoSettings | false;
@@ -331,6 +351,7 @@ export interface RawConfig {
331
351
  id: string;
332
352
  files?: Record<string, RawFileConfig>;
333
353
  groups?: Record<string, RawGroupConfig>;
354
+ conditionalGroups?: RawConditionalGroupConfig[];
334
355
  repos: RawRepoConfig[];
335
356
  prOptions?: PRMergeOptions;
336
357
  prTemplate?: string;
@@ -286,6 +286,79 @@ function validateGroups(config) {
286
286
  }
287
287
  }
288
288
  }
289
+ function validateConditionalGroups(config) {
290
+ if (config.conditionalGroups === undefined)
291
+ return;
292
+ if (!Array.isArray(config.conditionalGroups)) {
293
+ throw new ValidationError("conditionalGroups must be an array");
294
+ }
295
+ const rootCtx = buildRootSettingsContext(config);
296
+ const groupNames = config.groups ? Object.keys(config.groups) : [];
297
+ for (let i = 0; i < config.conditionalGroups.length; i++) {
298
+ const entry = config.conditionalGroups[i];
299
+ const ctx = `conditionalGroups[${i}]`;
300
+ // Validate 'when' clause
301
+ if (!entry.when || !isPlainObject(entry.when)) {
302
+ throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
303
+ }
304
+ const { allOf, anyOf } = entry.when;
305
+ if (!allOf && !anyOf) {
306
+ throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf' or 'anyOf'`);
307
+ }
308
+ if (allOf !== undefined) {
309
+ if (!Array.isArray(allOf) || allOf.length === 0) {
310
+ throw new ValidationError(`${ctx}: 'allOf' must be a non-empty array of strings`);
311
+ }
312
+ const seen = new Set();
313
+ for (const name of allOf) {
314
+ if (typeof name !== "string") {
315
+ throw new ValidationError(`${ctx}: 'allOf' entries must be strings`);
316
+ }
317
+ if (!groupNames.includes(name)) {
318
+ throw new ValidationError(`${ctx}: group '${name}' in allOf is not defined in root 'groups'`);
319
+ }
320
+ if (seen.has(name)) {
321
+ throw new ValidationError(`${ctx}: duplicate group '${name}' in allOf`);
322
+ }
323
+ seen.add(name);
324
+ }
325
+ }
326
+ if (anyOf !== undefined) {
327
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
328
+ throw new ValidationError(`${ctx}: 'anyOf' must be a non-empty array of strings`);
329
+ }
330
+ const seen = new Set();
331
+ for (const name of anyOf) {
332
+ if (typeof name !== "string") {
333
+ throw new ValidationError(`${ctx}: 'anyOf' entries must be strings`);
334
+ }
335
+ if (!groupNames.includes(name)) {
336
+ throw new ValidationError(`${ctx}: group '${name}' in anyOf is not defined in root 'groups'`);
337
+ }
338
+ if (seen.has(name)) {
339
+ throw new ValidationError(`${ctx}: duplicate group '${name}' in anyOf`);
340
+ }
341
+ seen.add(name);
342
+ }
343
+ }
344
+ // Validate files
345
+ if (entry.files) {
346
+ for (const [fileName, fileConfig] of Object.entries(entry.files)) {
347
+ if (fileName === "inherit")
348
+ continue;
349
+ if (fileConfig === false)
350
+ continue;
351
+ if (fileConfig === undefined)
352
+ continue;
353
+ validateFileConfigFields(fileConfig, fileName, `${ctx}:`);
354
+ }
355
+ }
356
+ // Validate settings
357
+ if (entry.settings !== undefined) {
358
+ validateSettings(entry.settings, ctx, rootCtx);
359
+ }
360
+ }
361
+ }
289
362
  function validateRepoGitField(repo, index) {
290
363
  if (!repo.git) {
291
364
  throw new ValidationError(`Repo at index ${index} missing required field: git`);
@@ -359,6 +432,16 @@ function validateRepoFiles(config, repo, index, repoLabel) {
359
432
  }
360
433
  }
361
434
  }
435
+ if (config.conditionalGroups) {
436
+ for (const cg of config.conditionalGroups) {
437
+ if (cg.files) {
438
+ for (const fn of Object.keys(cg.files)) {
439
+ if (fn !== "inherit")
440
+ knownFiles.add(fn);
441
+ }
442
+ }
443
+ }
444
+ }
362
445
  for (const fileName of Object.keys(repo.files)) {
363
446
  if (fileName === "inherit") {
364
447
  const inheritValue = repo.files.inherit;
@@ -400,6 +483,29 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
400
483
  rootCtx.labelNames.push(name);
401
484
  }
402
485
  }
486
+ if (group?.settings?.repo !== undefined &&
487
+ group.settings.repo !== false) {
488
+ rootCtx.hasRepoSettings = true;
489
+ }
490
+ }
491
+ }
492
+ if (config.conditionalGroups) {
493
+ for (const cg of config.conditionalGroups) {
494
+ if (cg.settings?.rulesets) {
495
+ for (const name of Object.keys(cg.settings.rulesets)) {
496
+ if (name !== "inherit")
497
+ rootCtx.rulesetNames.push(name);
498
+ }
499
+ }
500
+ if (cg.settings?.labels) {
501
+ for (const name of Object.keys(cg.settings.labels)) {
502
+ if (name !== "inherit")
503
+ rootCtx.labelNames.push(name);
504
+ }
505
+ }
506
+ if (cg.settings?.repo !== undefined && cg.settings.repo !== false) {
507
+ rootCtx.hasRepoSettings = true;
508
+ }
403
509
  }
404
510
  }
405
511
  validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
@@ -427,7 +533,20 @@ export function validateRawConfig(config) {
427
533
  const hasGrpFiles = hasGroupFiles(config);
428
534
  const hasGrpSettings = isPlainObject(config.groups) &&
429
535
  Object.values(config.groups).some((g) => g.settings && isPlainObject(g.settings));
430
- if (!hasFiles && !hasSettings && !hasGrpFiles && !hasGrpSettings) {
536
+ const hasCondGrpFiles = Array.isArray(config.conditionalGroups) &&
537
+ config.conditionalGroups.some((cg) => cg.files &&
538
+ Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0);
539
+ const hasCondGrpSettings = Array.isArray(config.conditionalGroups) &&
540
+ config.conditionalGroups.some((cg) => cg.settings && isPlainObject(cg.settings));
541
+ const hasCondGrpPR = Array.isArray(config.conditionalGroups) &&
542
+ config.conditionalGroups.some((cg) => cg.prOptions && isPlainObject(cg.prOptions));
543
+ if (!hasFiles &&
544
+ !hasSettings &&
545
+ !hasGrpFiles &&
546
+ !hasGrpSettings &&
547
+ !hasCondGrpFiles &&
548
+ !hasCondGrpSettings &&
549
+ !hasCondGrpPR) {
431
550
  throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
432
551
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
433
552
  }
@@ -443,6 +562,7 @@ export function validateRawConfig(config) {
443
562
  validateGithubHosts(config);
444
563
  validatePrOptions(config);
445
564
  validateGroups(config);
565
+ validateConditionalGroups(config);
446
566
  for (let i = 0; i < config.repos.length; i++) {
447
567
  validateRepoEntry(config, config.repos[i], i);
448
568
  }
@@ -461,11 +581,21 @@ export function validateForSync(config) {
461
581
  const hasRepoSettings = config.repos.some((repo) => hasActionableSettings(repo.settings));
462
582
  const hasGroupSettings = isPlainObject(config.groups) &&
463
583
  Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
584
+ const hasCondGrpFiles = Array.isArray(config.conditionalGroups) &&
585
+ config.conditionalGroups.some((cg) => cg.files &&
586
+ Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0);
587
+ const hasCondGrpSettings = Array.isArray(config.conditionalGroups) &&
588
+ config.conditionalGroups.some((cg) => cg.settings && hasActionableSettings(cg.settings));
589
+ const hasCondGrpPR = Array.isArray(config.conditionalGroups) &&
590
+ config.conditionalGroups.some((cg) => cg.prOptions && isPlainObject(cg.prOptions));
464
591
  if (!hasRootFiles &&
465
592
  !hasGrpFiles &&
466
593
  !hasSettings &&
467
594
  !hasRepoSettings &&
468
- !hasGroupSettings) {
595
+ !hasGroupSettings &&
596
+ !hasCondGrpFiles &&
597
+ !hasCondGrpSettings &&
598
+ !hasCondGrpPR) {
469
599
  throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
470
600
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
471
601
  }
@@ -147,6 +147,7 @@ function formatRulesetConfigPlain(config) {
147
147
  * Shared between formatSettingsReportMarkdown and unified-summary's renderSettingsLines.
148
148
  */
149
149
  export function renderRepoSettingsDiffLines(repo, diffLines) {
150
+ const startLength = diffLines.length;
150
151
  for (const setting of repo.settings) {
151
152
  if (setting.oldValue === undefined && setting.newValue === undefined) {
152
153
  continue;
@@ -158,7 +159,15 @@ export function renderRepoSettingsDiffLines(repo, diffLines) {
158
159
  diffLines.push(`! ${setting.name}: ${formatValuePlain(setting.oldValue)} → ${formatValuePlain(setting.newValue)}`);
159
160
  }
160
161
  }
161
- for (const ruleset of repo.rulesets) {
162
+ // Blank line before rulesets if there was content above
163
+ if (repo.rulesets.length > 0 && diffLines.length > startLength) {
164
+ diffLines.push("");
165
+ }
166
+ for (let i = 0; i < repo.rulesets.length; i++) {
167
+ const ruleset = repo.rulesets[i];
168
+ // Blank line between rulesets
169
+ if (i > 0)
170
+ diffLines.push("");
162
171
  if (ruleset.action === "create") {
163
172
  diffLines.push(`+ ruleset "${ruleset.name}"`);
164
173
  if (ruleset.config) {
@@ -188,6 +197,10 @@ export function renderRepoSettingsDiffLines(repo, diffLines) {
188
197
  diffLines.push(`- ruleset "${ruleset.name}"`);
189
198
  }
190
199
  }
200
+ // Blank line before labels if there was content above
201
+ if (repo.labels.length > 0 && diffLines.length > startLength) {
202
+ diffLines.push("");
203
+ }
191
204
  for (const label of repo.labels) {
192
205
  if (label.action === "create") {
193
206
  diffLines.push(`+ label "${label.name}"`);
@@ -238,8 +251,7 @@ export function formatSettingsReportMarkdown(report, dryRun) {
238
251
  lines.push("> This was a dry run — no changes were applied");
239
252
  lines.push("");
240
253
  }
241
- // Diff block
242
- const diffLines = [];
254
+ // Per-repo sections: heading + diff block
243
255
  for (const repo of report.repos) {
244
256
  if (repo.settings.length === 0 &&
245
257
  repo.rulesets.length === 0 &&
@@ -247,14 +259,16 @@ export function formatSettingsReportMarkdown(report, dryRun) {
247
259
  !repo.error) {
248
260
  continue;
249
261
  }
250
- diffLines.push(`@@ ${repo.repoName} @@`);
251
- renderRepoSettingsDiffLines(repo, diffLines);
252
- }
253
- if (diffLines.length > 0) {
254
- lines.push("```diff");
255
- lines.push(...diffLines);
256
- lines.push("```");
262
+ lines.push(`### ${repo.repoName}`);
257
263
  lines.push("");
264
+ const diffLines = [];
265
+ renderRepoSettingsDiffLines(repo, diffLines);
266
+ if (diffLines.length > 0) {
267
+ lines.push("```diff");
268
+ lines.push(...diffLines);
269
+ lines.push("```");
270
+ lines.push("");
271
+ }
258
272
  }
259
273
  // Summary
260
274
  lines.push(`**${formatSettingsSummary(report.totals)}**`);
@@ -58,27 +58,32 @@ export function formatSyncReportMarkdown(report, dryRun) {
58
58
  lines.push("> This was a dry run — no changes were applied");
59
59
  lines.push("");
60
60
  }
61
- // Diff block
62
- const diffLines = [];
61
+ // Per-repo sections: heading + diff block
63
62
  for (const repo of report.repos) {
64
63
  if (repo.files.length === 0 && !repo.error) {
65
64
  continue;
66
65
  }
67
- diffLines.push(`@@ ${repo.repoName} @@`);
68
- renderSyncLines(repo, diffLines);
69
- }
70
- if (diffLines.length > 0) {
71
- lines.push("```diff");
72
- lines.push(...diffLines);
73
- lines.push("```");
66
+ lines.push(`### ${repo.repoName}`);
74
67
  lines.push("");
68
+ const diffLines = [];
69
+ renderSyncLines(repo, diffLines);
70
+ if (diffLines.length > 0) {
71
+ lines.push("```diff");
72
+ lines.push(...diffLines);
73
+ lines.push("```");
74
+ lines.push("");
75
+ }
75
76
  }
76
77
  // Summary
77
78
  lines.push(`**${formatSyncSummary(report.totals)}**`);
78
79
  return lines.join("\n");
79
80
  }
80
81
  export function renderSyncLines(syncRepo, diffLines) {
81
- for (const file of syncRepo.files) {
82
+ for (let i = 0; i < syncRepo.files.length; i++) {
83
+ const file = syncRepo.files[i];
84
+ // Blank line between files for readability
85
+ if (i > 0)
86
+ diffLines.push("");
82
87
  if (file.action === "create") {
83
88
  diffLines.push(`+ ${file.path}`);
84
89
  }
@@ -165,8 +165,7 @@ export function formatUnifiedSummaryMarkdown(input) {
165
165
  addRepo(r.repoName);
166
166
  for (const r of input.settings?.repos ?? [])
167
167
  addRepo(r.repoName);
168
- // Diff block
169
- const diffLines = [];
168
+ // Per-repo sections: heading + diff block
170
169
  for (const repoName of allRepos) {
171
170
  const lcAction = lifecycleByRepo.get(repoName);
172
171
  const syncRepo = syncByRepo.get(repoName);
@@ -180,19 +179,27 @@ export function formatUnifiedSummaryMarkdown(input) {
180
179
  settingsRepo.error);
181
180
  if (!hasLcChange && !hasSyncChanges && !hasSettingsChanges)
182
181
  continue;
183
- diffLines.push(`@@ ${repoName} @@`);
182
+ lines.push(`### ${repoName}`);
183
+ lines.push("");
184
+ const diffLines = [];
184
185
  if (lcAction)
185
186
  renderLifecycleLines(lcAction, diffLines);
187
+ // Blank line between lifecycle and sync sections
188
+ if (hasLcChange && hasSyncChanges)
189
+ diffLines.push("");
186
190
  if (syncRepo)
187
191
  renderSyncLines(syncRepo, diffLines);
192
+ // Blank line between files and settings sections
193
+ if (hasSyncChanges && hasSettingsChanges)
194
+ diffLines.push("");
188
195
  if (settingsRepo)
189
196
  renderRepoSettingsDiffLines(settingsRepo, diffLines);
190
- }
191
- if (diffLines.length > 0) {
192
- lines.push("```diff");
193
- lines.push(...diffLines);
194
- lines.push("```");
195
- lines.push("");
197
+ if (diffLines.length > 0) {
198
+ lines.push("```diff");
199
+ lines.push(...diffLines);
200
+ lines.push("```");
201
+ lines.push("");
202
+ }
196
203
  }
197
204
  // Combined summary
198
205
  lines.push(`**${formatCombinedSummary(input)}**`);
@@ -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.
@@ -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...`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "5.1.5",
3
+ "version": "5.2.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"