Merge 062b4c3e2dbe44921a3ce763031743a03b2d49ac into bc4d1baf43da951271a59361559d2c71c78de642
Diffinery authored 2 parents bc4d1ba062b4c3commit 3dc94d14c81ff03a14269521b86b8616288cad29Browse files
12 files changed+451−26
Files (12)
- apps/diffinery/docs/actions/compatibility.md+1−1
- apps/diffinery/docs/collaboration/pull-requests.md+1−1
- apps/diffinery/docs/guides.md+3−2
- apps/diffinery/docs/guides/release-please.md+94−0
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/reference/graphql-api.md+8−4
- apps/diffinery/src/docs/api-meta.ts+1−1
- apps/diffinery/src/http/graphql-schema.ts+41−2
- apps/diffinery/src/http/graphql.ts+143−6
- apps/diffinery/src/repo/RepoDO.ts+22−4
- apps/diffinery/src/repo/social.ts+22−4
- apps/diffinery/test/e2e/graphql.test.mjs+114−1
modifiedapps/diffinery/docs/actions/compatibility.md+1−1View file
| @@ -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)). 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 }}`. | |
| 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, CI, commit history and releases it answers, so `gh` works in a job with `GH_TOKEN: ${{ github.token }}` and `googleapis/release-please-action` runs unchanged (see [release-please](/docs/guides/release-please)). | |
| 302 | 302 | |
| 303 | 303 | ### Actions that use the cache or artifact services |
| 304 | 304 |
modifiedapps/diffinery/docs/collaboration/pull-requests.md+1−1View file
| @@ -911,7 +911,7 @@ | ||
| 911 | 911 | |
| 912 | 912 | To search across repositories with qualifiers, use `GET /search/issues` with `is:pr`, for example `q=repo:acme/website is:pr is:open base:main`. Supported qualifiers are listed in [Issues](/docs/collaboration/issues#search-issues-and-pull-requests). |
| 913 | 913 | |
| 914 | -To find the pull requests a commit belongs to, use `GET /repos/{owner}/{repo}/commits/{sha}/pulls`. It returns pull requests whose head commit or merge commit is that commit, and open pull requests (among the 50 most recently opened) whose head branch contains it. An unknown commit returns `422` with `No commit found for SHA: <sha>`. | |
| 914 | +To find the pull requests a commit belongs to, use `GET /repos/{owner}/{repo}/commits/{sha}/pulls`. It returns pull requests whose head commit or merge commit is that commit and, for a commit that isn't on the default branch, open pull requests (among the 50 most recently opened) whose head branch contains it. As on GitHub, a commit on the default branch lists only the pull request that merged it, not every open pull request that branched off after it. An unknown commit returns `422` with `No commit found for SHA: <sha>`. | |
| 915 | 915 | |
| 916 | 916 | `diffinery pr comment 12 --body "..."` adds a conversation comment. |
| 917 | 917 |
modifiedapps/diffinery/docs/guides.md+3−2View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Guides |
| 3 | -description: Short, task-focused guides to common jobs on Diffinery: moving GitHub Actions workflows, faster CI, AI coding agents with guardrails, and trying Diffinery CI from GitHub. | |
| 4 | -updated: 2026-10-25 | |
| 3 | +description: Short, task-focused guides to common jobs on Diffinery: moving GitHub Actions workflows, faster CI, releases with release-please, AI coding agents with guardrails, and trying Diffinery CI from GitHub. | |
| 4 | +updated: 2026-10-26 | |
| 5 | 5 | --- |
| 6 | 6 | |
| 7 | 7 | Each guide answers one question in a few steps and links to the pages that cover the details. |
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | | --- | --- | |
| 11 | 11 | | [Move your GitHub Actions workflows to Diffinery](/docs/guides/move-github-actions-workflows) | What runs unchanged, runner labels and sizes, marketplace actions, secrets, caches, Docker, OIDC, environments, self-hosted runners for macOS, Windows and Arm, and a checklist. | |
| 12 | 12 | | [Make your CI faster on Diffinery](/docs/guides/faster-ci) | Runner sizes and automatic sizing, caching, setup and Docker head starts, matrices, cancelling superseded runs, and measured numbers. | |
| 13 | +| [Automate versions and releases with release-please](/docs/guides/release-please) | The release-please action and CLI on Diffinery: the workflow, permissions, CI on release pull requests and troubleshooting. | | |
| 13 | 14 | | [Give an AI coding agent its own account, with guardrails](/docs/guides/ai-agent-with-guardrails) | Create an agent and its token, what the server enforces, connecting Claude Code, Codex or any MCP client, reviewing and merging agent pull requests, and auditing what agents did. | |
| 14 | 15 | | [Run Diffinery CI while your code stays on GitHub](/docs/guides/mirror-from-github) | Mirror every GitHub push to Diffinery, copy the secrets your workflows need, and fail on GitHub when Diffinery's runs fail. | |
| 15 | 16 |
addedapps/diffinery/docs/guides/release-please.md+94−0View file
| @@ -1,0 +1,94 @@ | ||
| 1 | +--- | |
| 2 | +title: Automate versions and releases with release-please | |
| 3 | +description: Run release-please on Diffinery with the release-please action or its CLI: the workflow, the permissions and setting it needs, CI on release pull requests, and troubleshooting. | |
| 4 | +updated: 2026-10-26 | |
| 5 | +--- | |
| 6 | + | |
| 7 | +[release-please](https://github.com/googleapis/release-please) reads your Conventional Commits, keeps a release pull request open with the next version and changelog, and creates the tag and release when you merge it. It works on Diffinery as it does on GitHub: the `googleapis/release-please-action@v4` action runs unchanged, and the `release-please` CLI works with Diffinery's API URLs. Diffinery answers the GraphQL queries release-please sends (commit history with each commit's pull request, merged pull requests and releases) and the REST calls it makes (files, git data, pull requests, labels, tags and releases). | |
| 8 | + | |
| 9 | +## Steps | |
| 10 | + | |
| 11 | +1. **Keep your release-please files.** `release-please-config.json` and `.release-please-manifest.json` (or the action's `release-type` input) work as they are. An imported repository already has them. | |
| 12 | +2. **Let workflows open pull requests.** Open the repository's **Settings → Actions** tab, check **Allow GitHub Actions to create and approve pull requests** under **Workflow permissions**, and click **Save**. This needs the admin role. It's off by default, as on GitHub, and without it the action fails with `GitHub Actions is not permitted to create pull requests.` See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#creating-and-approving-pull-requests). | |
| 13 | +3. **Add the workflow.** The same file as on GitHub: | |
| 14 | + | |
| 15 | + ```yaml | |
| 16 | + # .github/workflows/release-please.yml | |
| 17 | + on: | |
| 18 | + push: | |
| 19 | + branches: [main] | |
| 20 | + | |
| 21 | + permissions: | |
| 22 | + contents: write # tags and releases | |
| 23 | + pull-requests: write # the release pull request | |
| 24 | + issues: write # its labels | |
| 25 | + | |
| 26 | + jobs: | |
| 27 | + release-please: | |
| 28 | + runs-on: ubuntu-latest | |
| 29 | + outputs: | |
| 30 | + release_created: ${{ steps.release.outputs.release_created }} | |
| 31 | + tag_name: ${{ steps.release.outputs.tag_name }} | |
| 32 | + steps: | |
| 33 | + - uses: googleapis/release-please-action@v4 | |
| 34 | + id: release | |
| 35 | + with: | |
| 36 | + release-type: node # or leave it out and keep release-please-config.json | |
| 37 | + ``` | |
| 38 | + | |
| 39 | + The action's `github-api-url`, `github-graphql-url` and `changelog-host` inputs default to `${{ github.api_url }}`, `${{ github.graphql_url }}` and `${{ github.server_url }}`, which point at Diffinery, so you don't set them. Changelog links point at your repository on Diffinery. | |
| 40 | +4. **Build and ship from the release.** Later jobs read the outputs, as on GitHub, for example to build and upload a mobile app only when a release was created: | |
| 41 | + | |
| 42 | + ```yaml | |
| 43 | + ship: | |
| 44 | + needs: release-please | |
| 45 | + if: ${{ needs.release-please.outputs.release_created == 'true' }} | |
| 46 | + runs-on: ubuntu-latest | |
| 47 | + steps: | |
| 48 | + - uses: actions/checkout@v4 | |
| 49 | + - run: ./scripts/ship.sh "${{ needs.release-please.outputs.tag_name }}" | |
| 50 | + ``` | |
| 51 | + | |
| 52 | +5. **Merge the release pull request** when you want to release. Squash, merge commit and rebase merges all work. The next run of the workflow tags the merge, creates the release and relabels the pull request `autorelease: tagged`. | |
| 53 | + | |
| 54 | +## CI on the release pull request | |
| 55 | + | |
| 56 | +As on GitHub, what a job's `GITHUB_TOKEN` does starts no new workflow runs, so a release pull request opened with it gets no `pull_request` runs. If your branch protection requires checks, give the action a token of its own: a [personal or agent token](/docs/accounts/tokens) with the `write` scope, stored as a secret. | |
| 57 | + | |
| 58 | +```yaml | |
| 59 | + - uses: googleapis/release-please-action@v4 | |
| 60 | + with: | |
| 61 | + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} | |
| 62 | +``` | |
| 63 | + | |
| 64 | +Pull requests and pushes made with that token start workflows like anyone else's. If it's an [agent's](/docs/guides/ai-agent-with-guardrails) token, the agent rules apply: it opens and updates the release pull request, and a person merges it. | |
| 65 | + | |
| 66 | +## The CLI | |
| 67 | + | |
| 68 | +Run release-please from a terminal or another CI system with Diffinery's REST and GraphQL URLs. `--graphql-url` is the address without `/graphql` (release-please adds it): | |
| 69 | + | |
| 70 | +```sh | |
| 71 | +export DIFFINERY_TOKEN=dfy_... # a token with the write scope | |
| 72 | +npx release-please release-pr --repo-url acme/app --token "$DIFFINERY_TOKEN" \ | |
| 73 | + --api-url https://diffinery.com/api/v3 --graphql-url https://diffinery.com/api | |
| 74 | +npx release-please github-release --repo-url acme/app --token "$DIFFINERY_TOKEN" \ | |
| 75 | + --api-url https://diffinery.com/api/v3 --graphql-url https://diffinery.com/api | |
| 76 | +``` | |
| 77 | + | |
| 78 | +`--target-branch` picks another branch than the default one. For changelog links that point at Diffinery when you use the CLI, add `"changelog-host": "https://diffinery.com"` to `release-please-config.json`; the action sets it for you. | |
| 79 | + | |
| 80 | +## Troubleshooting | |
| 81 | + | |
| 82 | +| Message | Fix | | |
| 83 | +| --- | --- | | |
| 84 | +| `GitHub Actions is not permitted to create pull requests.` | Check **Allow GitHub Actions to create and approve pull requests** (step 2), or use a token of its own. | | |
| 85 | +| `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the `permissions:` from step 3 to the workflow or job. | | |
| 86 | +| `Could not find commits for branch <name> - it likely does not exist.` | The branch in `target-branch` (or the default branch) doesn't exist, or the token can't read the repository. | | |
| 87 | +| Changelog links go to github.com | Set `changelog-host` (CLI), or check that the action's `changelog-host` input isn't set to GitHub. | | |
| 88 | +| `No commits for path: ., skipping` | Nothing since the last release follows Conventional Commits (`feat:`, `fix:`, `feat!:` and so on), so there's nothing to release. | | |
| 89 | + | |
| 90 | +## Related | |
| 91 | + | |
| 92 | +- [Releases](/docs/collaboration/releases): tags, drafts, notes and assets. | |
| 93 | +- [GraphQL API](/docs/reference/graphql-api): the queries Diffinery answers. | |
| 94 | +- [Move your GitHub Actions workflows to Diffinery](/docs/guides/move-github-actions-workflows). |
modifiedapps/diffinery/docs/reference/graphql-api.md+8−4View 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 issues, pull requests and CI: what it answers, limits and errors. | |
| 3 | +description: The GitHub-shaped GraphQL endpoint at /api/graphql that gh, Claude Code and release-please use for issues, pull requests, CI, commit history and releases: what it answers and limits. | |
| 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, 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. | |
| 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, CI, commit history and releases. 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. [release-please](/docs/guides/release-please) reads commit history, merged pull requests and releases from it, and `gh release list` lists releases. | |
| 7 | 7 | |
| 8 | -Everything else (releases, Actions, settings and so on) is in the [REST API](/docs/reference/rest-api). | |
| 8 | +Everything else (creating releases, Actions, settings and so on) is in the [REST API](/docs/reference/rest-api). | |
| 9 | 9 | |
| 10 | 10 | ## Requests |
| 11 | 11 | |
| @@ -35,7 +35,7 @@ | ||
| 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, `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. | | |
| 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`, `mentionableUsers`, `ref(qualifiedName)`, `releases(orderBy)`, `release(tagName)` and `latestRelease`. A renamed repository is found by its old name. | | |
| 39 | 39 | | `user(login)` | A person or agent by login. | |
| 40 | 40 | | `node(id)`, `nodes(ids)` | A repository, issue or pull request by its global id (the `node_id` of the REST API). | |
| 41 | 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. | |
| @@ -45,6 +45,10 @@ | ||
| 45 | 45 | |
| 46 | 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`. |
| 47 | 47 | |
| 48 | +**Commits and refs.** `ref(qualifiedName:)` takes a full name (`refs/heads/main`, `refs/tags/v1.0.0`) or a short one, which is looked up as a branch, then as a tag, as on GitHub; a ref that doesn't exist is `null`. A ref's `target` is its commit (an annotated tag is peeled to the commit it tags). A `Commit` has `history(first, after, last, before, path)`, the commit and its ancestors in `git log` order (with `path`, only the commits that changed that file or directory), and `associatedPullRequests`: for a commit on the default branch, the merged pull request that brought it there; for any other commit, also the open pull requests that contain it, as on GitHub. | |
| 49 | + | |
| 50 | +**Releases.** `releases` lists published releases, and drafts for those who can push, oldest first unless `orderBy: {field: CREATED_AT, direction: DESC}` asks for newest first, as on GitHub. A `Release` has its `name`, `tagName`, `tag` and `tagCommit` (both `null` while a draft's tag doesn't exist yet), `description` (the notes), `url`, `isDraft`, `isPrerelease`, `isLatest`, `author`, `createdAt`, `publishedAt` and `updatedAt`. | |
| 51 | + | |
| 48 | 52 | **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. |
| 49 | 53 | |
| 50 | 54 | **Mutations** |
modifiedapps/diffinery/src/docs/api-meta.ts+1−1View file
| @@ -523,7 +523,7 @@ | ||
| 523 | 523 | "GET /repos/{owner}/{repo}/commits/{ref}": { summary: "Get a commit", description: "The commit with its diff.", access: "read" }, |
| 524 | 524 | "GET /repos/{owner}/{repo}/commits/{sha}/pulls": { |
| 525 | 525 | summary: "List pull requests for a commit", |
| 526 | - description: "Pull requests whose head or merge commit is this commit, plus open pull requests (the 50 newest) that contain it. 422 when the commit doesn't exist.", | |
| 526 | + description: "Pull requests whose head or merge commit is this commit and, for a commit that isn't on the default branch, open pull requests (the 50 newest) that contain it, as on GitHub. 422 when the commit doesn't exist.", | |
| 527 | 527 | access: "read", |
| 528 | 528 | query: { ...page }, |
| 529 | 529 | }, |
modifiedapps/diffinery/src/http/graphql-schema.ts+41−2View file
| @@ -4,8 +4,9 @@ | ||
| 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 | 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. | |
| 7 | + * `edit` and `status` work on its issues. release-please reads commit history (with each commit's | |
| 8 | + * pull requests), merged pull requests and releases from it. The mutations are the pull request and | |
| 9 | + * issue ones gh uses; everything else changes through the REST API. | |
| 9 | 10 | */ |
| 10 | 11 | export const SCHEMA_SDL = /* GraphQL */ ` |
| 11 | 12 | """Ids from the REST API, which can be wider than GraphQL's 32-bit Int (GitHub types these as Int).""" |
| @@ -344,6 +345,12 @@ | ||
| 344 | 345 | pullRequest(number: Int!): PullRequest |
| 345 | 346 | pullRequests(first: Int, last: Int, after: String, before: String, states: [PullRequestState!], headRefName: String, baseRefName: String, labels: [String!], orderBy: IssueOrder): PullRequestConnection! |
| 346 | 347 | pushedAt: DateTime |
| 348 | + "A branch, tag or full ref name: refs/heads/main, refs/tags/v1.0.0, or main (a branch before a tag), as on GitHub." | |
| 349 | + ref(qualifiedName: String!): Ref | |
| 350 | + "Published releases, and drafts for those who can push. Oldest first unless orderBy says DESC, as on GitHub." | |
| 351 | + releases(first: Int, last: Int, after: String, before: String, orderBy: ReleaseOrder): ReleaseConnection! | |
| 352 | + release(tagName: String!): Release | |
| 353 | + latestRelease: Release | |
| 347 | 354 | rebaseMergeAllowed: Boolean! |
| 348 | 355 | resourcePath: URI! |
| 349 | 356 | squashMergeAllowed: Boolean! |
| @@ -363,6 +370,34 @@ | ||
| 363 | 370 | target: Commit |
| 364 | 371 | } |
| 365 | 372 | |
| 373 | +type CommitHistoryConnection { edges: [CommitEdge], nodes: [Commit], pageInfo: PageInfo!, totalCount: Int! } | |
| 374 | +type CommitEdge { cursor: String!, node: Commit } | |
| 375 | + | |
| 376 | +enum ReleaseOrderField { CREATED_AT } | |
| 377 | +input ReleaseOrder { field: ReleaseOrderField!, direction: OrderDirection! } | |
| 378 | + | |
| 379 | +type Release implements Node { | |
| 380 | + author: User | |
| 381 | + createdAt: DateTime! | |
| 382 | + databaseId: Int | |
| 383 | + description: String | |
| 384 | + id: ID! | |
| 385 | + isDraft: Boolean! | |
| 386 | + isLatest: Boolean! | |
| 387 | + isPrerelease: Boolean! | |
| 388 | + name: String | |
| 389 | + publishedAt: DateTime | |
| 390 | + resourcePath: URI! | |
| 391 | + "The tag's ref, or null while a draft's tag doesn't exist yet." | |
| 392 | + tag: Ref | |
| 393 | + tagCommit: Commit | |
| 394 | + tagName: String! | |
| 395 | + updatedAt: DateTime! | |
| 396 | + url: URI! | |
| 397 | +} | |
| 398 | +type ReleaseEdge { cursor: String!, node: Release } | |
| 399 | +type ReleaseConnection { edges: [ReleaseEdge], nodes: [Release], pageInfo: PageInfo!, totalCount: Int! } | |
| 400 | + | |
| 366 | 401 | type BranchProtectionRule implements Node { |
| 367 | 402 | id: ID! |
| 368 | 403 | pattern: String! |
| @@ -586,6 +621,10 @@ | ||
| 586 | 621 | committer: GitActor |
| 587 | 622 | id: ID! |
| 588 | 623 | message: String! |
| 624 | + "The merged pull request that brought this commit to the default branch; for a commit not on it, also the open pull requests that contain it." | |
| 625 | + associatedPullRequests(first: Int, last: Int, after: String, before: String): PullRequestConnection | |
| 626 | + "The commit and its ancestors, newest first (git log order). With path, only the commits that changed it." | |
| 627 | + history(first: Int, last: Int, after: String, before: String, path: String): CommitHistoryConnection! | |
| 589 | 628 | messageBody: String! |
| 590 | 629 | messageHeadline: String! |
| 591 | 630 | oid: GitObjectID! |
modifiedapps/diffinery/src/http/graphql.ts+143−6View file
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | * CI chip a GitHub one does. See /docs/api/graphql. |
| 10 | 10 | * |
| 11 | 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 | |
| 12 | + * `comment`, `close`, `reopen`, `edit`, `status`) and `gh release list`, and release-please's commit | |
| 13 | + * history, merged pull request and release queries; a workflow's GITHUB_TOKEN may use it with the | |
| 13 | 14 | * same per-scope `permissions:` it has over REST. |
| 14 | 15 | * |
| 15 | 16 | * Execution is lenient where GitHub is strict: a field Diffinery doesn't have is left out of the |
| @@ -44,8 +45,9 @@ | ||
| 44 | 45 | import { atLeast, type Caller, type Role } from "../repo/types.js"; |
| 45 | 46 | import type { Check, Issue, Review } from "../repo/collab.js"; |
| 46 | 47 | import type { CommitStatus } from "../repo/statuses.js"; |
| 48 | +import type { Release } from "../repo/releases.js"; | |
| 47 | 49 | import { hiddenAuthors, loadRepo, repoStub } from "./common.js"; |
| 48 | -import { baseOf, nodeId, numericId, repoHtml } from "./gh.js"; | |
| 50 | +import { baseOf, encPath, isoSeconds, nodeId, numericId, repoHtml } from "./gh.js"; | |
| 49 | 51 | import { SCHEMA_SDL } from "./graphql-schema.js"; |
| 50 | 52 | import { apiRootJson } from "./api.js"; |
| 51 | 53 | |
| @@ -326,8 +328,47 @@ | ||
| 326 | 328 | viewerCanAdminister: caller.role === "admin", |
| 327 | 329 | defaultBranchRef: async () => { |
| 328 | 330 | const sha = await defaultHead(ctx, l); |
| 329 | - return sha ? { __typename: "Ref", id: nodeId("Ref", `${repo.id}/refs/heads/${repo.default_branch}`), name: repo.default_branch, prefix: "refs/heads/", target: commit(ctx, l, sha) } : null; | |
| 331 | + return sha ? refObj(ctx, l, `refs/heads/${repo.default_branch}`, sha) : null; | |
| 330 | 332 | }, |
| 333 | + ref: async (args: Args) => { | |
| 334 | + need(l, ["contents"]); | |
| 335 | + const q = String(args.qualifiedName ?? ""); | |
| 336 | + // A ref name, not a revision such as `main~1`. | |
| 337 | + if (!q || /[~^:\\\s]|\.\.|@\{/.test(q)) return null; | |
| 338 | + for (const name of q.startsWith("refs/") ? [q] : [`refs/heads/${q}`, `refs/tags/${q}`]) { | |
| 339 | + const sha = await stub.resolve(name).catch(() => null); | |
| 340 | + if (sha) return refObj(ctx, l, name, sha); | |
| 341 | + } | |
| 342 | + return null; | |
| 343 | + }, | |
| 344 | + releases: async (args: Args) => { | |
| 345 | + need(l, ["contents"]); | |
| 346 | + const order = args.orderBy as { direction?: string } | undefined; | |
| 347 | + // As GitHub: oldest first unless asked otherwise (so `releases(last: 1)` is the newest). | |
| 348 | + const newestFirst = order?.direction === "DESC"; | |
| 349 | + return pagedConnection( | |
| 350 | + args, | |
| 351 | + async (limit, offset) => { | |
| 352 | + if (newestFirst) return stub.listReleases({ limit, offset }, caller); | |
| 353 | + // The repository lists newest first: read the same window from the other end. | |
| 354 | + const total = (await stub.listReleases({ limit: 0, offset: 0 }, caller)).total; | |
| 355 | + const from = Math.max(0, total - offset - limit); | |
| 356 | + const page = await stub.listReleases({ limit: Math.max(0, total - offset - from), offset: from }, caller); | |
| 357 | + return { items: page.items.reverse(), total: page.total }; | |
| 358 | + }, | |
| 359 | + (r) => releaseObj(ctx, l, r), | |
| 360 | + ); | |
| 361 | + }, | |
| 362 | + release: async (args: Args) => { | |
| 363 | + need(l, ["contents"]); | |
| 364 | + const r = await orNull(stub.releaseByTag(String(args.tagName ?? ""), caller)); | |
| 365 | + return r ? releaseObj(ctx, l, r) : null; | |
| 366 | + }, | |
| 367 | + latestRelease: async () => { | |
| 368 | + need(l, ["contents"]); | |
| 369 | + const r = await orNull(stub.latestRelease()); | |
| 370 | + return r ? releaseObj(ctx, l, r) : null; | |
| 371 | + }, | |
| 331 | 372 | assignableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), |
| 332 | 373 | mentionableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args), |
| 333 | 374 | labels: async (args: Args) => { |
| @@ -409,11 +450,52 @@ | ||
| 409 | 450 | |
| 410 | 451 | const defaultHead = (ctx: Ctx, l: Loaded) => once(ctx, `head:${l.repo.id}`, () => l.stub.resolve(l.repo.default_branch).catch(() => null)); |
| 411 | 452 | |
| 453 | +/** A branch or tag (`name` is the full ref) pointing at commit `oid`; an annotated tag is peeled to its commit. */ | |
| 454 | +function refObj(ctx: Ctx, l: Loaded, name: string, oid: string): Obj { | |
| 455 | + const prefix = name.startsWith("refs/heads/") ? "refs/heads/" : name.startsWith("refs/tags/") ? "refs/tags/" : name.slice(0, name.lastIndexOf("/") + 1); | |
| 456 | + return { __typename: "Ref", id: nodeId("Ref", `${l.repo.id}/${name}`), name: name.slice(prefix.length), prefix, target: commit(ctx, l, oid) }; | |
| 457 | +} | |
| 458 | + | |
| 459 | +function releaseObj(ctx: Ctx, l: Loaded, r: Release): Obj { | |
| 460 | + const { repo, stub } = l; | |
| 461 | + const tagRef = `refs/tags/${r.tagName}`; | |
| 462 | + // A draft's tag may not exist yet: then `tag` and `tagCommit` are null, as on GitHub. | |
| 463 | + const tagSha = () => once(ctx, `tag:${repo.id}:${r.tagName}`, () => stub.resolve(tagRef).catch(() => null)); | |
| 464 | + return { | |
| 465 | + __typename: "Release", | |
| 466 | + id: nodeId("Release", r.id), | |
| 467 | + databaseId: r.id, | |
| 468 | + name: r.name || null, | |
| 469 | + tagName: r.tagName, | |
| 470 | + description: r.body || null, | |
| 471 | + isDraft: r.draft, | |
| 472 | + isPrerelease: r.prerelease, | |
| 473 | + isLatest: !!r.latest, | |
| 474 | + createdAt: isoSeconds(r.createdAt), | |
| 475 | + publishedAt: isoSeconds(r.publishedAt), | |
| 476 | + updatedAt: isoSeconds(r.updatedAt), | |
| 477 | + author: { ...actor(ctx, r.author), __typename: "User" }, | |
| 478 | + url: `${repoHtml(ctx.base, repo.owner_login, repo.name)}/releases/tag/${encPath(r.tagName)}`, | |
| 479 | + resourcePath: `/${repo.owner_login}/${repo.name}/releases/tag/${encPath(r.tagName)}`, | |
| 480 | + tag: async () => { | |
| 481 | + const sha = await tagSha(); | |
| 482 | + return sha ? refObj(ctx, l, tagRef, sha) : null; | |
| 483 | + }, | |
| 484 | + tagCommit: async () => { | |
| 485 | + const sha = await tagSha(); | |
| 486 | + return sha ? commit(ctx, l, sha) : null; | |
| 487 | + }, | |
| 488 | + }; | |
| 489 | +} | |
| 490 | + | |
| 412 | 491 | const issueOf = (ctx: Ctx, l: Loaded, n: number): Promise<Issue> => |
| 413 | 492 | once(ctx, `issue:${l.repo.id}:${n}`, async () => (await l.stub.issue(n, await hiddenAuthors(ctx.c))).issue); |
| 414 | 493 | |
| 415 | -const issueOrNull = (ctx: Ctx, l: Loaded, n: number): Promise<Issue | null> => | |
| 416 | - issueOf(ctx, l, n).catch((e) => { | |
| 494 | +const issueOrNull = (ctx: Ctx, l: Loaded, n: number): Promise<Issue | null> => orNull(issueOf(ctx, l, n)); | |
| 495 | + | |
| 496 | +/** A lookup's value, or null when the repository answers 404. */ | |
| 497 | +const orNull = <T>(p: Promise<T>): Promise<T | null> => | |
| 498 | + p.catch((e) => { | |
| 417 | 499 | if (statusOf(e)?.status === 404) return null; |
| 418 | 500 | throw e; |
| 419 | 501 | }); |
| @@ -665,7 +747,12 @@ | ||
| 665 | 747 | |
| 666 | 748 | // ---------------------------------------------------------------- commits and checks |
| 667 | 749 | |
| 668 | -type CommitOpts = { pullNumber?: number; review?: () => Promise<{ requiredChecks: string[] }> }; | |
| 750 | +type CommitOpts = { | |
| 751 | + pullNumber?: number; | |
| 752 | + review?: () => Promise<{ requiredChecks: string[] }>; | |
| 753 | + /** The numbers of the pull requests associated with the commit, when a history page has read them for all its commits at once. */ | |
| 754 | + pulls?: () => Promise<number[]>; | |
| 755 | +}; | |
| 669 | 756 | type CommitLike = { sha: string; message: string; author: { name: string; email: string; date?: string; time?: number }; committer: { name: string; email: string; date?: string; time?: number } }; |
| 670 | 757 | |
| 671 | 758 | function commit(ctx: Ctx, l: Loaded, oid: string, opts: CommitOpts = {}, known?: CommitLike): Obj { |
| @@ -687,6 +774,19 @@ | ||
| 687 | 774 | commitUrl: `${html}/commit/${oid}`, |
| 688 | 775 | message, |
| 689 | 776 | messageHeadline: async () => (await message()).split("\n")[0]!, |
| 777 | + associatedPullRequests: async (args: Args) => { | |
| 778 | + need(l, ["pull-requests"]); | |
| 779 | + const numbers = await (opts.pulls ?? (() => once(ctx, `pulls-for:${repo.id}:${oid}`, () => stub.pullsForCommit(oid))))(); | |
| 780 | + const found = (await Promise.all(numbers.map((n) => issueOrNull(ctx, l, n)))).filter((i): i is Issue => i?.kind === "pull"); | |
| 781 | + return connection( | |
| 782 | + found.map((i) => pullRequest(ctx, l, i, () => repository(ctx, l))), | |
| 783 | + args, | |
| 784 | + ); | |
| 785 | + }, | |
| 786 | + history: (args: Args) => { | |
| 787 | + need(l, ["contents"]); | |
| 788 | + return history(ctx, l, oid, args); | |
| 789 | + }, | |
| 690 | 790 | messageBody: async () => (await message()).split("\n").slice(1).join("\n").replace(/^\n+/, ""), |
| 691 | 791 | authoredDate: async () => dateOf((await info()).author), |
| 692 | 792 | committedDate: async () => dateOf((await info()).committer), |
| @@ -721,6 +821,43 @@ | ||
| 721 | 821 | }; |
| 722 | 822 | } |
| 723 | 823 | |
| 824 | +/** | |
| 825 | + * A commit and its ancestors in git log order, a page at a time: the page is read with one more | |
| 826 | + * commit than asked for, to know whether another follows, and the whole count is only walked when | |
| 827 | + * `last`, `before` or `totalCount` needs it. Each page reads its commits' pull requests in one call. | |
| 828 | + */ | |
| 829 | +async function history(ctx: Ctx, l: Loaded, oid: string, args: Args): Promise<Obj> { | |
| 830 | + const { repo, stub } = l; | |
| 831 | + pageSize(args); | |
| 832 | + const path = typeof args.path === "string" && args.path.replace(/^\/+|\/+$/g, "") ? args.path.replace(/^\/+|\/+$/g, "") : undefined; | |
| 833 | + const total = () => once(ctx, `log-count:${repo.id}:${oid}:${path ?? ""}`, () => stub.logCount(oid, path)); | |
| 834 | + const after = offsetOf(args.after); | |
| 835 | + const before = offsetOf(args.before); | |
| 836 | + const first = typeof args.first === "number" ? args.first : null; | |
| 837 | + const last = typeof args.last === "number" ? args.last : null; | |
| 838 | + let start = after >= 0 ? after + 1 : 0; | |
| 839 | + let end = last !== null || before >= 0 ? await total() : Number.MAX_SAFE_INTEGER; | |
| 840 | + if (before >= 0) end = Math.max(Math.min(before, end), start); | |
| 841 | + if (first !== null) end = Math.min(end, start + first); | |
| 842 | + if (last !== null) start = Math.max(start, end - last); | |
| 843 | + if (first === null && last === null) end = Math.min(end, start + LIMITS.pageSize); | |
| 844 | + const want = Math.max(0, end - start); | |
| 845 | + // log answers at most 100 commits a call, so a full page of 100 asks separately whether one follows. | |
| 846 | + const read = want ? await stub.log(oid, { path, limit: Math.min(want + 1, 100), skip: start }) : []; | |
| 847 | + const more = read.length > want || (want === 100 && read.length === 100 && (await stub.log(oid, { path, limit: 1, skip: start + want })).length > 0); | |
| 848 | + const page = read.slice(0, want); | |
| 849 | + // Walking the default branch, every commit is on it: no need to check open pull requests' ancestry. | |
| 850 | + const onDefault = oid === (await defaultHead(ctx, l)) ? true : undefined; | |
| 851 | + const pulls = () => once(ctx, `pulls-for-page:${repo.id}:${oid}:${start}:${want}`, () => stub.pullsForCommits(page.map((c) => c.sha), onDefault)); | |
| 852 | + const nodes = page.map((c) => commit(ctx, l, c.sha, { pulls: async () => (await pulls())[c.sha] ?? [] }, c)); | |
| 853 | + return { | |
| 854 | + totalCount: total, | |
| 855 | + nodes, | |
| 856 | + edges: nodes.map((node, i) => ({ node, cursor: cursorOf(start + i) })), | |
| 857 | + pageInfo: { hasNextPage: more, hasPreviousPage: start > 0, startCursor: page.length ? cursorOf(start) : null, endCursor: page.length ? cursorOf(start + page.length - 1) : null }, | |
| 858 | + }; | |
| 859 | +} | |
| 860 | + | |
| 724 | 861 | function dateOf(s: { date?: string; time?: number; timestamp?: number }): string | null { |
| 725 | 862 | if (typeof s.date === "string") return s.date; |
| 726 | 863 | const t = typeof s.time === "number" ? s.time : typeof s.timestamp === "number" ? s.timestamp : null; |
modifiedapps/diffinery/src/repo/RepoDO.ts+22−4View file
| @@ -995,10 +995,7 @@ | ||
| 995 | 995 | const out: CommitInfo[] = []; |
| 996 | 996 | let skipped = 0; |
| 997 | 997 | for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) { |
| 998 | - const mine = await resolvePath(this.store, c.commit.tree, opts.path); | |
| 999 | - const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null; | |
| 1000 | - const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null; | |
| 1001 | - if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue; | |
| 998 | + if (!(await this.changes(c.commit, opts.path))) continue; | |
| 1002 | 999 | if (skipped++ < skip) continue; |
| 1003 | 1000 | out.push(toInfo(c.oid, c.commit)); |
| 1004 | 1001 | if (out.length >= limit) break; |
| @@ -1006,6 +1003,24 @@ | ||
| 1006 | 1003 | return out; |
| 1007 | 1004 | } |
| 1008 | 1005 | |
| 1006 | + /** How many commits `log` lists from a commit: up to MAX_LOG_DEPTH, or with `path`, those of the first 2000 that changed it. */ | |
| 1007 | + async logCount(rev: string, path?: string): Promise<number> { | |
| 1008 | + const sha = await this.resolve(rev); | |
| 1009 | + if (!sha) return 0; | |
| 1010 | + if (!path) return (await walkCommits(this.store, [sha], { limit: MAX_LOG_DEPTH })).length; | |
| 1011 | + let n = 0; | |
| 1012 | + for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) if (await this.changes(c.commit, path)) n++; | |
| 1013 | + return n; | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + /** Whether a commit's tree entry at `path` differs from its first parent's. */ | |
| 1017 | + private async changes(c: Commit, path: string): Promise<boolean> { | |
| 1018 | + const mine = await resolvePath(this.store, c.tree, path); | |
| 1019 | + const parent = c.parents[0] ? await readCommit(this.store, c.parents[0]).catch(() => null) : null; | |
| 1020 | + const theirs = parent ? await resolvePath(this.store, parent.tree, path) : null; | |
| 1021 | + return (mine?.oid ?? null) !== (theirs?.oid ?? null); | |
| 1022 | + } | |
| 1023 | + | |
| 1009 | 1024 | /** Diff between two commits (or a commit and its first parent). */ |
| 1010 | 1025 | async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) { |
| 1011 | 1026 | const head = await readCommit(this.store, headSha); |
| @@ -1895,6 +1910,9 @@ | ||
| 1895 | 1910 | pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) { |
| 1896 | 1911 | return this.social.pullsForCommit(...a); |
| 1897 | 1912 | } |
| 1913 | + pullsForCommits(...a: Parameters<Social["pullsForCommits"]>) { | |
| 1914 | + return this.social.pullsForCommits(...a); | |
| 1915 | + } | |
| 1898 | 1916 | reactions(...a: Parameters<Social["reactions"]>) { |
| 1899 | 1917 | return this.social.reactions(...a); |
| 1900 | 1918 | } |
modifiedapps/diffinery/src/repo/social.ts+22−4View file
| @@ -408,15 +408,33 @@ | ||
| 408 | 408 | return { sha }; |
| 409 | 409 | } |
| 410 | 410 | |
| 411 | - /** Pull requests associated with a commit: merged by it or containing it as their head (basic). */ | |
| 412 | - async pullsForCommit(sha: string): Promise<number[]> { | |
| 411 | + /** | |
| 412 | + * Pull requests associated with a commit, as GitHub lists them: those merged by it or with it as | |
| 413 | + * their head and, for a commit that isn't on the default branch, the open pull requests that | |
| 414 | + * contain it. (On the default branch every open pull request contains its older commits, so | |
| 415 | + * GitHub lists only the merged one.) `onDefault` skips that ancestry check when the caller knows, | |
| 416 | + * as a walk of the default branch's history does. | |
| 417 | + */ | |
| 418 | + async pullsForCommit(sha: string, onDefault?: boolean): Promise<number[]> { | |
| 413 | 419 | const direct = this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE merge_commit = ? OR head_sha = ? ORDER BY number DESC", sha, sha).toArray().map((r) => r.number); |
| 420 | + if (onDefault || !this.repo.store.has(sha)) return direct; | |
| 414 | 421 | const open = this.sql.exec<{ number: number; head_sha: string }>("SELECT p.number, p.head_sha FROM pulls p JOIN issues i ON i.number = p.number WHERE i.state = 'open' ORDER BY p.number DESC LIMIT 50").toArray(); |
| 415 | 422 | const out = new Set(direct); |
| 416 | - for (const p of open) if (!out.has(p.number) && this.repo.store.has(sha) && (await isAncestor(this.repo.store, sha, p.head_sha).catch(() => false))) out.add(p.number); | |
| 417 | - return [...out]; | |
| 423 | + const containing: number[] = []; | |
| 424 | + for (const p of open) if (!out.has(p.number) && (await isAncestor(this.repo.store, sha, p.head_sha).catch(() => false))) containing.push(p.number); | |
| 425 | + if (!containing.length) return direct; | |
| 426 | + const main = this.repo.getRef(this.repo.headRef); | |
| 427 | + if (onDefault === undefined && main && (await isAncestor(this.repo.store, sha, main).catch(() => false))) return direct; | |
| 428 | + return [...direct, ...containing]; | |
| 418 | 429 | } |
| 419 | 430 | |
| 431 | + /** pullsForCommit for each of a page of commits, in one call. */ | |
| 432 | + async pullsForCommits(shas: string[], onDefault?: boolean): Promise<Record<string, number[]>> { | |
| 433 | + const out: Record<string, number[]> = {}; | |
| 434 | + for (const sha of shas.slice(0, 100)) out[sha] = await this.pullsForCommit(sha, onDefault); | |
| 435 | + return out; | |
| 436 | + } | |
| 437 | + | |
| 420 | 438 | // ---------------------------------------------------------------- reactions |
| 421 | 439 | |
| 422 | 440 | private subjectExists(subject: string): boolean { |
modifiedapps/diffinery/test/e2e/graphql.test.mjs+114−1View file
| @@ -3,7 +3,8 @@ | ||
| 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 | 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 | |
| 6 | +// (list, view, create, comment, edit, close, reopen, status, list --search), release-please's | |
| 7 | +// commit history, merged pull request and release queries (and `gh release list`), and a workflow | |
| 7 | 8 | // job's GITHUB_TOKEN with its `permissions:`. Also covers sign-in, token scopes, limits and |
| 8 | 9 | // GitHub's error shape. |
| 9 | 10 | // |
| @@ -328,6 +329,118 @@ | ||
| 328 | 329 | assert.equal((await search(`repo:ann/app is:pr`)).issueCount, 1); |
| 329 | 330 | }); |
| 330 | 331 | |
| 332 | +// release-please's queries, word for word (release-please 17, src/github.ts and src/github-api.ts). | |
| 333 | +const RP_COMMITS = `query pullRequestsSince($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $cursor: String) { | |
| 334 | + repository(owner: $owner, name: $repo) { ref(qualifiedName: $targetBranch) { target { ... on Commit { history(first: $num, after: $cursor) { | |
| 335 | + nodes { | |
| 336 | + associatedPullRequests(first: 10) { nodes { number title baseRefName headRefName labels(first: 10) { nodes { name } } body mergeCommit { oid } files(first: $maxFilesChanged) { nodes { path } pageInfo { endCursor hasNextPage } } } } | |
| 337 | + sha: oid message author { name email user { login } } | |
| 338 | + } | |
| 339 | + pageInfo { hasNextPage endCursor } | |
| 340 | + } } } } } | |
| 341 | +}`; | |
| 342 | +const RP_PULLS = `query mergedPullRequests($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $states: [PullRequestState!], $cursor: String) { | |
| 343 | + repository(owner: $owner, name: $repo) { pullRequests(first: $num, after: $cursor, baseRefName: $targetBranch, states: $states, orderBy: {field: CREATED_AT, direction: DESC}) { | |
| 344 | + nodes { number title baseRefName headRefName labels(first: 10) { nodes { name } } body mergeCommit { oid } files(first: $maxFilesChanged) { nodes { path } pageInfo { endCursor hasNextPage } } } | |
| 345 | + pageInfo { endCursor hasNextPage } | |
| 346 | + } } | |
| 347 | +}`; | |
| 348 | +const RP_RELEASES = `query releases($owner: String!, $repo: String!, $num: Int!, $cursor: String) { | |
| 349 | + repository(owner: $owner, name: $repo) { releases(first: $num, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { | |
| 350 | + nodes { name tag { name } tagCommit { oid } url description isDraft } | |
| 351 | + pageInfo { endCursor hasNextPage } | |
| 352 | + } } | |
| 353 | +}`; | |
| 354 | + | |
| 355 | +test("release-please: commit history with associated pull requests, refs and releases", async () => { | |
| 356 | + await rest("POST", "/user/repos", { name: "rel", private: true }); | |
| 357 | + const src = join(work, "rel"); | |
| 358 | + mkdirSync(src); | |
| 359 | + const commit = (file, text, message) => { | |
| 360 | + writeFileSync(join(src, file), text); | |
| 361 | + git(src, ["add", "-A"]); | |
| 362 | + git(src, ["commit", "-q", "-m", message]); | |
| 363 | + return git(src, ["rev-parse", "HEAD"]).trim(); | |
| 364 | + }; | |
| 365 | + git(src, ["init", "-q", "-b", "main"]); | |
| 366 | + const first = commit("package.json", '{"version": "1.0.0"}\n', "chore: initial commit"); | |
| 367 | + git(src, ["push", "-q", `${base}/ann/rel.git`, "main"]); | |
| 368 | + git(src, ["checkout", "-q", "-b", "feat"]); | |
| 369 | + commit("hello.txt", "hi\n", "add hello"); | |
| 370 | + git(src, ["push", "-q", `${base}/ann/rel.git`, "feat"]); | |
| 371 | + const pr = await rest("POST", "/repos/ann/rel/pulls", { title: "feat: add hello", head: "feat", base: "main", body: "Says hi." }); | |
| 372 | + await rest("POST", `/repos/ann/rel/issues/${pr.number}/labels`, { labels: ["autorelease: pending"] }); | |
| 373 | + const squash = (await rest("PUT", `/repos/ann/rel/pulls/${pr.number}/merge`, { merge_method: "squash" })).sha; | |
| 374 | + git(src, ["checkout", "-q", "main"]); | |
| 375 | + git(src, ["pull", "-q", `${base}/ann/rel.git`, "main"]); | |
| 376 | + const direct = commit("hello.txt", "hi there\n", "fix: a direct commit"); | |
| 377 | + git(src, ["push", "-q", `${base}/ann/rel.git`, "main"]); | |
| 378 | + // An open pull request whose head contains every main commit: GitHub doesn't associate it with them. | |
| 379 | + git(src, ["checkout", "-q", "-b", "next"]); | |
| 380 | + const nextSha = commit("next.txt", "next\n", "feat: next"); | |
| 381 | + git(src, ["push", "-q", `${base}/ann/rel.git`, "next"]); | |
| 382 | + const open = await rest("POST", "/repos/ann/rel/pulls", { title: "feat: next", head: "next", base: "main" }); | |
| 383 | + | |
| 384 | + const vars = { owner: "ann", repo: "rel", num: 2, maxFilesChanged: 100, targetBranch: "main" }; | |
| 385 | + const page1 = (await data(RP_COMMITS, vars)).repository.ref.target.history; | |
| 386 | + assert.deepEqual(page1.nodes.map((n) => n.sha), [direct, squash]); | |
| 387 | + assert.equal(page1.pageInfo.hasNextPage, true); | |
| 388 | + assert.deepEqual(page1.nodes[0].associatedPullRequests.nodes, []); | |
| 389 | + assert.equal(page1.nodes[0].message, "fix: a direct commit"); | |
| 390 | + assert.equal(page1.nodes[0].author.email, "ann@example.com"); | |
| 391 | + const merged = page1.nodes[1].associatedPullRequests.nodes; | |
| 392 | + assert.equal(merged.length, 1); | |
| 393 | + assert.deepEqual({ ...merged[0], labels: merged[0].labels.nodes.map((l) => l.name), files: merged[0].files.nodes.map((f) => f.path) }, { | |
| 394 | + number: pr.number, title: "feat: add hello", baseRefName: "main", headRefName: "feat", body: "Says hi.", mergeCommit: { oid: squash }, | |
| 395 | + labels: ["autorelease: pending"], files: ["hello.txt"], | |
| 396 | + }); | |
| 397 | + const page2 = (await data(RP_COMMITS, { ...vars, cursor: page1.pageInfo.endCursor })).repository.ref.target.history; | |
| 398 | + assert.deepEqual(page2.nodes.map((n) => n.sha), [first]); | |
| 399 | + assert.equal(page2.pageInfo.hasNextPage, false); | |
| 400 | + // A branch that doesn't exist has no ref (release-please then stops quietly), and neither does a revision. | |
| 401 | + assert.equal((await data(RP_COMMITS, { ...vars, targetBranch: "nope" })).repository.ref, null); | |
| 402 | + assert.equal((await data(`query { repository(owner: "ann", name: "rel") { ref(qualifiedName: "main~1") { name } } }`)).repository.ref, null); | |
| 403 | + // Off the default branch, a commit is associated with the open pull requests that contain it, as over REST. | |
| 404 | + const off = await data(`query { repository(owner: "ann", name: "rel") { ref(qualifiedName: "refs/heads/next") { name prefix target { history(first: 1) { totalCount nodes { associatedPullRequests(first: 5) { nodes { number state } } } } } } } }`); | |
| 405 | + assert.equal(off.repository.ref.name, "next"); | |
| 406 | + assert.equal(off.repository.ref.prefix, "refs/heads/"); | |
| 407 | + assert.equal(off.repository.ref.target.history.totalCount, 4); | |
| 408 | + assert.deepEqual(off.repository.ref.target.history.nodes[0].associatedPullRequests.nodes, [{ number: open.number, state: "OPEN" }]); | |
| 409 | + assert.deepEqual((await rest("GET", `/repos/ann/rel/commits/${direct}/pulls`)).map((p) => p.number), []); | |
| 410 | + assert.deepEqual((await rest("GET", `/repos/ann/rel/commits/${nextSha}/pulls`)).map((p) => p.number), [open.number]); | |
| 411 | + // history(path:) and last: | |
| 412 | + const byPath = await data(`query { repository(owner: "ann", name: "rel") { defaultBranchRef { target { ... on Commit { history(first: 10, path: "hello.txt") { totalCount nodes { oid } } last: history(last: 1) { nodes { oid } pageInfo { hasPreviousPage } } } } } } }`); | |
| 413 | + assert.deepEqual(byPath.repository.defaultBranchRef.target.history.nodes.map((n) => n.oid), [direct, squash]); | |
| 414 | + assert.equal(byPath.repository.defaultBranchRef.target.history.totalCount, 2); | |
| 415 | + assert.deepEqual(byPath.repository.defaultBranchRef.target.last.nodes.map((n) => n.oid), [first]); | |
| 416 | + assert.equal(byPath.repository.defaultBranchRef.target.last.pageInfo.hasPreviousPage, true); | |
| 417 | + | |
| 418 | + const prs = (await data(RP_PULLS, { ...vars, num: 25, maxFilesChanged: 64, states: "MERGED" })).repository.pullRequests; | |
| 419 | + assert.deepEqual(prs.nodes.map((p) => [p.number, p.mergeCommit?.oid]), [[pr.number, squash]]); | |
| 420 | + | |
| 421 | + // Releases: none yet, then two and a draft whose tag doesn't exist yet. | |
| 422 | + assert.deepEqual((await data(RP_RELEASES, { owner: "ann", repo: "rel", num: 25 })).repository.releases.nodes, []); | |
| 423 | + await rest("POST", "/repos/ann/rel/releases", { tag_name: "v1.0.0", target_commitish: first, name: "v1.0.0", body: "First." }); | |
| 424 | + await rest("POST", "/repos/ann/rel/releases", { tag_name: "v1.1.0", target_commitish: squash, name: "v1.1.0", body: "Second." }); | |
| 425 | + await rest("POST", "/repos/ann/rel/releases", { tag_name: "v2.0.0-draft", target_commitish: "main", name: "draft", draft: true }); | |
| 426 | + const rels = (await data(RP_RELEASES, { owner: "ann", repo: "rel", num: 25 })).repository.releases; | |
| 427 | + assert.deepEqual(rels.nodes.map((r) => [r.tag?.name ?? null, r.tagCommit?.oid ?? null, r.isDraft]), [[null, null, true], ["v1.1.0", squash, false], ["v1.0.0", first, false]]); | |
| 428 | + assert.equal(rels.nodes[1].url, `${base}/ann/rel/releases/tag/v1.1.0`); | |
| 429 | + assert.equal(rels.nodes[1].description, "Second."); | |
| 430 | + // gh release list's query, and GitHub's default order (oldest first, so `last: 1` is the newest). | |
| 431 | + const gh = await data(`query { repository(owner: "ann", name: "rel") { releases(first: 2, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { name tagName isDraft isLatest isPrerelease createdAt publishedAt } } oldest: releases(first: 1) { totalCount nodes { tagName } } newest: releases(last: 1) { nodes { tagName } } latestRelease { tagName isLatest } release(tagName: "v1.0.0") { name tag { prefix name target { oid } } } missing: release(tagName: "v9") { name } } }`); | |
| 432 | + assert.deepEqual(gh.repository.releases.nodes.map((r) => [r.tagName, r.isLatest]), [["v2.0.0-draft", false], ["v1.1.0", true]]); | |
| 433 | + assert.match(gh.repository.releases.nodes[1].createdAt, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); | |
| 434 | + assert.equal(gh.repository.oldest.totalCount, 3); | |
| 435 | + assert.deepEqual(gh.repository.oldest.nodes.map((r) => r.tagName), ["v1.0.0"]); | |
| 436 | + assert.deepEqual(gh.repository.newest.nodes.map((r) => r.tagName), ["v2.0.0-draft"]); | |
| 437 | + assert.deepEqual(gh.repository.latestRelease, { tagName: "v1.1.0", isLatest: true }); | |
| 438 | + assert.deepEqual(gh.repository.release.tag, { prefix: "refs/tags/", name: "v1.0.0", target: { oid: first } }); | |
| 439 | + assert.equal(gh.repository.missing, null); | |
| 440 | + // A tag by its short name, after the branches. | |
| 441 | + assert.equal((await data(`query { repository(owner: "ann", name: "rel") { ref(qualifiedName: "v1.1.0") { prefix target { oid } } } }`)).repository.ref.target.oid, squash); | |
| 442 | +}); | |
| 443 | + | |
| 331 | 444 | test("a workflow's GITHUB_TOKEN uses GraphQL with its permissions, as over REST", async () => { |
| 332 | 445 | 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 | 446 | await rest("PUT", "/repos/ann/app/contents/.github/workflows/gh.yml", { message: "gh in CI", content: Buffer.from(workflow).toString("base64") }); |