@aspruyt/xfg 3.8.2 → 3.9.1

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 (37) hide show
  1. package/dist/cli/settings-command.d.ts +2 -1
  2. package/dist/cli/settings-command.js +82 -7
  3. package/dist/cli/sync-command.d.ts +2 -1
  4. package/dist/cli/sync-command.js +80 -5
  5. package/dist/config/normalizer.js +2 -0
  6. package/dist/config/types.d.ts +9 -0
  7. package/dist/config/validator.js +62 -0
  8. package/dist/lifecycle/ado-migration-source.d.ts +15 -0
  9. package/dist/lifecycle/ado-migration-source.js +37 -0
  10. package/dist/lifecycle/github-lifecycle-provider.d.ts +56 -0
  11. package/dist/lifecycle/github-lifecycle-provider.js +314 -0
  12. package/dist/lifecycle/index.d.ts +7 -0
  13. package/dist/lifecycle/index.js +6 -0
  14. package/dist/lifecycle/lifecycle-formatter.d.ts +14 -0
  15. package/dist/lifecycle/lifecycle-formatter.js +34 -0
  16. package/dist/lifecycle/lifecycle-helpers.d.ts +27 -0
  17. package/dist/lifecycle/lifecycle-helpers.js +47 -0
  18. package/dist/lifecycle/repo-lifecycle-factory.d.ts +14 -0
  19. package/dist/lifecycle/repo-lifecycle-factory.js +57 -0
  20. package/dist/lifecycle/repo-lifecycle-manager.d.ts +20 -0
  21. package/dist/lifecycle/repo-lifecycle-manager.js +139 -0
  22. package/dist/lifecycle/types.d.ts +104 -0
  23. package/dist/lifecycle/types.js +1 -0
  24. package/dist/output/lifecycle-report.d.ts +37 -0
  25. package/dist/output/lifecycle-report.js +146 -0
  26. package/dist/output/settings-report.js +26 -26
  27. package/dist/output/sync-report.js +5 -5
  28. package/dist/output/unified-summary.d.ts +4 -0
  29. package/dist/output/unified-summary.js +147 -0
  30. package/dist/settings/repo-settings/diff.js +1 -0
  31. package/dist/settings/repo-settings/github-repo-settings-strategy.js +1 -0
  32. package/dist/shared/logger.d.ts +2 -0
  33. package/dist/shared/logger.js +5 -0
  34. package/dist/sync/manifest-strategy.js +3 -1
  35. package/dist/vcs/authenticated-git-ops.js +1 -1
  36. package/dist/vcs/git-ops.js +1 -1
  37. package/package.json +4 -2
@@ -1,5 +1,6 @@
1
1
  import { SharedOptions } from "./sync-command.js";
2
2
  import { ProcessorFactory, RulesetProcessorFactory, RepoSettingsProcessorFactory } from "./types.js";
3
+ import { type IRepoLifecycleManager } from "../lifecycle/index.js";
3
4
  /**
4
5
  * Options for the settings command.
5
6
  */
@@ -7,4 +8,4 @@ export type SettingsOptions = SharedOptions;
7
8
  /**
8
9
  * Run the settings command - manages GitHub Rulesets and repo settings.
9
10
  */
10
- export declare function runSettings(options: SettingsOptions, processorFactory?: RulesetProcessorFactory, repoProcessorFactory?: ProcessorFactory, repoSettingsProcessorFactory?: RepoSettingsProcessorFactory): Promise<void>;
11
+ export declare function runSettings(options: SettingsOptions, processorFactory?: RulesetProcessorFactory, repoProcessorFactory?: ProcessorFactory, repoSettingsProcessorFactory?: RepoSettingsProcessorFactory, lifecycleManager?: IRepoLifecycleManager): Promise<void>;
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import { loadRawConfig, normalizeConfig } from "../config/index.js";
5
5
  import { validateForSettings } from "../config/validator.js";
6
6
  import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
7
+ import { hasGitHubAppCredentials, GitHubAppTokenManager, } from "../vcs/index.js";
7
8
  import { logger } from "../shared/logger.js";
