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.

Merge 0cf5d864f8c971dc05633a4d910d38308cdb02cf into e5c2e86574539f1cee8ddfeff33787ab48ce33f5

Diffinery authored 2 parents e5c2e860cf5d86commit adb65c4662cf91e9f866f515d66170f4b2f88ba8Browse files
10 files changed+669−89
Files (10)
modifiedapps/diffinery/docs/actions/compatibility.md+3−3View file
@@ -250,7 +250,7 @@
250250 });
251251 ```
252252
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.
253+`github.graphql` works for what Diffinery's [GraphQL API](/docs/reference/graphql-api) answers (repositories, issues, pull requests and CI), with the job token and its `permissions:`. 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 it doesn't accept a job's token 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)). The same goes for the [GraphQL API](/docs/reference/graphql-api), for the issues, pull requests and CI it answers, so `gh` works in a job with `GH_TOKEN: ${{ github.token }}`.
302302
303303 ### Actions that use the cache or artifact services
304304
@@ -387,7 +387,7 @@
387387 6. Check what each job does with `GITHUB_TOKEN`. In a new repository the default token only reads, so give jobs that push, tag, comment, label or merge the `permissions:` they need, or have an admin set the repository default to write (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions)). Jobs that change workflow files, or that open or approve pull requests without the repository allowing it, need a personal or agent token from a secret.
388388 7. Give `actions/checkout` of another private repository a token from a secret.
389389 8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](/docs/actions/environments)), and set required reviewers by Diffinery login (GitHub's numeric ids and teams don't carry over).
390-9. Replace `github.graphql` calls in `actions/github-script` with REST calls.
390+9. Check `github.graphql` calls in `actions/github-script`: queries about issues, pull requests and CI work; replace others with REST calls.
391391 10. Replace `ghcr.io` with your instance's host (`diffinery.com` here) in image names and logins; see [Container registry](/docs/packages/container-registry#migrating-from-ghcrio).
392392 11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`.
393393 12. Update OIDC trust policies in your cloud accounts to the Diffinery issuer and subject format.
modifiedapps/diffinery/docs/agents/github-cli.md+20−4View file
@@ -3,7 +3,7 @@
33 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.
44 ---
55
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).
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, and so do `gh issue list`, `gh issue create` and the other issue commands. 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).
77
88 ## Set it up
99
@@ -41,16 +41,32 @@
4141 gh pr checks --watch
4242 gh pr ready
4343 gh pr merge --auto --squash
44+gh issue create --title "The parser drops comments" --label bug
45+gh issue list --label bug
46+gh issue close 42 --reason "not planned"
4447 ```
4548
4649 | Works | Commands |
4750 | --- | --- |
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+| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh issue list` (with `--label`, `--assignee`, `--author`, `--mention`, `--search`, `--state`), `gh issue view` (with `--comments` and `--json`), `gh issue status`, `gh repo view`, `gh label list`, `gh release list`, `gh api` (REST and `gh api graphql`) |
52+| 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`), `gh issue create` (with `--label`, `--assignee`, `--milestone`), `gh issue edit`, `gh issue comment`, `gh issue close` (with `--reason`), `gh issue reopen`, `gh label create` |
53+| Not yet | Team reviewers, projects, `gh issue delete`, `lock`, `pin` and `transfer`. Use `gh api` with the REST paths for anything else. |
5154
5255 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.
5356
57+## gh in workflows
58+
59+Inside a Diffinery workflow, gh works with the job's own token, as with `GITHUB_TOKEN` on GitHub. It can do what the job's [`permissions:`](/docs/actions/workflow-permissions) allow: `gh issue list` needs `issues: read`, `gh pr comment` needs `pull-requests: write`, and so on.
60+
61+```yaml
62+permissions:
63+ issues: write
64+steps:
65+ - run: gh issue create --title "Nightly build failed" --body "See ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
66+ env:
67+ GH_TOKEN: ${{ github.token }}
68+```
69+
5470 ## Claude Code's pull request badge
5571
5672 Claude Code shows the pull request for the branch a session is on, and follows it as it changes:
modifiedapps/diffinery/docs/migrate-from-github.md+2−2View file
@@ -820,7 +820,7 @@
820820 | --- | --- |
821821 | Has jobs that mostly wait (deploys, API calls, polling), or runs on every push | They run as they are. To spend less hosted runner time, pin a smaller size for waiting jobs (`runs-on: [ubuntu-latest, diffinery-small]`) and cancel superseded pull request runs with a `concurrency` group. See [What hosted jobs cost](/docs/actions/runners#what-hosted-jobs-cost-and-keeping-it-low). |
822822 | 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). |
823-| 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. |
823+| Uses `actions/github-script` | Works with the REST API (`github.rest.*`) and the job's token. `github.graphql` works for the issues, pull requests and CI that Diffinery's [GraphQL API](/docs/reference/graphql-api) answers. |
824824 | 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). |
825825 | 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. |
826826 | 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. |
@@ -844,7 +844,7 @@
844844 const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" });
845845 ```
846846
847-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)).
847+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, issues, pull requests and CI, which is what the GitHub CLI needs (see [GitHub CLI and Claude Code](/docs/agents/github-cli)).
848848
849849 ## Keep GitHub as upstream and mirror to Diffinery
850850
modifiedapps/diffinery/docs/reference/graphql-api.md+17−12View file
@@ -1,11 +1,11 @@
11 ---
22 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.
3+description: The GitHub-shaped GraphQL endpoint at /api/graphql that the GitHub CLI and Claude Code use for issues, pull requests and CI: what it answers, limits and errors.
44 ---
55
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.
6+Diffinery answers GraphQL at `https://diffinery.com/api/graphql` with the part of GitHub's GraphQL schema that covers repositories, issues, 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 issue list`, `gh issue create` 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.
77
8-Everything else (issues, releases, Actions, settings and so on) is in the [REST API](/docs/reference/rest-api).
8+Everything else (releases, Actions, settings and so on) is in the [REST API](/docs/reference/rest-api).
99
1010 ## Requests
1111
@@ -22,9 +22,9 @@
2222 | --- | --- |
2323 | URL | `https://diffinery.com/api/graphql` |
2424 | 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. |
25+| Authentication | Required. A personal access token or agent token (`Authorization: Bearer dfy_...`, or `token dfy_...`), a workflow job's token (`GITHUB_TOKEN`), or a browser session. Without one the answer is `401`. |
2626 | 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. |
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. A job's token reads and changes what its [`permissions:`](/docs/actions/workflow-permissions) allow: issues need `issues`, pull requests `pull-requests`, merging `contents`, and anyone may read a public repository's. |
2828 | Introspection | `__schema` and `__type` work, so you can read the whole schema with any GraphQL client. |
2929 | Subscriptions | Not supported. |
3030
@@ -35,12 +35,14 @@
3535 | Field | What you get |
3636 | --- | --- |
3737 | `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. |
38+| `repository(owner, name)` | A repository: its id, name, owner, visibility, default branch, merge methods, your permission, `issue(number)`, `issues(states, labels, orderBy, filterBy)`, `issueOrPullRequest(number)`, `pullRequest(number)`, `pullRequests(states, headRefName, baseRefName, labels, orderBy)`, `labels`, `milestones`, `assignableUsers` and `mentionableUsers`. A renamed repository is found by its old name. |
3939 | `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. |
40+| `node(id)`, `nodes(ids)` | A repository, issue or pull request by its global id (the `node_id` of the REST API). |
41+| `search(query, type: ISSUE)` | Issues and pull requests in one repository (`ISSUE_ADVANCED` works too). The query needs a `repo:owner/name` qualifier and understands `is:issue`, `is:pr`, `type:`, `is:open`, `is:closed`, `is:merged`, `state:`, `author:`, `assignee:`, `mentions:`, `label:`, `milestone:`, `no:assignee`, `no:label`, `no:milestone`, `review-requested:`, `head:`, `base:`, `draft:`, `sort:` and words to find in the title or body, with `@me` for yourself. This is what `gh pr status` and `gh issue list --search` send. |
4242 | `rateLimit` | A budget in GitHub's shape. Diffinery limits requests per account (see [Limits](#limits)), not by query cost. |
4343
44+`issues` takes GitHub's `filterBy`: `assignee` (a login, `*` for anyone, `null` for no one), `createdBy`, `mentioned`, `labels`, `milestoneNumber` (a number, `*` or `null`), `since` and `states`. `labels` on `issues` matches issues with any of the labels, as on GitHub. An `Issue` has its number, title, body, state and `stateReason`, author, labels, assignees, milestone and comments.
45+
4446 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`.
4547
4648 **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.
@@ -51,14 +53,17 @@
5153 | --- | --- | --- |
5254 | `createPullRequest` | Opens a pull request, as a draft with `draft: true`. | `gh pr create` |
5355 | `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+| `addLabelsToLabelable`, `removeLabelsFromLabelable` | Adds or removes labels on a pull request or issue. | `gh pr edit` and `gh issue edit` with `--add-label`, `--remove-label` |
57+| `addAssigneesToAssignable`, `removeAssigneesFromAssignable` | Adds or removes assignees on a pull request or issue. | `gh pr edit` and `gh issue edit` with `--add-assignee`, `--remove-assignee` |
5658 | `requestReviews` | Asks people for a review. Team reviewers aren't supported. | `gh pr create --reviewer`, `gh pr edit --add-reviewer` |
5759 | `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` |
60+| `addComment` | Comments on a pull request or issue. | `gh pr comment`, `gh issue comment` |
5961 | `closePullRequest`, `reopenPullRequest` | Closes or reopens a pull request. | `gh pr close`, `gh pr reopen` |
6062 | `mergePullRequest` | Merges with `MERGE`, `SQUASH` or `REBASE`, if nothing blocks it. | `gh pr merge` |
6163 | `enablePullRequestAutoMerge`, `disablePullRequestAutoMerge` | Turns [auto-merge](/docs/collaboration/pull-requests) on or off. | `gh pr merge --auto`, `--disable-auto` |
64+| `createIssue` | Opens an issue, with labels, assignees and a milestone if you have triage access. | `gh issue create` |
65+| `updateIssue` | Changes an issue's title, body, state, labels, assignees or milestone. | `gh issue edit` |
66+| `closeIssue`, `reopenIssue` | Closes an issue, as `COMPLETED`, `NOT_PLANNED` or `DUPLICATE`, or reopens it. | `gh issue close --reason`, `gh issue reopen` |
6267
6368 ## Fields Diffinery doesn't have
6469
@@ -94,4 +99,4 @@
9499 | Page size (`first`, `last`) | 100 |
95100 | Repositories in one query | 25 |
96101
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).
102+Each request counts against your account's request limit like a REST call, and `createPullRequest`, `createIssue` and `addComment` also count against the limit on creating things (60 a minute), as `POST /pulls`, `POST /issues` and comments do over REST. See [Rate limits](/docs/reference/rest-api#rate-limits).
modifiedapps/diffinery/src/http/api.ts+8−1View file
@@ -76,7 +76,14 @@
7676 api.get("/", apiRootJson);
7777 // Imported when used: web/indexnow.ts reaches the docs, whose API reference imports this module.
7878 const indexNow = () => import("../web/indexnow.js");
79-api.get("/meta", async (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY, runner_image: c.env.RUNNER_IMAGE ?? null, indexnow: await (await indexNow()).indexNowMeta(c.env) }));
79+/**
80+ * The GitHub Enterprise Server release whose API Diffinery's matches, as GHES reports it here. The
81+ * GitHub CLI reads it to pick features: below 3.18 it can't search issues (`gh issue list --label`
82+ * or `--search`) and without it `gh workflow run` stops; from 3.21 it would ask dispatches for the
83+ * new run's details, which Diffinery doesn't answer yet.
84+ */
85+export const GHES_VERSION = "3.20.0";
86+api.get("/meta", async (c) => c.json({ name: "Diffinery", installed_version: GHES_VERSION, oidc: !!c.env.OIDC_PRIVATE_KEY, runner_image: c.env.RUNNER_IMAGE ?? null, indexnow: await (await indexNow()).indexNowMeta(c.env) }));
8087 /** Site admin: the newest unexpected server errors (lib/server-errors.ts), and one by the request id its 5xx answer carried. */
8188 api.get("/diffinery/admin/errors", async (c) => {
8289 if (!requireAccount(c).site_admin) throw new HttpError(403, "site administrators only");
modifiedapps/diffinery/src/http/common.ts+4−3View file
@@ -1,7 +1,7 @@
11 import type { Env } from "../env.js";
22 import { mcpChallenge } from "../auth/oauth.js";
33 import type { Repo } from "../lib/directory.js";
4-import { denied, readOnly, requestPermission, type JobPermissions } from "../lib/job-permissions.js";
4+import { denied, readOnly, requestPermission, type JobPermissions, type Requirement } from "../lib/job-permissions.js";
55 import { HttpError } from "../lib/util.js";
66 import { errorRecord, recordServerError, requestId } from "../lib/server-errors.js";
77 import { movedPath, movedStatus } from "../lib/redirects.js";
@@ -58,7 +58,7 @@
5858 /** Paths whose reads show issues, pull requests or their comments (the web pages and the REST API). */
5959 const CONVERSATION_PATH = /\/(?:issues|pulls?|comments)(?:\/|$)/;
6060
61-export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read", opts: { readOnlyPost?: boolean; whileArchived?: boolean; whileImporting?: boolean; follow?: boolean } = {}): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub>; moved: boolean }> {
61+export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read", opts: { readOnlyPost?: boolean; whileArchived?: boolean; whileImporting?: boolean; follow?: boolean; permission?: Requirement } = {}): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub>; moved: boolean }> {
6262 const dir = c.get("dir");
6363 const anonymous = c.get("principal").type === "anonymous";
6464 const reading = c.req.method === "GET" || c.req.method === "HEAD" || (opts.readOnlyPost && c.req.method === "POST") || c.get("readOnly") === true;
@@ -90,7 +90,8 @@
9090 if (caller.permissions) {
9191 // A workflow's GITHUB_TOKEN: each kind of endpoint needs its permission (issues, contents, ...).
9292 // What anyone may read in a public repository stays readable with `permissions: { contents: none }`.
93- const req = requestPermission(c.req.method, c.req.path, min);
93+ // `permission`: what an endpoint that isn't one REST path per resource (GraphQL) needs for this lookup.
94+ const req = opts.permission ?? requestPermission(c.req.method, c.req.path, min);
9495 const reason = denied(caller.permissions as JobPermissions, req, isAppCaller(caller) ? "app" : "job");
9596 if (reason && !(repo.visibility === "public" && readOnly(req))) throw new HttpError(403, reason);
9697 }
modifiedapps/diffinery/src/http/graphql-schema.ts+107−10View file
@@ -3,8 +3,9 @@
33 * /api/graphql. Names, types and enum values are GitHub's, so the GitHub CLI and tools built on it
44 * (Claude Code's pull request badge, `gh pr view`, `gh pr checks`, `gh pr list`, `gh pr status`,
55 * `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.
6+ * GitHub Enterprise Server host, and `gh issue list`, `view`, `create`, `comment`, `close`, `reopen`,
7+ * `edit` and `status` work on its issues. The mutations are the pull request and issue ones gh uses;
8+ * everything else changes through the REST API.
89 */
910 export const SCHEMA_SDL = /* GraphQL */ `
1011 """Ids from the REST API, which can be wider than GraphQL's 32-bit Int (GitHub types these as Int)."""
@@ -22,7 +23,9 @@
2223 addComment(input: AddCommentInput!): AddCommentPayload
2324 addLabelsToLabelable(input: AddLabelsToLabelableInput!): AddLabelsToLabelablePayload
2425 closePullRequest(input: ClosePullRequestInput!): ClosePullRequestPayload
26+ closeIssue(input: CloseIssueInput!): CloseIssuePayload
2527 convertPullRequestToDraft(input: ConvertPullRequestToDraftInput!): ConvertPullRequestToDraftPayload
28+ createIssue(input: CreateIssueInput!): CreateIssuePayload
2629 createPullRequest(input: CreatePullRequestInput!): CreatePullRequestPayload
2730 disablePullRequestAutoMerge(input: DisablePullRequestAutoMergeInput!): DisablePullRequestAutoMergePayload
2831 enablePullRequestAutoMerge(input: EnablePullRequestAutoMergeInput!): EnablePullRequestAutoMergePayload
@@ -30,11 +33,44 @@
3033 mergePullRequest(input: MergePullRequestInput!): MergePullRequestPayload
3134 removeAssigneesFromAssignable(input: RemoveAssigneesFromAssignableInput!): RemoveAssigneesFromAssignablePayload
3235 removeLabelsFromLabelable(input: RemoveLabelsFromLabelableInput!): RemoveLabelsFromLabelablePayload
36+ reopenIssue(input: ReopenIssueInput!): ReopenIssuePayload
3337 reopenPullRequest(input: ReopenPullRequestInput!): ReopenPullRequestPayload
3438 requestReviews(input: RequestReviewsInput!): RequestReviewsPayload
39+ updateIssue(input: UpdateIssueInput!): UpdateIssuePayload
3540 updatePullRequest(input: UpdatePullRequestInput!): UpdatePullRequestPayload
3641 }
3742
43+input CreateIssueInput {
44+ assigneeIds: [ID!]
45+ body: String
46+ clientMutationId: String
47+ issueTemplate: String
48+ labelIds: [ID!]
49+ milestoneId: ID
50+ projectIds: [ID!]
51+ repositoryId: ID!
52+ title: String!
53+}
54+type CreateIssuePayload { clientMutationId: String, issue: Issue }
55+
56+input UpdateIssueInput {
57+ assigneeIds: [ID!]
58+ body: String
59+ clientMutationId: String
60+ id: ID!
61+ labelIds: [ID!]
62+ milestoneId: ID
63+ projectIds: [ID!]
64+ state: IssueState
65+ title: String
66+}
67+type UpdateIssuePayload { actor: Actor, clientMutationId: String, issue: Issue }
68+
69+input CloseIssueInput { clientMutationId: String, duplicateIssueId: ID, issueId: ID!, stateReason: IssueClosedStateReason }
70+type CloseIssuePayload { clientMutationId: String, issue: Issue }
71+input ReopenIssueInput { clientMutationId: String, issueId: ID! }
72+type ReopenIssuePayload { clientMutationId: String, issue: Issue }
73+
3874 input CreatePullRequestInput {
3975 baseRefName: String!
4076 body: String
@@ -102,13 +138,13 @@
102138 type AddCommentPayload { clientMutationId: String, commentEdge: IssueCommentEdge, subject: Node }
103139
104140 input AddLabelsToLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! }
105-type AddLabelsToLabelablePayload { clientMutationId: String, labelable: PullRequest }
141+type AddLabelsToLabelablePayload { clientMutationId: String, labelable: Labelable }
106142 input RemoveLabelsFromLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! }
107-type RemoveLabelsFromLabelablePayload { clientMutationId: String, labelable: PullRequest }
143+type RemoveLabelsFromLabelablePayload { clientMutationId: String, labelable: Labelable }
108144 input AddAssigneesToAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! }
109-type AddAssigneesToAssignablePayload { clientMutationId: String, assignable: PullRequest }
145+type AddAssigneesToAssignablePayload { clientMutationId: String, assignable: Assignable }
110146 input RemoveAssigneesFromAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! }
111-type RemoveAssigneesFromAssignablePayload { clientMutationId: String, assignable: PullRequest }
147+type RemoveAssigneesFromAssignablePayload { clientMutationId: String, assignable: Assignable }
112148
113149 input RequestReviewsInput {
114150 clientMutationId: String
@@ -128,15 +164,15 @@
128164 user(login: String!): User
129165 "Request budget, reported as GitHub does. Diffinery limits requests per account rather than by query cost."
130166 rateLimit(dryRun: Boolean = false): RateLimit
131- "A repository or pull request by its global id."
167+ "A repository, issue or pull request by its global id."
132168 node(id: ID!): Node
133169 nodes(ids: [ID!]!): [Node]!
134- "Pull requests in one repository (the query needs a repo: qualifier), as \`gh pr status\` asks for them."
170+ "Issues or pull requests in one repository (the query needs a repo: qualifier), as \`gh pr status\` and \`gh issue list --search\` ask for them."
135171 search(query: String!, type: SearchType!, first: Int, last: Int, after: String, before: String): SearchResultItemConnection!
136172 }
137173
138174 enum SearchType { ISSUE ISSUE_ADVANCED REPOSITORY USER DISCUSSION }
139-union SearchResultItem = PullRequest
175+union SearchResultItem = Issue | PullRequest
140176 type SearchResultItemEdge { cursor: String!, node: SearchResultItem }
141177 type SearchResultItemConnection {
142178 edges: [SearchResultItemEdge]
@@ -173,6 +209,14 @@
173209
174210 interface Node { id: ID! }
175211
212+interface Labelable {
213+ labels(first: Int, last: Int, after: String, before: String): LabelConnection
214+}
215+
216+interface Assignable {
217+ assignees(first: Int, last: Int, after: String, before: String): UserConnection!
218+}
219+
176220 interface Actor {
177221 avatarUrl(size: Int): URI!
178222 login: String!
@@ -232,6 +276,19 @@
232276 enum RepositoryVisibility { PRIVATE PUBLIC INTERNAL }
233277 enum PullRequestState { OPEN CLOSED MERGED }
234278 enum IssueState { OPEN CLOSED }
279+enum IssueStateReason { COMPLETED DUPLICATE NOT_PLANNED REOPENED }
280+enum IssueClosedStateReason { COMPLETED DUPLICATE NOT_PLANNED }
281+input IssueFilters {
282+ assignee: String
283+ createdBy: String
284+ labels: [String!]
285+ mentioned: String
286+ milestone: String
287+ milestoneNumber: String
288+ since: DateTime
289+ states: [IssueState!]
290+ viewerSubscribed: Boolean = false
291+}
235292 enum OrderDirection { ASC DESC }
236293 enum IssueOrderField { CREATED_AT UPDATED_AT COMMENTS }
237294 input IssueOrder { field: IssueOrderField!, direction: OrderDirection! }
@@ -266,6 +323,9 @@
266323 id: ID!
267324 isArchived: Boolean!
268325 isEmpty: Boolean!
326+ issue(number: Int!): Issue
327+ issueOrPullRequest(number: Int!): IssueOrPullRequest
328+ issues(first: Int, last: Int, after: String, before: String, states: [IssueState!], labels: [String!], orderBy: IssueOrder, filterBy: IssueFilters): IssueConnection!
269329 isFork: Boolean!
270330 isInOrganization: Boolean!
271331 isLocked: Boolean!
@@ -322,7 +382,44 @@
322382 }
323383 type PullRequestEdge { cursor: String!, node: PullRequest }
324384
325-type PullRequest implements Node {
385+union IssueOrPullRequest = Issue | PullRequest
386+
387+type IssueConnection {
388+ edges: [IssueEdge]
389+ nodes: [Issue]
390+ pageInfo: PageInfo!
391+ totalCount: Int!
392+}
393+type IssueEdge { cursor: String!, node: Issue }
394+
395+type Issue implements Node & Labelable & Assignable {
396+ assignees(first: Int, last: Int, after: String, before: String): UserConnection!
397+ author: Actor
398+ authorAssociation: String!
399+ body: String!
400+ closed: Boolean!
401+ closedAt: DateTime
402+ comments(first: Int, last: Int, after: String, before: String): IssueCommentConnection!
403+ createdAt: DateTime!
404+ databaseId: BigInt
405+ id: ID!
406+ labels(first: Int, last: Int, after: String, before: String): LabelConnection
407+ locked: Boolean!
408+ milestone: Milestone
409+ number: Int!
410+ projectItems(first: Int, last: Int, after: String, before: String): ProjectV2ItemConnection!
411+ reactionGroups: [ReactionGroup!]
412+ repository: Repository!
413+ resourcePath: URI!
414+ state: IssueState!
415+ stateReason: IssueStateReason
416+ title: String!
417+ updatedAt: DateTime!
418+ url: URI!
419+ viewerCanUpdate: Boolean!
420+}
421+
422+type PullRequest implements Node & Labelable & Assignable {
326423 additions: Int!
327424 assignees(first: Int, last: Int, after: String, before: String): UserConnection!
328425 author: Actor
modifiedapps/diffinery/src/http/graphql.ts+349−53View file
@@ -8,6 +8,10 @@
88 * statusCheckRollup`), so with this endpoint a Diffinery repository gets the same pull request and
99 * CI chip a GitHub one does. See /docs/api/graphql.
1010 *
11+ * The same endpoint answers the GitHub CLI's issue commands (`gh issue list`, `view`, `create`,
12+ * `comment`, `close`, `reopen`, `edit`, `status`), and a workflow's GITHUB_TOKEN may use it with the
13+ * same per-scope `permissions:` it has over REST.
14+ *
1115 * Execution is lenient where GitHub is strict: a field Diffinery doesn't have is left out of the
1216 * answer (and named in `extensions.unsupported`) instead of failing the whole query, because gh
1317 * asks for many fields at once and most commands need only a few of them.
@@ -34,8 +38,10 @@
3438 import type { AppContext, AppVars } from "../auth/principal.js";
3539 import type { Account, Repo } from "../lib/directory.js";
3640 import { HttpError } from "../lib/util.js";
41+import { denied, type JobPermissions, type PermissionScope, type Requirement } from "../lib/job-permissions.js";
42+import { isAppCaller } from "../repo/token-policy.js";
3743 import { overLimit, RATE_LIMITS } from "../lib/rate-limit.js";
38-import type { Caller, Role } from "../repo/types.js";
44+import { atLeast, type Caller, type Role } from "../repo/types.js";
3945 import type { Check, Issue, Review } from "../repo/collab.js";
4046 import type { CommitStatus } from "../repo/statuses.js";
4147 import { hiddenAuthors, loadRepo, repoStub } from "./common.js";
@@ -134,6 +140,50 @@
134140 };
135141 }
136142
143+type Page<T> = { items: T[]; total: number };
144+
145+/**
146+ * first/last/after/before over a list the repository pages itself (`fetch(limit, offset)`), so a
147+ * connection over thousands of issues reads only the page asked for. The total is read first only
148+ * when `last` or `before` needs it.
149+ */
150+async function pagedConnection<T>(args: Args, fetch: (limit: number, offset: number) => Promise<Page<T>>, map: (x: T) => Obj): Promise<Obj> {
151+ pageSize(args);
152+ const after = offsetOf(args.after);
153+ const before = offsetOf(args.before);
154+ const first = typeof args.first === "number" ? args.first : null;
155+ const last = typeof args.last === "number" ? args.last : null;
156+ let start = after >= 0 ? after + 1 : 0;
157+ let total: number | null = null;
158+ if (last !== null || before >= 0) total = (await fetch(0, 0)).total;
159+ let end = total ?? Number.MAX_SAFE_INTEGER;
160+ if (before >= 0) end = Math.max(Math.min(before, end), start);
161+ if (first !== null) end = Math.min(end, start + first);
162+ if (last !== null) start = Math.max(start, end - last);
163+ if (first === null && last === null) end = Math.min(end, start + LIMITS.pageSize);
164+ const page = end > start ? await fetch(end - start, start) : { items: [] as T[], total: total ?? (await fetch(0, 0)).total };
165+ total = page.total;
166+ const slice = page.items.slice(0, Math.max(0, end - start));
167+ const nodes = slice.map(map);
168+ return {
169+ totalCount: total,
170+ nodes,
171+ edges: nodes.map((node, i) => ({ node, cursor: cursorOf(start + i) })),
172+ pageInfo: { hasNextPage: start + slice.length < total, hasPreviousPage: start > 0, startCursor: slice.length ? cursorOf(start) : null, endCursor: slice.length ? cursorOf(start + slice.length - 1) : null },
173+ };
174+}
175+
176+/** Every match, a page at a time (the repository answers at most 100 per call), up to GitHub's 1,000 search results. */
177+async function everyIssue(stub: Stub, opts: Parameters<Stub["issues"]>[0]): Promise<Issue[]> {
178+ const out: Issue[] = [];
179+ for (let offset = 0; offset < 1000; offset += 100) {
180+ const res = await stub.issues({ ...opts, limit: 100, offset });
181+ out.push(...res.items);
182+ if (res.items.length < 100 || out.length >= res.total) break;
183+ }
184+ return out;
185+}
186+
137187 // ---------------------------------------------------------------- people
138188
139189 type Person = { id: string; login: string; kind?: string | null; name?: string | null };
@@ -171,11 +221,26 @@
171221
172222 type Loaded = { repo: Repo; caller: Caller; stub: Stub };
173223
224+/**
225+ * A workflow job's or an app's token reads through GraphQL only what its permissions let it read
226+ * over REST: the repository itself always, pull requests with pull-requests, issues with issues.
227+ * Anyone may read a public repository's. (Changes are checked as each repository is loaded for
228+ * one: see loadForWrite.)
229+ */
230+function need(l: Loaded, scopes: PermissionScope[]): void {
231+ if (!l.caller.permissions || l.repo.visibility === "public") return;
232+ const reason = denied(l.caller.permissions as JobPermissions, { scopes, access: "read" }, isAppCaller(l.caller) ? "app" : "job");
233+ if (reason) throw new GraphQLError(reason, { extensions: { type: "FORBIDDEN" } });
234+}
235+
236+const writes = (...scopes: PermissionScope[]): Requirement => ({ scopes, access: "write" });
237+
174238 async function loadRepository(ctx: Ctx, owner: string, name: string): Promise<Loaded | null> {
175239 return once(ctx, `repo:${owner.toLowerCase()}/${name.toLowerCase()}`, async () => {
176240 if (++ctx.repos > LIMITS.repositories) throw new GraphQLError(`A query can look up at most ${LIMITS.repositories} repositories.`, { extensions: { type: "MAX_NODE_LIMIT_EXCEEDED" } });
177241 try {
178- const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, "read", { follow: true });
242+ // The repository's metadata: what any job token may read about its own repository.
243+ const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, "read", { follow: true, permission: { metadata: true } });
179244 return { repo, caller, stub };
180245 } catch (e) {
181246 const s = statusOf(e);
@@ -266,30 +331,60 @@
266331 assignableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args),
267332 mentionableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args),
268333 labels: async (args: Args) => {
334+ need(l, ["issues", "pull-requests"]);
269335 const q = typeof args.query === "string" ? args.query.toLowerCase() : "";
270336 const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows());
271337 return connection(rows.filter((r) => !q || r.name.toLowerCase().includes(q)).map((r) => labelObj(l, r.name, r)), args);
272338 },
273339 label: async (args: Args) => {
340+ need(l, ["issues", "pull-requests"]);
274341 const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows());
275342 const r = rows.find((x) => x.name.toLowerCase() === String(args.name).toLowerCase());
276343 return r ? labelObj(l, r.name, r) : null;
277344 },
278345 milestones: async (args: Args) => {
346+ need(l, ["issues", "pull-requests"]);
279347 const states = Array.isArray(args.states) ? (args.states as string[]) : [];
280348 const list = await stub.milestones(states.length === 1 ? (states[0] === "CLOSED" ? "closed" : "open") : "all");
281349 return connection(list.map((m) => milestoneObj(l, m)), args);
282350 },
283351 pullRequest: async (args: Args) => {
352+ need(l, ["pull-requests"]);
284353 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- });
354+ const issue = await issueOrNull(ctx, l, n);
289355 if (!issue || issue.kind !== "pull") throw notFound(`Could not resolve to a PullRequest with the number of ${n}.`);
290356 return pullRequest(ctx, l, issue, self);
291357 },
358+ issue: async (args: Args) => {
359+ need(l, ["issues"]);
360+ const n = args.number as number;
361+ const issue = await issueOrNull(ctx, l, n);
362+ if (!issue || issue.kind !== "issue") throw notFound(`Could not resolve to an Issue with the number of ${n}.`);
363+ return issueObj(ctx, l, issue, self);
364+ },
365+ issueOrPullRequest: async (args: Args) => {
366+ const n = args.number as number;
367+ const issue = await issueOrNull(ctx, l, n);
368+ if (!issue) throw notFound(`Could not resolve to an issue or pull request with the number of ${n}.`);
369+ need(l, [issue.kind === "pull" ? "pull-requests" : "issues"]);
370+ return issue.kind === "pull" ? pullRequest(ctx, l, issue, self) : issueObj(ctx, l, issue, self);
371+ },
372+ issues: async (args: Args) => {
373+ need(l, ["issues"]);
374+ const opts = await issueFilters(ctx, l, args);
375+ if (!opts) return pagedConnection(args, async () => ({ items: [], total: 0 }), () => ({}));
376+ const hide = await hiddenAuthors(ctx.c);
377+ return pagedConnection(
378+ args,
379+ (limit, offset) => stub.issues({ ...opts, kind: "issue", limit, offset, hide }),
380+ (i) => {
381+ ctx.memo.set(`issue:${repo.id}:${i.number}`, Promise.resolve(i));
382+ return issueObj(ctx, l, i, self);
383+ },
384+ );
385+ },
292386 pullRequests: async (args: Args) => {
387+ need(l, ["pull-requests"]);
293388 pageSize(args);
294389 const states = (args.states as string[] | null | undefined) ?? [];
295390 const want = new Set(states);
@@ -299,8 +394,7 @@
299394 const sort = order?.field === "UPDATED_AT" ? "updated" : order?.field === "COMMENTS" ? "comments" : "created";
300395 // GitHub lists oldest first unless asked otherwise.
301396 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;
397+ let items = await everyIssue(stub, { kind: "pull", state, head: (args.headRefName as string) || undefined, base: (args.baseRefName as string) || undefined, labels, sort, direction, hide: await hiddenAuthors(ctx.c) });
304398 // "closed" holds both closed and merged pull requests: keep only the states asked for.
305399 if (want.size && state !== "all") items = items.filter((i) => want.has(pullState(i)));
306400 else if (want.size && !(want.has("CLOSED") && want.has("MERGED"))) items = items.filter((i) => want.has(pullState(i)));
@@ -318,17 +412,128 @@
318412 const issueOf = (ctx: Ctx, l: Loaded, n: number): Promise<Issue> =>
319413 once(ctx, `issue:${l.repo.id}:${n}`, async () => (await l.stub.issue(n, await hiddenAuthors(ctx.c))).issue);
320414
415+const issueOrNull = (ctx: Ctx, l: Loaded, n: number): Promise<Issue | null> =>
416+ issueOf(ctx, l, n).catch((e) => {
417+ if (statusOf(e)?.status === 404) return null;
418+ throw e;
419+ });
420+
321421 const pullState = (i: Issue): string => (i.pull?.merged ? "MERGED" : i.state === "open" ? "OPEN" : "CLOSED");
322422
323-// ---------------------------------------------------------------- pull requests
423+/**
424+ * Repository.issues' arguments as the repository's own list filters, or null when they can match
425+ * nothing (a milestone or person that isn't there). `states`, `labels` (any of them, as on GitHub),
426+ * `orderBy` and `filterBy` (assignee, createdBy, mentioned, labels, milestoneNumber, since, states).
427+ */
428+async function issueFilters(ctx: Ctx, l: Loaded, args: Args): Promise<Parameters<Stub["issues"]>[0] | null> {
429+ const f = (args.filterBy ?? {}) as Record<string, unknown>;
430+ const list = (v: unknown) => (Array.isArray(v) ? (v as string[]) : []);
431+ const states = new Set(list(args.states).length ? list(args.states) : list(f.states));
432+ const state = states.size === 1 ? (states.has("OPEN") ? "open" : "closed") : "all";
433+ const anyLabels = [...new Set([...list(args.labels), ...list(f.labels)])];
434+ const order = args.orderBy as { field?: string; direction?: string } | undefined;
435+ const sort = order?.field === "UPDATED_AT" ? "updated" : order?.field === "COMMENTS" ? "comments" : "created";
436+ // As GitHub: oldest first unless asked otherwise.
437+ const direction = order?.direction === "DESC" ? "desc" : "asc";
438+ const me = ctx.c.get("principal");
439+ const who = (v: unknown) => (typeof v === "string" && v === "@me" && me.type === "account" ? me.account.login : v);
440+ // assignee: a login, "*" for anyone, null for no one (as on GitHub).
441+ const assignee = f.assignee === null ? "none" : typeof f.assignee === "string" ? String(who(f.assignee)) : undefined;
442+ // milestoneNumber: a number, "*" for any, null for none. `milestone` is GitHub's older name for it.
443+ const m = f.milestoneNumber !== undefined ? f.milestoneNumber : f.milestone;
444+ let milestone: number | "none" | "*" | undefined;
445+ if (m === null || m === "none") milestone = "none";
446+ else if (m === "*") milestone = "*";
447+ else if (typeof m === "string" && /^\d+$/.test(m)) milestone = Number(m);
448+ else if (typeof m === "string") {
449+ // A title, as `gh issue list --milestone` passes when it can.
450+ const found = (await l.stub.milestones("all")).find((x) => x.title === m);
451+ if (!found) return null;
452+ milestone = found.number;
453+ }
454+ const mentioned = typeof f.mentioned === "string" ? String(who(f.mentioned)) : undefined;
455+ return {
456+ state,
457+ anyLabels: anyLabels.length ? anyLabels : undefined,
458+ author: typeof f.createdBy === "string" ? String(who(f.createdBy)) : undefined,
459+ assignee,
460+ mentioned,
461+ milestone,
462+ since: typeof f.since === "string" ? f.since : undefined,
463+ sort,
464+ direction,
465+ };
466+}
324467
468+// ---------------------------------------------------------------- issues and pull requests
469+
470+type CommentRow = { id: string; author: Person; body: string; createdAt: string; reviewId?: string | null; minimized?: { reason: string } | string | null };
471+type Full = { issue: Issue; comments: CommentRow[]; reviews: Review[] };
472+
473+const association = (l: Loaded, login: string) => (login.toLowerCase() === l.repo.owner_login.toLowerCase() ? "OWNER" : "NONE");
474+
475+/** An issue's or pull request's conversation comments (review comments belong to their reviews). */
476+function commentConnection(ctx: Ctx, l: Loaded, url: string, full: Full, args: Args): Obj {
477+ return connection(
478+ full.comments
479+ .filter((cm) => !cm.reviewId)
480+ .map((cm) => {
481+ const minimized = typeof cm.minimized === "string" ? cm.minimized : (cm.minimized?.reason ?? null);
482+ return { __typename: "IssueComment", id: nodeId("IssueComment", `${l.repo.id}/${cm.id}`), author: actor(ctx, cm.author), authorAssociation: association(l, cm.author.login), body: cm.body, createdAt: cm.createdAt, url: `${url}#issuecomment-${cm.id}`, includesCreatedEdit: false, isMinimized: !!minimized, minimizedReason: minimized, reactionGroups: NO_REACTIONS, viewerDidAuthor: viewerId(ctx) === cm.author.id };
483+ }),
484+ args,
485+ );
486+}
487+
488+const STATE_REASONS: Record<string, string> = { completed: "COMPLETED", not_planned: "NOT_PLANNED", reopened: "REOPENED", duplicate: "DUPLICATE" };
489+
490+function issueObj(ctx: Ctx, l: Loaded, i: Issue, repoObj: () => Obj): Obj {
491+ const { repo, stub, caller } = l;
492+ const url = `${repoHtml(ctx.base, repo.owner_login, repo.name)}/issues/${i.number}`;
493+ const full = () => once(ctx, `full:${repo.id}:${i.number}`, async () => stub.issue(i.number, await hiddenAuthors(ctx.c))) as Promise<Full>;
494+ return {
495+ __typename: "Issue",
496+ id: nodeId("Issue", `${repo.id}/${i.number}`),
497+ databaseId: i.number,
498+ number: i.number,
499+ title: i.title,
500+ body: i.body ?? "",
501+ url,
502+ resourcePath: `/${repo.owner_login}/${repo.name}/issues/${i.number}`,
503+ state: i.state === "open" ? "OPEN" : "CLOSED",
504+ stateReason: i.stateReason ? (STATE_REASONS[i.stateReason] ?? null) : null,
505+ closed: i.state === "closed",
506+ closedAt: i.closedAt,
507+ createdAt: i.createdAt,
508+ updatedAt: i.updatedAt,
509+ locked: i.locked,
510+ author: actor(ctx, i.author),
511+ authorAssociation: association(l, i.author.login),
512+ viewerCanUpdate: atLeast(caller.role, "triage") || viewerId(ctx) === i.author.id,
513+ reactionGroups: NO_REACTIONS,
514+ projectItems: (args: Args) => connection([], args),
515+ repository: repoObj,
516+ milestone: i.milestone ? milestoneObj(l, { ...i.milestone, description: null }) : null,
517+ labels: async (args: Args) => {
518+ const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows());
519+ const byName = new Map(rows.map((r) => [r.name, r]));
520+ return connection(
521+ i.labels.map((name) => labelObj(l, name, byName.get(name))),
522+ args,
523+ );
524+ },
525+ assignees: (args: Args) => connection(i.assignees.map((login) => userByLogin(ctx, login)), args),
526+ comments: async (args: Args) => commentConnection(ctx, l, url, await full(), args),
527+ };
528+}
529+
325530 function pullRequest(ctx: Ctx, l: Loaded, i: Issue, repoObj: () => Obj): Obj {
326531 const { repo, stub, caller } = l;
327532 const p = i.pull!;
328533 const html = repoHtml(ctx.base, repo.owner_login, repo.name);
329534 const url = `${html}/pull/${i.number}`;
330535 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[] }>;
536+ const full = () => once(ctx, `full:${key}`, () => stub.issue(i.number, [])) as Promise<Full>;
332537 const stats = () => once(ctx, `stats:${key}`, () => stub.pullStats(i.number));
333538 const review = () => once(ctx, `review:${key}`, () => stub.reviewState(i.number));
334539 const merge = () => once(ctx, `merge:${key}`, () => stub.mergeability(i.number, caller));
@@ -350,7 +555,7 @@
350555 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 };
351556 },
352557 };
353- const assoc = (login: string) => (login.toLowerCase() === repo.owner_login.toLowerCase() ? "OWNER" : "NONE");
558+ const assoc = (login: string) => association(l, login);
354559 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 });
355560 return {
356561 __typename: "PullRequest",
@@ -442,13 +647,7 @@
442647 for (const r of (await full()).reviews) if (r.state !== "COMMENTED" || !latest.has(r.author.id)) latest.set(r.author.id, r);
443648 return connection([...latest.values()].map(reviewObj), args);
444649 },
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- ),
650+ comments: async (args: Args) => commentConnection(ctx, l, url, await full(), args),
452651 files: async (args: Args) => {
453652 const res = await once(ctx, `files:${key}`, () => stub.pullFiles(i.number));
454653 const change: Record<string, string> = { added: "ADDED", deleted: "DELETED", modified: "MODIFIED" };
@@ -630,11 +829,11 @@
630829 }
631830 }
632831
633-/** A repository or pull request by node id, with the same access check as a lookup by name. */
832+/** A repository, issue or pull request by node id, with the same access check as a lookup by name. */
634833 async function nodeById(ctx: Ctx, id: string): Promise<Obj | null> {
635834 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];
835+ if (!d || (d.type !== "Repository" && d.type !== "PullRequest" && d.type !== "Issue")) return null;
836+ const [repoId, num] = d.type === "Repository" ? [d.key] : d.key.split("/");
638837 const row = await ctx.c.get("dir").repoById(repoId!);
639838 if (!row) return null;
640839 const l = await loadRepository(ctx, row.owner_login, row.name);
@@ -642,39 +841,75 @@
642841 const repoObj = repository(ctx, l);
643842 if (d.type === "Repository") return repoObj;
644843 const issue = await issueOf(ctx, l, Number(num)).catch(() => null);
645- return issue?.kind === "pull" ? pullRequest(ctx, l, issue, () => repoObj) : null;
844+ if (!issue || issue.kind !== (d.type === "Issue" ? "issue" : "pull")) return null;
845+ need(l, [issue.kind === "pull" ? "pull-requests" : "issues"]);
846+ return issue.kind === "pull" ? pullRequest(ctx, l, issue, () => repoObj) : issueObj(ctx, l, issue, () => repoObj);
646847 }
647848
648849 /**
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.
850+ * GitHub's issue search, in one repository: `repo:` is required, and `is:` (open, closed, merged,
851+ * issue, pr), `type:`, `state:`, `author:`, `assignee:`, `mentions:`, `label:`, `milestone:`,
852+ * `no:` (assignee, label, milestone), `head:`, `base:`, `review-requested:`, `draft:` and words
853+ * (in the title or body) narrow it; `sort:` orders it. GitHub's advanced syntax parses too, for
854+ * the AND of its terms (gh writes `( words ) qualifiers`).
651855 */
652856 async function search(ctx: Ctx, args: Args): Promise<Obj> {
653857 pageSize(args);
654858 const empty = () => ({ ...connection([], args), issueCount: 0, repositoryCount: 0, userCount: 0 });
655859 if (args.type !== "ISSUE" && args.type !== "ISSUE_ADVANCED") return empty();
656860 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, ""));
861+ const words: string[] = [];
862+ for (const m of String(args.query).matchAll(/(-?)(?:([a-z-]+):("[^"]*"|[^\s()]+)|("[^"]*"|[^\s()]+))/g)) {
863+ if (m[2]) {
864+ if (!m[1]) (q[m[2]] ??= []).push(m[3]!.replace(/^"|"$/g, ""));
865+ } else if (!m[1] && m[4] && !/^(AND|OR)$/.test(m[4])) words.push(m[4].replace(/^"|"$/g, "").toLowerCase());
866+ }
658867 const viewer = ctx.c.get("principal");
659868 const me = viewer.type === "account" ? viewer.account.login : "";
660869 const who = (v: string | undefined) => (v === "@me" ? me : v);
661870 const [full] = q.repo ?? [];
662- if (!full || q.repo!.length > 1 || (q.is ?? []).includes("issue") || (q.type ?? []).includes("issue")) return empty();
871+ if (!full || q.repo!.length > 1) return empty();
663872 const [owner, name] = full.split("/");
664873 const l = owner && name ? await loadRepository(ctx, owner, name) : null;
665874 if (!l) return empty();
666- const states = new Set([...(q.state ?? []), ...(q.is ?? []).filter((x) => ["open", "closed", "merged"].includes(x))]);
875+ const is = new Set([...(q.is ?? []), ...(q.type ?? [])].map((x) => (x === "pull-request" || x === "pullrequest" ? "pr" : x)));
876+ const kind = is.has("issue") && !is.has("pr") ? "issue" : is.has("pr") && !is.has("issue") ? "pull" : undefined;
877+ need(l, kind === "issue" ? ["issues"] : kind === "pull" ? ["pull-requests"] : ["issues", "pull-requests"]);
878+ const states = new Set([...(q.state ?? []), ...[...is].filter((x) => ["open", "closed", "merged"].includes(x))]);
667879 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) });
880+ const no = new Set(q.no ?? []);
881+ let milestone: number | "none" | undefined = no.has("milestone") ? "none" : undefined;
882+ if (q.milestone?.[0]) {
883+ const found = (await l.stub.milestones("all")).find((x) => x.title === q.milestone![0]);
884+ if (!found) return empty();
885+ milestone = found.number;
886+ }
887+ const sortBy = /^(created|updated|comments)(?:-(asc|desc))?$/.exec(q.sort?.[0] ?? "");
888+ const all = await everyIssue(l.stub, {
889+ kind,
890+ state,
891+ author: who(q.author?.[0]),
892+ assignee: no.has("assignee") ? "none" : who(q.assignee?.[0]),
893+ mentioned: who(q.mentions?.[0]),
894+ labels: q.label,
895+ milestone,
896+ head: q.head?.[0],
897+ base: q.base?.[0],
898+ sort: (sortBy?.[1] as "created" | "updated" | "comments" | undefined) ?? "created",
899+ direction: sortBy?.[2] === "asc" ? "asc" : "desc",
900+ hide: await hiddenAuthors(ctx.c),
901+ });
669902 const reviewer = who(q["review-requested"]?.[0])?.toLowerCase();
670- const items = res.items.filter((i) => {
903+ const items = all.filter((i) => {
671904 if (states.has("merged") && !states.has("closed") && !i.pull?.merged) return false;
672905 if (q.draft?.[0] && String(!!i.pull?.draft) !== q.draft[0]) return false;
673906 if (reviewer && !i.pull?.requestedReviewers.some((r) => r.toLowerCase() === reviewer)) return false;
674- return true;
907+ if (no.has("label") && i.labels.length) return false;
908+ const text = `${i.title}\n${i.body ?? ""}`.toLowerCase();
909+ return words.every((w) => text.includes(w));
675910 });
676911 const repoObj = repository(ctx, l);
677- return { ...connection(items.map((i) => pullRequest(ctx, l, i, () => repoObj)), args), issueCount: items.length, repositoryCount: 0, userCount: 0 };
912+ return { ...connection(items.map((i) => (i.kind === "pull" ? pullRequest(ctx, l, i, () => repoObj) : issueObj(ctx, l, i, () => repoObj))), args), issueCount: items.length, repositoryCount: 0, userCount: 0 };
678913 }
679914
680915 function root(ctx: Ctx): Obj {
@@ -709,10 +944,13 @@
709944
710945 // ---------------------------------------------------------------- mutations (the pull request ones gh uses)
711946
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> {
947+/**
948+ * loadRepo for a change: the role the REST route for the same change needs, and the permission a
949+ * workflow's GITHUB_TOKEN needs for it (`permission`, as REST asks for the same change).
950+ */
951+async function loadForWrite(ctx: Ctx, owner: string, name: string, min: Role, permission: Requirement): Promise<Loaded> {
714952 try {
715- const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, min, { follow: true });
953+ const { repo, caller, stub } = await loadRepo(ctx.c, owner, name, min, { follow: true, permission });
716954 return { repo, caller, stub };
717955 } catch (e) {
718956 if (statusOf(e)?.status === 404) throw notFound(`Could not resolve to a Repository with the name '${owner}/${name}'.`);
@@ -720,26 +958,46 @@
720958 }
721959 }
722960
723-async function repoForWrite(ctx: Ctx, id: unknown, min: Role): Promise<Loaded> {
961+async function repoForWrite(ctx: Ctx, id: unknown, min: Role, permission: Requirement): Promise<Loaded> {
724962 const d = decodeNodeId(String(id));
725963 const row = d?.type === "Repository" ? await ctx.c.get("dir").repoById(d.key) : null;
726964 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);
965+ return loadForWrite(ctx, row.owner_login, row.name, min, permission);
728966 }
729967
730-async function pullForWrite(ctx: Ctx, id: unknown, min: Role = "read"): Promise<{ l: Loaded; number: number }> {
968+/**
969+ * The issue or pull request a change names by node id. `only` refuses the other kind, as GitHub's
970+ * pull request mutations refuse an issue's id. Without a `permission`, a workflow's token needs
971+ * issues or pull-requests write, whichever the node is.
972+ */
973+async function issueForWrite(ctx: Ctx, id: unknown, o: { min?: Role; permission?: Requirement; only?: "issue" | "pull" } = {}): Promise<{ l: Loaded; number: number }> {
731974 const d = decodeNodeId(String(id));
732- const [repoId, n] = d && (d.type === "PullRequest" || d.type === "Issue") ? d.key.split("/") : [];
975+ const type = d?.type === "PullRequest" ? "pull" : d?.type === "Issue" ? "issue" : null;
976+ const [repoId, n] = d && type && (!o.only || o.only === type) ? d.key.split("/") : [];
733977 const row = repoId ? await ctx.c.get("dir").repoById(repoId) : null;
734978 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) };
979+ const l = await loadForWrite(ctx, row.owner_login, row.name, o.min ?? "read", o.permission ?? writes(type === "pull" ? "pull-requests" : "issues"));
980+ const found = await issueOrNull(ctx, l, Number(n));
981+ // An id made for the other kind (an issue's number with a pull request's type) is no node at all.
982+ if (!found || found.kind !== type) throw notFound(`Could not resolve to a node with the global id of '${id}'.`);
983+ return { l, number: Number(n) };
736984 }
737985
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;
986+const pullForWrite = (ctx: Ctx, id: unknown, min: Role = "read", permission: Requirement = writes("pull-requests")) => issueForWrite(ctx, id, { min, permission, only: "pull" });
987+
988+/** What a label or assignee change returns: the issue or pull request as it is now. */
989+async function labelable(ctx: Ctx, l: Loaded, number: number): Promise<Obj> {
990+ return (await issueOf(ctx, l, number)).kind === "pull" ? freshPull(ctx, l, number) : freshIssue(ctx, l, number);
741991 }
742992
993+/** The issue as it is after a change. */
994+async function freshIssue(ctx: Ctx, l: Loaded, number: number): Promise<Obj> {
995+ for (const k of ["issue", "full"]) ctx.memo.delete(`${k}:${l.repo.id}:${number}`);
996+ const issue = await issueOf(ctx, l, number);
997+ const repoObj = repository(ctx, l);
998+ return issueObj(ctx, l, issue, () => repoObj);
999+}
1000+
7431001 /** The pull request as it is after a change (this request's cached copies are dropped). */
7441002 async function freshPull(ctx: Ctx, l: Loaded, number: number): Promise<Obj> {
7451003 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}`);
@@ -805,7 +1063,7 @@
8051063 return {
8061064 createPullRequest: async (args: Args) => {
8071065 const inp = input(args);
808- const l = await repoForWrite(ctx, inp.repositoryId, "write");
1066+ const l = await repoForWrite(ctx, inp.repositoryId, "write", writes("pull-requests"));
8091067 await creating(ctx);
8101068 const head = String(inp.headRefName ?? "");
8111069 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);
@@ -851,37 +1109,37 @@
8511109 },
8521110 enablePullRequestAutoMerge: async (args: Args) => {
8531111 const inp = input(args);
854- const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write");
1112+ const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write", writes("contents"));
8551113 await l.stub.enableAutoMerge(number, { method: method(inp.mergeMethod), commitTitle: str(inp.commitHeadline), commitMessage: str(inp.commitBody), expectedHeadSha: str(inp.expectedHeadOid) }, l.caller);
8561114 return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer });
8571115 },
8581116 disablePullRequestAutoMerge: async (args: Args) => {
8591117 const inp = input(args);
860- const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write");
1118+ const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write", writes("contents"));
8611119 await l.stub.disableAutoMerge(number, l.caller);
8621120 return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer });
8631121 },
8641122 mergePullRequest: async (args: Args) => {
8651123 const inp = input(args);
866- const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write");
1124+ const { l, number } = await pullForWrite(ctx, inp.pullRequestId, "write", writes("contents"));
8671125 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);
8681126 return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer });
8691127 },
8701128 addComment: async (args: Args) => {
8711129 const inp = input(args);
872- const { l, number } = await pullForWrite(ctx, inp.subjectId);
1130+ const { l, number } = await issueForWrite(ctx, inp.subjectId, { permission: writes("issues", "pull-requests") });
8731131 const body = String(inp.body ?? "");
8741132 if (!body.trim()) throw new GraphQLError("Body can't be blank.", { extensions: { type: "UNPROCESSABLE" } });
8751133 await creating(ctx);
8761134 const cm = await l.stub.addComment(number, body, l.caller);
8771135 const html = repoHtml(ctx.base, l.repo.owner_login, l.repo.name);
8781136 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 });
1137+ const node = { __typename: "IssueComment", id: nodeId("IssueComment", `${l.repo.id}/${cm.id}`), author: actor(ctx, cm.author), authorAssociation: association(l, cm.author.login), 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 };
1138+ return payload(inp, { commentEdge: { cursor: cursorOf(0), node }, subject: await labelable(ctx, l, number) });
8811139 },
8821140 addLabelsToLabelable: async (args: Args) => {
8831141 const inp = input(args);
884- const { l, number } = await pullForWrite(ctx, inp.labelableId);
1142+ const { l, number } = await issueForWrite(ctx, inp.labelableId, { permission: writes("issues", "pull-requests") });
8851143 const current = await issueOf(ctx, l, number);
8861144 const add = await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : []);
8871145 await l.stub.updateIssue(number, { labels: [...new Set([...current.labels, ...add])] }, l.caller);
@@ -889,7 +1147,7 @@
8891147 },
8901148 removeLabelsFromLabelable: async (args: Args) => {
8911149 const inp = input(args);
892- const { l, number } = await pullForWrite(ctx, inp.labelableId);
1150+ const { l, number } = await issueForWrite(ctx, inp.labelableId, { permission: writes("issues", "pull-requests") });
8931151 const current = await issueOf(ctx, l, number);
8941152 const drop = new Set(await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : []));
8951153 await l.stub.updateIssue(number, { labels: current.labels.filter((x) => !drop.has(x)) }, l.caller);
@@ -897,19 +1155,57 @@
8971155 },
8981156 addAssigneesToAssignable: async (args: Args) => {
8991157 const inp = input(args);
900- const { l, number } = await pullForWrite(ctx, inp.assignableId);
1158+ const { l, number } = await issueForWrite(ctx, inp.assignableId, { permission: writes("issues", "pull-requests") });
9011159 const current = await issueOf(ctx, l, number);
9021160 await l.stub.updateIssue(number, { assignees: [...new Set([...current.assignees, ...(await loginsFor(ctx, inp.assigneeIds))])] }, l.caller);
9031161 return payload(inp, { assignable: await labelable(ctx, l, number) });
9041162 },
9051163 removeAssigneesFromAssignable: async (args: Args) => {
9061164 const inp = input(args);
907- const { l, number } = await pullForWrite(ctx, inp.assignableId);
1165+ const { l, number } = await issueForWrite(ctx, inp.assignableId, { permission: writes("issues", "pull-requests") });
9081166 const current = await issueOf(ctx, l, number);
9091167 const drop = new Set(await loginsFor(ctx, inp.assigneeIds));
9101168 await l.stub.updateIssue(number, { assignees: current.assignees.filter((x) => !drop.has(x)) }, l.caller);
9111169 return payload(inp, { assignable: await labelable(ctx, l, number) });
9121170 },
1171+ createIssue: async (args: Args) => {
1172+ const inp = input(args);
1173+ const l = await repoForWrite(ctx, inp.repositoryId, "read", writes("issues"));
1174+ if (Array.isArray(inp.projectIds) && inp.projectIds.length) throw new GraphQLError("Projects aren't supported.", { extensions: { type: "UNPROCESSABLE" } });
1175+ const title = String(inp.title ?? "");
1176+ if (!title.trim()) throw new GraphQLError("Title can't be blank.", { extensions: { type: "UNPROCESSABLE" } });
1177+ const labels = Array.isArray(inp.labelIds) ? await labelNamesFor(ctx, l, inp.labelIds) : undefined;
1178+ const assignees = Array.isArray(inp.assigneeIds) ? await loginsFor(ctx, inp.assigneeIds) : undefined;
1179+ await creating(ctx);
1180+ const issue = await l.stub.createIssue({ title, body: str(inp.body), labels, assignees }, l.caller);
1181+ // As over REST, a milestone from someone without triage access is left off.
1182+ if (inp.milestoneId && atLeast(l.caller.role, "triage")) await setMilestone(ctx, l, issue.number, inp.milestoneId);
1183+ return payload(inp, { issue: await freshIssue(ctx, l, issue.number) });
1184+ },
1185+ updateIssue: async (args: Args) => {
1186+ const inp = input(args);
1187+ const { l, number } = await issueForWrite(ctx, inp.id, { only: "issue" });
1188+ if (Array.isArray(inp.projectIds) && inp.projectIds.length) throw new GraphQLError("Projects aren't supported.", { extensions: { type: "UNPROCESSABLE" } });
1189+ const state = inp.state === "OPEN" ? "open" : inp.state === "CLOSED" ? "closed" : undefined;
1190+ const labels = Array.isArray(inp.labelIds) ? await labelNamesFor(ctx, l, inp.labelIds) : undefined;
1191+ const assignees = Array.isArray(inp.assigneeIds) ? await loginsFor(ctx, inp.assigneeIds) : undefined;
1192+ 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);
1193+ if (inp.milestoneId !== undefined) await setMilestone(ctx, l, number, inp.milestoneId);
1194+ return payload(inp, { issue: await freshIssue(ctx, l, number), actor: root(ctx).viewer });
1195+ },
1196+ closeIssue: async (args: Args) => {
1197+ const inp = input(args);
1198+ const { l, number } = await issueForWrite(ctx, inp.issueId, { only: "issue" });
1199+ const reason = { COMPLETED: "completed", NOT_PLANNED: "not_planned", DUPLICATE: "duplicate" }[String(inp.stateReason ?? "COMPLETED")] ?? "completed";
1200+ await l.stub.updateIssue(number, { state: "closed", stateReason: reason }, l.caller);
1201+ return payload(inp, { issue: await freshIssue(ctx, l, number) });
1202+ },
1203+ reopenIssue: async (args: Args) => {
1204+ const inp = input(args);
1205+ const { l, number } = await issueForWrite(ctx, inp.issueId, { only: "issue" });
1206+ await l.stub.updateIssue(number, { state: "open" }, l.caller);
1207+ return payload(inp, { issue: await freshIssue(ctx, l, number) });
1208+ },
9131209 requestReviews: async (args: Args) => {
9141210 const inp = input(args);
9151211 if (Array.isArray(inp.teamIds) && inp.teamIds.length) throw new GraphQLError("Team reviewers aren't supported; request people.", { extensions: { type: "UNPROCESSABLE" } });
modifiedapps/diffinery/src/repo/collab.ts+18−0View file
@@ -260,7 +260,11 @@
260260 q?: string;
261261 // GitHub list filters (REST `issues` and `pulls` query parameters).
262262 labels?: string[];
263+ /** Any of these labels (GraphQL's `labels`); `labels` needs all of them (REST's). */
264+ anyLabels?: string[];
263265 assignee?: string;
266+ /** Mentioned as @login in the body or a comment (GraphQL's `filterBy.mentioned`, search's `mentions:`). */
267+ mentioned?: string;
264268 milestone?: number | "none" | "*";
265269 since?: string;
266270 head?: string;
@@ -301,6 +305,20 @@
301305 where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)");
302306 args.push(l);
303307 }
308+ if (opts.anyLabels?.length) {
309+ where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value IN (SELECT value FROM json_each(?)))");
310+ args.push(JSON.stringify(opts.anyLabels));
311+ }
312+ if (opts.mentioned !== undefined) {
313+ // @login with no letter, digit or underscore before it and no login character after it
314+ // (so @ann doesn't match @anna or an email address). Logins are letters, digits and hyphens.
315+ if (!/^[A-Za-z0-9-]+$/.test(opts.mentioned)) where.push("0");
316+ else {
317+ const pattern = `*[^a-z0-9_]@${opts.mentioned.toLowerCase()}[^a-z0-9-]*`;
318+ where.push("(lower(' ' || body || ' ') GLOB ? OR EXISTS (SELECT 1 FROM comments c WHERE c.number = issues.number AND lower(' ' || c.body || ' ') GLOB ?))");
319+ args.push(pattern, pattern);
320+ }
321+ }
304322 if (opts.assignee === "none") where.push("assignees = '[]'");
305323 else if (opts.assignee === "*") where.push("assignees != '[]'");
306324 else if (opts.assignee) {
modifiedapps/diffinery/test/e2e/graphql.test.mjs+141−1View file
@@ -2,7 +2,9 @@
22 // the queries Claude Code sends to show a session's pull request and its CI (the badge and the
33 // check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull
44 // 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
5+// `gh pr close`, `gh pr reopen` and `gh pr merge`, the queries and mutations behind `gh issue`
6+// (list, view, create, comment, edit, close, reopen, status, list --search), and a workflow
7+// job's GITHUB_TOKEN with its `permissions:`. Also covers sign-in, token scopes, limits and
68 // GitHub's error shape.
79 //
810 // pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs
@@ -221,6 +223,144 @@
221223 assert.equal(p.merged, true);
222224 });
223225
226+// gh's issue fields (api.IssueGraphQL for `gh issue list` and `gh issue view`), as gh 2.89 sends them.
227+const ISSUE_FIELDS = `number,title,url,state,stateReason,createdAt,updatedAt,closedAt,isPinned,author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount},labels(first:100){nodes{id,name,description,color},totalCount},milestone{number,title,description,dueOn},comments(first:100){nodes{id,author{login,...on User{id,name}},authorAssociation,body,createdAt,includesCreatedEdit,isMinimized,minimizedReason,reactionGroups{content,users{totalCount}},url,viewerDidAuthor},pageInfo{hasNextPage,endCursor},totalCount},reactionGroups{content,users{totalCount}},body,projectCards(first:100){nodes{project{name}column{name}},totalCount}`;
228+const ISSUE_LIST = `fragment issue on Issue {${ISSUE_FIELDS}}
229+query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String) {
230+ repository(owner: $owner, name: $repo) {
231+ hasIssuesEnabled
232+ issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention}) {
233+ totalCount nodes { ...issue } pageInfo { hasNextPage endCursor }
234+ }
235+ }
236+}`;
237+const ISSUE_BY_NUMBER = `query IssueByNumber($owner: String!, $repo: String!, $number: Int!) {
238+ repository(owner: $owner, name: $repo) {
239+ hasIssuesEnabled
240+ issue: issueOrPullRequest(number: $number) { __typename ...on Issue{id,${ISSUE_FIELDS}} ...on PullRequest{id,number,title,state,url} }
241+ }
242+}`;
243+
244+test("gh issue create, list, view, comment, edit, close, reopen, status and list --search", async () => {
245+ // gh issue create: the repository's id (RepositoryInfo), label ids, then createIssue.
246+ const labels = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 100) { nodes { id name } } } }`)).repository.labels.nodes;
247+ await rest("POST", "/repos/ann/app/labels", { name: "bug", color: "d73a4a" });
248+ const bug = (await data(`query { repository(owner: "ann", name: "app") { label(name: "bug") { id } } }`)).repository.label.id;
249+ assert.ok(labels.some((l) => l.name === "docs"));
250+ const ann = (await data(`query { repository(owner: "ann", name: "app") { assignableUsers(first: 10) { nodes { id login } } } }`)).repository.assignableUsers.nodes.find((u) => u.login === "ann");
251+ const create = (input) => data(`mutation IssueCreate($input: CreateIssueInput!) { createIssue(input: $input) { issue { id url } } }`, { input });
252+ const first = (await create({ repositoryId: repoId, title: "Crash on start", body: "It crashes when @bob opens it.", labelIds: [bug], assigneeIds: [ann.id] })).createIssue.issue;
253+ const second = (await create({ repositoryId: repoId, title: "Docs typo", body: "A typo." })).createIssue.issue;
254+ const third = (await create({ repositoryId: repoId, title: "Old idea", body: "Later." })).createIssue.issue;
255+ const n = (issue) => Number(issue.url.split("/").pop());
256+ assert.match(first.url, new RegExp(`^${base}/ann/app/issues/\\d+$`));
257+ assert.ok(n(first) > 1, "issues share numbers with pull requests");
258+
259+ // gh issue list: newest first, a page at a time, without the pull request.
260+ let page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2 })).repository;
261+ assert.equal(page.hasIssuesEnabled, true);
262+ assert.equal(page.issues.totalCount, 3);
263+ assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(third), n(second)]);
264+ assert.equal(page.issues.pageInfo.hasNextPage, true);
265+ page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2, endCursor: page.issues.pageInfo.endCursor })).repository;
266+ assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(first)]);
267+ assert.equal(page.issues.pageInfo.hasNextPage, false);
268+ const listed = page.issues.nodes[0];
269+ assert.equal(listed.state, "OPEN");
270+ assert.equal(listed.author.login, "ann");
271+ assert.deepEqual(listed.labels.nodes.map((l) => l.name), ["bug"]);
272+ assert.deepEqual(listed.assignees.nodes.map((u) => u.login), ["ann"]);
273+ // --assignee, --author and --mention.
274+ const only = async (vars) => (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 30, ...vars })).repository.issues.nodes.map((i) => i.number);
275+ assert.deepEqual(await only({ assignee: "ann" }), [n(first)]);
276+ assert.deepEqual(await only({ author: "ann" }), [n(third), n(second), n(first)]);
277+ assert.deepEqual(await only({ mention: "bob" }), [n(first)]);
278+ assert.deepEqual(await only({ mention: "bo" }), []);
279+
280+ // gh issue view: issueOrPullRequest, then the issue's project items (none).
281+ let view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(first) })).repository.issue;
282+ assert.equal(view.__typename, "Issue");
283+ assert.equal(view.id, first.id);
284+ assert.equal(view.body, "It crashes when @bob opens it.");
285+ const pr = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: 1 })).repository.issue;
286+ assert.equal(pr.__typename, "PullRequest");
287+ const items = await data(`query IssueProjectItems($endCursor: String, $name: String!, $number: Int!, $owner: String!) { repository(owner: $owner, name: $name) { issue(number: $number) { projectItems(first: 100, after: $endCursor) { totalCount nodes { id project { id title } status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { optionId name } } } pageInfo { hasNextPage endCursor } } } } }`, { owner: "ann", name: "app", number: n(first) });
288+ assert.equal(items.repository.issue.projectItems.totalCount, 0);
289+ const missing = await gql(`query { repository(owner: "ann", name: "app") { issue(number: 1) { id } } }`);
290+ assert.equal(missing.body.errors[0].type, "NOT_FOUND", "a pull request's number is no issue");
291+
292+ // gh issue comment, gh issue edit (title, body, labels), gh issue close and reopen.
293+ const comment = await data(`mutation CommentCreate($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: first.id, body: "Same here, @bob." } });
294+ assert.match(comment.addComment.commentEdge.node.url, new RegExp(`/ann/app/issues/${n(first)}#issuecomment-`));
295+ await data(`mutation IssueUpdate($input: UpdateIssueInput!) { updateIssue(input: $input) { __typename } }`, { input: { id: second.id, title: "Docs: fix a typo", body: "In the README." } });
296+ const docs = labels.find((l) => l.name === "docs");
297+ await data(`mutation LabelAdd($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { __typename } }`, { input: { labelableId: second.id, labelIds: [docs.id] } });
298+ await data(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id, stateReason: "NOT_PLANNED" } });
299+ view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(second) })).repository.issue;
300+ assert.equal(view.title, "Docs: fix a typo");
301+ assert.equal(view.body, "In the README.");
302+ assert.deepEqual(view.labels.nodes.map((l) => l.name), ["docs"]);
303+ const closed = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue;
304+ assert.deepEqual([closed.state, closed.stateReason], ["CLOSED", "NOT_PLANNED"]);
305+ assert.deepEqual(await only({}), [n(second), n(first)], "open issues by default");
306+ assert.deepEqual(await only({ states: ["CLOSED"] }), [n(third)]);
307+ await data(`mutation IssueReopen($input: ReopenIssueInput!) { reopenIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id } });
308+ assert.equal((await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue.state, "OPEN");
309+ // An issue's id isn't a pull request's: GitHub's pull request mutations refuse it.
310+ const wrong = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: first.id });
311+ assert.equal(wrong.body.errors[0].type, "NOT_FOUND");
312+ // A read token can look but not open or close issues.
313+ const refused = await gql(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: first.id } }, T.annRead);
314+ assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body));
315+
316+ // gh issue status: three filtered lists at once.
317+ const status = await data(
318+ `fragment issue on Issue{number,title} query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { hasIssuesEnabled assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } } }`,
319+ { owner: "ann", repo: "app", viewer: "ann" },
320+ );
321+ assert.deepEqual([status.repository.assigned.totalCount, status.repository.mentioned.totalCount, status.repository.authored.totalCount], [1, 0, 3]);
322+
323+ // gh issue list --label/--search: GitHub's issue search, in the advanced syntax gh writes.
324+ const search = async (query) => (await data(`fragment issue on Issue {number,title} query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { repository(name: $repo, owner: $owner) { hasIssuesEnabled } search(type: $type, last: $limit, after: $after, query: $query) { issueCount nodes { ...issue } pageInfo { hasNextPage endCursor } } }`, { owner: "ann", repo: "app", type: "ISSUE_ADVANCED", limit: 30, query })).search;
325+ assert.deepEqual((await search(`repo:ann/app state:open type:issue label:bug`)).nodes.map((i) => i.number), [n(first)]);
326+ assert.deepEqual((await search(`( crash ) repo:ann/app type:issue`)).nodes.map((i) => i.number), [n(first)]);
327+ assert.equal((await search(`repo:ann/app type:issue`)).issueCount, 3);
328+ assert.equal((await search(`repo:ann/app is:pr`)).issueCount, 1);
329+});
330+
331+test("a workflow's GITHUB_TOKEN uses GraphQL with its permissions, as over REST", async () => {
332+ const workflow = "name: gh\non: push\npermissions:\n contents: read\n issues: read\njobs:\n gh:\n runs-on: [self-hosted, linux]\n steps:\n - run: gh issue list\n";
333+ await rest("PUT", "/repos/ann/app/contents/.github/workflows/gh.yml", { message: "gh in CI", content: Buffer.from(workflow).toString("base64") });
334+ const reg = await rest("POST", "/repos/ann/app/actions/runners/registration-token");
335+ const runner = await rest("POST", "/repos/ann/app/actions/runners/register", { registration_token: reg.token, name: "graphql-e2e", labels: ["linux"] });
336+ let claim;
337+ for (const end = Date.now() + 60_000; !claim; ) {
338+ const r = await fetch(`${base}/api/v3/repos/ann/app/actions/runners/claim`, { method: "POST", headers: { authorization: `Bearer ${runner.token}`, "content-type": "application/json" }, body: "{}" });
339+ if (r.status === 200) claim = await r.json();
340+ else if (Date.now() > end) assert.fail(`no job to claim: ${r.status} ${await r.text()}`);
341+ else await new Promise((res) => setTimeout(res, 500));
342+ }
343+ try {
344+ // The job starts (the runner reads its spec): its token gets the workflow's permissions.
345+ const spec = await fetch(`${base}/runner/jobs/${claim.job_id}`, { headers: { authorization: `Bearer ${claim.token}` } });
346+ assert.equal(spec.status, 200, await spec.clone().text());
347+ // The repository and its issues: `gh repo view` and `gh issue list` in the job.
348+ const d = await data(`query { repository(owner: "ann", name: "app") { nameWithOwner issues(first: 10) { totalCount } } }`, {}, claim.token);
349+ assert.equal(d.repository.nameWithOwner, "ann/app");
350+ assert.equal(typeof d.repository.issues.totalCount, "number");
351+ // Pull requests need pull-requests: read, which this job doesn't have.
352+ const prs = await gql(`query { repository(owner: "ann", name: "app") { pullRequests(first: 10) { totalCount } } }`, {}, claim.token);
353+ assert.equal(prs.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(prs.body));
354+ assert.match(prs.body.errors[0].message, /pull-requests/);
355+ // issues: read can't open an issue.
356+ const opened = await gql(`mutation($input: CreateIssueInput!) { createIssue(input: $input) { issue { id } } }`, { input: { repositoryId: repoId, title: "from CI" } }, claim.token);
357+ assert.equal(opened.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(opened.body));
358+ assert.match(opened.body.errors[0].message, /issues: write/);
359+ } finally {
360+ await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
361+ }
362+});
363+
224364 test("sign-in, privacy, limits and GitHub's error shape", async () => {
225365 const anonymous = await gql(`query { viewer { login } }`, {}, null);
226366 assert.equal(anonymous.status, 401);