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.

178 lines · 11.1 KBRaw History
1import { afterEach, beforeEach, describe, expect, it } from "vitest";
2import { parseRemote } from "../src/git.js";
3import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js";
4
5const 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
22describe("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
31describe("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});