@aspruyt/xfg 5.4.0 → 5.5.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.
Files changed (91) hide show
  1. package/dist/{shared → cli}/branch-utils.js +1 -1
  2. package/dist/cli/settings-report-builder.d.ts +6 -1
  3. package/dist/cli/settings-report-builder.js +19 -0
  4. package/dist/cli/sync-command.js +38 -23
  5. package/dist/cli/sync-report-builder.d.ts +2 -1
  6. package/dist/cli/types.d.ts +5 -2
  7. package/dist/config/config-merger.d.ts +6 -0
  8. package/dist/config/config-merger.js +66 -0
  9. package/dist/config/index.d.ts +1 -1
  10. package/dist/config/loader.d.ts +2 -2
  11. package/dist/config/loader.js +50 -6
  12. package/dist/config/merge.js +11 -16
  13. package/dist/config/normalizer.js +29 -0
  14. package/dist/config/types.d.ts +12 -0
  15. package/dist/config/validator.js +104 -46
  16. package/dist/lifecycle/ado-migration-source.d.ts +1 -1
  17. package/dist/lifecycle/github-lifecycle-provider.d.ts +1 -1
  18. package/dist/lifecycle/index.d.ts +1 -1
  19. package/dist/lifecycle/lifecycle-helpers.d.ts +2 -1
  20. package/dist/lifecycle/lifecycle-helpers.js +1 -1
  21. package/dist/lifecycle/repo-lifecycle-factory.d.ts +1 -1
  22. package/dist/lifecycle/repo-lifecycle-manager.js +1 -1
  23. package/dist/output/types.d.ts +2 -1
  24. package/dist/settings/code-scanning/diff.d.ts +19 -0
  25. package/dist/settings/code-scanning/diff.js +75 -0
  26. package/dist/settings/code-scanning/formatter.d.ts +17 -0
  27. package/dist/settings/code-scanning/formatter.js +37 -0
  28. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +19 -0
  29. package/dist/settings/code-scanning/github-code-scanning-strategy.js +20 -0
  30. package/dist/settings/code-scanning/index.d.ts +3 -0
  31. package/dist/settings/code-scanning/index.js +4 -0
  32. package/dist/settings/code-scanning/processor.d.ts +20 -0
  33. package/dist/settings/code-scanning/processor.js +81 -0
  34. package/dist/settings/code-scanning/types.d.ts +22 -0
  35. package/dist/settings/code-scanning/types.js +1 -0
  36. package/dist/settings/index.d.ts +1 -0
  37. package/dist/settings/index.js +2 -0
  38. package/dist/settings/labels/github-labels-strategy.d.ts +2 -2
  39. package/dist/settings/repo-settings/diff.d.ts +2 -1
  40. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +1 -1
  41. package/dist/settings/repo-settings/processor.d.ts +4 -2
  42. package/dist/settings/repo-settings/processor.js +14 -16
  43. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +2 -2
  44. package/dist/settings/rulesets/processor.d.ts +1 -1
  45. package/dist/shared/cleanup-utils.d.ts +8 -0
  46. package/dist/shared/cleanup-utils.js +15 -0
  47. package/dist/shared/gh-api-utils.d.ts +2 -2
  48. package/dist/shared/gh-api-utils.js +7 -4
  49. package/dist/shared/repo-detector.d.ts +1 -20
  50. package/dist/shared/repo-detector.js +3 -45
  51. package/dist/shared/repo-info-utils.d.ts +20 -0
  52. package/dist/shared/repo-info-utils.js +46 -0
  53. package/dist/shared/repo-metadata-provider.d.ts +27 -0
  54. package/dist/shared/repo-metadata-provider.js +20 -0
  55. package/dist/shared/retry-utils.d.ts +1 -1
  56. package/dist/shared/retry-utils.js +1 -1
  57. package/dist/shared/type-guards.d.ts +0 -8
  58. package/dist/shared/type-guards.js +0 -14
  59. package/dist/shared/xfg-template.js +2 -2
  60. package/dist/sync/auth-options-builder.d.ts +1 -1
  61. package/dist/sync/branch-manager.d.ts +3 -7
  62. package/dist/sync/commit-push-manager.d.ts +1 -1
  63. package/dist/sync/commit-push-manager.js +2 -2
  64. package/dist/sync/diff-utils.js +0 -7
  65. package/dist/sync/manifest.js +1 -8
  66. package/dist/sync/repository-processor.d.ts +1 -1
  67. package/dist/sync/repository-processor.js +1 -2
  68. package/dist/sync/repository-session.d.ts +2 -2
  69. package/dist/sync/repository-session.js +1 -1
  70. package/dist/sync/sync-workflow.d.ts +1 -1
  71. package/dist/sync/sync-workflow.js +1 -1
  72. package/dist/vcs/authenticated-git-ops.js +2 -2
  73. package/dist/vcs/azure-pr-strategy.d.ts +1 -1
  74. package/dist/vcs/azure-pr-strategy.js +2 -1
  75. package/dist/vcs/commit-strategy-selector.d.ts +2 -2
  76. package/dist/vcs/git-commit-strategy.d.ts +1 -1
  77. package/dist/vcs/git-ops.d.ts +1 -1
  78. package/dist/vcs/git-ops.js +1 -1
  79. package/dist/vcs/github-pr-strategy.js +3 -2
  80. package/dist/vcs/gitlab-pr-strategy.d.ts +1 -1
  81. package/dist/vcs/gitlab-pr-strategy.js +4 -3
  82. package/dist/vcs/graphql-commit-strategy.d.ts +1 -1
  83. package/dist/vcs/index.d.ts +2 -0
  84. package/dist/vcs/index.js +3 -0
  85. package/dist/vcs/pr-creator.d.ts +2 -2
  86. package/dist/vcs/pr-creator.js +1 -1
  87. package/dist/vcs/pr-strategy-factory.d.ts +2 -2
  88. package/dist/vcs/pr-strategy.d.ts +4 -2
  89. package/dist/vcs/pr-strategy.js +7 -2
  90. package/package.json +2 -1
  91. /package/dist/{shared → cli}/branch-utils.d.ts +0 -0