8
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
9
10
  import { buildErrorResult } from "../output/summary-utils.js";
@@ -11,6 +12,7 @@ import { getManagedRulesets } from "../sync/manifest.js";
11
12
  import { formatSettingsReportCLI, writeSettingsReportSummary, } from "../output/settings-report.js";
12
13
  import { buildSettingsReport, } from "./settings-report-builder.js";
13
14
  import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, } from "./types.js";
15
+ import { RepoLifecycleManager, runLifecycleCheck, } from "../lifecycle/index.js";
14
16
  /**
15
17
  * Collects processing results for the SettingsReport.
16
18
  * Provides a centralized way to track results across rulesets and repo settings.
@@ -39,12 +41,75 @@ class ResultsCollector {
39
41
  return this.results;
40
42
  }
41
43
  }
44
+ /**
45
+ * Run lifecycle checks for all unique repos before processing.
46
+ * Returns a Set of git URLs to skip (lifecycle errors or repos that would be created in dry-run).
47
+ */
48
+ async function runLifecycleChecks(allRepos, config, options, lifecycleManager, results, collector, tokenManager) {
49
+ const checked = new Set();
50
+ const skippedRepos = new Set();
51
+ for (let i = 0; i < allRepos.length; i++) {
52
+ const repoConfig = allRepos[i];
53
+ if (checked.has(repoConfig.git)) {
54
+ continue;
55
+ }
56
+ checked.add(repoConfig.git);
57
+ let repoInfo;
58
+ try {
59
+ repoInfo = parseGitUrl(repoConfig.git, {
60
+ githubHosts: config.githubHosts,
61
+ });
62
+ }
63
+ catch {
64
+ // URL parsing errors are handled in individual processors
65
+ continue;
66
+ }
67
+ const repoName = getRepoDisplayName(repoInfo);
68
+ // Resolve auth token for lifecycle gh commands
69
+ let lifecycleToken;
70
+ if (isGitHubRepo(repoInfo)) {
71
+ try {
72
+ lifecycleToken =
73
+ (await tokenManager?.getTokenForRepo(repoInfo)) ??
74
+ process.env.GH_TOKEN;
75
+ }
76
+ catch {
77
+ lifecycleToken = process.env.GH_TOKEN;
78
+ }
79
+ }
80
+ try {
81
+ const { outputLines, lifecycleResult } = await runLifecycleCheck(repoConfig, repoInfo, i, {
82
+ dryRun: options.dryRun ?? false,
83
+ workDir: options.workDir,
84
+ githubHosts: config.githubHosts,
85
+ token: lifecycleToken,
86
+ }, lifecycleManager, config.settings?.repo);
87
+ for (const line of outputLines) {
88
+ logger.info(line);
89
+ }
90
+ // In dry-run, skip processing repos that don't exist yet
91
+ if (options.dryRun && lifecycleResult.action !== "existed") {
92
+ skippedRepos.add(repoConfig.git);
93
+ }
94
+ }
95
+ catch (error) {
96
+ logger.error(i + 1, repoName, `Lifecycle error: ${error instanceof Error ? error.message : String(error)}`);
97
+ results.push(buildErrorResult(repoName, error));
98
+ collector.appendError(repoName, error);
99
+ skippedRepos.add(repoConfig.git);
100
+ }
101
+ }
102
+ return skippedRepos;
103
+ }
42
104
  /**
43
105
  * Process rulesets for all configured repositories.
44
106
  */
