| 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 | }); |