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.

247 lines · 14.6 KBRaw History
1// End-to-end GraphQL API (/api/graphql), on a real `wrangler dev` Worker with the real git CLI:
2// the queries Claude Code sends to show a session's pull request and its CI (the badge and the
3// check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull
4// request mutations behind `gh pr create`, `gh pr ready`, `gh pr edit`, `gh pr comment`,
5// `gh pr close`, `gh pr reopen` and `gh pr merge`. Also covers sign-in, token scopes, limits and
6// GitHub's error shape.
7//
8// pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs
9import { spawn, execFileSync } from "node:child_process";
10import { createHash, randomBytes } from "node:crypto";
11import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12import { tmpdir } from "node:os";
13import { join, dirname, resolve } from "node:path";
14import { fileURLToPath } from "node:url";
15import { test, before, after } from "node:test";
16import assert from "node:assert/strict";
17import { migratedState } from "./d1-state.mjs";
18import { freePort } from "./ports.mjs";
19
20const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
21const work = mkdtempSync(join(tmpdir(), "diffinery-graphql-e2e-"));
22let dev;
23let base;
24let log = "";
25
26const hash = (s) => createHash("sha256").update(s).digest("hex");
27const T = { ann: `dfy_ann_${randomBytes(12).toString("hex")}`, annRead: `dfy_anr_${randomBytes(12).toString("hex")}`, bob: `dfy_bob_${randomBytes(12).toString("hex")}` };
28
29before(async () => {
30 const port = await freePort();
31 base = `http://127.0.0.1:${port}`;
32 const persist = join(work, "state");
33 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
34 const at = new Date().toISOString();
35 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
36 const person = (id, login) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${login}@example.com', 'active', 0, '${at}')`;
37 const token = (id, account, t, scope) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["${scope}"]', '${later}', '${at}')`;
38 migratedState(APP, persist);
39 wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", [person("u-ann", "ann"), person("u-bob", "bob"), token("t-ann", "u-ann", T.ann, "admin"), token("t-anr", "u-ann", T.annRead, "read"), token("t-bob", "u-bob", T.bob, "admin")].join("; "));
40 // No fixed dev identity (DEV_AUTH empty), so a request without a token is anonymous.
41 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], {
42 cwd: APP,
43 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
44 stdio: ["ignore", "pipe", "pipe"],
45 detached: true,
46 });
47 dev.stdout.on("data", (d) => (log += d));
48 dev.stderr.on("data", (d) => (log += d));
49 const end = Date.now() + 120_000;
50 for (;;) {
51 try {
52 if ((await fetch(`${base}/healthz`)).ok) break;
53 } catch {}
54 if (Date.now() > end) assert.fail(`wrangler dev did not start\n${log.slice(-4000)}`);
55 await new Promise((r) => setTimeout(r, 500));
56 }
57});
58
59after(() => {
60 try {
61 process.kill(-dev.pid, "SIGTERM");
62 } catch {}
63 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
64});
65
66async function rest(method, path, body, tok = T.ann) {
67 const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
68 const text = await res.text();
69 assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`);
70 return text ? JSON.parse(text) : null;
71}
72
73/** POST /api/graphql; returns { status, body }. */
74async function gql(query, variables = {}, tok = T.ann) {
75 const res = await fetch(`${base}/api/graphql`, { method: "POST", headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json" }, body: JSON.stringify({ query, variables }) });
76 return { status: res.status, body: await res.json() };
77}
78
79/** The data of a query that must succeed without errors. */
80async function data(query, variables, tok) {
81 const r = await gql(query, variables, tok);
82 assert.equal(r.status, 200, JSON.stringify(r.body));
83 assert.equal(r.body.errors, undefined, JSON.stringify(r.body.errors));
84 return r.body.data;
85}
86
87function git(cwd, args) {
88 const auth = Buffer.from(`x-token:${T.ann}`).toString("base64");
89 return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "user.name=ann", "-c", "user.email=ann@example.com", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
90}
91
92// Claude Code's query for the pull requests of its sessions (one alias per repository and pull request).
93const CLAUDE_BATCH = `fragment pr on PullRequest {
94 number title state isDraft additions deletions
95 reviewDecision
96 commits(last:1){nodes{commit{statusCheckRollup{
97 state
98 contexts(first:0){
99 checkRunCountsByState{state count}
100 statusContextCountsByState{state count}
101 }
102 }}}}
103}
104query {
105 rateLimit { cost remaining resetAt }
106 r0: repository(owner: "ann", name: "app") { p0_0: pullRequest(number: 1) { ...pr } }
107}`;
108
109let headSha;
110let repoId;
111let prId;
112
113test("setup: a private repository with a pull request, two check runs and a commit status", async () => {
114 await rest("POST", "/user/repos", { name: "app", private: true });
115 const src = join(work, "app");
116 mkdirSync(src);
117 writeFileSync(join(src, "README.md"), "# app\n");
118 git(src, ["init", "-q", "-b", "main"]);
119 git(src, ["add", "-A"]);
120 git(src, ["commit", "-q", "-m", "init"]);
121 git(src, ["push", "-q", `${base}/ann/app.git`, "main"]);
122 git(src, ["checkout", "-q", "-b", "feature/chip"]);
123 writeFileSync(join(src, "README.md"), "# app\n\nA second line.\nAnd a third.\n");
124 git(src, ["commit", "-q", "-am", "more readme"]);
125 git(src, ["push", "-q", `${base}/ann/app.git`, "feature/chip"]);
126 headSha = git(src, ["rev-parse", "HEAD"]).trim();
127
128 // gh pr create: the repository's id, then createPullRequest.
129 const d = await data(`query RepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id name owner { login } viewerPermission defaultBranchRef { name } squashMergeAllowed } }`, { owner: "ann", name: "app" });
130 assert.equal(d.repository.viewerPermission, "ADMIN");
131 assert.equal(d.repository.defaultBranchRef.name, "main");
132 repoId = d.repository.id;
133 const created = await data(`mutation PullRequestCreate($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { id url number isDraft } } }`, {
134 input: { repositoryId: repoId, baseRefName: "main", headRefName: "feature/chip", title: "Longer readme", body: "Two lines.", draft: true, maintainerCanModify: true },
135 });
136 assert.equal(created.createPullRequest.pullRequest.url, `${base}/ann/app/pull/1`);
137 assert.equal(created.createPullRequest.pullRequest.isDraft, true);
138 prId = created.createPullRequest.pullRequest.id;
139
140 await rest("POST", "/repos/ann/app/check-runs", { name: "lint", head_sha: headSha, status: "completed", conclusion: "success" });
141 await rest("POST", "/repos/ann/app/check-runs", { name: "tests", head_sha: headSha, status: "in_progress" });
142 await rest("POST", `/repos/ann/app/statuses/${headSha}`, { state: "success", context: "deploy/preview" });
143});
144
145test("Claude Code: the current branch's pull request and its review decision", async () => {
146 // Over REST, as Claude Code looks it up for the branch it's on.
147 const list = await rest("GET", "/repos/ann/app/pulls?head=ann:feature/chip&state=open&per_page=1");
148 assert.equal(list.length, 1);
149 assert.equal(list[0].number, 1);
150 assert.equal(list[0].html_url, `${base}/ann/app/pull/1`);
151 const d = await data(`query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewDecision}}}`, { o: "ann", r: "app", n: 1 });
152 assert.equal(d.repository.pullRequest.reviewDecision, null);
153});
154
155test("Claude Code: CI counts and pull request state for the badge", async () => {
156 const d = await data(CLAUDE_BATCH);
157 assert.ok(d.rateLimit.remaining > 0);
158 const p = d.r0.p0_0;
159 assert.deepEqual({ number: p.number, title: p.title, state: p.state, isDraft: p.isDraft, additions: p.additions, deletions: p.deletions }, { number: 1, title: "Longer readme", state: "OPEN", isDraft: true, additions: 3, deletions: 0 });
160 const rollup = p.commits.nodes[0].commit.statusCheckRollup;
161 assert.equal(rollup.state, "PENDING");
162 const counts = (list) => Object.fromEntries(list.map((x) => [x.state, x.count]));
163 assert.deepEqual(counts(rollup.contexts.checkRunCountsByState), { IN_PROGRESS: 1, SUCCESS: 1 });
164 assert.deepEqual(counts(rollup.contexts.statusContextCountsByState), { SUCCESS: 1 });
165});
166
167test("gh pr checks and gh pr view: the rollup, node lookup and fields Diffinery doesn't have", async () => {
168 const d = await data(
169 `query PullRequestStatusChecks($id: ID!) { node(id: $id) { ...on PullRequest { statusCheckRollup: commits(last: 1) { nodes { commit { oid statusCheckRollup { contexts(first: 100) { nodes { __typename ...on StatusContext { context state targetUrl } ...on CheckRun { name status conclusion workflowName: checkSuite { workflowRun { workflow { name } } } } } pageInfo { hasNextPage endCursor } } } } } } } } }`,
170 { id: prId },
171 );
172 const nodes = d.node.statusCheckRollup.nodes[0].commit.statusCheckRollup.contexts.nodes;
173 assert.equal(d.node.statusCheckRollup.nodes[0].commit.oid, headSha);
174 assert.deepEqual(nodes.map((n) => n.name ?? n.context).sort(), ["deploy/preview", "lint", "tests"]);
175 assert.equal(nodes.find((n) => n.name === "tests").status, "IN_PROGRESS");
176 assert.equal(nodes.find((n) => n.name === "lint").conclusion, "SUCCESS");
177
178 // A field GitHub has and Diffinery doesn't is left out and named, so gh carries on.
179 const r = await gql(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { number mergeStateStatus mergeable notAThing } } }`);
180 assert.equal(r.body.errors, undefined, JSON.stringify(r.body));
181 assert.equal(r.body.data.repository.pullRequest.number, 1);
182 assert.equal(r.body.data.repository.pullRequest.mergeStateStatus, "DRAFT");
183 assert.equal(r.body.data.repository.pullRequest.mergeable, "MERGEABLE");
184 assert.ok(r.body.extensions.unsupported.some((m) => m.includes("notAThing")));
185
186 // gh pr status: pull requests by the viewer, through search.
187 const s = await data(`query { search(query: "repo:ann/app is:pr is:open author:@me", type: ISSUE, first: 10) { issueCount nodes { ...on PullRequest { number } } } }`);
188 assert.equal(s.search.issueCount, 1);
189});
190
191test("gh pr ready, edit, comment, close, reopen and merge", async () => {
192 const pr = async () => (await data(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { title body state isDraft merged labels(first: 10) { nodes { name } } comments(first: 10) { nodes { body } } } } }`)).repository.pullRequest;
193 await data(`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: prId });
194 assert.equal((await pr()).isDraft, false);
195
196 await rest("POST", "/repos/ann/app/labels", { name: "docs", color: "0075ca" });
197 const label = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 10) { nodes { id name } } } }`)).repository.labels.nodes.find((l) => l.name === "docs");
198 await data(`mutation($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { pullRequest { title } } }`, { input: { pullRequestId: prId, title: "A longer readme", body: "Three lines." } });
199 await data(`mutation($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { clientMutationId } }`, { input: { labelableId: prId, labelIds: [label.id] } });
200 await data(`mutation($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: prId, body: "Looks right." } });
201 let p = await pr();
202 assert.equal(p.title, "A longer readme");
203 assert.equal(p.body, "Three lines.");
204 assert.deepEqual(p.labels.nodes.map((l) => l.name), ["docs"]);
205 assert.deepEqual(p.comments.nodes.map((c) => c.body), ["Looks right."]);
206
207 await data(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId });
208 assert.equal((await pr()).state, "CLOSED");
209 await data(`mutation($id: ID!) { reopenPullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId });
210 assert.equal((await pr()).state, "OPEN");
211
212 // A read token can look but not change anything.
213 const refused = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }, T.annRead);
214 assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body));
215 assert.equal((await pr()).state, "OPEN");
216
217 await data(`mutation($input: MergePullRequestInput!) { mergePullRequest(input: $input) { clientMutationId } }`, { input: { pullRequestId: prId, mergeMethod: "SQUASH" } });
218 p = await pr();
219 assert.equal(p.state, "MERGED");
220 assert.equal(p.merged, true);
221});
222
223test("sign-in, privacy, limits and GitHub's error shape", async () => {
224 const anonymous = await gql(`query { viewer { login } }`, {}, null);
225 assert.equal(anonymous.status, 401);
226 assert.equal((await data(`query UserCurrent { viewer { login } }`)).viewer.login, "ann");
227
228 // Someone without access gets GitHub's NOT_FOUND, the same as for a repository that doesn't exist.
229 for (const [owner, name, tok] of [["ann", "app", T.bob], ["ann", "nope", T.ann]]) {
230 const r = await gql(`query($o: String!, $n: String!) { repository(owner: $o, name: $n) { id } }`, { o: owner, n: name }, tok);
231 assert.equal(r.body.data.repository, null);
232 assert.equal(r.body.errors[0].type, "NOT_FOUND");
233 }
234
235 let deep = "id";
236 for (let i = 0; i < 20; i++) deep = `parent { ${deep} }`;
237 const tooDeep = await gql(`query { repository(owner: "ann", name: "app") { ${deep} } }`);
238 assert.match(tooDeep.body.errors[0].message, /nested/);
239
240 const sub = await gql(`subscription { viewer { login } }`);
241 assert.match(sub.body.errors[0].message, /Subscriptions/);
242
243 // gh auth login checks the REST root of a GitHub Enterprise host.
244 const root = await fetch(`${base}/api/v3/`, { headers: { authorization: `Bearer ${T.ann}` } });
245 assert.equal(root.status, 200);
246 assert.equal((await root.json()).graphql, `${base}/api/graphql`);
247});