| 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 } 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 | const visibility = forcePrivate ? "private" : (gh.visibility ?? (gh.private ? "private" : "public")); |
| 105 | const body = { name: to.name, description: gh.description ?? undefined, visibility }; |
| 106 | const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body); |
| 107 | ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`); |
| 108 | return { repo, created: true }; |
| 109 | } |
| 110 | |
| 111 | async function migrateGit(ctx: Ctx, gh: GhRepo, ghToken: string | null, target: string, token: string | null, diffineryUrl: string, s: Summary): Promise<Set<string>> { |
| 112 | const dir = await mkdtemp(path.join(tmpdir(), "diffinery-migrate-")); |
| 113 | const mirror = path.join(dir, "repo.git"); |
| 114 | try { |
| 115 | ctx.out(`Cloning ${gh.html_url} …`); |
| 116 | const cloneEnv = withGitConfig(ctx.io.env, ghToken && /^https?:\/\//.test(gh.clone_url) ? [basicAuthHeader(new URL(gh.clone_url).origin, "x-access-token", ghToken)] : []); |
| 117 | const cl = await gitCapture(ctx.io, ["clone", "--mirror", "--quiet", gh.clone_url, mirror], { env: { ...cloneEnv, GIT_TERMINAL_PROMPT: "0" } }); |
| 118 | if (cl.code !== 0) throw new CliError(`git clone --mirror failed: ${cl.stderr.trim()}`); |
| 119 | const refs = (await git(ctx.io, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: mirror })).split("\n").filter(Boolean); |
| 120 | const branches = new Set(refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length))); |
| 121 | s.branches = branches.size; |
| 122 | s.tags = refs.length - branches.size; |
| 123 | if (!refs.length) { |
| 124 | ctx.out("The GitHub repository is empty; nothing to push."); |
| 125 | return branches; |
| 126 | } |
| 127 | ctx.out(`Pushing ${s.branches} branch${s.branches === 1 ? "" : "es"} and ${s.tags} tag${s.tags === 1 ? "" : "s"} to ${target} …`); |
| 128 | const pushEnv = { ...withGitConfig(ctx.io.env, token ? [basicAuthHeader(diffineryUrl, "x-token", token)] : []), GIT_TERMINAL_PROMPT: "0" }; |
| 129 | const remote = `${diffineryUrl}/${target}.git`; |
| 130 | const all = await gitCapture(ctx.io, ["push", "--quiet", remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { cwd: mirror, env: pushEnv }); |
| 131 | if (all.code !== 0) { |
| 132 | // One big push can hit request size limits or a rejected ref; fall back to ref by ref. |
| 133 | ctx.err(`${ctx.c.yellow("warning:")} bulk push failed (${all.stderr.trim().split("\n").pop()}); pushing refs one at a time`); |
| 134 | for (const ref of refs) { |
| 135 | const r = await gitCapture(ctx.io, ["push", "--quiet", remote, `${ref}:${ref}`], { cwd: mirror, env: pushEnv }); |
| 136 | if (r.code !== 0) { |
| 137 | s.pushFailures.push(`${ref}: ${r.stderr.trim().split("\n").pop()}`); |
| 138 | ctx.err(`${ctx.c.red("X")} ${ref}: ${r.stderr.trim().split("\n").pop()}`); |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | return branches; |
| 143 | } finally { |
| 144 | await rm(dir, { recursive: true, force: true }); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | async function migrateIssues(ctx: Ctx, gh: GitHub, api: Api, src: string, gr: GhRepo, to: RepoRef, branches: Set<string>, s: Summary): Promise<void> { |
| 149 | const base = repoPath(to); |
| 150 | const ghPath = `/repos/${src}`; |
| 151 | // Labels |
| 152 | const labels = (await optional(ctx, s, "labels", () => gh.paginate<{ name: string; color: string; description: string | null }>(`${ghPath}/labels`))) ?? []; |
| 153 | for (const l of labels) { |
| 154 | try { |
| 155 | await api.post(`${base}/labels`, { name: l.name, color: l.color, description: l.description ?? "" }); |
| 156 | s.labels++; |
| 157 | } catch (e) { |
| 158 | s.warnings.push(`label ${l.name}: ${(e as Error).message}`); |
| 159 | } |
| 160 | } |
| 161 | if (labels.length) ctx.out(`${ctx.c.green("✓")} Copied ${s.labels} label${s.labels === 1 ? "" : "s"}`); |
| 162 | |
| 163 | const openPulls = new Map<number, GhPull>(); |
| 164 | for (const p of (await optional(ctx, s, "pull requests", () => gh.paginate<GhPull>(`${ghPath}/pulls`, { state: "open" }))) ?? []) openPulls.set(p.number, p); |
| 165 | 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); |
| 166 | ctx.out(`Importing ${items.length} issues and pull requests …`); |
| 167 | |
| 168 | for (const it of items) { |
| 169 | let created: IssueJson | null = null; |
| 170 | if (it.pull_request) { |
| 171 | const p = openPulls.get(it.number); |
| 172 | if (it.state !== "open" || !p) { |
| 173 | s.skippedPulls.push({ number: it.number, reason: it.state === "open" ? "not found" : "closed or merged" }); |
| 174 | continue; |
| 175 | } |
| 176 | if (p.head.repo?.full_name?.toLowerCase() !== gr.full_name.toLowerCase()) { |
| 177 | s.skippedPulls.push({ number: it.number, reason: "head branch is in a fork" }); |
| 178 | continue; |
| 179 | } |
| 180 | if (!branches.has(p.head.ref) || !branches.has(p.base.ref)) { |
| 181 | s.skippedPulls.push({ number: it.number, reason: `branch ${branches.has(p.head.ref) ? p.base.ref : p.head.ref} missing` }); |
| 182 | continue; |
| 183 | } |
| 184 | try { |
| 185 | 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 }); |
| 186 | s.pulls.push({ from: it.number, to: created.number }); |
| 187 | } catch (e) { |
| 188 | s.skippedPulls.push({ number: it.number, reason: (e as Error).message }); |
| 189 | continue; |
| 190 | } |
| 191 | } else { |
| 192 | const labelNames = it.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean); |
| 193 | const body = withAttribution(attribution("issue", it.number, it.user, it.created_at, gr.html_url), it.body); |
| 194 | try { |
| 195 | created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body, labels: labelNames.length ? labelNames : undefined }); |
| 196 | } catch (e) { |
| 197 | if (!(e instanceof ApiError) || !labelNames.length) throw e; |
| 198 | created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body }); |
| 199 | s.warnings.push(`issue #${it.number}: labels not applied (${e.message})`); |
| 200 | } |
| 201 | s.issues.push({ from: it.number, to: created.number }); |
| 202 | } |
| 203 | if (it.comments > 0) { |
| 204 | const comments = (await optional(ctx, s, `comments of #${it.number}`, () => gh.paginate<GhComment>(`${ghPath}/issues/${it.number}/comments`))) ?? []; |
| 205 | for (const cm of comments) { |
| 206 | await api.post(`${base}/issues/${created.number}/comments`, { body: withAttribution(attribution("comment", it.number, cm.user, cm.created_at, gr.html_url), cm.body) }); |
| 207 | s.comments++; |
| 208 | } |
| 209 | } |
| 210 | if (it.state === "closed" && !it.pull_request) { |
| 211 | await api.patch(`${base}/issues/${created.number}`, { state: "closed", state_reason: it.state_reason === "not_planned" ? "not_planned" : "completed" }); |
| 212 | } |
| 213 | ctx.out(ctx.c.dim(` GitHub #${it.number} → #${created.number} ${it.title.slice(0, 60)}`)); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | const github: Command = { |
| 218 | name: "migrate github", |
| 219 | summary: "Import a GitHub repository (git data, and optionally issues)", |
| 220 | args: "<GH-OWNER/GH-REPO>", |
| 221 | description: `Creates the Diffinery repository if needed, mirrors all branches and tags (not refs/pull), |
| 222 | sets the default branch, copies Actions variables and lists Actions secret names to set. |
| 223 | With --issues, also copies labels, issues and comments (with attribution lines), and recreates |
| 224 | open pull requests whose branches were imported. |
| 225 | |
| 226 | GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL |
| 227 | selects a GitHub Enterprise API).`, |
| 228 | options: { |
| 229 | to: { type: "string", short: "t", desc: "Diffinery repository to create or push into (default: <you>/<gh-repo>)", arg: "OWNER/NAME" }, |
| 230 | issues: { type: "boolean", short: "i", desc: "Also import labels, issues, comments and open pull requests" }, |
| 231 | private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" }, |
| 232 | }, |
| 233 | json: true, |
| 234 | minArgs: 1, |
| 235 | maxArgs: 1, |
| 236 | async run(ctx, o, [srcArg]) { |
| 237 | const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, "")); |
| 238 | const srcName = `${src.owner}/${src.name}`; |
| 239 | const api = await ctx.authedApi(); |
| 240 | const cfg = await ctx.config(); |
| 241 | const ghToken = ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null; |
| 242 | const gh = new GitHub({ baseUrl: ctx.io.env.GITHUB_API_URL, token: ghToken, sleep: (ms) => ctx.io.sleep(ms), log: (m) => ctx.err(ctx.c.yellow(m)) }); |
| 243 | if (!ghToken) 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")); |
| 244 | |
| 245 | const me = await api.get<{ login: string }>("/user"); |
| 246 | const gr = await gh.get<GhRepo>(`/repos/${srcName}`); |
| 247 | const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name }; |
| 248 | const target = `${to.owner}/${to.name}`; |
| 249 | const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] }; |
| 250 | |
| 251 | const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true); |
| 252 | s.created = created; |
| 253 | const branches = await migrateGit(ctx, gr, ghToken, target, cfg.token, cfg.url, s); |
| 254 | if (branches.has(gr.default_branch)) { |
| 255 | await api.patch(repoPath(to), { default_branch: gr.default_branch }); |
| 256 | s.defaultBranch = gr.default_branch; |
| 257 | } |
| 258 | |
| 259 | const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? []; |
| 260 | for (const v of vars) { |
| 261 | await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value }); |
| 262 | s.variables.push(v.name); |
| 263 | } |
| 264 | s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name); |
| 265 | |
| 266 | if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s); |
| 267 | |
| 268 | if (ctx.json) { |
| 269 | ctx.out(JSON.stringify(s, null, 2)); |
| 270 | return s.pushFailures.length ? 1 : 0; |
| 271 | } |
| 272 | const c = ctx.c; |
| 273 | ctx.out(""); |
| 274 | ctx.out(c.bold(`Imported ${srcName} → ${target}`)); |
| 275 | ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`); |
| 276 | ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`); |
| 277 | ctx.out(` variables: ${s.variables.length}`); |
| 278 | if (o.issues) { |
| 279 | ctx.out(` labels: ${s.labels}`); |
| 280 | ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`); |
| 281 | ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`); |
| 282 | for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`)); |
| 283 | } |
| 284 | if (s.secrets.length) { |
| 285 | ctx.out(""); |
| 286 | ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`); |
| 287 | for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`); |
| 288 | } |
| 289 | for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`); |
| 290 | ctx.out(""); |
| 291 | ctx.out(`${cfg.url}/${target}`); |
| 292 | return s.pushFailures.length ? 1 : 0; |
| 293 | }, |
| 294 | }; |
| 295 | |
| 296 | export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github] }; |