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