@aspruyt/xfg 3.8.2 → 3.9.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.
- 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 +77 -3
- 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 +152 -0
- package/dist/output/settings-report.js +36 -28
- package/dist/output/sync-report.js +15 -8
- 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
|
@@ -0,0 +1,314 @@
|
|
|
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 { isGitHubRepo, } from "../shared/repo-detector.js";
|
|
5
|
+
import { logger } from "../shared/logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* Error messages that indicate "repo not found" vs actual errors.
|
|
8
|
+
*/
|
|
9
|
+
const REPO_NOT_FOUND_PATTERNS = [
|
|
10
|
+
"Could not resolve to a Repository",
|
|
11
|
+
"Not Found",
|
|
12
|
+
"404",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Check if an error indicates repo not found (vs network/auth error).
|
|
16
|
+
*/
|
|
17
|
+
function isRepoNotFoundError(error) {
|
|
18
|
+
const message = error instanceof Error
|
|
19
|
+
? error.message + (error.stderr ?? "")
|
|
20
|
+
: String(error);
|
|
21
|
+
return REPO_NOT_FOUND_PATTERNS.some((pattern) => message.includes(pattern));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Get the hostname flag for gh commands.
|
|
25
|
+
* Returns "--hostname HOST" for GHE, empty string for github.com.
|
|
26
|
+
*/
|
|
27
|
+
function getHostnameFlag(repoInfo) {
|
|
28
|
+
if (repoInfo.host && repoInfo.host !== "github.com") {
|
|
29
|
+
return `--hostname ${escapeShellArg(repoInfo.host)}`;
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Default timeout for waiting for fork readiness (60 seconds).
|
|
35
|
+
*/
|
|
36
|
+
const FORK_READY_TIMEOUT_MS = 60_000;
|
|
37
|
+
/**
|
|
38
|
+
* Interval between fork readiness checks (2 seconds).
|
|
39
|
+
*/
|
|
40
|
+
const FORK_POLL_INTERVAL_MS = 2_000;
|
|
41
|
+
export class GitHubLifecycleProvider {
|
|
42
|
+
platform = "github";
|
|
43
|
+
executor;
|
|
44
|
+
retries;
|
|
45
|
+
cwd;
|
|
46
|
+
forkReadyTimeoutMs;
|
|
47
|
+
forkPollIntervalMs;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
const opts = options ?? {};
|
|
50
|
+
this.executor = opts.executor ?? defaultExecutor;
|
|
51
|
+
this.retries = opts.retries ?? 3;
|
|
52
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
53
|
+
this.forkReadyTimeoutMs = opts.forkReadyTimeoutMs ?? FORK_READY_TIMEOUT_MS;
|
|
54
|
+
this.forkPollIntervalMs = opts.forkPollIntervalMs ?? FORK_POLL_INTERVAL_MS;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if a GitHub owner is an organization (vs user).
|
|
58
|
+
* Uses gh api to query the user/org endpoint.
|
|
59
|
+
*/
|
|
60
|
+
async isOrganization(owner, repoInfo, token) {
|
|
61
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
62
|
+
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
63
|
+
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
64
|
+
const command = `${tokenPrefix}gh api ${hostnamePart}users/${escapeShellArg(owner)}`;
|
|
65
|
+
try {
|
|
66
|
+
const stdout = await withRetry(() => this.executor.exec(command, this.cwd), { retries: this.retries });
|
|
67
|
+
const data = JSON.parse(stdout);
|
|
68
|
+
return data.type === "Organization";
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// If we can't determine, assume it's an org (safer - uses --org flag).
|
|
72
|
+
// This may cause fork to fail with a misleading error for personal accounts.
|
|
73
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
74
|
+
logger.debug(`Could not determine if '${owner}' is an organization, defaulting to org behavior: ${errMsg}`);
|
|
75
|
+
logger.info(`Warning: Could not verify if '${owner}' is an organization or user account. ` +
|
|
76
|
+
`If fork fails, check your authentication (gh auth status) and ensure the ` +
|
|
77
|
+
`target owner is correct.`);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
assertGitHub(repoInfo) {
|
|
82
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
83
|
+
throw new Error(`GitHubLifecycleProvider requires GitHub repo, got: ${repoInfo.type}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build GH_TOKEN prefix for gh CLI commands.
|
|
88
|
+
* Returns "GH_TOKEN=<escaped_token> " when token is provided, "" otherwise.
|
|
89
|
+
* Token is escaped via escapeShellArg to prevent injection.
|
|
90
|
+
*/
|
|
91
|
+
buildTokenPrefix(token) {
|
|
92
|
+
return token ? `GH_TOKEN=${escapeShellArg(token)} ` : "";
|
|
93
|
+
}
|
|
94
|
+
async exists(repoInfo, token) {
|
|
95
|
+
this.assertGitHub(repoInfo);
|
|
96
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
97
|
+
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
98
|
+
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
99
|
+
const command = `${tokenPrefix}gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
|
|
100
|
+
try {
|
|
101
|
+
// Note: withRetry already classifies 404/not-found as permanent errors,
|
|
102
|
+
// so retries are aborted immediately for non-existent repos.
|
|
103
|
+
await withRetry(() => this.executor.exec(command, this.cwd), {
|
|
104
|
+
retries: this.retries,
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
// Distinguish "repo not found" from actual errors
|
|
110
|
+
if (isRepoNotFoundError(error)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
// Re-throw network/auth errors
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async create(repoInfo, settings, token) {
|
|
118
|
+
this.assertGitHub(repoInfo);
|
|
119
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
120
|
+
const parts = [
|
|
121
|
+
`${tokenPrefix}gh repo create`,
|
|
122
|
+
escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
|
|
123
|
+
];
|
|
124
|
+
// Visibility flag (default to private for safety)
|
|
125
|
+
if (settings?.visibility === "public") {
|
|
126
|
+
parts.push("--public");
|
|
127
|
+
}
|
|
128
|
+
else if (settings?.visibility === "internal") {
|
|
129
|
+
parts.push("--internal");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
parts.push("--private");
|
|
133
|
+
}
|
|
134
|
+
// Description
|
|
135
|
+
if (settings?.description) {
|
|
136
|
+
parts.push("--description", escapeShellArg(settings.description));
|
|
137
|
+
}
|
|
138
|
+
// Disable features if specified
|
|
139
|
+
if (settings?.hasIssues === false) {
|
|
140
|
+
parts.push("--disable-issues");
|
|
141
|
+
}
|
|
142
|
+
if (settings?.hasWiki === false) {
|
|
143
|
+
parts.push("--disable-wiki");
|
|
144
|
+
}
|
|
145
|
+
// Add --add-readme to establish the default branch via an initial commit.
|
|
146
|
+
// This avoids empty repos where HEAD doesn't resolve.
|
|
147
|
+
parts.push("--add-readme");
|
|
148
|
+
const command = parts.join(" ");
|
|
149
|
+
await withRetry(() => this.executor.exec(command, this.cwd), {
|
|
150
|
+
retries: this.retries,
|
|
151
|
+
});
|
|
152
|
+
// Delete the README so xfg sync starts from a clean state.
|
|
153
|
+
await this.deleteReadme(repoInfo, token);
|
|
154
|
+
}
|
|
155
|
+
async fork(upstream, target, settings, token) {
|
|
156
|
+
this.assertGitHub(upstream);
|
|
157
|
+
this.assertGitHub(target);
|
|
158
|
+
// Guard: cannot fork a repo to the same owner
|
|
159
|
+
if (upstream.owner.toLowerCase() === target.owner.toLowerCase()) {
|
|
160
|
+
throw new Error(`Cannot fork ${upstream.owner}/${upstream.repo} to the same owner '${target.owner}'. ` +
|
|
161
|
+
`The upstream and target owners must be different.`);
|
|
162
|
+
}
|
|
163
|
+
// Determine if target owner is an organization or user
|
|
164
|
+
const isOrg = await this.isOrganization(target.owner, target, token);
|
|
165
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
166
|
+
// Build fork command
|
|
167
|
+
// For orgs: gh repo fork <upstream> --org <target-org> --fork-name <name> --clone=false
|
|
168
|
+
// For users: gh repo fork <upstream> --fork-name <name> --clone=false
|
|
169
|
+
const parts = [
|
|
170
|
+
`${tokenPrefix}gh repo fork`,
|
|
171
|
+
escapeShellArg(`${upstream.owner}/${upstream.repo}`),
|
|
172
|
+
];
|
|
173
|
+
if (isOrg) {
|
|
174
|
+
parts.push("--org", escapeShellArg(target.owner));
|
|
175
|
+
}
|
|
176
|
+
parts.push("--fork-name", escapeShellArg(target.repo), "--clone=false");
|
|
177
|
+
const forkCommand = parts.join(" ");
|
|
178
|
+
await withRetry(() => this.executor.exec(forkCommand, this.cwd), {
|
|
179
|
+
retries: this.retries,
|
|
180
|
+
});
|
|
181
|
+
// GitHub forks are async - wait for the fork to be ready for git operations
|
|
182
|
+
await this.waitForForkReady(target, this.forkReadyTimeoutMs, this.forkPollIntervalMs, token);
|
|
183
|
+
// Apply settings after fork (visibility, description, etc.)
|
|
184
|
+
if (settings?.visibility || settings?.description) {
|
|
185
|
+
await this.applyRepoSettings(target, settings, token);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Wait for a forked repo to become available via the GitHub API.
|
|
190
|
+
* GitHub forks are created asynchronously; polls exists() with a timeout.
|
|
191
|
+
*/
|
|
192
|
+
async waitForForkReady(repoInfo, timeoutMs = FORK_READY_TIMEOUT_MS, intervalMs = FORK_POLL_INTERVAL_MS, token) {
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
try {
|
|
196
|
+
const ready = await this.exists(repoInfo, token);
|
|
197
|
+
if (ready) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Ignore transient errors during polling
|
|
203
|
+
}
|
|
204
|
+
const remaining = deadline - Date.now();
|
|
205
|
+
if (remaining <= 0)
|
|
206
|
+
break;
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)));
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Timed out waiting for fork ${repoInfo.owner}/${repoInfo.repo} to become available ` +
|
|
210
|
+
`after ${timeoutMs / 1000}s. The fork may still be processing on GitHub.`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Apply settings to an existing repo using gh repo edit.
|
|
214
|
+
*/
|
|
215
|
+
async applyRepoSettings(repoInfo, settings, token) {
|
|
216
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
217
|
+
const parts = [
|
|
218
|
+
`${tokenPrefix}gh repo edit`,
|
|
219
|
+
escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
|
|
220
|
+
];
|
|
221
|
+
if (settings.visibility) {
|
|
222
|
+
parts.push("--visibility", settings.visibility, "--accept-visibility-change-consequences");
|
|
223
|
+
}
|
|
224
|
+
if (settings.description) {
|
|
225
|
+
parts.push("--description", escapeShellArg(settings.description));
|
|
226
|
+
}
|
|
227
|
+
const command = parts.join(" ");
|
|
228
|
+
await withRetry(() => this.executor.exec(command, this.cwd), {
|
|
229
|
+
retries: this.retries,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async receiveMigration(repoInfo, sourceDir, settings, token) {
|
|
233
|
+
this.assertGitHub(repoInfo);
|
|
234
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
235
|
+
// Remove existing "origin" remote if present (e.g., from git clone --mirror).
|
|
236
|
+
// gh repo create --source --push needs to set its own origin remote.
|
|
237
|
+
try {
|
|
238
|
+
await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} remote remove origin`, this.cwd);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// No origin remote — nothing to remove
|
|
242
|
+
}
|
|
243
|
+
// Remove all non-standard refs that GitHub rejects on push.
|
|
244
|
+
// Mirror clones include ALL refs from the source, but GitHub only
|
|
245
|
+
// accepts branches (refs/heads/*) and tags (refs/tags/*).
|
|
246
|
+
// Other refs like refs/pull/* (GitHub), refs/merge-requests/* (GitLab),
|
|
247
|
+
// refs/keep-around/* etc. must be removed.
|
|
248
|
+
try {
|
|
249
|
+
const allRefs = await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} for-each-ref --format='%(refname)'`, this.cwd);
|
|
250
|
+
for (const ref of allRefs.split("\n").filter((r) => r.trim())) {
|
|
251
|
+
const trimmed = ref.trim();
|
|
252
|
+
if (!trimmed.startsWith("refs/heads/") &&
|
|
253
|
+
!trimmed.startsWith("refs/tags/")) {
|
|
254
|
+
await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} update-ref -d ${escapeShellArg(trimmed)}`, this.cwd);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// No refs to remove — ignore
|
|
260
|
+
}
|
|
261
|
+
// Use gh repo create --source --push to create and mirror in one step.
|
|
262
|
+
// For bare repos (from git clone --mirror), --push mirrors all refs.
|
|
263
|
+
// This uses gh CLI authentication, avoiding raw git auth issues with GHE.
|
|
264
|
+
const parts = [
|
|
265
|
+
`${tokenPrefix}gh repo create`,
|
|
266
|
+
escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
|
|
267
|
+
"--source",
|
|
268
|
+
escapeShellArg(sourceDir),
|
|
269
|
+
"--push",
|
|
270
|
+
];
|
|
271
|
+
// Visibility flag (default to private for safety)
|
|
272
|
+
if (settings?.visibility === "public") {
|
|
273
|
+
parts.push("--public");
|
|
274
|
+
}
|
|
275
|
+
else if (settings?.visibility === "internal") {
|
|
276
|
+
parts.push("--internal");
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
parts.push("--private");
|
|
280
|
+
}
|
|
281
|
+
// Description
|
|
282
|
+
if (settings?.description) {
|
|
283
|
+
parts.push("--description", escapeShellArg(settings.description));
|
|
284
|
+
}
|
|
285
|
+
// Disable features if specified
|
|
286
|
+
if (settings?.hasIssues === false) {
|
|
287
|
+
parts.push("--disable-issues");
|
|
288
|
+
}
|
|
289
|
+
if (settings?.hasWiki === false) {
|
|
290
|
+
parts.push("--disable-wiki");
|
|
291
|
+
}
|
|
292
|
+
const command = parts.join(" ");
|
|
293
|
+
await withRetry(() => this.executor.exec(command, this.cwd), {
|
|
294
|
+
retries: this.retries,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Delete the README.md that --add-readme creates.
|
|
299
|
+
* This leaves the repo with a default branch established (from the initial
|
|
300
|
+
* commit) but no files, so xfg sync starts from a clean state.
|
|
301
|
+
*/
|
|
302
|
+
async deleteReadme(repoInfo, token) {
|
|
303
|
+
const tokenPrefix = this.buildTokenPrefix(token);
|
|
304
|
+
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
305
|
+
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
306
|
+
const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
|
|
307
|
+
// Get the SHA of the README.md created by --add-readme
|
|
308
|
+
const fileInfo = await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md --jq '.sha'`, this.cwd), { retries: this.retries });
|
|
309
|
+
const sha = fileInfo.trim();
|
|
310
|
+
// Delete the README.md to leave the repo clean
|
|
311
|
+
await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md ` +
|
|
312
|
+
`--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd), { retries: this.retries });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { LifecyclePlatform, LifecycleResult, LifecycleOptions, CreateRepoSettings, IRepoLifecycleProvider, IMigrationSource, IRepoLifecycleFactory, IRepoLifecycleManager, } from "./types.js";
|
|
2
|
+
export { GitHubLifecycleProvider, type GitHubLifecycleProviderOptions, } from "./github-lifecycle-provider.js";
|
|
3
|
+
export { AdoMigrationSource } from "./ado-migration-source.js";
|
|
4
|
+
export { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
|
|
5
|
+
export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
|
|
6
|
+
export { formatLifecycleAction, type FormatOptions, } from "./lifecycle-formatter.js";
|
|
7
|
+
export { runLifecycleCheck, toCreateRepoSettings, type LifecycleCheckOptions, type LifecycleCheckResult, } from "./lifecycle-helpers.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { GitHubLifecycleProvider, } from "./github-lifecycle-provider.js";
|
|
2
|
+
export { AdoMigrationSource } from "./ado-migration-source.js";
|
|
3
|
+
export { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
|
|
4
|
+
export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
|
|
5
|
+
export { formatLifecycleAction, } from "./lifecycle-formatter.js";
|
|
6
|
+
export { runLifecycleCheck, toCreateRepoSettings, } from "./lifecycle-helpers.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LifecycleResult } from "./types.js";
|
|
2
|
+
export interface FormatOptions {
|
|
3
|
+
upstream?: string;
|
|
4
|
+
source?: string;
|
|
5
|
+
settings?: {
|
|
6
|
+
visibility?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Format lifecycle action for output (used in both dry-run and real execution).
|
|
12
|
+
* Returns empty array if action is "existed" (no output needed).
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatLifecycleAction(result: LifecycleResult, options?: FormatOptions): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getRepoDisplayName } from "../shared/repo-detector.js";
|
|
3
|
+
/**
|
|
4
|
+
* Format lifecycle action for output (used in both dry-run and real execution).
|
|
5
|
+
* Returns empty array if action is "existed" (no output needed).
|
|
6
|
+
*/
|
|
7
|
+
export function formatLifecycleAction(result, options) {
|
|
8
|
+
if (result.action === "existed") {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const lines = [];
|
|
12
|
+
const repoDisplay = getRepoDisplayName(result.repoInfo);
|
|
13
|
+
switch (result.action) {
|
|
14
|
+
case "created":
|
|
15
|
+
lines.push(chalk.green(`+ CREATE ${repoDisplay}`));
|
|
16
|
+
break;
|
|
17
|
+
case "forked":
|
|
18
|
+
lines.push(chalk.green(`+ FORK ${options?.upstream ?? "upstream"} -> ${repoDisplay}`));
|
|
19
|
+
break;
|
|
20
|
+
case "migrated":
|
|
21
|
+
lines.push(chalk.green(`+ MIGRATE ${options?.source ?? "source"} -> ${repoDisplay}`));
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
// Add settings details if provided
|
|
25
|
+
if (options?.settings) {
|
|
26
|
+
if (options.settings.visibility) {
|
|
27
|
+
lines.push(` visibility: ${options.settings.visibility}`);
|
|
28
|
+
}
|
|
29
|
+
if (options.settings.description) {
|
|
30
|
+
lines.push(` description: "${options.settings.description}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RepoConfig, GitHubRepoSettings } from "../config/types.js";
|
|
2
|
+
import type { RepoInfo } from "../shared/repo-detector.js";
|
|
3
|
+
import type { IRepoLifecycleManager, CreateRepoSettings, LifecycleResult } from "./types.js";
|
|
4
|
+
export interface LifecycleCheckOptions {
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
/** Base work directory (combined with repoIndex to compute full path). */
|
|
7
|
+
workDir?: string;
|
|
8
|
+
githubHosts?: string[];
|
|
9
|
+
/** Pre-resolved work directory. If provided, used directly instead of computing from workDir + repoIndex. */
|
|
10
|
+
resolvedWorkDir?: string;
|
|
11
|
+
/** Auth token (GitHub App installation token or PAT) for gh CLI commands */
|
|
12
|
+
token?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build CreateRepoSettings from GitHubRepoSettings.
|
|
16
|
+
* Extracts only the fields relevant for repo creation.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toCreateRepoSettings(repo: GitHubRepoSettings | undefined): CreateRepoSettings | undefined;
|
|
19
|
+
export interface LifecycleCheckResult {
|
|
20
|
+
lifecycleResult: LifecycleResult;
|
|
21
|
+
outputLines: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run lifecycle check for a single repo.
|
|
25
|
+
* Returns the lifecycle result and formatted output lines.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runLifecycleCheck(repoConfig: RepoConfig, repoInfo: RepoInfo, repoIndex: number, options: LifecycleCheckOptions, lifecycleManager: IRepoLifecycleManager, repoSettings?: GitHubRepoSettings): Promise<LifecycleCheckResult>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
import { generateWorkspaceName } from "../shared/workspace-utils.js";
|
|
3
|
+
import { formatLifecycleAction } from "./lifecycle-formatter.js";
|
|
4
|
+
/**
|
|
5
|
+
* Build CreateRepoSettings from GitHubRepoSettings.
|
|
6
|
+
* Extracts only the fields relevant for repo creation.
|
|
7
|
+
*/
|
|
8
|
+
export function toCreateRepoSettings(repo) {
|
|
9
|
+
if (!repo)
|
|
10
|
+
return undefined;
|
|
11
|
+
const result = {};
|
|
12
|
+
if (repo.visibility !== undefined)
|
|
13
|
+
result.visibility = repo.visibility;
|
|
14
|
+
if (repo.description !== undefined)
|
|
15
|
+
result.description = repo.description;
|
|
16
|
+
if (repo.hasIssues !== undefined)
|
|
17
|
+
result.hasIssues = repo.hasIssues;
|
|
18
|
+
if (repo.hasWiki !== undefined)
|
|
19
|
+
result.hasWiki = repo.hasWiki;
|
|
20
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Run lifecycle check for a single repo.
|
|
24
|
+
* Returns the lifecycle result and formatted output lines.
|
|
25
|
+
*/
|
|
26
|
+
export async function runLifecycleCheck(repoConfig, repoInfo, repoIndex, options, lifecycleManager, repoSettings) {
|
|
27
|
+
const workDir = options.resolvedWorkDir ??
|
|
28
|
+
resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(repoIndex)));
|
|
29
|
+
const createSettings = toCreateRepoSettings(repoSettings);
|
|
30
|
+
const lifecycleResult = await lifecycleManager.ensureRepo(repoConfig, repoInfo, {
|
|
31
|
+
dryRun: options.dryRun,
|
|
32
|
+
workDir,
|
|
33
|
+
githubHosts: options.githubHosts,
|
|
34
|
+
token: options.token,
|
|
35
|
+
}, createSettings);
|
|
36
|
+
const outputLines = formatLifecycleAction(lifecycleResult, {
|
|
37
|
+
upstream: repoConfig.upstream,
|
|
38
|
+
source: repoConfig.source,
|
|
39
|
+
settings: createSettings
|
|
40
|
+
? {
|
|
41
|
+
visibility: createSettings.visibility,
|
|
42
|
+
description: createSettings.description,
|
|
43
|
+
}
|
|
44
|
+
: undefined,
|
|
45
|
+
});
|
|
46
|
+
return { lifecycleResult, outputLines };
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ICommandExecutor } from "../shared/command-executor.js";
|
|
2
|
+
import type { IRepoLifecycleFactory, IRepoLifecycleProvider, IMigrationSource, LifecyclePlatform } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Factory for creating lifecycle providers and migration sources.
|
|
5
|
+
*/
|
|
6
|
+
export declare class RepoLifecycleFactory implements IRepoLifecycleFactory {
|
|
7
|
+
private readonly providers;
|
|
8
|
+
private readonly sources;
|
|
9
|
+
private readonly executor;
|
|
10
|
+
private readonly retries;
|
|
11
|
+
constructor(executor?: ICommandExecutor, retries?: number);
|
|
12
|
+
getProvider(platform: LifecyclePlatform): IRepoLifecycleProvider;
|
|
13
|
+
getMigrationSource(platform: LifecyclePlatform): IMigrationSource;
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defaultExecutor, } from "../shared/command-executor.js";
|
|
2
|
+
import { GitHubLifecycleProvider } from "./github-lifecycle-provider.js";
|
|
3
|
+
import { AdoMigrationSource } from "./ado-migration-source.js";
|
|
4
|
+
/**
|
|
5
|
+
* Factory for creating lifecycle providers and migration sources.
|
|
6
|
+
*/
|
|
7
|
+
export class RepoLifecycleFactory {
|
|
8
|
+
providers = new Map();
|
|
9
|
+
sources = new Map();
|
|
10
|
+
executor;
|
|
11
|
+
retries;
|
|
12
|
+
constructor(executor, retries) {
|
|
13
|
+
this.executor = executor ?? defaultExecutor;
|
|
14
|
+
this.retries = retries ?? 3;
|
|
15
|
+
}
|
|
16
|
+
getProvider(platform) {
|
|
17
|
+
// Check cache first
|
|
18
|
+
const cached = this.providers.get(platform);
|
|
19
|
+
if (cached) {
|
|
20
|
+
return cached;
|
|
21
|
+
}
|
|
22
|
+
// Create provider
|
|
23
|
+
let provider;
|
|
24
|
+
switch (platform) {
|
|
25
|
+
case "github":
|
|
26
|
+
provider = new GitHubLifecycleProvider({
|
|
27
|
+
executor: this.executor,
|
|
28
|
+
retries: this.retries,
|
|
29
|
+
});
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Platform '${platform}' not supported as target for lifecycle operations. ` +
|
|
33
|
+
`Currently supported: github`);
|
|
34
|
+
}
|
|
35
|
+
this.providers.set(platform, provider);
|
|
36
|
+
return provider;
|
|
37
|
+
}
|
|
38
|
+
getMigrationSource(platform) {
|
|
39
|
+
// Check cache first
|
|
40
|
+
const cached = this.sources.get(platform);
|
|
41
|
+
if (cached) {
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
// Create source
|
|
45
|
+
let source;
|
|
46
|
+
switch (platform) {
|
|
47
|
+
case "azure-devops":
|
|
48
|
+
source = new AdoMigrationSource(this.executor, this.retries);
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`Platform '${platform}' not supported as migration source. ` +
|
|
52
|
+
`Currently supported: azure-devops`);
|
|
53
|
+
}
|
|
54
|
+
this.sources.set(platform, source);
|
|
55
|
+
return source;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type RepoInfo } from "../shared/repo-detector.js";
|
|
2
|
+
import type { RepoConfig } from "../config/types.js";
|
|
3
|
+
import type { IRepoLifecycleManager, IRepoLifecycleFactory, LifecycleResult, LifecycleOptions, CreateRepoSettings } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Orchestrates repo lifecycle operations before sync.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RepoLifecycleManager implements IRepoLifecycleManager {
|
|
8
|
+
private readonly factory;
|
|
9
|
+
constructor(factory?: IRepoLifecycleFactory, retries?: number);
|
|
10
|
+
ensureRepo(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LifecycleOptions, settings?: CreateRepoSettings): Promise<LifecycleResult>;
|
|
11
|
+
private create;
|
|
12
|
+
private fork;
|
|
13
|
+
private migrate;
|
|
14
|
+
/**
|
|
15
|
+
* Polls provider.exists() until the repo is visible, with timeout.
|
|
16
|
+
* GitHub's API may return success from create/fork before the git
|
|
17
|
+
* backend has fully propagated, causing subsequent clone to 403.
|
|
18
|
+
*/
|
|
19
|
+
private waitForRepoReady;
|
|
20
|
+
}
|