Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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
test-bot authored 1 parent 83f34e3commit eb6a15bef27f335653230e7d015f9fc25b233a1dBrowse files
22 files changed+2223−15
Files (22)
modifiedapps/cli/src/commands/auth.ts+41−1View file
@@ -1,6 +1,7 @@
11 import { Api } from "../api.js";
22 import { str, type Command, type Group } from "../cli.js";
33 import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
4+import { spawn } from "node:child_process";
45 import { gitCapture } from "../git.js";
56 import { CliError, readAll } from "../io.js";
67 import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js";
@@ -116,6 +117,45 @@
116117 },
117118 };
118119
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+
119159 /** Parses the git credential helper protocol's key=value lines. */
120160 export function parseCredentialInput(text: string): Record<string, string> {
121161 const out: Record<string, string> = {};
@@ -165,4 +205,4 @@
165205 };
166206
167207 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 @@
11 import { execFileSync } from "node:child_process";
2+import { chmodSync, readFileSync, writeFileSync } from "node:fs";
3+import path from "node:path";
24 import { describe, expect, it } from "vitest";
35 import { parseCredentialInput } from "../src/commands/auth.js";
4-import { isolatedEnv, run } from "./helpers.js";
6+import { isolatedEnv, run, tempDir } from "./helpers.js";
57
68 describe("git credential helper", () => {
79 const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" });
@@ -51,4 +53,24 @@
5153 await run(["auth", "setup-git"], { env });
5254 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"]);
5355 });
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+ });
5476 });
modifiedapps/diffinery/docs/actions/compatibility.md+2−2View file
@@ -250,7 +250,7 @@
250250 });
251251 ```
252252
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.
254254
255255 ## Other actions
256256
@@ -298,7 +298,7 @@
298298
299299 ### Actions that call the GitHub API
300300
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.
302302
303303 ### Actions that use the cache or artifact services
304304
modifiedapps/diffinery/docs/actions/expressions.md+2−2View file
@@ -240,7 +240,7 @@
240240 | `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) |
241241 | `github.server_url` | `https://diffinery.com` |
242242 | `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 |
244244 | `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) |
245245 | `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` |
246246 | `github.retention_days` | `90` |
@@ -370,7 +370,7 @@
370370 | `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` |
371371 | `GITHUB_SERVER_URL` | `https://diffinery.com` |
372372 | `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 |
374374 | `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 |
375375 | `GITHUB_WORKFLOW` | The workflow's name, or its path |
376376 | `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 @@
817817 | If a workflow... | Do this on Diffinery |
818818 | --- | --- |
819819 | 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. |
821821 | 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). |
822822 | 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. |
823823 | 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 @@
841841 const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" });
842842 ```
843843
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)).
845845
846846 ## Keep GitHub as upstream and mirror to Diffinery
847847
modifiedapps/diffinery/docs/nav.json+2−0View file
@@ -77,6 +77,7 @@
7777 "agents/projects",
7878 "agents/review",
7979 "agents/mcp",
80+ "agents/github-cli",
8081 "agents/sandboxes",
8182 "agents/terminal",
8283 "agents/organizations",
@@ -106,6 +107,7 @@
106107 "title": "Reference",
107108 "pages": [
108109 "reference/rest-api",
110+ "reference/graphql-api",
109111 "api",
110112 "reference/cli",
111113 "reference/mcp-tools",
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 @@
2020 ```
2121
2222 ```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" }
2424 ```
2525
2626 `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 @@
9090 | Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. |
9191 | Webhooks and tokens | Strings. |
9292
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.
9494
9595 ### Differences to plan for
9696
@@ -642,7 +642,7 @@
642642
643643 ## Not supported yet
644644
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.
646646 - Setting secrets with a sealed-box `encrypted_value`; send `value` instead.
647647 - Team reviewers and custom deployment protection rules on environments.
648648 - Forks, and team reviewers.
modifiedapps/diffinery/package.json+1−0View file
@@ -25,6 +25,7 @@
2525 "@jsquash/resize": "2.1.1",
2626 "@jsquash/webp": "1.5.0",
2727 "@simplewebauthn/server": "14.0.3",
28+ "graphql": "16.14.2",
2829 "hono": "^4.13.0",
2930 "shiki": "4.5.0",
3031 "yaml": "^2.9.1"
modifiedapps/diffinery/src/app.ts+2−0View file
@@ -22,6 +22,7 @@
2222 import { files } from "./http/files.js";
2323 import { webhookPayload } from "./lib/webhook-payload.js";
2424 import { mcp } from "./mcp/server.js";
25+import { graphql } from "./http/graphql.js";
2526 import { registry } from "./registry/http.js";
2627 import { packagesApi } from "./registry/api.js";
2728 import { RegistryStore } from "./registry/store.js";
@@ -107,6 +108,7 @@
107108 app.route("/", registry);
108109 app.route("/", packagesApi);
109110 app.route("/", actionsRuntime);
111+app.route("/", graphql);
110112 app.route("/", api);
111113 app.route("/", files);
112114 app.route("/", mcp);
modifiedapps/diffinery/src/http/api.ts+7−1View file
@@ -66,7 +66,8 @@
6666
6767 // ---------------------------------------------------------------- meta & users
6868
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);
7071 api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY, runner_image: c.env.RUNNER_IMAGE ?? null }));
7172 /** Site admin: the newest unexpected server errors (lib/server-errors.ts), and one by the request id its 5xx answer carried. */
7273 api.get("/diffinery/admin/errors", async (c) => {
@@ -880,6 +881,11 @@
880881 });
881882 api.get("/repos/:owner/:repo/pulls/:number", async (c) => {
882883 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+ }
883889 return c.json(await pullDetail(c, repo, stub, num(c), caller));
884890 });
885891 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 @@
11 import type { Env } from "../env.js";
22 import type { RepoMeta, Role } from "../repo/types.js";
33 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";
55 import { mailForAudit, tokenCreated } from "../mail/notify.js";
66 import { enqueueMail } from "../mail/send.js";
77 import { archivePrefix, deletePrefixes } from "./storage-keys.js";
@@ -471,7 +471,9 @@
471471 /** `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). */
472472 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 }> {
473473 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)}`;
475477 const id = newId();
476478 const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
477479 await this.db
modifiedapps/diffinery/src/lib/util.ts+14−0View file
@@ -9,6 +9,20 @@
99 return b64url(b);
1010 }
1111
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+
1226 export function b64url(b: Uint8Array): string {
1327 let s = "";
1428 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 @@
11021102 pullCommits(...a: Parameters<Collab["pullCommits"]>) {
11031103 return this.collab.pullCommits(...a);
11041104 }
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+ }
11051111 submitReview(...a: Parameters<Collab["submitReview"]>) {
11061112 return this.collab.submitReview(...a);
11071113 }
modifiedapps/diffinery/src/repo/collab.ts+43−0View file
@@ -658,6 +658,49 @@
658658 return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) };
659659 }
660660
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+
661704 /** The first workflow file the pull request adds, changes or deletes, compared with where it branched off its base. */
662705 async changedWorkflow(number: number): Promise<string | null> {
663706 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 @@
104104 expect(ok.status).toBe(200);
105105 expect(ok.headers.get("cache-control")).toBe("no-store");
106106 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}$/);
108109 expect(body).toMatchObject({ scope: "read", login: "ana" });
109110 expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000);
110111 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 @@
6363 '@simplewebauthn/server':
6464 specifier: 14.0.3
6565 version: 14.0.3
66+ graphql:
67+ specifier: 16.14.2
68+ version: 16.14.2
6669 hono:
6770 specifier: ^4.13.0
6871 version: 4.13.13
@@ -1831,6 +1834,10 @@
18311834 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
18321835 os: [darwin]
18331836
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+
18341841 hast-util-to-html@9.0.5:
18351842 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
18361843
@@ -3679,6 +3686,8 @@
36793686 fsevents@2.3.3:
36803687 optional: true
36813688
3689+ graphql@16.14.2: {}
3690+
36823691 hast-util-to-html@9.0.5:
36833692 dependencies:
36843693 '@types/hast': 3.0.5