gh issue commands over GraphQL, and gh inside workflows with the job token (#324)
* gh issue commands over GraphQL, and gh inside workflows with the job token
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
- Mirror to GitHub / Push main to the GitHub mirrorsuccess
10 files changed+669−89
Files (10)
- apps/diffinery/docs/actions/compatibility.md+3−3
- apps/diffinery/docs/agents/github-cli.md+20−4
- apps/diffinery/docs/migrate-from-github.md+2−2
- apps/diffinery/docs/reference/graphql-api.md+17−12
- apps/diffinery/src/http/api.ts+8−1
- apps/diffinery/src/http/common.ts+4−3
- apps/diffinery/src/http/graphql-schema.ts+107−10
- apps/diffinery/src/http/graphql.ts+349−53
- apps/diffinery/src/repo/collab.ts+18−0
- apps/diffinery/test/e2e/graphql.test.mjs+141−1
modifiedapps/diffinery/docs/actions/compatibility.md+3−3View file
| @@ -250,7 +250,7 @@ | ||
| 250 | 250 | }); |
| 251 | 251 | ``` |
| 252 | 252 | |
| 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. | |
| 254 | 254 | |
| 255 | 255 | ## Other actions |
| 256 | 256 | |
| @@ -298,7 +298,7 @@ | ||
| 298 | 298 | |
| 299 | 299 | ### Actions that call the GitHub API |
| 300 | 300 | |
| 301 | -Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because 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 }}`. | |
| 302 | 302 | |
| 303 | 303 | ### Actions that use the cache or artifact services |
| 304 | 304 | |
| @@ -387,7 +387,7 @@ | ||
| 387 | 387 | 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. |
| 388 | 388 | 7. Give `actions/checkout` of another private repository a token from a secret. |
| 389 | 389 | 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. | |
| 391 | 391 | 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). |
| 392 | 392 | 11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`. |
| 393 | 393 | 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 @@ | ||
| 3 | 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 | 4 | --- |
| 5 | 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). | |
| 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). | |
| 7 | 7 | |
| 8 | 8 | ## Set it up |
| 9 | 9 | |
| @@ -41,16 +41,32 @@ | ||
| 41 | 41 | gh pr checks --watch |
| 42 | 42 | gh pr ready |
| 43 | 43 | 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" | |
| 44 | 47 | ``` |
| 45 | 48 | |
| 46 | 49 | | Works | Commands | |
| 47 | 50 | | --- | --- | |
| 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. | | |
| 51 | 54 | |
| 52 | 55 | 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 | 56 | |
| 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 | + | |
| 54 | 70 | ## Claude Code's pull request badge |
| 55 | 71 | |
| 56 | 72 | 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 @@ | ||
| 820 | 820 | | --- | --- | |
| 821 | 821 | | 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). | |
| 822 | 822 | | 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. | | |
| 824 | 824 | | 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). | |
| 825 | 825 | | 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. | |
| 826 | 826 | | 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 @@ | ||
| 844 | 844 | const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" }); |
| 845 | 845 | ``` |
| 846 | 846 | |
| 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)). | |
| 848 | 848 | |
| 849 | 849 | ## Keep GitHub as upstream and mirror to Diffinery |
| 850 | 850 |
modifiedapps/diffinery/docs/reference/graphql-api.md+17−12View file
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | --- |
| 2 | 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. | |
| 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. | |
| 4 | 4 | --- |
| 5 | 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. | |
| 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. | |
| 7 | 7 | |
| 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). | |
| 9 | 9 | |
| 10 | 10 | ## Requests |
| 11 | 11 | |
| @@ -22,9 +22,9 @@ | ||
| 22 | 22 | | --- | --- | |
| 23 | 23 | | URL | `https://diffinery.com/api/graphql` | |
| 24 | 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. | | |
| 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`. | | |
| 26 | 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. | | |
| 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. | | |
| 28 | 28 | | Introspection | `__schema` and `__type` work, so you can read the whole schema with any GraphQL client. | |
| 29 | 29 | | Subscriptions | Not supported. | |
| 30 | 30 | |
| @@ -35,12 +35,14 @@ | ||
| 35 | 35 | | Field | What you get | |
| 36 | 36 | | --- | --- | |
| 37 | 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. | | |
| 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. | | |
| 39 | 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. | | |
| 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. | | |
| 42 | 42 | | `rateLimit` | A budget in GitHub's shape. Diffinery limits requests per account (see [Limits](#limits)), not by query cost. | |
| 43 | 43 | |
| 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 | + | |
| 44 | 46 | 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 | 47 | |
| 46 | 48 | **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 @@ | ||
| 51 | 53 | | --- | --- | --- | |
| 52 | 54 | | `createPullRequest` | Opens a pull request, as a draft with `draft: true`. | `gh pr create` | |
| 53 | 55 | | `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` | | |
| 56 | 58 | | `requestReviews` | Asks people for a review. Team reviewers aren't supported. | `gh pr create --reviewer`, `gh pr edit --add-reviewer` | |
| 57 | 59 | | `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` | | |
| 59 | 61 | | `closePullRequest`, `reopenPullRequest` | Closes or reopens a pull request. | `gh pr close`, `gh pr reopen` | |
| 60 | 62 | | `mergePullRequest` | Merges with `MERGE`, `SQUASH` or `REBASE`, if nothing blocks it. | `gh pr merge` | |
| 61 | 63 | | `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` | | |
| 62 | 67 | |
| 63 | 68 | ## Fields Diffinery doesn't have |
| 64 | 69 | |
| @@ -94,4 +99,4 @@ | ||
| 94 | 99 | | Page size (`first`, `last`) | 100 | |
| 95 | 100 | | Repositories in one query | 25 | |
| 96 | 101 | |
| 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 @@ | ||
| 76 | 76 | api.get("/", apiRootJson); |
| 77 | 77 | // Imported when used: web/indexnow.ts reaches the docs, whose API reference imports this module. |
| 78 | 78 | 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) })); | |
| 80 | 87 | /** Site admin: the newest unexpected server errors (lib/server-errors.ts), and one by the request id its 5xx answer carried. */ |
| 81 | 88 | api.get("/diffinery/admin/errors", async (c) => { |
| 82 | 89 | 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 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import { mcpChallenge } from "../auth/oauth.js"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { HttpError } from "../lib/util.js"; |
| 6 | 6 | import { errorRecord, recordServerError, requestId } from "../lib/server-errors.js"; |
| 7 | 7 | import { movedPath, movedStatus } from "../lib/redirects.js"; |
| @@ -58,7 +58,7 @@ | ||
| 58 | 58 | /** Paths whose reads show issues, pull requests or their comments (the web pages and the REST API). */ |
| 59 | 59 | const CONVERSATION_PATH = /\/(?:issues|pulls?|comments)(?:\/|$)/; |
| 60 | 60 | |
| 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 }> { | |
| 62 | 62 | const dir = c.get("dir"); |
| 63 | 63 | const anonymous = c.get("principal").type === "anonymous"; |
| 64 | 64 | const reading = c.req.method === "GET" || c.req.method === "HEAD" || (opts.readOnlyPost && c.req.method === "POST") || c.get("readOnly") === true; |
| @@ -90,7 +90,8 @@ | ||
| 90 | 90 | if (caller.permissions) { |
| 91 | 91 | // A workflow's GITHUB_TOKEN: each kind of endpoint needs its permission (issues, contents, ...). |
| 92 | 92 | // 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); | |
| 94 | 95 | const reason = denied(caller.permissions as JobPermissions, req, isAppCaller(caller) ? "app" : "job"); |
| 95 | 96 | if (reason && !(repo.visibility === "public" && readOnly(req))) throw new HttpError(403, reason); |
| 96 | 97 | } |
modifiedapps/diffinery/src/http/graphql-schema.ts+107−10View file
| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | * /api/graphql. Names, types and enum values are GitHub's, so the GitHub CLI and tools built on it |
| 4 | 4 | * (Claude Code's pull request badge, `gh pr view`, `gh pr checks`, `gh pr list`, `gh pr status`, |
| 5 | 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. | |
| 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. | |
| 8 | 9 | */ |
| 9 | 10 | export const SCHEMA_SDL = /* GraphQL */ ` |
| 10 | 11 | """Ids from the REST API, which can be wider than GraphQL's 32-bit Int (GitHub types these as Int).""" |
| @@ -22,7 +23,9 @@ | ||
| 22 | 23 | addComment(input: AddCommentInput!): AddCommentPayload |
| 23 | 24 | addLabelsToLabelable(input: AddLabelsToLabelableInput!): AddLabelsToLabelablePayload |
| 24 | 25 | closePullRequest(input: ClosePullRequestInput!): ClosePullRequestPayload |
| 26 | + closeIssue(input: CloseIssueInput!): CloseIssuePayload | |
| 25 | 27 | convertPullRequestToDraft(input: ConvertPullRequestToDraftInput!): ConvertPullRequestToDraftPayload |
| 28 | + createIssue(input: CreateIssueInput!): CreateIssuePayload | |
| 26 | 29 | createPullRequest(input: CreatePullRequestInput!): CreatePullRequestPayload |
| 27 | 30 | disablePullRequestAutoMerge(input: DisablePullRequestAutoMergeInput!): DisablePullRequestAutoMergePayload |
| 28 | 31 | enablePullRequestAutoMerge(input: EnablePullRequestAutoMergeInput!): EnablePullRequestAutoMergePayload |
| @@ -30,11 +33,44 @@ | ||
| 30 | 33 | mergePullRequest(input: MergePullRequestInput!): MergePullRequestPayload |
| 31 | 34 | removeAssigneesFromAssignable(input: RemoveAssigneesFromAssignableInput!): RemoveAssigneesFromAssignablePayload |
| 32 | 35 | removeLabelsFromLabelable(input: RemoveLabelsFromLabelableInput!): RemoveLabelsFromLabelablePayload |
| 36 | + reopenIssue(input: ReopenIssueInput!): ReopenIssuePayload | |
| 33 | 37 | reopenPullRequest(input: ReopenPullRequestInput!): ReopenPullRequestPayload |
| 34 | 38 | requestReviews(input: RequestReviewsInput!): RequestReviewsPayload |
| 39 | + updateIssue(input: UpdateIssueInput!): UpdateIssuePayload | |
| 35 | 40 | updatePullRequest(input: UpdatePullRequestInput!): UpdatePullRequestPayload |
| 36 | 41 | } |
| 37 | 42 | |
| 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 | + | |
| 38 | 74 | input CreatePullRequestInput { |
| 39 | 75 | baseRefName: String! |
| 40 | 76 | body: String |
| @@ -102,13 +138,13 @@ | ||
| 102 | 138 | type AddCommentPayload { clientMutationId: String, commentEdge: IssueCommentEdge, subject: Node } |
| 103 | 139 | |
| 104 | 140 | input AddLabelsToLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! } |
| 105 | -type AddLabelsToLabelablePayload { clientMutationId: String, labelable: PullRequest } | |
| 141 | +type AddLabelsToLabelablePayload { clientMutationId: String, labelable: Labelable } | |
| 106 | 142 | input RemoveLabelsFromLabelableInput { clientMutationId: String, labelableId: ID!, labelIds: [ID!]! } |
| 107 | -type RemoveLabelsFromLabelablePayload { clientMutationId: String, labelable: PullRequest } | |
| 143 | +type RemoveLabelsFromLabelablePayload { clientMutationId: String, labelable: Labelable } | |
| 108 | 144 | input AddAssigneesToAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! } |
| 109 | -type AddAssigneesToAssignablePayload { clientMutationId: String, assignable: PullRequest } | |
| 145 | +type AddAssigneesToAssignablePayload { clientMutationId: String, assignable: Assignable } | |
| 110 | 146 | input RemoveAssigneesFromAssignableInput { clientMutationId: String, assignableId: ID!, assigneeIds: [ID!]! } |
| 111 | -type RemoveAssigneesFromAssignablePayload { clientMutationId: String, assignable: PullRequest } | |
| 147 | +type RemoveAssigneesFromAssignablePayload { clientMutationId: String, assignable: Assignable } | |
| 112 | 148 | |
| 113 | 149 | input RequestReviewsInput { |
| 114 | 150 | clientMutationId: String |
| @@ -128,15 +164,15 @@ | ||
| 128 | 164 | user(login: String!): User |
| 129 | 165 | "Request budget, reported as GitHub does. Diffinery limits requests per account rather than by query cost." |
| 130 | 166 | 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." | |
| 132 | 168 | node(id: ID!): Node |
| 133 | 169 | 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." | |
| 135 | 171 | search(query: String!, type: SearchType!, first: Int, last: Int, after: String, before: String): SearchResultItemConnection! |
| 136 | 172 | } |
| 137 | 173 | |
| 138 | 174 | enum SearchType { ISSUE ISSUE_ADVANCED REPOSITORY USER DISCUSSION } |
| 139 | -union SearchResultItem = PullRequest | |
| 175 | +union SearchResultItem = Issue | PullRequest | |
| 140 | 176 | type SearchResultItemEdge { cursor: String!, node: SearchResultItem } |
| 141 | 177 | type SearchResultItemConnection { |
| 142 | 178 | edges: [SearchResultItemEdge] |
| @@ -173,6 +209,14 @@ | ||
| 173 | 209 | |
| 174 | 210 | interface Node { id: ID! } |
| 175 | 211 | |
| 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 | + | |
| 176 | 220 | interface Actor { |
| 177 | 221 | avatarUrl(size: Int): URI! |
| 178 | 222 | login: String! |
| @@ -232,6 +276,19 @@ | ||
| 232 | 276 | enum RepositoryVisibility { PRIVATE PUBLIC INTERNAL } |
| 233 | 277 | enum PullRequestState { OPEN CLOSED MERGED } |
| 234 | 278 | 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 | +} | |
| 235 | 292 | enum OrderDirection { ASC DESC } |
| 236 | 293 | enum IssueOrderField { CREATED_AT UPDATED_AT COMMENTS } |
| 237 | 294 | input IssueOrder { field: IssueOrderField!, direction: OrderDirection! } |
| @@ -266,6 +323,9 @@ | ||
| 266 | 323 | id: ID! |
| 267 | 324 | isArchived: Boolean! |
| 268 | 325 | 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! | |
| 269 | 329 | isFork: Boolean! |
| 270 | 330 | isInOrganization: Boolean! |
| 271 | 331 | isLocked: Boolean! |
| @@ -322,7 +382,44 @@ | ||
| 322 | 382 | } |
| 323 | 383 | type PullRequestEdge { cursor: String!, node: PullRequest } |
| 324 | 384 | |
| 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 { | |
| 326 | 423 | additions: Int! |
| 327 | 424 | assignees(first: Int, last: Int, after: String, before: String): UserConnection! |
| 328 | 425 | author: Actor |
modifiedapps/diffinery/src/http/graphql.ts+349−53View file
| @@ -8,6 +8,10 @@ | ||
| 8 | 8 | * statusCheckRollup`), so with this endpoint a Diffinery repository gets the same pull request and |
| 9 | 9 | * CI chip a GitHub one does. See /docs/api/graphql. |
| 10 | 10 | * |
| 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 | + * | |
| 11 | 15 | * Execution is lenient where GitHub is strict: a field Diffinery doesn't have is left out of the |
| 12 | 16 | * answer (and named in `extensions.unsupported`) instead of failing the whole query, because gh |
| 13 | 17 | * asks for many fields at once and most commands need only a few of them. |
| @@ -34,8 +38,10 @@ | ||
| 34 | 38 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 35 | 39 | import type { Account, Repo } from "../lib/directory.js"; |
| 36 | 40 | 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"; | |
| 37 | 43 | 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"; | |
| 39 | 45 | import type { Check, Issue, Review } from "../repo/collab.js"; |
| 40 | 46 | import type { CommitStatus } from "../repo/statuses.js"; |
| 41 | 47 | import { hiddenAuthors, loadRepo, repoStub } from "./common.js"; |
| @@ -134,6 +140,50 @@ | ||
| 134 | 140 | }; |
| 135 | 141 | } |
| 136 | 142 | |
| 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 | + | |
| 137 | 187 | // ---------------------------------------------------------------- people |
| 138 | 188 | |
| 139 | 189 | type Person = { id: string; login: string; kind?: string | null; name?: string | null }; |
| @@ -171,11 +221,26 @@ | ||
| 171 | 221 | |
| 172 | 222 | type Loaded = { repo: Repo; caller: Caller; stub: Stub }; |
| 173 | 223 | |
| 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 | + | |
| 174 | 238 | async function loadRepository(ctx: Ctx, owner: string, name: string): Promise<Loaded | null> { |
| 175 | 239 | return once(ctx, `repo:${owner.toLowerCase()}/${name.toLowerCase()}`, async () => { |
| 176 | 240 | 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 | 241 | 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 } }); | |
| 179 | 244 | return { repo, caller, stub }; |
| 180 | 245 | } catch (e) { |
| 181 | 246 | const s = statusOf(e); |
| @@ -266,30 +331,60 @@ | ||
| 266 | 331 | assignableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), |
| 267 | 332 | mentionableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), |
| 268 | 333 | labels: async (args: Args) => { |
| 334 | + need(l, ["issues", "pull-requests"]); | |
| 269 | 335 | const q = typeof args.query === "string" ? args.query.toLowerCase() : ""; |
| 270 | 336 | const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows()); |
| 271 | 337 | return connection(rows.filter((r) => !q || r.name.toLowerCase().includes(q)).map((r) => labelObj(l, r.name, r)), args); |
| 272 | 338 | }, |
| 273 | 339 | label: async (args: Args) => { |
| 340 | + need(l, ["issues", "pull-requests"]); | |
| 274 | 341 | const rows = await once(ctx, `labels:${repo.id}`, () => stub.labelRows()); |
| 275 | 342 | const r = rows.find((x) => x.name.toLowerCase() === String(args.name).toLowerCase()); |
| 276 | 343 | return r ? labelObj(l, r.name, r) : null; |
| 277 | 344 | }, |
| 278 | 345 | milestones: async (args: Args) => { |
| 346 | + need(l, ["issues", "pull-requests"]); | |
| 279 | 347 | const states = Array.isArray(args.states) ? (args.states as string[]) : []; |
| 280 | 348 | const list = await stub.milestones(states.length === 1 ? (states[0] === "CLOSED" ? "closed" : "open") : "all"); |
| 281 | 349 | return connection(list.map((m) => milestoneObj(l, m)), args); |
| 282 | 350 | }, |
| 283 | 351 | pullRequest: async (args: Args) => { |
| 352 | + need(l, ["pull-requests"]); | |
| 284 | 353 | 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); | |
| 289 | 355 | if (!issue || issue.kind !== "pull") throw notFound(`Could not resolve to a PullRequest with the number of ${n}.`); |
| 290 | 356 | return pullRequest(ctx, l, issue, self); |
| 291 | 357 | }, |
| 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 | + }, | |
| 292 | 386 | pullRequests: async (args: Args) => { |
| 387 | + need(l, ["pull-requests"]); | |
| 293 | 388 | pageSize(args); |
| 294 | 389 | const states = (args.states as string[] | null | undefined) ?? []; |
| 295 | 390 | const want = new Set(states); |
| @@ -299,8 +394,7 @@ | ||
| 299 | 394 | const sort = order?.field === "UPDATED_AT" ? "updated" : order?.field === "COMMENTS" ? "comments" : "created"; |
| 300 | 395 | // GitHub lists oldest first unless asked otherwise. |
| 301 | 396 | 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) }); | |
| 304 | 398 | // "closed" holds both closed and merged pull requests: keep only the states asked for. |
| 305 | 399 | if (want.size && state !== "all") items = items.filter((i) => want.has(pullState(i))); |
| 306 | 400 | else if (want.size && !(want.has("CLOSED") && want.has("MERGED"))) items = items.filter((i) => want.has(pullState(i))); |
| @@ -318,17 +412,128 @@ | ||
| 318 | 412 | const issueOf = (ctx: Ctx, l: Loaded, n: number): Promise<Issue> => |
| 319 | 413 | once(ctx, `issue:${l.repo.id}:${n}`, async () => (await l.stub.issue(n, await hiddenAuthors(ctx.c))).issue); |
| 320 | 414 | |
| 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 | + | |
| 321 | 421 | const pullState = (i: Issue): string => (i.pull?.merged ? "MERGED" : i.state === "open" ? "OPEN" : "CLOSED"); |
| 322 | 422 | |
| 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 | +} | |
| 324 | 467 | |
| 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 | + | |
| 325 | 530 | function pullRequest(ctx: Ctx, l: Loaded, i: Issue, repoObj: () => Obj): Obj { |
| 326 | 531 | const { repo, stub, caller } = l; |
| 327 | 532 | const p = i.pull!; |
| 328 | 533 | const html = repoHtml(ctx.base, repo.owner_login, repo.name); |
| 329 | 534 | const url = `${html}/pull/${i.number}`; |
| 330 | 535 | 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>; | |
| 332 | 537 | const stats = () => once(ctx, `stats:${key}`, () => stub.pullStats(i.number)); |
| 333 | 538 | const review = () => once(ctx, `review:${key}`, () => stub.reviewState(i.number)); |
| 334 | 539 | const merge = () => once(ctx, `merge:${key}`, () => stub.mergeability(i.number, caller)); |
| @@ -350,7 +555,7 @@ | ||
| 350 | 555 | 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 | 556 | }, |
| 352 | 557 | }; |
| 353 | - const assoc = (login: string) => (login.toLowerCase() === repo.owner_login.toLowerCase() ? "OWNER" : "NONE"); | |
| 558 | + const assoc = (login: string) => association(l, login); | |
| 354 | 559 | 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 | 560 | return { |
| 356 | 561 | __typename: "PullRequest", |
| @@ -442,13 +647,7 @@ | ||
| 442 | 647 | for (const r of (await full()).reviews) if (r.state !== "COMMENTED" || !latest.has(r.author.id)) latest.set(r.author.id, r); |
| 443 | 648 | return connection([...latest.values()].map(reviewObj), args); |
| 444 | 649 | }, |
| 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), | |
| 452 | 651 | files: async (args: Args) => { |
| 453 | 652 | const res = await once(ctx, `files:${key}`, () => stub.pullFiles(i.number)); |
| 454 | 653 | const change: Record<string, string> = { added: "ADDED", deleted: "DELETED", modified: "MODIFIED" }; |
| @@ -630,11 +829,11 @@ | ||
| 630 | 829 | } |
| 631 | 830 | } |
| 632 | 831 | |
| 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. */ | |
| 634 | 833 | async function nodeById(ctx: Ctx, id: string): Promise<Obj | null> { |
| 635 | 834 | 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("/"); | |
| 638 | 837 | const row = await ctx.c.get("dir").repoById(repoId!); |
| 639 | 838 | if (!row) return null; |
| 640 | 839 | const l = await loadRepository(ctx, row.owner_login, row.name); |
| @@ -642,39 +841,75 @@ | ||
| 642 | 841 | const repoObj = repository(ctx, l); |
| 643 | 842 | if (d.type === "Repository") return repoObj; |
| 644 | 843 | 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); | |
| 646 | 847 | } |
| 647 | 848 | |
| 648 | 849 | /** |
| 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`). | |
| 651 | 855 | */ |
| 652 | 856 | async function search(ctx: Ctx, args: Args): Promise<Obj> { |
| 653 | 857 | pageSize(args); |
| 654 | 858 | const empty = () => ({ ...connection([], args), issueCount: 0, repositoryCount: 0, userCount: 0 }); |
| 655 | 859 | if (args.type !== "ISSUE" && args.type !== "ISSUE_ADVANCED") return empty(); |
| 656 | 860 | 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 | + } | |
| 658 | 867 | const viewer = ctx.c.get("principal"); |
| 659 | 868 | const me = viewer.type === "account" ? viewer.account.login : ""; |
| 660 | 869 | const who = (v: string | undefined) => (v === "@me" ? me : v); |
| 661 | 870 | 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(); | |
| 663 | 872 | const [owner, name] = full.split("/"); |
| 664 | 873 | const l = owner && name ? await loadRepository(ctx, owner, name) : null; |
| 665 | 874 | 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))]); | |
| 667 | 879 | 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 | + }); | |
| 669 | 902 | const reviewer = who(q["review-requested"]?.[0])?.toLowerCase(); |
| 670 | - const items = res.items.filter((i) => { | |
| 903 | + const items = all.filter((i) => { | |
| 671 | 904 | if (states.has("merged") && !states.has("closed") && !i.pull?.merged) return false; |
| 672 | 905 | if (q.draft?.[0] && String(!!i.pull?.draft) !== q.draft[0]) return false; |
| 673 | 906 | 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)); | |
| 675 | 910 | }); |
| 676 | 911 | 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 }; | |
| 678 | 913 | } |
| 679 | 914 | |
| 680 | 915 | function root(ctx: Ctx): Obj { |
| @@ -709,10 +944,13 @@ | ||
| 709 | 944 | |
| 710 | 945 | // ---------------------------------------------------------------- mutations (the pull request ones gh uses) |
| 711 | 946 | |
| 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> { | |
| 714 | 952 | 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 }); | |
| 716 | 954 | return { repo, caller, stub }; |
| 717 | 955 | } catch (e) { |
| 718 | 956 | if (statusOf(e)?.status === 404) throw notFound(`Could not resolve to a Repository with the name '${owner}/${name}'.`); |
| @@ -720,26 +958,46 @@ | ||
| 720 | 958 | } |
| 721 | 959 | } |
| 722 | 960 | |
| 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> { | |
| 724 | 962 | const d = decodeNodeId(String(id)); |
| 725 | 963 | const row = d?.type === "Repository" ? await ctx.c.get("dir").repoById(d.key) : null; |
| 726 | 964 | 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); | |
| 728 | 966 | } |
| 729 | 967 | |
| 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 }> { | |
| 731 | 974 | 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("/") : []; | |
| 733 | 977 | const row = repoId ? await ctx.c.get("dir").repoById(repoId) : null; |
| 734 | 978 | 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) }; | |
| 736 | 984 | } |
| 737 | 985 | |
| 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); | |
| 741 | 991 | } |
| 742 | 992 | |
| 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 | + | |
| 743 | 1001 | /** The pull request as it is after a change (this request's cached copies are dropped). */ |
| 744 | 1002 | async function freshPull(ctx: Ctx, l: Loaded, number: number): Promise<Obj> { |
| 745 | 1003 | 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 @@ | ||
| 805 | 1063 | return { |
| 806 | 1064 | createPullRequest: async (args: Args) => { |
| 807 | 1065 | 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")); | |
| 809 | 1067 | await creating(ctx); |
| 810 | 1068 | const head = String(inp.headRefName ?? ""); |
| 811 | 1069 | 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 @@ | ||
| 851 | 1109 | }, |
| 852 | 1110 | enablePullRequestAutoMerge: async (args: Args) => { |
| 853 | 1111 | 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")); | |
| 855 | 1113 | await l.stub.enableAutoMerge(number, { method: method(inp.mergeMethod), commitTitle: str(inp.commitHeadline), commitMessage: str(inp.commitBody), expectedHeadSha: str(inp.expectedHeadOid) }, l.caller); |
| 856 | 1114 | return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); |
| 857 | 1115 | }, |
| 858 | 1116 | disablePullRequestAutoMerge: async (args: Args) => { |
| 859 | 1117 | 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")); | |
| 861 | 1119 | await l.stub.disableAutoMerge(number, l.caller); |
| 862 | 1120 | return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); |
| 863 | 1121 | }, |
| 864 | 1122 | mergePullRequest: async (args: Args) => { |
| 865 | 1123 | 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")); | |
| 867 | 1125 | 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 | 1126 | return payload(inp, { pullRequest: await freshPull(ctx, l, number), actor: root(ctx).viewer }); |
| 869 | 1127 | }, |
| 870 | 1128 | addComment: async (args: Args) => { |
| 871 | 1129 | 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") }); | |
| 873 | 1131 | const body = String(inp.body ?? ""); |
| 874 | 1132 | if (!body.trim()) throw new GraphQLError("Body can't be blank.", { extensions: { type: "UNPROCESSABLE" } }); |
| 875 | 1133 | await creating(ctx); |
| 876 | 1134 | const cm = await l.stub.addComment(number, body, l.caller); |
| 877 | 1135 | const html = repoHtml(ctx.base, l.repo.owner_login, l.repo.name); |
| 878 | 1136 | 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) }); | |
| 881 | 1139 | }, |
| 882 | 1140 | addLabelsToLabelable: async (args: Args) => { |
| 883 | 1141 | 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") }); | |
| 885 | 1143 | const current = await issueOf(ctx, l, number); |
| 886 | 1144 | const add = await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : []); |
| 887 | 1145 | await l.stub.updateIssue(number, { labels: [...new Set([...current.labels, ...add])] }, l.caller); |
| @@ -889,7 +1147,7 @@ | ||
| 889 | 1147 | }, |
| 890 | 1148 | removeLabelsFromLabelable: async (args: Args) => { |
| 891 | 1149 | 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") }); | |
| 893 | 1151 | const current = await issueOf(ctx, l, number); |
| 894 | 1152 | const drop = new Set(await labelNamesFor(ctx, l, Array.isArray(inp.labelIds) ? inp.labelIds : [])); |
| 895 | 1153 | await l.stub.updateIssue(number, { labels: current.labels.filter((x) => !drop.has(x)) }, l.caller); |
| @@ -897,19 +1155,57 @@ | ||
| 897 | 1155 | }, |
| 898 | 1156 | addAssigneesToAssignable: async (args: Args) => { |
| 899 | 1157 | 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") }); | |
| 901 | 1159 | const current = await issueOf(ctx, l, number); |
| 902 | 1160 | await l.stub.updateIssue(number, { assignees: [...new Set([...current.assignees, ...(await loginsFor(ctx, inp.assigneeIds))])] }, l.caller); |
| 903 | 1161 | return payload(inp, { assignable: await labelable(ctx, l, number) }); |
| 904 | 1162 | }, |
| 905 | 1163 | removeAssigneesFromAssignable: async (args: Args) => { |
| 906 | 1164 | 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") }); | |
| 908 | 1166 | const current = await issueOf(ctx, l, number); |
| 909 | 1167 | const drop = new Set(await loginsFor(ctx, inp.assigneeIds)); |
| 910 | 1168 | await l.stub.updateIssue(number, { assignees: current.assignees.filter((x) => !drop.has(x)) }, l.caller); |
| 911 | 1169 | return payload(inp, { assignable: await labelable(ctx, l, number) }); |
| 912 | 1170 | }, |
| 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 | + }, | |
| 913 | 1209 | requestReviews: async (args: Args) => { |
| 914 | 1210 | const inp = input(args); |
| 915 | 1211 | 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 @@ | ||
| 260 | 260 | q?: string; |
| 261 | 261 | // GitHub list filters (REST `issues` and `pulls` query parameters). |
| 262 | 262 | labels?: string[]; |
| 263 | + /** Any of these labels (GraphQL's `labels`); `labels` needs all of them (REST's). */ | |
| 264 | + anyLabels?: string[]; | |
| 263 | 265 | assignee?: string; |
| 266 | + /** Mentioned as @login in the body or a comment (GraphQL's `filterBy.mentioned`, search's `mentions:`). */ | |
| 267 | + mentioned?: string; | |
| 264 | 268 | milestone?: number | "none" | "*"; |
| 265 | 269 | since?: string; |
| 266 | 270 | head?: string; |
| @@ -301,6 +305,20 @@ | ||
| 301 | 305 | where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)"); |
| 302 | 306 | args.push(l); |
| 303 | 307 | } |
| 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 | + } | |
| 304 | 322 | if (opts.assignee === "none") where.push("assignees = '[]'"); |
| 305 | 323 | else if (opts.assignee === "*") where.push("assignees != '[]'"); |
| 306 | 324 | else if (opts.assignee) { |
modifiedapps/diffinery/test/e2e/graphql.test.mjs+141−1View file
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | // the queries Claude Code sends to show a session's pull request and its CI (the badge and the |
| 3 | 3 | // check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull |
| 4 | 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 | |
| 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 | |
| 6 | 8 | // GitHub's error shape. |
| 7 | 9 | // |
| 8 | 10 | // pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs |
| @@ -221,6 +223,144 @@ | ||
| 221 | 223 | assert.equal(p.merged, true); |
| 222 | 224 | }); |
| 223 | 225 | |
| 226 | +// gh's issue fields (api.IssueGraphQL for `gh issue list` and `gh issue view`), as gh 2.89 sends them. | |
| 227 | +const ISSUE_FIELDS = `number,title,url,state,stateReason,createdAt,updatedAt,closedAt,isPinned,author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount},labels(first:100){nodes{id,name,description,color},totalCount},milestone{number,title,description,dueOn},comments(first:100){nodes{id,author{login,...on User{id,name}},authorAssociation,body,createdAt,includesCreatedEdit,isMinimized,minimizedReason,reactionGroups{content,users{totalCount}},url,viewerDidAuthor},pageInfo{hasNextPage,endCursor},totalCount},reactionGroups{content,users{totalCount}},body,projectCards(first:100){nodes{project{name}column{name}},totalCount}`; | |
| 228 | +const ISSUE_LIST = `fragment issue on Issue {${ISSUE_FIELDS}} | |
| 229 | +query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String) { | |
| 230 | + repository(owner: $owner, name: $repo) { | |
| 231 | + hasIssuesEnabled | |
| 232 | + issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention}) { | |
| 233 | + totalCount nodes { ...issue } pageInfo { hasNextPage endCursor } | |
| 234 | + } | |
| 235 | + } | |
| 236 | +}`; | |
| 237 | +const ISSUE_BY_NUMBER = `query IssueByNumber($owner: String!, $repo: String!, $number: Int!) { | |
| 238 | + repository(owner: $owner, name: $repo) { | |
| 239 | + hasIssuesEnabled | |
| 240 | + issue: issueOrPullRequest(number: $number) { __typename ...on Issue{id,${ISSUE_FIELDS}} ...on PullRequest{id,number,title,state,url} } | |
| 241 | + } | |
| 242 | +}`; | |
| 243 | + | |
| 244 | +test("gh issue create, list, view, comment, edit, close, reopen, status and list --search", async () => { | |
| 245 | + // gh issue create: the repository's id (RepositoryInfo), label ids, then createIssue. | |
| 246 | + const labels = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 100) { nodes { id name } } } }`)).repository.labels.nodes; | |
| 247 | + await rest("POST", "/repos/ann/app/labels", { name: "bug", color: "d73a4a" }); | |
| 248 | + const bug = (await data(`query { repository(owner: "ann", name: "app") { label(name: "bug") { id } } }`)).repository.label.id; | |
| 249 | + assert.ok(labels.some((l) => l.name === "docs")); | |
| 250 | + const ann = (await data(`query { repository(owner: "ann", name: "app") { assignableUsers(first: 10) { nodes { id login } } } }`)).repository.assignableUsers.nodes.find((u) => u.login === "ann"); | |
| 251 | + const create = (input) => data(`mutation IssueCreate($input: CreateIssueInput!) { createIssue(input: $input) { issue { id url } } }`, { input }); | |
| 252 | + const first = (await create({ repositoryId: repoId, title: "Crash on start", body: "It crashes when @bob opens it.", labelIds: [bug], assigneeIds: [ann.id] })).createIssue.issue; | |
| 253 | + const second = (await create({ repositoryId: repoId, title: "Docs typo", body: "A typo." })).createIssue.issue; | |
| 254 | + const third = (await create({ repositoryId: repoId, title: "Old idea", body: "Later." })).createIssue.issue; | |
| 255 | + const n = (issue) => Number(issue.url.split("/").pop()); | |
| 256 | + assert.match(first.url, new RegExp(`^${base}/ann/app/issues/\\d+$`)); | |
| 257 | + assert.ok(n(first) > 1, "issues share numbers with pull requests"); | |
| 258 | + | |
| 259 | + // gh issue list: newest first, a page at a time, without the pull request. | |
| 260 | + let page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2 })).repository; | |
| 261 | + assert.equal(page.hasIssuesEnabled, true); | |
| 262 | + assert.equal(page.issues.totalCount, 3); | |
| 263 | + assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(third), n(second)]); | |
| 264 | + assert.equal(page.issues.pageInfo.hasNextPage, true); | |
| 265 | + page = (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 2, endCursor: page.issues.pageInfo.endCursor })).repository; | |
| 266 | + assert.deepEqual(page.issues.nodes.map((i) => i.number), [n(first)]); | |
| 267 | + assert.equal(page.issues.pageInfo.hasNextPage, false); | |
| 268 | + const listed = page.issues.nodes[0]; | |
| 269 | + assert.equal(listed.state, "OPEN"); | |
| 270 | + assert.equal(listed.author.login, "ann"); | |
| 271 | + assert.deepEqual(listed.labels.nodes.map((l) => l.name), ["bug"]); | |
| 272 | + assert.deepEqual(listed.assignees.nodes.map((u) => u.login), ["ann"]); | |
| 273 | + // --assignee, --author and --mention. | |
| 274 | + const only = async (vars) => (await data(ISSUE_LIST, { owner: "ann", repo: "app", limit: 30, ...vars })).repository.issues.nodes.map((i) => i.number); | |
| 275 | + assert.deepEqual(await only({ assignee: "ann" }), [n(first)]); | |
| 276 | + assert.deepEqual(await only({ author: "ann" }), [n(third), n(second), n(first)]); | |
| 277 | + assert.deepEqual(await only({ mention: "bob" }), [n(first)]); | |
| 278 | + assert.deepEqual(await only({ mention: "bo" }), []); | |
| 279 | + | |
| 280 | + // gh issue view: issueOrPullRequest, then the issue's project items (none). | |
| 281 | + let view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(first) })).repository.issue; | |
| 282 | + assert.equal(view.__typename, "Issue"); | |
| 283 | + assert.equal(view.id, first.id); | |
| 284 | + assert.equal(view.body, "It crashes when @bob opens it."); | |
| 285 | + const pr = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: 1 })).repository.issue; | |
| 286 | + assert.equal(pr.__typename, "PullRequest"); | |
| 287 | + const items = await data(`query IssueProjectItems($endCursor: String, $name: String!, $number: Int!, $owner: String!) { repository(owner: $owner, name: $name) { issue(number: $number) { projectItems(first: 100, after: $endCursor) { totalCount nodes { id project { id title } status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { optionId name } } } pageInfo { hasNextPage endCursor } } } } }`, { owner: "ann", name: "app", number: n(first) }); | |
| 288 | + assert.equal(items.repository.issue.projectItems.totalCount, 0); | |
| 289 | + const missing = await gql(`query { repository(owner: "ann", name: "app") { issue(number: 1) { id } } }`); | |
| 290 | + assert.equal(missing.body.errors[0].type, "NOT_FOUND", "a pull request's number is no issue"); | |
| 291 | + | |
| 292 | + // gh issue comment, gh issue edit (title, body, labels), gh issue close and reopen. | |
| 293 | + const comment = await data(`mutation CommentCreate($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: first.id, body: "Same here, @bob." } }); | |
| 294 | + assert.match(comment.addComment.commentEdge.node.url, new RegExp(`/ann/app/issues/${n(first)}#issuecomment-`)); | |
| 295 | + await data(`mutation IssueUpdate($input: UpdateIssueInput!) { updateIssue(input: $input) { __typename } }`, { input: { id: second.id, title: "Docs: fix a typo", body: "In the README." } }); | |
| 296 | + const docs = labels.find((l) => l.name === "docs"); | |
| 297 | + await data(`mutation LabelAdd($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { __typename } }`, { input: { labelableId: second.id, labelIds: [docs.id] } }); | |
| 298 | + await data(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id, stateReason: "NOT_PLANNED" } }); | |
| 299 | + view = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(second) })).repository.issue; | |
| 300 | + assert.equal(view.title, "Docs: fix a typo"); | |
| 301 | + assert.equal(view.body, "In the README."); | |
| 302 | + assert.deepEqual(view.labels.nodes.map((l) => l.name), ["docs"]); | |
| 303 | + const closed = (await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue; | |
| 304 | + assert.deepEqual([closed.state, closed.stateReason], ["CLOSED", "NOT_PLANNED"]); | |
| 305 | + assert.deepEqual(await only({}), [n(second), n(first)], "open issues by default"); | |
| 306 | + assert.deepEqual(await only({ states: ["CLOSED"] }), [n(third)]); | |
| 307 | + await data(`mutation IssueReopen($input: ReopenIssueInput!) { reopenIssue(input: $input) { issue { id } } }`, { input: { issueId: third.id } }); | |
| 308 | + assert.equal((await data(ISSUE_BY_NUMBER, { owner: "ann", repo: "app", number: n(third) })).repository.issue.state, "OPEN"); | |
| 309 | + // An issue's id isn't a pull request's: GitHub's pull request mutations refuse it. | |
| 310 | + const wrong = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: first.id }); | |
| 311 | + assert.equal(wrong.body.errors[0].type, "NOT_FOUND"); | |
| 312 | + // A read token can look but not open or close issues. | |
| 313 | + const refused = await gql(`mutation IssueClose($input: CloseIssueInput!) { closeIssue(input: $input) { issue { id } } }`, { input: { issueId: first.id } }, T.annRead); | |
| 314 | + assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body)); | |
| 315 | + | |
| 316 | + // gh issue status: three filtered lists at once. | |
| 317 | + const status = await data( | |
| 318 | + `fragment issue on Issue{number,title} query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { hasIssuesEnabled assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } } }`, | |
| 319 | + { owner: "ann", repo: "app", viewer: "ann" }, | |
| 320 | + ); | |
| 321 | + assert.deepEqual([status.repository.assigned.totalCount, status.repository.mentioned.totalCount, status.repository.authored.totalCount], [1, 0, 3]); | |
| 322 | + | |
| 323 | + // gh issue list --label/--search: GitHub's issue search, in the advanced syntax gh writes. | |
| 324 | + const search = async (query) => (await data(`fragment issue on Issue {number,title} query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { repository(name: $repo, owner: $owner) { hasIssuesEnabled } search(type: $type, last: $limit, after: $after, query: $query) { issueCount nodes { ...issue } pageInfo { hasNextPage endCursor } } }`, { owner: "ann", repo: "app", type: "ISSUE_ADVANCED", limit: 30, query })).search; | |
| 325 | + assert.deepEqual((await search(`repo:ann/app state:open type:issue label:bug`)).nodes.map((i) => i.number), [n(first)]); | |
| 326 | + assert.deepEqual((await search(`( crash ) repo:ann/app type:issue`)).nodes.map((i) => i.number), [n(first)]); | |
| 327 | + assert.equal((await search(`repo:ann/app type:issue`)).issueCount, 3); | |
| 328 | + assert.equal((await search(`repo:ann/app is:pr`)).issueCount, 1); | |
| 329 | +}); | |
| 330 | + | |
| 331 | +test("a workflow's GITHUB_TOKEN uses GraphQL with its permissions, as over REST", async () => { | |
| 332 | + const workflow = "name: gh\non: push\npermissions:\n contents: read\n issues: read\njobs:\n gh:\n runs-on: [self-hosted, linux]\n steps:\n - run: gh issue list\n"; | |
| 333 | + await rest("PUT", "/repos/ann/app/contents/.github/workflows/gh.yml", { message: "gh in CI", content: Buffer.from(workflow).toString("base64") }); | |
| 334 | + const reg = await rest("POST", "/repos/ann/app/actions/runners/registration-token"); | |
| 335 | + const runner = await rest("POST", "/repos/ann/app/actions/runners/register", { registration_token: reg.token, name: "graphql-e2e", labels: ["linux"] }); | |
| 336 | + let claim; | |
| 337 | + for (const end = Date.now() + 60_000; !claim; ) { | |
| 338 | + const r = await fetch(`${base}/api/v3/repos/ann/app/actions/runners/claim`, { method: "POST", headers: { authorization: `Bearer ${runner.token}`, "content-type": "application/json" }, body: "{}" }); | |
| 339 | + if (r.status === 200) claim = await r.json(); | |
| 340 | + else if (Date.now() > end) assert.fail(`no job to claim: ${r.status} ${await r.text()}`); | |
| 341 | + else await new Promise((res) => setTimeout(res, 500)); | |
| 342 | + } | |
| 343 | + try { | |
| 344 | + // The job starts (the runner reads its spec): its token gets the workflow's permissions. | |
| 345 | + const spec = await fetch(`${base}/runner/jobs/${claim.job_id}`, { headers: { authorization: `Bearer ${claim.token}` } }); | |
| 346 | + assert.equal(spec.status, 200, await spec.clone().text()); | |
| 347 | + // The repository and its issues: `gh repo view` and `gh issue list` in the job. | |
| 348 | + const d = await data(`query { repository(owner: "ann", name: "app") { nameWithOwner issues(first: 10) { totalCount } } }`, {}, claim.token); | |
| 349 | + assert.equal(d.repository.nameWithOwner, "ann/app"); | |
| 350 | + assert.equal(typeof d.repository.issues.totalCount, "number"); | |
| 351 | + // Pull requests need pull-requests: read, which this job doesn't have. | |
| 352 | + const prs = await gql(`query { repository(owner: "ann", name: "app") { pullRequests(first: 10) { totalCount } } }`, {}, claim.token); | |
| 353 | + assert.equal(prs.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(prs.body)); | |
| 354 | + assert.match(prs.body.errors[0].message, /pull-requests/); | |
| 355 | + // issues: read can't open an issue. | |
| 356 | + const opened = await gql(`mutation($input: CreateIssueInput!) { createIssue(input: $input) { issue { id } } }`, { input: { repositoryId: repoId, title: "from CI" } }, claim.token); | |
| 357 | + assert.equal(opened.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(opened.body)); | |
| 358 | + assert.match(opened.body.errors[0].message, /issues: write/); | |
| 359 | + } finally { | |
| 360 | + await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 361 | + } | |
| 362 | +}); | |
| 363 | + | |
| 224 | 364 | test("sign-in, privacy, limits and GitHub's error shape", async () => { |
| 225 | 365 | const anonymous = await gql(`query { viewer { login } }`, {}, null); |
| 226 | 366 | assert.equal(anonymous.status, 401); |