@aspruyt/xfg 4.0.4 → 5.0.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 (74) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/program.d.ts +3 -0
  3. package/dist/cli/program.js +18 -13
  4. package/dist/cli/sync-command.js +62 -39
  5. package/dist/cli/sync-report-builder.js +7 -4
  6. package/dist/config/formatter.js +14 -9
  7. package/dist/config/merge.d.ts +2 -4
  8. package/dist/config/merge.js +15 -67
  9. package/dist/config/validator.js +2 -9
  10. package/dist/lifecycle/repo-lifecycle-factory.js +0 -4
  11. package/dist/output/github-summary.d.ts +3 -2
  12. package/dist/output/github-summary.js +1 -7
  13. package/dist/output/lifecycle-report.js +7 -14
  14. package/dist/output/sync-report.d.ts +2 -19
  15. package/dist/output/sync-report.js +16 -28
  16. package/dist/output/types.d.ts +19 -0
  17. package/dist/output/types.js +1 -0
  18. package/dist/output/unified-summary.d.ts +2 -1
  19. package/dist/output/unified-summary.js +4 -1
  20. package/dist/settings/base-processor.d.ts +3 -1
  21. package/dist/settings/base-processor.js +9 -5
  22. package/dist/settings/index.d.ts +1 -1
  23. package/dist/settings/labels/diff.d.ts +2 -1
  24. package/dist/settings/labels/formatter.js +2 -4
  25. package/dist/settings/labels/github-labels-strategy.js +2 -1
  26. package/dist/settings/labels/processor.js +0 -1
  27. package/dist/settings/repo-settings/github-repo-settings-strategy.js +2 -1
  28. package/dist/settings/rulesets/diff-algorithm.js +0 -1
  29. package/dist/settings/rulesets/diff.d.ts +2 -1
  30. package/dist/settings/rulesets/diff.js +37 -21
  31. package/dist/settings/rulesets/formatter.js +44 -38
  32. package/dist/settings/rulesets/github-ruleset-strategy.js +2 -1
  33. package/dist/settings/rulesets/processor.js +0 -1
  34. package/dist/shared/gh-api-utils.d.ts +8 -7
  35. package/dist/shared/gh-api-utils.js +2 -16
  36. package/dist/shared/interpolation-engine.d.ts +3 -0
  37. package/dist/shared/interpolation-engine.js +0 -3
  38. package/dist/shared/json-utils.d.ts +6 -0
  39. package/dist/shared/json-utils.js +16 -0
  40. package/dist/shared/repo-detector.js +0 -4
  41. package/dist/shared/xfg-template.d.ts +3 -0
  42. package/dist/shared/xfg-template.js +0 -20
  43. package/dist/sync/auth-options-builder.js +7 -1
  44. package/dist/sync/branch-manager.d.ts +1 -1
  45. package/dist/sync/commit-message.d.ts +1 -1
  46. package/dist/sync/commit-push-manager.d.ts +1 -1
  47. package/dist/sync/commit-push-manager.js +2 -2
  48. package/dist/sync/diff-utils.d.ts +15 -2
  49. package/dist/sync/diff-utils.js +50 -14
  50. package/dist/sync/file-sync-orchestrator.js +2 -4
  51. package/dist/sync/file-sync-strategy.js +11 -4
  52. package/dist/sync/file-writer.js +9 -4
  53. package/dist/sync/index.d.ts +2 -1
  54. package/dist/sync/index.js +1 -0
  55. package/dist/sync/manifest-manager.d.ts +1 -1
  56. package/dist/sync/manifest-manager.js +20 -6
  57. package/dist/sync/pr-merge-handler.js +6 -1
  58. package/dist/sync/repository-processor.js +8 -1
  59. package/dist/sync/types.d.ts +5 -4
  60. package/dist/vcs/authenticated-git-ops.d.ts +9 -1
  61. package/dist/vcs/authenticated-git-ops.js +7 -14
  62. package/dist/vcs/git-ops.js +29 -12
  63. package/dist/vcs/github-pr-strategy.js +6 -1
  64. package/dist/vcs/gitlab-pr-strategy.js +7 -2
  65. package/dist/vcs/graphql-commit-strategy.js +2 -1
  66. package/dist/vcs/index.d.ts +1 -0
  67. package/dist/vcs/index.js +2 -0
  68. package/dist/vcs/pr-creator.d.ts +5 -1
  69. package/dist/vcs/pr-creator.js +4 -4
  70. package/package.json +1 -1
  71. package/dist/output/index.d.ts +0 -5
  72. package/dist/output/index.js +0 -10
  73. package/dist/shared/index.d.ts +0 -15
  74. package/dist/shared/index.js +0 -30
