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.

Keep the e2e suite out of unit test runs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent b3a19becommit 099d8a4867bbc2707c8da8cbbbdc247539bf15e6Browse files
2 files changed+135−1
addedapps/cli/test/migrate.test.ts+134−0View file
@@ -1,0 +1,134 @@
1+import { execFileSync } from "node:child_process";
2+import { mkdirSync } from "node:fs";
3+import path from "node:path";
4+import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
6+
7+describe("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+});
modifiedapps/diffinery/package.json+1−1View file
@@ -7,7 +7,7 @@
77 "dev": "wrangler dev -e dev",
88 "deploy": "wrangler deploy -c wrangler.deploy.jsonc",
99 "typecheck": "tsc --noEmit",
10- "test": "vitest run --passWithNoTests",
10+ "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
1212 "e2e": "node --test --test-timeout=600000 test/e2e/e2e.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev"