@aspruyt/xfg 5.1.6 → 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.
- package/dist/config/index.d.ts +1 -1
- package/dist/config/normalizer.js +87 -4
- package/dist/config/types.d.ts +21 -0
- package/dist/config/validator.js +132 -2
- package/dist/shared/gh-api-utils.d.ts +16 -0
- package/dist/shared/gh-api-utils.js +56 -6
- package/dist/shared/retry-utils.d.ts +7 -0
- package/dist/shared/retry-utils.js +40 -2
- package/package.json +2 -1
package/dist/config/index.d.ts
CHANGED
|
@@ -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
|
|
400
|
-
|
|
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
|
-
|
|
478
|
+
let effectivePROptions = rawRepo.groups?.length
|
|
404
479
|
? mergeGroupPROptions(raw.prOptions, rawRepo.groups, raw.groups ?? {})
|
|
405
480
|
: raw.prOptions;
|
|
406
|
-
|
|
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 = [];
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/validator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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(() =>
|
|
44
|
-
retries,
|
|
45
|
-
});
|
|
96
|
+
return await withRetry(() => execAndParse(command), retryOpts);
|
|
46
97
|
}
|
|
47
|
-
return await withRetry(() =>
|
|
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
|
-
//
|
|
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.
|
|
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"
|