package/README.md CHANGED
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
33
  - uses: actions/checkout@v4
34
- - uses: anthony-spruyt/xfg@v4
34
+ - uses: anthony-spruyt/xfg@v5
35
35
  with:
36
36
  config: ./sync-config.yaml
37
37
  github-token: ${{ secrets.GH_PAT }} # PAT with repo scope for cross-repo access
@@ -1,2 +1,5 @@
1
1
  import { program } from "commander";
2
+ import { MergeMode, MergeStrategy } from "../config/index.js";
3
+ export declare function parseMergeMode(value: string): MergeMode;
4
+ export declare function parseMergeStrategy(value: string): MergeStrategy;
2
5
  export { program };
@@ -28,30 +28,35 @@ function addSharedOptions(cmd) {
28
28
  .option("--no-delete", "Skip deletion of orphaned resources even if deleteOrphaned is configured");
29
29
  }
30
30
  // =============================================================================
31
- // CLI Program
31
+ // Validators
32
32
  // =============================================================================
33
- program
34
- .name("xfg")
35
- .description("Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab")
36
- .version(packageJson.version);
37
- // Sync command (file synchronization)
38
- const syncCommand = new Command("sync")
39
- .description("Sync configuration files across repositories")
40
- .option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
41
- .option("-m, --merge <mode>", "PR merge mode: manual, auto (default, merge when checks pass), force (bypass requirements), direct (push to default branch, no PR)", (value) => {
33
+ export function parseMergeMode(value) {
42
34
  const valid = ["manual", "auto", "force", "direct"];
43
35
  if (!valid.includes(value)) {
44
36
  throw new ValidationError(`Invalid merge mode: ${value}. Valid: ${valid.join(", ")}`);
45
37
  }
46
38
  return value;
47
- })
48
- .option("--merge-strategy <strategy>", "Merge strategy: merge, squash (default), rebase", (value) => {
39
+ }
40
+ export function parseMergeStrategy(value) {
49
41
  const valid = ["merge", "squash", "rebase"];
50
42
  if (!valid.includes(value)) {
51
43
  throw new ValidationError(`Invalid merge strategy: ${value}. Valid: ${valid.join(", ")}`);
52
44
  }
53
45
  return value;
54
- })
46
+ }
47
+ // =============================================================================
48
+ // CLI Program
49
+ // =============================================================================
50
+ program
51
+ .name("xfg")
52
+ .description("Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab")
53
+ .version(packageJson.version);
54
+ // Sync command (file synchronization)
55
+ const syncCommand = new Command("sync")
56
+ .description("Sync configuration files across repositories")
57
+ .option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
58
+ .option("-m, --merge <mode>", "PR merge mode: manual, auto (default, merge when checks pass), force (bypass requirements), direct (push to default branch, no PR)", parseMergeMode)
59
+ .option("--merge-strategy <strategy>", "Merge strategy: merge, squash (default), rebase", parseMergeStrategy)
55
60
  .option("--delete-branch", "Delete source branch after merge")