@@ -1,4 +1,4 @@
1
- import { ValidationError } from "./errors.js";
1
+ import { ValidationError } from "../shared/errors.js";
2
2
  export function sanitizeBranchName(fileName) {
3
3
  return fileName
4
4
  .toLowerCase()
@@ -1,5 +1,5 @@
1
1
  import type { SettingsReport } from "../output/settings-report.js";
2
- import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry } from "../settings/index.js";
2
+ import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry, type CodeScanningPlanEntry } from "../settings/index.js";
3
3
  /**
4
4
  * Result from processing a repository's settings and rulesets.
5
5
  * Used to collect results during settings command execution.
@@ -21,6 +21,11 @@ export interface ProcessorResults {
21
21
  entries?: LabelsPlanEntry[];
22
22
  };
23
23
  };
24
+ codeScanningResult?: {
25
+ planOutput?: {
26
+ entries?: CodeScanningPlanEntry[];
27
+ };
28
+ };
24
29
  error?: string;
25
30
  }
26
31
  export declare function buildSettingsReport(results: ProcessorResults[]): SettingsReport;
@@ -66,6 +66,25 @@ export function buildSettingsReport(results) {
66
66
  totals.labels.update += counts.update;
67
67
  totals.labels.delete += counts.delete;
68
68
  }
69
+ // Convert code scanning processor output
70
+ if (result.codeScanningResult?.planOutput?.entries) {
71
+ let csCreates = 0;
72
+ let csUpdates = 0;
73
+ for (const entry of result.codeScanningResult.planOutput.entries) {
74
+ repoChanges.settings.push({
75
+ name: `codeScanning.${entry.property}`,
76
+ action: entry.action,
77
+ oldValue: entry.oldValue,
78
+ newValue: entry.newValue ?? null,
79
+ });
80
+ if (entry.action === "create")
81
+ csCreates++;
82
+ if (entry.action === "update")
83
+ csUpdates++;
84
+ }
85
+ totals.settings.create += csCreates;
86
+ totals.settings.update += csUpdates;
87
+ }
69
88
  if (result.error) {
70
89
  repoChanges.error = result.error;
71
90
  }
@@ -3,10 +3,11 @@ import { existsSync } from "node:fs";
3
3
  import { loadRawConfig, normalizeConfig, validateForSync, } from "../config/index.js";
4
4
  import { ValidationError, SyncError } from "../shared/errors.js";
5
5
  import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
6
- import { sanitizeBranchName, validateBranchName, } from "../shared/branch-utils.js";
6
+ import { sanitizeBranchName, validateBranchName } from "./branch-utils.js";
7
7
  import { createTokenManager } from "../vcs/index.js";
8
8
  import { RepositoryProcessor } from "../sync/index.js";
9
- import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, } from "../settings/index.js";
9
+ import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, CodeScanningProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, GitHubCodeScanningStrategy, } from "../settings/index.js";
10
+ import { GitHubRepoMetadataProvider } from "../shared/repo-metadata-provider.js";
10
11
  import { ShellCommandExecutor } from "../shared/command-executor.js";
11
12
  import { Logger } from "../shared/logger.js";
12
13
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
@@ -24,12 +25,18 @@ function createDefaultRulesetProcessorFactory() {
24
25
  }
25
26
  function createDefaultRepoSettingsProcessorFactory() {
26
27
  const cwd = process.cwd();
27
- return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(getDefaultExecutor(), { cwd }));
28
+ const executor = getDefaultExecutor();
29
+ return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(executor, { cwd }), new GitHubRepoMetadataProvider(executor, { cwd }));
28
30
  }
29
31
  function createDefaultLabelsProcessorFactory() {
30
32
  const cwd = process.cwd();
31
33
  return () => new LabelsProcessor(new GitHubLabelsStrategy(getDefaultExecutor(), { cwd }));
32
34
  }
35
+ function createDefaultCodeScanningProcessorFactory() {
36
+ const cwd = process.cwd();
37
+ const executor = getDefaultExecutor();
38
+ return () => new CodeScanningProcessor(new GitHubCodeScanningStrategy(executor, { cwd }), new GitHubRepoMetadataProvider(executor, { cwd }));
39
+ }
33
40
  import { ResultsCollector } from "./results-collector.js";
34
41
  import { buildSettingsReport, } from "./settings-report-builder.js";
35
42
  import { formatSettingsReportCLI } from "../output/settings-report.js";
@@ -40,7 +47,7 @@ import { formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecy
40
47
  import { writeUnifiedSummary } from "../output/unified-summary.js";
41
48
  import { toErrorMessage } from "../shared/type-guards.js";
42
49
  import { resolveGitHubToken } from "../shared/gh-api-utils.js";
43
- import { RepoLifecycleManager, runLifecycleCheck, toCreateRepoSettings, } from "../lifecycle/index.js";
50
+ import { RepoLifecycleManager, runLifecycleCheck, } from "../lifecycle/index.js";
44
51
  function getUniqueFileNames(config) {
45
52
  const fileNames = new Set();
46
53
  for (const repo of config.repos) {
@@ -97,46 +104,53 @@ function logSettingsResult(result, label, repoNumber, repoName, settingsCollecto
97
104
  settingsCollector.appendError(repoName, result.message);
98
105
  }
99
106
  }
107
+ // Each processor returns a subtype of BaseProcessorResult whose planOutput
108
+ // contains both `lines` (for CLI display) and `entries` (for report building).
109
+ // ProcessorResults fields capture only the `entries` slice; the runtime object
110
+ // satisfies both views, so we assign with an explicit per-field cast.
111
+ async function runAndStoreResult(factory, repoConfig, repoInfo, opts, repoName, settingsCollector, assign) {
112
+ const result = await runSettingsProcessor(factory, repoConfig, repoInfo, opts);
113
+ if (!result.skipped) {
114
+ assign(settingsCollector.getOrCreate(repoName), result);
115
+ }
116
+ return result;
117
+ }
100
118
  function buildSettingsDescriptors(ctx) {
101
- const { repoConfig, repoInfo, options, token, repoName, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, } = ctx;
119
+ const { repoConfig, repoInfo, options, token, repoName, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, codeScanningProcessorFactory, } = ctx;
102
120
  const sharedOpts = {
103
121
  dryRun: options.dryRun,
104
122
  noDelete: options.noDelete,
105
123
  token,
106
124
  };
107
- // Each processor returns a subtype of BaseProcessorResult whose planOutput
108
- // contains both `lines` (for CLI display) and `entries` (for report building).
109
- // ProcessorResults fields capture only the `entries` slice; the runtime object
110
- // satisfies both views, so we assign with an explicit per-field cast.
111
- const runAndStore = async (factory, opts, assign) => {
112
- const result = await runSettingsProcessor(factory, repoConfig, repoInfo, opts);
113
- if (!result.skipped) {
114
- assign(settingsCollector.getOrCreate(repoName), result);
115
- }
116
- return result;
117
- };
118
125
  return [
119
126
  {
120
127
  key: "rulesets",
121
128
  label: "Rulesets",
122
- run: () => runAndStore(rulesetProcessorFactory, sharedOpts, (e, r) => {
129
+ run: () => runAndStoreResult(rulesetProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
123
130
  e.rulesetResult = r;
124
131
  }),
125
132
  },
126
133
  {
127
134
  key: "labels",
128
135
  label: "Labels",
129
- run: () => runAndStore(labelsProcessorFactory, sharedOpts, (e, r) => {
136
+ run: () => runAndStoreResult(labelsProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
130
137
  e.labelsResult = r;
131
138
  }),
132
139
  },
133
140
  {
134
141
  key: "repo",
135
142
  label: "Repo Settings",
136
- run: () => runAndStore(repoSettingsProcessorFactory, { dryRun: options.dryRun, token }, (e, r) => {
143
+ run: () => runAndStoreResult(repoSettingsProcessorFactory, repoConfig, repoInfo, { dryRun: options.dryRun, token }, repoName, settingsCollector, (e, r) => {
137
144
  e.settingsResult = r;
138
145
  }),
139
146
  },
147
+ {
148
+ key: "codeScanning",
149
+ label: "Code Scanning",
150
+ run: () => runAndStoreResult(codeScanningProcessorFactory, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
151
+ e.codeScanningResult = r;
152
+ }),
153
+ },
140
154
  ];
141
155
  }
142
156
  function runSettingsProcessor(factory, repoConfig, repoInfo, processOptions) {
@@ -269,6 +283,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
269
283
  rulesetProcessorFactory: ctx.rulesetProcessorFactory,
270
284
  repoSettingsProcessorFactory: ctx.repoSettingsProcessorFactory,
271
285
  labelsProcessorFactory: ctx.labelsProcessorFactory,
286
+ codeScanningProcessorFactory: ctx.codeScanningProcessorFactory,
272
287
  });
273
288
  }
274
289
  /**
@@ -278,7 +293,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
278
293
  async function runLifecyclePhase(repo, ctx) {
279
294
  const repoNumber = repo.index + 1;
280
295
  try {
281
- const { outputLines, lifecycleResult } = await runLifecycleCheck(repo.repoConfig, repo.repoInfo, {
296
+ const { outputLines, lifecycleResult, createSettings } = await runLifecycleCheck(repo.repoConfig, repo.repoInfo, {
282
297
  dryRun: ctx.options.dryRun ?? false,
283
298
  resolvedWorkDir: repo.workDir,
284
299
  githubHosts: ctx.config.githubHosts,
@@ -290,7 +305,6 @@ async function runLifecyclePhase(repo, ctx) {
290
305
  for (const line of outputLines) {
291
306
  getLogger().info(line);
292
307
  }
293
- const createSettings = toCreateRepoSettings(ctx.config.settings?.repo);
294
308
  ctx.lifecycleReportInputs.push({
295
309
  repoName: repo.repoName,
296
310
  action: lifecycleResult.action,
@@ -381,10 +395,10 @@ export async function runSync(options, deps = {}) {
381
395
  // Reset module-level singletons to ensure fresh state per invocation
382
396
  _defaultExecutor = undefined;
383
397
  _logger = undefined;
384
- const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), } = deps;
398
+ const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), codeScanningProcessorFactory = createDefaultCodeScanningProcessorFactory(), } = deps;
385
399
  const configPath = resolve(options.config);
386
400
  if (!existsSync(configPath)) {
387
- throw new ValidationError(`Config file not found: ${configPath}`);
401
+ throw new ValidationError(`Config path not found: ${configPath}`);
388
402
  }
389
403
  getLogger().log(`Loading config from: ${configPath}`);
390
404
  if (options.dryRun) {
@@ -432,6 +446,7 @@ export async function runSync(options, deps = {}) {
432
446
  rulesetProcessorFactory,
433
447
  repoSettingsProcessorFactory,
434
448
  labelsProcessorFactory,
449
+ codeScanningProcessorFactory,
435
450
  };
436
451
  for (let i = 0; i < config.repos.length; i++) {
437
452
  await processSingleRepo(config.repos[i], i, ctx);
@@ -1,10 +1,11 @@
1
+ import type { MergeMode } from "../config/index.js";
1
2
  import type { SyncReport, ReportFileChange } from "../output/sync-report.js";
2
3
  interface SyncResultInput {
3
4
  repoName: string;
4
5
  success: boolean;
5
6
  fileChanges: ReportFileChange[];
6
7
  prUrl?: string;
7
- mergeOutcome?: "manual" | "auto" | "force" | "direct";
8
+ mergeOutcome?: MergeMode;
8
9
  error?: string;
9
10
  }
10
11
  export declare function buildSyncReport(results: SyncResultInput[]): SyncReport;
@@ -1,7 +1,7 @@
1
1
  import type { MergeMode, MergeStrategy, RepoConfig } from "../config/index.js";
2
2
  import type { IRepoLifecycleManager } from "../lifecycle/index.js";
3
3
  import type { IRepositoryProcessor } from "../sync/index.js";
4
- import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, BaseProcessorResult } from "../settings/index.js";
4
+ import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, ICodeScanningProcessor, BaseProcessorResult } from "../settings/index.js";
5
5
  import type { RepoInfo } from "../shared/repo-detector.js";
6
6
  import type { ResultsCollector } from "./results-collector.js";
7
7
  export type ProcessorFactory = () => IRepositoryProcessor;
@@ -9,6 +9,7 @@ export type SettingsProcessorFactory<T extends ISettingsProcessor> = () => T;
9
9
  export type RulesetProcessorFactory = SettingsProcessorFactory<IRulesetProcessor>;
10
10
  export type RepoSettingsProcessorFactory = SettingsProcessorFactory<IRepoSettingsProcessor>;
11
11
  export type LabelsProcessorFactory = SettingsProcessorFactory<ILabelsProcessor>;
12
+ export type CodeScanningProcessorFactory = SettingsProcessorFactory<ICodeScanningProcessor>;
12
13
  /**
13
14
  * Dependencies for the sync command (dependency injection).
14
15
  */
