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.

134 lines · 9.2 KBRaw History
1import { execFileSync } from "node:child_process";
2import { mkdirSync } from "node:fs";
3import path from "node:path";
4import { afterEach, beforeEach, describe, expect, it } from "vitest";
5import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
6
7describe("migrate github", () => {
8 let srv: FakeServer;
9 let env: NodeJS.ProcessEnv;
10 let gitRoot: string;
11 let srcBare: string;
12
13 beforeEach(async () => {
14 env = isolatedEnv();
15 const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim();
16 // Source repository standing in for GitHub: main, develop, a tag and a pull ref.
17 const work = tempDir("diffinery-src-");
18 g(work, "init", "-q", "-b", "main");
19 g(work, "commit", "-q", "--allow-empty", "-m", "one");
20 g(work, "tag", "v1");
21 g(work, "checkout", "-q", "-b", "develop");
22 g(work, "commit", "-q", "--allow-empty", "-m", "two");
23 srcBare = path.join(tempDir("diffinery-gh-"), "widgets.git");
24 g(work, "clone", "-q", "--bare", work, srcBare);
25 g(srcBare, "update-ref", "refs/pull/2/head", g(work, "rev-parse", "HEAD"));
26 // Target: an empty bare repository served over smart HTTP.
27 gitRoot = tempDir("diffinery-git-");
28 mkdirSync(path.join(gitRoot, "alice"));
29 g(path.join(gitRoot, "alice"), "init", "-q", "--bare", "widgets.git");
30
31 srv = await fakeServer({ gitRoot, gitAuth: `Basic ${Buffer.from("x-token:dfy_t").toString("base64")}` });
32 const now = "2024-05-01T10:00:00Z";
33 const ghRepo = { full_name: "gh-org/widgets", name: "widgets", private: false, visibility: "public", description: "Widgets!", default_branch: "develop", clone_url: srcBare, html_url: "https://github.com/gh-org/widgets" };
34 srv.on("GET", "/gh/repos/gh-org/widgets", { body: ghRepo });
35 srv.on("GET", "/gh/repos/gh-org/widgets/actions/variables", { body: { total_count: 1, variables: [{ name: "REGION", value: "eu-west" }] } });
36 srv.on("GET", "/gh/repos/gh-org/widgets/actions/secrets", { body: { total_count: 1, secrets: [{ name: "NPM_TOKEN" }] } });
37 srv.on("GET", "/gh/repos/gh-org/widgets/labels", { body: [{ name: "bug", color: "d73a4a", description: "Broken" }] });
38 srv.on("GET", "/gh/repos/gh-org/widgets/pulls", {
39 body: [
40 { number: 2, title: "Develop work", body: "PR body", draft: false, user: { login: "carol" }, created_at: now, head: { ref: "develop", repo: { full_name: "gh-org/widgets" } }, base: { ref: "main" } },
41 { number: 4, title: "From a fork", body: null, user: { login: "mallory" }, created_at: now, head: { ref: "patch-1", repo: { full_name: "mallory/widgets" } }, base: { ref: "main" } },
42 ],
43 });
44 srv.on("GET", "/gh/repos/gh-org/widgets/issues", {
45 body: [
46 { number: 5, title: "Open issue", body: "five", state: "open", user: { login: "dave" }, labels: [], comments: 0, created_at: now },
47 { number: 1, title: "Old bug", body: "it breaks", state: "closed", state_reason: "not_planned", user: { login: "bob" }, labels: [{ name: "bug" }], comments: 1, created_at: now },
48 { number: 2, title: "Develop work", body: "PR body", state: "open", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} },
49 { number: 3, title: "Merged PR", body: "", state: "closed", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} },
50 { number: 4, title: "From a fork", body: null, state: "open", user: { login: "mallory" }, labels: [], comments: 0, created_at: now, pull_request: {} },
51 ],
52 });
53 srv.on("GET", "/gh/repos/gh-org/widgets/issues/1/comments", { body: [{ body: "me too", user: { login: "erin" }, created_at: "2024-05-02T00:00:00Z" }] });
54
55 let n = 0;
56 srv.on("GET", "/api/v3/user", { body: { login: "alice" } });
57 srv.on("POST", "/api/v3/user/repos", (r) => ({ status: 201, body: { full_name: "alice/widgets", visibility: (r.body as { visibility: string }).visibility } }));
58 srv.on("PATCH", "/api/v3/repos/alice/widgets", { body: {} });
59 srv.on("PUT", /\/actions\/variables\//, { status: 204 });
60 srv.on("POST", "/api/v3/repos/alice/widgets/labels", (r) => ({ status: 201, body: r.body }));
61 srv.on("POST", "/api/v3/repos/alice/widgets/issues", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } }));
62 srv.on("POST", "/api/v3/repos/alice/widgets/pulls", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } }));
63 srv.on("POST", /\/issues\/\d+\/comments$/, { status: 201, body: {} });
64 srv.on("PATCH", /\/issues\/\d+$/, { body: {} });
65 Object.assign(env, { DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t", GH_TOKEN: "ght", GITHUB_API_URL: `${srv.url}/gh` });
66 });
67 afterEach(() => srv.close());
68
69 it("creates the repo, pushes branches and tags, and copies issues, PRs, variables and secret names", async () => {
70 const r = await run(["migrate", "github", "gh-org/widgets", "--issues"], { env });
71 expect(r.stderr).not.toContain("error");
72 expect(r.code).toBe(0);
73
74 // Repository created with GitHub's visibility and description.
75 expect(srv.find("POST", "/api/v3/user/repos")[0]!.body).toEqual({ name: "widgets", description: "Widgets!", visibility: "public" });
76
77 // Git data: heads and tags only.
78 const refs = execFileSync("git", ["for-each-ref", "--format=%(refname)"], { cwd: path.join(gitRoot, "alice", "widgets.git"), env }).toString().trim().split("\n").sort();
79 expect(refs).toEqual(["refs/heads/develop", "refs/heads/main", "refs/tags/v1"]);
80 expect(srv.find("PATCH", "/api/v3/repos/alice/widgets")[0]!.body).toEqual({ default_branch: "develop" });
81
82 // Variables copied, secrets listed.
83 expect(srv.find("PUT", /\/actions\/variables\//).map((q) => [q.path, q.body])).toEqual([["/api/v3/repos/alice/widgets/actions/variables/REGION", { value: "eu-west" }]]);
84 expect(r.stdout).toContain("diffinery secret set NPM_TOKEN -R alice/widgets");
85
86 // Labels, then issues and PRs in GitHub number order.
87 expect(srv.find("POST", "/api/v3/repos/alice/widgets/labels")[0]!.body).toEqual({ name: "bug", color: "d73a4a", description: "Broken" });
88 const issues = srv.find("POST", "/api/v3/repos/alice/widgets/issues").map((q) => q.body as { title: string; body: string; labels?: string[] });
89 expect(issues.map((i) => i.title)).toEqual(["Old bug", "Open issue"]);
90 expect(issues[0]!.body).toBe("> Imported from GitHub issue [#1](https://github.com/gh-org/widgets/issues/1) by @bob on 2024-05-01\n\nit breaks");
91 expect(issues[0]!.labels).toEqual(["bug"]);
92 const comment = srv.find("POST", /\/issues\/1\/comments$/)[0]!.body as { body: string };
93 expect(comment.body).toBe("> Imported from GitHub: comment by @erin on 2024-05-02\n\nme too");
94 expect(srv.find("PATCH", /\/issues\/1$/)[0]!.body).toEqual({ state: "closed", state_reason: "not_planned" });
95
96 const pulls = srv.find("POST", "/api/v3/repos/alice/widgets/pulls").map((q) => q.body);
97 expect(pulls).toEqual([{ title: "Develop work", body: "> Imported from GitHub pull request [#2](https://github.com/gh-org/widgets/pull/2) by @carol on 2024-05-01\n\nPR body", head: "develop", base: "main" }]);
98 expect(r.stdout).toContain("#4: head branch is in a fork");
99 expect(r.stdout).toContain("issues: 2 (1 comments)");
100
101 // The GitHub token went to GitHub only, the Diffinery token to Diffinery only.
102 for (const q of srv.requests) {
103 if (q.path.startsWith("/gh/")) expect(q.headers.authorization).toBe("Bearer ght");
104 else if (q.path.startsWith("/api/")) expect(q.headers.authorization).toBe("Bearer dfy_t");
105 }
106 });
107
108 it("repo clone authenticates for that command only", async () => {
109 await run(["migrate", "github", "gh-org/widgets"], { env });
110 const cwd = tempDir("diffinery-clone-");
111 const r = await run(["repo", "clone", "alice/widgets", "w"], { env, cwd });
112 expect(r.code).toBe(0);
113 const dir = path.join(cwd, "w");
114 expect(execFileSync("git", ["branch", "-r"], { cwd: dir, env }).toString()).toContain("origin/develop");
115 const config = execFileSync("git", ["config", "--list", "--local"], { cwd: dir, env }).toString();
116 expect(config).toContain(`remote.origin.url=${srv.url}/alice/widgets.git`);
117 expect(config).not.toMatch(/extraheader|dfy_t|Basic/i);
118 // Without the header, the server refuses.
119 const anon = await run(["repo", "clone", "alice/widgets", "w2"], { env: { ...env, DIFFINERY_TOKEN: "", GIT_TERMINAL_PROMPT: "0" }, cwd });
120 expect(anon.code).not.toBe(0);
121 });
122
123 it("--private and --to override the target", async () => {
124 srv.on("GET", "/api/v3/repos/team/w2", { status: 404, body: { message: "repository not found" } });
125 srv.on("POST", "/api/v3/orgs/team/repos", { status: 201, body: { full_name: "team/w2", visibility: "private" } });
126 srv.on("PATCH", "/api/v3/repos/team/w2", { body: {} });
127 mkdirSync(path.join(gitRoot, "team"));
128 execFileSync("git", ["init", "-q", "--bare", "w2.git"], { cwd: path.join(gitRoot, "team"), env });
129 const r = await run(["migrate", "github", "https://github.com/gh-org/widgets", "--to", "team/w2", "--private"], { env });
130 expect(r.code).toBe(0);
131 expect(srv.find("POST", "/api/v3/orgs/team/repos")[0]!.body).toMatchObject({ name: "w2", visibility: "private" });
132 expect(srv.find("POST", /\/issues$/)).toHaveLength(0);
133 });
134});