56
61
  .action((opts) => {
57
62
  runSync(opts).catch((error) => {
@@ -11,12 +11,26 @@ import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, GitHubRuleset
11
11
  import { ShellCommandExecutor } from "../shared/command-executor.js";
12
12
  import { Logger } from "../shared/logger.js";
13
13
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
14
- const defaultExecutor = new ShellCommandExecutor(process.env);
15
- const logger = new Logger(!!(process.env.DEBUG || process.env.XFG_DEBUG));
16
- const cwd = process.cwd();
17
- const defaultRulesetProcessorFactory = () => new RulesetProcessor(new GitHubRulesetStrategy(defaultExecutor, { cwd }));
18
- const defaultRepoSettingsProcessorFactory = () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(defaultExecutor, { cwd }));
19
- const defaultLabelsProcessorFactory = () => new LabelsProcessor(new GitHubLabelsStrategy(defaultExecutor, { cwd }));
14
+ let _defaultExecutor;
15
+ let _logger;
16
+ function getDefaultExecutor() {
17
+ return (_defaultExecutor ??= new ShellCommandExecutor(process.env));
18
+ }
19
+ function getLogger() {
20
+ return (_logger ??= new Logger(!!(process.env.DEBUG || process.env.XFG_DEBUG)));
21
+ }
22
+ function createDefaultRulesetProcessorFactory() {
23
+ const cwd = process.cwd();
24
+ return () => new RulesetProcessor(new GitHubRulesetStrategy(getDefaultExecutor(), { cwd }));
25
+ }
26
+ function createDefaultRepoSettingsProcessorFactory() {
27
+ const cwd = process.cwd();
28
+ return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(getDefaultExecutor(), { cwd }));
29
+ }
30
+ function createDefaultLabelsProcessorFactory() {
31
+ const cwd = process.cwd();
32
+ return () => new LabelsProcessor(new GitHubLabelsStrategy(getDefaultExecutor(), { cwd }));
33
+ }
20
34
  import { ResultsCollector } from "./results-collector.js";
21
35
  import { buildSettingsReport } from "./settings-report-builder.js";
22
36
  import { formatSettingsReportCLI } from "../output/settings-report.js";
@@ -73,22 +87,22 @@ function determineMergeOutcome(result) {
73
87
  }
74
88
  function logSettingsResult(result, label, current, repoName, settingsCollector) {
75
89
  if (result.planOutput?.lines?.length) {
76
- logger.info("");
77
- logger.info(`${repoName} - ${label}:`);
90
+ getLogger().info("");
91
+ getLogger().info(`${repoName} - ${label}:`);
78
92
  for (const line of result.planOutput.lines) {
79
- logger.info(line);
93
+ getLogger().info(line);
80
94
  }
81
95
  if (result.warnings?.length) {
82
96
  for (const warning of result.warnings) {
83
- logger.warn(warning);
97
+ getLogger().warn(warning);
84
98
  }
85
99
  }
86
100
  }
87
101
  else if (!result.skipped && result.success) {
88
- logger.success(current, repoName, `${label}: ${result.message}`);
102
+ getLogger().success(current, repoName, `${label}: ${result.message}`);
89
103
  }
90
104
  if (!result.success && !result.skipped) {
91
- logger.error(current, repoName, `${label}: ${result.message}`);
105
+ getLogger().error(current, repoName, `${label}: ${result.message}`);
92
106
  settingsCollector.appendError(repoName, result.message);
93
107
  }
94
108
  }
@@ -148,7 +162,7 @@ async function applyRepoSettings(ctx) {
148
162
  logSettingsResult(result, desc.label, current, repoName, settingsCollector);
149
163
  }
150
164
  catch (error) {
151
- logger.error(current, repoName, `${desc.label}: ${toErrorMessage(error)}`);
165
+ getLogger().error(current, repoName, `${desc.label}: ${toErrorMessage(error)}`);
152
166
  settingsCollector.appendError(repoName, error);
153
167
  }
154
168
  }
@@ -156,15 +170,15 @@ async function applyRepoSettings(ctx) {
156
170
  function displayReports(reportResults, lifecycleReportInputs, settingsCollector, dryRun) {
157
171
  const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
158
172
  if (hasLifecycleChanges(lifecycleReport)) {
159
- logger.log("");
173
+ getLogger().log("");
160
174
  for (const line of formatLifecycleReportCLI(lifecycleReport)) {
161
- logger.log(line);
175
+ getLogger().log(line);
162
176
  }
163
177
  }
164
178
  const report = buildSyncReport(reportResults);
165
- logger.log("");
179
+ getLogger().log("");
166
180
  for (const line of formatSyncReportCLI(report)) {
167
- logger.log(line);
181
+ getLogger().log(line);
168
182
  }
169
183
  // Build and display settings report (if any settings were processed)
170
184
  const settingsResults = settingsCollector.getAll();
@@ -173,9 +187,9 @@ function displayReports(reportResults, lifecycleReportInputs, settingsCollector,
173
187
  settingsReport = buildSettingsReport(settingsResults);
174
188
  const settingsLines = formatSettingsReportCLI(settingsReport);
175
189
  if (settingsLines.length > 0) {
176
- logger.log("");
190
+ getLogger().log("");
177
191
  for (const line of settingsLines) {
178
- logger.log(line);
192
+ getLogger().log(line);
179
193
  }
180
194
  }
181
195
  }
@@ -206,7 +220,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
206
220
  }
207
221
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
208
222
  if (mergeMode === "direct" && repoConfig.prOptions?.mergeStrategy) {
209
- logger.warn(`mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode for ${repoConfig.git}`);
223
+ getLogger().warn(`mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode for ${repoConfig.git}`);
210
224
  }
211
225
  let repoInfo;
212
226
  try {
@@ -215,7 +229,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
215
229
  });
216
230
  }
