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.

283 lines · 18.5 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 list --head only lists pull requests from that branch", async () => {
174 srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [] });
175 const r = await run(["pr", "list", "-R", "o/r", "--head", "topic"], { env });
176 expect(r.code).toBe(0);
177 expect(r.stderr).toContain("No open pull requests from topic in o/r.");
178 expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query).toMatchObject({ head: "o:topic", state: "open" });
179 });
180
181 it("pr view, checks, diff, comment, review, ready and merge default to the current branch's pull request", async () => {
182 srv.on("GET", "/api/v3/repos/acme/widgets/pulls", (req) => ({ body: req.query.head === "acme:feature/x" ? [pull(7)] : [] }));
183 srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7", { body: { ...pull(7), checks: [{ name: "ci / test", state: "success" }] } });
184 srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7/files", { body: [{ filename: "a.txt", status: "modified" }] });
185 srv.on("POST", "/api/v3/repos/acme/widgets/issues/7/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
186 srv.on("POST", "/api/v3/repos/acme/widgets/pulls/7/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } });
187 srv.on("PATCH", "/api/v3/repos/acme/widgets/pulls/7", { body: pull(7) });
188 srv.on("PUT", "/api/v3/repos/acme/widgets/pulls/7/merge", { body: { merged: true, sha: "abc", method: "squash" } });
189 const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x");
190 const view = await run(["pr", "view"], { env, cwd });
191 expect(view.stderr).toBe("");
192 expect(view.code).toBe(0);
193 expect(view.stdout).toContain("#7");
194 expect(srv.find("GET", "/api/v3/repos/acme/widgets/pulls")[0]!.query).toMatchObject({ head: "acme:feature/x", state: "open" });
195 expect((await run(["pr", "checks"], { env, cwd })).code).toBe(0);
196 expect((await run(["pr", "diff", "--name-only"], { env, cwd })).stdout).toBe("a.txt\n");
197 expect((await run(["pr", "comment", "-b", "hi"], { env, cwd })).stdout).toContain("Commented on #7");
198 expect((await run(["pr", "review", "--approve"], { env, cwd })).stdout).toContain("Approved #7");
199 expect((await run(["pr", "ready"], { env, cwd })).stdout).toContain("Marked #7 ready");
200 expect((await run(["pr", "merge", "--squash"], { env, cwd })).stdout).toContain("Merged #7");
201 // Usage errors are reported before any lookup.
202 const before = srv.requests.length;
203 expect((await run(["pr", "merge", "--auto", "--admin"], { env, cwd })).code).toBe(2);
204 expect((await run(["pr", "comment"], { env, cwd, stdinTTY: true })).code).toBe(2);
205 expect(srv.requests.length).toBe(before);
206 });
207
208 it("pr view shows auto-merge and, for an agent, what is left before it lands", async () => {
209 const detail = { mergeable: false, mergeable_state: "blocked", merge_blockers: ["needs 1 more approving review(s)", "required check 'ci' is in_progress"], approvals: 0, required_approvals: 1, checks: [] };
210 srv.on("GET", "/api/v3/repos/o/r/pulls/3", { body: { ...pull(3), ...detail, auto_merge: { merge_method: "squash" }, auto_merge_blockers: ["required check 'ci' is in_progress"] } });
211 srv.on("GET", "/api/v3/repos/o/r/pulls/4", { body: { ...pull(4), ...detail, auto_merge: { merge_method: "merge" }, auto_merge_blockers: [] } });
212 srv.on("GET", "/api/v3/repos/o/r/pulls/5", { body: { ...pull(5), ...detail, auto_merge: { merge_method: "rebase" } } });
213 srv.on("GET", "/api/v3/repos/o/r/pulls/6", { body: { ...pull(6), ...detail, auto_merge: null } });
214 const agent = (await run(["pr", "view", "3", "-R", "o/r"], { env })).stdout;
215 expect(agent).toContain("auto-merge: on (squash), it merges by itself once these are done:\n • required check 'ci' is in_progress\n");
216 expect((await run(["pr", "view", "4", "-R", "o/r"], { env })).stdout).toContain("auto-merge: on (merge), nothing is left, it merges now");
217 expect((await run(["pr", "view", "5", "-R", "o/r"], { env })).stdout).toContain("auto-merge: on (rebase), it merges by itself once every requirement is met");
218 expect((await run(["pr", "view", "6", "-R", "o/r"], { env })).stdout).not.toContain("auto-merge");
219 });
220
221 it("without a number, fails clearly when there is no current branch or no open pull request for it", async () => {
222 srv.on("GET", "/api/v3/repos/acme/widgets/pulls", { body: [] });
223 const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "lonely");
224 const none = await run(["pr", "checks"], { env, cwd });
225 expect(none.code).toBe(1);
226 expect(none.stderr).toContain("no open pull request for the current branch (lonely) in acme/widgets; pass a pull request number");
227 const outside = await run(["pr", "view", "-R", "o/r"], { env });
228 expect(outside.code).toBe(1);
229 expect(outside.stderr).toContain("no current branch");
230 expect(outside.stderr).toContain("pass a pull request number");
231 });
232
233 it("pr checks --watch polls until nothing is pending", async () => {
234 const states = [["queued", "queued"], ["in_progress", "success"], ["in_progress", "success"], ["failure", "success"]];
235 let i = 0;
236 srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => {
237 const [a, b] = states[Math.min(i++, states.length - 1)]!;
238 return { body: { ...pull(9), checks: [{ name: "ci / test", state: a }, { name: "lint", state: b }] } };
239 });
240 // Piped: one table, the final one.
241 const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env });
242 expect(r.code).toBe(1);
243 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4);
244 expect(r.stdout.match(/ci \/ test/g)).toHaveLength(1);
245 expect(r.stdout).toMatch(/ci \/ test\s+failure/);
246 // On a terminal: the table each time it changes.
247 i = 0;
248 const tty = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "--interval", "0"], { env: { ...env, NO_COLOR: "1" }, stdoutTTY: true });
249 expect(tty.code).toBe(1);
250 expect(tty.stdout.match(/ci \/ test/g)).toHaveLength(3);
251 expect(tty.stdout).toMatch(/ci \/ test\s+queued[\s\S]*ci \/ test\s+in progress[\s\S]*ci \/ test\s+failure/);
252 // --json prints only the final list, and all passing exits 0.
253 states.splice(3, 1, ["success", "skipped"]);
254 i = 0;
255 const j = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0", "--json"], { env });
256 expect(j.code).toBe(0);
257 expect(JSON.parse(j.stdout)).toEqual([{ name: "ci / test", state: "success" }, { name: "lint", state: "skipped" }]);
258 // Without --watch, pending checks exit 8.
259 i = 0;
260 expect((await run(["pr", "checks", "9", "-R", "o/r"], { env })).code).toBe(8);
261 });
262
263 it("pr checks --watch waits for the first check right after a push, and gives up after 2 minutes", async () => {
264 let i = 0;
265 srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => ({ body: { ...pull(9), checks: i++ < 2 ? [] : [{ name: "ci / test", state: i < 4 ? "in_progress" : "success" }] } }));
266 const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env });
267 expect(r.code).toBe(0);
268 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4);
269 expect(r.stdout).toMatch(/ci \/ test\s+success/);
270 // A repository without CI: no checks ever appear, so it stops after 2 minutes of polls (1 s each at -i 0).
271 srv.on("GET", "/api/v3/repos/o/r/pulls/10", { body: { ...pull(10), checks: [] } });
272 const none = await run(["pr", "checks", "10", "-R", "o/r", "--watch", "-i", "0"], { env });
273 expect(none.code).toBe(0);
274 expect(none.stderr).toContain("No checks reported");
275 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/10")).toHaveLength(121);
276 });
277
278 it("pr comment posts to the issue comments endpoint", async () => {
279 srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
280 expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0);
281 expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" });
282 });
283});