@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.
- package/dist/cli/types.d.ts +1 -1
- package/dist/config/extends-resolver.d.ts +13 -0
- package/dist/config/extends-resolver.js +63 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/normalizer.js +103 -46
- package/dist/config/types.d.ts +22 -0
- package/dist/config/validator.js +212 -4
- package/dist/output/settings-report.js +4 -8
- package/dist/settings/base-processor.js +15 -9
- package/dist/settings/labels/diff.d.ts +1 -1
- package/dist/settings/labels/diff.js +1 -1
- package/dist/settings/repo-settings/formatter.js +2 -9
- package/dist/settings/rulesets/formatter.js +10 -12
- package/dist/shared/branch-utils.d.ts +1 -1
- package/dist/shared/branch-utils.js +1 -1
- package/dist/shared/gh-api-utils.d.ts +16 -0
- package/dist/shared/gh-api-utils.js +57 -7
- package/dist/shared/retry-utils.d.ts +7 -0
- package/dist/shared/retry-utils.js +40 -2
- package/dist/shared/string-utils.d.ts +6 -0
- package/dist/shared/string-utils.js +16 -0
- package/dist/sync/auth-options-builder.d.ts +1 -1
- package/dist/sync/repository-processor.d.ts +1 -1
- package/dist/vcs/git-ops.d.ts +2 -2
- package/dist/vcs/git-ops.js +2 -2
- package/dist/vcs/graphql-commit-strategy.d.ts +2 -1
- package/dist/vcs/graphql-commit-strategy.js +2 -1
- package/dist/vcs/index.d.ts +1 -0
- package/package.json +2 -1
package/dist/cli/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
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, 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
|
-
|
|
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
|
-
//
|
|
400
|
-
const
|
|
401
|
-
?
|
|
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
|
-
|
|
404
|
-
? mergeGroupPROptions(raw.prOptions,
|
|
452
|
+
let effectivePROptions = expandedGroups.length
|
|
453
|
+
? mergeGroupPROptions(raw.prOptions, expandedGroups, raw.groups ?? {})
|
|
405
454
|
: raw.prOptions;
|
|
406
|
-
|
|
407
|
-
? mergeGroupSettings(raw.settings,
|
|
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 = [];
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/validator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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(() =>
|
|
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.
|
|
@@ -61,7 +110,7 @@ export class GhApiClient {
|
|
|
61
110
|
this.retries = retries;
|
|
62
111
|
this.cwd = cwd;
|
|
63
112
|
}
|
|
64
|
-
|
|
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
|
-
//
|
|
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/
|
|
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/
|
|
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.
|
package/dist/vcs/git-ops.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/vcs/git-ops.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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;
|
package/dist/vcs/index.d.ts
CHANGED
|
@@ -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.
|
|
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"
|