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.

388 lines · 27.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`, the queries and mutations behind `gh issue`
6// (list, view, create, comment, edit, close, reopen, status, list --search), and a workflow
7// job's GITHUB_TOKEN with its `permissions:`. Also covers sign-in, token scopes, limits and
8// GitHub's error shape.
9//
10// pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs
11import "./fresh-connections.mjs";
12import { spawn, execFileSync } from "node:child_process";
13import { createHash, randomBytes } from "node:crypto";
14import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15import { tmpdir } from "node:os";
16import { join, dirname, resolve } from "node:path";
17import { fileURLToPath } from "node:url";
18import { test, before, after } from "node:test";
19import assert from "node:assert/strict";
20import { migratedState } from "./d1-state.mjs";
21import { freePort } from "./ports.mjs";
22
23const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
24const work = mkdtempSync(join(tmpdir(), "diffinery-graphql-e2e-"));
25let dev;
26let base;
27let log = "";
28
29const hash = (s) => createHash("sha256").update(s).digest("hex");
30const T = { ann: `dfy_ann_${randomBytes(12).toString("hex")}`, annRead: `dfy_anr_${randomBytes(12).toString("hex")}`, bob: `dfy_bob_${randomBytes(12).toString("hex")}` };
31
32before(async () => {
33 const port = await freePort();
34 base = `http://127.0.0.1:${port}`;
35 const persist = join(work, "state");
36 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
37 const at = new Date().toISOString();
38 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
39 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}')`;
40 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}')`;
41 migratedState(APP, persist);
42 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("; "));
43 // No fixed dev identity (DEV_AUTH empty), so a request without a token is anonymous.
44 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"], {
45 cwd: APP,
46 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
47 stdio: ["ignore", "pipe", "pipe"],
48 detached: true,
49 });
50 dev.stdout.on("data", (d) => (log += d));
51 dev.stderr.on("data", (d) => (log += d));
52 const end = Date.now() + 120_000;
53 for (;;) {
54 try {
55 if ((await fetch(`${base}/healthz`)).ok) break;
56 } catch {}
57 if (Date.now() > end) assert.fail(`wrangler dev did not start\n${log.slice(-4000)}`);
58 await new Promise((r) => setTimeout(r, 500));
59 }
60});
61
62after(() => {
63 try {
64 process.kill(-dev.pid, "SIGTERM");
65 } catch {}
66 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
67});
68
69async function rest(method, path, body, tok = T.ann) {
70 const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
71 const text = await res.text();
72 assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`);
73 return text ? JSON.parse(text) : null;
74}
75
76/** POST /api/graphql; returns { status, body }. */
77async function gql(query, variables = {}, tok = T.ann) {
78 const res = await fetch(`${base}/api/graphql`, { method: "POST", headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json" }, body: JSON.stringify({ query, variables }) });
79 return { status: res.status, body: await res.json() };
80}
81
82/** The data of a query that must succeed without errors. */
83async function data(query, variables, tok) {
84 const r = await gql(query, variables, tok);
85 assert.equal(r.status, 200, JSON.stringify(r.body));
86 assert.equal(r.body.errors, undefined, JSON.stringify(r.body.errors));
87 return r.body.data;
88}
89
90function git(cwd, args) {
91 const auth = Buffer.from(`x-token:${T.ann}`).toString("base64");
92 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"] });
93}
94
95// Claude Code's query for the pull requests of its sessions (one alias per repository and pull request).
96const CLAUDE_BATCH = `fragment pr on PullRequest {
97 number title state isDraft additions deletions
98 reviewDecision
99 commits(last:1){nodes{commit{statusCheckRollup{
100 state
101 contexts(first:0){
102 checkRunCountsByState{state count}
103 statusContextCountsByState{state count}
104 }
105 }}}}
106}
107query {
108 rateLimit { cost remaining resetAt }
109 r0: repository(owner: "ann", name: "app") { p0_0: pullRequest(number: 1) { ...pr } }
110}`;
111
112let headSha;
113let repoId;
114let prId;
115
116test("setup: a private repository with a pull request, two check runs and a commit status", async () => {
117 await rest("POST", "/user/repos", { name: "app", private: true });
118 const src = join(work, "app");
119 mkdirSync(src);
120 writeFileSync(join(src, "README.md"), "# app\n");
121 git(src, ["init", "-q", "-b", "main"]);
122 git(src, ["add", "-A"]);
123 git(src, ["commit", "-q", "-m", "init"]);
124 git(src, ["push", "-q", `${base}/ann/app.git`, "main"]);
125 git(src, ["checkout", "-q", "-b", "feature/chip"]);
126 writeFileSync(join(src, "README.md"), "# app\n\nA second line.\nAnd a third.\n");
127 git(src, ["commit", "-q", "-am", "more readme"]);
128 git(src, ["push", "-q", `${base}/ann/app.git`, "feature/chip"]);
129 headSha = git(src, ["rev-parse", "HEAD"]).trim();
130
131 // gh pr create: the repository's id, then createPullRequest.
132 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" });
133 assert.equal(d.repository.viewerPermission, "ADMIN");
134 assert.equal(d.repository.defaultBranchRef.name, "main");
135 repoId = d.repository.id;
136 const created = await data(`mutation PullRequestCreate($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { id url number isDraft } } }`, {
137 input: { repositoryId: repoId, baseRefName: "main", headRefName: "feature/chip", title: "Longer readme", body: "Two lines.", draft: true, maintainerCanModify: true },
138 });
139 assert.equal(created.createPullRequest.pullRequest.url, `${base}/ann/app/pull/1`);
140 assert.equal(created.createPullRequest.pullRequest.isDraft, true);
141 prId = created.createPullRequest.pullRequest.id;
142
143 await rest("POST", "/repos/ann/app/check-runs", { name: "lint", head_sha: headSha, status: "completed", conclusion: "success" });
144 await rest("POST", "/repos/ann/app/check-runs", { name: "tests", head_sha: headSha, status: "in_progress" });
145 await rest("POST", `/repos/ann/app/statuses/${headSha}`, { state: "success", context: "deploy/preview" });
146});
147
148test("Claude Code: the current branch's pull request and its review decision", async () => {
149 // Over REST, as Claude Code looks it up for the branch it's on.
150 const list = await rest("GET", "/repos/ann/app/pulls?head=ann:feature/chip&state=open&per_page=1");
151 assert.equal(list.length, 1);
152 assert.equal(list[0].number, 1);
153 assert.equal(list[0].html_url, `${base}/ann/app/pull/1`);
154 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 });
155 assert.equal(d.repository.pullRequest.reviewDecision, null);
156});
157
158test("Claude Code: CI counts and pull request state for the badge", async () => {
159 const d = await data(CLAUDE_BATCH);
160 assert.ok(d.rateLimit.remaining > 0);
161 const p = d.r0.p0_0;
162 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 });
163 const rollup = p.commits.nodes[0].commit.statusCheckRollup;
164 assert.equal(rollup.state, "PENDING");
165 const counts = (list) => Object.fromEntries(list.map((x) => [x.state, x.count]));
166 assert.deepEqual(counts(rollup.contexts.checkRunCountsByState), { IN_PROGRESS: 1, SUCCESS: 1 });
167 assert.deepEqual(counts(rollup.contexts.statusContextCountsByState), { SUCCESS: 1 });
168});
169
170test("gh pr checks and gh pr view: the rollup, node lookup and fields Diffinery doesn't have", async () => {
171 const d = await data(
172 `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 } } } } } } } } }`,
173 { id: prId },
174 );
175 const nodes = d.node.statusCheckRollup.nodes[0].commit.statusCheckRollup.contexts.nodes;
176 assert.equal(d.node.statusCheckRollup.nodes[0].commit.oid, headSha);
177 assert.deepEqual(nodes.map((n) => n.name ?? n.context).sort(), ["deploy/preview", "lint", "tests"]);
178 assert.equal(nodes.find((n) => n.name === "tests").status, "IN_PROGRESS");
179 assert.equal(nodes.find((n) => n.name === "lint").conclusion, "SUCCESS");
180
181 // A field GitHub has and Diffinery doesn't is left out and named, so gh carries on.
182 const r = await gql(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { number mergeStateStatus mergeable notAThing } } }`);
183 assert.equal(r.body.errors, undefined, JSON.stringify(r.body));
184 assert.equal(r.body.data.repository.pullRequest.number, 1);
185 assert.equal(r.body.data.repository.pullRequest.mergeStateStatus, "DRAFT");
186 assert.equal(r.body.data.repository.pullRequest.mergeable, "MERGEABLE");
187 assert.ok(r.body.extensions.unsupported.some((m) => m.includes("notAThing")));
188
189 // gh pr status: pull requests by the viewer, through search.
190 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 } } } }`);
191 assert.equal(s.search.issueCount, 1);
192});
193
194test("gh pr ready, edit, comment, close, reopen and merge", async () => {
195 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;
196 await data(`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: prId });
197 assert.equal((await pr()).isDraft, false);
198
199 await rest("POST", "/repos/ann/app/labels", { name: "docs", color: "0075ca" });
200 const label = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 10) { nodes { id name } } } }`)).repository.labels.nodes.find((l) => l.name === "docs");
201 await data(`mutation($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { pullRequest { title } } }`, { input: { pullRequestId: prId, title: "A longer readme", body: "Three lines." } });
202 await data(`mutation($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { clientMutationId } }`, { input: { labelableId: prId, labelIds: [label.id] } });
203 await data(`mutation($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: prId, body: "Looks right." } });
204 let p = await pr();
205 assert.equal(p.title, "A longer readme");
206 assert.equal(p.body, "Three lines.");
207 assert.deepEqual(p.labels.nodes.map((l) => l.name), ["docs"]);
208 assert.deepEqual(p.comments.nodes.map((c) => c.body), ["Looks right."]);
209
210 await data(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId });
211 assert.equal((await pr()).state, "CLOSED");
212 await data(`mutation($id: ID!) { reopenPullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId });
213 assert.equal((await pr()).state, "OPEN");
214
215 // A read token can look but not change anything.
216 const refused = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }, T.annRead);
217 assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body));
218 assert.equal((await pr()).state, "OPEN");
219
220 await data(`mutation($input: MergePullRequestInput!) { mergePullRequest(input: $input) { clientMutationId } }`, { input: { pullRequestId: prId, mergeMethod: "SQUASH" } });
221 p = await pr();
222 assert.equal(p.state, "MERGED");
223 assert.equal(p.merged, true);
224});
225
226// gh's issue fields (api.IssueGraphQL for `gh issue list` and `gh issue view`), as gh 2.89 sends them.
227const ISSUE_FIELDS = `number,title,url,state,stateReason,createdAt,updatedAt,closedAt,isPinned,author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount},labels(first:100){nodes{id,name,description,color},totalCount},milestone{number,title,description,dueOn},comments(first:100){nodes{id,author{login,...on User{id,name}},authorAssociation,body,createdAt,includesCreatedEdit,isMinimized,minimizedReason,reactionGroups{content,users{totalCount}},url,viewerDidAuthor},pageInfo{hasNextPage,endCursor},totalCount},reactionGroups{content,users{totalCount}},body,projectCards(first:100){nodes{project{name}column{name}},totalCount}`;
228const ISSUE_LIST = `fragment issue on Issue {${ISSUE_FIELDS}}
229query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String) {
230 repository(owner: $owner, name: $repo) {
231 hasIssuesEnabled
232 issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention}) {
233 totalCount nodes { ...issue } pageInfo { hasNextPage endCursor }
234 }
235 }
236}`;
237const ISSUE_BY_NUMBER = `query IssueByNumber($owner: String!, $repo: String!, $number: Int!) {
238 repository(owner: $owner, name: $repo) {
239 hasIssuesEnabled
240 issue: issueOrPullRequest(number: $number) { __typename ...on Issue{id,${ISSUE_FIELDS}} ...on PullRequest{id,number,title,state,url} }
241 }
242}`;
243
244test("gh issue create, list, view, comment, edit, close, reopen, status and list --search", async () => {
245 // gh issue create: the repository's id (RepositoryInfo), label ids, then createIssue.
246 const labels = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 100) { nodes { id name } } } }`)).repository.labels.nodes;
247 await rest("POST", "/repos/ann/app/labels", { name: "bug", color: "d73a4a" });
248 const bug = (await data(`query { repository(owner: "ann", name: "app") { label(name: "bug") { id } } }`)).repository.label.id;
249 assert.ok(labels.some((l) => l.name === "docs"));
250 const ann = (await data(`query { repository(owner: "ann", name: "app") { assignableUsers(first: 10) { nodes { id login } } } }`)).repository.assignableUsers.nodes.find((u) => u.login === "ann");
251 const create = (input) => data(`mutation IssueCreate($input: CreateIssueInput!) { createIssue(input: $input) { issue { id url } } }`, { input });
252 const first = (await create({ repositoryId: repoId, title: "Crash on start", body: "It crashes when @bob opens it.", labelIds: [bug], assigneeIds: [ann.id] })).createIssue.issue;
253 const second = (await create({ repositoryId: repoId, title: "Docs typo", body: "A typo." })).createIssue.issue;
254 const third = (await create({ repositoryId: repoId, title: "Old idea", body: "Later." })).createIssue.issue;
255 const n = (issue) => Number(issue.url.split("/").pop());
256 assert.match(first.url, new RegExp(`^${base}/ann/app/issues/\\d+$`));
257 assert.ok(n(first) > 1, "issues share numbers with pull requests");
258
259 // gh issue list: newest first, a page at a time, without the pull request.
260 let page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2 })).repository;
261 assert.equal(page.hasIssuesEnabled, true);
262 assert.equal(page.issues.totalCount, 3);
263 assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(third), n(second)]);
264 assert.equal(page.issues.pageInfo.hasNextPage, true);
265 page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2, endCursor: page.issues.pageInfo.endCursor })).repository;
266 assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(first)]);
267 assert.equal(page.issues.pageInfo.hasNextPage, false);
268 const listed = page.issues.nodes[0];
269 assert.equal(listed.state, "OPEN");
270 assert.equal(listed.author.login, "ann");
271 assert.deepEqual(listed.labels.nodes.map((l) => l.name), ["bug"]);
272 assert.deepEqual(listed.assignees.nodes.map((u) => u.login), ["ann"]);
273 // --assignee, --author and --mention.
274 const only = async (vars) => (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 30, ...vars })).repository.issues.nodes.map((i) => i.number);
275 assert.deepEqual(await only({ assignee: "ann" }), [n(first)]);
276 assert.deepEqual(await only({ author: "ann" }), [n(third), n(second), n(first)]);
277 assert.deepEqual(await only({ mention: "bob" }), [n(first)]);
278 assert.deepEqual(await only({ mention: "bo" }), []);
279
280 // gh issue view: issueOrPullRequest, then the issue's project items (none).
281 let view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(first) })).repository.issue;
282 assert.equal(view.__typename, "Issue");
283 assert.equal(view.id, first.id);
284 assert.equal(view.body, "It crashes when @bob opens it.");
285 const pr = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: 1 })).repository.issue;
286 assert.equal(pr.__typename, "PullRequest");
287 const items = await data(`query IssueProjectItems($endCursor: String, $name: String!, $number: Int!, $owner: String!) { repository(owner: $owner, name: $name) { issue(number: $number) { projectItems(first: 100, after: $endCursor) { totalCount nodes { id project { id title } status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { optionId name } } } pageInfo { hasNextPage endCursor } } } } }`, { owner: "ann", name: "app", number: n(first) });
288 assert.equal(items.repository.issue.projectItems.totalCount, 0);
289 const missing = await gql(`query { repository(owner: "ann", name: "app") { issue(number: 1) { id } } }`);
290 assert.equal(missing.body.errors[0].type, "NOT_FOUND", "a pull request's number is no issue");
291
292 // gh issue comment, gh issue edit (title, body, labels), gh issue close and reopen.
293 const comment = await data(`mutation CommentCreate($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: first.id, body: "Same here, @bob." } });
294 assert.match(comment.addComment.commentEdge.node.url, new RegExp(`/ann/app/issues/${n(first)}#issuecomment-`));
295 await data(`mutation IssueUpdate($input: UpdateIssueInput!) { updateIssue(input: $input) { __typename } }`, { input: { id: second.id, title: "Docs: fix a typo", body: "In the README." } });
296 const docs = labels.find((l) => l.name === "docs");
297 await data(`mutation LabelAdd($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { __typename } }`, { input: { labelableId: second.id, labelIds: [docs.id] } });
298 await data(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id, stateReason: "NOT_PLANNED" } });
299 view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(second) })).repository.issue;
300 assert.equal(view.title, "Docs: fix a typo");
301 assert.equal(view.body, "In the README.");
302 assert.deepEqual(view.labels.nodes.map((l) => l.name), ["docs"]);
303 const closed = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue;
304 assert.deepEqual([closed.state, closed.stateReason], ["CLOSED", "NOT_PLANNED"]);
305 assert.deepEqual(await only({}), [n(second), n(first)], "open issues by default");
306 assert.deepEqual(await only({ states: ["CLOSED"] }), [n(third)]);
307 await data(`mutation IssueReopen($input: ReopenIssueInput!) { reopenIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id } });
308 assert.equal((await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue.state, "OPEN");
309 // An issue's id isn't a pull request's: GitHub's pull request mutations refuse it.
310 const wrong = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: first.id });
311 assert.equal(wrong.body.errors[0].type, "NOT_FOUND");
312 // A read token can look but not open or close issues.
313 const refused = await gql(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: first.id } }, T.annRead);
314 assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body));
315
316 // gh issue status: three filtered lists at once.
317 const status = await data(
318 `fragment issue on Issue{number,title} query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { hasIssuesEnabled assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } } }`,
319 { owner: "ann", repo: "app", viewer: "ann" },
320 );
321 assert.deepEqual([status.repository.assigned.totalCount, status.repository.mentioned.totalCount, status.repository.authored.totalCount], [1, 0, 3]);
322
323 // gh issue list --label/--search: GitHub's issue search, in the advanced syntax gh writes.
324 const search = async (query) => (await data(`fragment issue on Issue {number,title} query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { repository(name: $repo, owner: $owner) { hasIssuesEnabled } search(type: $type, last: $limit, after: $after, query: $query) { issueCount nodes { ...issue } pageInfo { hasNextPage endCursor } } }`, { owner: "ann", repo: "app", type: "ISSUE_ADVANCED", limit: 30, query })).search;
325 assert.deepEqual((await search(`repo:ann/app state:open type:issue label:bug`)).nodes.map((i) => i.number), [n(first)]);
326 assert.deepEqual((await search(`( crash ) repo:ann/app type:issue`)).nodes.map((i) => i.number), [n(first)]);
327 assert.equal((await search(`repo:ann/app type:issue`)).issueCount, 3);
328 assert.equal((await search(`repo:ann/app is:pr`)).issueCount, 1);
329});
330
331test("a workflow's GITHUB_TOKEN uses GraphQL with its permissions, as over REST", async () => {
332 const workflow = "name: gh\non: push\npermissions:\n contents: read\n issues: read\njobs:\n gh:\n runs-on: [self-hosted, linux]\n steps:\n - run: gh issue list\n";
333 await rest("PUT", "/repos/ann/app/contents/.github/workflows/gh.yml", { message: "gh in CI", content: Buffer.from(workflow).toString("base64") });
334 const reg = await rest("POST", "/repos/ann/app/actions/runners/registration-token");
335 const runner = await rest("POST", "/repos/ann/app/actions/runners/register", { registration_token: reg.token, name: "graphql-e2e", labels: ["linux"] });
336 let claim;
337 for (const end = Date.now() + 60_000; !claim; ) {
338 const r = await fetch(`${base}/api/v3/repos/ann/app/actions/runners/claim`, { method: "POST", headers: { authorization: `Bearer ${runner.token}`, "content-type": "application/json" }, body: "{}" });
339 if (r.status === 200) claim = await r.json();
340 else if (Date.now() > end) assert.fail(`no job to claim: ${r.status} ${await r.text()}`);
341 else await new Promise((res) => setTimeout(res, 500));
342 }
343 try {
344 // The job starts (the runner reads its spec): its token gets the workflow's permissions.
345 const spec = await fetch(`${base}/runner/jobs/${claim.job_id}`, { headers: { authorization: `Bearer ${claim.token}` } });
346 assert.equal(spec.status, 200, await spec.clone().text());
347 // The repository and its issues: `gh repo view` and `gh issue list` in the job.
348 const d = await data(`query { repository(owner: "ann", name: "app") { nameWithOwner issues(first: 10) { totalCount } } }`, {}, claim.token);
349 assert.equal(d.repository.nameWithOwner, "ann/app");
350 assert.equal(typeof d.repository.issues.totalCount, "number");
351 // Pull requests need pull-requests: read, which this job doesn't have.
352 const prs = await gql(`query { repository(owner: "ann", name: "app") { pullRequests(first: 10) { totalCount } } }`, {}, claim.token);
353 assert.equal(prs.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(prs.body));
354 assert.match(prs.body.errors[0].message, /pull-requests/);
355 // issues: read can't open an issue.
356 const opened = await gql(`mutation($input: CreateIssueInput!) { createIssue(input: $input) { issue { id } } }`, { input: { repositoryId: repoId, title: "from CI" } }, claim.token);
357 assert.equal(opened.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(opened.body));
358 assert.match(opened.body.errors[0].message, /issues: write/);
359 } finally {
360 await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
361 }
362});
363
364test("sign-in, privacy, limits and GitHub's error shape", async () => {
365 const anonymous = await gql(`query { viewer { login } }`, {}, null);
366 assert.equal(anonymous.status, 401);
367 assert.equal((await data(`query UserCurrent { viewer { login } }`)).viewer.login, "ann");
368
369 // Someone without access gets GitHub's NOT_FOUND, the same as for a repository that doesn't exist.
370 for (const [owner, name, tok] of [["ann", "app", T.bob], ["ann", "nope", T.ann]]) {
371 const r = await gql(`query($o: String!, $n: String!) { repository(owner: $o, name: $n) { id } }`, { o: owner, n: name }, tok);
372 assert.equal(r.body.data.repository, null);
373 assert.equal(r.body.errors[0].type, "NOT_FOUND");
374 }
375
376 let deep = "id";
377 for (let i = 0; i < 20; i++) deep = `parent { ${deep} }`;
378 const tooDeep = await gql(`query { repository(owner: "ann", name: "app") { ${deep} } }`);
379 assert.match(tooDeep.body.errors[0].message, /nested/);
380
381 const sub = await gql(`subscription { viewer { login } }`);
382 assert.match(sub.body.errors[0].message, /Subscriptions/);
383
384 // gh auth login checks the REST root of a GitHub Enterprise host.
385 const root = await fetch(`${base}/api/v3/`, { headers: { authorization: `Bearer ${T.ann}` } });
386 assert.equal(root.status, 200);
387 assert.equal((await root.json()).graphql, `${base}/api/graphql`);
388});