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.

514 lines · 24.4 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/**
275 * Secret names across the repository and its environments, with the command to set each.
276 * Environment secrets (`<environment>/<NAME>`) have no `secret set` option, so they get the
277 * REST call through `diffinery api` instead.
278 */
279function secretLines(ctx: Ctx, imp: ImportJson, target: string): void {
280 const repoNames = imp.summary.secretNames ?? [];
281 const envNames = imp.summary.environments?.secretNames ?? [];
282 const total = repoNames.length + envNames.length;
283 if (!total) return;
284 ctx.out("");
285 ctx.out(`GitHub does not reveal secret values. Set these ${total} secret${total === 1 ? "" : "s"} by hand:`);
286 for (const n of repoNames) ctx.out(` diffinery secret set ${n} -R ${target}`);
287 for (const n of envNames) {
288 const at = n.lastIndexOf("/");
289 const [env, name] = [n.slice(0, at), n.slice(at + 1)];
290 ctx.out(` diffinery api PUT /repos/${target}/environments/${encodeURIComponent(env)}/secrets/${encodeURIComponent(name)} -f value=...`);
291 }
292}
293
294function renderImport(ctx: Ctx, imp: ImportJson): number {
295 if (ctx.json) {
296 ctx.out(JSON.stringify(imp, null, 2));
297 return imp.status === "succeeded" ? 0 : 1;
298 }
299 const c = ctx.c;
300 const target = imp.target.full_name ?? "—";
301 ctx.out("");
302 if (imp.status === "succeeded") ctx.out(c.bold(`Imported ${imp.source.full_name} → ${target}`));
303 else if (imp.status === "cancelled") ctx.out(c.yellow(`Import of ${imp.source.full_name} was cancelled`));
304 else ctx.out(c.red(`Import of ${imp.source.full_name} failed${imp.error ? `: ${imp.error}` : ""}`));
305 for (const w of imp.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
306 if (imp.status === "succeeded") {
307 secretLines(ctx, imp, target);
308 if (imp.target.html_url) {
309 ctx.out("");
310 ctx.out(imp.target.html_url);
311 }
312 }
313 return imp.status === "succeeded" ? 0 : 1;
314}
315
316const ghToken = (ctx: Ctx) => ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null;
317const ghApiUrl = (ctx: Ctx) => ctx.io.env.GITHUB_API_URL || undefined;
318
319/**
320 * The GitHub token to send to a given URL, or null. M3: only attach it to github.com (its API/host)
321 * or to the host of an explicitly configured GITHUB_API_URL — never to a host taken from the source
322 * repository argument, so a crafted source cannot capture the token.
323 */
324function ghTokenFor(ctx: Ctx, url: string): string | null {
325 const token = ghToken(ctx);
326 if (!token) return null;
327 let host: string;
328 try {
329 host = new URL(url).host.toLowerCase();
330 } catch {
331 return null;
332 }
333 if (host === "github.com" || host === "www.github.com" || host === "api.github.com") return token;
334 const apiUrl = ctx.io.env.GITHUB_API_URL;
335 if (apiUrl) {
336 try {
337 if (new URL(apiUrl).host.toLowerCase() === host) return token;
338 } catch {
339 // ignore a malformed GITHUB_API_URL
340 }
341 }
342 return null;
343}
344
345/** The server-side single-repository import: POST the request, then follow it to completion. */
346async function runServer(ctx: Ctx, o: Values, srcArg: string): Promise<number> {
347 const src = parseRepoArg(srcArg.replace(/^https?:\/\/github\.com\//, ""));
348 const srcName = `${src.owner}/${src.name}`;
349 const api = await ctx.authedApi();
350 const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : null;
351 let owner = to?.owner;
352 if (!owner) owner = (await api.get<{ login: string }>("/user")).login;
353 const body = {
354 source: srcName,
355 owner,
356 name: to?.name,
357 visibility: o.private === true ? "private" : "keep",
358 github_token: ghToken(ctx) ?? undefined,
359 github_api_url: ghApiUrl(ctx),
360 };
361 if (!ctx.json) ctx.out(`Starting server import of ${srcName} …`);
362 const created = await api.post<ImportJson>("/diffinery/imports", body);
363 const final = await pollImport(ctx, api, created.id);
364 return renderImport(ctx, final);
365}
366
367const github: Command = {
368 name: "migrate github",
369 summary: "Import a GitHub repository (git data, issues, pull requests, releases and more)",
370 args: "<GH-OWNER/GH-REPO>",
371 description: `Runs a full server-side import: git data (keeping issue and pull-request numbers), labels,
372milestones, issues and pull requests with comments and reviews, branch protection, Actions
373variables, release assets and deployment environments. The command follows progress until the
374import finishes. Secret values are never read from GitHub; their names are listed to set by hand.
375
376With --local, runs the old client-side import instead (mirror push plus a best-effort recreation
377of issues and open pull requests); --issues then also copies labels, issues and comments.
378
379GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL
380selects a GitHub Enterprise API).`,
381 options: {
382 to: { type: "string", short: "t", desc: "Diffinery repository to create (default: <you>/<gh-repo>)", arg: "OWNER/NAME" },
383 private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" },
384 local: { type: "boolean", short: "l", desc: "Use the old client-side import (no server importer)" },
385 issues: { type: "boolean", short: "i", desc: "With --local: also import labels, issues, comments and open pull requests" },
386 },
387 json: true,
388 minArgs: 1,
389 maxArgs: 1,
390 async run(ctx, o, [srcArg]) {
391 if (o.local !== true) return runServer(ctx, o, srcArg!);
392 const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, ""));
393 const srcName = `${src.owner}/${src.name}`;
394 const api = await ctx.authedApi();
395 const cfg = await ctx.config();
396 const token = ghToken(ctx);
397 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)) });
398 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"));
399
400 const me = await api.get<{ login: string }>("/user");
401 const gr = await gh.get<GhRepo>(`/repos/${srcName}`);
402 const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name };
403 const target = `${to.owner}/${to.name}`;
404 const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] };
405
406 const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true);
407 s.created = created;
408 const branches = await migrateGit(ctx, gr, target, cfg.token, cfg.url, s);
409 if (branches.has(gr.default_branch)) {
410 await api.patch(repoPath(to), { default_branch: gr.default_branch });
411 s.defaultBranch = gr.default_branch;
412 }
413
414 const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? [];
415 for (const v of vars) {
416 await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value });
417 s.variables.push(v.name);
418 }
419 s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name);
420
421 if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s);
422
423 if (ctx.json) {
424 ctx.out(JSON.stringify(s, null, 2));
425 return s.pushFailures.length ? 1 : 0;
426 }
427 const c = ctx.c;
428 ctx.out("");
429 ctx.out(c.bold(`Imported ${srcName} → ${target}`));
430 ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`);
431 ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`);
432 ctx.out(` variables: ${s.variables.length}`);
433 if (o.issues) {
434 ctx.out(` labels: ${s.labels}`);
435 ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`);
436 ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`);
437 for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`));
438 }
439 if (s.secrets.length) {
440 ctx.out("");
441 ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`);
442 for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`);
443 }
444 for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
445 ctx.out("");
446 ctx.out(`${cfg.url}/${target}`);
447 return s.pushFailures.length ? 1 : 0;
448 },
449};
450
451const githubOrg: Command = {
452 name: "migrate github-org",
453 summary: "Import many repositories from a GitHub organization or user",
454 args: "<GH-ORG-OR-USER>",
455 description: `Starts a server-side bulk import of every repository in a GitHub organization or user account
456(or just the ones named with --repos), into a Diffinery organization or user. Each repository is
457imported in full, the same as "migrate github". The command follows progress until every
458repository has finished.
459
460GitHub access uses GH_TOKEN or GITHUB_TOKEN; GITHUB_API_URL selects a GitHub Enterprise API.`,
461 options: {
462 to: { type: "string", short: "t", desc: "Diffinery owner to import into (default: you)", arg: "OWNER" },
463 repos: { type: "string", desc: "Comma-separated repository names to import (default: all)", arg: "A,B,C" },
464 private: { type: "boolean", short: "p", desc: "Create every repository as private" },
465 "exclude-forks": { type: "boolean", desc: "Skip forks" },
466 "exclude-archived": { type: "boolean", desc: "Skip archived repositories" },
467 concurrency: { type: "string", short: "c", desc: "Repositories to import at once, 1–8 (default 3)", arg: "N" },
468 },
469 json: true,
470 minArgs: 1,
471 maxArgs: 1,
472 async run(ctx, o, [orgArg]) {
473 const org = orgArg!.replace(/^https?:\/\/github\.com\//, "").replace(/\/+$/, "");
474 const api = await ctx.authedApi();
475 const owner = str(o, "to") || (await api.get<{ login: string }>("/user")).login;
476 const repositories = str(o, "repos")
477 ? str(o, "repos")!
478 .split(",")
479 .map((s) => s.trim())
480 .filter(Boolean)
481 : undefined;
482 const concurrency = str(o, "concurrency") ? Number(str(o, "concurrency")) : undefined;
483 const body = {
484 source_owner: org,
485 owner,
486 repositories,
487 exclude_forks: o["exclude-forks"] === true || undefined,
488 exclude_archived: o["exclude-archived"] === true || undefined,
489 concurrency,
490 visibility: o.private === true ? "private" : "keep",
491 github_token: ghToken(ctx) ?? undefined,
492 github_api_url: ghApiUrl(ctx),
493 };
494 if (!ctx.json) ctx.out(`Starting bulk import of ${org} into ${owner} …`);
495 const created = await api.post<ImportJson>("/diffinery/imports", body);
496 const final = await pollImport(ctx, api, created.id);
497 if (ctx.json) {
498 ctx.out(JSON.stringify(final, null, 2));
499 return final.status === "succeeded" ? 0 : 1;
500 }
501 const c = ctx.c;
502 const kids = final.children ?? [];
503 const ok = kids.filter((k) => k.status === "succeeded").length;
504 const failed = kids.filter((k) => k.status === "failed").length;
505 ctx.out("");
506 ctx.out(c.bold(`Bulk import of ${org}: ${ok}/${kids.length} repositories imported${failed ? c.red(`, ${failed} failed`) : ""}`));
507 for (const w of final.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
508 ctx.out("");
509 ctx.out(final.html_url);
510 return final.status === "succeeded" ? 0 : 1;
511 },
512};
513
514export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github, githubOrg] };