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.

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