Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

503 lines · 23.9 KBRaw History
1import { mkdtemp, rm } from "node:fs/promises";
2import { tmpdir } from "node:os";
3import path from "node:path";
4import { ApiError, type Api } from "../api.js";
5import { parseRepoArg, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js";
6import { basicAuthHeader, git, gitCapture, withGitConfig } from "../git.js";
7import { GitHub, GitHubError } from "../github.js";
8import { CliError } from "../io.js";
9import type { IssueJson } from "./pr.js";
10import type { RepoJson } from "./repo.js";
11
12interface GhRepo {
13 full_name: string;
14 name: string;
15 private: boolean;
16 visibility?: "public" | "private" | "internal";
17 description: string | null;
18 default_branch: string;
19 clone_url: string;
20 html_url: string;
21}
22
23interface GhUser {
24 login: string;
25}
26
27interface GhIssue {
28 number: number;
29 title: string;
30 body: string | null;
31 state: "open" | "closed";
32 state_reason?: string | null;
33 user: GhUser | null;
34 labels: Array<{ name: string } | string>;
35 comments: number;
36 created_at: string;
37 pull_request?: unknown;
38}
39
40interface GhPull {
41 number: number;
42 title: string;
43 body: string | null;
44 draft?: boolean;
45 user: GhUser | null;
46 created_at: string;
47 head: { ref: string; repo: { full_name: string } | null };
48 base: { ref: string };
49}
50
51interface GhComment {
52 body: string | null;
53 user: GhUser | null;
54 created_at: string;
55}
56
57const day = (iso: string) => iso.slice(0, 10);
58const who = (u: GhUser | null) => (u ? `@${u.login}` : "a deleted user");
59
60export function attribution(kind: "issue" | "pull request" | "comment", n: number, user: GhUser | null, createdAt: string, htmlBase: string): string {
61 const link = `${htmlBase}/${kind === "pull request" ? "pull" : "issues"}/${n}`;
62 if (kind === "comment") return `> Imported from GitHub: comment by ${who(user)} on ${day(createdAt)}`;
63 return `> Imported from GitHub ${kind} [#${n}](${link}) by ${who(user)} on ${day(createdAt)}`;
64}
65
66const withAttribution = (line: string, body: string | null) => `${line}\n\n${body ?? ""}`.trimEnd();
67
68interface Summary {
69 repo: string;
70 created: boolean;
71 branches: number;
72 tags: number;
73 pushFailures: string[];
74 defaultBranch: string | null;
75 labels: number;
76 issues: Array<{ from: number; to: number }>;
77 pulls: Array<{ from: number; to: number }>;
78 skippedPulls: Array<{ number: number; reason: string }>;
79 comments: number;
80 variables: string[];
81 secrets: string[];
82 warnings: string[];
83}
84
85async function optional<T>(ctx: Ctx, s: Summary, what: string, f: () => Promise<T>): Promise<T | null> {
86 try {
87 return await f();
88 } catch (e) {
89 if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) {
90 s.warnings.push(`could not read ${what} from GitHub (${e.status}; the token may lack access)`);
91 ctx.err(`${ctx.c.yellow("warning:")} could not read ${what} from GitHub (${e.status})`);
92 return null;
93 }
94 throw e;
95 }
96}
97
98async function ensureRepo(ctx: Ctx, api: Api, to: RepoRef, me: string, gh: GhRepo, forcePrivate: boolean): Promise<{ repo: RepoJson; created: boolean }> {
99 const existing = await api.raw<RepoJson>("GET", repoPath(to), { allow: [404] });
100 if (existing.status !== 404) {
101 ctx.out(`Using existing repository ${to.owner}/${to.name}`);
102 return { repo: existing.data, created: false };
103 }
104 // Diffinery has only public and private; GitHub "internal" repositories become private.
105 const visibility = !forcePrivate && (gh.visibility === "public" || (gh.visibility === undefined && !gh.private)) ? "public" : "private";
106 const body = { name: to.name, description: gh.description ?? undefined, visibility };
107 const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body);
108 ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`);
109 return { repo, created: true };
110}
111
112async function migrateGit(ctx: Ctx, gh: GhRepo, target: string, token: string | null, diffineryUrl: string, s: Summary): Promise<Set<string>> {
113 const dir = await mkdtemp(path.join(tmpdir(), "diffinery-migrate-"));
114 const mirror = path.join(dir, "repo.git");
115 try {
116 ctx.out(`Cloning ${gh.html_url} …`);
117 const cloneToken = /^https?:\/\//.test(gh.clone_url) ? ghTokenFor(ctx, gh.clone_url) : null;
118 const cloneEnv = withGitConfig(ctx.io.env, cloneToken ? [basicAuthHeader(new URL(gh.clone_url).origin, "x-access-token", cloneToken)] : []);
119 const cl = await gitCapture(ctx.io, ["clone", "--mirror", "--quiet", gh.clone_url, mirror], { env: { ...cloneEnv, GIT_TERMINAL_PROMPT: "0" } });
120 if (cl.code !== 0) throw new CliError(`git clone --mirror failed: ${cl.stderr.trim()}`);
121 const refs = (await git(ctx.io, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: mirror })).split("\n").filter(Boolean);
122 const branches = new Set(refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length)));
123 s.branches = branches.size;
124 s.tags = refs.length - branches.size;
125 if (!refs.length) {
126 ctx.out("The GitHub repository is empty; nothing to push.");
127 return branches;
128 }
129 ctx.out(`Pushing ${s.branches} branch${s.branches === 1 ? "" : "es"} and ${s.tags} tag${s.tags === 1 ? "" : "s"} to ${target} …`);
130 const pushEnv = { ...withGitConfig(ctx.io.env, token ? [basicAuthHeader(diffineryUrl, "x-token", token)] : []), GIT_TERMINAL_PROMPT: "0" };
131 const remote = `${diffineryUrl}/${target}.git`;
132 const all = await gitCapture(ctx.io, ["push", "--quiet", remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { cwd: mirror, env: pushEnv });
133 if (all.code !== 0) {
134 // One big push can hit request size limits or a rejected ref; fall back to ref by ref.
135 ctx.err(`${ctx.c.yellow("warning:")} bulk push failed (${all.stderr.trim().split("\n").pop()}); pushing refs one at a time`);
136 for (const ref of refs) {
137 const r = await gitCapture(ctx.io, ["push", "--quiet", remote, `${ref}:${ref}`], { cwd: mirror, env: pushEnv });
138 if (r.code !== 0) {
139 s.pushFailures.push(`${ref}: ${r.stderr.trim().split("\n").pop()}`);
140 ctx.err(`${ctx.c.red("X")} ${ref}: ${r.stderr.trim().split("\n").pop()}`);
141 }
142 }
143 }
144 return branches;
145 } finally {
146 await rm(dir, { recursive: true, force: true });
147 }
148}
149
150async function migrateIssues(ctx: Ctx, gh: GitHub, api: Api, src: string, gr: GhRepo, to: RepoRef, branches: Set<string>, s: Summary): Promise<void> {
151 const base = repoPath(to);
152 const ghPath = `/repos/${src}`;
153 // Labels
154 const labels = (await optional(ctx, s, "labels", () => gh.paginate<{ name: string; color: string; description: string | null }>(`${ghPath}/labels`))) ?? [];
155 for (const l of labels) {
156 try {
157 await api.post(`${base}/labels`, { name: l.name, color: l.color, description: l.description ?? "" });
158 s.labels++;
159 } catch (e) {
160 s.warnings.push(`label ${l.name}: ${(e as Error).message}`);
161 }
162 }
163 if (labels.length) ctx.out(`${ctx.c.green("✓")} Copied ${s.labels} label${s.labels === 1 ? "" : "s"}`);
164
165 const openPulls = new Map<number, GhPull>();
166 for (const p of (await optional(ctx, s, "pull requests", () => gh.paginate<GhPull>(`${ghPath}/pulls`, { state: "open" }))) ?? []) openPulls.set(p.number, p);
167 const items = ((await optional(ctx, s, "issues", () => gh.paginate<GhIssue>(`${ghPath}/issues`, { state: "all", sort: "created", direction: "asc" }))) ?? []).sort((a, b) => a.number - b.number);
168 ctx.out(`Importing ${items.length} issues and pull requests …`);
169
170 for (const it of items) {
171 let created: IssueJson | null = null;
172 if (it.pull_request) {
173 const p = openPulls.get(it.number);
174 if (it.state !== "open" || !p) {
175 s.skippedPulls.push({ number: it.number, reason: it.state === "open" ? "not found" : "closed or merged" });
176 continue;
177 }
178 if (p.head.repo?.full_name?.toLowerCase() !== gr.full_name.toLowerCase()) {
179 s.skippedPulls.push({ number: it.number, reason: "head branch is in a fork" });
180 continue;
181 }
182 if (!branches.has(p.head.ref) || !branches.has(p.base.ref)) {
183 s.skippedPulls.push({ number: it.number, reason: `branch ${branches.has(p.head.ref) ? p.base.ref : p.head.ref} missing` });
184 continue;
185 }
186 try {
187 created = await api.post<IssueJson>(`${base}/pulls`, { title: p.title, body: withAttribution(attribution("pull request", p.number, p.user, p.created_at, gr.html_url), p.body), head: p.head.ref, base: p.base.ref, draft: p.draft || undefined });
188 s.pulls.push({ from: it.number, to: created.number });
189 } catch (e) {
190 s.skippedPulls.push({ number: it.number, reason: (e as Error).message });
191 continue;
192 }
193 } else {
194 const labelNames = it.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean);
195 const body = withAttribution(attribution("issue", it.number, it.user, it.created_at, gr.html_url), it.body);
196 try {
197 created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body, labels: labelNames.length ? labelNames : undefined });
198 } catch (e) {
199 if (!(e instanceof ApiError) || !labelNames.length) throw e;
200 created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body });
201 s.warnings.push(`issue #${it.number}: labels not applied (${e.message})`);
202 }
203 s.issues.push({ from: it.number, to: created.number });
204 }
205 if (it.comments > 0) {
206 const comments = (await optional(ctx, s, `comments of #${it.number}`, () => gh.paginate<GhComment>(`${ghPath}/issues/${it.number}/comments`))) ?? [];
207 for (const cm of comments) {
208 await api.post(`${base}/issues/${created.number}/comments`, { body: withAttribution(attribution("comment", it.number, cm.user, cm.created_at, gr.html_url), cm.body) });
209 s.comments++;
210 }
211 }
212 if (it.state === "closed" && !it.pull_request) {
213 await api.patch(`${base}/issues/${created.number}`, { state: "closed", state_reason: it.state_reason === "not_planned" ? "not_planned" : "completed" });
214 }
215 ctx.out(ctx.c.dim(` GitHub #${it.number} → #${created.number} ${it.title.slice(0, 60)}`));
216 }
217}
218
219// ---------------------------------------------------------------- the server importer
220
221/** The import resource returned by `/api/v3/diffinery/imports` (the fields the CLI shows). */
222interface ImportJson {
223 id: string;
224 html_url: string;
225 status: "draft" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
226 kind: "repository" | "organization";
227 source: { full_name: string };
228 target: { full_name: string | null; html_url: string | null };
229 steps: Array<{ name: string; title: string; status: "pending" | "running" | "done" | "skipped" | "failed"; message: string | null; count: number | null }>;
230 git: { phase: string | null; message: string | null; bytes: number; chunks: number } | null;
231 summary: { secretNames?: string[]; environments?: { secretNames: string[] } } & Record<string, unknown>;
232 warnings: string[];
233 error: string | null;
234 children?: Array<{ id: string; source: string; target: string; status: string; step: string | null; error: string | null }>;
235}
236
237const DONE = new Set(["succeeded", "failed", "cancelled"]);
238const POLL_MS = 1500;
239
240/** Poll an import to completion, printing each step and Git phase as it changes. */
241async function pollImport(ctx: Ctx, api: Api, id: string): Promise<ImportJson> {
242 const seenStep = new Map<string, string>();
243 let seenGit = "";
244 let seenChild = new Map<string, string>();
245 for (let i = 0; ; i++) {
246 const imp = await api.get<ImportJson>(`/diffinery/imports/${encodeURIComponent(id)}`);
247 if (!ctx.json) {
248 for (const s of imp.steps) {
249 const prev = seenStep.get(s.name);
250 if (prev === s.status) continue;
251 seenStep.set(s.name, s.status);
252 if (s.status === "running") ctx.out(ctx.c.dim(` … ${s.title}${s.message ? ` (${s.message})` : ""}`));
253 else if (s.status === "done") ctx.out(` ${ctx.c.green("✓")} ${s.title}${s.count != null ? ` (${s.count})` : s.message ? ` (${s.message})` : ""}`);
254 else if (s.status === "skipped") ctx.out(ctx.c.dim(` – ${s.title}${s.message ? ` (${s.message})` : ""}`));
255 else if (s.status === "failed") ctx.out(` ${ctx.c.red("X")} ${s.title}${s.message ? `: ${s.message}` : ""}`);
256 }
257 if (imp.git && imp.git.phase && imp.git.phase !== seenGit) {
258 seenGit = imp.git.phase;
259 ctx.out(ctx.c.dim(` … git: ${imp.git.message ?? imp.git.phase}`));
260 }
261 for (const ch of imp.children ?? []) {
262 const key = `${ch.status}:${ch.step ?? ""}`;
263 if (seenChild.get(ch.id) === key) continue;
264 seenChild.set(ch.id, key);
265 const mark = ch.status === "succeeded" ? ctx.c.green("✓") : ch.status === "failed" ? ctx.c.red("X") : ch.status === "cancelled" ? ctx.c.dim("–") : ctx.c.dim("…");
266 ctx.out(` ${mark} ${ch.source} → ${ch.target}${ch.status === "running" && ch.step ? ctx.c.dim(` (${ch.step})`) : ch.error ? ctx.c.red(` ${ch.error}`) : ""}`);
267 }
268 }
269 if (DONE.has(imp.status)) return imp;
270 await ctx.io.sleep(POLL_MS);
271 }
272}
273
274/** Secret names across the repository and its environments, with the command to set each. */
275function secretLines(ctx: Ctx, imp: ImportJson, target: string): void {
276 const names = [...(imp.summary.secretNames ?? []), ...(imp.summary.environments?.secretNames ?? [])];
277 if (!names.length) return;
278 ctx.out("");
279 ctx.out(`GitHub does not reveal secret values. Set these ${names.length} secret${names.length === 1 ? "" : "s"} by hand:`);
280 for (const n of names) ctx.out(` diffinery secret set ${n} -R ${target}`);
281}
282
283function renderImport(ctx: Ctx, imp: ImportJson): number {
284 if (ctx.json) {
285 ctx.out(JSON.stringify(imp, null, 2));
286 return imp.status === "succeeded" ? 0 : 1;
287 }
288 const c = ctx.c;
289 const target = imp.target.full_name ?? "—";
290 ctx.out("");
291 if (imp.status === "succeeded") ctx.out(c.bold(`Imported ${imp.source.full_name} → ${target}`));
292 else if (imp.status === "cancelled") ctx.out(c.yellow(`Import of ${imp.source.full_name} was cancelled`));
293 else ctx.out(c.red(`Import of ${imp.source.full_name} failed${imp.error ? `: ${imp.error}` : ""}`));
294 for (const w of imp.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
295 if (imp.status === "succeeded") {
296 secretLines(ctx, imp, target);
297 if (imp.target.html_url) {
298 ctx.out("");
299 ctx.out(imp.target.html_url);
300 }
301 }
302 return imp.status === "succeeded" ? 0 : 1;
303}
304
305const ghToken = (ctx: Ctx) => ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null;
306const ghApiUrl = (ctx: Ctx) => ctx.io.env.GITHUB_API_URL || undefined;
307
308/**
309 * The GitHub token to send to a given URL, or null. M3: only attach it to github.com (its API/host)
310 * or to the host of an explicitly configured GITHUB_API_URL — never to a host taken from the source
311 * repository argument, so a crafted source cannot capture the token.
312 */
313function ghTokenFor(ctx: Ctx, url: string): string | null {
314 const token = ghToken(ctx);
315 if (!token) return null;
316 let host: string;
317 try {
318 host = new URL(url).host.toLowerCase();
319 } catch {
320 return null;
321 }
322 if (host === "github.com" || host === "www.github.com" || host === "api.github.com") return token;
323 const apiUrl = ctx.io.env.GITHUB_API_URL;
324 if (apiUrl) {
325 try {
326 if (new URL(apiUrl).host.toLowerCase() === host) return token;
327 } catch {
328 // ignore a malformed GITHUB_API_URL
329 }
330 }
331 return null;
332}
333
334/** The server-side single-repository import: POST the request, then follow it to completion. */
335async function runServer(ctx: Ctx, o: Values, srcArg: string): Promise<number> {
336 const src = parseRepoArg(srcArg.replace(/^https?:\/\/github\.com\//, ""));
337 const srcName = `${src.owner}/${src.name}`;
338 const api = await ctx.authedApi();
339 const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : null;
340 let owner = to?.owner;
341 if (!owner) owner = (await api.get<{ login: string }>("/user")).login;
342 const body = {
343 source: srcName,
344 owner,
345 name: to?.name,
346 visibility: o.private === true ? "private" : "keep",
347 github_token: ghToken(ctx) ?? undefined,
348 github_api_url: ghApiUrl(ctx),
349 };
350 if (!ctx.json) ctx.out(`Starting server import of ${srcName} …`);
351 const created = await api.post<ImportJson>("/diffinery/imports", body);
352 const final = await pollImport(ctx, api, created.id);
353 return renderImport(ctx, final);
354}
355
356const github: Command = {
357 name: "migrate github",
358 summary: "Import a GitHub repository (git data, issues, pull requests, releases and more)",
359 args: "<GH-OWNER/GH-REPO>",
360 description: `Runs a full server-side import: git data (keeping issue and pull-request numbers), labels,
361milestones, issues and pull requests with comments and reviews, branch protection, Actions
362variables, release assets and deployment environments. The command follows progress until the
363import finishes. Secret values are never read from GitHub; their names are listed to set by hand.
364
365With --local, runs the old client-side import instead (mirror push plus a best-effort recreation
366of issues and open pull requests); --issues then also copies labels, issues and comments.
367
368GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL
369selects a GitHub Enterprise API).`,
370 options: {
371 to: { type: "string", short: "t", desc: "Diffinery repository to create (default: <you>/<gh-repo>)", arg: "OWNER/NAME" },
372 private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" },
373 local: { type: "boolean", short: "l", desc: "Use the old client-side import (no server importer)" },
374 issues: { type: "boolean", short: "i", desc: "With --local: also import labels, issues, comments and open pull requests" },
375 },
376 json: true,
377 minArgs: 1,
378 maxArgs: 1,
379 async run(ctx, o, [srcArg]) {
380 if (o.local !== true) return runServer(ctx, o, srcArg!);
381 const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, ""));
382 const srcName = `${src.owner}/${src.name}`;
383 const api = await ctx.authedApi();
384 const cfg = await ctx.config();
385 const token = ghToken(ctx);
386 const gh = new GitHub({ baseUrl: ctx.io.env.GITHUB_API_URL, token, sleep: (ms) => ctx.io.sleep(ms), log: (m) => ctx.err(ctx.c.yellow(m)) });
387 if (!token) ctx.err(ctx.c.yellow("note: GH_TOKEN/GITHUB_TOKEN is not set; only public data can be read and GitHub rate limits are low"));
388
389 const me = await api.get<{ login: string }>("/user");
390 const gr = await gh.get<GhRepo>(`/repos/${srcName}`);
391 const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name };
392 const target = `${to.owner}/${to.name}`;
393 const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] };
394
395 const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true);
396 s.created = created;
397 const branches = await migrateGit(ctx, gr, target, cfg.token, cfg.url, s);
398 if (branches.has(gr.default_branch)) {
399 await api.patch(repoPath(to), { default_branch: gr.default_branch });
400 s.defaultBranch = gr.default_branch;
401 }
402
403 const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? [];
404 for (const v of vars) {
405 await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value });
406 s.variables.push(v.name);
407 }
408 s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name);
409
410 if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s);
411
412 if (ctx.json) {
413 ctx.out(JSON.stringify(s, null, 2));
414 return s.pushFailures.length ? 1 : 0;
415 }
416 const c = ctx.c;
417 ctx.out("");
418 ctx.out(c.bold(`Imported ${srcName} → ${target}`));
419 ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`);
420 ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`);
421 ctx.out(` variables: ${s.variables.length}`);
422 if (o.issues) {
423 ctx.out(` labels: ${s.labels}`);
424 ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`);
425 ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`);
426 for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`));
427 }
428 if (s.secrets.length) {
429 ctx.out("");
430 ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`);
431 for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`);
432 }
433 for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
434 ctx.out("");
435 ctx.out(`${cfg.url}/${target}`);
436 return s.pushFailures.length ? 1 : 0;
437 },
438};
439
440const githubOrg: Command = {
441 name: "migrate github-org",
442 summary: "Import many repositories from a GitHub organization or user",
443 args: "<GH-ORG-OR-USER>",
444 description: `Starts a server-side bulk import of every repository in a GitHub organization or user account
445(or just the ones named with --repos), into a Diffinery organization or user. Each repository is
446imported in full, the same as "migrate github". The command follows progress until every
447repository has finished.
448
449GitHub access uses GH_TOKEN or GITHUB_TOKEN; GITHUB_API_URL selects a GitHub Enterprise API.`,
450 options: {
451 to: { type: "string", short: "t", desc: "Diffinery owner to import into (default: you)", arg: "OWNER" },
452 repos: { type: "string", desc: "Comma-separated repository names to import (default: all)", arg: "A,B,C" },
453 private: { type: "boolean", short: "p", desc: "Create every repository as private" },
454 "exclude-forks": { type: "boolean", desc: "Skip forks" },
455 "exclude-archived": { type: "boolean", desc: "Skip archived repositories" },
456 concurrency: { type: "string", short: "c", desc: "Repositories to import at once, 1–8 (default 3)", arg: "N" },
457 },
458 json: true,
459 minArgs: 1,
460 maxArgs: 1,
461 async run(ctx, o, [orgArg]) {
462 const org = orgArg!.replace(/^https?:\/\/github\.com\//, "").replace(/\/+$/, "");
463 const api = await ctx.authedApi();
464 const owner = str(o, "to") || (await api.get<{ login: string }>("/user")).login;
465 const repositories = str(o, "repos")
466 ? str(o, "repos")!
467 .split(",")
468 .map((s) => s.trim())
469 .filter(Boolean)
470 : undefined;
471 const concurrency = str(o, "concurrency") ? Number(str(o, "concurrency")) : undefined;
472 const body = {
473 source_owner: org,
474 owner,
475 repositories,
476 exclude_forks: o["exclude-forks"] === true || undefined,
477 exclude_archived: o["exclude-archived"] === true || undefined,
478 concurrency,
479 visibility: o.private === true ? "private" : "keep",
480 github_token: ghToken(ctx) ?? undefined,
481 github_api_url: ghApiUrl(ctx),
482 };
483 if (!ctx.json) ctx.out(`Starting bulk import of ${org} into ${owner} …`);
484 const created = await api.post<ImportJson>("/diffinery/imports", body);
485 const final = await pollImport(ctx, api, created.id);
486 if (ctx.json) {
487 ctx.out(JSON.stringify(final, null, 2));
488 return final.status === "succeeded" ? 0 : 1;
489 }
490 const c = ctx.c;
491 const kids = final.children ?? [];
492 const ok = kids.filter((k) => k.status === "succeeded").length;
493 const failed = kids.filter((k) => k.status === "failed").length;
494 ctx.out("");
495 ctx.out(c.bold(`Bulk import of ${org}: ${ok}/${kids.length} repositories imported${failed ? c.red(`, ${failed} failed`) : ""}`));
496 for (const w of final.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
497 ctx.out("");
498 ctx.out(final.html_url);
499 return final.status === "succeeded" ? 0 : 1;
500 },
501};
502
503export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github, githubOrg] };