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.

133 lines · 7.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 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});