Diffinery

codician/diffinerypublic

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

Merge 062b4c3e2dbe44921a3ce763031743a03b2d49ac into bc4d1baf43da951271a59361559d2c71c78de642

Diffinery authored 2 parents bc4d1ba062b4c3commit 3dc94d14c81ff03a14269521b86b8616288cad29Browse files
12 files changed+451−26
Files (12)
modifiedapps/diffinery/docs/actions/compatibility.md+1−1View file
@@ -298,7 +298,7 @@
298298
299299 ### Actions that call the GitHub API
300300
301-Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). 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)).
302302
303303 ### Actions that use the cache or artifact services
304304
modifiedapps/diffinery/docs/collaboration/pull-requests.md+1−1View file
@@ -911,7 +911,7 @@
911911
912912 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).
913913
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>`.
915915
916916 `diffinery pr comment 12 --body "..."` adds a conversation comment.
917917
modifiedapps/diffinery/docs/guides.md+3−2View file
@@ -1,7 +1,7 @@
11 ---
22 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
55 ---
66
77 Each guide answers one question in a few steps and links to the pages that cover the details.
@@ -10,6 +10,7 @@
1010 | --- | --- |
1111 | [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. |
1212 | [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. |
1314 | [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. |
1415 | [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. |
1516
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/nav.json+1−0View file
@@ -14,6 +14,7 @@
1414 "guides",
1515 "guides/move-github-actions-workflows",
1616 "guides/faster-ci",
17+ "guides/release-please",
1718 "guides/ai-agent-with-guardrails",
1819 "guides/mirror-from-github"
1920 ]
modifiedapps/diffinery/docs/reference/graphql-api.md+8−4View file
@@ -1,11 +1,11 @@
11 ---
22 title: GraphQL API
3-description: The GitHub-shaped GraphQL endpoint at /api/graphql that the GitHub CLI and Claude Code use for 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.
44 ---
55
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.
77
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).
99
1010 ## Requests
1111
@@ -35,7 +35,7 @@
3535 | Field | What you get |
3636 | --- | --- |
3737 | `viewer` | The signed-in account. |
38-| `repository(owner, name)` | A repository: its id, name, owner, visibility, default branch, merge methods, your permission, `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. |
3939 | `user(login)` | A person or agent by login. |
4040 | `node(id)`, `nodes(ids)` | A repository, issue or pull request by its global id (the `node_id` of the REST API). |
4141 | `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 @@
4545
4646 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`.
4747
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+
4852 **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.
4953
5054 **Mutations**
modifiedapps/diffinery/src/docs/api-meta.ts+1−1View file
@@ -523,7 +523,7 @@
523523 "GET /repos/{owner}/{repo}/commits/{ref}": { summary: "Get a commit", description: "The commit with its diff.", access: "read" },
524524 "GET /repos/{owner}/{repo}/commits/{sha}/pulls": {
525525 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.",
527527 access: "read",
528528 query: { ...page },
529529 },
modifiedapps/diffinery/src/http/graphql-schema.ts+41−2View file
@@ -4,8 +4,9 @@
44 * (Claude Code's pull request badge, `gh pr view`, `gh pr checks`, `gh pr list`, `gh pr status`,
55 * `gh pr create`, `gh pr merge`, `gh pr ready`, `gh pr comment`) read and change Diffinery as a
66 * 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.
910 */
1011 export const SCHEMA_SDL = /* GraphQL */ `
1112 """Ids from the REST API, which can be wider than GraphQL's 32-bit Int (GitHub types these as Int)."""
@@ -344,6 +345,12 @@
344345 pullRequest(number: Int!): PullRequest
345346 pullRequests(first: Int, last: Int, after: String, before: String, states: [PullRequestState!], headRefName: String, baseRefName: String, labels: [String!], orderBy: IssueOrder): PullRequestConnection!
346347 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
347354 rebaseMergeAllowed: Boolean!
348355 resourcePath: URI!
349356 squashMergeAllowed: Boolean!
@@ -363,6 +370,34 @@
363370 target: Commit
364371 }
365372
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+
366401 type BranchProtectionRule implements Node {
367402 id: ID!
368403 pattern: String!
@@ -586,6 +621,10 @@
586621 committer: GitActor
587622 id: ID!
588623 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!
589628 messageBody: String!
590629 messageHeadline: String!
591630 oid: GitObjectID!
modifiedapps/diffinery/src/http/graphql.ts+143−6View file
@@ -9,7 +9,8 @@
99 * CI chip a GitHub one does. See /docs/api/graphql.
1010 *
1111 * 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
1314 * same per-scope `permissions:` it has over REST.
1415 *
1516 * Execution is lenient where GitHub is strict: a field Diffinery doesn't have is left out of the
@@ -44,8 +45,9 @@
4445 import { atLeast, type Caller, type Role } from "../repo/types.js";
4546 import type { Check, Issue, Review } from "../repo/collab.js";
4647 import type { CommitStatus } from "../repo/statuses.js";
48+import type { Release } from "../repo/releases.js";
4749 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";
4951 import { SCHEMA_SDL } from "./graphql-schema.js";
5052 import { apiRootJson } from "./api.js";
5153
@@ -326,8 +328,47 @@
326328 viewerCanAdminister: caller.role === "admin",
327329 defaultBranchRef: async () => {
328330 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;
330332 },
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+ },
331372 assignableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args),
332373 mentionableUsers: async (args: Args) => connection(await people(ctx, l, args.query), args),
333374 labels: async (args: Args) => {
@@ -409,11 +450,52 @@
409450
410451 const defaultHead = (ctx: Ctx, l: Loaded) => once(ctx, `head:${l.repo.id}`, () => l.stub.resolve(l.repo.default_branch).catch(() => null));
411452
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+
412491 const issueOf = (ctx: Ctx, l: Loaded, n: number): Promise<Issue> =>
413492 once(ctx, `issue:${l.repo.id}:${n}`, async () => (await l.stub.issue(n, await hiddenAuthors(ctx.c))).issue);
414493
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) => {
417499 if (statusOf(e)?.status === 404) return null;
418500 throw e;
419501 });
@@ -665,7 +747,12 @@
665747
666748 // ---------------------------------------------------------------- commits and checks
667749
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+};
669756 type CommitLike = { sha: string; message: string; author: { name: string; email: string; date?: string; time?: number }; committer: { name: string; email: string; date?: string; time?: number } };
670757
671758 function commit(ctx: Ctx, l: Loaded, oid: string, opts: CommitOpts = {}, known?: CommitLike): Obj {
@@ -687,6 +774,19 @@
687774 commitUrl: `${html}/commit/${oid}`,
688775 message,
689776 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+ },
690790 messageBody: async () => (await message()).split("\n").slice(1).join("\n").replace(/^\n+/, ""),
691791 authoredDate: async () => dateOf((await info()).author),
692792 committedDate: async () => dateOf((await info()).committer),
@@ -721,6 +821,43 @@
721821 };
722822 }
723823
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+
724861 function dateOf(s: { date?: string; time?: number; timestamp?: number }): string | null {
725862 if (typeof s.date === "string") return s.date;
726863 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 @@
995995 const out: CommitInfo[] = [];
996996 let skipped = 0;
997997 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;
1002999 if (skipped++ < skip) continue;
10031000 out.push(toInfo(c.oid, c.commit));
10041001 if (out.length >= limit) break;
@@ -1006,6 +1003,24 @@
10061003 return out;
10071004 }
10081005
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+
10091024 /** Diff between two commits (or a commit and its first parent). */
10101025 async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) {
10111026 const head = await readCommit(this.store, headSha);
@@ -1895,6 +1910,9 @@
18951910 pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) {
18961911 return this.social.pullsForCommit(...a);
18971912 }
1913+ pullsForCommits(...a: Parameters<Social["pullsForCommits"]>) {
1914+ return this.social.pullsForCommits(...a);
1915+ }
18981916 reactions(...a: Parameters<Social["reactions"]>) {
18991917 return this.social.reactions(...a);
19001918 }
modifiedapps/diffinery/src/repo/social.ts+22−4View file
@@ -408,15 +408,33 @@
408408 return { sha };
409409 }
410410
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[]> {
413419 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;
414421 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();
415422 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];
418429 }
419430
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+
420438 // ---------------------------------------------------------------- reactions
421439
422440 private subjectExists(subject: string): boolean {
modifiedapps/diffinery/test/e2e/graphql.test.mjs+114−1View file
@@ -3,7 +3,8 @@
33 // check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull
44 // request mutations behind `gh pr create`, `gh pr ready`, `gh pr edit`, `gh pr comment`,
55 // `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
78 // job's GITHUB_TOKEN with its `permissions:`. Also covers sign-in, token scopes, limits and
89 // GitHub's error shape.
910 //
@@ -328,6 +329,118 @@
328329 assert.equal((await search(`repo:ann/app is:pr`)).issueCount, 1);
329330 });
330331
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+
331444 test("a workflow's GITHUB_TOKEN uses GraphQL with its permissions, as over REST", async () => {
332445 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";
333446 await rest("PUT", "/repos/ann/app/contents/.github/workflows/gh.yml", { message: "gh in CI", content: Buffer.from(workflow).toString("base64") });