@aspruyt/xfg 5.2.0 → 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 +55 -81
- package/dist/config/types.d.ts +1 -0
- package/dist/config/validator.js +80 -2
- 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.js +1 -1
- 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 +1 -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, RawConditionalGroupWhen, RawConditionalGroupConfig, 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
|
}
|
|
@@ -385,42 +389,8 @@ function mergeConditionalGroups(accumulatedFiles, accumulatedPROptions, accumula
|
|
|
385
389
|
for (const cg of conditionalGroups) {
|
|
386
390
|
if (!evaluateWhenClause(cg.when, effectiveGroups))
|
|
387
391
|
continue;
|
|
388
|
-
// Merge files using same logic as mergeGroupFiles inner loop
|
|
389
392
|
if (cg.files) {
|
|
390
|
-
|
|
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
|
-
}
|
|
393
|
+
files = applyFileLayer(files, cg.files);
|
|
424
394
|
}
|
|
425
395
|
// Merge prOptions
|
|
426
396
|
if (cg.prOptions) {
|
|
@@ -471,19 +441,23 @@ export function normalizeConfig(raw, env) {
|
|
|
471
441
|
const expandedRepos = [];
|
|
472
442
|
for (const rawRepo of raw.repos) {
|
|
473
443
|
const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
|
|
444
|
+
// Phase 0: Expand extends chains
|
|
445
|
+
const expandedGroups = rawRepo.groups?.length
|
|
446
|
+
? expandRepoGroups(rawRepo.groups, raw.groups ?? {})
|
|
447
|
+
: [];
|
|
474
448
|
// Phase 1: Resolve groups - build effective root files/prOptions/settings by merging group layers
|
|
475
|
-
let effectiveRootFiles =
|
|
476
|
-
? mergeGroupFiles(raw.files ?? {},
|
|
449
|
+
let effectiveRootFiles = expandedGroups.length
|
|
450
|
+
? mergeGroupFiles(raw.files ?? {}, expandedGroups, raw.groups ?? {})
|
|
477
451
|
: (raw.files ?? {});
|
|
478
|
-
let effectivePROptions =
|
|
479
|
-
? mergeGroupPROptions(raw.prOptions,
|
|
452
|
+
let effectivePROptions = expandedGroups.length
|
|
453
|
+
? mergeGroupPROptions(raw.prOptions, expandedGroups, raw.groups ?? {})
|
|
480
454
|
: raw.prOptions;
|
|
481
|
-
let effectiveSettings =
|
|
482
|
-
? mergeGroupSettings(raw.settings,
|
|
455
|
+
let effectiveSettings = expandedGroups.length
|
|
456
|
+
? mergeGroupSettings(raw.settings, expandedGroups, raw.groups ?? {})
|
|
483
457
|
: raw.settings;
|
|
484
458
|
// Phase 2 + 3: Evaluate and merge conditional groups
|
|
485
459
|
if (raw.conditionalGroups?.length) {
|
|
486
|
-
const effectiveGroups = new Set(
|
|
460
|
+
const effectiveGroups = new Set(expandedGroups);
|
|
487
461
|
const merged = mergeConditionalGroups(effectiveRootFiles, effectivePROptions, effectiveSettings, effectiveGroups, raw.conditionalGroups);
|
|
488
462
|
effectiveRootFiles = merged.files;
|
|
489
463
|
effectivePROptions = merged.prOptions;
|
package/dist/config/types.d.ts
CHANGED
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,8 @@ 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);
|
|
288
364
|
}
|
|
289
365
|
function validateConditionalGroups(config) {
|
|
290
366
|
if (config.conditionalGroups === undefined)
|
|
@@ -422,7 +498,8 @@ function validateRepoFiles(config, repo, index, repoLabel) {
|
|
|
422
498
|
}
|
|
423
499
|
const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
|
|
424
500
|
if (repo.groups && config.groups) {
|
|
425
|
-
|
|
501
|
+
const expandedGroups = expandRepoGroups(repo.groups, config.groups);
|
|
502
|
+
for (const groupName of expandedGroups) {
|
|
426
503
|
const group = config.groups[groupName];
|
|
427
504
|
if (group?.files) {
|
|
428
505
|
for (const fn of Object.keys(group.files)) {
|
|
@@ -469,7 +546,8 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
|
|
|
469
546
|
return;
|
|
470
547
|
const rootCtx = buildRootSettingsContext(config);
|
|
471
548
|
if (repo.groups && config.groups) {
|
|
472
|
-
|
|
549
|
+
const expandedGroups = expandRepoGroups(repo.groups, config.groups);
|
|
550
|
+
for (const groupName of expandedGroups) {
|
|
473
551
|
const group = config.groups[groupName];
|
|
474
552
|
if (group?.settings?.rulesets) {
|
|
475
553
|
for (const name of Object.keys(group.settings.rulesets)) {
|
|
@@ -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() === "") {
|
|
@@ -110,7 +110,7 @@ export class GhApiClient {
|
|
|
110
110
|
this.retries = retries;
|
|
111
111
|
this.cwd = cwd;
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
call(method, endpoint, params) {
|
|
114
114
|
return ghApiCall(method, endpoint, {
|
|
115
115
|
executor: this.executor,
|
|
116
116
|
retries: this.retries,
|
|
@@ -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