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.

307 lines · 20.3 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 --auto and --no-auto send auto_merge, and say what happened", async () => {
60 srv.on("POST", "/api/v3/repos/o/r/pulls", (req) =>
61 (req.body as { auto_merge?: boolean }).auto_merge
62 ? { status: 201, body: { ...pull(2), auto_merge: { merge_method: "squash" } } }
63 : { status: 201, body: { ...pull(3), auto_merge: null, auto_merge_skipped: "main has no required status checks, so auto-merge would not wait for CI" } },
64 );
65 const on = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--auto"], { env });
66 expect(on.code).toBe(0);
67 expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic", auto_merge: true });
68 expect(on.stdout).toContain("auto-merge: on (squash)");
69 const off = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--no-auto"], { env });
70 expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[1]!.body).toEqual({ title: "T", head: "topic", auto_merge: false });
71 expect(off.stdout).not.toContain("auto-merge: on");
72 const both = await run(["pr", "create", "-R", "o/r", "--title", "T", "--auto", "--no-auto"], { env });
73 expect(both.code).toBe(2);
74 });
75
76 it("pr create says why auto-merge stayed off when it was asked for", async () => {
77 srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: { ...pull(4), auto_merge: null, auto_merge_skipped: "main has no required status checks, so auto-merge would not wait for CI" } });
78 const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--auto"], { env });
79 expect(r.code).toBe(0);
80 expect(r.stderr).toContain("warning: auto-merge is off: main has no required status checks");
81 });
82
83 it("pr create requires a title", async () => {
84 const r = await run(["pr", "create", "-R", "o/r"], { env });
85 expect(r.code).toBe(2);
86 expect(r.stderr).toContain("--title is required");
87 });
88
89 it("pr merge sends the method and delete_branch", async () => {
90 srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } });
91 const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env });
92 expect(r.code).toBe(0);
93 expect(r.stdout).toContain("Merged #12");
94 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true });
95 });
96
97 it("pr merge defaults to a merge commit and rejects conflicting flags", async () => {
98 srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } });
99 expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0);
100 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" });
101 const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env });
102 expect(bad.code).toBe(2);
103 });
104
105 it("pr merge queues the pull request when the base branch uses a merge queue", async () => {
106 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" } });
107 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 } });
108 const r = await run(["pr", "merge", "6", "-R", "o/r", "-d"], { env });
109 expect(r.code).toBe(0);
110 expect(r.stdout).toContain("Added #6 to the merge queue (position 2)");
111 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue")[0]!.body).toEqual({ delete_branch: true });
112 // Like gh, --disable-auto turns auto-merge off; a pull request already in the queue leaves it too.
113 srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge", { status: 204 });
114 srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 204 });
115 const off = await run(["pr", "merge", "6", "-R", "o/r", "--disable-auto"], { env });
116 expect(off.code).toBe(0);
117 expect(off.stdout).toContain("Turned off auto-merge for #6 and removed it from the merge queue");
118 expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge")).toHaveLength(1);
119 const admin = await run(["pr", "merge", "6", "-R", "o/r", "--admin"], { env });
120 expect(admin.code).toBe(1);
121 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 });
122 });
123
124 it("pr merge --auto enables auto-merge like gh, and --disable-auto turns it off", async () => {
125 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 } });
126 const r = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--squash", "-d", "-t", "Ship it", "--match-head-commit", "abc"], { env });
127 expect(r.stderr).toBe("");
128 expect(r.code).toBe(0);
129 expect(r.stdout).toContain("Auto-merge is on for #8 (squash)");
130 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" });
131 // It never tries a direct merge or the queue itself.
132 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge")).toHaveLength(0);
133 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge-queue")).toHaveLength(0);
134 // Without a method flag the server picks one (the queue's, or squash under linear history).
135 expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto"], { env })).code).toBe(0);
136 expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge")[1]!.body).toEqual({});
137 // When it is ready already, it merges (or joins the queue) at once.
138 srv.on("PUT", "/api/v3/repos/o/r/pulls/9/auto-merge", { body: { state: "merged", auto_merge: null, merge_commit_sha: "0123456789abcdef" } });
139 expect((await run(["pr", "merge", "9", "-R", "o/r", "--auto"], { env })).stdout).toContain("Merged #9 0123456789ab");
140 srv.on("PUT", "/api/v3/repos/o/r/pulls/10/auto-merge", { body: { state: "queued", auto_merge: null, merge_commit_sha: null } });
141 expect((await run(["pr", "merge", "10", "-R", "o/r", "--auto"], { env })).stdout).toContain("Added #10 to the merge queue");
142 const json = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--json"], { env });
143 expect(JSON.parse(json.stdout).state).toBe("enabled");
144 // --auto can't be combined with --admin (a bypass) or --disable-auto.
145 expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--admin"], { env })).code).toBe(2);
146 expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--disable-auto"], { env })).code).toBe(2);
147 // --disable-auto sends DELETE to the same path; not being in a queue is fine.
148 srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge", { status: 204 });
149 srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/merge-queue", { status: 404, body: { message: "#8 is not in the merge queue" } });
150 const off = await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto"], { env });
151 expect(off.stderr).toBe("");
152 expect(off.code).toBe(0);
153 expect(off.stdout).toContain("Turned off auto-merge for #8");
154 expect(off.stdout).not.toContain("merge queue");
155 expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge")).toHaveLength(1);
156 expect(JSON.parse((await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto", "--json"], { env })).stdout)).toEqual({ auto_merge: null, dequeued: false });
157 // Other refusals still surface.
158 srv.on("DELETE", "/api/v3/repos/o/r/pulls/11/auto-merge", { status: 403, body: { message: "write access is required to disable auto-merge" } });
159 const denied = await run(["pr", "merge", "11", "-R", "o/r", "--disable-auto"], { env });
160 expect(denied.code).toBe(1);
161 expect(denied.stderr).toContain("write access is required");
162 });
163
164 it("surfaces the server's { message } on errors", async () => {
165 srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } });
166 const r = await run(["pr", "merge", "4", "-R", "o/r"], { env });
167 expect(r.code).toBe(1);
168 expect(r.stderr).toContain("1 approval required");
169 });
170
171 it("pr review maps flags to review events", async () => {
172 srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } });
173 expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0);
174 expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0);
175 expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2);
176 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" }]);
177 });
178
179 it("pr list and --json", async () => {
180 srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] });
181 const r = await run(["pr", "list", "-R", "o/r"], { env });
182 expect(r.code).toBe(0);
183 expect(r.stdout).toMatch(/#1\s+Add thing/);
184 expect(r.stdout).toMatch(/#2\s+WIP.*draft/);
185 const j = await run(["pr", "list", "-R", "o/r", "--json"], { env });
186 expect(JSON.parse(j.stdout)).toHaveLength(2);
187 expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open");
188 });
189
190 it("pr checks exits 1 on failure", async () => {
191 srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } });
192 const r = await run(["pr", "checks", "9", "-R", "o/r"], { env });
193 expect(r.code).toBe(1);
194 expect(r.stdout).toContain("ci / test");
195 });
196
197 it("pr list --head only lists pull requests from that branch", async () => {
198 srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [] });
199 const r = await run(["pr", "list", "-R", "o/r", "--head", "topic"], { env });
200 expect(r.code).toBe(0);
201 expect(r.stderr).toContain("No open pull requests from topic in o/r.");
202 expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query).toMatchObject({ head: "o:topic", state: "open" });
203 });
204
205 it("pr view, checks, diff, comment, review, ready and merge default to the current branch's pull request", async () => {
206 srv.on("GET", "/api/v3/repos/acme/widgets/pulls", (req) => ({ body: req.query.head === "acme:feature/x" ? [pull(7)] : [] }));
207 srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7", { body: { ...pull(7), checks: [{ name: "ci / test", state: "success" }] } });
208 srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7/files", { body: [{ filename: "a.txt", status: "modified" }] });
209 srv.on("POST", "/api/v3/repos/acme/widgets/issues/7/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
210 srv.on("POST", "/api/v3/repos/acme/widgets/pulls/7/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } });
211 srv.on("PATCH", "/api/v3/repos/acme/widgets/pulls/7", { body: pull(7) });
212 srv.on("PUT", "/api/v3/repos/acme/widgets/pulls/7/merge", { body: { merged: true, sha: "abc", method: "squash" } });
213 const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x");
214 const view = await run(["pr", "view"], { env, cwd });
215 expect(view.stderr).toBe("");
216 expect(view.code).toBe(0);
217 expect(view.stdout).toContain("#7");
218 expect(srv.find("GET", "/api/v3/repos/acme/widgets/pulls")[0]!.query).toMatchObject({ head: "acme:feature/x", state: "open" });
219 expect((await run(["pr", "checks"], { env, cwd })).code).toBe(0);
220 expect((await run(["pr", "diff", "--name-only"], { env, cwd })).stdout).toBe("a.txt\n");
221 expect((await run(["pr", "comment", "-b", "hi"], { env, cwd })).stdout).toContain("Commented on #7");
222 expect((await run(["pr", "review", "--approve"], { env, cwd })).stdout).toContain("Approved #7");
223 expect((await run(["pr", "ready"], { env, cwd })).stdout).toContain("Marked #7 ready");
224 expect((await run(["pr", "merge", "--squash"], { env, cwd })).stdout).toContain("Merged #7");
225 // Usage errors are reported before any lookup.
226 const before = srv.requests.length;
227 expect((await run(["pr", "merge", "--auto", "--admin"], { env, cwd })).code).toBe(2);
228 expect((await run(["pr", "comment"], { env, cwd, stdinTTY: true })).code).toBe(2);
229 expect(srv.requests.length).toBe(before);
230 });
231
232 it("pr view shows auto-merge and, for an agent, what is left before it lands", async () => {
233 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: [] };
234 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"] } });
235 srv.on("GET", "/api/v3/repos/o/r/pulls/4", { body: { ...pull(4), ...detail, auto_merge: { merge_method: "merge" }, auto_merge_blockers: [] } });
236 srv.on("GET", "/api/v3/repos/o/r/pulls/5", { body: { ...pull(5), ...detail, auto_merge: { merge_method: "rebase" } } });
237 srv.on("GET", "/api/v3/repos/o/r/pulls/6", { body: { ...pull(6), ...detail, auto_merge: null } });
238 const agent = (await run(["pr", "view", "3", "-R", "o/r"], { env })).stdout;
239 expect(agent).toContain("auto-merge: on (squash), it merges by itself once these are done:\n • required check 'ci' is in_progress\n");
240 expect((await run(["pr", "view", "4", "-R", "o/r"], { env })).stdout).toContain("auto-merge: on (merge), nothing is left, it merges now");
241 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");
242 expect((await run(["pr", "view", "6", "-R", "o/r"], { env })).stdout).not.toContain("auto-merge");
243 });
244
245 it("without a number, fails clearly when there is no current branch or no open pull request for it", async () => {
246 srv.on("GET", "/api/v3/repos/acme/widgets/pulls", { body: [] });
247 const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "lonely");
248 const none = await run(["pr", "checks"], { env, cwd });
249 expect(none.code).toBe(1);
250 expect(none.stderr).toContain("no open pull request for the current branch (lonely) in acme/widgets; pass a pull request number");
251 const outside = await run(["pr", "view", "-R", "o/r"], { env });
252 expect(outside.code).toBe(1);
253 expect(outside.stderr).toContain("no current branch");
254 expect(outside.stderr).toContain("pass a pull request number");
255 });
256
257 it("pr checks --watch polls until nothing is pending", async () => {
258 const states = [["queued", "queued"], ["in_progress", "success"], ["in_progress", "success"], ["failure", "success"]];
259 let i = 0;
260 srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => {
261 const [a, b] = states[Math.min(i++, states.length - 1)]!;
262 return { body: { ...pull(9), checks: [{ name: "ci / test", state: a }, { name: "lint", state: b }] } };
263 });
264 // Piped: one table, the final one.
265 const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env });
266 expect(r.code).toBe(1);
267 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4);
268 expect(r.stdout.match(/ci \/ test/g)).toHaveLength(1);
269 expect(r.stdout).toMatch(/ci \/ test\s+failure/);
270 // On a terminal: the table each time it changes.
271 i = 0;
272 const tty = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "--interval", "0"], { env: { ...env, NO_COLOR: "1" }, stdoutTTY: true });
273 expect(tty.code).toBe(1);
274 expect(tty.stdout.match(/ci \/ test/g)).toHaveLength(3);
275 expect(tty.stdout).toMatch(/ci \/ test\s+queued[\s\S]*ci \/ test\s+in progress[\s\S]*ci \/ test\s+failure/);
276 // --json prints only the final list, and all passing exits 0.
277 states.splice(3, 1, ["success", "skipped"]);
278 i = 0;
279 const j = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0", "--json"], { env });
280 expect(j.code).toBe(0);
281 expect(JSON.parse(j.stdout)).toEqual([{ name: "ci / test", state: "success" }, { name: "lint", state: "skipped" }]);
282 // Without --watch, pending checks exit 8.
283 i = 0;
284 expect((await run(["pr", "checks", "9", "-R", "o/r"], { env })).code).toBe(8);
285 });
286
287 it("pr checks --watch waits for the first check right after a push, and gives up after 2 minutes", async () => {
288 let i = 0;
289 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" }] } }));
290 const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env });
291 expect(r.code).toBe(0);
292 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4);
293 expect(r.stdout).toMatch(/ci \/ test\s+success/);
294 // A repository without CI: no checks ever appear, so it stops after 2 minutes of polls (1 s each at -i 0).
295 srv.on("GET", "/api/v3/repos/o/r/pulls/10", { body: { ...pull(10), checks: [] } });
296 const none = await run(["pr", "checks", "10", "-R", "o/r", "--watch", "-i", "0"], { env });
297 expect(none.code).toBe(0);
298 expect(none.stderr).toContain("No checks reported");
299 expect(srv.find("GET", "/api/v3/repos/o/r/pulls/10")).toHaveLength(121);
300 });
301
302 it("pr comment posts to the issue comments endpoint", async () => {
303 srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
304 expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0);
305 expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" });
306 });
307});