| 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 | srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 204 }); |
| 89 | expect((await run(["pr", "merge", "6", "-R", "o/r", "--disable-auto"], { env })).stdout).toContain("Removed #6 from the merge queue"); |
| 90 | const admin = await run(["pr", "merge", "6", "-R", "o/r", "--admin"], { env }); |
| 91 | expect(admin.code).toBe(1); |
| 92 | 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 }); |
| 93 | }); |
| 94 | |
| 95 | it("surfaces the server's { message } on errors", async () => { |
| 96 | srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); |
| 97 | const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); |
| 98 | expect(r.code).toBe(1); |
| 99 | expect(r.stderr).toContain("1 approval required"); |
| 100 | }); |
| 101 | |
| 102 | it("pr review maps flags to review events", async () => { |
| 103 | srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); |
| 104 | expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0); |
| 105 | expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0); |
| 106 | expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2); |
| 107 | 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" }]); |
| 108 | }); |
| 109 | |
| 110 | it("pr list and --json", async () => { |
| 111 | srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] }); |
| 112 | const r = await run(["pr", "list", "-R", "o/r"], { env }); |
| 113 | expect(r.code).toBe(0); |
| 114 | expect(r.stdout).toMatch(/#1\s+Add thing/); |
| 115 | expect(r.stdout).toMatch(/#2\s+WIP.*draft/); |
| 116 | const j = await run(["pr", "list", "-R", "o/r", "--json"], { env }); |
| 117 | expect(JSON.parse(j.stdout)).toHaveLength(2); |
| 118 | expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open"); |
| 119 | }); |
| 120 | |
| 121 | it("pr checks exits 1 on failure", async () => { |
| 122 | srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } }); |
| 123 | const r = await run(["pr", "checks", "9", "-R", "o/r"], { env }); |
| 124 | expect(r.code).toBe(1); |
| 125 | expect(r.stdout).toContain("ci / test"); |
| 126 | }); |
| 127 | |
| 128 | it("pr comment posts to the issue comments endpoint", async () => { |
| 129 | srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); |
| 130 | expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); |
| 131 | expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" }); |
| 132 | }); |
| 133 | }); |