@@ -18,6 +19,7 @@ export interface SyncDependencies {
18
19
  rulesetProcessorFactory?: RulesetProcessorFactory;
19
20
  repoSettingsProcessorFactory?: RepoSettingsProcessorFactory;
20
21
  labelsProcessorFactory?: LabelsProcessorFactory;
22
+ codeScanningProcessorFactory?: CodeScanningProcessorFactory;
21
23
  }
22
24
  export interface SharedOptions {
23
25
  config: string;
@@ -41,7 +43,7 @@ export interface SyncResultEntry {
41
43
  diffLines?: string[];
42
44
  }>;
43
45
  prUrl?: string;
44
- mergeOutcome?: "manual" | "auto" | "force" | "direct";
46
+ mergeOutcome?: MergeMode;
45
47
  error?: string;
46
48
  }
47
49
  export interface SettingsResult extends BaseProcessorResult {
@@ -65,4 +67,5 @@ export interface ApplyRepoSettingsContext {
65
67
  rulesetProcessorFactory: NonNullable<SyncDependencies["rulesetProcessorFactory"]>;
66
68
  repoSettingsProcessorFactory: NonNullable<SyncDependencies["repoSettingsProcessorFactory"]>;
67
69
  labelsProcessorFactory: NonNullable<SyncDependencies["labelsProcessorFactory"]>;
70
+ codeScanningProcessorFactory: NonNullable<SyncDependencies["codeScanningProcessorFactory"]>;
68
71
  }
@@ -0,0 +1,6 @@
1
+ import type { RawConfig } from "./types.js";
2
+ export interface ConfigFragment {
3
+ fileName: string;
4
+ config: Partial<RawConfig>;
5
+ }
6
+ export declare function mergeConfigFragments(fragments: ConfigFragment[]): RawConfig;
@@ -0,0 +1,66 @@
1
+ import { ValidationError } from "../shared/errors.js";
2
+ /** Keys that can only appear in one file across a config directory. */
3
+ const SINGLE_FILE_KEYS = [
4
+ "id",
5
+ "files",
6
+ "prOptions",
7
+ "prTemplate",
8
+ "settings",
9
+ "githubHosts",
10
+ "deleteOrphaned",
11
+ ];
12
+ export function mergeConfigFragments(fragments) {
13
+ if (fragments.length === 0) {
14
+ throw new ValidationError("No config fragments to merge");
15
+ }
16
+ const merged = {};
17
+ const singleKeySource = {};
18
+ const allRepos = [];
19
+ const allGroups = {};
20
+ const groupSource = {};
21
+ const allConditionalGroups = [];
22
+ for (const { fileName, config } of fragments) {
23
+ // Enforce single-file keys
24
+ for (const key of SINGLE_FILE_KEYS) {
25
+ if (config[key] !== undefined) {
26
+ if (singleKeySource[key] !== undefined) {
27
+ throw new ValidationError(`'${key}' is defined in both ${singleKeySource[key]} and ${fileName} — this key can only appear in one file`);
28
+ }
29
+ singleKeySource[key] = fileName;
30
+ merged[key] = config[key];
31
+ }
32
+ }
33
+ // Concatenate repos
34
+ if (config.repos) {
35
+ allRepos.push(...config.repos);
36
+ }
37
+ // Merge groups (unique names only)
38
+ if (config.groups) {
39
+ for (const [groupName, groupConfig] of Object.entries(config.groups)) {
40
+ if (groupName in allGroups) {
41
+ throw new ValidationError(`group '${groupName}' is defined in both ${groupSource[groupName]} and ${fileName} — group names must be unique across files`);
42
+ }
43
+ allGroups[groupName] = groupConfig;
44
+ groupSource[groupName] = fileName;
45
+ }
46
+ }
47
+ // Concatenate conditional groups
48
+ if (config.conditionalGroups) {
49
+ allConditionalGroups.push(...config.conditionalGroups);
50
+ }
51
+ }
52
+ if (!merged.id) {
53
+ throw new ValidationError("No 'id' found in any config file — exactly one file must define 'id'");
54
+ }
55
+ if (allRepos.length === 0) {
56
+ throw new ValidationError("No 'repos' found in any config file — at least one file must define 'repos'");
57
+ }
58
+ return {
59
+ ...merged,
60
+ repos: allRepos,
61
+ ...(Object.keys(allGroups).length > 0 ? { groups: allGroups } : {}),
62
+ ...(allConditionalGroups.length > 0
63
+ ? { conditionalGroups: allConditionalGroups }
64
+ : {}),
65
+ };
66
+ }
@@ -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, RawGroupConfig, 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, CodeScanningSettings, CodeScanningState, CodeScanningQuerySuite, CodeScanningLanguage, 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";
@@ -5,5 +5,5 @@ export { normalizeConfigInternal as normalizeConfig };
5
5
  * Load and validate raw config without normalization.
6
6
  * Use this when you need to perform command-specific validation before normalizing.
7
7
  */
8
- export declare function loadRawConfig(filePath: string): RawConfig;
9
- export declare function loadConfig(filePath: string, env: Record<string, string | undefined>): Config;
8
+ export declare function loadRawConfig(configPath: string): RawConfig;
9
+ export declare function loadConfig(configPath: string, env: Record<string, string | undefined>): Config;
@@ -1,17 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { readFileSync, statSync, readdirSync } from "node:fs";
2
+ import { dirname, join, extname } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { validateRawConfig } from "./validator.js";
5
5
  import { normalizeConfig as normalizeConfigInternal } from "./normalizer.js";
6
6
  import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
7
7
  import { toErrorMessage } from "../shared/type-guards.js";
8
8
  import { ValidationError } from "../shared/errors.js";
9
+ import { mergeConfigFragments } from "./config-merger.js";
9
10
  export { normalizeConfigInternal as normalizeConfig };
10
11
  /**
11
12
  * Load and validate raw config without normalization.
12
13
  * Use this when you need to perform command-specific validation before normalizing.
13
14
  */
14
- export function loadRawConfig(filePath) {
15
+ export function loadRawConfig(configPath) {
16
+ const stat = statSync(configPath);
17
+ if (stat.isDirectory()) {
18
+ return loadRawConfigFromDirectory(configPath);
19
+ }
20
+ return loadRawConfigFromFile(configPath);
21
+ }
22
+ export function loadConfig(configPath, env) {
23
+ const rawConfig = loadRawConfig(configPath);
24
+ return normalizeConfigInternal(rawConfig, env);
25
+ }
26
+ function loadRawConfigFromFile(filePath) {
15
27
  const content = readFileSync(filePath, "utf-8");
16
28
  const configDir = dirname(filePath);
17
29
  let rawConfig;
@@ -27,7 +39,39 @@ export function loadRawConfig(filePath) {
27
39
  validateRawConfig(rawConfig);
28
40
  return rawConfig;
29
41
  }
30
- export function loadConfig(filePath, env) {
31
- const rawConfig = loadRawConfig(filePath);
32
- return normalizeConfigInternal(rawConfig, env);
42
+ function loadRawConfigFromDirectory(dirPath) {
43
+ const entries = readdirSync(dirPath, { withFileTypes: true });
44
+ const yamlFiles = entries
45
+ .filter((entry) => entry.isFile() &&
46
+ [".yaml", ".yml"].includes(extname(entry.name).toLowerCase()))
47
+ .map((entry) => entry.name)
48
+ .sort();
49
+ if (yamlFiles.length === 0) {
50
+ throw new ValidationError(`No .yaml or .yml files found in directory: ${dirPath}`);
51
+ }
52
+ const fragments = yamlFiles.map((fileName) => {
53
+ const filePath = join(dirPath, fileName);
54
+ const content = readFileSync(filePath, "utf-8");
55
+ const configDir = dirname(filePath);
56
+ let config;
57
+ try {
58
+ config = parse(content);
59
+ }
60
+ catch (error) {
61
+ const message = toErrorMessage(error);
62
+ throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`);
63
+ }
64
+ if (!config || typeof config !== "object") {
65
+ throw new ValidationError(`Config file ${fileName} is empty or invalid — expected a YAML mapping`);
66
+ }
67
+ // Safe cast: resolveFileReferencesInConfig only accesses optional fields
68
+ // (files, groups, etc.), so fragments missing id/repos work correctly.
69
+ config = resolveFileReferencesInConfig(config, {
70
+ configDir,
71
+ });
72
+ return { fileName, config };
73
+ });
74
+ const merged = mergeConfigFragments(fragments);
75
+ validateRawConfig(merged);
76
+ return merged;
33
77
  }
@@ -140,23 +140,18 @@ export function mergeTextContent(base, overlay, strategy = "replace") {
140
140
  if (typeof overlay === "string") {
141
141
  return overlay;
142
142
  }
143
- // If overlay is an array
144
- if (Array.isArray(overlay)) {
145
- // If base is also an array, apply merge strategy
146
- if (Array.isArray(base)) {
147
- switch (strategy) {
148
- case "append":
149
- return [...base, ...overlay];
150
- case "prepend":
151
- return [...overlay, ...base];
152
- case "replace":
153
- default:
154
- return overlay;
155
- }
143
+ // If base is also an array, apply merge strategy
144
+ if (Array.isArray(base)) {
145
+ switch (strategy) {
146
+ case "append":
147
+ return [...base, ...overlay];
148
+ case "prepend":
149
+ return [...overlay, ...base];
150
+ case "replace":
151
+ default:
152
+ return overlay;
156
153
  }
157
- // Base is string, overlay is array - overlay replaces
158
- return overlay;
159
154
  }
160
- // Fallback (shouldn't reach here with proper types)
155
+ // Base is string, overlay is array - overlay replaces
161
156
  return overlay;
162
157
  }
@@ -203,6 +203,26 @@ export function mergeSettings(root, perRepo) {
203
203
  if (mergedLabels) {
204
204
  result.labels = mergedLabels;
205
205
  }
206
+ // Merge code scanning: per-repo fully replaces root (not shallow merge).
207
+ // Unlike `repo` settings (which shallow-merge via spread), code scanning
208
+ // uses full replacement because its 3 fields (state, querySuite, languages)
209
+ // are tightly coupled — partial inheritance (e.g., inheriting languages
210
+ // from root while changing querySuite) would be confusing.
211
+ // codeScanning: false means opt out of all root code scanning settings
212
+ if (perRepo?.codeScanning === false) {
213
+ // Opt-out: don't include any code scanning settings
214
+ }
215
+ else {
216
+ const mergedCodeScanning = perRepo?.codeScanning ?? root?.codeScanning;
217
+ if (mergedCodeScanning) {
218
+ // At this point mergedCodeScanning is CodeScanningSettings (not false),
219
+ // because root uses RawRootSettings where false is filtered by the
220
+ // outer check on perRepo?.codeScanning === false
221
+ if (typeof mergedCodeScanning === "object") {
222
+ result.codeScanning = mergedCodeScanning;
223
+ }
224
+ }
225
+ }
206
226
  return Object.keys(result).length > 0 ? result : undefined;
207
227
  }
208
228
  /**
@@ -344,6 +364,15 @@ function mergeRawSettings(base, overlay) {
344
364
  }
345
365
  }
346
366
  }
367
+ // Merge code scanning: overlay fully replaces base (same semantics as mergeSettings)
368
+ if (overlay.codeScanning !== undefined) {
369
+ if (overlay.codeScanning === false) {
370
+ result.codeScanning = false;
371
+ }
372
+ else {
373
+ result.codeScanning = structuredClone(overlay.codeScanning);
374
+ }
375
+ }
347
376
  // deleteOrphaned: overlay wins
348
377
  if (overlay.deleteOrphaned !== undefined) {
349
378
  result.deleteOrphaned = overlay.deleteOrphaned;
@@ -259,6 +259,14 @@ export interface Label {
259
259
  /** Rename target. Maps to GitHub API's new_name field. */
260
260
  new_name?: string;
261
261
  }
262
+ export type CodeScanningState = "configured" | "not-configured";
263
+ export type CodeScanningQuerySuite = "default" | "extended";
264
+ export type CodeScanningLanguage = "actions" | "c-cpp" | "csharp" | "go" | "java-kotlin" | "javascript-typescript" | "python" | "ruby" | "swift";
265
+ export interface CodeScanningSettings {
266
+ state: CodeScanningState;
267
+ querySuite?: CodeScanningQuerySuite;
268
+ languages?: CodeScanningLanguage[];
269
+ }
262
270
  export interface RepoSettings {
263
271
  /** GitHub rulesets keyed by name */
264
272
  rulesets?: Record<string, Ruleset>;
@@ -266,6 +274,8 @@ export interface RepoSettings {
266
274
  repo?: GitHubRepoSettings;
267
275
  /** GitHub labels keyed by name */
268
276
  labels?: Record<string, Label>;
277
+ /** GitHub code scanning default setup */
278
+ codeScanning?: CodeScanningSettings;
269
279
  deleteOrphaned?: boolean;
270
280
  }
271
281
  export type ContentValue = Record<string, unknown> | string | string[];
@@ -323,6 +333,7 @@ export interface RawRootSettings {
323
333
  rulesets?: Record<string, Ruleset | false>;
324
334
  repo?: GitHubRepoSettings | false;
325
335
  labels?: Record<string, Label | false>;
336
+ codeScanning?: CodeScanningSettings | false;
326
337
  deleteOrphaned?: boolean;
327
338
  }
328
339
  export interface RawRepoSettings {
@@ -333,6 +344,7 @@ export interface RawRepoSettings {
333
344
  labels?: Record<string, Label | false> & {
334
345
  inherit?: boolean;
335
346
  };
347
+ codeScanning?: CodeScanningSettings | false;
336
348
  deleteOrphaned?: boolean;
337
349
  }
338
350
  export interface RawRepoConfig {