@aspruyt/xfg 4.0.0 → 4.0.2

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 (174) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/index.d.ts +1 -2
  3. package/dist/cli/index.js +0 -1
  4. package/dist/cli/program.js +7 -2
  5. package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
  6. package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
  7. package/dist/cli/settings-report-builder.d.ts +1 -3
  8. package/dist/cli/sync-command.d.ts +2 -24
  9. package/dist/cli/sync-command.js +295 -301
  10. package/dist/cli/types.d.ts +60 -40
  11. package/dist/cli/types.js +1 -12
  12. package/dist/config/errors.d.ts +9 -0
  13. package/dist/config/errors.js +11 -0
  14. package/dist/config/file-reference-resolver.d.ts +2 -1
  15. package/dist/config/file-reference-resolver.js +10 -8
  16. package/dist/config/formatter.d.ts +3 -2
  17. package/dist/config/index.d.ts +4 -6
  18. package/dist/config/index.js +4 -8
  19. package/dist/config/loader.js +4 -2
  20. package/dist/config/merge.d.ts +0 -9
  21. package/dist/config/merge.js +2 -7
  22. package/dist/config/normalizer.d.ts +4 -0
  23. package/dist/config/normalizer.js +61 -110
  24. package/dist/config/types.d.ts +15 -19
  25. package/dist/config/types.js +1 -1
  26. package/dist/config/validator.d.ts +0 -4
  27. package/dist/config/validator.js +286 -363
  28. package/dist/config/validators/file-validator.d.ts +2 -8
  29. package/dist/config/validators/file-validator.js +6 -17
  30. package/dist/config/validators/index.d.ts +3 -3
  31. package/dist/config/validators/index.js +3 -3
  32. package/dist/config/validators/repo-settings-validator.d.ts +0 -6
  33. package/dist/config/validators/repo-settings-validator.js +9 -9
  34. package/dist/config/validators/ruleset-validator.d.ts +0 -14
  35. package/dist/config/validators/ruleset-validator.js +28 -28
  36. package/dist/lifecycle/ado-migration-source.js +2 -1
  37. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
  38. package/dist/lifecycle/github-lifecycle-provider.js +79 -90
  39. package/dist/lifecycle/index.d.ts +2 -6
  40. package/dist/lifecycle/index.js +0 -4
  41. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  42. package/dist/lifecycle/lifecycle-formatter.js +4 -0
  43. package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
  44. package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
  45. package/dist/lifecycle/types.d.ts +0 -8
  46. package/dist/output/github-summary.d.ts +5 -0
  47. package/dist/output/github-summary.js +9 -2
  48. package/dist/output/index.d.ts +2 -2
  49. package/dist/output/index.js +1 -1
  50. package/dist/output/lifecycle-report.js +5 -23
  51. package/dist/output/settings-report.d.ts +14 -3
  52. package/dist/output/settings-report.js +137 -197
  53. package/dist/output/summary-utils.d.ts +1 -1
  54. package/dist/output/summary-utils.js +2 -1
  55. package/dist/output/sync-report.js +5 -8
  56. package/dist/output/unified-summary.d.ts +2 -1
  57. package/dist/output/unified-summary.js +71 -133
  58. package/dist/settings/base-processor.d.ts +67 -0
  59. package/dist/settings/base-processor.js +91 -0
  60. package/dist/settings/index.d.ts +4 -3
  61. package/dist/settings/index.js +3 -3
  62. package/dist/settings/labels/converter.d.ts +2 -1
  63. package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
  64. package/dist/settings/labels/github-labels-strategy.js +17 -73
  65. package/dist/settings/labels/index.d.ts +2 -6
  66. package/dist/settings/labels/index.js +1 -9
  67. package/dist/settings/labels/processor.d.ts +6 -30
  68. package/dist/settings/labels/processor.js +62 -152
  69. package/dist/settings/labels/types.d.ts +5 -8
  70. package/dist/settings/repo-settings/formatter.d.ts +2 -2
  71. package/dist/settings/repo-settings/formatter.js +6 -6
  72. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
  73. package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
  74. package/dist/settings/repo-settings/index.d.ts +2 -5
  75. package/dist/settings/repo-settings/index.js +1 -9
  76. package/dist/settings/repo-settings/processor.d.ts +6 -27
  77. package/dist/settings/repo-settings/processor.js +51 -104
  78. package/dist/settings/repo-settings/types.d.ts +7 -9
  79. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
  80. package/dist/settings/rulesets/diff-algorithm.js +1 -10
  81. package/dist/settings/rulesets/diff.d.ts +1 -1
  82. package/dist/settings/rulesets/diff.js +2 -21
  83. package/dist/settings/rulesets/formatter.d.ts +1 -3
  84. package/dist/settings/rulesets/formatter.js +1 -8
  85. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
  86. package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
  87. package/dist/settings/rulesets/index.d.ts +3 -6
  88. package/dist/settings/rulesets/index.js +5 -9
  89. package/dist/settings/rulesets/processor.d.ts +8 -33
  90. package/dist/settings/rulesets/processor.js +58 -151
  91. package/dist/settings/rulesets/types.d.ts +35 -6
  92. package/dist/shared/command-executor.d.ts +2 -22
  93. package/dist/shared/command-executor.js +8 -7
  94. package/dist/shared/env.d.ts +0 -8
  95. package/dist/shared/env.js +14 -70
  96. package/dist/shared/file-status.d.ts +2 -0
  97. package/dist/shared/file-status.js +13 -0
  98. package/dist/shared/gh-api-utils.d.ts +46 -0
  99. package/dist/shared/gh-api-utils.js +107 -0
  100. package/dist/shared/index.d.ts +5 -5
  101. package/dist/shared/index.js +3 -3
  102. package/dist/shared/interpolation-engine.d.ts +31 -0
  103. package/dist/shared/interpolation-engine.js +50 -0
  104. package/dist/shared/logger.d.ts +3 -7
  105. package/dist/shared/logger.js +4 -1
  106. package/dist/shared/repo-detector.d.ts +17 -2
  107. package/dist/shared/repo-detector.js +27 -0
  108. package/dist/shared/retry-utils.d.ts +9 -17
  109. package/dist/shared/retry-utils.js +22 -28
  110. package/dist/shared/sanitize-utils.d.ts +0 -7
  111. package/dist/shared/sanitize-utils.js +0 -7
  112. package/dist/shared/shell-utils.d.ts +1 -0
  113. package/dist/shared/shell-utils.js +3 -0
  114. package/dist/shared/string-utils.d.ts +4 -0
  115. package/dist/shared/string-utils.js +6 -0
  116. package/dist/shared/type-guards.d.ts +17 -0
  117. package/dist/shared/type-guards.js +26 -0
  118. package/dist/shared/workspace-utils.d.ts +0 -4
  119. package/dist/shared/workspace-utils.js +0 -4
  120. package/dist/{sync → shared}/xfg-template.d.ts +3 -2
  121. package/dist/{sync → shared}/xfg-template.js +13 -54
  122. package/dist/sync/auth-options-builder.d.ts +4 -5
  123. package/dist/sync/auth-options-builder.js +15 -26
  124. package/dist/sync/branch-manager.d.ts +5 -0
  125. package/dist/sync/branch-manager.js +12 -10
  126. package/dist/sync/commit-push-manager.d.ts +1 -1
  127. package/dist/sync/commit-push-manager.js +22 -18
  128. package/dist/sync/diff-utils.d.ts +4 -9
  129. package/dist/sync/diff-utils.js +2 -19
  130. package/dist/sync/file-sync-orchestrator.js +9 -8
  131. package/dist/sync/file-writer.d.ts +2 -1
  132. package/dist/sync/file-writer.js +3 -6
  133. package/dist/sync/index.d.ts +2 -15
  134. package/dist/sync/index.js +0 -19
  135. package/dist/sync/manifest-manager.d.ts +4 -0
  136. package/dist/sync/manifest-manager.js +5 -1
  137. package/dist/sync/manifest.d.ts +10 -41
  138. package/dist/sync/manifest.js +11 -56
  139. package/dist/sync/pr-merge-handler.d.ts +2 -6
  140. package/dist/sync/pr-merge-handler.js +6 -3
  141. package/dist/sync/repository-processor.d.ts +1 -2
  142. package/dist/sync/repository-processor.js +20 -12
  143. package/dist/sync/repository-session.js +5 -14
  144. package/dist/sync/sync-workflow.js +31 -38
  145. package/dist/sync/types.d.ts +43 -178
  146. package/dist/vcs/authenticated-git-ops.d.ts +27 -70
  147. package/dist/vcs/authenticated-git-ops.js +70 -96
  148. package/dist/vcs/azure-pr-strategy.d.ts +6 -4
  149. package/dist/vcs/azure-pr-strategy.js +34 -82
  150. package/dist/vcs/branch-utils.d.ts +6 -0
  151. package/dist/vcs/branch-utils.js +29 -0
  152. package/dist/vcs/commit-strategy-selector.d.ts +5 -0
  153. package/dist/vcs/commit-strategy-selector.js +10 -0
  154. package/dist/vcs/git-commit-strategy.js +1 -2
  155. package/dist/vcs/git-ops.d.ts +15 -59
  156. package/dist/vcs/git-ops.js +46 -110
  157. package/dist/vcs/github-app-token-manager.d.ts +0 -6
  158. package/dist/vcs/github-app-token-manager.js +5 -12
  159. package/dist/vcs/github-pr-strategy.d.ts +5 -5
  160. package/dist/vcs/github-pr-strategy.js +44 -122
  161. package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
  162. package/dist/vcs/gitlab-pr-strategy.js +39 -87
  163. package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
  164. package/dist/vcs/graphql-commit-strategy.js +31 -63
  165. package/dist/vcs/index.d.ts +3 -16
  166. package/dist/vcs/index.js +2 -33
  167. package/dist/vcs/pr-creator.d.ts +9 -9
  168. package/dist/vcs/pr-creator.js +11 -10
  169. package/dist/vcs/pr-strategy-factory.d.ts +5 -0
  170. package/dist/vcs/pr-strategy-factory.js +17 -0
  171. package/dist/vcs/pr-strategy.d.ts +13 -26
  172. package/dist/vcs/pr-strategy.js +20 -25
  173. package/dist/vcs/types.d.ts +87 -21
  174. package/package.json +2 -1