217
231
  catch (error) {
218
- logger.error(current, repoConfig.git, toErrorMessage(error));
232
+ getLogger().error(current, repoConfig.git, toErrorMessage(error));
219
233
  ctx.reportResults.push({
220
234
  repoName: repoConfig.git,
221
235
  success: false,
@@ -227,7 +241,13 @@ async function processSingleRepo(repoConfig, index, ctx) {
227
241
  const repoName = getRepoDisplayName(repoInfo);
228
242
  const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(index)));
229
243
  const repoToken = isGitHubRepo(repoInfo)
230
- ? (await resolveGitHubToken(repoInfo, ctx.tokenManager, repoName, logger, process.env.GH_TOKEN)).token
244
+ ? (await resolveGitHubToken({
245
+ repoInfo: repoInfo,
246
+ tokenManager: ctx.tokenManager,
247
+ context: repoName,
248
+ log: getLogger(),
249
+ envToken: process.env.GH_TOKEN,
250
+ })).token
231
251
  : undefined;
232
252
  const repo = {
233
253
  repoConfig,
@@ -273,7 +293,7 @@ async function runLifecyclePhase(repo, ctx) {
273
293
  repoSettings: ctx.config.settings?.repo,
274
294
  });
275
295
  for (const line of outputLines) {
276
- logger.info(line);
296
+ getLogger().info(line);
277
297
  }
278
298
  const createSettings = toCreateRepoSettings(ctx.config.settings?.repo);
279
299
  ctx.lifecycleReportInputs.push({
@@ -300,7 +320,7 @@ async function runLifecyclePhase(repo, ctx) {
300
320
  return false;
301
321
  }
302
322
  catch (error) {
303
- logger.error(current, repo.repoName, `Lifecycle error: ${toErrorMessage(error)}`);
323
+ getLogger().error(current, repo.repoName, `Lifecycle error: ${toErrorMessage(error)}`);
304
324
  ctx.reportResults.push({
305
325
  repoName: repo.repoName,
306
326
  success: false,
@@ -316,14 +336,14 @@ async function runLifecyclePhase(repo, ctx) {
316
336
  async function runFileSyncPhase(repo, ctx) {
317
337
  const current = repo.index + 1;
318
338
  try {
319
- logger.progress(current, repo.repoName, "Processing...");
339
+ getLogger().progress(current, repo.repoName, "Processing...");
320
340
  const result = await ctx.processor.process(repo.repoConfig, repo.repoInfo, {
321
341
  branchName: ctx.branchName,
322
342
  workDir: repo.workDir,
323
343
  configId: ctx.config.id,
324
344
  dryRun: ctx.options.dryRun,
325
345
  retries: ctx.options.retries,
326
- executor: defaultExecutor,
346
+ executor: getDefaultExecutor(),
327
347
  prTemplate: ctx.config.prTemplate,
328
348
  noDelete: ctx.options.noDelete,
329
349
  token: repo.token,
@@ -342,17 +362,17 @@ async function runFileSyncPhase(repo, ctx) {
342
362
  error: result.success ? undefined : result.message,
343
363
  });
344
364
  if (result.skipped) {
345
- logger.skip(current, repo.repoName, result.message);
365
+ getLogger().skip(current, repo.repoName, result.message);
346
366
  }
347
367
  else if (result.success) {
348
- logger.success(current, repo.repoName, result.message);
368
+ getLogger().success(current, repo.repoName, result.message);
349
369
  }
350
370
  else {
351
- logger.error(current, repo.repoName, result.message);
371
+ getLogger().error(current, repo.repoName, result.message);
352
372
  }
353
373
  }
354
374
  catch (error) {
355
- logger.error(current, repo.repoName, toErrorMessage(error));
375
+ getLogger().error(current, repo.repoName, toErrorMessage(error));
356
376
  ctx.reportResults.push({
357
377
  repoName: repo.repoName,
358
378
  success: false,
@@ -362,14 +382,17 @@ async function runFileSyncPhase(repo, ctx) {
362
382
  }
363
383
  }
364
384
  export async function runSync(options, deps = {}) {
365
- const { lifecycleManager, rulesetProcessorFactory = defaultRulesetProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, labelsProcessorFactory = defaultLabelsProcessorFactory, } = deps;
385
+ // Reset module-level singletons to ensure fresh state per invocation
386
+ _defaultExecutor = undefined;
387
+ _logger = undefined;
388
+ const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), } = deps;
366
389
  const configPath = resolve(options.config);
367
390
  if (!existsSync(configPath)) {
368
391
  throw new ValidationError(`Config file not found: ${configPath}`);
369
392
  }
370
- logger.log(`Loading config from: ${configPath}`);
393
+ getLogger().log(`Loading config from: ${configPath}`);
371
394
  if (options.dryRun) {
372
- logger.log("Running in DRY RUN mode - no changes will be made\n");
395
+ getLogger().log("Running in DRY RUN mode - no changes will be made\n");
373
396
  }
374
397
  const rawConfig = loadRawConfig(configPath);
375
398
  validateForSync(rawConfig);
@@ -383,10 +406,10 @@ export async function runSync(options, deps = {}) {
383
406
  else {
384
407
  branchName = generateBranchName(fileNames);
385
408
  }
386
- logger.setTotal(config.repos.length);
387
- logger.log(`Found ${config.repos.length} repositories to process`);
388
- logger.log(`Target files: ${formatFileNames(fileNames)}`);
389
- logger.log(`Branch: ${branchName}\n`);
409
+ getLogger().setTotal(config.repos.length);
410
+ getLogger().log(`Found ${config.repos.length} repositories to process`);
411
+ getLogger().log(`Target files: ${formatFileNames(fileNames)}`);
412
+ getLogger().log(`Branch: ${branchName}\n`);
390
413
  const tokenManager = createTokenManager(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
391
414
  ? {
392
415
  appId: process.env.XFG_GITHUB_APP_ID,
@@ -395,7 +418,7 @@ export async function runSync(options, deps = {}) {
395
418
  : undefined);
396
419
  const processor = deps.processorFactory
397
420
  ? deps.processorFactory()
398
- : new RepositoryProcessor(undefined, logger, {
421
+ : new RepositoryProcessor(undefined, getLogger(), {
399
422
  tokenManager,
400
423
  envToken: process.env.GH_TOKEN,
401
424
  });
@@ -405,7 +428,7 @@ export async function runSync(options, deps = {}) {
405
428
  branchName,
406
429
  processor,
407
430
  lifecycleManager: lifecycleManager ??
408
- new RepoLifecycleManager(undefined, defaultExecutor, options.retries, cwd, logger),
431
+ new RepoLifecycleManager(undefined, getDefaultExecutor(), options.retries, process.cwd(), getLogger()),
409
432
  tokenManager,
410
433
  reportResults: [],
411
434
  lifecycleReportInputs: [],
@@ -4,10 +4,13 @@ export function buildSyncReport(results) {
4
4
  files: { create: 0, update: 0, delete: 0 },
5
5
  };
6
6
  for (const result of results) {
7
- const files = result.fileChanges.map((f) => ({
8
- path: f.path,
9
- action: f.action,
10
- }));
7
+ const files = result.fileChanges.map((f) => {
8
+ const entry = { path: f.path, action: f.action };
9
+ if (f.diffLines) {
10
+ entry.diffLines = f.diffLines;
11
+ }
12
+ return entry;
13
+ });
11
14
  // Count totals
12
15
  for (const file of files) {
13
16
  if (file.action === "create")
@@ -1,4 +1,4 @@
1
- import { Document, stringify } from "yaml";
1
+ import { Document, isScalar, Scalar, stringify, visit } from "yaml";
2
2
  export function detectOutputFormat(fileName) {
3
3
  const ext = fileName.toLowerCase().split(".").pop();
4
4
  if (ext === "yaml" || ext === "yml") {
@@ -16,11 +16,9 @@ export function detectOutputFormat(fileName) {
16
16
  */
17
17
  function buildHeaderComment(header, schemaUrl) {
18
18
  const lines = [];
19
- // Add yaml-language-server schema directive first (if present)
20
19
  if (schemaUrl) {
21
20
  lines.push(` yaml-language-server: $schema=${schemaUrl}`);
22
21
  }
23
- // Add custom header lines (with space prefix for proper formatting)
24
22
  if (header && header.length > 0) {
25
23
  lines.push(...header.map((h) => ` ${h}`));
26
24
  }
@@ -34,11 +32,9 @@ function buildHeaderComment(header, schemaUrl) {
34
32
  */
35
33
  function buildCommentOnlyYaml(header, schemaUrl) {
36
34
  const lines = [];
37
- // Add yaml-language-server schema directive first (if present)
38
35
  if (schemaUrl) {
39
36
  lines.push(`# yaml-language-server: $schema=${schemaUrl}`);
40
37
  }
41
- // Add custom header lines
42
38
  if (header && header.length > 0) {
43
39
  lines.push(...header.map((h) => `# ${h}`));
44
40
  }
@@ -79,10 +75,19 @@ export function convertContentToString(content, fileName, options) {
79
75
  doc.commentBefore = headerComment;
80
76
  }
81
77
  }
82
- // Quote all string values for YAML 1.1 compatibility.
83
- // The yaml library outputs YAML 1.2 where "06:00" is a plain string,
84
- // but many tools (e.g., Dependabot) use YAML 1.1 parsers that interpret
85
- // unquoted values like "06:00" as sexagesimal (360) or "yes"/"no" as booleans.
78
+ // Use BLOCK_LITERAL (|) for multi-line string values to preserve readability.
79
+ // Single-line strings remain QUOTE_DOUBLE via defaultStringType for YAML 1.1
80
+ // compatibility (prevents "06:00" as sexagesimal, "yes"/"no" as booleans).
81
+ visit(doc, {
82
+ Scalar(key, node) {
83
+ if (key === "value" &&
84
+ isScalar(node) &&
85
+ typeof node.value === "string" &&
86
+ node.value.includes("\n")) {
87
+ node.type = Scalar.BLOCK_LITERAL;
88
+ }
89
+ },
90
+ });
86
91
  return stringify(doc, {
87
92
  indent: 2,
88
93
  defaultStringType: "QUOTE_DOUBLE",
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Deep merge utilities for JSON configuration objects.
3
- * Supports configurable array merge strategies via $arrayMerge directive.
3
+ * Supports per-field array merge strategies via $arrayMerge + $values directives.
4
4
  */
5
5
  export type ArrayMergeStrategy = "replace" | "append" | "prepend";
6
6
  export interface MergeContext {
7
- arrayStrategies: Map<string, ArrayMergeStrategy>;
8
7
  defaultArrayStrategy: ArrayMergeStrategy;
9
8
  }
10
9
  /**
@@ -13,9 +12,8 @@ export interface MergeContext {
13
12
  * @param base - The base object
14
13
  * @param overlay - The overlay object (values override base)
15
14
  * @param ctx - Merge context with array strategies
16
- * @param path - Current path for strategy lookup (internal)
17
15
  */
18
- export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext, path?: string): Record<string, unknown>;
16
+ export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext): Record<string, unknown>;
19
17
  /**
20
18
  * Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
21
19
  * Works recursively on nested objects and arrays.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Deep merge utilities for JSON configuration objects.
3
- * Supports configurable array merge strategies via $arrayMerge directive.
3
+ * Supports per-field array merge strategies via $arrayMerge + $values directives.
4
4
  */
5
5
  import { isPlainObject } from "../shared/type-guards.js";
6
6
  /**
@@ -23,92 +23,41 @@ function mergeArrays(base, overlay, strategy) {
23
23
  // Fallback to replace for unknown strategies
24
24
  return overlay;
25
25
  }
26
- /**
27
- * Extract array values from an overlay object that uses the directive syntax:
28
- * { $arrayMerge: 'append', values: [1, 2, 3] }
29
- *
30
- * Or just return the array if it's already an array.
31
- */
32
- function extractArrayFromOverlay(overlay) {
33
- if (Array.isArray(overlay)) {
34
- return overlay;
35
- }
36
- if (isPlainObject(overlay) && "values" in overlay) {
37
- const values = overlay.values;
38
- if (Array.isArray(values)) {
39
- return values;
40
- }
41
- }
42
- return null;
43
- }
44
- /**
45
- * Get merge strategy from an overlay object's $arrayMerge directive.
46
- */
47
- function getStrategyFromOverlay(overlay) {
48
- if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
49
- const strategy = overlay.$arrayMerge;
50
- if (strategy === "replace" ||
51
- strategy === "append" ||
52
- strategy === "prepend") {
53
- return strategy;
54
- }
55
- }
56
- return null;
57
- }
58
26
  /**
59
27
  * Deep merge two objects with configurable array handling.
60
28
  *
61
29
  * @param base - The base object
62
30
  * @param overlay - The overlay object (values override base)
63
31
  * @param ctx - Merge context with array strategies
64
- * @param path - Current path for strategy lookup (internal)
65
32
  */
66
- export function deepMerge(base, overlay, ctx, path = "") {
33
+ export function deepMerge(base, overlay, ctx) {
67
34
  const result = { ...base };
68
- // Check for $arrayMerge directive at this level (applies to child arrays)
69
- const levelStrategy = getStrategyFromOverlay(overlay);
70
35
  for (const [key, overlayValue] of Object.entries(overlay)) {
71
36
  // Skip directive keys in output
72
37
  if (key.startsWith("$"))
73
38
  continue;
74
- const currentPath = path ? `${path}.${key}` : key;
75
39
  const baseValue = base[key];
76
- // If overlay is an object with $arrayMerge directive for an array field
40
+ // Per-field $arrayMerge + $values directive
77
41
  if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
78
- const strategy = getStrategyFromOverlay(overlayValue);
79
- const overlayArray = extractArrayFromOverlay(overlayValue);
80
- if (strategy && overlayArray && Array.isArray(baseValue)) {
81
- result[key] = mergeArrays(baseValue, overlayArray, strategy);
42
+ const strategy = overlayValue.$arrayMerge;
43
+ const values = overlayValue.$values;
44
+ if ((strategy === "replace" ||
45
+ strategy === "append" ||
46
+ strategy === "prepend") &&
47
+ Array.isArray(values) &&
48
+ Array.isArray(baseValue)) {
49
+ result[key] = mergeArrays(baseValue, values, strategy);
82
50
  continue;
83
51
  }
84
52
  }
85
- // Both are arrays - apply strategy
53
+ // Both are arrays use default strategy
86
54
  if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
87
- // Check for level-specific strategy, then path-specific, then default
88
- const strategy = levelStrategy ??
89
- ctx.arrayStrategies.get(currentPath) ??
90
- ctx.defaultArrayStrategy;
91
- result[key] = mergeArrays(baseValue, overlayValue, strategy);
55
+ result[key] = mergeArrays(baseValue, overlayValue, ctx.defaultArrayStrategy);
92
56
  continue;
93
57
  }
94
- // Both are plain objects - recurse
58
+ // Both are plain objects recurse
95
59
  if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
96
- // Extract $arrayMerge for child paths if present
97
- if ("$arrayMerge" in overlayValue) {
98
- const childStrategy = getStrategyFromOverlay(overlayValue);
99
- if (childStrategy) {
100
- // Apply to all immediate child arrays
101
- for (const childKey of Object.keys(overlayValue)) {
102
- if (!childKey.startsWith("$")) {
103
- const childPath = currentPath
104
- ? `${currentPath}.${childKey}`
105
- : childKey;
106
- ctx.arrayStrategies.set(childPath, childStrategy);
107
- }
108
- }
109
- }
110
- }
111
- result[key] = deepMerge(baseValue, overlayValue, ctx, currentPath);
60
+ result[key] = deepMerge(baseValue, overlayValue, ctx);
112
61
  continue;
113
62
  }
114
63
  // Otherwise, overlay wins (including null values)
@@ -143,7 +92,6 @@ export function stripMergeDirectives(obj) {
143
92
  */
144
93
  export function createMergeContext(defaultStrategy = "replace") {
145
94
  return {
146
- arrayStrategies: new Map(),
147
95
  defaultArrayStrategy: defaultStrategy,
148
96
  };
149
97
  }
@@ -12,15 +12,8 @@ const CONFIG_ID_MAX_LENGTH = 64;
12
12
  * Supports SSH (git@host:path) and HTTPS (https://host/path) formats.
13
13
  */
14
14
  function isValidGitUrl(url) {
15
- // SSH format: git@hostname:path
16
- if (/^git@[^:]+:.+$/.test(url)) {
17
- return true;
18
- }
19
- // HTTPS format: https://hostname/path
20
- if (/^https?:\/\/[^/]+\/.+$/.test(url)) {
21
- return true;
22
- }
23
- return false;
15
+ // SSH format: git@hostname:path OR HTTPS format: https://hostname/path
16
+ return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
24
17
  }
25
18
  /**
26
19
  * Check if a git URL points to GitHub (github.com).
@@ -15,12 +15,10 @@ export class RepoLifecycleFactory {
15
15
  this.log = log;
16
16
  }
17
17
  getProvider(platform) {
18
- // Check cache first
19
18
  const cached = this.providers.get(platform);
20
19
  if (cached) {
21
20
  return cached;
22
21
  }
23
- // Create provider
24
22
  let provider;
25
23
  switch (platform) {
26
24
  case "github":
@@ -39,12 +37,10 @@ export class RepoLifecycleFactory {
39
37
  return provider;
40
38
  }
41
39
  getMigrationSource(platform) {
42
- // Check cache first
43
40
  const cached = this.sources.get(platform);
44
41
  if (cached) {
45
42
  return cached;
46
43
  }
47
- // Create source
48
44
  let source;
49
45
  switch (platform) {
50
46
  case "azure-devops":
@@ -1,4 +1,5 @@
1
1
  import type { DebugLog } from "../shared/logger.js";
2
+ import type { SettingsAction } from "../settings/index.js";
2
3
  export type MergeOutcome = "manual" | "auto" | "force" | "direct";
3
4
  export interface FileChanges {
4
5
  added: number;
@@ -8,7 +9,7 @@ export interface FileChanges {
8
9
  }
9
10
  export interface RulesetPlanDetail {
10
11
  name: string;
11
- action: "create" | "update" | "delete" | "unchanged";
12
+ action: SettingsAction;
12
13
  propertyCount?: number;
13
14
  propertyChanges?: {
14
15
  added: number;
@@ -22,7 +23,7 @@ export interface RepoSettingsPlanDetail {
22
23
  }
23
24
  export interface LabelsPlanDetail {
24
25
  name: string;
25
- action: "create" | "update" | "delete" | "unchanged";
26
+ action: SettingsAction;
26
27
  newName?: string;
27
28
  }
28
29
  export interface RepoResult {
@@ -179,13 +179,7 @@ export function formatSummary(data) {
179
179
  lines.push("| Label | Action |");
180
180
  lines.push("|-------|--------|");
181
181
  for (const detail of result.labelsPlanDetails) {
182
- const action = detail.action === "create"
183
- ? "+ Create"
184
- : detail.action === "update"
185
- ? "~ Update"
186
- : detail.action === "delete"
187
- ? "- Delete"
188
- : "No change";
182
+ const action = formatRulesetAction(detail.action);
189
183
  const name = detail.newName
190
184
  ? `${detail.name} \u2192 ${detail.newName}`
191
185
  : detail.name;
@@ -1,6 +1,6 @@
1
- // src/output/lifecycle-report.ts
2
1
  import chalk from "chalk";
3
2
  import { writeGitHubStepSummary } from "./github-summary.js";
3
+ import { formatCountEntry } from "./settings-report.js";
4
4
  export function buildLifecycleReport(results) {
5
5
  const actions = [];
6
6
  const totals = { created: 0, forked: 0, migrated: 0, existed: 0 };
@@ -17,19 +17,12 @@ export function buildLifecycleReport(results) {
17
17
  return { actions, totals };
18
18
  }
19
19
  function formatLifecycleSummary(totals) {
20
- const total = totals.created + totals.forked + totals.migrated;
21
- if (total === 0) {
22
- return "No changes";
23
- }
24
- const parts = [];
25
- if (totals.created > 0)
26
- parts.push(`${totals.created} to create`);
27
- if (totals.forked > 0)
28
- parts.push(`${totals.forked} to fork`);
29
- if (totals.migrated > 0)
30
- parts.push(`${totals.migrated} to migrate`);
31
- const repoWord = total === 1 ? "repo" : "repos";
32
- return `Plan: ${total} ${repoWord} (${parts.join(", ")})`;
20
+ const entry = formatCountEntry("repo", "repos", [
21
+ { label: "to create", value: totals.created },
22
+ { label: "to fork", value: totals.forked },
23
+ { label: "to migrate", value: totals.migrated },
24
+ ]);
25
+ return entry ? `Plan: ${entry}` : "No changes";
33
26
  }
34
27
  /**
35
28
  * Returns true if the report has any non-"existed" actions worth displaying.