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.

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