From 4a8ab273ca36c349609c5348eb95f467e906a5de Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 17:05:55 +0100 Subject: [PATCH 1/2] feat: add graph-source-github-issues package The GitHub issues adapter for the GraphSource port, ported from Graphle's GraphQL client and generalised: scopes name a repository or every repository an owner has, across any number of organisations. An issue is a bound activity whose actor is its repository, blockedBy is a transition from the blocker, sub-issues and assignees are relations, and open or closed is the overlay. Blockers and sub-issues in other repositories and organisations are included, and every nested connection is read to its end. Cursors record the scope, the owner's repository listing position and GitHub's issue cursor as plain data. Rate limiting in each of GitHub's forms is TOO_MANY_REQUESTS with the reset instant, HTTP 401 is UNAUTHORIZED, and NOT_FOUND or FORBIDDEN answers are gaps. The token comes from an injected port; the ./node entry point reads GITHUB_TOKEN or the GitHub CLI's token and never logs it. Offline tests replay exchanges recorded from public repositories and pass the conformance kit in process and over a message port; live tests run when PROCESS_GRAPH_LIVE_GITHUB=1 and are reported as skipped otherwise. --- README.md | 19 +- knip.config.ts | 4 + .../graph/source-github-issues/AGENTS.md | 1 + .../graph/source-github-issues/CLAUDE.md | 1 + .../graph/source-github-issues/README.md | 95 + .../source-github-issues/eslint.config.ts | 11 + .../graph/source-github-issues/package.json | 81 + .../scripts/record-fixtures.ts | 103 + .../graph/source-github-issues/src/cursor.ts | 152 + .../graph/source-github-issues/src/graphql.ts | 226 ++ .../src/graphql.unit.test.ts | 261 ++ .../source-github-issues/src/identifiers.ts | 60 + .../src/identifiers.unit.test.ts | 62 + .../graph/source-github-issues/src/index.ts | 20 + .../src/live.integration.test.ts | 60 + .../graph/source-github-issues/src/node.ts | 68 + .../src/node.unit.test.ts | 54 + .../source-github-issues/src/page-builder.ts | 124 + .../graph/source-github-issues/src/queries.ts | 131 + .../graph/source-github-issues/src/source.ts | 395 +++ .../src/source.unit.test.ts | 278 ++ .../src/test-support/fixtures/github.json | 2795 +++++++++++++++++ .../src/test-support/recording.ts | 45 + .../src/test-support/replay.ts | 62 + .../src/test-support/scopes.ts | 20 + .../src/test-support/sources.ts | 50 + .../source-github-issues/stryker.config.ts | 3 + .../test/package.smoke.test.ts | 47 + .../source-github-issues/tsconfig.build.json | 19 + .../graph/source-github-issues/tsconfig.json | 9 + .../source-github-issues/tsconfig.node.json | 8 + .../source-github-issues/vitest.config.ts | 23 + pnpm-lock.yaml | 40 + tsconfig.build.json | 3 + 34 files changed, 5321 insertions(+), 9 deletions(-) create mode 120000 packages/adapters/graph/source-github-issues/AGENTS.md create mode 120000 packages/adapters/graph/source-github-issues/CLAUDE.md create mode 100644 packages/adapters/graph/source-github-issues/README.md create mode 100644 packages/adapters/graph/source-github-issues/eslint.config.ts create mode 100644 packages/adapters/graph/source-github-issues/package.json create mode 100644 packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts create mode 100644 packages/adapters/graph/source-github-issues/src/cursor.ts create mode 100644 packages/adapters/graph/source-github-issues/src/graphql.ts create mode 100644 packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts create mode 100644 packages/adapters/graph/source-github-issues/src/identifiers.ts create mode 100644 packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts create mode 100644 packages/adapters/graph/source-github-issues/src/index.ts create mode 100644 packages/adapters/graph/source-github-issues/src/live.integration.test.ts create mode 100644 packages/adapters/graph/source-github-issues/src/node.ts create mode 100644 packages/adapters/graph/source-github-issues/src/node.unit.test.ts create mode 100644 packages/adapters/graph/source-github-issues/src/page-builder.ts create mode 100644 packages/adapters/graph/source-github-issues/src/queries.ts create mode 100644 packages/adapters/graph/source-github-issues/src/source.ts create mode 100644 packages/adapters/graph/source-github-issues/src/source.unit.test.ts create mode 100644 packages/adapters/graph/source-github-issues/src/test-support/fixtures/github.json create mode 100644 packages/adapters/graph/source-github-issues/src/test-support/recording.ts create mode 100644 packages/adapters/graph/source-github-issues/src/test-support/replay.ts create mode 100644 packages/adapters/graph/source-github-issues/src/test-support/scopes.ts create mode 100644 packages/adapters/graph/source-github-issues/src/test-support/sources.ts create mode 100644 packages/adapters/graph/source-github-issues/stryker.config.ts create mode 100644 packages/adapters/graph/source-github-issues/test/package.smoke.test.ts create mode 100644 packages/adapters/graph/source-github-issues/tsconfig.build.json create mode 100644 packages/adapters/graph/source-github-issues/tsconfig.json create mode 100644 packages/adapters/graph/source-github-issues/tsconfig.node.json create mode 100644 packages/adapters/graph/source-github-issues/vitest.config.ts diff --git a/README.md b/README.md index f63ec7a..34421b9 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,16 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config ## Packages -| Package | What it is | -| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | -| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | -| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | -| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | -| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | -| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | -| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | +| Package | What it is | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | +| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | +| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | +| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | +| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | +| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | +| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. | +| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | ## Conventions diff --git a/knip.config.ts b/knip.config.ts index 37306a3..f97958e 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -20,6 +20,10 @@ const config: KnipConfig = { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], }, + "packages/adapters/*/*": { + entry: ["src/index.{ts,tsx}"], + project: ["src/**/*.{ts,tsx}!"], + }, "packages/targets/*": { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], diff --git a/packages/adapters/graph/source-github-issues/AGENTS.md b/packages/adapters/graph/source-github-issues/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/adapters/graph/source-github-issues/CLAUDE.md b/packages/adapters/graph/source-github-issues/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/adapters/graph/source-github-issues/README.md b/packages/adapters/graph/source-github-issues/README.md new file mode 100644 index 0000000..0fb375b --- /dev/null +++ b/packages/adapters/graph/source-github-issues/README.md @@ -0,0 +1,95 @@ +# @exadev/process-graph-source-github-issues + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-source-github-issues) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +The GitHub issues adapter for the `GraphSource` port in `@exadev/process-graph-source-contract`. It reads issues, their blockers and sub-issues through GitHub's GraphQL API across any number of organisations and repositories, and returns them as pages of the process model. It passes the port's conformance kit. The main entry point imports nothing Node-only; `./node` adds a token helper for Node. + +```ts +import { createRouterClient } from "@orpc/server"; +import { + assembleSync, + createGraphSourceRouter, +} from "@exadev/process-graph-source-contract"; +import { createGitHubIssuesSource } from "@exadev/process-graph-source-github-issues"; +import { githubTokenFromEnvironment } from "@exadev/process-graph-source-github-issues/node"; + +const client = createRouterClient( + createGraphSourceRouter( + createGitHubIssuesSource({ token: githubTokenFromEnvironment() }), + ), +); + +const pages = []; +for await (const page of await client.v1.sync.stream({ + scope: ["github:ExaDev", "github:Mearman/agent-permissions"], +})) { + pages.push(page); +} +const { model, overlay, relations, gaps } = assembleSync(pages); +``` + +## Scopes + +| Scope | Reads | +| ------------------- | -------------------------------------------------------------------- | +| `github:owner/repo` | One repository's issues. | +| `github:owner` | The issues of every repository the account owns, by repository name. | + +Scopes are read in the order given, each once. A repository or account that does not exist, or that the token cannot see, is a gap on that scope and the sync carries on; anything else that is not one of the two forms is a `BAD_REQUEST` with reason `invalid-scope`. + +## The model it produces + +| GitHub | Model | +| ---------------------------- | ----------------------------------------------------------------------------------------- | +| Issue `owner/repo#n` | A bound activity `github:owner/repo#n`, labelled with its title, its actor the repository | +| Open or closed | `open` or `done` in the overlay | +| `blockedBy` | A transition from the blocker to the issue it blocks, `->` | +| Sub-issue | A `child-of` relation from the sub-issue to its parent | +| Assignee | A `role` participant `github:@login` and an `assigned-to` relation from the issue | +| Repository | A `system` participant `github:owner/repo`, `part-of` its owner | +| Organisation or user account | A participant `github:@login`: an organisation is a `system`, a user a `role` | + +Blockers and sub-issues in other repositories or organisations are included with their own repository and owner, so a dependency across organisations is a transition between their activities, and a hand-off between actors in the model. Every connection is read to its end: when an issue has more assignees, blockers or sub-issues than one request returns, the rest are read on their own. + +## Paging and resuming + +A page is one page of one repository's issues, oldest first. The cursor in a page's `next` records the scope, the position in an owner's repository listing and GitHub's own cursor into the issues, as plain data, so a sync resumes in another process. Issues are listed by creation time and repositories by name, so a position stays valid as new issues are opened. If an owner has fewer repositories than when the cursor was issued, the sync carries on after that listing page. + +## Errors and rate limits + +| GitHub's answer | Result | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| HTTP 401 | `UNAUTHORIZED` | +| HTTP 429, HTTP 403 with no requests remaining or naming a secondary rate limit, or a `RATE_LIMITED` GraphQL error | `TOO_MANY_REQUESTS` with `resetAt` when GitHub gives one (`x-ratelimit-reset`, or `retry-after` from the injected clock) | +| A `NOT_FOUND` or `FORBIDDEN` GraphQL error | A gap, with GitHub's message | +| Anything else | An error that ends the call | + +A rate-limited sync resumes from the last page's `next` once the limit resets. + +## Tokens + +The token comes from a `GitHubTokenPort`, `(owner, signal) => Promise`, asked for the owner of each repository read, so different organisations can use different credentials. The token is sent only in the `Authorization` header and never logged or put in an error. + +`githubTokenFromEnvironment()` from `./node` uses `GITHUB_TOKEN` when it is set and not empty, and otherwise asks the GitHub CLI once (`gh auth token`). A failure is reported as `unauthorised` without the CLI's output. + +## Options + +| Option | Default | Meaning | +| ---------- | ----------------------------- | -------------------------------------------------- | +| `token` | required | The token port. | +| `fetch` | the global `fetch` | How requests reach GitHub. | +| `now` | `Date.now` | The clock, for `retry-after`. | +| `pageSize` | half of GitHub's limit of 100 | Issues per page, and connection items per request. | + +## Tests + +The offline tests replay GraphQL exchanges recorded from public repositories (`src/test-support/fixtures/github.json`): GitHub's demo organisation as an owner scope, and two repositories whose issues block each other across repositories and organisations. The fixture holds request documents, variables, statuses and bodies, never headers or tokens. Re-record it after a build with `node scripts/record-fixtures.ts`, with a token in `GITHUB_TOKEN` or the GitHub CLI logged in. + +The live tests run the conformance kit and a cross-organisation sync against the real API when `PROCESS_GRAPH_LIVE_GITHUB=1` is set, and are reported as skipped otherwise. + +```sh +pnpm build # tsc -b -> dist/ (ESM + .d.ts) +pnpm test # unit tests, including the conformance kit over the recorded fixtures +PROCESS_GRAPH_LIVE_GITHUB=1 pnpm test:integration # the live tests +pnpm test:smoke # the built dist/ exercised as a real artefact +``` diff --git a/packages/adapters/graph/source-github-issues/eslint.config.ts b/packages/adapters/graph/source-github-issues/eslint.config.ts new file mode 100644 index 0000000..7211ee7 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/eslint.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "eslint/config"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + // The Node entry point: it runs the GitHub CLI for a token, so it is Node-only by purpose and published as its own `./node` export. + isomorphicExemptions: ["src/node.ts"], + }), +); diff --git a/packages/adapters/graph/source-github-issues/package.json b/packages/adapters/graph/source-github-issues/package.json new file mode 100644 index 0000000..8c4b9f4 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/package.json @@ -0,0 +1,81 @@ +{ + "name": "@exadev/process-graph-source-github-issues", + "version": "0.0.0", + "description": "The GitHub issues GraphSource adapter: issues as bound activities, blockedBy as transitions, sub-issues, repositories, organisations and assignees, read through the GraphQL API with pagination, rate-limit handling and typed errors across organisations and repositories.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/adapters/graph/source-github-issues" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/adapters/graph/source-github-issues#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:integration": "turbo run _test:integration", + "_test:integration": "vitest run --project integration", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only" + }, + "keywords": [ + "process-model", + "github", + "issues", + "graphql", + "dependencies", + "orpc" + ], + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.6.4", + "@vitest/coverage-v8": "5.0.3", + "eslint": "10.12.0", + "publint": "0.3.25", + "typescript": "6.0.3", + "vitest": "5.0.3" + }, + "dependencies": { + "@exadev/process-graph-source-contract": "0.0.0", + "zod": "4.6.5" + } +} diff --git a/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts b/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts new file mode 100644 index 0000000..feeebc1 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts @@ -0,0 +1,103 @@ +/** + * Records the GitHub GraphQL exchanges the adapter's offline tests replay. Run from the package directory after a build, with a token in `GITHUB_TOKEN` or the GitHub CLI logged in: + * + * node scripts/record-fixtures.ts + * + * It syncs the fixture scopes through the adapter with a fetch that records each request's document and variables and GitHub's status and body, and writes them to `src/test-support/fixtures/github.json`. No header is recorded, so the token never reaches the file. The scopes are public repositories, so the fixture holds only public data. + */ +import { writeFileSync } from "node:fs"; +import { + assembleSync, + createGraphSourceRouter, + type SyncPage, +} from "@exadev/process-graph-source-contract"; +import { inProcessClient } from "@exadev/process-graph-source-contract/conformance"; +import { createGitHubIssuesSource } from "../src/source.ts"; +import { githubTokenFromEnvironment } from "../src/node.ts"; +import { + type Exchange, + graphqlRequestOf, + type Recording, + requestKey, +} from "../src/test-support/recording.ts"; +import { + fixturePageSize, + fixtureScope, + missingOwnerScope, + missingRepositoryScope, + unauthorisedScope, +} from "../src/test-support/scopes.ts"; + +const exchanges = new Map(); + +const recordingFetch = async ( + url: string, + init: RequestInit, +): Promise => { + const request = graphqlRequestOf(init.body); + const response = await fetch(url, init); + const body: unknown = await response.json(); + exchanges.set(requestKey(request), { + request, + response: { status: response.status, body }, + }); + + return new Response(JSON.stringify(body), { status: response.status }); +}; + +const walk = async ( + token: ReturnType, + scope: readonly string[], +): Promise => { + const client = inProcessClient( + createGraphSourceRouter( + createGitHubIssuesSource({ + token, + fetch: recordingFetch, + pageSize: fixturePageSize, + }), + ), + ); + const pages: SyncPage[] = []; + for await (const page of await client.v1.sync.stream({ scope })) { + pages.push(page); + } + + return pages; +}; + +const token = githubTokenFromEnvironment(); +const pages = await walk(token, fixtureScope); +await walk(token, [missingOwnerScope, missingRepositoryScope]); +// A made-up token, so GitHub's answer to bad credentials is recorded too. Its scope is read nowhere else, so its request has its own key. +await walk( + async () => Promise.resolve("not-a-real-token"), + [unauthorisedScope], +).then( + () => { + throw new Error("expected GitHub to reject the made-up token"); + }, + (error: unknown) => { + if (!( + error instanceof Error && + "code" in error && + error.code === "UNAUTHORIZED" + )) { + throw error; + } + }, +); + +const recording: Recording = { + exchanges: [...exchanges.values()].sort((a, b) => + requestKey(a.request).localeCompare(requestKey(b.request)), + ), +}; +writeFileSync( + new URL("../src/test-support/fixtures/github.json", import.meta.url), + `${JSON.stringify(recording, null, 2)}\n`, +); +const { model } = assembleSync(pages); +console.log( + `recorded ${String(recording.exchanges.length)} exchanges; ${String(pages.length)} pages, ${String(model.elements.length)} activities, ${String(model.transitions.length)} transitions`, +); diff --git a/packages/adapters/graph/source-github-issues/src/cursor.ts b/packages/adapters/graph/source-github-issues/src/cursor.ts new file mode 100644 index 0000000..9b40820 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/cursor.ts @@ -0,0 +1,152 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import { z } from "zod"; +import type { Scope } from "./identifiers.ts"; + +/** + * Where the next page of a sync starts. A position is plain data, so a cursor resumes in any process. + * + * - `scope` is the scope identifier being read; a cursor is valid only for a request whose scope list contains it. + * - For an owner scope, `repositories` is the `after` cursor GitHub gave for the page of the owner's repositories that holds the current one (`null` for the first), and `repository` is its index within that page. Repositories are listed by name, so the same listing page returns the same repositories while the owner is unchanged. + * - `issues` is the `after` cursor for the next page of the current repository's issues (`null` for the first). Issues are listed by creation time, so new issues append and never shift a position. + */ +const repositoryPositionSchema = z.strictObject({ + scope: z.string(), + issues: z.string().nullable(), +}); + +const ownerPositionSchema = z.strictObject({ + scope: z.string(), + repositories: z.string().nullable(), + repository: z.int().nonnegative(), + issues: z.string().nullable(), +}); + +const positionSchema = z.union([repositoryPositionSchema, ownerPositionSchema]); + +/** A position within a repository scope. */ +type RepositoryPosition = z.infer; +/** A position within an owner scope. */ +type OwnerPosition = z.infer; +/** A position within a sync. */ +export type Position = RepositoryPosition | OwnerPosition; + +/** A position together with the scope it is in, matched by kind. `kind` repeats the scope's kind so the pair narrows as one. */ +export type Located = + | { + readonly kind: "repository"; + readonly scope: Extract; + readonly position: RepositoryPosition; + } + | { + readonly kind: "owner"; + readonly scope: Extract; + readonly position: OwnerPosition; + }; + +/** + * The start of a scope. + * @param scope - The scope. + * @returns The scope with the position of its first page. + */ +export const startOf = (scope: Scope): Located => + scope.kind === "owner" + ? { + kind: "owner", + scope, + position: { + scope: scope.id, + repositories: null, + repository: 0, + issues: null, + }, + } + : { + kind: "repository", + scope, + position: { scope: scope.id, issues: null }, + }; + +/** The version tag inside every cursor, so a cursor from a later format is rejected rather than misread. */ +const cursorFormat = 1; + +const envelopeSchema = z.strictObject({ + format: z.literal(cursorFormat), + position: positionSchema, +}); + +/** + * Encodes a position as an opaque cursor: base64 of a small JSON envelope, which is printable and free of the line breaks an event-stream id must not contain. + * @param position - Where the next page starts. + * @returns The cursor. + */ +export const encodeCursor = (position: Position): string => + btoa(JSON.stringify({ format: cursorFormat, position })); + +const invalidCursor = (cursor: string, message: string): GraphSourceError => + new GraphSourceError({ + kind: "bad-request", + message: `"${cursor}" is not a cursor this source issued: ${message}`, + reason: "invalid-cursor", + value: cursor, + }); + +/** Parses JSON from a decoded cursor, reporting malformed text as an invalid cursor. */ +const parseJson = (cursor: string, text: string): unknown => { + try { + const parsed: unknown = JSON.parse(text); + + return parsed; + } catch (error) { + throw invalidCursor( + cursor, + error instanceof Error ? error.message : "not JSON", + ); + } +}; + +/** Decodes base64, reporting text outside the base64 alphabet as an invalid cursor. */ +const decodeBase64 = (cursor: string): string => { + try { + return atob(cursor); + } catch { + throw invalidCursor(cursor, "not base64"); + } +}; + +/** + * Decodes a cursor this adapter issued. + * @param cursor - The cursor from an earlier page's `next`. + * @param scopes - The scopes of the request it is used with. + * @returns The position it encodes, with its scope. + * @throws GraphSourceError with `bad-request` and `invalid-cursor` when it is not a cursor this adapter issued, names a scope the request does not contain, or has the shape of the other kind of scope. + */ +export const decodeCursor = ( + cursor: string, + scopes: readonly Scope[], +): Located => { + const envelope = envelopeSchema.safeParse( + parseJson(cursor, decodeBase64(cursor)), + ); + if (!envelope.success) { + throw invalidCursor(cursor, envelope.error.message); + } + const { position } = envelope.data; + const scope = scopes.find((candidate) => candidate.id === position.scope); + if (scope === undefined) { + throw invalidCursor( + cursor, + `it is for scope ${position.scope}, which the request does not contain`, + ); + } + if (scope.kind === "owner" && "repositories" in position) { + return { kind: "owner", scope, position }; + } + if (scope.kind === "repository" && !("repositories" in position)) { + return { kind: "repository", scope, position }; + } + + throw invalidCursor( + cursor, + `it does not fit the ${scope.kind} scope ${scope.id}`, + ); +}; diff --git a/packages/adapters/graph/source-github-issues/src/graphql.ts b/packages/adapters/graph/source-github-issues/src/graphql.ts new file mode 100644 index 0000000..b64fe01 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/graphql.ts @@ -0,0 +1,226 @@ +import { + GraphSourceError, + type GapReason, +} from "@exadev/process-graph-source-contract"; +import type { z } from "zod"; + +/** GitHub's GraphQL endpoint. */ +export const githubGraphqlEndpoint = "https://api.github.com/graphql"; + +/** + * Supplies the token for requests about one owner's repositories, so different organisations can use different credentials. The token is only ever sent in the `Authorization` header; this adapter never logs or returns it. + * @param owner - The login of the account whose repositories the request reads. + * @param signal - The caller's abort signal. + * @returns The token. + */ +export type GitHubTokenPort = ( + owner: string, + signal: AbortSignal | undefined, +) => Promise; + +/** The part of `fetch` this adapter uses: one POST to the GraphQL endpoint. The global `fetch` satisfies it. */ +export type GraphqlFetch = ( + url: string, + init: RequestInit, +) => Promise; + +/** How requests reach GitHub. `fetch` and `now` are ports, so tests replay recorded responses and fix the clock. */ +export interface GraphqlTransport { + readonly token: GitHubTokenPort; + readonly fetch: GraphqlFetch; + /** The current time in milliseconds since the epoch, for turning a `retry-after` delay into a reset instant. */ + readonly now: () => number; +} + +/** A GraphQL error that leaves the rest of the response usable: part of what was asked for does not exist or is not visible to the token. */ +export interface Problem { + readonly reason: GapReason; + readonly message: string; +} + +/** A parsed response: its data, and the problems that nulled parts of it. */ +export interface QueryResult { + readonly data: Data; + readonly problems: readonly Problem[]; +} + +const millisecondsPerSecond = 1000; + +/** GraphQL error types that mean part of the data is absent rather than that the request failed. */ +const problemReasons: Readonly> = { + NOT_FOUND: "not-found", + FORBIDDEN: "forbidden", +}; + +interface GraphqlError { + readonly type: string | undefined; + readonly message: string; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The `errors` entries of a GraphQL response body, each with its `type` when GitHub gave one. */ +const errorsOf = (body: unknown): readonly GraphqlError[] => { + if (!isRecord(body) || !Array.isArray(body.errors)) { + return []; + } + const entries: readonly unknown[] = body.errors; + + return entries.filter(isRecord).map((entry) => ({ + type: typeof entry.type === "string" ? entry.type : undefined, + message: + typeof entry.message === "string" ? entry.message : "no message given", + })); +}; + +/** The `message` of a REST-style error body, which GitHub sends for HTTP-level refusals. */ +const messageOf = (body: unknown): string | undefined => + isRecord(body) && typeof body.message === "string" ? body.message : undefined; + +/** + * When GitHub says its limit resets: `x-ratelimit-reset` (epoch seconds) for the primary limit, `retry-after` (seconds from now) for a secondary limit. `undefined` when it gives neither. + */ +const resetAtOf = ( + headers: Readonly, + now: number, +): string | undefined => { + const reset = headers.get("x-ratelimit-reset"); + if (reset !== null && /^\d+$/u.test(reset)) { + return new Date(Number(reset) * millisecondsPerSecond).toISOString(); + } + const retryAfter = headers.get("retry-after"); + if (retryAfter !== null && /^\d+$/u.test(retryAfter)) { + return new Date( + now + Number(retryAfter) * millisecondsPerSecond, + ).toISOString(); + } + + return undefined; +}; + +/** + * Whether a response is GitHub refusing for rate limiting: HTTP 429; HTTP 403 with no requests remaining or naming a secondary rate limit; or a `RATE_LIMITED` GraphQL error, which GitHub sends with HTTP 200. + */ +const isRateLimited = ( + response: Response, + body: unknown, + errors: readonly GraphqlError[], +): boolean => { + const tooManyRequests = 429; + const forbidden = 403; + if (response.status === tooManyRequests) { + return true; + } + if (errors.some((error) => error.type === "RATE_LIMITED")) { + return true; + } + + const message = messageOf(body); + + return ( + response.status === forbidden && + (response.headers.get("x-ratelimit-remaining") === "0" || + (message !== undefined && /secondary rate limit/iu.test(message))) + ); +}; + +/** Reads a response body as JSON, failing loudly when it is not. */ +const readJson = async (response: Response): Promise => { + const text = await response.text(); + try { + const parsed: unknown = JSON.parse(text); + + return parsed; + } catch { + throw new Error( + `GitHub answered HTTP ${String(response.status)} with a body that is not JSON`, + ); + } +}; + +/** One GraphQL request. */ +export interface GraphqlRequest { + /** The GraphQL document. */ + readonly query: string; + /** Its variables. */ + readonly variables: Readonly>; + /** The login whose token authorises the request. */ + readonly owner: string; + /** The schema `data` must match. */ + readonly schema: Schema; +} + +/** + * Sends one GraphQL request and classifies the answer. + * @param transport - The token, fetch and clock ports. + * @param request - The document, its variables, the owner whose token to use and the schema for `data`. + * @param signal - The caller's abort signal, passed to `fetch`. + * @returns The parsed data and any `NOT_FOUND` or `FORBIDDEN` problems beside it. + * @throws GraphSourceError with `unauthorised` for HTTP 401, and `rate-limited` (with the reset instant when GitHub gives one) for any form of rate limiting. + * @throws Error for any other failure: an HTTP error, another GraphQL error type, or data that does not match the schema. + */ +export const graphql = async ( + transport: GraphqlTransport, + request: GraphqlRequest, + signal: AbortSignal | undefined, +): Promise>> => { + const token = await transport.token(request.owner, signal); + const response = await transport.fetch(githubGraphqlEndpoint, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + query: request.query, + variables: request.variables, + }), + signal: signal ?? null, + }); + const body = await readJson(response); + const errors = errorsOf(body); + const unauthorised = 401; + + if (response.status === unauthorised) { + throw new GraphSourceError({ + kind: "unauthorised", + message: `GitHub rejected the token for ${request.owner}: ${messageOf(body) ?? "HTTP 401"}`, + }); + } + if (isRateLimited(response, body, errors)) { + const resetAt = resetAtOf(response.headers, transport.now()); + throw new GraphSourceError({ + kind: "rate-limited", + message: + resetAt === undefined + ? "GitHub is rate limiting requests" + : `GitHub is rate limiting requests until ${resetAt}`, + resetAt, + }); + } + if (!response.ok) { + throw new Error( + `GitHub answered HTTP ${String(response.status)}: ${messageOf(body) ?? "no message given"}`, + ); + } + const problems = errors.map((error) => { + const reason = + error.type === undefined ? undefined : problemReasons[error.type]; + if (reason === undefined) { + throw new Error( + `GitHub GraphQL error${error.type === undefined ? "" : ` ${error.type}`}: ${error.message}`, + ); + } + + return { reason, message: error.message }; + }); + const data = request.schema.safeParse(isRecord(body) ? body.data : undefined); + if (!data.success) { + throw new Error( + `GitHub's response does not have the expected shape: ${data.error.message}`, + ); + } + + return { data: data.data, problems }; +}; diff --git a/packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts b/packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts new file mode 100644 index 0000000..a07716c --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts @@ -0,0 +1,261 @@ +import { inProcessClient } from "@exadev/process-graph-source-contract/conformance"; +import { describe, expect, test } from "vitest"; +import type { GraphqlFetch } from "./graphql.ts"; +import { fakeToken, fixedNow, stubbedRouter } from "./test-support/sources.ts"; + +const scope = ["github:ExaDev/document-schema.js"]; + +const ok = 200; +const forbidden = 403; +const tooManyRequests = 429; +const badGateway = 502; +const millisecondsPerSecond = 1000; +const retryAfterSeconds = 60; + +/** A fetch that answers every request with one response the test writes. */ +const answering = + ( + status: number, + body: unknown, + headers: Readonly> = {}, + ): GraphqlFetch => + async () => + Promise.resolve(new Response(JSON.stringify(body), { status, headers })); + +const pageWith = async (fetch: GraphqlFetch) => + inProcessClient(stubbedRouter(fetch)).v1.sync.page({ scope }); + +/** An issue as the repository query returns it, with no assignees, blockers or sub-issues unless given. */ +const issue = ( + number: number, + extra: Readonly> = {}, +) => { + const empty = { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }; + + return { + number, + title: `Issue ${String(number)}`, + state: "OPEN", + repository: { + name: "document-schema.js", + owner: { __typename: "Organization", login: "ExaDev" }, + }, + assignees: empty, + blockedBy: empty, + subIssues: empty, + ...extra, + }; +}; + +const repositoryWith = (nodes: readonly unknown[]) => ({ + data: { + repository: { + name: "document-schema.js", + owner: { __typename: "Organization", login: "ExaDev" }, + issues: { pageInfo: { hasNextPage: false, endCursor: null }, nodes }, + }, + }, +}); + +describe("rate limiting", () => { + const resetSeconds = fixedNow / millisecondsPerSecond + retryAfterSeconds; + const resetAt = new Date(resetSeconds * millisecondsPerSecond).toISOString(); + + test.each([ + [ + "HTTP 429 with retry-after", + answering( + tooManyRequests, + { message: "slow down" }, + { "retry-after": String(retryAfterSeconds) }, + ), + ], + [ + "HTTP 403 with no requests remaining", + answering( + forbidden, + { message: "API rate limit exceeded" }, + { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetSeconds), + }, + ), + ], + [ + "a RATE_LIMITED GraphQL error", + answering( + ok, + { + data: null, + errors: [ + { type: "RATE_LIMITED", message: "API rate limit exceeded" }, + ], + }, + { + "x-ratelimit-reset": String(resetSeconds), + }, + ), + ], + [ + "HTTP 403 naming a secondary rate limit", + answering( + forbidden, + { message: "You have exceeded a secondary rate limit." }, + { "retry-after": String(retryAfterSeconds) }, + ), + ], + ])("%s is TOO_MANY_REQUESTS with the reset instant", async (_, fetch) => { + await expect(pageWith(fetch)).rejects.toMatchObject({ + code: "TOO_MANY_REQUESTS", + defined: true, + data: { resetAt }, + }); + }); + + test("a rate limit without a reset instant still says when it is unknown", async () => { + await expect( + pageWith(answering(tooManyRequests, { message: "slow down" })), + ).rejects.toMatchObject({ + code: "TOO_MANY_REQUESTS", + data: {}, + }); + }); +}); + +describe("errors", () => { + test("a FORBIDDEN repository is a forbidden gap", async () => { + const page = await pageWith( + answering(ok, { + data: { repository: null }, + errors: [ + { + type: "FORBIDDEN", + message: "Resource protected by organization SAML enforcement.", + }, + ], + }), + ); + expect(page.gaps).toEqual([ + { + target: scope[0], + reason: "forbidden", + message: "Resource protected by organization SAML enforcement.", + }, + ]); + expect(page.activities).toEqual([]); + }); + + test("a GraphQL error of another type fails the call rather than being read as a gap", async () => { + await expect( + pageWith( + answering(ok, { + data: null, + errors: [{ type: "INTERNAL", message: "boom" }], + }), + ), + ).rejects.toThrow("GitHub GraphQL error INTERNAL: boom"); + }); + + test("a response of the wrong shape fails the call", async () => { + await expect( + pageWith(answering(ok, { data: { repository: { name: 1 } } })), + ).rejects.toThrow("GitHub's response does not have the expected shape"); + }); + + test("an HTTP error that is not rate limiting fails the call", async () => { + await expect( + pageWith(answering(badGateway, { message: "Bad gateway" })), + ).rejects.toThrow("GitHub answered HTTP 502: Bad gateway"); + }); + + test("a body that is not JSON fails the call", async () => { + await expect( + pageWith(async () => + Promise.resolve(new Response("", { status: ok })), + ), + ).rejects.toThrow("GitHub answered HTTP 200 with a body that is not JSON"); + }); +}); + +describe("requests", () => { + test("send the token as a bearer token to the GraphQL endpoint, with the caller's signal", async () => { + const seen: { url: string; init: RequestInit }[] = []; + const controller = new AbortController(); + await inProcessClient( + stubbedRouter(async (url, init) => { + seen.push({ url, init }); + + return Promise.resolve( + new Response(JSON.stringify(repositoryWith([])), { status: ok }), + ); + }), + ).v1.sync.page({ scope }, { signal: controller.signal }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.github.com/graphql"); + expect(new Headers(seen[0]?.init.headers).get("authorization")).toBe( + `Bearer ${fakeToken}`, + ); + expect(seen[0]?.init.signal).toBeInstanceOf(AbortSignal); + }); + + test("assignees become role participants related to their issue", async () => { + const page = await pageWith( + answering( + ok, + repositoryWith([ + issue(1, { + assignees: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ login: "octocat" }], + }, + }), + ]), + ), + ); + expect(page.participants).toContainEqual({ + id: "github:@octocat", + label: "octocat", + kind: "role", + }); + expect(page.relations).toContainEqual({ + kind: "assigned-to", + from: "github:ExaDev/document-schema.js#1", + to: "github:@octocat", + }); + expect(page.overlay).toEqual({ + "github:ExaDev/document-schema.js#1": "open", + }); + }); + + test("a node the token cannot see is skipped, and GitHub's error for it is a gap", async () => { + const page = await pageWith( + answering(ok, { + ...repositoryWith([ + issue(1, { + blockedBy: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [null], + }, + }), + ]), + errors: [ + { + type: "FORBIDDEN", + message: "Resource not accessible by integration", + }, + ], + }), + ); + expect(page.transitions).toEqual([]); + expect(page.gaps).toEqual([ + { + target: scope[0], + reason: "forbidden", + message: "Resource not accessible by integration", + }, + ]); + }); +}); diff --git a/packages/adapters/graph/source-github-issues/src/identifiers.ts b/packages/adapters/graph/source-github-issues/src/identifiers.ts new file mode 100644 index 0000000..6209a3a --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/identifiers.ts @@ -0,0 +1,60 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; + +/** The prefix of every identifier this adapter emits and every scope it accepts. */ +export const githubSource = "github"; + +const prefix = `${githubSource}:`; + +/** + * A GitHub login or repository name as GitHub allows them: letters, digits, hyphens, underscores and dots. Anything else cannot name an account or repository, so a scope containing it is malformed rather than missing. + */ +const namePattern = /^[\w.-]+$/u; + +/** What one scope identifier selects. */ +export type Scope = + | { readonly kind: "owner"; readonly id: string; readonly owner: string } + | { + readonly kind: "repository"; + readonly id: string; + readonly owner: string; + readonly name: string; + }; + +/** + * Parses a scope identifier: `github:owner` selects every repository the account owns, `github:owner/repo` selects one. + * @param id - The scope identifier. + * @returns The parsed scope. + * @throws GraphSourceError with `bad-request` and `invalid-scope` when it is neither form. + */ +export const parseScope = (id: string): Scope => { + const parts = id.startsWith(prefix) ? id.slice(prefix.length).split("/") : []; + const [owner, name, ...rest] = parts; + const valid = + owner !== undefined && + namePattern.test(owner) && + rest.length === 0 && + (name === undefined || namePattern.test(name)); + if (!valid) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${id}" is not a GitHub scope; expected github:owner or github:owner/repo`, + reason: "invalid-scope", + value: id, + }); + } + + return name === undefined + ? { kind: "owner", id, owner } + : { kind: "repository", id, owner, name }; +}; + +/** The identifier of an issue: `github:owner/repo#number`, which is also its element identifier. */ +export const issueId = (owner: string, name: string, number: number): string => + `${prefix}${owner}/${name}#${String(number)}`; + +/** The identifier of a repository participant: `github:owner/repo`. */ +export const repositoryId = (owner: string, name: string): string => + `${prefix}${owner}/${name}`; + +/** The identifier of an account participant, organisation or user: `github:@login`. The `@` keeps it apart from the `github:owner` scope syntax. */ +export const accountId = (login: string): string => `${prefix}@${login}`; diff --git a/packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts b/packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts new file mode 100644 index 0000000..2dfdd28 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts @@ -0,0 +1,62 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import { describe, expect, test } from "vitest"; +import { accountId, issueId, parseScope, repositoryId } from "./identifiers.ts"; + +/** The failure a call throws as a `GraphSourceError`, or `undefined` when it does not. */ +const failureOf = ( + call: () => unknown, +): GraphSourceError["failure"] | undefined => { + try { + call(); + } catch (error) { + if (error instanceof GraphSourceError) { + return error.failure; + } + } + + return undefined; +}; + +describe("parseScope", () => { + test("reads an owner scope and a repository scope", () => { + expect(parseScope("github:ExaDev")).toEqual({ + kind: "owner", + id: "github:ExaDev", + owner: "ExaDev", + }); + expect(parseScope("github:ExaDev/document-schema.js")).toEqual({ + kind: "repository", + id: "github:ExaDev/document-schema.js", + owner: "ExaDev", + name: "document-schema.js", + }); + }); + + test.each([ + "ExaDev", + "github:", + "github:/repo", + "github:a/b/c", + "github:a b", + "linear:team", + "github:ExaDev/", + ])("rejects %s as invalid-scope", (scope) => { + expect(failureOf(() => parseScope(scope))).toEqual({ + kind: "bad-request", + message: `"${scope}" is not a GitHub scope; expected github:owner or github:owner/repo`, + reason: "invalid-scope", + value: scope, + }); + }); +}); + +describe("identifiers", () => { + test("are source-qualified", () => { + const number = 11; + expect(issueId("ExaDev", "trilean", number)).toBe( + "github:ExaDev/trilean#11", + ); + expect(repositoryId("ExaDev", "trilean")).toBe("github:ExaDev/trilean"); + expect(accountId("ExaDev")).toBe("github:@ExaDev"); + }); +}); diff --git a/packages/adapters/graph/source-github-issues/src/index.ts b/packages/adapters/graph/source-github-issues/src/index.ts new file mode 100644 index 0000000..57544fa --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/index.ts @@ -0,0 +1,20 @@ +/** + * The GitHub issues `GraphSource` adapter. `createGitHubIssuesSource` returns a pager for `createGraphSourceRouter` from `@exadev/process-graph-source-contract`. The token comes from an injected port; `githubTokenFromEnvironment` in the `./node` entry point reads `GITHUB_TOKEN` or the GitHub CLI's token. + */ +export { + createGitHubIssuesSource, + type GitHubIssuesSourceOptions, +} from "./source.ts"; +export { + githubGraphqlEndpoint, + type GitHubTokenPort, + type GraphqlFetch, +} from "./graphql.ts"; +export { + accountId, + githubSource, + issueId, + parseScope, + repositoryId, + type Scope, +} from "./identifiers.ts"; diff --git a/packages/adapters/graph/source-github-issues/src/live.integration.test.ts b/packages/adapters/graph/source-github-issues/src/live.integration.test.ts new file mode 100644 index 0000000..1cc5d4a --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/live.integration.test.ts @@ -0,0 +1,60 @@ +import { + assembleSync, + createGraphSourceRouter, + type SyncPage, +} from "@exadev/process-graph-source-contract"; +import { + graphSourceConformance, + inProcessClient, +} from "@exadev/process-graph-source-contract/conformance"; +import { describe, expect, test } from "vitest"; +import { githubTokenFromEnvironment } from "./node.ts"; +import { createGitHubIssuesSource } from "./source.ts"; +import { fixtureScope, missingOwnerScope } from "./test-support/scopes.ts"; + +/** + * Live tests against GitHub run only when `PROCESS_GRAPH_LIVE_GITHUB` is `1`, with a token in `GITHUB_TOKEN` or the GitHub CLI logged in. Without it they are reported as skipped, never silently absent. + */ +const live = process.env.PROCESS_GRAPH_LIVE_GITHUB === "1"; + +/** At the default page size: the owner scope still spans a page per repository, so resumption is exercised with fewer requests. */ +const liveRouter = () => + createGraphSourceRouter( + createGitHubIssuesSource({ token: githubTokenFromEnvironment() }), + ); + +/** + * No per-test time limit: how long the live suite takes is GitHub's, not this code's, and each request is already bounded by GitHub's own server-side timeout for GraphQL requests. + */ +const unbounded = 0; + +describe.skipIf(!live)( + "GitHub issues against the live API (set PROCESS_GRAPH_LIVE_GITHUB=1 to run)", + { timeout: unbounded }, + () => { + graphSourceConformance({ + name: "GitHub issues, live", + createClient: () => inProcessClient(liveRouter()), + scope: fixtureScope, + missingScope: missingOwnerScope, + malformedScope: "ExaDev/document-schema.js", + }); + + test("a sync across two organisations keeps the transition between them", async () => { + const pages: SyncPage[] = []; + const client = inProcessClient(liveRouter()); + for await (const page of await client.v1.sync.stream({ + scope: [ + "github:Mearman/agent-permissions", + "github:ExaDev/document-schema.js", + ], + })) { + pages.push(page); + } + const { model } = assembleSync(pages); + expect(model.transitions.map((transition) => transition.id)).toContain( + "github:ExaDev/trilean#11->github:Mearman/agent-permissions#26", + ); + }); + }, +); diff --git a/packages/adapters/graph/source-github-issues/src/node.ts b/packages/adapters/graph/source-github-issues/src/node.ts new file mode 100644 index 0000000..961f4f6 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/node.ts @@ -0,0 +1,68 @@ +import { execFile } from "node:child_process"; +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import type { GitHubTokenPort } from "./graphql.ts"; + +/** Runs `gh auth token` and resolves with its standard output. */ +const ghAuthToken = async (): Promise => + new Promise((resolve, reject) => { + execFile("gh", ["auth", "token"], (error, stdout) => { + if (error === null) { + resolve(stdout); + } else { + reject(new Error("gh auth token failed", { cause: error })); + } + }); + }); + +/** Where `githubTokenFromEnvironment` looks. Both are ports, so a test supplies its own. */ +export interface EnvironmentTokenOptions { + /** The environment to read `GITHUB_TOKEN` from; `process.env` when absent. */ + readonly env?: Readonly>; + /** Runs `gh auth token`; the GitHub CLI when absent. */ + readonly runGh?: () => Promise; +} + +/** + * A token port for Node that uses `GITHUB_TOKEN` when it is set and not empty, and otherwise the token the GitHub CLI holds (`gh auth token`), asked for once and reused. The same token serves every owner. The token is never logged, and an error from the CLI is reported without its output. + * @param options - The environment and the CLI runner, for tests. + * @returns The token port. + */ +export const githubTokenFromEnvironment = ( + options: EnvironmentTokenOptions = {}, +): GitHubTokenPort => { + const env = options.env ?? process.env; + const runGh = options.runGh ?? ghAuthToken; + const fromEnvironment = env.GITHUB_TOKEN; + let fromCli: Promise | undefined; + + return async () => { + if (fromEnvironment !== undefined && fromEnvironment !== "") { + return fromEnvironment; + } + fromCli ??= runGh().then( + (output) => { + const token = output.trim(); + if (token === "") { + throw new GraphSourceError({ + kind: "unauthorised", + message: "gh auth token printed no token; run gh auth login", + }); + } + + return token; + }, + (error: unknown) => { + throw new GraphSourceError( + { + kind: "unauthorised", + message: + "no GitHub token: GITHUB_TOKEN is not set and gh auth token failed; set GITHUB_TOKEN or run gh auth login", + }, + { cause: error }, + ); + }, + ); + + return fromCli; + }; +}; diff --git a/packages/adapters/graph/source-github-issues/src/node.unit.test.ts b/packages/adapters/graph/source-github-issues/src/node.unit.test.ts new file mode 100644 index 0000000..8abb964 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/node.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { githubTokenFromEnvironment } from "./node.ts"; + +const tokenPrintedByGh = "token-from-gh"; + +describe("githubTokenFromEnvironment", () => { + test("uses GITHUB_TOKEN when it is set, without running gh", async () => { + const port = githubTokenFromEnvironment({ + env: { GITHUB_TOKEN: "token-from-env" }, + runGh: async () => Promise.reject(new Error("gh must not run")), + }); + await expect(port("ExaDev", undefined)).resolves.toBe("token-from-env"); + }); + + test("asks gh once when GITHUB_TOKEN is absent or empty, trimming its output", async () => { + let runs = 0; + const port = githubTokenFromEnvironment({ + env: { GITHUB_TOKEN: "" }, + runGh: async () => { + runs += 1; + + return Promise.resolve(`${tokenPrintedByGh}\n`); + }, + }); + await expect(port("ExaDev", undefined)).resolves.toBe(tokenPrintedByGh); + await expect(port("Mearman", undefined)).resolves.toBe(tokenPrintedByGh); + expect(runs).toBe(1); + }); + + test("reports a failing gh as unauthorised, without gh's output", async () => { + const port = githubTokenFromEnvironment({ + env: {}, + runGh: async () => Promise.reject(new Error("gh: secret-looking output")), + }); + const failure = port("ExaDev", undefined); + await expect(failure).rejects.toMatchObject({ + failure: { kind: "unauthorised" }, + }); + await expect(failure).rejects.not.toSatisfy( + (error: unknown) => + error instanceof Error && error.message.includes("secret-looking"), + ); + }); + + test("reports an empty gh token as unauthorised", async () => { + const port = githubTokenFromEnvironment({ + env: {}, + runGh: async () => Promise.resolve("\n"), + }); + await expect(port("ExaDev", undefined)).rejects.toMatchObject({ + failure: { kind: "unauthorised" }, + }); + }); +}); diff --git a/packages/adapters/graph/source-github-issues/src/page-builder.ts b/packages/adapters/graph/source-github-issues/src/page-builder.ts new file mode 100644 index 0000000..5bff894 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/page-builder.ts @@ -0,0 +1,124 @@ +import type { + BoundActivity, + Gap, + Relation, + SourceParticipant, + SourceTransition, + SyncPage, +} from "@exadev/process-graph-source-contract"; +import { accountId, issueId, repositoryId } from "./identifiers.ts"; +import type { Account, IssueRef, User } from "./queries.ts"; + +/** Collects one page's records, each identifier once, in the order first seen. */ +export interface PageBuilder { + /** Adds an account as a participant: an organisation acts as a system, a user as a role. */ + readonly account: (account: Readonly) => string; + /** Adds a repository and its owner as participants, the repository `part-of` the owner. */ + readonly repository: (owner: Readonly, name: string) => string; + /** Adds an issue as a bound activity whose actor is its repository, with its state in the overlay. */ + readonly issue: (issue: Readonly) => string; + /** Adds an assignee and the `assigned-to` relation from the issue. */ + readonly assignee: (issue: string, user: Readonly) => void; + /** Adds a blocker and the transition from it to the issue it blocks. */ + readonly blockedBy: (issue: string, blocker: Readonly) => void; + /** Adds a sub-issue and the `child-of` relation to its parent. */ + readonly subIssue: (parent: string, child: Readonly) => void; + /** Records a gap. */ + readonly gap: (gap: Readonly) => void; + /** The page, with `next` when one is given. */ + readonly build: (next: string | undefined) => SyncPage; +} + +/** + * Starts an empty page. + * @returns A builder whose `build` returns the page. + */ +export const createPageBuilder = (): PageBuilder => { + const participants = new Map(); + const activities = new Map(); + const transitions = new Map(); + const relations = new Map(); + const overlay = new Map(); + const gaps: Gap[] = []; + + const relate = (relation: Relation): void => { + relations.set( + JSON.stringify([relation.kind, relation.from, relation.to]), + relation, + ); + }; + + const account = (owner: Readonly): string => { + const id = accountId(owner.login); + participants.set(id, { + id, + label: owner.login, + kind: owner.__typename === "Organization" ? "system" : "role", + }); + + return id; + }; + + const repository = (owner: Readonly, name: string): string => { + const ownerParticipant = account(owner); + const id = repositoryId(owner.login, name); + participants.set(id, { + id, + label: `${owner.login}/${name}`, + kind: "system", + }); + relate({ kind: "part-of", from: id, to: ownerParticipant }); + + return id; + }; + + const issue = (ref: Readonly): string => { + const { owner, name } = ref.repository; + const actor = repository(owner, name); + const id = issueId(owner.login, name, ref.number); + activities.set(id, { + kind: "activity", + id, + source: id, + label: ref.title, + actor, + }); + overlay.set(id, ref.state === "OPEN" ? "open" : "done"); + + return id; + }; + + return { + account, + repository, + issue, + assignee: (issueIdentifier, user) => { + const id = accountId(user.login); + participants.set(id, { id, label: user.login, kind: "role" }); + relate({ kind: "assigned-to", from: issueIdentifier, to: id }); + }, + blockedBy: (issueIdentifier, blocker) => { + const source = issue(blocker); + const id = `${source}->${issueIdentifier}`; + transitions.set(id, { id, source, target: issueIdentifier }); + }, + subIssue: (parent, child) => { + relate({ kind: "child-of", from: issue(child), to: parent }); + }, + gap: (gap) => { + gaps.push(gap); + }, + build: (next) => { + const page = { + participants: [...participants.values()], + activities: [...activities.values()], + transitions: [...transitions.values()], + relations: [...relations.values()], + overlay: Object.fromEntries(overlay), + gaps: [...gaps], + }; + + return next === undefined ? page : { ...page, next }; + }, + }; +}; diff --git a/packages/adapters/graph/source-github-issues/src/queries.ts b/packages/adapters/graph/source-github-issues/src/queries.ts new file mode 100644 index 0000000..6c87acc --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/queries.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +// The GraphQL documents this adapter sends and the Zod schemas their `data` is parsed with. Each document asks for exactly the fields its schema reads, so a response GitHub changes incompatibly fails loudly at the parse. + +const pageInfoSchema = z.object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable(), +}); + +const accountSchema = z.object({ + __typename: z.enum(["Organization", "User"]), + login: z.string(), +}); + +const repositoryRefSchema = z.object({ + name: z.string(), + owner: accountSchema, +}); + +/** An issue reached from another one: a blocker or a sub-issue, possibly in another repository or organisation. */ +const issueRefSchema = z.object({ + number: z.int(), + title: z.string(), + state: z.enum(["OPEN", "CLOSED"]), + repository: repositoryRefSchema, +}); + +const connectionOf = (node: Node) => + z.object({ + pageInfo: pageInfoSchema, + // GitHub returns null for a node the token cannot see, alongside an error naming it. + nodes: z.array(node.nullable()), + }); + +const userSchema = z.object({ login: z.string() }); + +const issueSchema = issueRefSchema.extend({ + assignees: connectionOf(userSchema), + blockedBy: connectionOf(issueRefSchema), + subIssues: connectionOf(issueRefSchema), +}); + +export type PageInfo = z.infer; +export type Account = z.infer; +export type IssueRef = z.infer; +export type Issue = z.infer; +export type User = z.infer; + +const accountFields = "__typename login"; +const issueRefFields = `number title state repository { name owner { ${accountFields} } }`; +const pageInfoFields = "pageInfo { hasNextPage endCursor }"; + +/** A page of one repository's issues, oldest first, each with the first page of its assignees, blockers and sub-issues. */ +export const repositoryIssuesQuery = `query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + name + owner { ${accountFields} } + issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) { + ${pageInfoFields} + nodes { + ${issueRefFields} + assignees(first: $first) { ${pageInfoFields} nodes { login } } + blockedBy(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } + subIssues(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } + } + } + } +}`; + +export const repositoryIssuesSchema = z.object({ + repository: z + .object({ + name: z.string(), + owner: accountSchema, + issues: connectionOf(issueSchema), + }) + .nullable(), +}); + +/** A page of the repositories an account owns, by name. */ +export const ownerRepositoriesQuery = `query OwnerRepositories($login: String!, $first: Int!, $after: String) { + repositoryOwner(login: $login) { + ${accountFields} + repositories(first: $first, after: $after, ownerAffiliations: [OWNER], orderBy: { field: NAME, direction: ASC }) { + ${pageInfoFields} + nodes { name } + } + } +}`; + +export const ownerRepositoriesSchema = z.object({ + repositoryOwner: z + .object({ + __typename: z.enum(["Organization", "User"]), + login: z.string(), + repositories: connectionOf(z.object({ name: z.string() })), + }) + .nullable(), +}); + +/** The issue connections a page can overflow, each read on its own when it has more than one page. */ +export type IssueConnection = "assignees" | "blockedBy" | "subIssues"; + +/** The rest of one issue connection after `after`. */ +export const issueConnectionQuery = (connection: IssueConnection): string => { + const nodeFields = connection === "assignees" ? "login" : issueRefFields; + + return `query IssueConnection($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ${connection}(first: $first, after: $after) { ${pageInfoFields} nodes { ${nodeFields} } } + } + } +}`; +}; + +/** The data of an `issueConnectionQuery` response, for the connection `shape` names. */ +const issueConnectionDataSchema = (shape: Shape) => + z.object({ + repository: z.object({ issue: z.object(shape).nullable() }).nullable(), + }); + +export const assigneesConnectionSchema = issueConnectionDataSchema({ + assignees: connectionOf(userSchema), +}); +export const blockedByConnectionSchema = issueConnectionDataSchema({ + blockedBy: connectionOf(issueRefSchema), +}); +export const subIssuesConnectionSchema = issueConnectionDataSchema({ + subIssues: connectionOf(issueRefSchema), +}); diff --git a/packages/adapters/graph/source-github-issues/src/source.ts b/packages/adapters/graph/source-github-issues/src/source.ts new file mode 100644 index 0000000..a51fa81 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/source.ts @@ -0,0 +1,395 @@ +import { + type GraphSourcePager, + type PageRequest, + paginate, + type SyncPage, +} from "@exadev/process-graph-source-contract"; +import type { z } from "zod"; +import { + decodeCursor, + encodeCursor, + type Position, + startOf, +} from "./cursor.ts"; +import { + type GitHubTokenPort, + graphql, + type GraphqlFetch, + type GraphqlTransport, + type Problem, +} from "./graphql.ts"; +import { githubSource, issueId, parseScope } from "./identifiers.ts"; +import { createPageBuilder, type PageBuilder } from "./page-builder.ts"; +import { + assigneesConnectionSchema, + blockedByConnectionSchema, + type Issue, + type IssueConnection, + issueConnectionQuery, + ownerRepositoriesQuery, + ownerRepositoriesSchema, + type PageInfo, + repositoryIssuesQuery, + repositoryIssuesSchema, + subIssuesConnectionSchema, +} from "./queries.ts"; + +/** GitHub's largest allowed `first` on a connection; the API rejects larger values. */ +const githubMaxPageSize = 100; + +/** + * Issues per page, and items per request of each issue's assignees, blockers and sub-issues. Half of GitHub's limit keeps a page's GraphQL cost and response moderate while needing few requests; Graphle used the same size. + */ +const defaultPageSize = githubMaxPageSize / 2; + +/** What the GitHub issues source needs. */ +export interface GitHubIssuesSourceOptions { + /** Supplies a token per owner. `githubTokenFromEnvironment`, from the `./node` entry point, reads `GITHUB_TOKEN` or `gh auth token`. */ + readonly token: GitHubTokenPort; + /** The fetch implementation; the global `fetch` when absent. */ + readonly fetch?: GraphqlFetch; + /** The clock, in milliseconds since the epoch; `Date.now` when absent. */ + readonly now?: () => number; + /** Issues per page, and connection items per request, from 1 to GitHub's limit of 100. */ + readonly pageSize?: number; +} + +/** One page of a GraphQL connection, as GitHub returns it. */ +interface Connection { + readonly pageInfo: PageInfo; + readonly nodes: readonly (Node | null)[]; +} + +/** One issue connection to read to its end. */ +interface ConnectionRead { + /** The issue it belongs to. */ + readonly key: IssueKey; + readonly connection: IssueConnection; + /** The page of it the issue query returned. */ + readonly first: Connection; + /** The schema of an `issueConnectionQuery` response for it. */ + readonly schema: Schema; + /** Picks the connection out of such a response. */ + readonly extract: ( + data: z.infer, + ) => Connection | null | undefined; +} + +/** One page of one repository's issues to read. */ +interface RepositoryRead { + /** The identifier a gap for the repository is recorded on. */ + readonly target: string; + readonly repository: { readonly owner: string; readonly name: string }; + /** GitHub's cursor to read the issues after, or `null` for the first page. */ + readonly after: string | null; +} + +/** The issue whose connection is being read. */ +interface IssueKey { + readonly owner: string; + readonly name: string; + readonly number: number; +} + +const present = (connection: Connection): readonly Node[] => + connection.nodes.filter((node): node is Node => node !== null); + +const endCursorOf = (pageInfo: Readonly): string | undefined => + pageInfo.hasNextPage && pageInfo.endCursor !== null + ? pageInfo.endCursor + : undefined; + +/** Records the problems of a response as one gap on `target`, when there are any. */ +const recordProblems = ( + builder: PageBuilder, + target: string, + problems: readonly Problem[], +): void => { + const [first] = problems; + if (first !== undefined) { + builder.gap({ + target, + reason: first.reason, + message: problems.map((problem) => problem.message).join("; "), + }); + } +}; + +/** + * Records a scope GitHub answered with null as a gap. GitHub nulls a lookup with a `NOT_FOUND` or `FORBIDDEN` error when it can say why, and with no error at all for an owner login that does not exist, which is equally not found. + */ +const recordAbsence = ( + builder: PageBuilder, + target: string, + problems: readonly Problem[], +): void => { + if (problems.length === 0) { + builder.gap({ + target, + reason: "not-found", + message: `${target} does not exist or is not visible to the token`, + }); + } else { + recordProblems(builder, target, problems); + } +}; + +/** + * The GitHub issues `GraphSourcePager`. A page is one page of one repository's issues: each issue becomes a bound activity (`github:owner/repo#n`) whose actor is its repository, each `blockedBy` entry a transition from the blocker, each sub-issue a `child-of` relation, each assignee an `assigned-to` relation, and the repository `part-of` its owner. Blockers and sub-issues in other repositories or organisations are included with their own repository and owner. Open issues are `open` in the overlay and closed ones `done`. + * + * Scopes are read in request order, each once: `github:owner/repo` for one repository, `github:owner` for every repository the account owns, by name. A repository or owner that does not exist or that the token cannot see is a gap, and the sync continues. + * @param options - The token port, and optionally fetch, clock and page size. + * @returns A pager for `createGraphSourceRouter`. + * @throws RangeError when `pageSize` is not an integer from 1 to 100. + */ +export const createGitHubIssuesSource = ( + options: GitHubIssuesSourceOptions, +): GraphSourcePager => { + const pageSize = options.pageSize ?? defaultPageSize; + if ( + !Number.isInteger(pageSize) || + pageSize < 1 || + pageSize > githubMaxPageSize + ) { + throw new RangeError( + `pageSize must be an integer from 1 to ${String(githubMaxPageSize)}, not ${String(pageSize)}`, + ); + } + const transport: GraphqlTransport = { + token: options.token, + // Wrapped rather than passed as is: a browser's fetch throws when called as a method of another object. + fetch: options.fetch ?? (async (url, init) => fetch(url, init)), + now: options.now ?? Date.now, + }; + + /** Every node of one issue connection: the page the issue query returned, then the rest read on their own. */ + const allOf = async ( + read: ConnectionRead, + signal: AbortSignal | undefined, + ): Promise => { + const { key, connection, first, schema, extract } = read; + const after = endCursorOf(first.pageInfo); + if (after === undefined) { + return present(first); + } + const rest = paginate(after, async (cursor) => { + const { data } = await graphql( + transport, + { + query: issueConnectionQuery(connection), + variables: { ...key, first: pageSize, after: cursor }, + owner: key.owner, + schema, + }, + signal, + ); + const page = extract(data); + if (page === null || page === undefined) { + throw new Error( + `${issueId(key.owner, key.name, key.number)} was not returned while its ${connection} were read`, + ); + } + const next = endCursorOf(page.pageInfo); + + return next === undefined + ? { nodes: present(page) } + : { nodes: present(page), next }; + }); + const nodes = [...present(first)]; + for await (const page of rest) { + nodes.push(...page.nodes); + } + + return nodes; + }; + + const addIssue = async ( + builder: PageBuilder, + issue: Readonly, + signal: AbortSignal | undefined, + ): Promise => { + const id = builder.issue(issue); + const key: IssueKey = { + owner: issue.repository.owner.login, + name: issue.repository.name, + number: issue.number, + }; + const [assignees, blockers, children] = await Promise.all([ + allOf( + { + key, + connection: "assignees", + first: issue.assignees, + schema: assigneesConnectionSchema, + extract: (data) => data.repository?.issue?.assignees, + }, + signal, + ), + allOf( + { + key, + connection: "blockedBy", + first: issue.blockedBy, + schema: blockedByConnectionSchema, + extract: (data) => data.repository?.issue?.blockedBy, + }, + signal, + ), + allOf( + { + key, + connection: "subIssues", + first: issue.subIssues, + schema: subIssuesConnectionSchema, + extract: (data) => data.repository?.issue?.subIssues, + }, + signal, + ), + ]); + for (const user of assignees) { + builder.assignee(id, user); + } + for (const blocker of blockers) { + builder.blockedBy(id, blocker); + } + for (const child of children) { + builder.subIssue(id, child); + } + }; + + /** + * Adds one page of a repository's issues to the page. + * @returns The issues' page info, or `undefined` when the repository was a gap. + */ + const readRepository = async ( + builder: PageBuilder, + read: RepositoryRead, + signal: AbortSignal | undefined, + ): Promise => { + const { target, repository, after } = read; + const { data, problems } = await graphql( + transport, + { + query: repositoryIssuesQuery, + variables: { ...repository, first: pageSize, after }, + owner: repository.owner, + schema: repositoryIssuesSchema, + }, + signal, + ); + if (data.repository === null) { + recordAbsence(builder, target, problems); + + return undefined; + } + recordProblems(builder, target, problems); + builder.repository(data.repository.owner, data.repository.name); + await Promise.all( + present(data.repository.issues).map(async (issue) => + addIssue(builder, issue, signal), + ), + ); + + return data.repository.issues.pageInfo; + }; + + const read = async ({ + scope, + cursor, + signal, + }: Readonly): Promise => { + const scopes = [...new Set(scope)].map(parseScope); + const [firstScope] = scopes; + if (firstScope === undefined) { + throw new Error("unreachable: the contract requires at least one scope"); + } + const located = + cursor === undefined ? startOf(firstScope) : decodeCursor(cursor, scopes); + const following = scopes[scopes.indexOf(located.scope) + 1]; + const nextScope = + following === undefined ? undefined : startOf(following).position; + const builder = createPageBuilder(); + const build = (next: Position | undefined): SyncPage => + builder.build(next === undefined ? undefined : encodeCursor(next)); + + if (located.kind === "repository") { + const { scope: current, position } = located; + const issues = await readRepository( + builder, + { + target: current.id, + repository: { owner: current.owner, name: current.name }, + after: position.issues, + }, + signal, + ); + const after = issues === undefined ? undefined : endCursorOf(issues); + + return build( + after === undefined ? nextScope : { scope: current.id, issues: after }, + ); + } + + const { scope: current, position } = located; + const { data, problems } = await graphql( + transport, + { + query: ownerRepositoriesQuery, + variables: { + login: current.owner, + first: pageSize, + after: position.repositories, + }, + owner: current.owner, + schema: ownerRepositoriesSchema, + }, + signal, + ); + const owner = data.repositoryOwner; + if (owner === null) { + recordAbsence(builder, current.id, problems); + + return build(nextScope); + } + recordProblems(builder, current.id, problems); + builder.account(owner); + const listed = present(owner.repositories); + const listingAfter = endCursorOf(owner.repositories.pageInfo); + const afterListing: Position | undefined = + listingAfter === undefined + ? nextScope + : { + scope: current.id, + repositories: listingAfter, + repository: 0, + issues: null, + }; + const repository = listed[position.repository]; + if (repository === undefined) { + // An owner with no repositories, or a listing page that lost repositories since the cursor was issued: the page holds only the owner, and the sync carries on after the listing page. + return build(afterListing); + } + const issues = await readRepository( + builder, + { + target: `${current.id}/${repository.name}`, + repository: { owner: owner.login, name: repository.name }, + after: position.issues, + }, + signal, + ); + const issuesAfter = issues === undefined ? undefined : endCursorOf(issues); + const afterRepository: Position | undefined = + position.repository + 1 < listed.length + ? { ...position, repository: position.repository + 1, issues: null } + : afterListing; + + return build( + issuesAfter === undefined + ? afterRepository + : { ...position, issues: issuesAfter }, + ); + }; + + return { source: githubSource, page: read }; +}; diff --git a/packages/adapters/graph/source-github-issues/src/source.unit.test.ts b/packages/adapters/graph/source-github-issues/src/source.unit.test.ts new file mode 100644 index 0000000..c32401c --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/source.unit.test.ts @@ -0,0 +1,278 @@ +import { + assembleSync, + type SyncPage, +} from "@exadev/process-graph-source-contract"; +import { + graphSourceConformance, + inProcessClient, + messagePortClient, +} from "@exadev/process-graph-source-contract/conformance"; +import { describe, expect, test } from "vitest"; +import { createGitHubIssuesSource } from "./source.ts"; +import { encodeCursor } from "./cursor.ts"; +import { fakeToken, recordedRouter } from "./test-support/sources.ts"; +import { + fixtureScope, + missingOwnerScope, + missingRepositoryScope, + unauthorisedScope, +} from "./test-support/scopes.ts"; + +graphSourceConformance({ + name: "GitHub issues over recorded fixtures, in process", + createClient: () => inProcessClient(recordedRouter().router), + scope: fixtureScope, + missingScope: missingOwnerScope, + malformedScope: "ExaDev/document-schema.js", +}); + +graphSourceConformance({ + name: "GitHub issues over recorded fixtures, over a message port", + createClient: () => messagePortClient(recordedRouter().router), + scope: fixtureScope, + missingScope: missingRepositoryScope, + malformedScope: "github:ExaDev/document-schema.js/extra", +}); + +const syncAll = async ( + scope: readonly string[], +): Promise => { + const client = inProcessClient(recordedRouter().router); + const pages: SyncPage[] = []; + for await (const page of await client.v1.sync.stream({ scope })) { + pages.push(page); + } + + return pages; +}; + +describe("the recorded sync", async () => { + const pages = await syncAll(fixtureScope); + const { model, overlay, relations, gaps } = assembleSync(pages); + const transitionIds = model.transitions.map((transition) => transition.id); + + test("makes each issue a bound activity whose actor is its repository", () => { + expect(model.elements).toContainEqual({ + kind: "activity", + id: "github:Mearman/agent-permissions#26", + source: "github:Mearman/agent-permissions#26", + label: + "Adopt trilean's pattern-matching builders instead of the hand-rolled parsePattern/matchWildcard/globMatchPath", + actor: "github:Mearman/agent-permissions", + }); + }); + + test("turns a blocker in another organisation into a transition from it", () => { + expect(transitionIds).toContain( + "github:ExaDev/trilean#11->github:Mearman/agent-permissions#26", + ); + expect(model.elements.map((element) => element.id)).toContain( + "github:ExaDev/trilean#11", + ); + expect(relations).toContainEqual({ + kind: "part-of", + from: "github:ExaDev/trilean", + to: "github:@ExaDev", + }); + }); + + test("reads every blocker, including those past the first page of the connection", () => { + expect( + transitionIds + .filter((id) => id.endsWith("->github:ExaDev/document-schema.js#22")) + .sort(), + ).toEqual([ + "github:ExaDev/markdown-codec#63->github:ExaDev/document-schema.js#22", + "github:ExaDev/odf.js#59->github:ExaDev/document-schema.js#22", + "github:ExaDev/ooxml.js#65->github:ExaDev/document-schema.js#22", + "github:ExaDev/pdf-codec#66->github:ExaDev/document-schema.js#22", + ]); + expect(transitionIds).toContain( + "github:ExaDev/document-schema.js#13->github:ExaDev/document-schema.js#14", + ); + }); + + test("relates every sub-issue to its parent, across repositories and past the first page", () => { + const children = relations + .filter( + (relation) => + relation.kind === "child-of" && + relation.to === "github:ExaDev/document-schema.js#20", + ) + .map((relation) => relation.from) + .sort(); + expect(children).toEqual([ + "github:ExaDev/document-outline.js#2", + "github:ExaDev/document-schema.js#21", + "github:ExaDev/document-schema.js#22", + "github:ExaDev/documents.js#623", + "github:ExaDev/markdown-codec#63", + "github:ExaDev/odf.js#59", + "github:ExaDev/ooxml.js#65", + "github:ExaDev/pdf-codec#65", + "github:ExaDev/pdf-codec#66", + ]); + }); + + test("lays closed issues over the model as done", () => { + expect(overlay["github:ExaDev/document-schema.js#20"]).toBe("done"); + expect(overlay["github:ExaDev/trilean#11"]).toBe("done"); + }); + + test("reads every repository of an owner scope, the organisation as a system participant", () => { + expect(model.participants).toContainEqual({ + id: "github:@octo-org", + label: "octo-org", + kind: "system", + }); + expect(model.participants).toContainEqual({ + id: "github:@Mearman", + label: "Mearman", + kind: "role", + }); + const octoRepositories = relations + .filter( + (relation) => + relation.kind === "part-of" && relation.to === "github:@octo-org", + ) + .map((relation) => relation.from) + .sort(); + expect(octoRepositories).toEqual([ + "github:octo-org/hello-world-javascript-action", + "github:octo-org/octo-repo-go", + "github:octo-org/octo-template", + "github:octo-org/sabrowning1-test", + ]); + }); + + test("reports no gaps for scopes that exist", () => { + expect(gaps).toEqual([]); + }); +}); + +describe("gaps and failures", () => { + test("an owner that does not exist is a not-found gap on its scope", async () => { + const [page] = await syncAll([missingOwnerScope]); + expect(page?.gaps).toEqual([ + { + target: missingOwnerScope, + reason: "not-found", + message: `${missingOwnerScope} does not exist or is not visible to the token`, + }, + ]); + }); + + test("a repository that does not exist is a not-found gap with GitHub's message", async () => { + const [page] = await syncAll([missingRepositoryScope]); + expect(page?.gaps).toEqual([ + { + target: missingRepositoryScope, + reason: "not-found", + message: + "Could not resolve to a Repository with the name 'not.a.login/repository'.", + }, + ]); + }); + + test("a rejected token is UNAUTHORIZED, and the token is not in the error", async () => { + const client = inProcessClient(recordedRouter().router); + const failure = client.v1.sync.page({ scope: [unauthorisedScope] }); + await expect(failure).rejects.toMatchObject({ + code: "UNAUTHORIZED", + defined: true, + }); + await expect(failure).rejects.not.toSatisfy( + (error: unknown) => + error instanceof Error && error.message.includes(fakeToken), + ); + }); +}); + +describe("cursors", () => { + test("a cursor for a scope the request does not contain is invalid", async () => { + const client = inProcessClient(recordedRouter().router); + const cursor = encodeCursor({ scope: "github:ExaDev/other", issues: null }); + await expect( + client.v1.sync.page({ + scope: ["github:ExaDev/document-schema.js"], + after: cursor, + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: cursor }, + }); + }); + + test("a repository cursor used with an owner scope is invalid", async () => { + const client = inProcessClient(recordedRouter().router); + const cursor = encodeCursor({ scope: "github:octo-org", issues: null }); + await expect( + client.v1.sync.page({ scope: ["github:octo-org"], after: cursor }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor" }, + }); + }); + + test("a cursor past the repositories an owner now has carries on after the listing", async () => { + const client = inProcessClient(recordedRouter().router); + const cursor = encodeCursor({ + scope: "github:octo-org", + repositories: null, + repository: 50, + issues: null, + }); + const page = await client.v1.sync.page({ + scope: ["github:octo-org", "github:ExaDev/document-schema.js"], + after: cursor, + }); + expect(page.activities).toEqual([]); + expect(page.participants).toEqual([ + { id: "github:@octo-org", label: "octo-org", kind: "system" }, + ]); + const next = await client.v1.sync.page({ + scope: ["github:octo-org", "github:ExaDev/document-schema.js"], + after: page.next ?? "", + }); + expect(next.participants.map((participant) => participant.id)).toContain( + "github:octo-org/sabrowning1-test", + ); + }); +}); + +describe("createGitHubIssuesSource", () => { + const githubLimit = 100; + const fractional = 2.5; + + test.each([0, githubLimit + 1, fractional])( + "rejects a page size of %s", + (pageSize) => { + expect(() => + createGitHubIssuesSource({ + token: async () => Promise.resolve(fakeToken), + pageSize, + }), + ).toThrow(RangeError); + }, + ); + + test("asks the token port for the owner of the repository being read", async () => { + const owners: string[] = []; + const source = createGitHubIssuesSource({ + token: async (owner) => { + owners.push(owner); + + return Promise.resolve(fakeToken); + }, + fetch: async () => Promise.reject(new Error("offline")), + }); + await expect( + source.page({ + scope: ["github:ExaDev/document-schema.js"], + cursor: undefined, + signal: undefined, + }), + ).rejects.toThrow("offline"); + expect(owners).toEqual(["ExaDev"]); + }); +}); diff --git a/packages/adapters/graph/source-github-issues/src/test-support/fixtures/github.json b/packages/adapters/graph/source-github-issues/src/test-support/fixtures/github.json new file mode 100644 index 0000000..e5fbf1d --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/test-support/fixtures/github.json @@ -0,0 +1,2795 @@ +{ + "exchanges": [ + { + "request": { + "query": "query IssueConnection($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n blockedBy(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "number": 22, + "first": 3, + "after": "Mw" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "issue": { + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "NA" + }, + "nodes": [ + { + "number": 65, + "title": "Fidelity construct inventory: everything docx/xlsx/pptx carry that the content model drops", + "state": "CLOSED", + "repository": { + "name": "ooxml.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query IssueConnection($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n subIssues(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "number": 20, + "first": 3, + "after": "Mw" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "issue": { + "subIssues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Ng" + }, + "nodes": [ + { + "number": 65, + "title": "Demote LayoutDocument to codec-private; map to and from frames at the edge", + "state": "CLOSED", + "repository": { + "name": "pdf-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 66, + "title": "Fidelity construct inventory: annotations, AcroForm fields, embedded files, optional content groups", + "state": "CLOSED", + "repository": { + "name": "pdf-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 65, + "title": "Fidelity construct inventory: everything docx/xlsx/pptx carry that the content model drops", + "state": "CLOSED", + "repository": { + "name": "ooxml.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query IssueConnection($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n subIssues(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "number": 20, + "first": 3, + "after": "Ng" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "issue": { + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "OQ" + }, + "nodes": [ + { + "number": 59, + "title": "Fidelity construct inventory: text:section, bookmarks, field masters, tracked changes, auto-styles, xforms", + "state": "CLOSED", + "repository": { + "name": "odf.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 63, + "title": "Fidelity construct inventory: reference links, footnotes, raw HTML, frontmatter", + "state": "CLOSED", + "repository": { + "name": "markdown-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 2, + "title": "Re-charter around the package artefact: decompose/flatten with bijection tests, then builder migration", + "state": "CLOSED", + "repository": { + "name": "document-outline.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query OwnerRepositories($login: String!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n repositories(first: $first, after: $after, ownerAffiliations: [OWNER], orderBy: { field: NAME, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes { name }\n }\n }\n}", + "variables": { + "login": "octo-org", + "first": 3, + "after": "Y3Vyc29yOnYyOpKtb2N0by10ZW1wbGF0Zc4hRupd" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "octo-org", + "repositories": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpKwc2Ficm93bmluZzEtdGVzdM4sWWjY" + }, + "nodes": [ + { + "name": "sabrowning1-test" + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query OwnerRepositories($login: String!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n repositories(first: $first, after: $after, ownerAffiliations: [OWNER], orderBy: { field: NAME, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes { name }\n }\n }\n}", + "variables": { + "login": "not.a.login", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": null + } + } + } + }, + { + "request": { + "query": "query OwnerRepositories($login: String!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n repositories(first: $first, after: $after, ownerAffiliations: [OWNER], orderBy: { field: NAME, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes { name }\n }\n }\n}", + "variables": { + "login": "octo-org", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "octo-org", + "repositories": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpKtb2N0by10ZW1wbGF0Zc4hRupd" + }, + "nodes": [ + { + "name": "hello-world-javascript-action" + }, + { + "name": "octo-repo-go" + }, + { + "name": "octo-template" + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xN1QwODowNTo0N1rPAAAAATQjF5A=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xOFQwNzowNjoyMVrPAAAAATSyvhU=" + }, + "nodes": [ + { + "number": 15, + "title": "Math value schemas: two-layer math node (authoritative LaTeX presentation + JSON semantic core), symbol tables, units", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 20, + "title": "Promote DocumentPackage to the single hierarchical tree: structure, layout, and content in one artefact", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Mw" + }, + "nodes": [ + { + "number": 21, + "title": "Package-local referenced styles and group inheritance: compression-only, materialised on flatten", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 22, + "title": "Two-channel fidelity: harmonised semantic construct vocabulary plus a quarantined residue channel", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 623, + "title": "Run decompose at the package boundary and flatten at the builders (DocumentPackage tree promotion)", + "state": "CLOSED", + "repository": { + "name": "documents.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + } + }, + { + "number": 21, + "title": "Package-local referenced styles and group inheritance: compression-only, materialised on flatten", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xOFQwNzowNjoyMVrPAAAAATSyvhU=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xOFQxMzo0NTowM1rPAAAAATTmjvs=" + }, + "nodes": [ + { + "number": 22, + "title": "Two-channel fidelity: harmonised semantic construct vocabulary plus a quarantined residue channel", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Mw" + }, + "nodes": [ + { + "number": 66, + "title": "Fidelity construct inventory: annotations, AcroForm fields, embedded files, optional content groups", + "state": "CLOSED", + "repository": { + "name": "pdf-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 63, + "title": "Fidelity construct inventory: reference links, footnotes, raw HTML, frontmatter", + "state": "CLOSED", + "repository": { + "name": "markdown-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 59, + "title": "Fidelity construct inventory: text:section, bookmarks, field masters, tracked changes, auto-styles, xforms", + "state": "CLOSED", + "repository": { + "name": "odf.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 24, + "title": "Add the fidelity construct descriptor kinds additively (contentControl / field / anchor / link / provenance; package-level tables for layers/attachments/destinations)", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0wM1QwNTowMzo1NVrPAAAAAT3XU9w=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1OVrPAAAAAU-uPKc=" + }, + "nodes": [ + { + "number": 37, + "title": "fix: treat a rule's when condition as unknown, not satisfied, when the context lacks the field", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 38, + "title": "fix: a prefix or wildcard allow rule matches a whole compound command", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 39, + "title": "fix: deduplicateRules drops rules that differ only in their when condition", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo1OToxM1rPAAAAAU-56QY=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozOVrPAAAAAU-7caY=" + }, + "nodes": [ + { + "number": 64, + "title": "fix(codec): the other codecs still drop or loosen rules on encode without reporting it", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 65, + "title": "fix(codec): Codex path rules are written to the filesystem root instead of the project", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 66, + "title": "feat(codec): write Codex execpolicy rules for command deny and ask rules", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1OVrPAAAAAU-uPKc=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToxMVrPAAAAAU-uSGI=" + }, + "nodes": [ + { + "number": 40, + "title": "fix: pnpm-lock.yaml on main has a duplicated zod entry and a frozen install fails", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 41, + "title": "feat: return the matching rule and its source layer from evaluate and check", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 42, + "title": "feat: compile a policy once for repeated evaluation", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToxMVrPAAAAAU-uSGI=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToyNlrPAAAAAU-uVY0=" + }, + "nodes": [ + { + "number": 43, + "title": "feat: add env and remote conditions to when", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 44, + "title": "feat: load an ordered chain of layers with provenance, ceilings and profile extends", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 45, + "title": "feat: hide the tool of a deny rule marked hidden", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToyNlrPAAAAAU-uVY0=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMTozOFrPAAAAAU-uYCQ=" + }, + "nodes": [ + { + "number": 46, + "title": "feat: encode actors, roles, approvers and grantability, with fail-closed codecs", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 47, + "title": "feat: serve evaluation as a permission-prompt MCP tool", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 48, + "title": "feat: replay a policy over a transcript and suggest allow rules", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMTozOFrPAAAAAU-uYCQ=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDozNTo1MFrPAAAAAU-1Rv4=" + }, + "nodes": [ + { + "number": 49, + "title": "feat: generate read and edit confinement rules for a set of directories", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 50, + "title": "feat: enforce the delegation block in evaluate", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 56, + "title": "fix(ci): the audit job discards its fixes when the override prune install fails", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDozNTo1MFrPAAAAAU-1Rv4=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo1OToxM1rPAAAAAU-56QY=" + }, + "nodes": [ + { + "number": 59, + "title": "fix(ci): the audit job cannot open its fix PR because Actions may not create pull requests", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 60, + "title": "fix(release): the release notes step fails with conventionalcommits preset 10", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 63, + "title": "fix(release): the release commit push is rejected by the main branch ruleset", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozOVrPAAAAAU-7caY=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNzowNjowMFrPAAAAAU_Szl8=" + }, + "nodes": [ + { + "number": 73, + "title": "feat: accept trilean predicate trees in when", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 79, + "title": "fix(loader): loadPolicy silently skips a policy file it cannot use", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 82, + "title": "feat(sync): sync Oh My Pi's bash.patterns", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNzowNjowMFrPAAAAAU_Szl8=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0MVrPAAAAAU_vpQU=" + }, + "nodes": [ + { + "number": 83, + "title": "feat(codec): map the canonical default mode to Oh My Pi's approval mode", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 93, + "title": "feat(codec): report the canonical fields a codec does not carry", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 101, + "title": "Codex codec: support profile allow-list extends", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0MVrPAAAAAU_vpQU=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0NlrPAAAAAU_vqPs=" + }, + "nodes": [ + { + "number": 102, + "title": "Codex codec: command rules are not caught inside redirections or command substitutions", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 103, + "title": "sync: write Codex config and rules files", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 104, + "title": "Codex decoding rejects glob_scan_max_depth", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0NlrPAAAAAU_vqPs=" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo1MlrPAAAAAU_vriI=" + }, + "nodes": [ + { + "number": 105, + "title": "Oh My Pi codec: merge the two entries written for a prefix rule on decode", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 106, + "title": "Oh My Pi: read named profiles", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 107, + "title": "Oh My Pi codec: prompt for reads with per-tool approval entries", + "state": "OPEN", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octo-org", + "name": "octo-template", + "first": 3, + "after": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yMlQxNzo1MzoxNFrOvLeAFA==" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yM1QwMjowNToxN1rOvL1TAA==" + }, + "nodes": [ + { + "number": 7, + "title": "is everything unblock", + "state": "OPEN", + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 8, + "title": "Draft Issue", + "state": "OPEN", + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "Mearman", + "name": "agent-permissions", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0wM1QwNTowMzo1NVrPAAAAAT3XU9w=" + }, + "nodes": [ + { + "number": 12, + "title": "feat: add a fail-closed OMP permission codec", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 13, + "title": "fix: reject canonical command rules Codex cannot enforce", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 26, + "title": "Adopt trilean's pattern-matching builders instead of the hand-rolled parsePattern/matchWildcard/globMatchPath", + "state": "CLOSED", + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + }, + "nodes": [ + { + "number": 11, + "title": "Add portable pattern-matching builders (prefix, wildcard, hierarchical glob) compiling to textCompare", + "state": "CLOSED", + "repository": { + "name": "trilean", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "ExaDev", + "name": "document-schema.js", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xN1QwODowNTo0N1rPAAAAATQjF5A=" + }, + "nodes": [ + { + "number": 12, + "title": "Make DocumentPackage the unified content+layout tree, not a sibling container", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 13, + "title": "Add a canonical headingLevel field to the paragraph content model", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 14, + "title": "New sibling package: build a heading-based hierarchy over ContentDocument", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + }, + "nodes": [ + { + "number": 13, + "title": "Add a canonical headingLevel field to the paragraph content model", + "state": "CLOSED", + "repository": { + "name": "document-schema.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octo-org", + "name": "hello-world-javascript-action", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "hello-world-javascript-action", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octocat", + "name": "Hello-World", + "first": 3, + "after": null + } + }, + "response": { + "status": 401, + "body": { + "message": "Bad credentials", + "documentation_url": "https://docs.github.com/rest", + "status": "401" + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octo-org", + "name": "octo-repo-go", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-repo-go", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyMy0wNC0yMVQxMzoyOTo1OFrOZAxO3A==" + }, + "nodes": [ + { + "number": 1, + "title": "Fix code scanning alert - Database query built from user-controlled sources", + "state": "OPEN", + "repository": { + "name": "octo-repo-go", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 2, + "title": "Fix code scanning alert - Database query built from user-controlled sources", + "state": "OPEN", + "repository": { + "name": "octo-repo-go", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octo-org", + "name": "octo-template", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yMlQxNzo1MzoxNFrOvLeAFA==" + }, + "nodes": [ + { + "number": 1, + "title": "Objectionable content!", + "state": "OPEN", + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 2, + "title": "I don't think this belongs here...", + "state": "OPEN", + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + }, + { + "number": 6, + "title": "blue frames", + "state": "OPEN", + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "not.a.login", + "name": "repository", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": ["repository"], + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "message": "Could not resolve to a Repository with the name 'not.a.login/repository'." + } + ] + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {\n pageInfo { hasNextPage endCursor }\n nodes {\n number title state repository { name owner { __typename login } }\n assignees(first: $first) { pageInfo { hasNextPage endCursor } nodes { login } }\n blockedBy(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n subIssues(first: $first) { pageInfo { hasNextPage endCursor } nodes { number title state repository { name owner { __typename login } } } }\n }\n }\n }\n}", + "variables": { + "owner": "octo-org", + "name": "sabrowning1-test", + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "sabrowning1-test", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNC0wMy0wOFQxODozMzowMFrOgbu-Kg==" + }, + "nodes": [ + { + "number": 1, + "title": "hi", + "state": "OPEN", + "repository": { + "name": "sabrowning1-test", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + ] + } + } + } + } + } + } + ] +} diff --git a/packages/adapters/graph/source-github-issues/src/test-support/recording.ts b/packages/adapters/graph/source-github-issues/src/test-support/recording.ts new file mode 100644 index 0000000..6355509 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/test-support/recording.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** + * One recorded GraphQL exchange: the request's document and variables, and GitHub's status and body. No header is recorded, so neither the token sent nor anything GitHub returned about the account reaches a fixture. + */ +const exchangeSchema = z.strictObject({ + request: z.strictObject({ + query: z.string(), + variables: z.record(z.string(), z.unknown()), + }), + response: z.strictObject({ + status: z.int(), + body: z.unknown(), + }), +}); + +/** A fixture file: every exchange of a recorded sync, keyed by request. */ +export const recordingSchema = z.strictObject({ + exchanges: z.array(exchangeSchema), +}); + +export type Exchange = z.infer; +export type Recording = z.infer; + +/** The key a request is recorded and replayed under: its document and variables, with variable keys in sorted order. */ +export const requestKey = (request: Exchange["request"]): string => + JSON.stringify([ + request.query, + Object.entries(request.variables).sort(([a], [b]) => a.localeCompare(b)), + ]); + +/** + * Reads the GraphQL document and variables out of the body this adapter sends. + * @param body - A fetch request body. + * @returns The request, or a thrown error when the body is not a GraphQL request. + */ +export const graphqlRequestOf = ( + body: RequestInit["body"], +): Exchange["request"] => { + if (typeof body !== "string") { + throw new TypeError("expected a JSON string request body"); + } + + return exchangeSchema.shape.request.parse(JSON.parse(body)); +}; diff --git a/packages/adapters/graph/source-github-issues/src/test-support/replay.ts b/packages/adapters/graph/source-github-issues/src/test-support/replay.ts new file mode 100644 index 0000000..c53f66e --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/test-support/replay.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { githubGraphqlEndpoint, type GraphqlFetch } from "../graphql.ts"; +import { + graphqlRequestOf, + type Recording, + recordingSchema, + requestKey, +} from "./recording.ts"; + +/** + * Loads a recorded fixture from `fixtures/`. + * @param name - The fixture's file name without `.json`. + * @returns The recording. + */ +export const loadRecording = (name: string): Recording => + recordingSchema.parse( + JSON.parse( + readFileSync(new URL(`fixtures/${name}.json`, import.meta.url), "utf8"), + ), + ); + +/** + * A fetch that answers from a recording. A request that was not recorded fails the test with its operation and variables, never reaching the network; an aborted signal rejects as fetch does. + * @param recording - The exchanges to answer from. + * @returns The fetch, and the requests it has answered, in order. + */ +export const replayFetch = ( + recording: Readonly, +): { readonly fetch: GraphqlFetch; readonly requests: readonly string[] } => { + const responses = new Map( + recording.exchanges.map((exchange) => [ + requestKey(exchange.request), + exchange.response, + ]), + ); + const requests: string[] = []; + + const replay = async (url: string, init: RequestInit): Promise => { + init.signal?.throwIfAborted(); + if (url !== githubGraphqlEndpoint) { + throw new Error( + `expected a request to ${githubGraphqlEndpoint}, not ${url}`, + ); + } + const request = graphqlRequestOf(init.body); + const key = requestKey(request); + const response = responses.get(key); + if (response === undefined) { + throw new Error(`no recorded response for ${key}`); + } + requests.push(key); + + return Promise.resolve( + new Response(JSON.stringify(response.body), { + status: response.status, + headers: { "content-type": "application/json" }, + }), + ); + }; + + return { fetch: replay, requests }; +}; diff --git a/packages/adapters/graph/source-github-issues/src/test-support/scopes.ts b/packages/adapters/graph/source-github-issues/src/test-support/scopes.ts new file mode 100644 index 0000000..68e7f44 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/test-support/scopes.ts @@ -0,0 +1,20 @@ +/** + * The scopes the recorded fixtures cover, shared by the recorder and the tests that replay it. All are public: GitHub's own demo organisation, read as an owner scope, and two repositories whose issues block each other across repositories and across organisations. + */ +export const fixtureScope: readonly string[] = [ + "github:octo-org", + "github:ExaDev/document-schema.js", + "github:Mearman/agent-permissions", +]; + +/** An owner that cannot exist: GitHub logins cannot contain a dot. */ +export const missingOwnerScope = "github:not.a.login"; + +/** A repository of that owner, so it cannot exist either. */ +export const missingRepositoryScope = "github:not.a.login/repository"; + +/** A small page size, so the fixture scopes span many pages and every connection overflows into its own requests somewhere. */ +export const fixturePageSize = 3; + +/** A public repository read only with a made-up token, so the fixture holds GitHub's answer to bad credentials. */ +export const unauthorisedScope = "github:octocat/Hello-World"; diff --git a/packages/adapters/graph/source-github-issues/src/test-support/sources.ts b/packages/adapters/graph/source-github-issues/src/test-support/sources.ts new file mode 100644 index 0000000..3787fb6 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/test-support/sources.ts @@ -0,0 +1,50 @@ +import { + createGraphSourceRouter, + type GraphSourceRouter, +} from "@exadev/process-graph-source-contract"; +import type { GraphqlFetch } from "../graphql.ts"; +import { createGitHubIssuesSource } from "../source.ts"; +import { loadRecording, replayFetch } from "./replay.ts"; +import { fixturePageSize } from "./scopes.ts"; + +/** A made-up token: the replayed responses never check it, and a test can assert it never leaks into an error. */ +export const fakeToken = "test-token-not-a-credential"; + +/** A fixed clock for rate-limit resets. */ +export const fixedNow = Date.parse("2026-10-10T12:00:00Z"); + +/** + * The adapter over the recorded fixture, at the page size it was recorded with. + * @returns The router, and the requests the replay has answered. + */ +export const recordedRouter = (): { + readonly router: GraphSourceRouter; + readonly requests: readonly string[]; +} => { + const replay = replayFetch(loadRecording("github")); + const router = createGraphSourceRouter( + createGitHubIssuesSource({ + token: async () => Promise.resolve(fakeToken), + fetch: replay.fetch, + now: () => fixedNow, + pageSize: fixturePageSize, + }), + ); + + return { router, requests: replay.requests }; +}; + +/** + * The adapter over a fetch the test writes, for responses no recording can produce, such as rate limiting. + * @param fetch - Answers every request. + * @returns The router. + */ +export const stubbedRouter = (fetch: GraphqlFetch): GraphSourceRouter => + createGraphSourceRouter( + createGitHubIssuesSource({ + token: async () => Promise.resolve(fakeToken), + fetch, + now: () => fixedNow, + pageSize: fixturePageSize, + }), + ); diff --git a/packages/adapters/graph/source-github-issues/stryker.config.ts b/packages/adapters/graph/source-github-issues/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/adapters/graph/source-github-issues/test/package.smoke.test.ts b/packages/adapters/graph/source-github-issues/test/package.smoke.test.ts new file mode 100644 index 0000000..4f35a38 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/test/package.smoke.test.ts @@ -0,0 +1,47 @@ +import { createGraphSourceRouter } from "@exadev/process-graph-source-contract"; +import { inProcessClient } from "@exadev/process-graph-source-contract/conformance"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { createGitHubIssuesSource, parseScope } from "../dist/index.js"; +import { githubTokenFromEnvironment } from "../dist/node.js"; + +test("the built package reads a page through its fetch port", async () => { + const body = { + data: { + repository: { + name: "repo", + owner: { __typename: "Organization", login: "org" }, + issues: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }; + const client = inProcessClient( + createGraphSourceRouter( + createGitHubIssuesSource({ + token: async () => Promise.resolve("smoke-test-token"), + fetch: async () => + Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + }), + ), + ); + const page = await client.v1.sync.page({ scope: ["github:org/repo"] }); + expect(page.participants.map((participant) => participant.id)).toEqual([ + "github:@org", + "github:org/repo", + ]); + expect(parseScope("github:org")).toEqual({ + kind: "owner", + id: "github:org", + owner: "org", + }); +}); + +test("the built node entry point reads GITHUB_TOKEN", async () => { + const port = githubTokenFromEnvironment({ + env: { GITHUB_TOKEN: "from-env" }, + }); + await expect(port("org", undefined)).resolves.toBe("from-env"); +}); diff --git a/packages/adapters/graph/source-github-issues/tsconfig.build.json b/packages/adapters/graph/source-github-issues/tsconfig.build.json new file mode 100644 index 0000000..756f03a --- /dev/null +++ b/packages/adapters/graph/source-github-issues/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "types": ["node"] + }, + "exclude": ["src/**/*.test.ts", "src/test-support/**"], + "references": [ + { + "path": "../../../core/graph/source-contract/tsconfig.build.json" + } + ] +} diff --git a/packages/adapters/graph/source-github-issues/tsconfig.json b/packages/adapters/graph/source-github-issues/tsconfig.json new file mode 100644 index 0000000..a8da99c --- /dev/null +++ b/packages/adapters/graph/source-github-issues/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "WebWorker"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**", "src/node.ts"] +} diff --git a/packages/adapters/graph/source-github-issues/tsconfig.node.json b/packages/adapters/graph/source-github-issues/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/adapters/graph/source-github-issues/vitest.config.ts b/packages/adapters/graph/source-github-issues/vitest.config.ts new file mode 100644 index 0000000..6dbb1d1 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.unit.test.ts"] }, + }, + { + extends: true, + test: { + name: "integration", + include: ["src/**/*.integration.test.ts"], + }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b76e4a..55462c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,46 @@ importers: specifier: 5.0.3 version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/adapters/graph/source-github-issues: + dependencies: + '@exadev/process-graph-source-contract': + specifier: 0.0.0 + version: link:../../../core/graph/source-contract + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(vitest@5.0.3) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/dag: devDependencies: '@arethetypeswrong/cli': diff --git a/tsconfig.build.json b/tsconfig.build.json index 5d0d6bb..a843570 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -16,6 +16,9 @@ { "path": "packages/core/graph/source-contract/tsconfig.build.json" }, + { + "path": "packages/adapters/graph/source-github-issues/tsconfig.build.json" + }, { "path": "packages/core/graph/layout/tsconfig.build.json" }, From 307199c9f58c35309304c63036f675fa5c025776 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:51:29 +0100 Subject: [PATCH 2/2] build(source-github-issues): depend on the published source contract release --- packages/adapters/graph/source-github-issues/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adapters/graph/source-github-issues/package.json b/packages/adapters/graph/source-github-issues/package.json index 8c4b9f4..e201e88 100644 --- a/packages/adapters/graph/source-github-issues/package.json +++ b/packages/adapters/graph/source-github-issues/package.json @@ -75,7 +75,7 @@ "vitest": "5.0.3" }, "dependencies": { - "@exadev/process-graph-source-contract": "0.0.0", + "@exadev/process-graph-source-contract": "1.0.0", "zod": "4.6.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55462c0..4c65dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,7 +269,7 @@ importers: packages/adapters/graph/source-github-issues: dependencies: '@exadev/process-graph-source-contract': - specifier: 0.0.0 + specifier: 1.0.0 version: link:../../../core/graph/source-contract zod: specifier: 4.6.5