| 1 | import { mkdtemp, rm } from "node:fs/promises"; |
| 2 | import { tmpdir } from "node:os"; |
| 3 | import path from "node:path"; |
| 4 | import { ApiError, type Api } from "../api.js"; |
| 5 | import { parseRepoArg, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js"; |
| 6 | import { basicAuthHeader, git, gitCapture, withGitConfig } from "../git.js"; |
| 7 | import { GitHub, GitHubError } from "../github.js"; |
| 8 | import { CliError } from "../io.js"; |
| 9 | import type { IssueJson } from "./pr.js"; |
| 10 | import type { RepoJson } from "./repo.js"; |
| 11 | |
| 12 | interface 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 | |
| 23 | interface GhUser { |
| 24 | login: string; |
| 25 | } |
| 26 | |
| 27 | interface 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 | |
| 40 | interface 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 | |
| 51 | interface GhComment { |
| 52 | body: string | null; |
| 53 | user: GhUser | null; |
| 54 | created_at: string; |
| 55 | } |
| 56 | |
| 57 | const day = (iso: string) => iso.slice(0, 10); |
| 58 | const who = (u: GhUser | null) => (u ? `@${u.login}` : "a deleted user"); |
| 59 | |
| 60 | export 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 | |
| 66 | const withAttribution = (line: string, body: string | null) => `${line}\n\n${body ?? ""}`.trimEnd(); |
| 67 | |
| 68 | interface 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 | |
| 85 | async 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 | |
| 98 | async 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 | |
| 112 | async 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 | |
| 150 | async 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). */ |
| 222 | interface 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 | |
| 237 | const DONE = new Set(["succeeded", "failed", "cancelled"]); |
| 238 | const POLL_MS = 1500; |
| 239 | |
| 240 | /** Poll an import to completion, printing each step and Git phase as it changes. */ |
| 241 | async 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. */ |
| 275 | function 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 | |
| 283 | function 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 | |
| 305 | const ghToken = (ctx: Ctx) => ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null; |
| 306 | const 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 | */ |
| 313 | function 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. */ |
| 335 | async 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 | |
| 356 | const 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, |
| 361 | milestones, issues and pull requests with comments and reviews, branch protection, Actions |
| 362 | variables, release assets and deployment environments. The command follows progress until the |
| 363 | import finishes. Secret values are never read from GitHub; their names are listed to set by hand. |
| 364 | |
| 365 | With --local, runs the old client-side import instead (mirror push plus a best-effort recreation |
| 366 | of issues and open pull requests); --issues then also copies labels, issues and comments. |
| 367 | |
| 368 | GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL |
| 369 | selects 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 | |
| 440 | const 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 |
| 446 | imported in full, the same as "migrate github". The command follows progress until every |
| 447 | repository has finished. |
| 448 | |
| 449 | GitHub 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 | |
| 503 | export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github, githubOrg] }; |