gh and Claude Code work with Diffinery: GitHub-compatible GraphQL API, gh pr commands, PR chip with CI status (#200)
* GraphQL API (read-only) at /api/graphql, so gh and Claude Code see Diffinery pull requests and CI * GraphQL: node, search, refs with protection, lenient validation for gh; REST diff media type for gh pr diff * GraphQL mutations for gh pr create, edit, ready, merge, comment, close and reopen; UNSTABLE merge state; wide database ids * gh pr create flags (labels, assignees, reviewers), GraphQL docs, diffinery auth setup-gh, tokens gh can mask, e2e tests
- CI / Typecheck and bundleFailed
- CI / Unit tests (1/2)Failed
- CI / Unit tests (2/2)Failed
- End-to-end / End-to-end (agent reviews, tasks, Today)Failed
- End-to-end / End-to-end (git, PRs, Actions)Failed
- End-to-end / End-to-end (large pushes)Failed
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)Failed
- End-to-end / End-to-end (security)Failed
22 files changed+2223−15
Files (22)
- apps/cli/src/commands/auth.ts+41−1
- apps/cli/test/credential.test.ts+23−1
- apps/diffinery/docs/actions/compatibility.md+2−2
- apps/diffinery/docs/actions/expressions.md+2−2
- apps/diffinery/docs/agents/github-cli.md+73−0
- apps/diffinery/docs/migrate-from-github.md+2−2
- apps/diffinery/docs/nav.json+2−0
- apps/diffinery/docs/reference/graphql-api.md+97−0
- apps/diffinery/docs/reference/rest-api.md+3−3
- apps/diffinery/package.json+1−0
- apps/diffinery/src/app.ts+2−0
- apps/diffinery/src/http/api.ts+7−1
- apps/diffinery/src/http/graphql-schema.ts+589−0
- apps/diffinery/src/http/graphql.ts+1021−0
- apps/diffinery/src/lib/directory.ts+4−2
- apps/diffinery/src/lib/util.ts+14−0
- apps/diffinery/src/repo/RepoDO.ts+6−0
- apps/diffinery/src/repo/collab.ts+43−0
- apps/diffinery/test/e2e/graphql.test.mjs+255−0
- apps/diffinery/test/unit/cli-login.test.ts+2−1
- apps/diffinery/test/unit/graphql.test.ts+25−0
- pnpm-lock.yaml+9−0
modifiedapps/cli/src/commands/auth.ts+41−1View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Api } from "../api.js"; |
| 2 | 2 | import { str, type Command, type Group } from "../cli.js"; |
| 3 | 3 | import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; |
| 4 | +import { spawn } from "node:child_process"; | |
| 4 | 5 | import { gitCapture } from "../git.js"; |
| 5 | 6 | import { CliError, readAll } from "../io.js"; |
| 6 | 7 | import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js"; |
| @@ -116,6 +117,45 @@ | ||
| 116 | 117 | }, |
| 117 | 118 | }; |
| 118 | 119 | |
| 120 | +/** Runs gh with `input` on stdin; output is captured, so a token on stdin never reaches the terminal. */ | |
| 121 | +function ghCapture(env: NodeJS.ProcessEnv, args: string[], input: string): Promise<{ code: number; stderr: string }> { | |
| 122 | + return new Promise((resolve) => { | |
| 123 | + let child; | |
| 124 | + try { | |
| 125 | + child = spawn("gh", args, { env, stdio: ["pipe", "pipe", "pipe"] }); | |
| 126 | + } catch (e) { | |
| 127 | + return resolve({ code: 127, stderr: String((e as Error).message) }); | |
| 128 | + } | |
| 129 | + let stderr = ""; | |
| 130 | + child.stdout.on("data", () => {}); | |
| 131 | + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 132 | + child.on("error", (e) => resolve({ code: 127, stderr: (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed (https://cli.github.com)" : e.message })); | |
| 133 | + child.on("close", (code) => resolve({ code: code ?? 1, stderr })); | |
| 134 | + child.stdin.on("error", () => {}); | |
| 135 | + child.stdin.end(input); | |
| 136 | + }); | |
| 137 | +} | |
| 138 | + | |
| 139 | +const setupGh: Command = { | |
| 140 | + name: "auth setup-gh", | |
| 141 | + summary: "Sign the GitHub CLI (gh) in to Diffinery with your token", | |
| 142 | + description: `Runs: gh auth login --hostname <host> --with-token --git-protocol https, with the token | |
| 143 | +from the config file or DIFFINERY_TOKEN on its standard input. gh then treats the Diffinery host | |
| 144 | +like GitHub Enterprise Server: gh pr create, view, checks, status, merge and the rest work in | |
| 145 | +clones of Diffinery repositories, and so does the pull request badge of Claude Code, which | |
| 146 | +asks gh for its token.`, | |
| 147 | + maxArgs: 0, | |
| 148 | + async run(ctx) { | |
| 149 | + const cfg = await ctx.config(); | |
| 150 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 151 | + const url = new URL(cfg.url); | |
| 152 | + if (url.protocol !== "https:") throw new CliError(`gh only talks to hosts over HTTPS; ${cfg.url} is ${url.protocol.replace(/:$/, "")}`); | |
| 153 | + const r = await ghCapture(ctx.io.env, ["auth", "login", "--hostname", url.host, "--with-token", "--git-protocol", "https"], `${cfg.token}\n`); | |
| 154 | + if (r.code !== 0) throw new CliError(`gh auth login failed: ${r.stderr.trim() || `exit ${r.code}`}`); | |
| 155 | + ctx.out(`${ctx.c.green("✓")} gh is signed in to ${url.host}`); | |
| 156 | + }, | |
| 157 | +}; | |
| 158 | + | |
| 119 | 159 | /** Parses the git credential helper protocol's key=value lines. */ |
| 120 | 160 | export function parseCredentialInput(text: string): Record<string, string> { |
| 121 | 161 | const out: Record<string, string> = {}; |
| @@ -165,4 +205,4 @@ | ||
| 165 | 205 | }; |
| 166 | 206 | |
| 167 | 207 | export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; |
| 168 | -export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] }; | |
| 208 | +export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] }; |
modifiedapps/cli/test/credential.test.ts+23−1View file
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import { execFileSync } from "node:child_process"; |
| 2 | +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 2 | 4 | import { describe, expect, it } from "vitest"; |
| 3 | 5 | import { parseCredentialInput } from "../src/commands/auth.js"; |
| 4 | -import { isolatedEnv, run } from "./helpers.js"; | |
| 6 | +import { isolatedEnv, run, tempDir } from "./helpers.js"; | |
| 5 | 7 | |
| 6 | 8 | describe("git credential helper", () => { |
| 7 | 9 | const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" }); |
| @@ -51,4 +53,24 @@ | ||
| 51 | 53 | await run(["auth", "setup-git"], { env }); |
| 52 | 54 | expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]); |
| 53 | 55 | }); |
| 56 | + | |
| 57 | + it("auth setup-gh signs gh in to the host with the token on stdin, never in its arguments", async () => { | |
| 58 | + const bin = tempDir("diffinery-fake-gh-"); | |
| 59 | + const seen = path.join(bin, "seen"); | |
| 60 | + writeFileSync(path.join(bin, "gh"), `#!/bin/sh\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n`); | |
| 61 | + chmodSync(path.join(bin, "gh"), 0o755); | |
| 62 | + const e = { ...env, PATH: `${bin}${path.delimiter}${env.PATH}` }; | |
| 63 | + const r = await run(["auth", "setup-gh"], { env: e }); | |
| 64 | + expect(r.code).toBe(0); | |
| 65 | + expect(r.stdout).toContain("gh is signed in to forge.example.com"); | |
| 66 | + expect(r.stdout).not.toContain("dfy_secret"); | |
| 67 | + expect(readFileSync(`${seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https"); | |
| 68 | + expect(readFileSync(`${seen}.stdin`, "utf8")).toBe("dfy_secret\n"); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("auth setup-gh refuses plain HTTP, which gh doesn't speak", async () => { | |
| 72 | + const r = await run(["auth", "setup-gh"], { env: { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" } }); | |
| 73 | + expect(r.code).not.toBe(0); | |
| 74 | + expect(r.stderr).toContain("HTTPS"); | |
| 75 | + }); | |
| 54 | 76 | }); |
modifiedapps/diffinery/docs/actions/compatibility.md+2−2View file
| @@ -250,7 +250,7 @@ | ||
| 250 | 250 | }); |
| 251 | 251 | ``` |
| 252 | 252 | |
| 253 | -`github.graphql` fails, because Diffinery has no GraphQL API yet. REST calls to endpoints Diffinery doesn't have get a JSON `404 Not Found`. See [the REST API reference](/docs/reference/rest-api) for the endpoints Diffinery has. | |
| 253 | +`github.graphql` fails: Diffinery's [GraphQL API](/docs/reference/graphql-api) answers personal and agent tokens, not a job's token. REST calls to endpoints Diffinery doesn't have get a JSON `404 Not Found`. See [the REST API reference](/docs/reference/rest-api) for the endpoints Diffinery has. | |
| 254 | 254 | |
| 255 | 255 | ## Other actions |
| 256 | 256 | |
| @@ -298,7 +298,7 @@ | ||
| 298 | 298 | |
| 299 | 299 | ### Actions that call the GitHub API |
| 300 | 300 | |
| 301 | -Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because GraphQL isn't supported yet. | |
| 301 | +Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because it doesn't accept a job's token yet. | |
| 302 | 302 | |
| 303 | 303 | ### Actions that use the cache or artifact services |
| 304 | 304 |
modifiedapps/diffinery/docs/actions/expressions.md+2−2View file
| @@ -240,7 +240,7 @@ | ||
| 240 | 240 | | `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) | |
| 241 | 241 | | `github.server_url` | `https://diffinery.com` | |
| 242 | 242 | | `github.api_url` | `https://diffinery.com/api/v3` | |
| 243 | -| `github.graphql_url` | `https://diffinery.com/api/graphql`. Set for compatibility only: Diffinery has no GraphQL API yet | | |
| 243 | +| `github.graphql_url` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](/docs/reference/graphql-api) yet | | |
| 244 | 244 | | `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) | |
| 245 | 245 | | `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` | |
| 246 | 246 | | `github.retention_days` | `90` | |
| @@ -370,7 +370,7 @@ | ||
| 370 | 370 | | `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` | |
| 371 | 371 | | `GITHUB_SERVER_URL` | `https://diffinery.com` | |
| 372 | 372 | | `GITHUB_API_URL` | `https://diffinery.com/api/v3` | |
| 373 | -| `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. Set for compatibility only: Diffinery has no GraphQL API yet | | |
| 373 | +| `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](/docs/reference/graphql-api) yet | | |
| 374 | 374 | | `GITHUB_CUSTOM_API_URL`, `GITHUB_CUSTOM_SERVER_URL` | The same as `GITHUB_API_URL` and `GITHUB_SERVER_URL`. Tools built for GitHub Enterprise, such as super-linter, read these; without them they call api.github.com with the job's token | |
| 375 | 375 | | `GITHUB_WORKFLOW` | The workflow's name, or its path | |
| 376 | 376 | | `GITHUB_WORKFLOW_REF` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | |
addedapps/diffinery/docs/agents/github-cli.md+73−0View file
| @@ -1,0 +1,73 @@ | ||
| 1 | +--- | |
| 2 | +title: GitHub CLI and Claude Code | |
| 3 | +description: Use gh with Diffinery repositories, and get Claude Code's pull request badge and CI status for them, by signing gh in to Diffinery once. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +The GitHub CLI (`gh`) works with Diffinery the way it works with a GitHub Enterprise Server: in a clone of a Diffinery repository, `gh pr create`, `gh pr view`, `gh pr checks`, `gh pr merge` and the other pull request commands talk to Diffinery. Claude Code uses the same setup to show the pull request of the branch you're on and its CI, because it finds pull requests through gh's sign-in. Both go through Diffinery's [GraphQL API](/docs/reference/graphql-api) and [REST API](/docs/reference/rest-api). | |
| 7 | + | |
| 8 | +## Set it up | |
| 9 | + | |
| 10 | +You need a recent `gh` (Diffinery is tested with 2.89) and a Diffinery token. With the [Diffinery CLI](/docs/accounts/cli-sign-in) signed in, one command signs gh in too: | |
| 11 | + | |
| 12 | +```sh | |
| 13 | +diffinery login | |
| 14 | +diffinery auth setup-gh | |
| 15 | +``` | |
| 16 | + | |
| 17 | +`diffinery auth setup-gh` runs `gh auth login --hostname diffinery.com --with-token --git-protocol https` and hands gh your token on its standard input, so the token never shows in your shell history or on screen. gh keeps it in its own credential store. | |
| 18 | + | |
| 19 | +Without the Diffinery CLI, create a token under **Settings → Access tokens** (`write` scope, so gh can open and merge pull requests), save it to a file, and give it to gh the same way: | |
| 20 | + | |
| 21 | +```sh | |
| 22 | +gh auth login --hostname diffinery.com --with-token --git-protocol https < ~/diffinery-token.txt | |
| 23 | +``` | |
| 24 | + | |
| 25 | +In CI or a container, set the host and token in the environment instead. gh and Claude Code both read them: | |
| 26 | + | |
| 27 | +```sh | |
| 28 | +export GH_HOST=diffinery.com | |
| 29 | +export GH_ENTERPRISE_TOKEN=... # from your secret store | |
| 30 | +``` | |
| 31 | + | |
| 32 | +Check it with `gh api user --hostname diffinery.com --jq .login`, which prints your login. | |
| 33 | + | |
| 34 | +## Use gh | |
| 35 | + | |
| 36 | +In a clone whose `origin` is on Diffinery, gh picks the host from the remote: | |
| 37 | + | |
| 38 | +```sh | |
| 39 | +gh pr create --title "Fix the parser" --body "..." --draft | |
| 40 | +gh pr view --web | |
| 41 | +gh pr checks --watch | |
| 42 | +gh pr ready | |
| 43 | +gh pr merge --auto --squash | |
| 44 | +``` | |
| 45 | + | |
| 46 | +| Works | Commands | | |
| 47 | +| --- | --- | | |
| 48 | +| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh repo view`, `gh api` (REST and `gh api graphql`) | | |
| 49 | +| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`) | | |
| 50 | +| Not yet | Team reviewers, projects, and commands outside pull requests that need GraphQL, such as `gh issue` and `gh release` (use `gh api` with the REST paths) | | |
| 51 | + | |
| 52 | +gh changes things with your token's permissions and Diffinery's rules: branch protection, required checks and reviews, and the [agent guardrails](/docs/agents/overview) apply as they do on the web and over REST. `gh pr merge --auto` turns on Diffinery's auto-merge, which merges once the required checks pass. When nothing blocks the merge, gh merges at once. | |
| 53 | + | |
| 54 | +## Claude Code's pull request badge | |
| 55 | + | |
| 56 | +Claude Code shows the pull request for the branch a session is on, and follows it as it changes: | |
| 57 | + | |
| 58 | +- **In the terminal**, the footer shows `PR #12` for the current branch's open pull request. | |
| 59 | +- **In the Claude desktop app**, the session's pull request chip shows the pull request and its CI. Claude Code reports the branch's pull request when a session starts, and a pull request it opens with `gh pr create` as soon as gh prints its URL. | |
| 60 | + | |
| 61 | +There's nothing to set up beyond signing gh in. Claude Code treats any host other than github.com as GitHub Enterprise: it reads the remote of the folder it's in, gets the token from `GH_ENTERPRISE_TOKEN` (when `GH_HOST` names the host) or from `gh auth token --hostname diffinery.com`, and asks Diffinery's REST and GraphQL APIs for the pull request, its review decision and its CI counts. In the desktop app, the folder has to be one you've trusted. | |
| 62 | + | |
| 63 | +Sessions in Claude Code on the web (claude.ai/code) run in Anthropic's cloud and get their pull request status from Anthropic's GitHub integration, which only reaches github.com. Their badge doesn't show Diffinery pull requests. Those sessions can still use gh and the [MCP server](/docs/agents/mcp) to open pull requests and follow CI on Diffinery. | |
| 64 | + | |
| 65 | +## Troubleshooting | |
| 66 | + | |
| 67 | +| Symptom | Cause and fix | | |
| 68 | +| --- | --- | | |
| 69 | +| gh asks you to run `gh auth login` in a Diffinery clone | gh has no token for the host. Run `diffinery auth setup-gh`, or check that `GH_HOST` matches the remote's host. | | |
| 70 | +| `HTTP 401` from gh | The token expired or was revoked. Create a new one and sign gh in again. | | |
| 71 | +| `gh pr create` or `gh pr merge` fails with a permission error | The token has the `read` scope only, or you need write access to the repository. | | |
| 72 | +| No badge in the terminal | The branch has no open pull request, or gh isn't signed in to the host. `gh pr view` in the same folder shows which. | | |
| 73 | +| `gh auth status` shows most of an older token | gh hides a token only after its last `_`. Tokens Diffinery creates now have no `_` after `dfy_`; if yours does, create a new one and sign gh in again. | |
modifiedapps/diffinery/docs/migrate-from-github.md+2−2View file
| @@ -817,7 +817,7 @@ | ||
| 817 | 817 | | If a workflow... | Do this on Diffinery | |
| 818 | 818 | | --- | --- | |
| 819 | 819 | | Uses `runs-on: windows-*` or `macos-*` | Diffinery has no Windows or macOS hosted runners: Cloudflare Containers run Linux only. Run the job on a self-hosted runner (`diffinery runner`). See [Runners](/docs/actions/runners). | |
| 820 | -| Uses `actions/github-script` | Works with the REST API (`github.rest.*`) and the job's token. `github.graphql` doesn't: Diffinery has no GraphQL API yet. | | |
| 820 | +| Uses `actions/github-script` | Works with the REST API (`github.rest.*`) and the job's token. `github.graphql` doesn't: the job's token can't use Diffinery's [GraphQL API](/docs/reference/graphql-api) yet. | | |
| 821 | 821 | | Relies on `GITHUB_TOKEN` writing (comments, labels, statuses, releases, pushes) without a `permissions:` block | Repositories created on Diffinery, imported ones included, give the job token GitHub's restricted default, `contents` and `packages` read. Add a `permissions:` block (for example `contents: write`, `issues: write`, `pull-requests: write`) to the workflow or job, or have an admin set the repository default to write. Each endpoint needs its permission, as on GitHub; a missing one gets `403 Resource not accessible by integration`. See [Workflow permissions](/docs/actions/workflow-permissions). | |
| 822 | 822 | | Pushes commits or tags using `GITHUB_TOKEN` | Works with `permissions: contents: write`, as on GitHub, within branch protection. The token can't change files under `.github/workflows/`, and a push made with it starts no new `push` workflows. | |
| 823 | 823 | | Opens or approves pull requests using `GITHUB_TOKEN` | Refused until a repository admin allows it (`can_approve_pull_request_reviews` in the workflow permissions setting, off by default, as GitHub's **Allow GitHub Actions to create and approve pull requests**). Approvals from the token never count toward required approvals. | |
| @@ -841,7 +841,7 @@ | ||
| 841 | 841 | const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" }); |
| 842 | 842 | ``` |
| 843 | 843 | |
| 844 | -Inside workflows, `${{ github.api_url }}` is already `https://diffinery.com/api/v3`. Imported people without a Diffinery account come back as user objects with `"type": "Mannequin"` and a `github:` login, so tools that look up users by login should expect them. Differences to check, including endpoints and features that are not supported yet (such as GraphQL), are listed in the [REST API reference](/docs/reference/rest-api). | |
| 844 | +Inside workflows, `${{ github.api_url }}` is already `https://diffinery.com/api/v3`. Imported people without a Diffinery account come back as user objects with `"type": "Mannequin"` and a `github:` login, so tools that look up users by login should expect them. Differences to check, including endpoints and features that are not supported yet, are listed in the [REST API reference](/docs/reference/rest-api). The [GraphQL API](/docs/reference/graphql-api) covers repositories, pull requests and CI, which is what the GitHub CLI needs (see [GitHub CLI and Claude Code](/docs/agents/github-cli)). | |
| 845 | 845 | |
| 846 | 846 | ## Keep GitHub as upstream and mirror to Diffinery |
| 847 | 847 |
addedapps/diffinery/docs/reference/graphql-api.md+97−0View file
| @@ -1,0 +1,97 @@ | ||
| 1 | +--- | |
| 2 | +title: GraphQL API | |
| 3 | +description: The GitHub-shaped GraphQL endpoint at /api/graphql that the GitHub CLI and Claude Code use for pull requests and CI: what it answers, limits and errors. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery answers GraphQL at `https://diffinery.com/api/graphql` with the part of GitHub's GraphQL schema that covers repositories, pull requests, reviews and CI. Type, field, argument and enum names are GitHub's, so tools written for GitHub's GraphQL API work unchanged for those queries. The main users are the GitHub CLI (`gh pr create`, `gh pr view`, `gh pr checks`, `gh pr status`, `gh pr merge` and the rest, see [GitHub CLI and Claude Code](/docs/agents/github-cli)) and Claude Code, which shows a session's pull request and its CI from it. | |
| 7 | + | |
| 8 | +Everything else (issues, releases, Actions, settings and so on) is in the [REST API](/docs/reference/rest-api). | |
| 9 | + | |
| 10 | +## Requests | |
| 11 | + | |
| 12 | +Send a `POST` with a JSON body and a token, as on GitHub: | |
| 13 | + | |
| 14 | +```sh | |
| 15 | +curl https://diffinery.com/api/graphql \ | |
| 16 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 17 | + -H "Content-Type: application/json" \ | |
| 18 | + -d '{"query": "query { viewer { login } }"}' | |
| 19 | +``` | |
| 20 | + | |
| 21 | +| Property | Value | | |
| 22 | +| --- | --- | | |
| 23 | +| URL | `https://diffinery.com/api/graphql` | | |
| 24 | +| Method | `POST` with `{"query": "...", "variables": {...}, "operationName": "..."}`. A `GET` answers `405`. | | |
| 25 | +| Authentication | Required. A personal access token or agent token (`Authorization: Bearer dfy_...`, or `token dfy_...`), or a browser session. Without one the answer is `401`. A workflow job's token can't use it yet: in workflows, use the REST API. | | |
| 26 | +| Token scopes | `read` is enough for queries. Mutations need `write`, like the REST calls that make the same change. | | |
| 27 | +| Permissions | The same as over REST: a repository you can't see answers as if it didn't exist, and each mutation needs the role its REST equivalent needs. Branch protection, required checks, required reviews and the agent rules apply to merges here too. | | |
| 28 | +| Introspection | `__schema` and `__type` work, so you can read the whole schema with any GraphQL client. | | |
| 29 | +| Subscriptions | Not supported. | | |
| 30 | + | |
| 31 | +## What it answers | |
| 32 | + | |
| 33 | +**Queries** | |
| 34 | + | |
| 35 | +| Field | What you get | | |
| 36 | +| --- | --- | | |
| 37 | +| `viewer` | The signed-in account. | | |
| 38 | +| `repository(owner, name)` | A repository: its id, name, owner, visibility, default branch, merge methods, your permission, `pullRequest(number)`, `pullRequests(states, headRefName, baseRefName, labels, orderBy)`, `labels`, `milestones`, `assignableUsers` and `mentionableUsers`. A renamed repository is found by its old name. | | |
| 39 | +| `user(login)` | A person or agent by login. | | |
| 40 | +| `node(id)`, `nodes(ids)` | A repository or pull request by its global id (the `node_id` of the REST API). | | |
| 41 | +| `search(query, type: ISSUE)` | Pull requests in one repository. The query needs a `repo:owner/name` qualifier and understands `is:pr`, `is:open`, `is:closed`, `is:merged`, `state:`, `author:`, `assignee:`, `review-requested:`, `head:`, `base:`, `label:` and `draft:`, with `@me` for yourself. This is what `gh pr status` sends. | | |
| 42 | +| `rateLimit` | A budget in GitHub's shape. Diffinery limits requests per account (see [Limits](#limits)), not by query cost. | | |
| 43 | + | |
| 44 | +A `PullRequest` has its number, title, body, state, draft flag, author, branches (`baseRef` and `headRef`, with the branch's protection rule) and head commit, additions, deletions and changed files, labels, assignees, milestone, review requests, reviews and `latestReviews`, `reviewDecision`, comments, files, commits, `mergeable` and `mergeStateStatus`, auto-merge (`autoMergeRequest`) and `statusCheckRollup`. | |
| 45 | + | |
| 46 | +**CI.** A commit's `statusCheckRollup` combines its check runs and commit statuses: `state` is `SUCCESS`, `FAILURE`, `ERROR`, `PENDING` or `EXPECTED`, and `contexts` lists each `CheckRun` and `StatusContext`, with `checkRunCountsByState` and `statusContextCountsByState` for the counts. A check run from Diffinery Actions has its workflow under `checkSuite.workflowRun.workflow.name`, and `isRequired(pullRequestNumber:)` says whether the base branch requires it. | |
| 47 | + | |
| 48 | +**Mutations** | |
| 49 | + | |
| 50 | +| Mutation | What it does | `gh` command | | |
| 51 | +| --- | --- | --- | | |
| 52 | +| `createPullRequest` | Opens a pull request, as a draft with `draft: true`. | `gh pr create` | | |
| 53 | +| `updatePullRequest` | Changes the title, body, state, labels, assignees or milestone. The base branch can't change. | `gh pr edit` | | |
| 54 | +| `addLabelsToLabelable`, `removeLabelsFromLabelable` | Adds or removes labels. | `gh pr edit --add-label`, `--remove-label` | | |
| 55 | +| `addAssigneesToAssignable`, `removeAssigneesFromAssignable` | Adds or removes assignees. | `gh pr edit --add-assignee`, `--remove-assignee` | | |
| 56 | +| `requestReviews` | Asks people for a review. Team reviewers aren't supported. | `gh pr create --reviewer`, `gh pr edit --add-reviewer` | | |
| 57 | +| `markPullRequestReadyForReview`, `convertPullRequestToDraft` | Moves a pull request out of draft or back into it. | `gh pr ready`, `gh pr ready --undo` | | |
| 58 | +| `addComment` | Comments on a pull request. | `gh pr comment` | | |
| 59 | +| `closePullRequest`, `reopenPullRequest` | Closes or reopens a pull request. | `gh pr close`, `gh pr reopen` | | |
| 60 | +| `mergePullRequest` | Merges with `MERGE`, `SQUASH` or `REBASE`, if nothing blocks it. | `gh pr merge` | | |
| 61 | +| `enablePullRequestAutoMerge`, `disablePullRequestAutoMerge` | Turns [auto-merge](/docs/collaboration/pull-requests) on or off. | `gh pr merge --auto`, `--disable-auto` | | |
| 62 | + | |
| 63 | +## Fields Diffinery doesn't have | |
| 64 | + | |
| 65 | +GitHub's schema is much larger than this one. To keep tools that ask for a few extra fields working, a field, argument or type that Diffinery doesn't know is left out of the answer instead of failing the whole query, and the answer says which: | |
| 66 | + | |
| 67 | +```json | |
| 68 | +{ | |
| 69 | + "data": { "repository": { "pullRequest": { "number": 1 } } }, | |
| 70 | + "extensions": { "unsupported": ["Cannot query field \"notAThing\" on type \"PullRequest\"."] } | |
| 71 | +} | |
| 72 | +``` | |
| 73 | + | |
| 74 | +Lists Diffinery has no data for answer empty: `projectItems` and `reactionGroups`. Team review requests aren't supported. `isCrossRepository` is always `false`, since pull requests come from branches of the same repository. | |
| 75 | + | |
| 76 | +## Errors | |
| 77 | + | |
| 78 | +Errors use GitHub's shape: a `message`, the `path` of the field, and a `type` such as `NOT_FOUND`, `FORBIDDEN`, `UNPROCESSABLE` or `RATE_LIMITED`, both at the top of the error and under `extensions`. The GitHub CLI reads `type`. A field that fails is `null` while the rest of the answer still comes back. | |
| 79 | + | |
| 80 | +```json | |
| 81 | +{ | |
| 82 | + "data": { "repository": null }, | |
| 83 | + "errors": [{ "type": "NOT_FOUND", "path": ["repository"], "message": "Could not resolve to a Repository with the name 'octo/nope'." }] | |
| 84 | +} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +## Limits | |
| 88 | + | |
| 89 | +| What | Limit | | |
| 90 | +| --- | --- | | |
| 91 | +| Query text | 64 KB, 20,000 tokens | | |
| 92 | +| Nesting | 16 levels | | |
| 93 | +| Fields in one query, fragments counted where they're spread | 2,000 | | |
| 94 | +| Page size (`first`, `last`) | 100 | | |
| 95 | +| Repositories in one query | 25 | | |
| 96 | + | |
| 97 | +Each request counts against your account's request limit like a REST call, and `createPullRequest` and `addComment` also count against the limit on creating things (60 a minute), as `POST /pulls` and comments do over REST. See [Rate limits](/docs/reference/rest-api#rate-limits). |
modifiedapps/diffinery/docs/reference/rest-api.md+3−3View file
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | ``` |
| 21 | 21 | |
| 22 | 22 | ```json |
| 23 | -{ "name": "Diffinery", "api": "v3", "docs": "https://diffinery.com/docs/api", "mcp": "https://diffinery.com/mcp" } | |
| 23 | +{ "name": "Diffinery", "api": "v3", "docs": "https://diffinery.com/docs/api", "mcp": "https://diffinery.com/mcp", "graphql": "https://diffinery.com/api/graphql" } | |
| 24 | 24 | ``` |
| 25 | 25 | |
| 26 | 26 | `GET /api/v3/meta` returns `{"name": "Diffinery", "oidc": true}`, where `oidc` says whether this instance can issue OpenID Connect tokens to workflow jobs. |
| @@ -90,7 +90,7 @@ | ||
| 90 | 90 | | Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. | |
| 91 | 91 | | Webhooks and tokens | Strings. | |
| 92 | 92 | |
| 93 | -`node_id` is an opaque string, stable for an object. Diffinery has no GraphQL API, so nothing accepts a `node_id` as input. | |
| 93 | +`node_id` is an opaque string, stable for an object. The [GraphQL API](/docs/reference/graphql-api) accepts the `node_id` of a repository or pull request in `node(id:)` and in its mutations. | |
| 94 | 94 | |
| 95 | 95 | ### Differences to plan for |
| 96 | 96 | |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | |
| 643 | 643 | ## Not supported yet |
| 644 | 644 | |
| 645 | -- GraphQL (`/api/graphql`). | |
| 645 | +- GraphQL (`/api/graphql`) beyond repositories, pull requests and CI. See [GraphQL API](/docs/reference/graphql-api) for what it answers. | |
| 646 | 646 | - Setting secrets with a sealed-box `encrypted_value`; send `value` instead. |
| 647 | 647 | - Team reviewers and custom deployment protection rules on environments. |
| 648 | 648 | - Forks, and team reviewers. |
modifiedapps/diffinery/package.json+1−0View file
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | "@jsquash/resize": "2.1.1", |
| 26 | 26 | "@jsquash/webp": "1.5.0", |
| 27 | 27 | "@simplewebauthn/server": "14.0.3", |
| 28 | + "graphql": "16.14.2", | |
| 28 | 29 | "hono": "^4.13.0", |
| 29 | 30 | "shiki": "4.5.0", |
| 30 | 31 | "yaml": "^2.9.1" |
modifiedapps/diffinery/src/app.ts+2−0View file
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | import { files } from "./http/files.js"; |
| 23 | 23 | import { webhookPayload } from "./lib/webhook-payload.js"; |
| 24 | 24 | import { mcp } from "./mcp/server.js"; |
| 25 | +import { graphql } from "./http/graphql.js"; | |
| 25 | 26 | import { registry } from "./registry/http.js"; |
| 26 | 27 | import { packagesApi } from "./registry/api.js"; |
| 27 | 28 | import { RegistryStore } from "./registry/store.js"; |
| @@ -107,6 +108,7 @@ | ||
| 107 | 108 | app.route("/", registry); |
| 108 | 109 | app.route("/", packagesApi); |
| 109 | 110 | app.route("/", actionsRuntime); |
| 111 | +app.route("/", graphql); | |
| 110 | 112 | app.route("/", api); |
| 111 | 113 | app.route("/", files); |
| 112 | 114 | app.route("/", mcp); |
modifiedapps/diffinery/src/http/api.ts+7−1View file
| @@ -66,7 +66,8 @@ | ||
| 66 | 66 | |
| 67 | 67 | // ---------------------------------------------------------------- meta & users |
| 68 | 68 | |
| 69 | -api.get("/", (c) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp` })); | |
| 69 | +export const apiRootJson = (c: AppContext) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp`, graphql: `${c.env.PUBLIC_URL}/api/graphql` }); | |
| 70 | +api.get("/", apiRootJson); | |
| 70 | 71 | api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY, runner_image: c.env.RUNNER_IMAGE ?? null })); |
| 71 | 72 | /** Site admin: the newest unexpected server errors (lib/server-errors.ts), and one by the request id its 5xx answer carried. */ |
| 72 | 73 | api.get("/diffinery/admin/errors", async (c) => { |
| @@ -880,6 +881,11 @@ | ||
| 880 | 881 | }); |
| 881 | 882 | api.get("/repos/:owner/:repo/pulls/:number", async (c) => { |
| 882 | 883 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 884 | + // GitHub's diff media type (`gh pr diff` asks for it): the changes as one unified diff. | |
| 885 | + if (/application\/vnd\.github(\.v3)?\.(diff|patch)/.test(c.req.header("accept") ?? "")) { | |
| 886 | + const res = await stub.pullFiles(num(c)); | |
| 887 | + return new Response(res.files.map((f) => f.patch ?? "").join(""), { headers: { "content-type": "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } }); | |
| 888 | + } | |
| 883 | 889 | return c.json(await pullDetail(c, repo, stub, num(c), caller)); |
| 884 | 890 | }); |
| 885 | 891 | api.patch("/repos/:owner/:repo/pulls/:number", async (c) => { |
addedapps/diffinery/src/http/graphql-schema.ts+589−0View file
| @@ -1,0 +1,589 @@ | ||
| 1 | +/** | |
| 2 | + * The part of GitHub's GraphQL schema (https://docs.github.com/graphql) that Diffinery answers at | |
| 3 | + * /api/graphql. Names, types and enum values are GitHub's, so the GitHub CLI and tools built on it | |
| 4 | + * (Claude Code's pull request badge, `gh pr view`, `gh pr checks`, `gh pr list`, `gh pr status`, | |
| 5 | + * `gh pr create`, `gh pr merge`, `gh pr ready`, `gh pr comment`) read and change Diffinery as a | |
| 6 | + * GitHub Enterprise Server host. The mutations are the pull request ones gh uses; everything else | |
| 7 | + * changes through the REST API. | |
| 8 | + */ | |
| 9 | +export const SCHEMA_SDL = /* GraphQL */ ` | |
| 10 | +"""Ids from the REST API, which can be wider than GraphQL's 32-bit Int (GitHub types these as Int).""" | |
| 11 | +scalar BigInt | |
| 12 | +scalar DateTime | |
| 13 | +scalar URI | |
| 14 | +scalar GitObjectID | |
| 15 | +scalar HTML | |
| 16 | +scalar GitTimestamp | |
| 17 | + | |
| 18 | +schema { query: Query, mutation: Mutation } | |
| 19 | + | |
| 20 | +type Mutation { | |
| 21 | + addAssigneesToAssignable(input: AddAssigneesToAssignableInput!): AddAssigneesToAssignablePayload | |
| 22 | + addComment(input: AddCommentInput!): AddCommentPayload | |
| 23 | + addLabelsToLabelable(input: AddLabelsToLabelableInput!): AddLabelsToLabelablePayload | |
| 24 | + closePullRequest(input: ClosePullRequestInput!): ClosePullRequestPayload | |
| 25 | + convertPullRequestToDraft(input: ConvertPullRequestToDraftInput!): ConvertPullRequestToDraftPayload | |
| 26 | + createPullRequest(input: CreatePullRequestInput!): CreatePullRequestPayload | |
| 27 | + disablePullRequestAutoMerge(input: DisablePullRequestAutoMergeInput!): DisablePullRequestAutoMergePayload | |
| 28 | + enablePullRequestAutoMerge(input: EnablePullRequestAutoMergeInput!): EnablePullRequestAutoMergePayload | |
| 29 | + markPullRequestReadyForReview(input: MarkPullRequestReadyForReviewInput!): MarkPullRequestReadyForReviewPayload | |
| 30 | + mergePullRequest(input: MergePullRequestInput!): MergePullRequestPayload | |
| 31 | + removeAssigneesFromAssignable(input: RemoveAssigneesFromAssignableInput!): RemoveAssigneesFromAssignablePayload | |
| 32 | + removeLabelsFromLabelable(input: RemoveLabelsFromLabelableInput!): RemoveLabelsFromLabelablePayload | |
| 33 | + reopenPullRequest(input: ReopenPullRequestInput!): ReopenPullRequestPayload | |
| 34 | + requestReviews(input: RequestReviewsInput!): RequestReviewsPayload | |
| 35 | + updatePullRequest(input: UpdatePullRequestInput!): UpdatePullRequestPayload | |
| 36 | +} | |
| 37 | + | |
| 38 | +input CreatePullRequestInput { | |
| 39 | + baseRefName: String! | |
| 40 | + body: String | |
| 41 | + clientMutationId: String | |
| 42 | + draft: Boolean = false | |
| 43 | + headRefName: String! | |
| 44 | + headRepositoryId: ID | |
| 45 | + maintainerCanModify: Boolean = true | |
| 46 | + repositoryId: ID! | |
| 47 | + title: String! | |
| 48 | +} | |
| 49 | +type CreatePullRequestPayload { clientMutationId: String, pullRequest: PullRequest } | |
| 50 | + | |
| 51 | +input UpdatePullRequestInput { | |
| 52 | + assigneeIds: [ID!] | |
| 53 | + baseRefName: String | |
| 54 | + body: String | |
| 55 | + clientMutationId: String | |
| 56 | + labelIds: [ID!] | |
| 57 | + maintainerCanModify: Boolean | |
| 58 | + milestoneId: ID | |
| 59 | + projectIds: [ID!] | |
| 60 | + pullRequestId: ID! | |
| 61 | + state: PullRequestUpdateState | |
| 62 | + title: String | |
| 63 | +} | |
| 64 | +enum PullRequestUpdateState { OPEN CLOSED } | |
| 65 | +type UpdatePullRequestPayload { actor: Actor, clientMutationId: String, pullRequest: PullRequest } | |
| 66 | + | |
| 67 | +input ClosePullRequestInput { clientMutationId: String, pullRequestId: ID! } | |
| 68 | +type ClosePullRequestPayload { clientMutationId: String, pullRequest: PullRequest } | |
| 69 | +input ReopenPullRequestInput { clientMutationId: String, pullRequestId: ID! } | |
| 70 | +type ReopenPullRequestPayload { clientMutationId: String, pullRequest: PullRequest } | |
| 71 | +input MarkPullRequestReadyForReviewInput { clientMutationId: String, pullRequestId: ID! } | |
| 72 | +type MarkPullRequestReadyForReviewPayload { clientMutationId: String, pullRequest: PullRequest } | |
| 73 | +input ConvertPullRequestToDraftInput { clientMutationId: String, pullRequestId: ID! } | |
| 74 | +type ConvertPullRequestToDraftPayload { clientMutationId: String, pullRequest: PullRequest } | |
| 75 | + | |
| 76 | +input EnablePullRequestAutoMergeInput { | |
| 77 | + authorEmail: String | |
| 78 | + clientMutationId: String | |
| 79 | + commitBody: String | |
| 80 | + commitHeadline: String | |
| 81 | + expectedHeadOid: GitObjectID | |
| 82 | + mergeMethod: PullRequestMergeMethod = MERGE | |
| 83 | + pullRequestId: ID! | |
| 84 | +} | |
| 85 | +type EnablePullRequestAutoMergePayload { actor: Actor, clientMutationId: String, pullRequest: PullRequest } | |
| 86 | +input DisablePullRequestAutoMergeInput { clientMutationId: String, pullRequestId: ID! } | |
| 87 | +type DisablePullRequestAutoMergePayload { actor: Actor, clientMutationId: String, pullRequest: PullRequest } | |
| 88 | + | |
| 89 | +input MergePullRequestInput { | |
| 90 | + authorEmail: String | |
| 91 | + clientMutationId: String | |
| 92 | + commitBody: String | |
| 93 | + commitHeadline: String | |
| 94 | + expectedHeadOid: GitObjectID | |
| 95 | + mergeMethod: PullRequestMergeMethod = MERGE | |
| 96 | + pullRequestId: ID! | |
| 97 | +} | |
| 98 | +type MergePullRequestPayload { actor: Actor, clientMutationId: String, pullRequest: PullRequest } | |
| 99 | + | |
| 100 | +input AddCommentInput { body: String!, clientMutationId: String, subjectId: ID! } | |
| 101 | +type IssueCommentEdge { cursor: String!, node: IssueComment } | |
| 102 | +type AddCommentPayload { clientMutationId: String, commentEdge: IssueCommentEdge, subject: Node } | |
| 103 | + | |
| 104 | +input AddLabelsToLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! } | |
| 105 | +type AddLabelsToLabelablePayload { clientMutationId: String, labelable: PullRequest } | |
| 106 | +input RemoveLabelsFromLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! } | |
| 107 | +type RemoveLabelsFromLabelablePayload { clientMutationId: String, labelable: PullRequest } | |
| 108 | +input AddAssigneesToAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! } | |
| 109 | +type AddAssigneesToAssignablePayload { clientMutationId: String, assignable: PullRequest } | |
| 110 | +input RemoveAssigneesFromAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! } | |
| 111 | +type RemoveAssigneesFromAssignablePayload { clientMutationId: String, assignable: PullRequest } | |
| 112 | + | |
| 113 | +input RequestReviewsInput { | |
| 114 | + clientMutationId: String | |
| 115 | + pullRequestId: ID! | |
| 116 | + teamIds: [ID!] | |
| 117 | + union: Boolean = false | |
| 118 | + userIds: [ID!] | |
| 119 | +} | |
| 120 | +type RequestReviewsPayload { actor: Actor, clientMutationId: String, pullRequest: PullRequest } | |
| 121 | + | |
| 122 | +type Query { | |
| 123 | + "The signed-in account." | |
| 124 | + viewer: User! | |
| 125 | + "A repository by owner and name." | |
| 126 | + repository(owner: String!, name: String!, followRenames: Boolean = true): Repository | |
| 127 | + "An account by login." | |
| 128 | + user(login: String!): User | |
| 129 | + "Request budget, reported as GitHub does. Diffinery limits requests per account rather than by query cost." | |
| 130 | + rateLimit(dryRun: Boolean = false): RateLimit | |
| 131 | + "A repository or pull request by its global id." | |
| 132 | + node(id: ID!): Node | |
| 133 | + nodes(ids: [ID!]!): [Node]! | |
| 134 | + "Pull requests in one repository (the query needs a repo: qualifier), as \`gh pr status\` asks for them." | |
| 135 | + search(query: String!, type: SearchType!, first: Int, last: Int, after: String, before: String): SearchResultItemConnection! | |
| 136 | +} | |
| 137 | + | |
| 138 | +enum SearchType { ISSUE ISSUE_ADVANCED REPOSITORY USER DISCUSSION } | |
| 139 | +union SearchResultItem = PullRequest | |
| 140 | +type SearchResultItemEdge { cursor: String!, node: SearchResultItem } | |
| 141 | +type SearchResultItemConnection { | |
| 142 | + edges: [SearchResultItemEdge] | |
| 143 | + issueCount: Int! | |
| 144 | + nodes: [SearchResultItem] | |
| 145 | + pageInfo: PageInfo! | |
| 146 | + repositoryCount: Int! | |
| 147 | + userCount: Int! | |
| 148 | +} | |
| 149 | + | |
| 150 | +enum ReactionContent { THUMBS_UP THUMBS_DOWN LAUGH HOORAY CONFUSED HEART ROCKET EYES } | |
| 151 | +type ReactingUserConnection { totalCount: Int! } | |
| 152 | +type ReactionGroup { | |
| 153 | + content: ReactionContent! | |
| 154 | + users(first: Int, last: Int, after: String, before: String): ReactingUserConnection! | |
| 155 | + viewerHasReacted: Boolean! | |
| 156 | +} | |
| 157 | + | |
| 158 | +type RateLimit { | |
| 159 | + cost: Int! | |
| 160 | + limit: Int! | |
| 161 | + nodeCount: Int! | |
| 162 | + remaining: Int! | |
| 163 | + resetAt: DateTime! | |
| 164 | + used: Int! | |
| 165 | +} | |
| 166 | + | |
| 167 | +type PageInfo { | |
| 168 | + endCursor: String | |
| 169 | + hasNextPage: Boolean! | |
| 170 | + hasPreviousPage: Boolean! | |
| 171 | + startCursor: String | |
| 172 | +} | |
| 173 | + | |
| 174 | +interface Node { id: ID! } | |
| 175 | + | |
| 176 | +interface Actor { | |
| 177 | + avatarUrl(size: Int): URI! | |
| 178 | + login: String! | |
| 179 | + resourcePath: URI! | |
| 180 | + url: URI! | |
| 181 | +} | |
| 182 | + | |
| 183 | +interface RepositoryOwner { | |
| 184 | + avatarUrl(size: Int): URI! | |
| 185 | + id: ID! | |
| 186 | + login: String! | |
| 187 | + resourcePath: URI! | |
| 188 | + url: URI! | |
| 189 | +} | |
| 190 | + | |
| 191 | +type User implements Node & Actor & RepositoryOwner { | |
| 192 | + avatarUrl(size: Int): URI! | |
| 193 | + databaseId: BigInt | |
| 194 | + email: String! | |
| 195 | + id: ID! | |
| 196 | + isBot: Boolean! | |
| 197 | + login: String! | |
| 198 | + name: String | |
| 199 | + resourcePath: URI! | |
| 200 | + url: URI! | |
| 201 | +} | |
| 202 | + | |
| 203 | +type Organization implements Node & Actor & RepositoryOwner { | |
| 204 | + avatarUrl(size: Int): URI! | |
| 205 | + databaseId: BigInt | |
| 206 | + id: ID! | |
| 207 | + login: String! | |
| 208 | + name: String | |
| 209 | + resourcePath: URI! | |
| 210 | + url: URI! | |
| 211 | +} | |
| 212 | + | |
| 213 | +type Bot implements Node & Actor { | |
| 214 | + avatarUrl(size: Int): URI! | |
| 215 | + databaseId: BigInt | |
| 216 | + id: ID! | |
| 217 | + login: String! | |
| 218 | + resourcePath: URI! | |
| 219 | + url: URI! | |
| 220 | +} | |
| 221 | + | |
| 222 | +"Someone brought over by a GitHub import who has no Diffinery account." | |
| 223 | +type Mannequin implements Node & Actor { | |
| 224 | + avatarUrl(size: Int): URI! | |
| 225 | + id: ID! | |
| 226 | + login: String! | |
| 227 | + resourcePath: URI! | |
| 228 | + url: URI! | |
| 229 | +} | |
| 230 | + | |
| 231 | +enum RepositoryPermission { ADMIN MAINTAIN WRITE TRIAGE READ } | |
| 232 | +enum RepositoryVisibility { PRIVATE PUBLIC INTERNAL } | |
| 233 | +enum PullRequestState { OPEN CLOSED MERGED } | |
| 234 | +enum IssueState { OPEN CLOSED } | |
| 235 | +enum OrderDirection { ASC DESC } | |
| 236 | +enum IssueOrderField { CREATED_AT UPDATED_AT COMMENTS } | |
| 237 | +input IssueOrder { field: IssueOrderField!, direction: OrderDirection! } | |
| 238 | +enum LabelOrderField { NAME CREATED_AT } | |
| 239 | +input LabelOrder { field: LabelOrderField!, direction: OrderDirection! } | |
| 240 | +enum MilestoneState { OPEN CLOSED } | |
| 241 | +enum MilestoneOrderField { DUE_DATE CREATED_AT UPDATED_AT NUMBER } | |
| 242 | +input MilestoneOrder { field: MilestoneOrderField!, direction: OrderDirection! } | |
| 243 | +enum PullRequestReviewDecision { CHANGES_REQUESTED APPROVED REVIEW_REQUIRED } | |
| 244 | +enum PullRequestReviewState { PENDING COMMENTED APPROVED CHANGES_REQUESTED DISMISSED } | |
| 245 | +enum MergeableState { MERGEABLE CONFLICTING UNKNOWN } | |
| 246 | +enum MergeStateStatus { DIRTY UNKNOWN BLOCKED BEHIND DRAFT UNSTABLE HAS_HOOKS CLEAN } | |
| 247 | +enum PullRequestMergeMethod { MERGE SQUASH REBASE } | |
| 248 | +enum StatusState { EXPECTED ERROR FAILURE PENDING SUCCESS } | |
| 249 | +enum CheckStatusState { REQUESTED QUEUED IN_PROGRESS COMPLETED WAITING PENDING } | |
| 250 | +enum CheckConclusionState { ACTION_REQUIRED TIMED_OUT CANCELLED FAILURE SUCCESS NEUTRAL SKIPPED STARTUP_FAILURE STALE } | |
| 251 | +enum CheckRunState { REQUESTED QUEUED IN_PROGRESS COMPLETED WAITING PENDING ACTION_REQUIRED TIMED_OUT CANCELLED FAILURE SUCCESS NEUTRAL SKIPPED STARTUP_FAILURE STALE } | |
| 252 | +enum PatchStatus { ADDED DELETED RENAMED COPIED MODIFIED CHANGED } | |
| 253 | + | |
| 254 | +type Repository implements Node { | |
| 255 | + assignableUsers(first: Int, last: Int, after: String, before: String, query: String): UserConnection! | |
| 256 | + createdAt: DateTime! | |
| 257 | + databaseId: BigInt | |
| 258 | + defaultBranchRef: Ref | |
| 259 | + deleteBranchOnMerge: Boolean! | |
| 260 | + description: String | |
| 261 | + hasDiscussionsEnabled: Boolean! | |
| 262 | + hasIssuesEnabled: Boolean! | |
| 263 | + hasProjectsEnabled: Boolean! | |
| 264 | + hasWikiEnabled: Boolean! | |
| 265 | + homepageUrl: URI | |
| 266 | + id: ID! | |
| 267 | + isArchived: Boolean! | |
| 268 | + isEmpty: Boolean! | |
| 269 | + isFork: Boolean! | |
| 270 | + isInOrganization: Boolean! | |
| 271 | + isLocked: Boolean! | |
| 272 | + isMirror: Boolean! | |
| 273 | + isPrivate: Boolean! | |
| 274 | + isTemplate: Boolean! | |
| 275 | + label(name: String!): Label | |
| 276 | + labels(first: Int, last: Int, after: String, before: String, query: String, orderBy: LabelOrder): LabelConnection | |
| 277 | + mentionableUsers(first: Int, last: Int, after: String, before: String, query: String): UserConnection! | |
| 278 | + mergeCommitAllowed: Boolean! | |
| 279 | + milestones(first: Int, last: Int, after: String, before: String, states: [MilestoneState!], orderBy: MilestoneOrder, query: String): MilestoneConnection | |
| 280 | + name: String! | |
| 281 | + nameWithOwner: String! | |
| 282 | + owner: RepositoryOwner! | |
| 283 | + parent: Repository | |
| 284 | + pullRequest(number: Int!): PullRequest | |
| 285 | + pullRequests(first: Int, last: Int, after: String, before: String, states: [PullRequestState!], headRefName: String, baseRefName: String, labels: [String!], orderBy: IssueOrder): PullRequestConnection! | |
| 286 | + pushedAt: DateTime | |
| 287 | + rebaseMergeAllowed: Boolean! | |
| 288 | + resourcePath: URI! | |
| 289 | + squashMergeAllowed: Boolean! | |
| 290 | + sshUrl: String! | |
| 291 | + updatedAt: DateTime! | |
| 292 | + url: URI! | |
| 293 | + viewerCanAdminister: Boolean! | |
| 294 | + viewerPermission: RepositoryPermission | |
| 295 | + visibility: RepositoryVisibility! | |
| 296 | +} | |
| 297 | + | |
| 298 | +type Ref implements Node { | |
| 299 | + branchProtectionRule: BranchProtectionRule | |
| 300 | + id: ID! | |
| 301 | + name: String! | |
| 302 | + prefix: String! | |
| 303 | + target: Commit | |
| 304 | +} | |
| 305 | + | |
| 306 | +type BranchProtectionRule implements Node { | |
| 307 | + id: ID! | |
| 308 | + pattern: String! | |
| 309 | + requiredApprovingReviewCount: Int | |
| 310 | + requiredStatusCheckContexts: [String] | |
| 311 | + requiresApprovingReviews: Boolean! | |
| 312 | + requiresLinearHistory: Boolean! | |
| 313 | + requiresStatusChecks: Boolean! | |
| 314 | + requiresStrictStatusChecks: Boolean! | |
| 315 | +} | |
| 316 | + | |
| 317 | +type PullRequestConnection { | |
| 318 | + edges: [PullRequestEdge] | |
| 319 | + nodes: [PullRequest] | |
| 320 | + pageInfo: PageInfo! | |
| 321 | + totalCount: Int! | |
| 322 | +} | |
| 323 | +type PullRequestEdge { cursor: String!, node: PullRequest } | |
| 324 | + | |
| 325 | +type PullRequest implements Node { | |
| 326 | + additions: Int! | |
| 327 | + assignees(first: Int, last: Int, after: String, before: String): UserConnection! | |
| 328 | + author: Actor | |
| 329 | + authorAssociation: String! | |
| 330 | + autoMergeRequest: AutoMergeRequest | |
| 331 | + baseRef: Ref | |
| 332 | + baseRefName: String! | |
| 333 | + baseRefOid: GitObjectID! | |
| 334 | + baseRepository: Repository | |
| 335 | + body: String! | |
| 336 | + changedFiles: Int! | |
| 337 | + closed: Boolean! | |
| 338 | + closedAt: DateTime | |
| 339 | + comments(first: Int, last: Int, after: String, before: String): IssueCommentConnection! | |
| 340 | + commits(first: Int, last: Int, after: String, before: String): PullRequestCommitConnection! | |
| 341 | + createdAt: DateTime! | |
| 342 | + databaseId: BigInt | |
| 343 | + deletions: Int! | |
| 344 | + files(first: Int, last: Int, after: String, before: String): PullRequestChangedFileConnection | |
| 345 | + headRef: Ref | |
| 346 | + headRefName: String! | |
| 347 | + headRefOid: GitObjectID! | |
| 348 | + headRepository: Repository | |
| 349 | + headRepositoryOwner: RepositoryOwner | |
| 350 | + id: ID! | |
| 351 | + isCrossRepository: Boolean! | |
| 352 | + isDraft: Boolean! | |
| 353 | + labels(first: Int, last: Int, after: String, before: String): LabelConnection | |
| 354 | + latestReviews(first: Int, last: Int, after: String, before: String): PullRequestReviewConnection | |
| 355 | + locked: Boolean! | |
| 356 | + maintainerCanModify: Boolean! | |
| 357 | + mergeCommit: Commit | |
| 358 | + mergeStateStatus: MergeStateStatus! | |
| 359 | + mergeable: MergeableState! | |
| 360 | + merged: Boolean! | |
| 361 | + mergedAt: DateTime | |
| 362 | + mergedBy: Actor | |
| 363 | + milestone: Milestone | |
| 364 | + number: Int! | |
| 365 | + permalink: URI! | |
| 366 | + projectItems(first: Int, last: Int, after: String, before: String): ProjectV2ItemConnection! | |
| 367 | + reactionGroups: [ReactionGroup!] | |
| 368 | + repository: Repository! | |
| 369 | + resourcePath: URI! | |
| 370 | + reviewDecision: PullRequestReviewDecision | |
| 371 | + reviewRequests(first: Int, last: Int, after: String, before: String): ReviewRequestConnection | |
| 372 | + reviews(first: Int, last: Int, after: String, before: String, states: [PullRequestReviewState!]): PullRequestReviewConnection | |
| 373 | + state: PullRequestState! | |
| 374 | + statusCheckRollup: StatusCheckRollup | |
| 375 | + title: String! | |
| 376 | + updatedAt: DateTime! | |
| 377 | + url: URI! | |
| 378 | +} | |
| 379 | + | |
| 380 | +type AutoMergeRequest { | |
| 381 | + authorEmail: String | |
| 382 | + commitBody: String | |
| 383 | + commitHeadline: String | |
| 384 | + enabledAt: DateTime | |
| 385 | + enabledBy: Actor | |
| 386 | + mergeMethod: PullRequestMergeMethod! | |
| 387 | +} | |
| 388 | + | |
| 389 | +type Milestone implements Node { | |
| 390 | + description: String | |
| 391 | + dueOn: DateTime | |
| 392 | + id: ID! | |
| 393 | + number: Int! | |
| 394 | + state: String! | |
| 395 | + title: String! | |
| 396 | +} | |
| 397 | + | |
| 398 | +type MilestoneConnection { nodes: [Milestone], totalCount: Int!, pageInfo: PageInfo! } | |
| 399 | + | |
| 400 | +type UserConnection { nodes: [User], totalCount: Int!, pageInfo: PageInfo! } | |
| 401 | + | |
| 402 | +type Label implements Node { | |
| 403 | + color: String! | |
| 404 | + description: String | |
| 405 | + id: ID! | |
| 406 | + name: String! | |
| 407 | +} | |
| 408 | +type LabelConnection { nodes: [Label], totalCount: Int!, pageInfo: PageInfo! } | |
| 409 | + | |
| 410 | +type Team implements Node { | |
| 411 | + id: ID! | |
| 412 | + name: String! | |
| 413 | + organization: Organization! | |
| 414 | + slug: String! | |
| 415 | +} | |
| 416 | +union RequestedReviewer = User | Team | Bot | Mannequin | |
| 417 | +type ReviewRequest { requestedReviewer: RequestedReviewer } | |
| 418 | +type ReviewRequestConnection { nodes: [ReviewRequest], totalCount: Int!, pageInfo: PageInfo! } | |
| 419 | + | |
| 420 | +type PullRequestReview implements Node { | |
| 421 | + author: Actor | |
| 422 | + authorAssociation: String! | |
| 423 | + body: String! | |
| 424 | + commit: Commit | |
| 425 | + id: ID! | |
| 426 | + includesCreatedEdit: Boolean! | |
| 427 | + reactionGroups: [ReactionGroup!] | |
| 428 | + state: PullRequestReviewState! | |
| 429 | + submittedAt: DateTime | |
| 430 | +} | |
| 431 | +type PullRequestReviewConnection { nodes: [PullRequestReview], totalCount: Int!, pageInfo: PageInfo! } | |
| 432 | + | |
| 433 | +type IssueComment implements Node { | |
| 434 | + author: Actor | |
| 435 | + authorAssociation: String! | |
| 436 | + body: String! | |
| 437 | + createdAt: DateTime! | |
| 438 | + id: ID! | |
| 439 | + includesCreatedEdit: Boolean! | |
| 440 | + isMinimized: Boolean! | |
| 441 | + minimizedReason: String | |
| 442 | + reactionGroups: [ReactionGroup!] | |
| 443 | + url: URI! | |
| 444 | + viewerDidAuthor: Boolean! | |
| 445 | +} | |
| 446 | +type IssueCommentConnection { nodes: [IssueComment], totalCount: Int!, pageInfo: PageInfo! } | |
| 447 | + | |
| 448 | +type PullRequestChangedFile { | |
| 449 | + additions: Int! | |
| 450 | + changeType: PatchStatus! | |
| 451 | + deletions: Int! | |
| 452 | + path: String! | |
| 453 | +} | |
| 454 | +type PullRequestChangedFileConnection { nodes: [PullRequestChangedFile], totalCount: Int!, pageInfo: PageInfo! } | |
| 455 | + | |
| 456 | +type PullRequestCommit implements Node { | |
| 457 | + commit: Commit! | |
| 458 | + id: ID! | |
| 459 | + url: URI! | |
| 460 | +} | |
| 461 | +type PullRequestCommitEdge { cursor: String!, node: PullRequestCommit } | |
| 462 | +type PullRequestCommitConnection { | |
| 463 | + edges: [PullRequestCommitEdge] | |
| 464 | + nodes: [PullRequestCommit] | |
| 465 | + pageInfo: PageInfo! | |
| 466 | + totalCount: Int! | |
| 467 | +} | |
| 468 | + | |
| 469 | +type GitActor { | |
| 470 | + date: GitTimestamp | |
| 471 | + email: String | |
| 472 | + name: String | |
| 473 | + user: User | |
| 474 | +} | |
| 475 | + | |
| 476 | +type GitActorConnection { nodes: [GitActor], totalCount: Int!, pageInfo: PageInfo! } | |
| 477 | + | |
| 478 | +"Projects aren't part of Diffinery: always empty." | |
| 479 | +type ProjectV2Item implements Node { id: ID! } | |
| 480 | +type ProjectV2ItemConnection { nodes: [ProjectV2Item], totalCount: Int!, pageInfo: PageInfo! } | |
| 481 | + | |
| 482 | +type Commit implements Node { | |
| 483 | + abbreviatedOid: String! | |
| 484 | + author: GitActor | |
| 485 | + authoredDate: DateTime! | |
| 486 | + authors(first: Int, last: Int, after: String, before: String): GitActorConnection! | |
| 487 | + commitUrl: URI! | |
| 488 | + committedDate: DateTime! | |
| 489 | + committer: GitActor | |
| 490 | + id: ID! | |
| 491 | + message: String! | |
| 492 | + messageBody: String! | |
| 493 | + messageHeadline: String! | |
| 494 | + oid: GitObjectID! | |
| 495 | + status: Status | |
| 496 | + statusCheckRollup: StatusCheckRollup | |
| 497 | + url: URI! | |
| 498 | +} | |
| 499 | + | |
| 500 | +"The legacy combined commit status (commit statuses only, no check runs)." | |
| 501 | +type Status implements Node { | |
| 502 | + contexts: [StatusContext!]! | |
| 503 | + id: ID! | |
| 504 | + state: StatusState! | |
| 505 | +} | |
| 506 | + | |
| 507 | +type StatusCheckRollup implements Node { | |
| 508 | + commit: Commit | |
| 509 | + contexts(first: Int, last: Int, after: String, before: String): StatusCheckRollupContextConnection! | |
| 510 | + id: ID! | |
| 511 | + state: StatusState! | |
| 512 | +} | |
| 513 | + | |
| 514 | +union StatusCheckRollupContext = CheckRun | StatusContext | |
| 515 | + | |
| 516 | +type CheckRunStateCount { count: Int!, state: CheckRunState! } | |
| 517 | +type StatusContextStateCount { count: Int!, state: StatusState! } | |
| 518 | +type StatusCheckRollupContextEdge { cursor: String!, node: StatusCheckRollupContext } | |
| 519 | + | |
| 520 | +type StatusCheckRollupContextConnection { | |
| 521 | + checkRunCount: Int! | |
| 522 | + checkRunCountsByState: [CheckRunStateCount!] | |
| 523 | + edges: [StatusCheckRollupContextEdge] | |
| 524 | + nodes: [StatusCheckRollupContext] | |
| 525 | + pageInfo: PageInfo! | |
| 526 | + statusContextCount: Int! | |
| 527 | + statusContextCountsByState: [StatusContextStateCount!] | |
| 528 | + totalCount: Int! | |
| 529 | +} | |
| 530 | + | |
| 531 | +type App implements Node { | |
| 532 | + id: ID! | |
| 533 | + logoUrl(size: Int): URI! | |
| 534 | + name: String! | |
| 535 | + slug: String! | |
| 536 | +} | |
| 537 | + | |
| 538 | +type Workflow implements Node { | |
| 539 | + id: ID! | |
| 540 | + name: String! | |
| 541 | +} | |
| 542 | + | |
| 543 | +type WorkflowRun implements Node { | |
| 544 | + databaseId: BigInt | |
| 545 | + event: String! | |
| 546 | + id: ID! | |
| 547 | + runNumber: Int! | |
| 548 | + url: URI! | |
| 549 | + workflow: Workflow! | |
| 550 | +} | |
| 551 | + | |
| 552 | +type CheckSuite implements Node { | |
| 553 | + app: App | |
| 554 | + conclusion: CheckConclusionState | |
| 555 | + id: ID! | |
| 556 | + status: CheckStatusState! | |
| 557 | + workflowRun: WorkflowRun | |
| 558 | +} | |
| 559 | + | |
| 560 | +type CheckRun implements Node { | |
| 561 | + checkSuite: CheckSuite! | |
| 562 | + completedAt: DateTime | |
| 563 | + conclusion: CheckConclusionState | |
| 564 | + databaseId: BigInt | |
| 565 | + detailsUrl: URI | |
| 566 | + id: ID! | |
| 567 | + isRequired(pullRequestId: ID, pullRequestNumber: Int): Boolean! | |
| 568 | + name: String! | |
| 569 | + permalink: URI! | |
| 570 | + startedAt: DateTime | |
| 571 | + status: CheckStatusState! | |
| 572 | + summary: String | |
| 573 | + text: String | |
| 574 | + title: String | |
| 575 | + url: URI! | |
| 576 | +} | |
| 577 | + | |
| 578 | +type StatusContext implements Node { | |
| 579 | + avatarUrl(size: Int): URI | |
| 580 | + context: String! | |
| 581 | + createdAt: DateTime! | |
| 582 | + creator: Actor | |
| 583 | + description: String | |
| 584 | + id: ID! | |
| 585 | + isRequired(pullRequestId: ID, pullRequestNumber: Int): Boolean! | |
| 586 | + state: StatusState! | |
| 587 | + targetUrl: URI | |
| 588 | +} | |
| 589 | +`; |
addedapps/diffinery/src/http/graphql.ts+1021−0View file
| @@ -1,0 +1,1021 @@ | ||
| 1 | +/** | |
| 2 | + * GitHub's GraphQL API at /api/graphql, read-only, for the schema in ./graphql-schema.ts. | |
| 3 | + * | |
| 4 | + * Why: the GitHub CLI treats any host other than github.com as GitHub Enterprise Server and talks | |
| 5 | + * to it at https://<host>/api/v3 (REST) and https://<host>/api/graphql. Claude Code builds its pull | |
| 6 | + * request badge and CI status on exactly those calls (the open pull request for the current branch | |
| 7 | + * over REST, its review decision and check counts over GraphQL, and `gh pr view --json | |
| 8 | + * statusCheckRollup`), so with this endpoint a Diffinery repository gets the same pull request and | |
| 9 | + * CI chip a GitHub one does. See /docs/api/graphql. | |
| 10 | + * | |
| 11 | + * Execution is lenient where GitHub is strict: a field Diffinery doesn't have is left out of the | |
| 12 | + * answer (and named in `extensions.unsupported`) instead of failing the whole query, because gh | |
| 13 | + * asks for many fields at once and most commands need only a few of them. | |
| 14 | + */ | |
| 15 | +import { Hono } from "hono"; | |
| 16 | +import { | |
| 17 | + buildSchema, | |
| 18 | + execute, | |
| 19 | + FieldsOnCorrectTypeRule, | |
| 20 | + GraphQLError, | |
| 21 | + KnownArgumentNamesRule, | |
| 22 | + Kind, | |
| 23 | + KnownTypeNamesRule, | |
| 24 | + OverlappingFieldsCanBeMergedRule, | |
| 25 | + parse, | |
| 26 | + PossibleFragmentSpreadsRule, | |
| 27 | + specifiedRules, | |
| 28 | + validate, | |
| 29 | + type DocumentNode, | |
| 30 | + type GraphQLSchema, | |
| 31 | + type SelectionSetNode, | |
| 32 | +} from "graphql"; | |
| 33 | +import type { Env } from "../env.js"; | |
| 34 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 35 | +import type { Account, Repo } from "../lib/directory.js"; | |
| 36 | +import { HttpError } from "../lib/util.js"; | |
| 37 | +import { overLimit, RATE_LIMITS } from "../lib/rate-limit.js"; | |
| 38 | +import type { Caller, Role } from "../repo/types.js"; | |
| 39 | +import type { Check, Issue, Review } from "../repo/collab.js"; | |
| 40 | +import type { CommitStatus } from "../repo/statuses.js"; | |
| 41 | +import { hiddenAuthors, loadRepo, repoStub } from "./common.js"; | |
| 42 | +import { baseOf, nodeId, numericId, repoHtml } from "./gh.js"; | |
| 43 | +import { SCHEMA_SDL } from "./graphql-schema.js"; | |
| 44 | +import { apiRootJson } from "./api.js"; | |
| 45 | + | |
| 46 | +export const graphql = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 47 | + | |
| 48 | +/** Limits on one request, so a query can't make the Worker walk the whole site. */ | |
| 49 | +export const LIMITS = { queryBytes: 64 * 1024, depth: 16, fields: 2000, pageSize: 100, repositories: 25 }; | |
| 50 | + | |
| 51 | +let built: GraphQLSchema | null = null; | |
| 52 | +export const schema = (): GraphQLSchema => (built ??= buildSchema(SCHEMA_SDL)); | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Validation rules: GitHub's, less those that reject what Diffinery's smaller schema doesn't have | |
| 56 | + * (fields, arguments, and types such as `... on Team` that gh asks for on GitHub's union types), and | |
| 57 | + * the overlap rule, which GitHub itself doesn't hold gh's queries to (User.name and Team.name differ in nullability). | |
| 58 | + */ | |
| 59 | +const LENIENT = new Set([FieldsOnCorrectTypeRule, KnownArgumentNamesRule, KnownTypeNamesRule, PossibleFragmentSpreadsRule, OverlappingFieldsCanBeMergedRule]); | |
| 60 | +const RULES = specifiedRules.filter((r) => !LENIENT.has(r)); | |
| 61 | + | |
| 62 | +type Stub = ReturnType<typeof repoStub>; | |
| 63 | +type Obj = Record<string, unknown>; | |
| 64 | +type Args = Record<string, unknown>; | |
| 65 | + | |
| 66 | +/** Per-request state: the request, and lookups shared by every field that needs them. */ | |
| 67 | +interface Ctx { | |
| 68 | + c: AppContext; | |
| 69 | + base: string; | |
| 70 | + memo: Map<string, Promise<unknown>>; | |
| 71 | + repos: number; | |
| 72 | +} | |
| 73 | + | |
| 74 | +const once = <T>(ctx: Ctx, key: string, f: () => Promise<T>): Promise<T> => { | |
| 75 | + let p = ctx.memo.get(key) as Promise<T> | undefined; | |
| 76 | + if (!p) { | |
| 77 | + p = f(); | |
| 78 | + ctx.memo.set(key, p); | |
| 79 | + } | |
| 80 | + return p; | |
| 81 | +}; | |
| 82 | + | |
| 83 | +/** GitHub's NOT_FOUND error (gh reads `type`), for a repository or pull request that isn't there or isn't visible. */ | |
| 84 | +const notFound = (message: string) => new GraphQLError(message, { extensions: { type: "NOT_FOUND" } }); | |
| 85 | + | |
| 86 | +/** A Durable Object error ("404: ...") or an HttpError as its status and message. */ | |
| 87 | +function statusOf(e: unknown): { status: number; message: string } | null { | |
| 88 | + if (e instanceof HttpError) return { status: e.status, message: e.detail }; | |
| 89 | + const m = /^(\d{3}): ([\s\S]*)$/.exec((e as Error)?.message ?? ""); | |
| 90 | + return m ? { status: Number(m[1]), message: m[2]! } : null; | |
| 91 | +} | |
| 92 | + | |
| 93 | +// ---------------------------------------------------------------- connections | |
| 94 | + | |
| 95 | +const cursorOf = (i: number) => btoa(`cursor:${i}`); | |
| 96 | +function offsetOf(cursor: unknown): number { | |
| 97 | + if (typeof cursor !== "string" || !cursor) return -1; | |
| 98 | + try { | |
| 99 | + const m = /^cursor:(\d+)$/.exec(atob(cursor)); | |
| 100 | + if (m) return Number(m[1]); | |
| 101 | + } catch { | |
| 102 | + // fall through | |
| 103 | + } | |
| 104 | + throw new GraphQLError(`"${cursor}" does not appear to be a valid cursor.`, { extensions: { type: "INVALID_CURSOR_ARGUMENTS" } }); | |
| 105 | +} | |
| 106 | + | |
| 107 | +function pageSize(args: Args): void { | |
| 108 | + for (const k of ["first", "last"] as const) { | |
| 109 | + const n = args[k]; | |
| 110 | + if (n === undefined || n === null) continue; | |
| 111 | + if (typeof n !== "number" || n < 0) throw new GraphQLError(`"${k}" on a connection cannot be less than zero.`); | |
| 112 | + if (n > LIMITS.pageSize) throw new GraphQLError(`Requesting ${n} records on the connection exceeds the "${k}" limit of ${LIMITS.pageSize} records.`, { extensions: { type: "EXCESSIVE_PAGINATION" } }); | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** first/last/after/before over a list already in memory. */ | |
| 117 | +function connection<T>(all: T[], args: Args, extra: Obj = {}) { | |
| 118 | + pageSize(args); | |
| 119 | + let start = 0; | |
| 120 | + let end = all.length; | |
| 121 | + const after = offsetOf(args.after); | |
| 122 | + const before = offsetOf(args.before); | |
| 123 | + if (after >= 0) start = Math.min(after + 1, end); | |
| 124 | + if (before >= 0) end = Math.max(Math.min(before, end), start); | |
| 125 | + if (typeof args.first === "number") end = Math.min(end, start + args.first); | |
| 126 | + if (typeof args.last === "number") start = Math.max(start, end - args.last); | |
| 127 | + const slice = all.slice(start, end); | |
| 128 | + return { | |
| 129 | + totalCount: all.length, | |
| 130 | + nodes: slice, | |
| 131 | + edges: slice.map((node, i) => ({ node, cursor: cursorOf(start + i) })), | |
| 132 | + pageInfo: { hasNextPage: end < all.length, hasPreviousPage: start > 0, startCursor: slice.length ? cursorOf(start) : null, endCursor: slice.length ? cursorOf(end - 1) : null }, | |
| 133 | + ...extra, | |
| 134 | + }; | |
| 135 | +} | |
| 136 | + | |
| 137 | +// ---------------------------------------------------------------- people | |
| 138 | + | |
| 139 | +type Person = { id: string; login: string; kind?: string | null; name?: string | null }; | |
| 140 | + | |
| 141 | +function actor(ctx: Ctx, u: Person | null | undefined): Obj | null { | |
| 142 | + if (!u) return null; | |
| 143 | + if (u.kind === "github" || u.login.startsWith("github:")) { | |
| 144 | + const login = u.login.replace(/^github:/, ""); | |
| 145 | + return { __typename: "Mannequin", id: nodeId("Mannequin", u.id), login: `github:${login}`, url: `https://github.com/${encodeURIComponent(login)}`, resourcePath: `/${login}`, avatarUrl: () => `${ctx.base}/api/avatars/${encodeURIComponent(login)}` }; | |
| 146 | + } | |
| 147 | + const typename = u.kind === "org" ? "Organization" : u.kind === "agent" || u.kind === "system" ? "Bot" : "User"; | |
| 148 | + return { | |
| 149 | + __typename: typename, | |
| 150 | + id: nodeId(typename, u.id), | |
| 151 | + databaseId: numericId(u.id), | |
| 152 | + login: u.login, | |
| 153 | + name: u.name ?? null, | |
| 154 | + email: "", | |
| 155 | + isBot: typename === "Bot", | |
| 156 | + url: `${ctx.base}/${u.login}`, | |
| 157 | + resourcePath: `/${u.login}`, | |
| 158 | + avatarUrl: () => `${ctx.base}/api/avatars/${encodeURIComponent(u.login)}`, | |
| 159 | + }; | |
| 160 | +} | |
| 161 | + | |
| 162 | +/** An account as a GraphQL User (review requests and assignees are always people). */ | |
| 163 | +const userByLogin = (ctx: Ctx, login: string): Obj => ({ ...actor(ctx, { id: login, login, kind: "user" }), __typename: "User" }); | |
| 164 | + | |
| 165 | +/** Reactions aren't counted here yet: an empty list, which GitHub clients show as no reactions. */ | |
| 166 | +const NO_REACTIONS: Obj[] = []; | |
| 167 | + | |
| 168 | +// ---------------------------------------------------------------- repositories | |
| 169 | + | |
| 170 | +const PERMISSION: Record<Role, string> = { admin: "ADMIN", maintain: "MAINTAIN", write: "WRITE", triage: "TRIAGE", read: "READ" }; | |
| 171 | + | |
| 172 | +type Loaded = { repo: Repo; caller: Caller; stub: Stub }; | |
| 173 | + | |
| 174 | +async function loadRepository(ctx: Ctx, owner: string, name: string): Promise<Loaded | null> { | |
| 175 | + return once(ctx, `repo:${owner.toLowerCase()}/${name.toLowerCase()}`, async () => { | |
| 176 | + if (++ctx.repos > LIMITS.repositories) throw new GraphQLError(`A query can look up at most ${LIMITS.repositories} repositories.`, { extensions: { type: "MAX_NODE_LIMIT_EXCEEDED" } }); | |
| 177 | + try { | |
| 178 | + const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, "read", { follow: true }); | |
| 179 | + return { repo, caller, stub }; | |
| 180 | + } catch (e) { | |
| 181 | + const s = statusOf(e); | |
| 182 | + if (s && (s.status === 404 || s.status === 403 || s.status === 401)) return null; | |
| 183 | + throw e; | |
| 184 | + } | |
| 185 | + }); | |
| 186 | +} | |
| 187 | + | |
| 188 | +/** Who can be assigned or asked for a review: the owner or the organization's members, and collaborators. */ | |
| 189 | +async function people(ctx: Ctx, l: Loaded, query: unknown): Promise<Obj[]> { | |
| 190 | + const dir = ctx.c.get("dir"); | |
| 191 | + const all = await once(ctx, `people:${l.repo.id}`, async () => { | |
| 192 | + const found = new Map<string, Account>(); | |
| 193 | + if (l.repo.owner_kind === "org") for (const m of await dir.orgMembers(l.repo.owner_id)) found.set(m.id, m); | |
| 194 | + else { | |
| 195 | + const owner = await dir.account(l.repo.owner_id); | |
| 196 | + if (owner) found.set(owner.id, owner); | |
| 197 | + } | |
| 198 | + for (const x of await dir.collaborators(l.repo.id)) found.set(x.account.id, x.account); | |
| 199 | + return [...found.values()].filter((a) => a.status === "active" && a.kind !== "org").sort((a, b) => a.login.localeCompare(b.login)); | |
| 200 | + }); | |
| 201 | + const q = typeof query === "string" ? query.toLowerCase() : ""; | |
| 202 | + return all.filter((a) => !q || a.login.toLowerCase().includes(q) || (a.name ?? "").toLowerCase().includes(q)).map((a) => ({ ...actor(ctx, a), __typename: "User" })); | |
| 203 | +} | |
| 204 | + | |
| 205 | +const labelObj = (l: Loaded, name: string, row?: { color?: string; description?: string | null }): Obj => ({ | |
| 206 | + __typename: "Label", | |
| 207 | + id: nodeId("Label", `${l.repo.owner_login}/${l.repo.name}/${name}`), | |
| 208 | + name, | |
| 209 | + color: row?.color ?? "ededed", | |
| 210 | + description: row?.description ?? null, | |
| 211 | +}); | |
| 212 | + | |
| 213 | +const milestoneObj = (l: Loaded, m: { number: number; title: string; state: string; dueOn?: string | null; description?: string | null }): Obj => ({ | |
| 214 | + __typename: "Milestone", | |
| 215 | + id: nodeId("Milestone", `${l.repo.id}/${m.number}`), | |
| 216 | + number: m.number, | |
| 217 | + title: m.title, | |
| 218 | + state: m.state.toUpperCase(), | |
| 219 | + dueOn: m.dueOn ?? null, | |
| 220 | + description: m.description ?? null, | |
| 221 | +}); | |
| 222 | + | |
| 223 | +function repository(ctx: Ctx, l: Loaded): Obj { | |
| 224 | + const { repo, caller, stub } = l; | |
| 225 | + const html = repoHtml(ctx.base, repo.owner_login, repo.name); | |
| 226 | + const self = (): Obj => repository(ctx, l); | |
| 227 | + return { | |
| 228 | + __typename: "Repository", | |
| 229 | + id: nodeId("Repository", repo.id), | |
| 230 | + databaseId: numericId(repo.id), | |
| 231 | + name: repo.name, | |
| 232 | + nameWithOwner: `${repo.owner_login}/${repo.name}`, | |
| 233 | + owner: actor(ctx, { id: repo.owner_id, login: repo.owner_login, kind: repo.owner_kind }), | |
| 234 | + url: html, | |
| 235 | + resourcePath: `/${repo.owner_login}/${repo.name}`, | |
| 236 | + sshUrl: `${html}.git`, | |
| 237 | + description: repo.description || null, | |
| 238 | + homepageUrl: repo.homepage || null, | |
| 239 | + isPrivate: repo.visibility !== "public", | |
| 240 | + visibility: repo.visibility === "public" ? "PUBLIC" : "PRIVATE", | |
| 241 | + isArchived: !!repo.archived, | |
| 242 | + isFork: false, | |
| 243 | + isMirror: false, | |
| 244 | + isTemplate: false, | |
| 245 | + isLocked: false, | |
| 246 | + isInOrganization: repo.owner_kind === "org", | |
| 247 | + isEmpty: () => defaultHead(ctx, l).then((sha) => !sha), | |
| 248 | + hasIssuesEnabled: true, | |
| 249 | + hasWikiEnabled: false, | |
| 250 | + hasProjectsEnabled: false, | |
| 251 | + hasDiscussionsEnabled: false, | |
| 252 | + mergeCommitAllowed: true, | |
| 253 | + squashMergeAllowed: true, | |
| 254 | + rebaseMergeAllowed: true, | |
| 255 | + deleteBranchOnMerge: false, | |
| 256 | + createdAt: repo.created_at, | |
| 257 | + updatedAt: repo.updated_at, | |
| 258 | + pushedAt: repo.pushed_at, | |
| 259 | + parent: null, | |
| 260 | + viewerPermission: caller.role ? PERMISSION[caller.role] : null, | |
| 261 | + viewerCanAdminister: caller.role === "admin", | |
| 262 | + defaultBranchRef: async () => { | |
| 263 | + const sha = await defaultHead(ctx, l); | |
| 264 | + return sha ? { __typename: "Ref", id: nodeId("Ref", `${repo.id}/refs/heads/${repo.default_branch}`), name: repo.default_branch, prefix: "refs/heads/", target: commit(ctx, l, sha) } : null; | |
| 265 | + }, | |
| 266 | + assignableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), | |
| 267 | + mentionableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), | |
| 268 | + labels: async (args: Args) => { | |
| 269 | + const q = typeof args.query === "string" ? args.query.toLowerCase() : ""; | |
| 270 | + const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows()); | |
| 271 | + return connection(rows.filter((r) => !q || r.name.toLowerCase().includes(q)).map((r) => labelObj(l, r.name, r)), args); | |
| 272 | + }, | |
| 273 | + label: async (args: Args) => { | |
| 274 | + const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows()); | |
| 275 | + const r = rows.find((x) => x.name.toLowerCase() === String(args.name).toLowerCase()); | |
| 276 | + return r ? labelObj(l, r.name, r) : null; | |
| 277 | + }, | |
| 278 | + milestones: async (args: Args) => { | |
| 279 | + const states = Array.isArray(args.states) ? (args.states as string[]) : []; | |
| 280 | + const list = await stub.milestones(states.length === 1 ? (states[0] === "CLOSED" ? "closed" : "open") : "all"); | |
| 281 | + return connection(list.map((m) => milestoneObj(l, m)), args); | |
| 282 | + }, | |
| 283 | + pullRequest: async (args: Args) => { | |
| 284 | + const n = args.number as number; | |
| 285 | + const issue = await issueOf(ctx, l, n).catch((e) => { | |
| 286 | + if (statusOf(e)?.status === 404) return null; | |
| 287 | + throw e; | |
| 288 | + }); | |
| 289 | + if (!issue || issue.kind !== "pull") throw notFound(`Could not resolve to a PullRequest with the number of ${n}.`); | |
| 290 | + return pullRequest(ctx, l, issue, self); | |
| 291 | + }, | |
| 292 | + pullRequests: async (args: Args) => { | |
| 293 | + pageSize(args); | |
| 294 | + const states = (args.states as string[] | null | undefined) ?? []; | |
| 295 | + const want = new Set(states); | |
| 296 | + const state = !want.size || (want.has("OPEN") && (want.has("CLOSED") || want.has("MERGED"))) ? "all" : want.has("OPEN") ? "open" : "closed"; | |
| 297 | + const labels = Array.isArray(args.labels) && args.labels.length ? (args.labels as string[]) : undefined; | |
| 298 | + const order = args.orderBy as { field?: string; direction?: string } | undefined; | |
| 299 | + const sort = order?.field === "UPDATED_AT" ? "updated" : order?.field === "COMMENTS" ? "comments" : "created"; | |
| 300 | + // GitHub lists oldest first unless asked otherwise. | |
| 301 | + const direction = order?.direction === "DESC" ? "desc" : "asc"; | |
| 302 | + const res = await stub.issues({ kind: "pull", state, head: (args.headRefName as string) || undefined, base: (args.baseRefName as string) || undefined, labels, sort, direction, limit: 500, hide: await hiddenAuthors(ctx.c) }); | |
| 303 | + let items = res.items; | |
| 304 | + // "closed" holds both closed and merged pull requests: keep only the states asked for. | |
| 305 | + if (want.size && state !== "all") items = items.filter((i) => want.has(pullState(i))); | |
| 306 | + else if (want.size && !(want.has("CLOSED") && want.has("MERGED"))) items = items.filter((i) => want.has(pullState(i))); | |
| 307 | + for (const i of items) ctx.memo.set(`issue:${repo.id}:${i.number}`, Promise.resolve(i)); | |
| 308 | + return connection( | |
| 309 | + items.map((i) => pullRequest(ctx, l, i, self)), | |
| 310 | + args, | |
| 311 | + ); | |
| 312 | + }, | |
| 313 | + }; | |
| 314 | +} | |
| 315 | + | |
| 316 | +const defaultHead = (ctx: Ctx, l: Loaded) => once(ctx, `head:${l.repo.id}`, () => l.stub.resolve(l.repo.default_branch).catch(() => null)); | |
| 317 | + | |
| 318 | +const issueOf = (ctx: Ctx, l: Loaded, n: number): Promise<Issue> => | |
| 319 | + once(ctx, `issue:${l.repo.id}:${n}`, async () => (await l.stub.issue(n, await hiddenAuthors(ctx.c))).issue); | |
| 320 | + | |
| 321 | +const pullState = (i: Issue): string => (i.pull?.merged ? "MERGED" : i.state === "open" ? "OPEN" : "CLOSED"); | |
| 322 | + | |
| 323 | +// ---------------------------------------------------------------- pull requests | |
| 324 | + | |
| 325 | +function pullRequest(ctx: Ctx, l: Loaded, i: Issue, repoObj: () => Obj): Obj { | |
| 326 | + const { repo, stub, caller } = l; | |
| 327 | + const p = i.pull!; | |
| 328 | + const html = repoHtml(ctx.base, repo.owner_login, repo.name); | |
| 329 | + const url = `${html}/pull/${i.number}`; | |
| 330 | + const key = `${repo.id}:${i.number}`; | |
| 331 | + const full = () => once(ctx, `full:${key}`, () => stub.issue(i.number, [])) as Promise<{ issue: Issue; comments: Array<{ id: string; author: Person; body: string; createdAt: string; reviewId?: string | null; minimized?: string | null }>; reviews: Review[] }>; | |
| 332 | + const stats = () => once(ctx, `stats:${key}`, () => stub.pullStats(i.number)); | |
| 333 | + const review = () => once(ctx, `review:${key}`, () => stub.reviewState(i.number)); | |
| 334 | + const merge = () => once(ctx, `merge:${key}`, () => stub.mergeability(i.number, caller)); | |
| 335 | + const head = () => commit(ctx, l, p.headSha, { pullNumber: i.number, review }); | |
| 336 | + const ref = (name: string, oid: string | null): Obj | null => | |
| 337 | + oid === null | |
| 338 | + ? null | |
| 339 | + : { | |
| 340 | + __typename: "Ref", | |
| 341 | + id: nodeId("Ref", `${repo.id}/refs/heads/${name}`), | |
| 342 | + name, | |
| 343 | + prefix: "refs/heads/", | |
| 344 | + target: () => commit(ctx, l, oid), | |
| 345 | + branchProtectionRule: async () => { | |
| 346 | + if (name !== p.base) return null; | |
| 347 | + const rules = (await review()).protection; | |
| 348 | + if (!rules) return null; | |
| 349 | + const required = (await review()).requiredChecks; | |
| 350 | + return { __typename: "BranchProtectionRule", id: nodeId("BranchProtectionRule", `${repo.id}/${name}`), pattern: name, requiredApprovingReviewCount: rules.requiredApprovals, requiresApprovingReviews: rules.requiredApprovals > 0, requiredStatusCheckContexts: required, requiresStatusChecks: required.length > 0, requiresStrictStatusChecks: false, requiresLinearHistory: rules.linearHistory }; | |
| 351 | + }, | |
| 352 | + }; | |
| 353 | + const assoc = (login: string) => (login.toLowerCase() === repo.owner_login.toLowerCase() ? "OWNER" : "NONE"); | |
| 354 | + const reviewObj = (r: Review): Obj => ({ __typename: "PullRequestReview", id: nodeId("PullRequestReview", `${repo.id}/${r.id}`), author: actor(ctx, r.author), authorAssociation: assoc(r.author.login), body: r.body, includesCreatedEdit: false, reactionGroups: NO_REACTIONS, state: r.state, submittedAt: r.submittedAt, commit: r.commitId ? commit(ctx, l, r.commitId) : null }); | |
| 355 | + return { | |
| 356 | + __typename: "PullRequest", | |
| 357 | + id: nodeId("PullRequest", `${repo.id}/${i.number}`), | |
| 358 | + databaseId: i.number, | |
| 359 | + number: i.number, | |
| 360 | + title: i.title, | |
| 361 | + body: i.body ?? "", | |
| 362 | + url, | |
| 363 | + permalink: url, | |
| 364 | + resourcePath: `/${repo.owner_login}/${repo.name}/pull/${i.number}`, | |
| 365 | + state: pullState(i), | |
| 366 | + isDraft: p.draft, | |
| 367 | + closed: i.state === "closed", | |
| 368 | + merged: p.merged, | |
| 369 | + mergedAt: p.mergedAt, | |
| 370 | + mergedBy: p.mergedBy ? actor(ctx, { id: p.mergedBy, login: p.mergedBy }) : null, | |
| 371 | + closedAt: i.closedAt, | |
| 372 | + createdAt: i.createdAt, | |
| 373 | + updatedAt: i.updatedAt, | |
| 374 | + locked: i.locked, | |
| 375 | + author: actor(ctx, i.author), | |
| 376 | + authorAssociation: assoc(i.author.login), | |
| 377 | + headRefName: p.head, | |
| 378 | + headRefOid: p.headSha, | |
| 379 | + baseRefName: p.base, | |
| 380 | + baseRef: async () => ref(p.base, await stub.resolve(p.base).catch(() => null)), | |
| 381 | + headRef: () => (p.merged || i.state === "closed" ? null : ref(p.head, p.headSha)), | |
| 382 | + reactionGroups: NO_REACTIONS, | |
| 383 | + projectItems: (args: Args) => connection([], args), | |
| 384 | + baseRefOid: async () => (await stub.resolve(p.base).catch(() => null)) ?? "", | |
| 385 | + repository: repoObj, | |
| 386 | + baseRepository: repoObj, | |
| 387 | + headRepository: repoObj, | |
| 388 | + headRepositoryOwner: actor(ctx, { id: repo.owner_id, login: repo.owner_login, kind: repo.owner_kind }), | |
| 389 | + isCrossRepository: false, | |
| 390 | + maintainerCanModify: false, | |
| 391 | + mergeCommit: p.mergeCommit ? commit(ctx, l, p.mergeCommit) : null, | |
| 392 | + additions: async () => (await stats()).additions, | |
| 393 | + deletions: async () => (await stats()).deletions, | |
| 394 | + changedFiles: async () => (await stats()).changedFiles, | |
| 395 | + reviewDecision: async () => (await review()).decision, | |
| 396 | + mergeable: async () => { | |
| 397 | + if (i.state !== "open") return "UNKNOWN"; | |
| 398 | + return (await merge()).conflicts.length ? "CONFLICTING" : "MERGEABLE"; | |
| 399 | + }, | |
| 400 | + mergeStateStatus: async () => { | |
| 401 | + if (i.state !== "open") return "UNKNOWN"; | |
| 402 | + const m = await merge(); | |
| 403 | + // GitHub's UNSTABLE: nothing blocks the merge, but a check that is not required is failing or still running. | |
| 404 | + if (m.state === "clean" && m.checks.some((ch: { state: string }) => !["success", "neutral", "skipped"].includes(ch.state))) return "UNSTABLE"; | |
| 405 | + return { clean: "CLEAN", blocked: "BLOCKED", conflicting: "DIRTY", behind: "BEHIND", draft: "DRAFT" }[m.state as string] ?? "UNKNOWN"; | |
| 406 | + }, | |
| 407 | + statusCheckRollup: () => (head().statusCheckRollup as () => Promise<unknown>)(), | |
| 408 | + commits: async (args: Args) => { | |
| 409 | + pageSize(args); | |
| 410 | + // The head commit alone (`commits(last: 1)`, which Claude Code and gh ask for on every poll) needs no history walk. | |
| 411 | + if (args.last === 1 && !args.first && !args.after && !args.before) { | |
| 412 | + const node = { __typename: "PullRequestCommit", id: nodeId("PullRequestCommit", `${repo.id}/${p.headSha}`), url: `${url}/commits/${p.headSha}`, commit: head() }; | |
| 413 | + return { | |
| 414 | + nodes: [node], | |
| 415 | + edges: [{ node, cursor: "head" }], | |
| 416 | + totalCount: async () => (await once(ctx, `commits:${key}`, () => stub.pullCommits(i.number))).length, | |
| 417 | + pageInfo: { hasNextPage: false, hasPreviousPage: true, startCursor: "head", endCursor: "head" }, | |
| 418 | + }; | |
| 419 | + } | |
| 420 | + const list = await once(ctx, `commits:${key}`, () => stub.pullCommits(i.number)); | |
| 421 | + return connection( | |
| 422 | + list.map((cm) => ({ __typename: "PullRequestCommit", id: nodeId("PullRequestCommit", `${repo.id}/${cm.sha}`), url: `${url}/commits/${cm.sha}`, commit: commit(ctx, l, cm.sha, cm.sha === p.headSha ? { pullNumber: i.number, review } : {}, cm) })), | |
| 423 | + args, | |
| 424 | + ); | |
| 425 | + }, | |
| 426 | + labels: async (args: Args) => { | |
| 427 | + const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows()); | |
| 428 | + const byName = new Map(rows.map((r) => [r.name, r])); | |
| 429 | + return connection( | |
| 430 | + i.labels.map((name) => labelObj(l, name, byName.get(name))), | |
| 431 | + args, | |
| 432 | + ); | |
| 433 | + }, | |
| 434 | + assignees: (args: Args) => connection(i.assignees.map((login) => userByLogin(ctx, login)), args), | |
| 435 | + reviewRequests: (args: Args) => connection(p.requestedReviewers.map((login) => ({ requestedReviewer: userByLogin(ctx, login) })), args), | |
| 436 | + reviews: async (args: Args) => { | |
| 437 | + const states = Array.isArray(args.states) && args.states.length ? new Set(args.states as string[]) : null; | |
| 438 | + return connection((await full()).reviews.filter((r) => !states || states.has(r.state)).map(reviewObj), args); | |
| 439 | + }, | |
| 440 | + latestReviews: async (args: Args) => { | |
| 441 | + const latest = new Map<string, Review>(); | |
| 442 | + for (const r of (await full()).reviews) if (r.state !== "COMMENTED" || !latest.has(r.author.id)) latest.set(r.author.id, r); | |
| 443 | + return connection([...latest.values()].map(reviewObj), args); | |
| 444 | + }, | |
| 445 | + comments: async (args: Args) => | |
| 446 | + connection( | |
| 447 | + (await full()).comments | |
| 448 | + .filter((cm) => !cm.reviewId) | |
| 449 | + .map((cm) => ({ __typename: "IssueComment", id: nodeId("IssueComment", `${repo.id}/${cm.id}`), author: actor(ctx, cm.author), authorAssociation: assoc(cm.author.login), body: cm.body, createdAt: cm.createdAt, url: `${url}#issuecomment-${cm.id}`, includesCreatedEdit: false, isMinimized: !!cm.minimized, minimizedReason: cm.minimized ?? null, reactionGroups: NO_REACTIONS, viewerDidAuthor: viewerId(ctx) === cm.author.id })), | |
| 450 | + args, | |
| 451 | + ), | |
| 452 | + files: async (args: Args) => { | |
| 453 | + const res = await once(ctx, `files:${key}`, () => stub.pullFiles(i.number)); | |
| 454 | + const change: Record<string, string> = { added: "ADDED", deleted: "DELETED", modified: "MODIFIED" }; | |
| 455 | + return connection( | |
| 456 | + res.files.map((f) => ({ path: f.path, additions: f.additions, deletions: f.deletions, changeType: change[f.status as string] ?? "CHANGED" })), | |
| 457 | + args, | |
| 458 | + ); | |
| 459 | + }, | |
| 460 | + milestone: i.milestone ? milestoneObj(l, { ...i.milestone, description: null }) : null, | |
| 461 | + autoMergeRequest: p.autoMerge | |
| 462 | + ? { authorEmail: null, enabledAt: p.autoMerge.enabledAt, enabledBy: actor(ctx, p.autoMerge.enabledBy as Person), mergeMethod: p.autoMerge.method.toUpperCase(), commitHeadline: p.autoMerge.commitTitle ?? null, commitBody: p.autoMerge.commitMessage ?? null } | |
| 463 | + : null, | |
| 464 | + }; | |
| 465 | +} | |
| 466 | + | |
| 467 | +// ---------------------------------------------------------------- commits and checks | |
| 468 | + | |
| 469 | +type CommitOpts = { pullNumber?: number; review?: () => Promise<{ requiredChecks: string[] }> }; | |
| 470 | +type CommitLike = { sha: string; message: string; author: { name: string; email: string; date?: string; time?: number }; committer: { name: string; email: string; date?: string; time?: number } }; | |
| 471 | + | |
| 472 | +function commit(ctx: Ctx, l: Loaded, oid: string, opts: CommitOpts = {}, known?: CommitLike): Obj { | |
| 473 | + const { repo, stub } = l; | |
| 474 | + const html = repoHtml(ctx.base, repo.owner_login, repo.name); | |
| 475 | + const info = () => (known ? Promise.resolve(known) : (once(ctx, `commit:${repo.id}:${oid}`, () => stub.commitInfo(oid)) as Promise<CommitLike>)); | |
| 476 | + const signed = (s: CommitLike["author"]) => ({ name: s.name, email: s.email, date: dateOf(s), user: null }); | |
| 477 | + const message = async () => (await info()).message.replace(/\n+$/, ""); | |
| 478 | + const rollup = () => once(ctx, `rollup:${repo.id}:${oid}`, async () => { | |
| 479 | + const [checks, combined, required] = await Promise.all([stub.checks(oid), stub.combinedStatus(oid), opts.review ? opts.review().then((r) => r.requiredChecks) : Promise.resolve([] as string[])]); | |
| 480 | + return { checks, statuses: combined.statuses, required: new Set(required) }; | |
| 481 | + }); | |
| 482 | + return { | |
| 483 | + __typename: "Commit", | |
| 484 | + id: nodeId("Commit", oid), | |
| 485 | + oid, | |
| 486 | + abbreviatedOid: oid.slice(0, 7), | |
| 487 | + url: `${html}/commit/${oid}`, | |
| 488 | + commitUrl: `${html}/commit/${oid}`, | |
| 489 | + message, | |
| 490 | + messageHeadline: async () => (await message()).split("\n")[0]!, | |
| 491 | + messageBody: async () => (await message()).split("\n").slice(1).join("\n").replace(/^\n+/, ""), | |
| 492 | + authoredDate: async () => dateOf((await info()).author), | |
| 493 | + committedDate: async () => dateOf((await info()).committer), | |
| 494 | + author: async () => signed((await info()).author), | |
| 495 | + authors: async (args: Args) => connection([signed((await info()).author)], args), | |
| 496 | + committer: async () => signed((await info()).committer), | |
| 497 | + status: async () => { | |
| 498 | + const { statuses } = await rollup(); | |
| 499 | + if (!statuses.length) return null; | |
| 500 | + return { __typename: "Status", id: nodeId("Status", `${repo.id}/${oid}`), state: combinedState(statuses.map(statusState)), contexts: statuses.map((s) => statusContext(ctx, s, new Set())) }; | |
| 501 | + }, | |
| 502 | + statusCheckRollup: async () => { | |
| 503 | + const { checks, statuses, required } = await rollup(); | |
| 504 | + if (!checks.length && !statuses.length) return null; | |
| 505 | + const contexts: Obj[] = [...checks.map((c) => checkRun(ctx, l, c, required)), ...statuses.map((s) => statusContext(ctx, s, required))]; | |
| 506 | + const runStates = checks.map(checkRunState); | |
| 507 | + const statusStates = statuses.map(statusState); | |
| 508 | + return { | |
| 509 | + __typename: "StatusCheckRollup", | |
| 510 | + id: nodeId("StatusCheckRollup", `${repo.id}/${oid}`), | |
| 511 | + state: rollupState(runStates, statusStates), | |
| 512 | + commit: () => commit(ctx, l, oid, opts, known), | |
| 513 | + contexts: (args: Args) => | |
| 514 | + connection(contexts, args, { | |
| 515 | + checkRunCount: checks.length, | |
| 516 | + checkRunCountsByState: counts(runStates), | |
| 517 | + statusContextCount: statuses.length, | |
| 518 | + statusContextCountsByState: counts(statusStates), | |
| 519 | + }), | |
| 520 | + }; | |
| 521 | + }, | |
| 522 | + }; | |
| 523 | +} | |
| 524 | + | |
| 525 | +function dateOf(s: { date?: string; time?: number; timestamp?: number }): string | null { | |
| 526 | + if (typeof s.date === "string") return s.date; | |
| 527 | + const t = typeof s.time === "number" ? s.time : typeof s.timestamp === "number" ? s.timestamp : null; | |
| 528 | + return t === null ? null : new Date(t * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); | |
| 529 | +} | |
| 530 | + | |
| 531 | +function counts(states: string[]): Array<{ state: string; count: number }> { | |
| 532 | + const m = new Map<string, number>(); | |
| 533 | + for (const s of states) m.set(s, (m.get(s) ?? 0) + 1); | |
| 534 | + return [...m].map(([state, count]) => ({ state, count })); | |
| 535 | +} | |
| 536 | + | |
| 537 | +const CONCLUSIONS = new Set(["ACTION_REQUIRED", "TIMED_OUT", "CANCELLED", "FAILURE", "SUCCESS", "NEUTRAL", "SKIPPED", "STARTUP_FAILURE", "STALE"]); | |
| 538 | +const conclusionOf = (c: Check): string | null => { | |
| 539 | + if (c.status !== "completed") return null; | |
| 540 | + const v = (c.conclusion ?? "neutral").toUpperCase(); | |
| 541 | + return CONCLUSIONS.has(v) ? v : "NEUTRAL"; | |
| 542 | +}; | |
| 543 | +const statusOfCheck = (c: Check): string => (c.status === "in_progress" ? "IN_PROGRESS" : c.status === "completed" ? "COMPLETED" : "QUEUED"); | |
| 544 | +/** A check run counted by state as GitHub counts it: its conclusion once complete, its status before. */ | |
| 545 | +const checkRunState = (c: Check): string => conclusionOf(c) ?? statusOfCheck(c); | |
| 546 | +const statusState = (s: CommitStatus): string => s.state.toUpperCase(); | |
| 547 | + | |
| 548 | +const BAD = new Set(["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]); | |
| 549 | +const WAITING = new Set(["QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED", "EXPECTED"]); | |
| 550 | +function combinedState(states: string[]): string { | |
| 551 | + if (states.includes("ERROR")) return "ERROR"; | |
| 552 | + if (states.some((s) => BAD.has(s))) return "FAILURE"; | |
| 553 | + if (states.some((s) => WAITING.has(s))) return "PENDING"; | |
| 554 | + return "SUCCESS"; | |
| 555 | +} | |
| 556 | +/** GitHub's rollup: failure if anything failed, pending while anything runs, success otherwise. */ | |
| 557 | +const rollupState = (runs: string[], statuses: string[]): string => { | |
| 558 | + const s = combinedState([...runs, ...statuses]); | |
| 559 | + return s === "ERROR" ? "FAILURE" : s; | |
| 560 | +}; | |
| 561 | + | |
| 562 | +function checkRun(ctx: Ctx, l: Loaded, c: Check, required: Set<string>): Obj { | |
| 563 | + const { repo } = l; | |
| 564 | + const html = repoHtml(ctx.base, repo.owner_login, repo.name); | |
| 565 | + // Diffinery names an Actions check "<workflow> / <job>"; GitHub keeps the job in `name` and the workflow on the suite. | |
| 566 | + const split = c.runId ? /^(.+?) \/ (.+)$/.exec(c.name) : null; | |
| 567 | + const workflowName = split?.[1] ?? null; | |
| 568 | + const conclusion = conclusionOf(c); | |
| 569 | + const status = statusOfCheck(c); | |
| 570 | + const runUrl = c.runId ? `${html}/actions/runs/${c.runId}` : null; | |
| 571 | + const link = c.detailsUrl ?? runUrl ?? `${html}/commit/${c.sha}`; | |
| 572 | + return { | |
| 573 | + __typename: "CheckRun", | |
| 574 | + id: nodeId("CheckRun", c.id), | |
| 575 | + databaseId: c.seq ?? null, | |
| 576 | + name: split?.[2] ?? c.name, | |
| 577 | + status, | |
| 578 | + conclusion, | |
| 579 | + startedAt: c.startedAt, | |
| 580 | + completedAt: c.completedAt, | |
| 581 | + detailsUrl: link, | |
| 582 | + url: link, | |
| 583 | + permalink: link, | |
| 584 | + title: c.output?.title ?? null, | |
| 585 | + summary: c.output?.summary ?? null, | |
| 586 | + text: null, | |
| 587 | + isRequired: () => required.has(c.name), | |
| 588 | + checkSuite: { | |
| 589 | + id: nodeId("CheckSuite", c.runId ?? `${c.app}/${c.sha}`), | |
| 590 | + status, | |
| 591 | + conclusion, | |
| 592 | + app: { id: nodeId("App", c.app), name: c.app === "diffinery-actions" ? "Diffinery Actions" : c.app, slug: c.app, logoUrl: () => `${ctx.base}/api/avatars/${encodeURIComponent(c.app)}` }, | |
| 593 | + workflowRun: c.runId | |
| 594 | + ? { id: nodeId("WorkflowRun", c.runId), databaseId: null, runNumber: 0, event: "", url: runUrl, workflow: { id: nodeId("Workflow", `${repo.id}/${workflowName}`), name: workflowName ?? "" } } | |
| 595 | + : null, | |
| 596 | + }, | |
| 597 | + }; | |
| 598 | +} | |
| 599 | + | |
| 600 | +function statusContext(ctx: Ctx, s: CommitStatus, required: Set<string>): Obj { | |
| 601 | + return { | |
| 602 | + __typename: "StatusContext", | |
| 603 | + id: nodeId("StatusContext", String(s.id)), | |
| 604 | + context: s.context, | |
| 605 | + state: statusState(s), | |
| 606 | + description: s.description, | |
| 607 | + targetUrl: s.targetUrl, | |
| 608 | + createdAt: s.createdAt, | |
| 609 | + creator: actor(ctx, s.creator as Person), | |
| 610 | + avatarUrl: () => (s.creator ? `${ctx.base}/api/avatars/${encodeURIComponent(s.creator.login)}` : null), | |
| 611 | + isRequired: () => required.has(s.context), | |
| 612 | + }; | |
| 613 | +} | |
| 614 | + | |
| 615 | +// ---------------------------------------------------------------- the root | |
| 616 | + | |
| 617 | +const viewerId = (ctx: Ctx): string | null => { | |
| 618 | + const p = ctx.c.get("principal"); | |
| 619 | + return p.type === "account" ? p.account.id : null; | |
| 620 | +}; | |
| 621 | + | |
| 622 | +/** The type and key a node id (./gh.ts nodeId) was made from, or null for anything else. */ | |
| 623 | +function decodeNodeId(id: string): { type: string; key: string } | null { | |
| 624 | + try { | |
| 625 | + const padded = id.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((id.length + 3) % 4); | |
| 626 | + const m = /^([A-Za-z]+):(.+)$/.exec(new TextDecoder().decode(Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0)))); | |
| 627 | + return m ? { type: m[1]!, key: m[2]! } : null; | |
| 628 | + } catch { | |
| 629 | + return null; | |
| 630 | + } | |
| 631 | +} | |
| 632 | + | |
| 633 | +/** A repository or pull request by node id, with the same access check as a lookup by name. */ | |
| 634 | +async function nodeById(ctx: Ctx, id: string): Promise<Obj | null> { | |
| 635 | + const d = decodeNodeId(id); | |
| 636 | + if (!d || (d.type !== "Repository" && d.type !== "PullRequest")) return null; | |
| 637 | + const [repoId, num] = d.type === "PullRequest" ? d.key.split("/") : [d.key]; | |
| 638 | + const row = await ctx.c.get("dir").repoById(repoId!); | |
| 639 | + if (!row) return null; | |
| 640 | + const l = await loadRepository(ctx, row.owner_login, row.name); | |
| 641 | + if (!l) return null; | |
| 642 | + const repoObj = repository(ctx, l); | |
| 643 | + if (d.type === "Repository") return repoObj; | |
| 644 | + const issue = await issueOf(ctx, l, Number(num)).catch(() => null); | |
| 645 | + return issue?.kind === "pull" ? pullRequest(ctx, l, issue, () => repoObj) : null; | |
| 646 | +} | |
| 647 | + | |
| 648 | +/** | |
| 649 | + * GitHub's issue search, for pull requests in one repository: `repo:` is required, and `is:`, | |
| 650 | + * `state:`, `author:`, `assignee:`, `review-requested:`, `head:`, `base:`, `label:` and `draft:` narrow it. | |
| 651 | + */ | |
| 652 | +async function search(ctx: Ctx, args: Args): Promise<Obj> { | |
| 653 | + pageSize(args); | |
| 654 | + const empty = () => ({ ...connection([], args), issueCount: 0, repositoryCount: 0, userCount: 0 }); | |
| 655 | + if (args.type !== "ISSUE" && args.type !== "ISSUE_ADVANCED") return empty(); | |
| 656 | + const q: Record<string, string[]> = {}; | |
| 657 | + for (const m of String(args.query).matchAll(/(-?)([a-z-]+):("[^"]*"|\S+)/g)) if (!m[1]) (q[m[2]!] ??= []).push(m[3]!.replace(/^"|"$/g, "")); | |
| 658 | + const viewer = ctx.c.get("principal"); | |
| 659 | + const me = viewer.type === "account" ? viewer.account.login : ""; | |
| 660 | + const who = (v: string | undefined) => (v === "@me" ? me : v); | |
| 661 | + const [full] = q.repo ?? []; | |
| 662 | + if (!full || q.repo!.length > 1 || (q.is ?? []).includes("issue") || (q.type ?? []).includes("issue")) return empty(); | |
| 663 | + const [owner, name] = full.split("/"); | |
| 664 | + const l = owner && name ? await loadRepository(ctx, owner, name) : null; | |
| 665 | + if (!l) return empty(); | |
| 666 | + const states = new Set([...(q.state ?? []), ...(q.is ?? []).filter((x) => ["open", "closed", "merged"].includes(x))]); | |
| 667 | + const state = states.size === 1 && states.has("open") ? "open" : states.size && !states.has("open") ? "closed" : "all"; | |
| 668 | + const res = await l.stub.issues({ kind: "pull", state, author: who(q.author?.[0]), assignee: who(q.assignee?.[0]), labels: q.label, head: q.head?.[0], base: q.base?.[0], sort: "created", direction: "desc", limit: 500, hide: await hiddenAuthors(ctx.c) }); | |
| 669 | + const reviewer = who(q["review-requested"]?.[0])?.toLowerCase(); | |
| 670 | + const items = res.items.filter((i) => { | |
| 671 | + if (states.has("merged") && !states.has("closed") && !i.pull?.merged) return false; | |
| 672 | + if (q.draft?.[0] && String(!!i.pull?.draft) !== q.draft[0]) return false; | |
| 673 | + if (reviewer && !i.pull?.requestedReviewers.some((r) => r.toLowerCase() === reviewer)) return false; | |
| 674 | + return true; | |
| 675 | + }); | |
| 676 | + const repoObj = repository(ctx, l); | |
| 677 | + return { ...connection(items.map((i) => pullRequest(ctx, l, i, () => repoObj)), args), issueCount: items.length, repositoryCount: 0, userCount: 0 }; | |
| 678 | +} | |
| 679 | + | |
| 680 | +function root(ctx: Ctx): Obj { | |
| 681 | + const p = ctx.c.get("principal"); | |
| 682 | + return { | |
| 683 | + viewer: () => { | |
| 684 | + if (p.type !== "account") throw new GraphQLError("This endpoint requires you to be authenticated."); | |
| 685 | + return { ...actor(ctx, { id: p.account.id, login: p.account.login, kind: p.account.kind, name: p.account.name || null }), __typename: "User", email: p.account.email ?? "" }; | |
| 686 | + }, | |
| 687 | + user: async (args: Args) => { | |
| 688 | + const a = await ctx.c.get("dir").accountByLogin(String(args.login)).catch(() => null); | |
| 689 | + if (!a || a.kind === "org") throw notFound(`Could not resolve to a User with the login of '${args.login}'.`); | |
| 690 | + return { ...actor(ctx, { id: a.id, login: a.login, kind: a.kind, name: a.name || null }), __typename: "User" }; | |
| 691 | + }, | |
| 692 | + repository: async (args: Args) => { | |
| 693 | + const owner = String(args.owner); | |
| 694 | + const name = String(args.name); | |
| 695 | + const l = await loadRepository(ctx, owner, name); | |
| 696 | + if (!l) throw notFound(`Could not resolve to a Repository with the name '${owner}/${name}'.`); | |
| 697 | + return repository(ctx, l); | |
| 698 | + }, | |
| 699 | + node: (args: Args) => nodeById(ctx, String(args.id)), | |
| 700 | + nodes: (args: Args) => { | |
| 701 | + const ids = (args.ids as string[]) ?? []; | |
| 702 | + if (ids.length > LIMITS.pageSize) throw new GraphQLError(`You may not ask for more than ${LIMITS.pageSize} nodes at once.`, { extensions: { type: "EXCESSIVE_PAGINATION" } }); | |
| 703 | + return ids.map((id) => nodeById(ctx, id)); | |
| 704 | + }, | |
| 705 | + search: (args: Args) => search(ctx, args), | |
| 706 | + rateLimit: () => ({ cost: 1, limit: 5000, nodeCount: 0, remaining: 4999, used: 1, resetAt: new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString().replace(/\.\d{3}Z$/, "Z") }), | |
| 707 | + }; | |
| 708 | +} | |
| 709 | + | |
| 710 | +// ---------------------------------------------------------------- mutations (the pull request ones gh uses) | |
| 711 | + | |
| 712 | +/** loadRepo for a change: the role the REST route for the same change needs. */ | |
| 713 | +async function loadForWrite(ctx: Ctx, owner: string, name: string, min: Role): Promise<Loaded> { | |
| 714 | + try { | |
| 715 | + const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, min, { follow: true }); | |
| 716 | + return { repo, caller, stub }; | |
| 717 | + } catch (e) { | |
| 718 | + if (statusOf(e)?.status === 404) throw notFound(`Could not resolve to a Repository with the name '${owner}/${name}'.`); | |
| 719 | + throw e; | |
| 720 | + } | |
| 721 | +} | |
| 722 | + | |
| 723 | +async function repoForWrite(ctx: Ctx, id: unknown, min: Role): Promise<Loaded> { | |
| 724 | + const d = decodeNodeId(String(id)); | |
| 725 | + const row = d?.type === "Repository" ? await ctx.c.get("dir").repoById(d.key) : null; | |
| 726 | + if (!row) throw notFound(`Could not resolve to a node with the global id of '${id}'.`); | |
| 727 | + return loadForWrite(ctx, row.owner_login, row.name, min); | |
| 728 | +} | |
| 729 | + | |
| 730 | +async function pullForWrite(ctx: Ctx, id: unknown, min: Role = "read"): Promise<{ l: Loaded; number: number }> { | |
| 731 | + const d = decodeNodeId(String(id)); | |
| 732 | + const [repoId, n] = d && (d.type === "PullRequest" || d.type === "Issue") ? d.key.split("/") : []; | |
| 733 | + const row = repoId ? await ctx.c.get("dir").repoById(repoId) : null; | |
| 734 | + if (!row || !Number.isSafeInteger(Number(n))) throw notFound(`Could not resolve to a node with the global id of '${id}'.`); | |
| 735 | + return { l: await loadForWrite(ctx, row.owner_login, row.name, min), number: Number(n) }; | |
| 736 | +} | |
| 737 | + | |
| 738 | +/** What a label or assignee change returns: the pull request (issues aren't part of this API). */ | |
| 739 | +async function labelable(ctx: Ctx, l: Loaded, number: number): Promise<Obj | null> { | |
| 740 | + return (await issueOf(ctx, l, number)).kind === "pull" ? freshPull(ctx, l, number) : null; | |
| 741 | +} | |
| 742 | + | |
| 743 | +/** The pull request as it is after a change (this request's cached copies are dropped). */ | |
| 744 | +async function freshPull(ctx: Ctx, l: Loaded, number: number): Promise<Obj> { | |
| 745 | + for (const k of ["issue", "full", "review", "merge", "stats", "commits", "files"]) ctx.memo.delete(k === "issue" ? `issue:${l.repo.id}:${number}` : `${k}:${l.repo.id}:${number}`); | |
| 746 | + const issue = await issueOf(ctx, l, number); | |
| 747 | + const repoObj = repository(ctx, l); | |
| 748 | + return pullRequest(ctx, l, issue, () => repoObj); | |
| 749 | +} | |
| 750 | + | |
| 751 | +const input = (args: Args) => (args.input ?? {}) as Record<string, unknown>; | |
| 752 | +const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); | |
| 753 | +const method = (v: unknown): string | undefined => (typeof v === "string" ? v.toLowerCase() : undefined); | |
| 754 | +const payload = (inp: Record<string, unknown>, rest: Obj): Obj => ({ clientMutationId: inp.clientMutationId ?? null, ...rest }); | |
| 755 | + | |
| 756 | +/** Logins for GitHub user node ids (as gh resolves `--reviewer` logins), or logins passed as they are. */ | |
| 757 | +async function loginsFor(ctx: Ctx, ids: unknown): Promise<string[]> { | |
| 758 | + const out: string[] = []; | |
| 759 | + for (const id of Array.isArray(ids) ? ids.slice(0, 50) : []) { | |
| 760 | + const d = decodeNodeId(String(id)); | |
| 761 | + const a = d && d.type === "User" ? ((await ctx.c.get("dir").account(d.key).catch(() => null)) ?? (await ctx.c.get("dir").accountByLogin(d.key).catch(() => null))) : null; | |
| 762 | + if (!a) throw notFound(`Could not resolve to a User with the global id of '${id}'.`); | |
| 763 | + out.push(a.login); | |
| 764 | + } | |
| 765 | + return out; | |
| 766 | +} | |
| 767 | + | |
| 768 | +/** Label names from Label node ids, which must belong to this repository. */ | |
| 769 | +async function labelNamesFor(ctx: Ctx, l: Loaded, ids: unknown[]): Promise<string[]> { | |
| 770 | + const prefix = `${l.repo.owner_login}/${l.repo.name}/`; | |
| 771 | + const rows = await once(ctx, `labels:${l.repo.id}`, () => l.stub.labelRows()); | |
| 772 | + return ids.slice(0, 100).map((id) => { | |
| 773 | + const d = decodeNodeId(String(id)); | |
| 774 | + const name = d?.type === "Label" && d.key.startsWith(prefix) ? d.key.slice(prefix.length) : null; | |
| 775 | + const row = name !== null ? rows.find((r) => r.name === name) : undefined; | |
| 776 | + if (!row) throw notFound(`Could not resolve to a Label with the global id of '${id}'.`); | |
| 777 | + return row.name; | |
| 778 | + }); | |
| 779 | +} | |
| 780 | + | |
| 781 | +async function setMilestone(ctx: Ctx, l: Loaded, number: number, id: unknown): Promise<void> { | |
| 782 | + if (id !== null && typeof id !== "string") return; | |
| 783 | + let n: number | null = null; | |
| 784 | + if (id) { | |
| 785 | + const d = decodeNodeId(id); | |
| 786 | + const [repoId, num] = d?.type === "Milestone" ? d.key.split("/") : []; | |
| 787 | + n = repoId === l.repo.id ? Number(num) : NaN; | |
| 788 | + if (!Number.isInteger(n) || !(await l.stub.milestones("all")).some((m) => m.number === n)) throw notFound(`Could not resolve to a Milestone with the global id of '${id}'.`); | |
| 789 | + } | |
| 790 | + // As over REST: only people with triage access set milestones. | |
| 791 | + if (!l.caller.role || !["triage", "write", "maintain", "admin"].includes(l.caller.role)) throw new GraphQLError("Setting a milestone needs triage access.", { extensions: { type: "FORBIDDEN" } }); | |
| 792 | + await l.stub.setIssueMilestone(number, n); | |
| 793 | + ctx.memo.delete(`issue:${l.repo.id}:${number}`); | |
| 794 | +} | |
| 795 | + | |
| 796 | +/** Creating a pull request or comment counts against the same per-account limit as over REST (lib/rate-limit.ts). */ | |
| 797 | +async function creating(ctx: Ctx): Promise<void> { | |
| 798 | + const p = ctx.c.get("principal"); | |
| 799 | + if (p.type === "account" && (await overLimit(ctx.c.env, [["RL_CREATE", `acct:${p.account.id}`]]))) { | |
| 800 | + throw new GraphQLError(`Too many pull requests and comments from your account: at most ${RATE_LIMITS.RL_CREATE} a minute. Try again in a minute.`, { extensions: { type: "RATE_LIMITED" } }); | |
| 801 | + } | |
| 802 | +} | |
| 803 | + | |
| 804 | +function mutations(ctx: Ctx): Obj { | |
| 805 | + return { | |
| 806 | + createPullRequest: async (args: Args) => { | |
| 807 | + const inp = input(args); | |
| 808 | + const l = await repoForWrite(ctx, inp.repositoryId, "write"); | |
| 809 | + await creating(ctx); | |
| 810 | + const head = String(inp.headRefName ?? ""); | |
| 811 | + const issue = await l.stub.createPull({ title: String(inp.title ?? ""), body: str(inp.body), head: head.includes(":") ? head.slice(head.indexOf(":") + 1) : head, base: str(inp.baseRefName), draft: inp.draft === true }, l.caller); | |
| 812 | + ctx.memo.set(`issue:${l.repo.id}:${issue.number}`, Promise.resolve(issue)); | |
| 813 | + return payload(inp, { pullRequest: await freshPull(ctx, l, issue.number) }); | |
| 814 | + }, | |
| 815 | + updatePullRequest: async (args: Args) => { | |
| 816 | + const inp = input(args); | |
| 817 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 818 | + if (Array.isArray(inp.projectIds) && inp.projectIds.length) throw new GraphQLError("Projects aren't supported.", { extensions: { type: "UNPROCESSABLE" } }); | |
| 819 | + const current = await issueOf(ctx, l, number); | |
| 820 | + if (typeof inp.baseRefName === "string" && inp.baseRefName !== current.pull?.base) throw new GraphQLError("Changing a pull request's base branch isn't supported.", { extensions: { type: "UNPROCESSABLE" } }); | |
| 821 | + const state = inp.state === "OPEN" ? "open" : inp.state === "CLOSED" ? "closed" : undefined; | |
| 822 | + const labels = Array.isArray(inp.labelIds) ? await labelNamesFor(ctx, l, inp.labelIds) : undefined; | |
| 823 | + const assignees = Array.isArray(inp.assigneeIds) ? await loginsFor(ctx, inp.assigneeIds) : undefined; | |
| 824 | + if (inp.title !== undefined || inp.body !== undefined || state || labels || assignees) await l.stub.updateIssue(number, { title: str(inp.title), body: str(inp.body), state, labels, assignees }, l.caller); | |
| 825 | + if (inp.milestoneId !== undefined) await setMilestone(ctx, l, number, inp.milestoneId); | |
| 826 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); | |
| 827 | + }, | |
| 828 | + closePullRequest: async (args: Args) => { | |
| 829 | + const inp = input(args); | |
| 830 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 831 | + await l.stub.updateIssue(number, { state: "closed" }, l.caller); | |
| 832 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number) }); | |
| 833 | + }, | |
| 834 | + reopenPullRequest: async (args: Args) => { | |
| 835 | + const inp = input(args); | |
| 836 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 837 | + await l.stub.updateIssue(number, { state: "open" }, l.caller); | |
| 838 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number) }); | |
| 839 | + }, | |
| 840 | + markPullRequestReadyForReview: async (args: Args) => { | |
| 841 | + const inp = input(args); | |
| 842 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 843 | + await l.stub.setPullDraft(number, false, l.caller); | |
| 844 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number) }); | |
| 845 | + }, | |
| 846 | + convertPullRequestToDraft: async (args: Args) => { | |
| 847 | + const inp = input(args); | |
| 848 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 849 | + await l.stub.setPullDraft(number, true, l.caller); | |
| 850 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number) }); | |
| 851 | + }, | |
| 852 | + enablePullRequestAutoMerge: async (args: Args) => { | |
| 853 | + const inp = input(args); | |
| 854 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write"); | |
| 855 | + await l.stub.enableAutoMerge(number, { method: method(inp.mergeMethod), commitTitle: str(inp.commitHeadline), commitMessage: str(inp.commitBody), expectedHeadSha: str(inp.expectedHeadOid) }, l.caller); | |
| 856 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); | |
| 857 | + }, | |
| 858 | + disablePullRequestAutoMerge: async (args: Args) => { | |
| 859 | + const inp = input(args); | |
| 860 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write"); | |
| 861 | + await l.stub.disableAutoMerge(number, l.caller); | |
| 862 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); | |
| 863 | + }, | |
| 864 | + mergePullRequest: async (args: Args) => { | |
| 865 | + const inp = input(args); | |
| 866 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write"); | |
| 867 | + await l.stub.mergePull(number, { method: method(inp.mergeMethod) as "merge" | "squash" | "rebase" | undefined, title: str(inp.commitHeadline), message: str(inp.commitBody), expectedHeadSha: str(inp.expectedHeadOid) }, l.caller); | |
| 868 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); | |
| 869 | + }, | |
| 870 | + addComment: async (args: Args) => { | |
| 871 | + const inp = input(args); | |
| 872 | + const { l, number } = await pullForWrite(ctx, inp.subjectId); | |
| 873 | + const body = String(inp.body ?? ""); | |
| 874 | + if (!body.trim()) throw new GraphQLError("Body can't be blank.", { extensions: { type: "UNPROCESSABLE" } }); | |
| 875 | + await creating(ctx); | |
| 876 | + const cm = await l.stub.addComment(number, body, l.caller); | |
| 877 | + const html = repoHtml(ctx.base, l.repo.owner_login, l.repo.name); | |
| 878 | + const issue = await issueOf(ctx, l, number); | |
| 879 | + const node = { __typename: "IssueComment", id: nodeId("IssueComment", `${l.repo.id}/${cm.id}`), author: actor(ctx, cm.author), authorAssociation: "NONE", body: cm.body, createdAt: cm.createdAt, url: `${html}/${issue.kind === "pull" ? "pull" : "issues"}/${number}#issuecomment-${cm.id}`, includesCreatedEdit: false, isMinimized: false, minimizedReason: null, reactionGroups: NO_REACTIONS, viewerDidAuthor: true }; | |
| 880 | + return payload(inp, { commentEdge: { cursor: cursorOf(0), node }, subject: issue.kind === "pull" ? await freshPull(ctx, l, number) : null }); | |
| 881 | + }, | |
| 882 | + addLabelsToLabelable: async (args: Args) => { | |
| 883 | + const inp = input(args); | |
| 884 | + const { l, number } = await pullForWrite(ctx, inp.labelableId); | |
| 885 | + const current = await issueOf(ctx, l, number); | |
| 886 | + const add = await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : []); | |
| 887 | + await l.stub.updateIssue(number, { labels: [...new Set([...current.labels, ...add])] }, l.caller); | |
| 888 | + return payload(inp, { labelable: await labelable(ctx, l, number) }); | |
| 889 | + }, | |
| 890 | + removeLabelsFromLabelable: async (args: Args) => { | |
| 891 | + const inp = input(args); | |
| 892 | + const { l, number } = await pullForWrite(ctx, inp.labelableId); | |
| 893 | + const current = await issueOf(ctx, l, number); | |
| 894 | + const drop = new Set(await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : [])); | |
| 895 | + await l.stub.updateIssue(number, { labels: current.labels.filter((x) => !drop.has(x)) }, l.caller); | |
| 896 | + return payload(inp, { labelable: await labelable(ctx, l, number) }); | |
| 897 | + }, | |
| 898 | + addAssigneesToAssignable: async (args: Args) => { | |
| 899 | + const inp = input(args); | |
| 900 | + const { l, number } = await pullForWrite(ctx, inp.assignableId); | |
| 901 | + const current = await issueOf(ctx, l, number); | |
| 902 | + await l.stub.updateIssue(number, { assignees: [...new Set([...current.assignees, ...(await loginsFor(ctx, inp.assigneeIds))])] }, l.caller); | |
| 903 | + return payload(inp, { assignable: await labelable(ctx, l, number) }); | |
| 904 | + }, | |
| 905 | + removeAssigneesFromAssignable: async (args: Args) => { | |
| 906 | + const inp = input(args); | |
| 907 | + const { l, number } = await pullForWrite(ctx, inp.assignableId); | |
| 908 | + const current = await issueOf(ctx, l, number); | |
| 909 | + const drop = new Set(await loginsFor(ctx, inp.assigneeIds)); | |
| 910 | + await l.stub.updateIssue(number, { assignees: current.assignees.filter((x) => !drop.has(x)) }, l.caller); | |
| 911 | + return payload(inp, { assignable: await labelable(ctx, l, number) }); | |
| 912 | + }, | |
| 913 | + requestReviews: async (args: Args) => { | |
| 914 | + const inp = input(args); | |
| 915 | + if (Array.isArray(inp.teamIds) && inp.teamIds.length) throw new GraphQLError("Team reviewers aren't supported; request people.", { extensions: { type: "UNPROCESSABLE" } }); | |
| 916 | + const { l, number } = await pullForWrite(ctx, inp.pullRequestId); | |
| 917 | + await l.stub.requestReviewers(number, await loginsFor(ctx, inp.userIds), l.caller); | |
| 918 | + return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); | |
| 919 | + }, | |
| 920 | + }; | |
| 921 | +} | |
| 922 | + | |
| 923 | +// ---------------------------------------------------------------- limits | |
| 924 | + | |
| 925 | +/** Depth and field count of the operation, with fragments expanded (each fragment counted where it's spread). */ | |
| 926 | +export function measure(doc: DocumentNode): { depth: number; fields: number } { | |
| 927 | + const fragments = new Map<string, SelectionSetNode>(); | |
| 928 | + for (const d of doc.definitions) if (d.kind === Kind.FRAGMENT_DEFINITION) fragments.set(d.name.value, d.selectionSet); | |
| 929 | + let fields = 0; | |
| 930 | + let depth = 0; | |
| 931 | + const walk = (set: SelectionSetNode, level: number, seen: Set<string>) => { | |
| 932 | + if (level > depth) depth = level; | |
| 933 | + if (fields > LIMITS.fields || depth > LIMITS.depth) return; | |
| 934 | + for (const s of set.selections) { | |
| 935 | + if (s.kind === Kind.FIELD) { | |
| 936 | + fields++; | |
| 937 | + if (s.selectionSet) walk(s.selectionSet, level + 1, seen); | |
| 938 | + } else if (s.kind === Kind.INLINE_FRAGMENT) walk(s.selectionSet, level, seen); | |
| 939 | + else { | |
| 940 | + const f = fragments.get(s.name.value); | |
| 941 | + if (f && !seen.has(s.name.value)) walk(f, level, new Set([...seen, s.name.value])); | |
| 942 | + } | |
| 943 | + } | |
| 944 | + }; | |
| 945 | + for (const d of doc.definitions) if (d.kind === Kind.OPERATION_DEFINITION) walk(d.selectionSet, 1, new Set()); | |
| 946 | + return { depth, fields }; | |
| 947 | +} | |
| 948 | + | |
| 949 | +const failure = (status: number, message: string) => new Response(JSON.stringify({ message, documentation_url: "https://docs.github.com/graphql" }), { status, headers: { "content-type": "application/json" } }); | |
| 950 | + | |
| 951 | +/** GitHub's error shape: `type` sits beside `message` (gh reads it), as well as under `extensions`. */ | |
| 952 | +function errorJson(e: GraphQLError): Obj { | |
| 953 | + const out = e.toJSON() as unknown as Obj; | |
| 954 | + const type = e.extensions?.type; | |
| 955 | + if (typeof type === "string") out.type = type; | |
| 956 | + return out; | |
| 957 | +} | |
| 958 | + | |
| 959 | +export async function runGraphql(c: AppContext, input: { query?: unknown; variables?: unknown; operationName?: unknown }): Promise<{ status: number; body: Obj }> { | |
| 960 | + if (typeof input.query !== "string" || !input.query.trim()) return { status: 200, body: { errors: [{ message: "A query attribute must be specified and must be a string." }] } }; | |
| 961 | + if (input.query.length > LIMITS.queryBytes) return { status: 200, body: { errors: [{ message: `The query is over ${LIMITS.queryBytes} bytes.` }] } }; | |
| 962 | + let doc: DocumentNode; | |
| 963 | + try { | |
| 964 | + doc = parse(input.query, { maxTokens: 20_000 }); | |
| 965 | + } catch (e) { | |
| 966 | + return { status: 200, body: { errors: [errorJson(e as GraphQLError)] } }; | |
| 967 | + } | |
| 968 | + const operationName = typeof input.operationName === "string" && input.operationName ? input.operationName : undefined; | |
| 969 | + const ops = doc.definitions.filter((d) => d.kind === Kind.OPERATION_DEFINITION); | |
| 970 | + const op = operationName ? ops.find((d) => d.kind === Kind.OPERATION_DEFINITION && d.name?.value === operationName) : ops[0]; | |
| 971 | + const mutation = op?.kind === Kind.OPERATION_DEFINITION && op.operation === "mutation"; | |
| 972 | + if (op?.kind === Kind.OPERATION_DEFINITION && op.operation === "subscription") { | |
| 973 | + return { status: 200, body: { errors: [{ message: "Subscriptions aren't supported.", type: "FORBIDDEN" }] } }; | |
| 974 | + } | |
| 975 | + const size = measure(doc); | |
| 976 | + if (size.depth > LIMITS.depth) return { status: 200, body: { errors: [{ message: `The query is nested ${size.depth} levels deep; the limit is ${LIMITS.depth}.`, type: "MAX_NODE_LIMIT_EXCEEDED" }] } }; | |
| 977 | + if (size.fields > LIMITS.fields) return { status: 200, body: { errors: [{ message: `The query asks for more than ${LIMITS.fields} fields.`, type: "MAX_NODE_LIMIT_EXCEEDED" }] } }; | |
| 978 | + const s = schema(); | |
| 979 | + const errors = validate(s, doc, RULES); | |
| 980 | + if (errors.length) return { status: 200, body: { errors: errors.map(errorJson) } }; | |
| 981 | + // Fields Diffinery doesn't have are reported, not fatal (see the top of this file). | |
| 982 | + const unsupported = [...new Set(validate(s, doc, [FieldsOnCorrectTypeRule]).map((e) => e.message.replace(/ Did you mean .*$/, "")))]; | |
| 983 | + // A query only reads: repository lookups run as reads whatever the HTTP method. A mutation is a | |
| 984 | + // write, so each repository it touches is loaded as one (write scope, archive and import checks). | |
| 985 | + if (!mutation) c.set("readOnly", true); | |
| 986 | + const ctx: Ctx = { c, base: baseOf(c), memo: new Map(), repos: 0 }; | |
| 987 | + const variables = input.variables && typeof input.variables === "object" && !Array.isArray(input.variables) ? (input.variables as Record<string, unknown>) : undefined; | |
| 988 | + const result = await execute({ schema: s, document: doc, rootValue: mutation ? mutations(ctx) : root(ctx), contextValue: ctx, variableValues: variables, operationName }); | |
| 989 | + const body: Obj = {}; | |
| 990 | + if (result.errors?.length) { | |
| 991 | + // Unexpected failures are logged and hidden; GitHub-style errors (not found, limits) are shown. | |
| 992 | + body.errors = result.errors.map((e) => { | |
| 993 | + const original = e.originalError; | |
| 994 | + if (!original || original instanceof GraphQLError) return errorJson(e); | |
| 995 | + const st = statusOf(original); | |
| 996 | + if (st && st.status < 500) return errorJson(new GraphQLError(st.message, { path: e.path, nodes: e.nodes, extensions: { type: st.status === 404 ? "NOT_FOUND" : st.status === 403 ? "FORBIDDEN" : st.status === 429 ? "RATE_LIMITED" : "UNPROCESSABLE" } })); | |
| 997 | + console.error("graphql", e.path?.join("."), original); | |
| 998 | + return errorJson(new GraphQLError("Something went wrong while executing your query.", { path: e.path, nodes: e.nodes })); | |
| 999 | + }); | |
| 1000 | + } | |
| 1001 | + body.data = result.data ?? null; | |
| 1002 | + if (unsupported.length) body.extensions = { unsupported }; | |
| 1003 | + return { status: 200, body }; | |
| 1004 | +} | |
| 1005 | + | |
| 1006 | +graphql.post("/api/graphql", async (c) => { | |
| 1007 | + // As on GitHub, the GraphQL API needs a signed-in caller (a token, or a browser session). | |
| 1008 | + if (c.get("principal").type === "anonymous") return failure(401, "This endpoint requires you to be authenticated."); | |
| 1009 | + let input: { query?: unknown; variables?: unknown; operationName?: unknown }; | |
| 1010 | + try { | |
| 1011 | + input = (await c.req.json()) as typeof input; | |
| 1012 | + } catch { | |
| 1013 | + return failure(400, "Problems parsing JSON"); | |
| 1014 | + } | |
| 1015 | + if (!input || typeof input !== "object") return failure(400, "Problems parsing JSON"); | |
| 1016 | + const { status, body } = await runGraphql(c, input); | |
| 1017 | + return c.json(body, status as 200); | |
| 1018 | +}); | |
| 1019 | +// GitHub Enterprise's REST root ends in a slash: the GitHub CLI checks a token there (`gh auth login --hostname`). | |
| 1020 | +graphql.get("/api/v3/", apiRootJson); | |
| 1021 | +graphql.get("/api/graphql", (c) => failure(405, "The GraphQL API takes POST requests with a JSON body: {\"query\": \"...\", \"variables\": {...}}.")); |
modifiedapps/diffinery/src/lib/directory.ts+4−2View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import type { RepoMeta, Role } from "../repo/types.js"; |
| 3 | 3 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 4 | -import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js"; | |
| 4 | +import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomAlnum, randomToken, sha256 } from "./util.js"; | |
| 5 | 5 | import { mailForAudit, tokenCreated } from "../mail/notify.js"; |
| 6 | 6 | import { enqueueMail } from "../mail/send.js"; |
| 7 | 7 | import { archivePrefix, deletePrefixes } from "./storage-keys.js"; |
| @@ -471,7 +471,9 @@ | ||
| 471 | 471 | /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). `client`: "cli" for a CLI sign-in's token (auth/cli-login.ts). */ |
| 472 | 472 | async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; importId?: string; client?: "cli" }): Promise<{ token: string; id: string; expiresAt: string }> { |
| 473 | 473 | const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366); |
| 474 | - const token = `dfy_${randomToken(30)}`; | |
| 474 | + // Letters and digits only after the prefix: the GitHub CLI masks a token after its last `_` | |
| 475 | + // (`gh auth status`), so an underscore in the random part would show the rest of it. | |
| 476 | + const token = `dfy_${randomAlnum(40)}`; | |
| 475 | 477 | const id = newId(); |
| 476 | 478 | const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); |
| 477 | 479 | await this.db |
modifiedapps/diffinery/src/lib/util.ts+14−0View file
| @@ -9,6 +9,20 @@ | ||
| 9 | 9 | return b64url(b); |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; | |
| 13 | + | |
| 14 | +/** `length` random letters and digits (no `_` or `-`), drawn without bias. */ | |
| 15 | +export function randomAlnum(length: number): string { | |
| 16 | + let out = ""; | |
| 17 | + while (out.length < length) { | |
| 18 | + for (const v of crypto.getRandomValues(new Uint8Array(length))) { | |
| 19 | + // 248 = 4 × 62: values above it would favour the first characters. | |
| 20 | + if (v < 248 && out.length < length) out += ALNUM[v % 62]; | |
| 21 | + } | |
| 22 | + } | |
| 23 | + return out; | |
| 24 | +} | |
| 25 | + | |
| 12 | 26 | export function b64url(b: Uint8Array): string { |
| 13 | 27 | let s = ""; |
| 14 | 28 | for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); |
modifiedapps/diffinery/src/repo/RepoDO.ts+6−0View file
| @@ -1102,6 +1102,12 @@ | ||
| 1102 | 1102 | pullCommits(...a: Parameters<Collab["pullCommits"]>) { |
| 1103 | 1103 | return this.collab.pullCommits(...a); |
| 1104 | 1104 | } |
| 1105 | + pullStats(...a: Parameters<Collab["pullStats"]>) { | |
| 1106 | + return this.collab.pullStats(...a); | |
| 1107 | + } | |
| 1108 | + reviewState(...a: Parameters<Collab["reviewState"]>) { | |
| 1109 | + return this.collab.reviewState(...a); | |
| 1110 | + } | |
| 1105 | 1111 | submitReview(...a: Parameters<Collab["submitReview"]>) { |
| 1106 | 1112 | return this.collab.submitReview(...a); |
| 1107 | 1113 | } |
modifiedapps/diffinery/src/repo/collab.ts+43−0View file
| @@ -658,6 +658,49 @@ | ||
| 658 | 658 | return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) }; |
| 659 | 659 | } |
| 660 | 660 | |
| 661 | + /** | |
| 662 | + * Line counts for the pull request's changes (GraphQL's additions, deletions and changedFiles), kept | |
| 663 | + * for the base and head they were counted at: clients such as Claude Code ask for them on every poll. | |
| 664 | + */ | |
| 665 | + async pullStats(number: number): Promise<{ additions: number; deletions: number; changedFiles: number }> { | |
| 666 | + const p = this.pullRow(number); | |
| 667 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`) ?? p.head_sha; | |
| 668 | + const key = `${number}:${baseSha}:${p.head_sha}`; | |
| 669 | + const hit = this.statsCache.get(key); | |
| 670 | + if (hit) return hit; | |
| 671 | + const mb = await mergeBase(this.repo.store, baseSha, p.head_sha); | |
| 672 | + const d = await this.repo.diff(mb, p.head_sha, { patch: false }); | |
| 673 | + const stats = { additions: d.additions, deletions: d.deletions, changedFiles: d.totalFiles }; | |
| 674 | + if (this.statsCache.size >= 256) this.statsCache.delete(this.statsCache.keys().next().value!); | |
| 675 | + this.statsCache.set(key, stats); | |
| 676 | + return stats; | |
| 677 | + } | |
| 678 | + private statsCache = new Map<string, { additions: number; deletions: number; changedFiles: number }>(); | |
| 679 | + | |
| 680 | + /** | |
| 681 | + * GitHub's reviewDecision and the base branch's required checks, counted as mergeability() counts | |
| 682 | + * approvals but without its merge: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or null when no | |
| 683 | + * review is needed and nobody has reviewed. | |
| 684 | + */ | |
| 685 | + reviewState(number: number): { decision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; requiredChecks: string[]; protection: { requiredApprovals: number; linearHistory: boolean } | null } { | |
| 686 | + const r = this.row(number); | |
| 687 | + const p = this.pullRow(number); | |
| 688 | + const rules = this.repo.protectionFor(p.base_ref) ?? {}; | |
| 689 | + let required = rules.requiredApprovals ?? 0; | |
| 690 | + if (r.author_kind === "agent" && required < 1) required = 1; | |
| 691 | + const latest = new Map<string, Review>(); | |
| 692 | + for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv); | |
| 693 | + let approvals = 0; | |
| 694 | + let changes = false; | |
| 695 | + for (const rv of latest.values()) { | |
| 696 | + if (rv.state === "APPROVED" && rv.author.kind === "user" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) approvals++; | |
| 697 | + if (rv.state === "CHANGES_REQUESTED") changes = true; | |
| 698 | + } | |
| 699 | + const decision = changes ? "CHANGES_REQUESTED" : approvals > 0 && approvals >= required ? "APPROVED" : required > 0 ? "REVIEW_REQUIRED" : null; | |
| 700 | + const protectedBase = this.repo.protectionFor(p.base_ref) !== null; | |
| 701 | + return { decision, requiredChecks: rules.requiredChecks ?? [], protection: protectedBase ? { requiredApprovals: rules.requiredApprovals ?? 0, linearHistory: !!rules.requireLinearHistory } : null }; | |
| 702 | + } | |
| 703 | + | |
| 661 | 704 | /** The first workflow file the pull request adds, changes or deletes, compared with where it branched off its base. */ |
| 662 | 705 | async changedWorkflow(number: number): Promise<string | null> { |
| 663 | 706 | const p = this.pullRow(number); |
addedapps/diffinery/test/e2e/graphql.test.mjs+255−0View file
| @@ -1,0 +1,255 @@ | ||
| 1 | +// End-to-end GraphQL API (/api/graphql), on a real `wrangler dev` Worker with the real git CLI: | |
| 2 | +// the queries Claude Code sends to show a session's pull request and its CI (the badge and the | |
| 3 | +// check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull | |
| 4 | +// request mutations behind `gh pr create`, `gh pr ready`, `gh pr edit`, `gh pr comment`, | |
| 5 | +// `gh pr close`, `gh pr reopen` and `gh pr merge`. Also covers sign-in, token scopes, limits and | |
| 6 | +// GitHub's error shape. | |
| 7 | +// | |
| 8 | +// pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs | |
| 9 | +import { spawn, execFileSync } from "node:child_process"; | |
| 10 | +import { createHash, randomBytes } from "node:crypto"; | |
| 11 | +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 12 | +import { tmpdir } from "node:os"; | |
| 13 | +import { join, dirname, resolve } from "node:path"; | |
| 14 | +import { fileURLToPath } from "node:url"; | |
| 15 | +import { createServer } from "node:net"; | |
| 16 | +import { test, before, after } from "node:test"; | |
| 17 | +import assert from "node:assert/strict"; | |
| 18 | + | |
| 19 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 20 | +const work = mkdtempSync(join(tmpdir(), "diffinery-graphql-e2e-")); | |
| 21 | +let dev; | |
| 22 | +let base; | |
| 23 | +let log = ""; | |
| 24 | + | |
| 25 | +const freePort = () => | |
| 26 | + new Promise((res) => { | |
| 27 | + const s = createServer(); | |
| 28 | + s.listen(0, "127.0.0.1", () => { | |
| 29 | + const { port } = s.address(); | |
| 30 | + s.close(() => res(port)); | |
| 31 | + }); | |
| 32 | + }); | |
| 33 | + | |
| 34 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 35 | +const T = { ann: `dfy_ann_${randomBytes(12).toString("hex")}`, annRead: `dfy_anr_${randomBytes(12).toString("hex")}`, bob: `dfy_bob_${randomBytes(12).toString("hex")}` }; | |
| 36 | + | |
| 37 | +before(async () => { | |
| 38 | + const port = await freePort(); | |
| 39 | + base = `http://127.0.0.1:${port}`; | |
| 40 | + const persist = join(work, "state"); | |
| 41 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 42 | + const at = new Date().toISOString(); | |
| 43 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 44 | + 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}')`; | |
| 45 | + 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}')`; | |
| 46 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 47 | + 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("; ")); | |
| 48 | + // No fixed dev identity (DEV_AUTH empty), so a request without a token is anonymous. | |
| 49 | + 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"], { | |
| 50 | + cwd: APP, | |
| 51 | + env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, | |
| 52 | + stdio: ["ignore", "pipe", "pipe"], | |
| 53 | + detached: true, | |
| 54 | + }); | |
| 55 | + dev.stdout.on("data", (d) => (log += d)); | |
| 56 | + dev.stderr.on("data", (d) => (log += d)); | |
| 57 | + const end = Date.now() + 120_000; | |
| 58 | + for (;;) { | |
| 59 | + try { | |
| 60 | + if ((await fetch(`${base}/healthz`)).ok) break; | |
| 61 | + } catch {} | |
| 62 | + if (Date.now() > end) assert.fail(`wrangler dev did not start\n${log.slice(-4000)}`); | |
| 63 | + await new Promise((r) => setTimeout(r, 500)); | |
| 64 | + } | |
| 65 | +}); | |
| 66 | + | |
| 67 | +after(() => { | |
| 68 | + try { | |
| 69 | + process.kill(-dev.pid, "SIGTERM"); | |
| 70 | + } catch {} | |
| 71 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 72 | +}); | |
| 73 | + | |
| 74 | +async function rest(method, path, body, tok = T.ann) { | |
| 75 | + const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }); | |
| 76 | + const text = await res.text(); | |
| 77 | + assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`); | |
| 78 | + return text ? JSON.parse(text) : null; | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** POST /api/graphql; returns { status, body }. */ | |
| 82 | +async function gql(query, variables = {}, tok = T.ann) { | |
| 83 | + const res = await fetch(`${base}/api/graphql`, { method: "POST", headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json" }, body: JSON.stringify({ query, variables }) }); | |
| 84 | + return { status: res.status, body: await res.json() }; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** The data of a query that must succeed without errors. */ | |
| 88 | +async function data(query, variables, tok) { | |
| 89 | + const r = await gql(query, variables, tok); | |
| 90 | + assert.equal(r.status, 200, JSON.stringify(r.body)); | |
| 91 | + assert.equal(r.body.errors, undefined, JSON.stringify(r.body.errors)); | |
| 92 | + return r.body.data; | |
| 93 | +} | |
| 94 | + | |
| 95 | +function git(cwd, args) { | |
| 96 | + const auth = Buffer.from(`x-token:${T.ann}`).toString("base64"); | |
| 97 | + 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"] }); | |
| 98 | +} | |
| 99 | + | |
| 100 | +// Claude Code's query for the pull requests of its sessions (one alias per repository and pull request). | |
| 101 | +const CLAUDE_BATCH = `fragment pr on PullRequest { | |
| 102 | + number title state isDraft additions deletions | |
| 103 | + reviewDecision | |
| 104 | + commits(last:1){nodes{commit{statusCheckRollup{ | |
| 105 | + state | |
| 106 | + contexts(first:0){ | |
| 107 | + checkRunCountsByState{state count} | |
| 108 | + statusContextCountsByState{state count} | |
| 109 | + } | |
| 110 | + }}}} | |
| 111 | +} | |
| 112 | +query { | |
| 113 | + rateLimit { cost remaining resetAt } | |
| 114 | + r0: repository(owner: "ann", name: "app") { p0_0: pullRequest(number: 1) { ...pr } } | |
| 115 | +}`; | |
| 116 | + | |
| 117 | +let headSha; | |
| 118 | +let repoId; | |
| 119 | +let prId; | |
| 120 | + | |
| 121 | +test("setup: a private repository with a pull request, two check runs and a commit status", async () => { | |
| 122 | + await rest("POST", "/user/repos", { name: "app", private: true }); | |
| 123 | + const src = join(work, "app"); | |
| 124 | + mkdirSync(src); | |
| 125 | + writeFileSync(join(src, "README.md"), "# app\n"); | |
| 126 | + git(src, ["init", "-q", "-b", "main"]); | |
| 127 | + git(src, ["add", "-A"]); | |
| 128 | + git(src, ["commit", "-q", "-m", "init"]); | |
| 129 | + git(src, ["push", "-q", `${base}/ann/app.git`, "main"]); | |
| 130 | + git(src, ["checkout", "-q", "-b", "feature/chip"]); | |
| 131 | + writeFileSync(join(src, "README.md"), "# app\n\nA second line.\nAnd a third.\n"); | |
| 132 | + git(src, ["commit", "-q", "-am", "more readme"]); | |
| 133 | + git(src, ["push", "-q", `${base}/ann/app.git`, "feature/chip"]); | |
| 134 | + headSha = git(src, ["rev-parse", "HEAD"]).trim(); | |
| 135 | + | |
| 136 | + // gh pr create: the repository's id, then createPullRequest. | |
| 137 | + 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" }); | |
| 138 | + assert.equal(d.repository.viewerPermission, "ADMIN"); | |
| 139 | + assert.equal(d.repository.defaultBranchRef.name, "main"); | |
| 140 | + repoId = d.repository.id; | |
| 141 | + const created = await data(`mutation PullRequestCreate($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { id url number isDraft } } }`, { | |
| 142 | + input: { repositoryId: repoId, baseRefName: "main", headRefName: "feature/chip", title: "Longer readme", body: "Two lines.", draft: true, maintainerCanModify: true }, | |
| 143 | + }); | |
| 144 | + assert.equal(created.createPullRequest.pullRequest.url, `${base}/ann/app/pull/1`); | |
| 145 | + assert.equal(created.createPullRequest.pullRequest.isDraft, true); | |
| 146 | + prId = created.createPullRequest.pullRequest.id; | |
| 147 | + | |
| 148 | + await rest("POST", "/repos/ann/app/check-runs", { name: "lint", head_sha: headSha, status: "completed", conclusion: "success" }); | |
| 149 | + await rest("POST", "/repos/ann/app/check-runs", { name: "tests", head_sha: headSha, status: "in_progress" }); | |
| 150 | + await rest("POST", `/repos/ann/app/statuses/${headSha}`, { state: "success", context: "deploy/preview" }); | |
| 151 | +}); | |
| 152 | + | |
| 153 | +test("Claude Code: the current branch's pull request and its review decision", async () => { | |
| 154 | + // Over REST, as Claude Code looks it up for the branch it's on. | |
| 155 | + const list = await rest("GET", "/repos/ann/app/pulls?head=ann:feature/chip&state=open&per_page=1"); | |
| 156 | + assert.equal(list.length, 1); | |
| 157 | + assert.equal(list[0].number, 1); | |
| 158 | + assert.equal(list[0].html_url, `${base}/ann/app/pull/1`); | |
| 159 | + 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 }); | |
| 160 | + assert.equal(d.repository.pullRequest.reviewDecision, null); | |
| 161 | +}); | |
| 162 | + | |
| 163 | +test("Claude Code: CI counts and pull request state for the badge", async () => { | |
| 164 | + const d = await data(CLAUDE_BATCH); | |
| 165 | + assert.ok(d.rateLimit.remaining > 0); | |
| 166 | + const p = d.r0.p0_0; | |
| 167 | + 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 }); | |
| 168 | + const rollup = p.commits.nodes[0].commit.statusCheckRollup; | |
| 169 | + assert.equal(rollup.state, "PENDING"); | |
| 170 | + const counts = (list) => Object.fromEntries(list.map((x) => [x.state, x.count])); | |
| 171 | + assert.deepEqual(counts(rollup.contexts.checkRunCountsByState), { IN_PROGRESS: 1, SUCCESS: 1 }); | |
| 172 | + assert.deepEqual(counts(rollup.contexts.statusContextCountsByState), { SUCCESS: 1 }); | |
| 173 | +}); | |
| 174 | + | |
| 175 | +test("gh pr checks and gh pr view: the rollup, node lookup and fields Diffinery doesn't have", async () => { | |
| 176 | + const d = await data( | |
| 177 | + `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 } } } } } } } } }`, | |
| 178 | + { id: prId }, | |
| 179 | + ); | |
| 180 | + const nodes = d.node.statusCheckRollup.nodes[0].commit.statusCheckRollup.contexts.nodes; | |
| 181 | + assert.equal(d.node.statusCheckRollup.nodes[0].commit.oid, headSha); | |
| 182 | + assert.deepEqual(nodes.map((n) => n.name ?? n.context).sort(), ["deploy/preview", "lint", "tests"]); | |
| 183 | + assert.equal(nodes.find((n) => n.name === "tests").status, "IN_PROGRESS"); | |
| 184 | + assert.equal(nodes.find((n) => n.name === "lint").conclusion, "SUCCESS"); | |
| 185 | + | |
| 186 | + // A field GitHub has and Diffinery doesn't is left out and named, so gh carries on. | |
| 187 | + const r = await gql(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { number mergeStateStatus mergeable notAThing } } }`); | |
| 188 | + assert.equal(r.body.errors, undefined, JSON.stringify(r.body)); | |
| 189 | + assert.equal(r.body.data.repository.pullRequest.number, 1); | |
| 190 | + assert.equal(r.body.data.repository.pullRequest.mergeStateStatus, "DRAFT"); | |
| 191 | + assert.equal(r.body.data.repository.pullRequest.mergeable, "MERGEABLE"); | |
| 192 | + assert.ok(r.body.extensions.unsupported.some((m) => m.includes("notAThing"))); | |
| 193 | + | |
| 194 | + // gh pr status: pull requests by the viewer, through search. | |
| 195 | + 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 } } } }`); | |
| 196 | + assert.equal(s.search.issueCount, 1); | |
| 197 | +}); | |
| 198 | + | |
| 199 | +test("gh pr ready, edit, comment, close, reopen and merge", async () => { | |
| 200 | + 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; | |
| 201 | + await data(`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: prId }); | |
| 202 | + assert.equal((await pr()).isDraft, false); | |
| 203 | + | |
| 204 | + await rest("POST", "/repos/ann/app/labels", { name: "docs", color: "0075ca" }); | |
| 205 | + const label = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 10) { nodes { id name } } } }`)).repository.labels.nodes.find((l) => l.name === "docs"); | |
| 206 | + await data(`mutation($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { pullRequest { title } } }`, { input: { pullRequestId: prId, title: "A longer readme", body: "Three lines." } }); | |
| 207 | + await data(`mutation($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { clientMutationId } }`, { input: { labelableId: prId, labelIds: [label.id] } }); | |
| 208 | + await data(`mutation($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: prId, body: "Looks right." } }); | |
| 209 | + let p = await pr(); | |
| 210 | + assert.equal(p.title, "A longer readme"); | |
| 211 | + assert.equal(p.body, "Three lines."); | |
| 212 | + assert.deepEqual(p.labels.nodes.map((l) => l.name), ["docs"]); | |
| 213 | + assert.deepEqual(p.comments.nodes.map((c) => c.body), ["Looks right."]); | |
| 214 | + | |
| 215 | + await data(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }); | |
| 216 | + assert.equal((await pr()).state, "CLOSED"); | |
| 217 | + await data(`mutation($id: ID!) { reopenPullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }); | |
| 218 | + assert.equal((await pr()).state, "OPEN"); | |
| 219 | + | |
| 220 | + // A read token can look but not change anything. | |
| 221 | + const refused = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }, T.annRead); | |
| 222 | + assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body)); | |
| 223 | + assert.equal((await pr()).state, "OPEN"); | |
| 224 | + | |
| 225 | + await data(`mutation($input: MergePullRequestInput!) { mergePullRequest(input: $input) { clientMutationId } }`, { input: { pullRequestId: prId, mergeMethod: "SQUASH" } }); | |
| 226 | + p = await pr(); | |
| 227 | + assert.equal(p.state, "MERGED"); | |
| 228 | + assert.equal(p.merged, true); | |
| 229 | +}); | |
| 230 | + | |
| 231 | +test("sign-in, privacy, limits and GitHub's error shape", async () => { | |
| 232 | + const anonymous = await gql(`query { viewer { login } }`, {}, null); | |
| 233 | + assert.equal(anonymous.status, 401); | |
| 234 | + assert.equal((await data(`query UserCurrent { viewer { login } }`)).viewer.login, "ann"); | |
| 235 | + | |
| 236 | + // Someone without access gets GitHub's NOT_FOUND, the same as for a repository that doesn't exist. | |
| 237 | + for (const [owner, name, tok] of [["ann", "app", T.bob], ["ann", "nope", T.ann]]) { | |
| 238 | + const r = await gql(`query($o: String!, $n: String!) { repository(owner: $o, name: $n) { id } }`, { o: owner, n: name }, tok); | |
| 239 | + assert.equal(r.body.data.repository, null); | |
| 240 | + assert.equal(r.body.errors[0].type, "NOT_FOUND"); | |
| 241 | + } | |
| 242 | + | |
| 243 | + let deep = "id"; | |
| 244 | + for (let i = 0; i < 20; i++) deep = `parent { ${deep} }`; | |
| 245 | + const tooDeep = await gql(`query { repository(owner: "ann", name: "app") { ${deep} } }`); | |
| 246 | + assert.match(tooDeep.body.errors[0].message, /nested/); | |
| 247 | + | |
| 248 | + const sub = await gql(`subscription { viewer { login } }`); | |
| 249 | + assert.match(sub.body.errors[0].message, /Subscriptions/); | |
| 250 | + | |
| 251 | + // gh auth login checks the REST root of a GitHub Enterprise host. | |
| 252 | + const root = await fetch(`${base}/api/v3/`, { headers: { authorization: `Bearer ${T.ann}` } }); | |
| 253 | + assert.equal(root.status, 200); | |
| 254 | + assert.equal((await root.json()).graphql, `${base}/api/graphql`); | |
| 255 | +}); |
modifiedapps/diffinery/test/unit/cli-login.test.ts+2−1View file
| @@ -104,7 +104,8 @@ | ||
| 104 | 104 | expect(ok.status).toBe(200); |
| 105 | 105 | expect(ok.headers.get("cache-control")).toBe("no-store"); |
| 106 | 106 | const body = (await ok.json()) as { access_token: string; scope: string; login: string; expires_at: string }; |
| 107 | - expect(body.access_token).toMatch(/^dfy_/); | |
| 107 | + // Letters and digits after the prefix, so `gh auth status` masks all of it. | |
| 108 | + expect(body.access_token).toMatch(/^dfy_[A-Za-z0-9]{40}$/); | |
| 108 | 109 | expect(body).toMatchObject({ scope: "read", login: "ana" }); |
| 109 | 110 | expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000); |
| 110 | 111 | const cli = t.tokens().find((k) => k.client === "cli")!; |
addedapps/diffinery/test/unit/graphql.test.ts+25−0View file
| @@ -1,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { parse } from "graphql"; | |
| 3 | +import { measure } from "../../src/http/graphql.js"; | |
| 4 | +import { randomAlnum } from "../../src/lib/util.js"; | |
| 5 | + | |
| 6 | +describe("GraphQL limits", () => { | |
| 7 | + it("counts fields and depth with fragments expanded where they're spread", () => { | |
| 8 | + const doc = parse(`fragment pr on PullRequest { number title commits(last: 1) { nodes { commit { oid } } } } | |
| 9 | + query { a: repository(owner: "o", name: "r") { p1: pullRequest(number: 1) { ...pr } p2: pullRequest(number: 2) { ...pr } } }`); | |
| 10 | + // repository + 2 × (pullRequest + number + title + commits + nodes + commit + oid) | |
| 11 | + expect(measure(doc)).toEqual({ fields: 15, depth: 6 }); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("doesn't loop on a fragment that spreads itself", () => { | |
| 15 | + const doc = parse(`fragment f on Repository { id parent { ...f } } query { repository(owner: "o", name: "r") { ...f } }`); | |
| 16 | + expect(measure(doc).fields).toBe(3); | |
| 17 | + }); | |
| 18 | +}); | |
| 19 | + | |
| 20 | +describe("randomAlnum", () => { | |
| 21 | + it("draws letters and digits only, at the asked length", () => { | |
| 22 | + for (const n of [1, 40, 200]) expect(randomAlnum(n)).toMatch(new RegExp(`^[A-Za-z0-9]{${n}}$`)); | |
| 23 | + expect(new Set(Array.from({ length: 20 }, () => randomAlnum(40))).size).toBe(20); | |
| 24 | + }); | |
| 25 | +}); |
modifiedpnpm-lock.yaml+9−0View file
| @@ -63,6 +63,9 @@ | ||
| 63 | 63 | '@simplewebauthn/server': |
| 64 | 64 | specifier: 14.0.3 |
| 65 | 65 | version: 14.0.3 |
| 66 | + graphql: | |
| 67 | + specifier: 16.14.2 | |
| 68 | + version: 16.14.2 | |
| 66 | 69 | hono: |
| 67 | 70 | specifier: ^4.13.0 |
| 68 | 71 | version: 4.13.13 |
| @@ -1831,6 +1834,10 @@ | ||
| 1831 | 1834 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} |
| 1832 | 1835 | os: [darwin] |
| 1833 | 1836 | |
| 1837 | + graphql@16.14.2: | |
| 1838 | + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} | |
| 1839 | + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} | |
| 1840 | + | |
| 1834 | 1841 | hast-util-to-html@9.0.5: |
| 1835 | 1842 | resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} |
| 1836 | 1843 | |
| @@ -3679,6 +3686,8 @@ | ||
| 3679 | 3686 | fsevents@2.3.3: |
| 3680 | 3687 | optional: true |
| 3681 | 3688 | |
| 3689 | + graphql@16.14.2: {} | |
| 3690 | + | |
| 3682 | 3691 | hast-util-to-html@9.0.5: |
| 3683 | 3692 | dependencies: |
| 3684 | 3693 | '@types/hast': 3.0.5 |