@@ -1,20 +1,22 @@
1
1
  import { resolve, join } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
- import { loadRawConfig, normalizeConfig, } from "../config/index.js";
3
+ import { loadRawConfig, normalizeConfig } from "../config/index.js";
4
4
  import { validateForSync } from "../config/validator.js";
5
5
  import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
6
- import { sanitizeBranchName, validateBranchName } from "../vcs/git-ops.js";
7
- import { hasGitHubAppCredentials, GitHubAppTokenManager, } from "../vcs/index.js";
6
+ import { sanitizeBranchName, validateBranchName } from "../vcs/branch-utils.js";
7
+ import { createTokenManager } from "../vcs/index.js";
8
8
  import { logger } from "../shared/logger.js";
9
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
10
10
  import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./types.js";
11
- import { ResultsCollector } from "./settings/results-collector.js";
11
+ import { ResultsCollector } from "./results-collector.js";
12
12
  import { buildSettingsReport } from "./settings-report-builder.js";
13
13
  import { formatSettingsReportCLI } from "../output/settings-report.js";
14
14
  import { buildSyncReport } from "./sync-report-builder.js";
15
15
  import { formatSyncReportCLI } from "../output/sync-report.js";
16
16
  import { buildLifecycleReport, formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
17
17
  import { writeUnifiedSummary } from "../output/unified-summary.js";
18
+ import { toErrorMessage } from "../shared/type-guards.js";
19
+ import { resolveGitHubToken } from "../shared/gh-api-utils.js";
18
20
  import { RepoLifecycleManager, runLifecycleCheck, toCreateRepoSettings, } from "../lifecycle/index.js";
19
21
  /**
20
22
  * Get unique file names from all repos in the config
@@ -49,9 +51,6 @@ function formatFileNames(fileNames) {
49
51
  }
50
52
  return `${fileNames.length} files`;
51
53
  }
52
- /**
53
- * Determine merge outcome from processor result
54
- */
55
54
  function determineMergeOutcome(result) {
56
55
  if (!result.success)
57
56
  return undefined;
@@ -63,311 +62,100 @@ function determineMergeOutcome(result) {
63
62
  return "auto";
64
63
  return "manual";
65
64
  }
66
- /**
67
- * Run the sync command - synchronizes files across repositories.
68
- */
69
- export async function runSync(options, deps = {}) {
70
- const { processorFactory = defaultProcessorFactory, lifecycleManager, rulesetProcessorFactory = defaultRulesetProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, labelsProcessorFactory = defaultLabelsProcessorFactory, } = deps;
71
- const configPath = resolve(options.config);
72
- if (!existsSync(configPath)) {
73
- console.error(`Config file not found: ${configPath}`);
74
- process.exit(1);
75
- }
76
- console.log(`Loading config from: ${configPath}`);
77
- if (options.dryRun) {
78
- console.log("Running in DRY RUN mode - no changes will be made\n");
79
- }
80
- const rawConfig = loadRawConfig(configPath);
81
- try {
82
- validateForSync(rawConfig);
83
- }
84
- catch (error) {
85
- console.error(error instanceof Error ? error.message : String(error));
86
- process.exit(1);
87
- }
88
- const config = normalizeConfig(rawConfig);
89
- const fileNames = getUniqueFileNames(config);
90
- let branchName;
91
- if (options.branch) {
92
- validateBranchName(options.branch);
93
- branchName = options.branch;
94
- }
95
- else {
96
- branchName = generateBranchName(fileNames);
97
- }
98
- logger.setTotal(config.repos.length);
99
- console.log(`Found ${config.repos.length} repositories to process`);
100
- console.log(`Target files: ${formatFileNames(fileNames)}`);
101
- console.log(`Branch: ${branchName}\n`);
102
- const processor = processorFactory();
103
- const lm = lifecycleManager ?? new RepoLifecycleManager(undefined, options.retries);
104
- const tokenManager = hasGitHubAppCredentials()
105
- ? new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY)
106
- : null;
107
- const reportResults = [];
108
- const lifecycleReportInputs = [];
109
- const settingsCollector = new ResultsCollector();
110
- for (let i = 0; i < config.repos.length; i++) {
111
- const repoConfig = config.repos[i];
112
- if (options.merge || options.mergeStrategy || options.deleteBranch) {
113
- repoConfig.prOptions = {
114
- ...repoConfig.prOptions,
115
- merge: options.merge ?? repoConfig.prOptions?.merge,
116
- mergeStrategy: options.mergeStrategy ?? repoConfig.prOptions?.mergeStrategy,
117
- deleteBranch: options.deleteBranch ?? repoConfig.prOptions?.deleteBranch,
118
- };
119
- }
120
- const current = i + 1;
121
- let repoInfo;
122
- try {
123
- repoInfo = parseGitUrl(repoConfig.git, {
124
- githubHosts: config.githubHosts,
125
- });
65
+ function logSettingsResult(result, label, current, repoName, settingsCollector) {
66
+ if (result.planOutput?.lines?.length) {
67
+ logger.info("");
68
+ logger.info(`${repoName} - ${label}:`);
69
+ for (const line of result.planOutput.lines) {
70
+ logger.info(line);
126
71
  }
127
- catch (error) {
128
- logger.error(current, repoConfig.git, String(error));
129
- reportResults.push({
130
- repoName: repoConfig.git,
131
- success: false,
132
- fileChanges: [],
133
- error: error instanceof Error ? error.message : String(error),
134
- });
135
- continue;
136
- }
137
- const repoName = getRepoDisplayName(repoInfo);
138
- const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
139
- // Resolve auth token for lifecycle gh commands
140
- let lifecycleToken;
141
- if (isGitHubRepo(repoInfo)) {
142
- try {
143
- lifecycleToken =
144
- (await tokenManager?.getTokenForRepo(repoInfo)) ??
145
- process.env.GH_TOKEN;
146
- }
147
- catch {
148
- lifecycleToken = process.env.GH_TOKEN;
72
+ if (result.warnings?.length) {
73
+ for (const warning of result.warnings) {
74
+ logger.warn(warning);
149
75
  }
150
76
  }
151
- // Check if repo exists, create/fork/migrate if needed
152
- try {
153
- const { outputLines, lifecycleResult } = await runLifecycleCheck(repoConfig, repoInfo, i, {
154
- dryRun: options.dryRun ?? false,
155
- resolvedWorkDir: workDir,
156
- githubHosts: config.githubHosts,
157
- token: lifecycleToken,
158
- }, lm, config.settings?.repo);
159
- for (const line of outputLines) {
160
- logger.info(line);
161
- }
162
- // Collect lifecycle result for report
163
- const createSettings = toCreateRepoSettings(config.settings?.repo);
164
- lifecycleReportInputs.push({
165
- repoName,
166
- action: lifecycleResult.action,
167
- upstream: repoConfig.upstream,
168
- source: repoConfig.source,
169
- settings: createSettings
170
- ? {
171
- visibility: createSettings.visibility,
172
- description: createSettings.description,
173
- }
174
- : undefined,
175
- });
176
- // In dry-run, skip processing repos that don't exist yet
177
- if (options.dryRun && lifecycleResult.action !== "existed") {
178
- reportResults.push({
179
- repoName,
180
- success: true,
181
- fileChanges: [],
77
+ }
78
+ else if (!result.skipped && result.success) {
79
+ logger.success(current, repoName, `${label}: ${result.message}`);
80
+ }
81
+ if (!result.success && !result.skipped) {
82
+ logger.error(current, repoName, `${label}: ${result.message}`);
83
+ settingsCollector.appendError(repoName, result.message);
84
+ }
85
+ }
86
+ async function applyRepoSettings(ctx) {
87
+ const { repoConfig, repoInfo, repoName, current, options, token, settingsCollector, rulesetProcessorFactory, repoSettingsProcessorFactory, labelsProcessorFactory, } = ctx;
88
+ if (!repoConfig.settings || !isGitHubRepo(repoInfo))
89
+ return;
90
+ const settingsDescriptors = [
91
+ {
92
+ key: "rulesets",
93
+ label: "Rulesets",
94
+ run: async () => {
95
+ const result = await rulesetProcessorFactory().process(repoConfig, repoInfo, {
96
+ dryRun: options.dryRun,
97
+ noDelete: options.noDelete,
98
+ token,
182
99
  });
183
- continue;
184
- }
185
- }
186
- catch (error) {
187
- logger.error(current, repoName, `Lifecycle error: ${error instanceof Error ? error.message : String(error)}`);
188
- reportResults.push({
189
- repoName,
190
- success: false,
191
- fileChanges: [],
192
- error: error instanceof Error ? error.message : String(error),
193
- });
100
+ if (!result.skipped) {
101
+ settingsCollector.getOrCreate(repoName).rulesetResult = result;
102
+ }
103
+ return result;
104
+ },
105
+ },
106
+ {
107
+ key: "labels",
108
+ label: "Labels",
109
+ run: async () => {
110
+ const result = await labelsProcessorFactory().process(repoConfig, repoInfo, {
111
+ dryRun: options.dryRun,
112
+ noDelete: options.noDelete,
113
+ token,
114
+ });
115
+ if (!result.skipped) {
116
+ settingsCollector.getOrCreate(repoName).labelsResult = result;
117
+ }
118
+ return result;
119
+ },
120
+ },
121
+ {
122
+ key: "repo",
123
+ label: "Repo Settings",
124
+ run: async () => {
125
+ const result = await repoSettingsProcessorFactory().process(repoConfig, repoInfo, { dryRun: options.dryRun, token });
126
+ if (!result.skipped) {
127
+ settingsCollector.getOrCreate(repoName).settingsResult = result;
128
+ }
129
+ return result;
130
+ },
131
+ },
132
+ ];
133
+ for (const desc of settingsDescriptors) {
134
+ const settingsValue = repoConfig.settings[desc.key];
135
+ if (!settingsValue || Object.keys(settingsValue).length === 0)
194
136
  continue;
195
- }
196
137
  try {
197
- logger.progress(current, repoName, "Processing...");
198
- const result = await processor.process(repoConfig, repoInfo, {
199
- branchName,
200
- workDir,
201
- configId: config.id,
202
- dryRun: options.dryRun,
203
- retries: options.retries,
204
- prTemplate: config.prTemplate,
205
- noDelete: options.noDelete,
206
- });
207
- const mergeOutcome = determineMergeOutcome(result);
208
- reportResults.push({
209
- repoName,
210
- success: result.success,
211
- fileChanges: (result.fileChanges ?? []).map((f) => ({
212
- path: f.path,
213
- action: f.action,
214
- })),
215
- prUrl: result.prUrl,
216
- mergeOutcome,
217
- error: result.success ? undefined : result.message,
218
- });
219
- if (result.skipped) {
220
- logger.skip(current, repoName, result.message);
221
- }
222
- else if (result.success) {
223
- logger.success(current, repoName, result.message);
224
- }
225
- else {
226
- logger.error(current, repoName, result.message);
227
- }
138
+ const result = await desc.run();
139
+ logSettingsResult(result, desc.label, current, repoName, settingsCollector);
228
140
  }
229
141
  catch (error) {
230
- logger.error(current, repoName, String(error));
231
- reportResults.push({
232
- repoName,
233
- success: false,
234
- fileChanges: [],
235
- error: error instanceof Error ? error.message : String(error),
236
- });
237
- }
238
- // After file sync, apply settings via API (GitHub-only — ADO and GitLab repos are skipped)
239
- if (repoConfig.settings && isGitHubRepo(repoInfo)) {
240
- const githubRepo = repoInfo;
241
- let settingsToken;
242
- try {
243
- settingsToken =
244
- (await tokenManager?.getTokenForRepo(githubRepo)) ??
245
- process.env.GH_TOKEN;
246
- }
247
- catch {
248
- settingsToken = process.env.GH_TOKEN;
249
- }
250
- // Apply rulesets
251
- if (repoConfig.settings.rulesets &&
252
- Object.keys(repoConfig.settings.rulesets).length > 0) {
253
- try {
254
- const rulesetProcessor = rulesetProcessorFactory();
255
- const rulesetResult = await rulesetProcessor.process(repoConfig, repoInfo, {
256
- dryRun: options.dryRun,
257
- noDelete: options.noDelete,
258
- token: settingsToken,
259
- });
260
- if (rulesetResult.planOutput?.lines?.length) {
261
- logger.info("");
262
- logger.info(`${repoName} - Rulesets:`);
263
- for (const line of rulesetResult.planOutput.lines) {
264
- logger.info(line);
265
- }
266
- }
267
- else if (!rulesetResult.skipped && rulesetResult.success) {
268
- logger.success(current, repoName, `Rulesets: ${rulesetResult.message}`);
269
- }
270
- if (!rulesetResult.skipped) {
271
- settingsCollector.getOrCreate(repoName).rulesetResult =
272
- rulesetResult;
273
- }
274
- if (!rulesetResult.success && !rulesetResult.skipped) {
275
- logger.error(current, repoName, `Rulesets: ${rulesetResult.message}`);
276
- settingsCollector.appendError(repoName, rulesetResult.message);
277
- }
278
- }
279
- catch (error) {
280
- logger.error(current, repoName, `Rulesets: ${String(error)}`);
281
- settingsCollector.appendError(repoName, error);
282
- }
283
- }
284
- // Apply labels
285
- if (repoConfig.settings.labels &&
286
- Object.keys(repoConfig.settings.labels).length > 0) {
287
- try {
288
- const labelsProcessor = labelsProcessorFactory();
289
- const labelsResult = await labelsProcessor.process(repoConfig, repoInfo, {
290
- dryRun: options.dryRun,
291
- noDelete: options.noDelete,
292
- token: settingsToken,
293
- });
294
- if (labelsResult.planOutput?.lines?.length) {
295
- logger.info("");
296
- logger.info(`${repoName} - Labels:`);
297
- for (const line of labelsResult.planOutput.lines) {
298
- logger.info(line);
299
- }
300
- }
301
- else if (!labelsResult.skipped && labelsResult.success) {
302
- logger.success(current, repoName, `Labels: ${labelsResult.message}`);
303
- }
304
- if (!labelsResult.skipped) {
305
- settingsCollector.getOrCreate(repoName).labelsResult = labelsResult;
306
- }
307
- if (!labelsResult.success && !labelsResult.skipped) {
308
- logger.error(current, repoName, `Labels: ${labelsResult.message}`);
309
- settingsCollector.appendError(repoName, labelsResult.message);
310
- }
311
- }
312
- catch (error) {
313
- logger.error(current, repoName, `Labels: ${String(error)}`);
314
- settingsCollector.appendError(repoName, error);
315
- }
316
- }
317
- // Apply repo settings
318
- if (repoConfig.settings.repo &&
319
- Object.keys(repoConfig.settings.repo).length > 0) {
320
- try {
321
- const repoSettingsProcessor = repoSettingsProcessorFactory();
322
- const repoSettingsResult = await repoSettingsProcessor.process(repoConfig, repoInfo, {
323
- dryRun: options.dryRun,
324
- token: settingsToken,
325
- });
326
- if (repoSettingsResult.planOutput?.lines?.length) {
327
- logger.info("");
328
- logger.info(`${repoName} - Repo Settings:`);
329
- for (const line of repoSettingsResult.planOutput.lines) {
330
- logger.info(line);
331
- }
332
- if (repoSettingsResult.warnings?.length) {
333
- for (const warning of repoSettingsResult.warnings) {
334
- logger.info(`Warning: ${warning}`);
335
- }
336
- }
337
- }
338
- else if (!repoSettingsResult.skipped &&
339
- repoSettingsResult.success) {
340
- logger.success(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
341
- }
342
- if (!repoSettingsResult.skipped) {
343
- settingsCollector.getOrCreate(repoName).settingsResult =
344
- repoSettingsResult;
345
- }
346
- if (!repoSettingsResult.success && !repoSettingsResult.skipped) {
347
- logger.error(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
348
- settingsCollector.appendError(repoName, repoSettingsResult.message);
349
- }
350
- }
351
- catch (error) {
352
- logger.error(current, repoName, `Repo settings: ${String(error)}`);
353
- settingsCollector.appendError(repoName, error);
354
- }
355
- }
142
+ logger.error(current, repoName, `${desc.label}: ${toErrorMessage(error)}`);
143
+ settingsCollector.appendError(repoName, error);
356
144
  }
357
145
  }
358
- // Build and display lifecycle report (before sync report)
146
+ }
147
+ function displayReports(reportResults, lifecycleReportInputs, settingsCollector, dryRun) {
359
148
  const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
360
149
  if (hasLifecycleChanges(lifecycleReport)) {
361
- console.log("");
150
+ logger.log("");
362
151
  for (const line of formatLifecycleReportCLI(lifecycleReport)) {
363
- console.log(line);
152
+ logger.log(line);
364
153
  }
365
154
  }
366
- // Build and display sync report
367
155
  const report = buildSyncReport(reportResults);
368
- console.log("");
156
+ logger.log("");
369
157
  for (const line of formatSyncReportCLI(report)) {
370
- console.log(line);
158
+ logger.log(line);
371
159
  }
372
160
  // Build and display settings report (if any settings were processed)
373
161
  const settingsResults = settingsCollector.getAll();
@@ -376,9 +164,9 @@ export async function runSync(options, deps = {}) {
376
164
  settingsReport = buildSettingsReport(settingsResults);
377
165
  const settingsLines = formatSettingsReportCLI(settingsReport);
378
166
  if (settingsLines.length > 0) {
379
- console.log("");
167
+ logger.log("");
380
168
  for (const line of settingsLines) {
381
- console.log(line);
169
+ logger.log(line);
382
170
  }
383
171
  }
384
172
  }
@@ -387,12 +175,218 @@ export async function runSync(options, deps = {}) {
387
175
  lifecycle: lifecycleReport,
388
176
  sync: report,
389
177
  settings: settingsReport,
390
- dryRun: options.dryRun ?? false,
178
+ dryRun,
391
179
  });
392
- // Exit with error if any failures (file sync or settings)
393
- const hasErrors = reportResults.some((r) => r.error);
180
+ }
181
+ /**
182
+ * Process a single repository: resolve URL, run lifecycle check, sync files, apply settings.
183
+ * Pushes results into ctx.reportResults, ctx.lifecycleReportInputs, and ctx.settingsCollector.
184
+ */
185
+ async function processSingleRepo(repoConfig, index, ctx) {
186
+ const { config, options } = ctx;
187
+ const current = index + 1;
188
+ // Apply CLI-level PR option overrides
189
+ if (options.merge || options.mergeStrategy || options.deleteBranch) {
190
+ repoConfig.prOptions = {
191
+ ...repoConfig.prOptions,
192
+ merge: options.merge ?? repoConfig.prOptions?.merge,
193
+ mergeStrategy: options.mergeStrategy ?? repoConfig.prOptions?.mergeStrategy,
194
+ deleteBranch: options.deleteBranch ?? repoConfig.prOptions?.deleteBranch,
195
+ };
196
+ }
197
+ const mergeMode = repoConfig.prOptions?.merge ?? "auto";
198
+ if (mergeMode === "direct" && repoConfig.prOptions?.mergeStrategy) {
199
+ logger.warn(`mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode for ${repoConfig.git}`);
200
+ }
201
+ let repoInfo;
202
+ try {
203
+ repoInfo = parseGitUrl(repoConfig.git, {
204
+ githubHosts: config.githubHosts,
205
+ });
206
+ }
207
+ catch (error) {
208
+ logger.error(current, repoConfig.git, toErrorMessage(error));
209
+ ctx.reportResults.push({
210
+ repoName: repoConfig.git,
211
+ success: false,
212
+ fileChanges: [],
213
+ error: toErrorMessage(error),
214
+ });
215
+ return;
216
+ }
217
+ const repoName = getRepoDisplayName(repoInfo);
218
+ const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(index)));
219
+ const repoToken = isGitHubRepo(repoInfo)
220
+ ? (await resolveGitHubToken(repoInfo, ctx.tokenManager, repoName, logger, process.env.GH_TOKEN)).token
221
+ : undefined;
222
+ const skipFileSync = await runLifecyclePhase(repoConfig, repoInfo, repoName, index, workDir, repoToken, ctx);
223
+ if (skipFileSync)
224
+ return;
225
+ // Sync files via processor
226
+ await runFileSyncPhase(repoConfig, repoInfo, repoName, current, workDir, repoToken, ctx);
227
+ // Apply settings via API (GitHub-only — ADO and GitLab repos are skipped)
228
+ await applyRepoSettings({
229
+ repoConfig,
230
+ repoInfo,
231
+ repoName,
232
+ current,
233
+ options,
234
+ token: repoToken,
235
+ settingsCollector: ctx.settingsCollector,
236
+ rulesetProcessorFactory: ctx.rulesetProcessorFactory,
237
+ repoSettingsProcessorFactory: ctx.repoSettingsProcessorFactory,
238
+ labelsProcessorFactory: ctx.labelsProcessorFactory,
239
+ });
240
+ }
241
+ /**
242
+ * Run lifecycle check (repo existence, creation, forking).
243
+ * Returns true if the main loop should skip file sync for this repo.
244
+ */
245
+ async function runLifecyclePhase(repoConfig, repoInfo, repoName, index, workDir, lifecycleToken, ctx) {
246
+ const current = index + 1;
247
+ try {
248
+ const { outputLines, lifecycleResult } = await runLifecycleCheck(repoConfig, repoInfo, index, {
249
+ dryRun: ctx.options.dryRun ?? false,
250
+ resolvedWorkDir: workDir,
251
+ githubHosts: ctx.config.githubHosts,
252
+ token: lifecycleToken,
253
+ }, ctx.lifecycleManager, ctx.config.settings?.repo);
254
+ for (const line of outputLines) {
255
+ logger.info(line);
256
+ }
257
+ const createSettings = toCreateRepoSettings(ctx.config.settings?.repo);
258
+ ctx.lifecycleReportInputs.push({
259
+ repoName,
260
+ action: lifecycleResult.action,
261
+ upstream: repoConfig.upstream,
262
+ source: repoConfig.source,
263
+ settings: createSettings
264
+ ? {
265
+ visibility: createSettings.visibility,
266
+ description: createSettings.description,
267
+ }
268
+ : undefined,
269
+ });
270
+ // In dry-run, skip processing repos that don't exist yet
271
+ if (ctx.options.dryRun && lifecycleResult.action !== "existed") {
272
+ ctx.reportResults.push({
273
+ repoName,
274
+ success: true,
275
+ fileChanges: [],
276
+ });
277
+ return true;
278
+ }
279
+ return false;
280
+ }
281
+ catch (error) {
282
+ logger.error(current, repoName, `Lifecycle error: ${toErrorMessage(error)}`);
283
+ ctx.reportResults.push({
284
+ repoName,
285
+ success: false,
286
+ fileChanges: [],
287
+ error: toErrorMessage(error),
288
+ });
289
+ return true;
290
+ }
291
+ }
292
+ /**
293
+ * Run the file sync processor for a single repo and collect results.
294
+ */
295
+ async function runFileSyncPhase(repoConfig, repoInfo, repoName, current, workDir, token, ctx) {
296
+ try {
297
+ logger.progress(current, repoName, "Processing...");
298
+ const result = await ctx.processor.process(repoConfig, repoInfo, {
299
+ branchName: ctx.branchName,
300
+ workDir,
301
+ configId: ctx.config.id,
302
+ dryRun: ctx.options.dryRun,
303
+ retries: ctx.options.retries,
304
+ prTemplate: ctx.config.prTemplate,
305
+ noDelete: ctx.options.noDelete,
306
+ token,
307
+ isGraphQLCommitMode: isGitHubRepo(repoInfo) && ctx.tokenManager !== null,
308
+ });
309
+ const mergeOutcome = determineMergeOutcome(result);
310
+ ctx.reportResults.push({
311
+ repoName,
312
+ success: result.success,
313
+ fileChanges: (result.fileChanges ?? []).map((f) => ({
314
+ path: f.path,
315
+ action: f.action,
316
+ })),
317
+ prUrl: result.prUrl,
318
+ mergeOutcome,
319
+ error: result.success ? undefined : result.message,
320
+ });
321
+ if (result.skipped) {
322
+ logger.skip(current, repoName, result.message);
323
+ }
324
+ else if (result.success) {
325
+ logger.success(current, repoName, result.message);
326
+ }
327
+ else {
328
+ logger.error(current, repoName, result.message);
329
+ }
330
+ }
331
+ catch (error) {
332
+ logger.error(current, repoName, toErrorMessage(error));
333
+ ctx.reportResults.push({
334
+ repoName,
335
+ success: false,
336
+ fileChanges: [],
337
+ error: toErrorMessage(error),
338
+ });
339
+ }
340
+ }
341
+ export async function runSync(options, deps = {}) {
342
+ const { processorFactory = defaultProcessorFactory, lifecycleManager, rulesetProcessorFactory = defaultRulesetProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, labelsProcessorFactory = defaultLabelsProcessorFactory, } = deps;
343
+ const configPath = resolve(options.config);
344
+ if (!existsSync(configPath)) {
345
+ throw new Error(`Config file not found: ${configPath}`);
346
+ }
347
+ logger.log(`Loading config from: ${configPath}`);
348
+ if (options.dryRun) {
349
+ logger.log("Running in DRY RUN mode - no changes will be made\n");
350
+ }
351
+ const rawConfig = loadRawConfig(configPath);
352
+ validateForSync(rawConfig);
353
+ const config = normalizeConfig(rawConfig);
354
+ const fileNames = getUniqueFileNames(config);
355
+ let branchName;
356
+ if (options.branch) {
357
+ validateBranchName(options.branch);
358
+ branchName = options.branch;
359
+ }
360
+ else {
361
+ branchName = generateBranchName(fileNames);
362
+ }
363
+ logger.setTotal(config.repos.length);
364
+ logger.log(`Found ${config.repos.length} repositories to process`);
365
+ logger.log(`Target files: ${formatFileNames(fileNames)}`);
366
+ logger.log(`Branch: ${branchName}\n`);
367
+ const ctx = {
368
+ config,
369
+ options,
370
+ branchName,
371
+ processor: processorFactory(),
372
+ lifecycleManager: lifecycleManager ?? new RepoLifecycleManager(undefined, options.retries),
373
+ tokenManager: createTokenManager(),
374
+ reportResults: [],
375
+ lifecycleReportInputs: [],
376
+ settingsCollector: new ResultsCollector(),
377
+ rulesetProcessorFactory,
378
+ repoSettingsProcessorFactory,
379
+ labelsProcessorFactory,
380
+ };
381
+ for (let i = 0; i < config.repos.length; i++) {
382
+ await processSingleRepo(config.repos[i], i, ctx);
383
+ }
384
+ displayReports(ctx.reportResults, ctx.lifecycleReportInputs, ctx.settingsCollector, options.dryRun ?? false);
385
+ // Propagate failures to caller (CLI entry handles process.exit)
386
+ const settingsResults = ctx.settingsCollector.getAll();
387
+ const hasErrors = ctx.reportResults.some((r) => r.error);
394
388
  const hasSettingsErrors = settingsResults.some((r) => r.error);
395
389
  if (hasErrors || hasSettingsErrors) {
396
- process.exit(1);
390
+ throw new Error("One or more repositories had errors during sync");
397
391
  }
398
392
  }