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.

296 lines · 14.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 } 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 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
111async 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
148async 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
217const 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),
222sets the default branch, copies Actions variables and lists Actions secret names to set.
223With --issues, also copies labels, issues and comments (with attribution lines), and recreates
224open pull requests whose branches were imported.
225
226GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL
227selects 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
296export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github] };