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.

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