| 1 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 2 | import { parseRemote } from "../src/git.js"; |
| 3 | import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js"; |
| 4 | |
| 5 | const pull = (n: number, extra: Record<string, unknown> = {}) => ({ |
| 6 | number: n, |
| 7 | title: "Add thing", |
| 8 | body: "", |
| 9 | state: "open", |
| 10 | user: { login: "alice", type: "User" }, |
| 11 | labels: [], |
| 12 | assignees: [], |
| 13 | comments: 0, |
| 14 | created_at: new Date().toISOString(), |
| 15 | updated_at: new Date().toISOString(), |
| 16 | html_url: `http://x/acme/widgets/pull/${n}`, |
| 17 | head: { ref: "feature/x", sha: "abc" }, |
| 18 | base: { ref: "main" }, |
| 19 | ...extra, |
| 20 | }); |
| 21 | |
| 22 | describe("remote parsing", () => { |
| 23 | it("recognizes https, ssh and scp-style remotes for the configured host", () => { |
| 24 | expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); |
| 25 | expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" }); |
| 26 | expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); |
| 27 | expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull(); |
| 28 | }); |
| 29 | }); |
| 30 | |
| 31 | describe("pull requests", () => { |
| 32 | let srv: FakeServer; |
| 33 | let env: NodeJS.ProcessEnv; |
| 34 | beforeEach(async () => { |
| 35 | srv = await fakeServer(); |
| 36 | env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); |
| 37 | }); |
| 38 | afterEach(() => srv.close()); |
| 39 | |
| 40 | it("pr create infers the repo from origin and the head from the current branch", async () => { |
| 41 | srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) }); |
| 42 | const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); |
| 43 | const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd }); |
| 44 | expect(r.stderr).toBe(""); |
| 45 | expect(r.code).toBe(0); |
| 46 | expect(r.stdout).toContain("#7"); |
| 47 | const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!; |
| 48 | expect(req.headers.authorization).toBe("Bearer dfy_t"); |
| 49 | expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" }); |
| 50 | }); |
| 51 | |
| 52 | it("pr create with -R and --head omits unset fields", async () => { |
| 53 | srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) }); |
| 54 | const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env }); |
| 55 | expect(r.code).toBe(0); |
| 56 | expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" }); |
| 57 | }); |
| 58 | |
| 59 | it("pr create requires a title", async () => { |
| 60 | const r = await run(["pr", "create", "-R", "o/r"], { env }); |
| 61 | expect(r.code).toBe(2); |
| 62 | expect(r.stderr).toContain("--title is required"); |
| 63 | }); |
| 64 | |
| 65 | it("pr merge sends the method and delete_branch", async () => { |
| 66 | srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } }); |
| 67 | const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env }); |
| 68 | expect(r.code).toBe(0); |
| 69 | expect(r.stdout).toContain("Merged #12"); |
| 70 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true }); |
| 71 | }); |
| 72 | |
| 73 | it("pr merge defaults to a merge commit and rejects conflicting flags", async () => { |
| 74 | srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } }); |
| 75 | expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0); |
| 76 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" }); |
| 77 | const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env }); |
| 78 | expect(bad.code).toBe(2); |
| 79 | }); |
| 80 | |
| 81 | it("pr merge queues the pull request when the base branch uses a merge queue", async () => { |
| 82 | srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge", { status: 405, body: { message: "main requires a merge queue: add the pull request to the queue" } }); |
| 83 | srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 201, body: { state: "queued", entry: { position: 2 }, merge_commit_sha: null, reason: null } }); |
| 84 | const r = await run(["pr", "merge", "6", "-R", "o/r", "-d"], { env }); |
| 85 | expect(r.code).toBe(0); |
| 86 | expect(r.stdout).toContain("Added #6 to the merge queue (position 2)"); |
| 87 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue")[0]!.body).toEqual({ delete_branch: true }); |
| 88 | // Like gh, --disable-auto turns auto-merge off; a pull request already in the queue leaves it too. |
| 89 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge", { status: 204 }); |
| 90 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 204 }); |
| 91 | const off = await run(["pr", "merge", "6", "-R", "o/r", "--disable-auto"], { env }); |
| 92 | expect(off.code).toBe(0); |
| 93 | expect(off.stdout).toContain("Turned off auto-merge for #6 and removed it from the merge queue"); |
| 94 | expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge")).toHaveLength(1); |
| 95 | const admin = await run(["pr", "merge", "6", "-R", "o/r", "--admin"], { env }); |
| 96 | expect(admin.code).toBe(1); |
| 97 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge").at(-1)!.body).toEqual({ merge_method: "merge", bypass_merge_queue: true, bypass_rules: true }); |
| 98 | }); |
| 99 | |
| 100 | it("pr merge --auto enables auto-merge like gh, and --disable-auto turns it off", async () => { |
| 101 | srv.on("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge", { body: { state: "enabled", auto_merge: { merge_method: "squash", enabled_by: { login: "alice" } }, merge_commit_sha: null } }); |
| 102 | const r = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--squash", "-d", "-t", "Ship it", "--match-head-commit", "abc"], { env }); |
| 103 | expect(r.stderr).toBe(""); |
| 104 | expect(r.code).toBe(0); |
| 105 | expect(r.stdout).toContain("Auto-merge is on for #8 (squash)"); |
| 106 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true, commit_title: "Ship it", sha: "abc" }); |
| 107 | // It never tries a direct merge or the queue itself. |
| 108 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge")).toHaveLength(0); |
| 109 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge-queue")).toHaveLength(0); |
| 110 | // Without a method flag the server picks one (the queue's, or squash under linear history). |
| 111 | expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto"], { env })).code).toBe(0); |
| 112 | expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge")[1]!.body).toEqual({}); |
| 113 | // When it is ready already, it merges (or joins the queue) at once. |
| 114 | srv.on("PUT", "/api/v3/repos/o/r/pulls/9/auto-merge", { body: { state: "merged", auto_merge: null, merge_commit_sha: "0123456789abcdef" } }); |
| 115 | expect((await run(["pr", "merge", "9", "-R", "o/r", "--auto"], { env })).stdout).toContain("Merged #9 0123456789ab"); |
| 116 | srv.on("PUT", "/api/v3/repos/o/r/pulls/10/auto-merge", { body: { state: "queued", auto_merge: null, merge_commit_sha: null } }); |
| 117 | expect((await run(["pr", "merge", "10", "-R", "o/r", "--auto"], { env })).stdout).toContain("Added #10 to the merge queue"); |
| 118 | const json = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--json"], { env }); |
| 119 | expect(JSON.parse(json.stdout).state).toBe("enabled"); |
| 120 | // --auto can't be combined with --admin (a bypass) or --disable-auto. |
| 121 | expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--admin"], { env })).code).toBe(2); |
| 122 | expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--disable-auto"], { env })).code).toBe(2); |
| 123 | // --disable-auto sends DELETE to the same path; not being in a queue is fine. |
| 124 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge", { status: 204 }); |
| 125 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/merge-queue", { status: 404, body: { message: "#8 is not in the merge queue" } }); |
| 126 | const off = await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto"], { env }); |
| 127 | expect(off.stderr).toBe(""); |
| 128 | expect(off.code).toBe(0); |
| 129 | expect(off.stdout).toContain("Turned off auto-merge for #8"); |
| 130 | expect(off.stdout).not.toContain("merge queue"); |
| 131 | expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge")).toHaveLength(1); |
| 132 | expect(JSON.parse((await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto", "--json"], { env })).stdout)).toEqual({ auto_merge: null, dequeued: false }); |
| 133 | // Other refusals still surface. |
| 134 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/11/auto-merge", { status: 403, body: { message: "write access is required to disable auto-merge" } }); |
| 135 | const denied = await run(["pr", "merge", "11", "-R", "o/r", "--disable-auto"], { env }); |
| 136 | expect(denied.code).toBe(1); |
| 137 | expect(denied.stderr).toContain("write access is required"); |
| 138 | }); |
| 139 | |
| 140 | it("surfaces the server's { message } on errors", async () => { |
| 141 | srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); |
| 142 | const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); |
| 143 | expect(r.code).toBe(1); |
| 144 | expect(r.stderr).toContain("1 approval required"); |
| 145 | }); |
| 146 | |
| 147 | it("pr review maps flags to review events", async () => { |
| 148 | srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); |
| 149 | expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0); |
| 150 | expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0); |
| 151 | expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2); |
| 152 | expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]); |
| 153 | }); |
| 154 | |
| 155 | it("pr list and --json", async () => { |
| 156 | srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] }); |
| 157 | const r = await run(["pr", "list", "-R", "o/r"], { env }); |
| 158 | expect(r.code).toBe(0); |
| 159 | expect(r.stdout).toMatch(/#1\s+Add thing/); |
| 160 | expect(r.stdout).toMatch(/#2\s+WIP.*draft/); |
| 161 | const j = await run(["pr", "list", "-R", "o/r", "--json"], { env }); |
| 162 | expect(JSON.parse(j.stdout)).toHaveLength(2); |
| 163 | expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open"); |
| 164 | }); |
| 165 | |
| 166 | it("pr checks exits 1 on failure", async () => { |
| 167 | srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } }); |
| 168 | const r = await run(["pr", "checks", "9", "-R", "o/r"], { env }); |
| 169 | expect(r.code).toBe(1); |
| 170 | expect(r.stdout).toContain("ci / test"); |
| 171 | }); |
| 172 | |
| 173 | it("pr comment posts to the issue comments endpoint", async () => { |
| 174 | srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); |
| 175 | expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); |
| 176 | expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" }); |
| 177 | }); |
| 178 | }); |