| 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 |
| 11 | import "./fresh-connections.mjs"; |
| 12 | import { spawn, execFileSync } from "node:child_process"; |
| 13 | import { createHash, randomBytes } from "node:crypto"; |
| 14 | import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; |
| 15 | import { tmpdir } from "node:os"; |
| 16 | import { join, dirname, resolve } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; |
| 18 | import { test, before, after } from "node:test"; |
| 19 | import assert from "node:assert/strict"; |
| 20 | import { migratedState } from "./d1-state.mjs"; |
| 21 | import { freePort } from "./ports.mjs"; |
| 22 | |
| 23 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 24 | const work = mkdtempSync(join(tmpdir(), "diffinery-graphql-e2e-")); |
| 25 | let dev; |
| 26 | let base; |
| 27 | let log = ""; |
| 28 | |
| 29 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 30 | const T = { ann: `dfy_ann_${randomBytes(12).toString("hex")}`, annRead: `dfy_anr_${randomBytes(12).toString("hex")}`, bob: `dfy_bob_${randomBytes(12).toString("hex")}` }; |
| 31 | |
| 32 | before(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 | |
| 62 | after(() => { |
| 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 | |
| 69 | async 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 }. */ |
| 77 | async 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. */ |
| 83 | async 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 | |
| 90 | function 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). |
| 96 | const 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 | } |
| 107 | query { |
| 108 | rateLimit { cost remaining resetAt } |
| 109 | r0: repository(owner: "ann", name: "app") { p0_0: pullRequest(number: 1) { ...pr } } |
| 110 | }`; |
| 111 | |
| 112 | let headSha; |
| 113 | let repoId; |
| 114 | let prId; |
| 115 | |
| 116 | test("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 | |
| 148 | test("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 | |
| 158 | test("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 | |
| 170 | test("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 | |
| 194 | test("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. |
| 227 | const 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}`; |
| 228 | const ISSUE_LIST = `fragment issue on Issue {${ISSUE_FIELDS}} |
| 229 | query 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 | }`; |
| 237 | const 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 | |
| 244 | test("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 | |
| 331 | test("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 | |
| 364 | test("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 | }); |