45
- async function processRulesets(repos, config, options, processor, repoProcessor, results, collector) {
107
+ async function processRulesets(repos, config, options, processor, repoProcessor, results, collector, lifecycleSkipped) {
46
108
  for (let i = 0; i < repos.length; i++) {
47
109
  const repoConfig = repos[i];
110
+ if (lifecycleSkipped.has(repoConfig.git)) {
111
+ continue;
112
+ }
48
113
  let repoInfo;
49
114
  try {
50
115
  repoInfo = parseGitUrl(repoConfig.git, {
@@ -126,7 +191,7 @@ async function processRulesets(repos, config, options, processor, repoProcessor,
126
191
  /**
127
192
  * Process repo settings for all configured repositories.
128
193
  */
129
- async function processRepoSettings(repos, config, options, processorFactory, results, collector) {
194
+ async function processRepoSettings(repos, config, options, processorFactory, results, collector, lifecycleSkipped) {
130
195
  if (repos.length === 0) {
131
196
  return;
132
197
  }
@@ -134,6 +199,9 @@ async function processRepoSettings(repos, config, options, processorFactory, res
134
199
  console.log(`\nProcessing repo settings for ${repos.length} repositories\n`);
135
200
  for (let i = 0; i < repos.length; i++) {
136
201
  const repoConfig = repos[i];
202
+ if (lifecycleSkipped.has(repoConfig.git)) {
203
+ continue;
204
+ }
137
205
  let repoInfo;
138
206
  try {
139
207
  repoInfo = parseGitUrl(repoConfig.git, {
@@ -141,7 +209,7 @@ async function processRepoSettings(repos, config, options, processorFactory, res
141
209
  });
142
210
  }
143
211
  catch (error) {
144
- console.error(`Failed to parse ${repoConfig.git}: ${error}`);
212
+ logger.error(i + 1, repoConfig.git, String(error));
145
213
  collector.appendError(repoConfig.git, error);
146
214
  continue;
147
215
  }
@@ -190,7 +258,7 @@ async function processRepoSettings(repos, config, options, processorFactory, res
190
258
  }
191
259
  }
192
260
  catch (error) {
193
- console.error(` ✗ ${repoName}: ${error}`);
261
+ logger.error(i + 1, repoName, String(error));
194
262
  collector.appendError(repoName, error);
195
263
  }
196
264
  }
@@ -198,7 +266,7 @@ async function processRepoSettings(repos, config, options, processorFactory, res
198
266
  /**
199
267
  * Run the settings command - manages GitHub Rulesets and repo settings.
200
268
  */
201
- export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory) {
269
+ export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, lifecycleManager) {
202
270
  const configPath = resolve(options.config);
203
271
  if (!existsSync(configPath)) {
204
272
  console.error(`Config file not found: ${configPath}`);
@@ -233,10 +301,17 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
233
301
  logger.setTotal(reposWithRulesets.length + reposWithRepoSettings.length);
234
302
  const processor = processorFactory();
235
303
  const repoProcessor = repoProcessorFactory();
304
+ const lm = lifecycleManager ?? new RepoLifecycleManager(undefined, options.retries);
305
+ const tokenManager = hasGitHubAppCredentials()
306
+ ? new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY)
307
+ : null;
236
308
  const results = [];
237
309
  const collector = new ResultsCollector();
238
- await processRulesets(reposWithRulesets, config, options, processor, repoProcessor, results, collector);
239
- await processRepoSettings(reposWithRepoSettings, config, options, repoSettingsProcessorFactory, results, collector);
310
+ // Pre-check lifecycle for all unique repos before processing
311
+ const allRepos = [...reposWithRulesets, ...reposWithRepoSettings];
312
+ const lifecycleSkipped = await runLifecycleChecks(allRepos, config, options, lm, results, collector, tokenManager);
313
+ await processRulesets(reposWithRulesets, config, options, processor, repoProcessor, results, collector, lifecycleSkipped);
314
+ await processRepoSettings(reposWithRepoSettings, config, options, repoSettingsProcessorFactory, results, collector, lifecycleSkipped);
240
315
  console.log("");
241
316
  const report = buildSettingsReport(collector.getAll());
242
317
  const lines = formatSettingsReportCLI(report);
@@ -1,5 +1,6 @@
1
1
  import { MergeMode, MergeStrategy } from "../config/index.js";
2
2
  import { ProcessorFactory } from "./types.js";
3
+ import { type IRepoLifecycleManager } from "../lifecycle/index.js";
3
4
  /**
4
5
  * Shared options common to all commands.
5
6
  */
@@ -22,4 +23,4 @@ export interface SyncOptions extends SharedOptions {
22
23
  /**
23
24
  * Run the sync command - synchronizes files across repositories.
24
25
  */
25
- export declare function runSync(options: SyncOptions, processorFactory?: ProcessorFactory): Promise<void>;
26
+ export declare function runSync(options: SyncOptions, processorFactory?: ProcessorFactory, lifecycleManager?: IRepoLifecycleManager): Promise<void>;
@@ -2,13 +2,17 @@ import { resolve, join } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import { loadRawConfig, normalizeConfig, } from "../config/index.js";
4
4
  import { validateForSync } from "../config/validator.js";
5
- import { parseGitUrl, getRepoDisplayName } from "../shared/repo-detector.js";
5
+ import { parseGitUrl, getRepoDisplayName, isGitHubRepo, } from "../shared/repo-detector.js";
6
6
  import { sanitizeBranchName, validateBranchName } from "../vcs/git-ops.js";
7
+ import { hasGitHubAppCredentials, GitHubAppTokenManager, } from "../vcs/index.js";
7
8
  import { logger } from "../shared/logger.js";
8
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
9
10
  import { defaultProcessorFactory } from "./types.js";
10
11
  import { buildSyncReport } from "./sync-report-builder.js";
11
- import { formatSyncReportCLI, writeSyncReportSummary, } from "../output/sync-report.js";
12
+ import { formatSyncReportCLI } from "../output/sync-report.js";
13
+ import { buildLifecycleReport, formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
14
+ import { writeUnifiedSummary } from "../output/unified-summary.js";
15
+ import { RepoLifecycleManager, runLifecycleCheck, toCreateRepoSettings, } from "../lifecycle/index.js";
12
16
  /**
13
17
  * Get unique file names from all repos in the config
14
18
  */
@@ -59,7 +63,7 @@ function determineMergeOutcome(result) {
59
63
  /**
60
64
  * Run the sync command - synchronizes files across repositories.
61
65
  */
62
- export async function runSync(options, processorFactory = defaultProcessorFactory) {
66
+ export async function runSync(options, processorFactory = defaultProcessorFactory, lifecycleManager) {
63
67
  const configPath = resolve(options.config);
64
68
  if (!existsSync(configPath)) {
65
69
  console.error(`Config file not found: ${configPath}`);
@@ -92,7 +96,12 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
92
96
  console.log(`Target files: ${formatFileNames(fileNames)}`);
93
97
  console.log(`Branch: ${branchName}\n`);
94
98
  const processor = processorFactory();
99
+ const lm = lifecycleManager ?? new RepoLifecycleManager(undefined, options.retries);
100
+ const tokenManager = hasGitHubAppCredentials()
101
+ ? new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY)
102
+ : null;
95
103
  const reportResults = [];
104
+ const lifecycleReportInputs = [];
96
105
  for (let i = 0; i < config.repos.length; i++) {
97
106
  const repoConfig = config.repos[i];
98
107
  if (options.merge || options.mergeStrategy || options.deleteBranch) {
@@ -122,6 +131,63 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
122
131
  }
123
132
  const repoName = getRepoDisplayName(repoInfo);
124
133
  const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
134
+ // Resolve auth token for lifecycle gh commands
135
+ let lifecycleToken;
136
+ if (isGitHubRepo(repoInfo)) {
137
+ try {
138
+ lifecycleToken =
139
+ (await tokenManager?.getTokenForRepo(repoInfo)) ??
140
+ process.env.GH_TOKEN;
141
+ }
142
+ catch {
143
+ lifecycleToken = process.env.GH_TOKEN;
144
+ }
145
+ }
146
+ // Check if repo exists, create/fork/migrate if needed
147
+ try {
148
+ const { outputLines, lifecycleResult } = await runLifecycleCheck(repoConfig, repoInfo, i, {
149
+ dryRun: options.dryRun ?? false,
150
+ resolvedWorkDir: workDir,
151
+ githubHosts: config.githubHosts,
152
+ token: lifecycleToken,
153
+ }, lm, config.settings?.repo);
154
+ for (const line of outputLines) {
155
+ logger.info(line);
156
+ }
157
+ // Collect lifecycle result for report
158
+ const createSettings = toCreateRepoSettings(config.settings?.repo);
159
+ lifecycleReportInputs.push({
160
+ repoName,
161
+ action: lifecycleResult.action,
162
+ upstream: repoConfig.upstream,
163
+ source: repoConfig.source,
164
+ settings: createSettings
165
+ ? {
166
+ visibility: createSettings.visibility,
167
+ description: createSettings.description,
168
+ }
169
+ : undefined,
170
+ });
171
+ // In dry-run, skip processing repos that don't exist yet
172
+ if (options.dryRun && lifecycleResult.action !== "existed") {
173
+ reportResults.push({
174
+ repoName,
175
+ success: true,
176
+ fileChanges: [],
177
+ });
178
+ continue;
179
+ }
180
+ }
181
+ catch (error) {
182
+ logger.error(current, repoName, `Lifecycle error: ${error instanceof Error ? error.message : String(error)}`);
183
+ reportResults.push({
184
+ repoName,
185
+ success: false,
186
+ fileChanges: [],
187
+ error: error instanceof Error ? error.message : String(error),
188
+ });
189
+ continue;
190
+ }
125
191
  try {
126
192
  logger.progress(current, repoName, "Processing...");
127
193
  const result = await processor.process(repoConfig, repoInfo, {
@@ -165,13 +231,22 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
165
231
  });
166
232
  }
167
233
  }
168
- // Build and display report
234
+ // Build and display lifecycle report (before sync report)
235
+ const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
236
+ if (hasLifecycleChanges(lifecycleReport)) {
237
+ console.log("");
238
+ for (const line of formatLifecycleReportCLI(lifecycleReport)) {
239
+ console.log(line);
240
+ }
241
+ }
242
+ // Build and display sync report
169
243
  const report = buildSyncReport(reportResults);
170
244
  console.log("");
171
245
  for (const line of formatSyncReportCLI(report)) {
172
246
  console.log(line);
173
247
  }
174
- writeSyncReportSummary(report, options.dryRun ?? false);
248
+ // Write unified summary to GITHUB_STEP_SUMMARY
249
+ writeUnifiedSummary(lifecycleReport, report, options.dryRun ?? false);
175
250
  // Exit with error if any failures
176
251
  const hasErrors = reportResults.some((r) => r.error);
177
252
  if (hasErrors) {
@@ -228,6 +228,8 @@ export function normalizeConfig(raw) {
228
228
  files,
229
229
  prOptions,
230
230
  settings,
231
+ upstream: rawRepo.upstream,
232
+ source: rawRepo.source,
231
233
  });
232
234
  }
233
235
  }
@@ -233,6 +233,7 @@ export type RepoVisibility = "public" | "private" | "internal";
233
233
  * @see https://docs.github.com/en/rest/repos/repos#update-a-repository
234
234
  */
235
235
  export interface GitHubRepoSettings {
236
+ description?: string;
236
237
  hasIssues?: boolean;
237
238
  hasProjects?: boolean;
238
239
  hasWiki?: boolean;
@@ -303,6 +304,10 @@ export interface RawRepoConfig {
303
304
  };
304
305
  prOptions?: PRMergeOptions;
305
306
  settings?: RawRepoSettings;
307
+ /** Fork upstream repo if target doesn't exist */
308
+ upstream?: string;
309
+ /** Migrate from source repo if target doesn't exist */
310
+ source?: string;
306
311
  }
307
312
  export interface RawConfig {
308
313
  id: string;
@@ -330,6 +335,10 @@ export interface RepoConfig {
330
335
  files: FileContent[];
331
336
  prOptions?: PRMergeOptions;
332
337
  settings?: RepoSettings;
338
+ /** Fork upstream repo if target doesn't exist */
339
+ upstream?: string;
340
+ /** Migrate from source repo if target doesn't exist */
341
+ source?: string;
333
342
  }
334
343
  export interface Config {
335
344
  id: string;
@@ -4,6 +4,41 @@ import { validateRuleset } from "./validators/ruleset-validator.js";
4
4
  // Pattern for valid config ID: alphanumeric, hyphens, underscores
5
5
  const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
6
6
  const CONFIG_ID_MAX_LENGTH = 64;
7
+ /**
8
+ * Check if a string looks like a valid git URL.
9
+ * Supports SSH (git@host:path) and HTTPS (https://host/path) formats.
10
+ */
11
+ function isValidGitUrl(url) {
12
+ // SSH format: git@hostname:path
13
+ if (/^git@[^:]+:.+$/.test(url)) {
14
+ return true;
15
+ }
16
+ // HTTPS format: https://hostname/path
17
+ if (/^https?:\/\/[^/]+\/.+$/.test(url)) {
18
+ return true;
19
+ }
20
+ return false;
21
+ }
22
+ /**
23
+ * Escape special regex characters in a string.
24
+ */
25
+ function escapeRegExp(str) {
26
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
+ }
28
+ /**
29
+ * Check if a git URL points to GitHub (github.com).
30
+ * Used to reject GitHub URLs as migration sources (not supported).
31
+ */
32
+ function isGitHubUrl(url, githubHosts) {
33
+ const hosts = ["github.com", ...(githubHosts ?? [])];
34
+ for (const host of hosts) {
35
+ if (url.startsWith(`git@${host}:`) ||
36
+ url.match(new RegExp(`^https?://${escapeRegExp(host)}/`))) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
7
42
  function getGitDisplayName(git) {
8
43
  if (Array.isArray(git)) {
9
44
  return git[0] || "unknown";
@@ -201,6 +236,33 @@ export function validateRawConfig(config) {
201
236
  if (Array.isArray(repo.git) && repo.git.length === 0) {
202
237
  throw new Error(`Repo at index ${i} has empty git array`);
203
238
  }
239
+ // Validate lifecycle fields (upstream/source)
240
+ if (repo.upstream !== undefined && repo.source !== undefined) {
241
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' and 'source' are mutually exclusive. ` +
242
+ `Use 'upstream' to fork, or 'source' to migrate, not both.`);
243
+ }
244
+ if (repo.upstream !== undefined) {
245
+ if (typeof repo.upstream !== "string") {
246
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a string`);
247
+ }
248
+ if (!isValidGitUrl(repo.upstream)) {
249
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'upstream' must be a valid git URL ` +
250
+ `(SSH: git@host:path or HTTPS: https://host/path)`);
251
+ }
252
+ }
253
+ if (repo.source !== undefined) {
254
+ if (typeof repo.source !== "string") {
255
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' must be a string`);
256
+ }
257
+ if (!isValidGitUrl(repo.source)) {
258
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' must be a valid git URL ` +
259
+ `(SSH: git@host:path or HTTPS: https://host/path)`);
260
+ }
261
+ if (isGitHubUrl(repo.source, config.githubHosts)) {
262
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: 'source' cannot be a GitHub URL. ` +
263
+ `Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
264
+ }
265
+ }
204
266
  // Validate per-repo file overrides
205
267
  if (repo.files) {
206
268
  if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
@@ -0,0 +1,15 @@
1
+ import { ICommandExecutor } from "../shared/command-executor.js";
2
+ import { type RepoInfo } from "../shared/repo-detector.js";
3
+ import type { IMigrationSource, LifecyclePlatform } from "./types.js";
4
+ /**
5
+ * Azure DevOps implementation of IMigrationSource.
6
+ * Uses git clone --mirror to get all refs for migration.
7
+ */
8
+ export declare class AdoMigrationSource implements IMigrationSource {
9
+ private readonly executor;
10
+ private readonly retries;
11
+ readonly platform: LifecyclePlatform;
12
+ constructor(executor?: ICommandExecutor, retries?: number);
13
+ private assertAdo;
14
+ cloneForMigration(repoInfo: RepoInfo, workDir: string): Promise<void>;
15
+ }
@@ -0,0 +1,37 @@
1
+ import { escapeShellArg } from "../shared/shell-utils.js";
2
+ import { defaultExecutor, } from "../shared/command-executor.js";
3
+ import { withRetry } from "../shared/retry-utils.js";
4
+ import { isAzureDevOpsRepo, } from "../shared/repo-detector.js";
5
+ /**
6
+ * Azure DevOps implementation of IMigrationSource.
7
+ * Uses git clone --mirror to get all refs for migration.
8
+ */
9
+ export class AdoMigrationSource {
10
+ executor;
11
+ retries;
12
+ platform = "azure-devops";
13
+ constructor(executor = defaultExecutor, retries = 3) {
14
+ this.executor = executor;
15
+ this.retries = retries;
16
+ }
17
+ assertAdo(repoInfo) {
18
+ if (!isAzureDevOpsRepo(repoInfo)) {
19
+ throw new Error(`AdoMigrationSource requires Azure DevOps repo, got: ${repoInfo.type}`);
20
+ }
21
+ }
22
+ async cloneForMigration(repoInfo, workDir) {
23
+ this.assertAdo(repoInfo);
24
+ const command = `git clone --mirror ${escapeShellArg(repoInfo.gitUrl)} ${escapeShellArg(workDir)}`;
25
+ try {
26
+ await withRetry(() => this.executor.exec(command, process.cwd()), {
27
+ retries: this.retries,
28
+ });
29
+ }
30
+ catch (error) {
31
+ const msg = error instanceof Error ? error.message : String(error);
32
+ throw new Error(`Failed to clone migration source ${repoInfo.gitUrl}: ${msg}. ` +
33
+ `Ensure you have authentication configured for Azure DevOps ` +
34
+ `(e.g., AZURE_DEVOPS_EXT_PAT or git credential helper).`);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,56 @@
1
+ import { ICommandExecutor } from "../shared/command-executor.js";
2
+ import { type RepoInfo } from "../shared/repo-detector.js";
3
+ import type { IRepoLifecycleProvider, LifecyclePlatform, CreateRepoSettings } from "./types.js";
4
+ /**
5
+ * GitHub implementation of IRepoLifecycleProvider.
6
+ * Uses gh CLI for all operations.
7
+ */
8
+ export interface GitHubLifecycleProviderOptions {
9
+ executor?: ICommandExecutor;
10
+ retries?: number;
11
+ cwd?: string;
12
+ /** Timeout in ms for waiting for fork readiness (default: 60000) */
13
+ forkReadyTimeoutMs?: number;
14
+ /** Poll interval in ms for fork readiness checks (default: 2000) */
15
+ forkPollIntervalMs?: number;
16
+ }
17
+ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
18
+ readonly platform: LifecyclePlatform;
19
+ private readonly executor;
20
+ private readonly retries;
21
+ private readonly cwd;
22
+ private readonly forkReadyTimeoutMs;
23
+ private readonly forkPollIntervalMs;
24
+ constructor(options?: GitHubLifecycleProviderOptions);
25
+ /**
26
+ * Check if a GitHub owner is an organization (vs user).
27
+ * Uses gh api to query the user/org endpoint.
28
+ */
29
+ private isOrganization;
30
+ private assertGitHub;
31
+ /**
32
+ * Build GH_TOKEN prefix for gh CLI commands.
33
+ * Returns "GH_TOKEN=<escaped_token> " when token is provided, "" otherwise.
34
+ * Token is escaped via escapeShellArg to prevent injection.
35
+ */
36
+ private buildTokenPrefix;
37
+ exists(repoInfo: RepoInfo, token?: string): Promise<boolean>;
38
+ create(repoInfo: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
39
+ fork(upstream: RepoInfo, target: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
40
+ /**
41
+ * Wait for a forked repo to become available via the GitHub API.
42
+ * GitHub forks are created asynchronously; polls exists() with a timeout.
43
+ */
44
+ private waitForForkReady;
45
+ /**
46
+ * Apply settings to an existing repo using gh repo edit.
47
+ */
48
+ private applyRepoSettings;
49
+ receiveMigration(repoInfo: RepoInfo, sourceDir: string, settings?: CreateRepoSettings, token?: string): Promise<void>;
50
+ /**
51
+ * Delete the README.md that --add-readme creates.
52
+ * This leaves the repo with a default branch established (from the initial
53
+ * commit) but no files, so xfg sync starts from a clean state.
54
+ */
55
+ private deleteReadme;
56
+ }