@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.
- package/dist/cli/settings-command.d.ts +2 -1
- package/dist/cli/settings-command.js +82 -7
- package/dist/cli/sync-command.d.ts +2 -1
- package/dist/cli/sync-command.js +80 -5
- package/dist/config/normalizer.js +2 -0
- package/dist/config/types.d.ts +9 -0
- package/dist/config/validator.js +62 -0
- package/dist/lifecycle/ado-migration-source.d.ts +15 -0
- package/dist/lifecycle/ado-migration-source.js +37 -0
- package/dist/lifecycle/github-lifecycle-provider.d.ts +56 -0
- package/dist/lifecycle/github-lifecycle-provider.js +314 -0
- package/dist/lifecycle/index.d.ts +7 -0
- package/dist/lifecycle/index.js +6 -0
- package/dist/lifecycle/lifecycle-formatter.d.ts +14 -0
- package/dist/lifecycle/lifecycle-formatter.js +34 -0
- package/dist/lifecycle/lifecycle-helpers.d.ts +27 -0
- package/dist/lifecycle/lifecycle-helpers.js +47 -0
- package/dist/lifecycle/repo-lifecycle-factory.d.ts +14 -0
- package/dist/lifecycle/repo-lifecycle-factory.js +57 -0
- package/dist/lifecycle/repo-lifecycle-manager.d.ts +20 -0
- package/dist/lifecycle/repo-lifecycle-manager.js +139 -0
- package/dist/lifecycle/types.d.ts +104 -0
- package/dist/lifecycle/types.js +1 -0
- package/dist/output/lifecycle-report.d.ts +37 -0
- package/dist/output/lifecycle-report.js +146 -0
- package/dist/output/settings-report.js +26 -26
- package/dist/output/sync-report.js +5 -5
- package/dist/output/unified-summary.d.ts +4 -0
- package/dist/output/unified-summary.js +147 -0
- package/dist/settings/repo-settings/diff.js +1 -0
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +1 -0
- package/dist/shared/logger.d.ts +2 -0
- package/dist/shared/logger.js +5 -0
- package/dist/sync/manifest-strategy.js +3 -1
- package/dist/vcs/authenticated-git-ops.js +1 -1
- package/dist/vcs/git-ops.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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>;
|
package/dist/cli/sync-command.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/validator.js
CHANGED
|
@@ -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
|
+
}
|