diff --git a/README.md b/README.md index 522b1e4..b02ef80 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config | [`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, and the inverses that turn an edited view into a diff against the model. | | [`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. | -| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; with an in-memory fake and a conformance kit. | +| [`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, typed filters and scope listings, 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, `blockedBy` and `blocking`, sub-issues and assignees across organisations, repositories and Projects v2 boards, with server-side filters, a scope listing and per-owner tokens. | +| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items and relations from a source, ready, blocked-chain, impact and hierarchy queries, a change feed; with an in-memory fake and a conformance kit. | | [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. | | [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. | | [`graph-sqlite-wasm`](packages/adapters/graph/sqlite-wasm/README.md) | The SQLite store in the browser: SQLite wasm in a dedicated worker on OPFS `opfs-sahpool`, shared between tabs under a Web Lock with no special headers, served to the page over oRPC. | diff --git a/packages/adapters/graph/source-github-issues/README.md b/packages/adapters/graph/source-github-issues/README.md index 0fb375b..e00e818 100644 --- a/packages/adapters/graph/source-github-issues/README.md +++ b/packages/adapters/graph/source-github-issues/README.md @@ -2,7 +2,7 @@ [![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. +The GitHub issues adapter for the `GraphSource` port in `@exadev/process-graph-source-contract`. It reads issues, what blocks them, what they block and their sub-issues through GitHub's GraphQL API across any number of organisations, repositories and Projects v2 boards, and returns them as pages of the process model. It also lists the scopes a picker offers. 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"; @@ -30,12 +30,21 @@ 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. | +| Scope | Reads | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `github:owner/repo` | One repository's issues. | +| `github:owner` | The issues of every repository the account owns, by repository name. | +| `github:owner/projects/number` | The issues on an organisation's or user's Projects v2 board, in the board's order; other items skipped. | -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`. +Scopes are read in the order given, each once. A repository, account or board 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 forms is a `BAD_REQUEST` with reason `invalid-scope`. A board's pull requests and draft issues are not issues, so a board sync passes over them. + +## Filters + +A sync's `filter` becomes the arguments of GitHub's issue listing, so the narrowing happens on GitHub's side: `states` `open` and `done` are `OPEN` and `CLOSED`, `labels` keeps issues with any of the labels, and `order` is GitHub's `IssueOrder` (`CREATED_AT`, `UPDATED_AT` or `COMMENTS`, ascending or descending). With no filter every issue is listed, oldest first. A board has no such listing, so a filter on a sync that includes a board scope is a `BAD_REQUEST` with reason `invalid-scope`. A blocker, blocked issue or sub-issue reached from a listed issue is read whatever the filter, since the listed work depends on it. + +## Listing scopes + +`scopes.roots` lists the viewer's own account and then the organisations it belongs to; `scopes.children` lists the repositories an owner has, by name (a repository or a board has nothing inside it). Each entry carries the scope identifier to sync, its name, its description and URL, and whether GitHub has archived it. An owner that does not exist is a gap on an empty page. The viewer's listing asks the token port for a token with no owner (see Tokens). ## The model it produces @@ -43,17 +52,17 @@ Scopes are read in the order given, each once. A repository or account that does | ---------------------------- | ----------------------------------------------------------------------------------------- | | 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, `->` | +| `blockedBy` and `blocking` | 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. +Blockers, blocked issues 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. A dependency is read from both of its ends, so work blocked in a repository outside the requested scopes is still found from its blocker, and the two reads give one transition. Every connection is read to its end: when an issue has more assignees, blockers, blocked issues 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. +A page is one page of one repository's issues, or of one board's items. The cursor in a page's `next` records the scope, the position in an owner's repository listing, GitHub's own cursor into the issues or items, and the filter, as plain data, so a sync resumes in another process and a cursor is refused under another filter. Without a filter, issues are listed by creation time and repositories by name, so a position stays valid as new issues are opened; ordering by `updated` or `comments` lets an issue move while a sync is under way. If an owner has fewer repositories than when the cursor was issued, the sync carries on after that listing page. ## Errors and rate limits @@ -68,24 +77,26 @@ 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. +The token comes from a `GitHubTokenPort`, `(owner, signal) => Promise`, asked for the owner of each repository or board read, so different organisations can use different credentials, and with `owner` undefined for the viewer's own listing, which no owner names. The token is sent only in the `Authorization` header and never logged or put in an error. + +`storedTokenPort({ list, used })` chooses among tokens a host keeps (a browser's token store, say), each scoped to some owners or to any: a token naming the owner wins, the one naming the fewest owners first and then the most recently used; otherwise the most recently used token for any owner; a request with no owner takes only a token for any owner. Logins match without regard to case. `list` is read on every request, `used` is told which token was chosen so the host can keep its recency, and no fitting token is `unauthorised`. `selectStoredToken` is the choice on its own. `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. | +| 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, items or scopes 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 offline tests replay GraphQL exchanges recorded from public repositories (`src/test-support/fixtures/github.json`): GitHub's demo organisation as an owner scope and as a listing, two repositories whose issues block each other across repositories and organisations, one of them also synced under a filter, and a public Projects v2 board of GitHub's own. The fixture holds request documents, variables, statuses and bodies, never headers or tokens. The viewer's listing is never recorded, since it would name the recording account's organisations; it is tested over written responses and live. 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. +The live tests run the conformance kit (scope listing included), a cross-organisation sync and a board 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) diff --git a/packages/adapters/graph/source-github-issues/package.json b/packages/adapters/graph/source-github-issues/package.json index 8561119..48d60ab 100644 --- a/packages/adapters/graph/source-github-issues/package.json +++ b/packages/adapters/graph/source-github-issues/package.json @@ -1,7 +1,7 @@ { "name": "@exadev/process-graph-source-github-issues", "version": "1.0.1", - "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.", + "description": "The GitHub issues GraphSource adapter: issues as bound activities, blockedBy and blocking as transitions, sub-issues, repositories, organisations and assignees, read through the GraphQL API with server-side filters, pagination, rate-limit handling and typed errors across organisations, repositories and Projects v2 boards, with a scope listing for pickers.", "type": "module", "repository": { "type": "git", diff --git a/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts b/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts index feeebc1..eb66689 100644 --- a/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts +++ b/packages/adapters/graph/source-github-issues/scripts/record-fixtures.ts @@ -9,9 +9,15 @@ import { writeFileSync } from "node:fs"; import { assembleSync, createGraphSourceRouter, + type GraphSourceClient, + paginate, + type SyncFilter, type SyncPage, } from "@exadev/process-graph-source-contract"; -import { inProcessClient } from "@exadev/process-graph-source-contract/conformance"; +import { + conformanceFilter, + inProcessClient, +} from "@exadev/process-graph-source-contract/conformance"; import { createGitHubIssuesSource } from "../src/source.ts"; import { githubTokenFromEnvironment } from "../src/node.ts"; import { @@ -21,7 +27,11 @@ import { requestKey, } from "../src/test-support/recording.ts"; import { + fixtureFilter, + fixtureFilteredScope, + fixtureListingParent, fixturePageSize, + fixtureProjectScope, fixtureScope, missingOwnerScope, missingRepositoryScope, @@ -45,11 +55,10 @@ const recordingFetch = async ( return new Response(JSON.stringify(body), { status: response.status }); }; -const walk = async ( +const clientWith = ( token: ReturnType, - scope: readonly string[], -): Promise => { - const client = inProcessClient( +): GraphSourceClient => + inProcessClient( createGraphSourceRouter( createGitHubIssuesSource({ token, @@ -58,17 +67,46 @@ const walk = async ( }), ), ); + +const walk = async ( + token: ReturnType, + scope: readonly string[], + filter?: SyncFilter, +): Promise => { const pages: SyncPage[] = []; - for await (const page of await client.v1.sync.stream({ scope })) { + for await (const page of await clientWith(token).v1.sync.stream( + filter === undefined ? { scope } : { scope, filter }, + )) { pages.push(page); } return pages; }; +/** Reads every page of an owner's scope listing. The viewer's own listing is never recorded: it would name the recording account's organisations. */ +const walkChildren = async ( + token: ReturnType, + parent: string, +): Promise => { + const client = clientWith(token); + const pages = paginate(undefined, async (after) => + client.v1.scopes.children( + after === undefined ? { scope: parent } : { scope: parent, after }, + ), + ); + for await (const page of pages) { + console.log(`listed ${String(page.entries.length)} scopes of ${parent}`); + } +}; + const token = githubTokenFromEnvironment(); const pages = await walk(token, fixtureScope); +await walk(token, fixtureScope, conformanceFilter); +await walk(token, [fixtureFilteredScope], fixtureFilter); +await walk(token, [fixtureProjectScope]); await walk(token, [missingOwnerScope, missingRepositoryScope]); +await walkChildren(token, fixtureListingParent); +await walkChildren(token, missingOwnerScope); // 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"), diff --git a/packages/adapters/graph/source-github-issues/src/cursor.ts b/packages/adapters/graph/source-github-issues/src/cursor.ts index 9b40820..549c77c 100644 --- a/packages/adapters/graph/source-github-issues/src/cursor.ts +++ b/packages/adapters/graph/source-github-issues/src/cursor.ts @@ -1,4 +1,8 @@ -import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import { + GraphSourceError, + type SyncFilter, + syncFilterSchema, +} from "@exadev/process-graph-source-contract"; import { z } from "zod"; import type { Scope } from "./identifiers.ts"; @@ -7,7 +11,8 @@ import type { Scope } from "./identifiers.ts"; * * - `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. + * - `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 unless a filter orders them otherwise, so new issues append and never shift a position. + * - For a project scope, `items` is the `after` cursor for the next page of the board's items (`null` for the first). */ const repositoryPositionSchema = z.strictObject({ scope: z.string(), @@ -21,14 +26,25 @@ const ownerPositionSchema = z.strictObject({ issues: z.string().nullable(), }); -const positionSchema = z.union([repositoryPositionSchema, ownerPositionSchema]); +const projectPositionSchema = z.strictObject({ + scope: z.string(), + items: z.string().nullable(), +}); + +const positionSchema = z.union([ + repositoryPositionSchema, + ownerPositionSchema, + projectPositionSchema, +]); /** A position within a repository scope. */ type RepositoryPosition = z.infer; /** A position within an owner scope. */ type OwnerPosition = z.infer; +/** A position within a project scope. */ +type ProjectPosition = z.infer; /** A position within a sync. */ -export type Position = RepositoryPosition | OwnerPosition; +export type Position = RepositoryPosition | OwnerPosition | ProjectPosition; /** 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 = @@ -41,6 +57,11 @@ export type Located = readonly kind: "owner"; readonly scope: Extract; readonly position: OwnerPosition; + } + | { + readonly kind: "project"; + readonly scope: Extract; + readonly position: ProjectPosition; }; /** @@ -48,39 +69,84 @@ export type Located = * @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 }, - }; +export const startOf = (scope: Scope): Located => { + if (scope.kind === "owner") { + return { + kind: "owner", + scope, + position: { + scope: scope.id, + repositories: null, + repository: 0, + issues: null, + }, + }; + } + if (scope.kind === "project") { + return { + kind: "project", + scope, + position: { scope: scope.id, items: null }, + }; + } + + return { + 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; +/** A sync cursor: where the next page starts, and the filter it was issued under, absent for none. */ const envelopeSchema = z.strictObject({ format: z.literal(cursorFormat), position: positionSchema, + filter: syncFilterSchema.optional(), }); +/** A listing cursor: the parent scope whose children it pages (`null` for the top-level listing), and GitHub's cursor to read on after. */ +const listingEnvelopeSchema = z.strictObject({ + format: z.literal(cursorFormat), + parent: z.string().nullable(), + after: z.string(), +}); + +/** A filter's fields in a fixed order, so two filters compare by value whatever order their fields were written in. */ +const filterKey = (filter: SyncFilter | undefined): string => + JSON.stringify( + filter === undefined + ? null + : [ + filter.states ?? null, + filter.labels ?? null, + filter.order === undefined + ? null + : [filter.order.field, filter.order.direction], + ], + ); + +/** Base64 of a small JSON envelope, which is printable and free of the line breaks an event-stream id must not contain. */ +const encodeEnvelope = (envelope: unknown): string => + btoa(JSON.stringify(envelope)); + /** - * 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. + * Encodes a position as an opaque cursor, together with the filter the sync runs under. * @param position - Where the next page starts. + * @param filter - The sync's filter, or `undefined` for none. * @returns The cursor. */ -export const encodeCursor = (position: Position): string => - btoa(JSON.stringify({ format: cursorFormat, position })); +export const encodeCursor = ( + position: Position, + filter: SyncFilter | undefined, +): string => + encodeEnvelope({ + format: cursorFormat, + position, + ...(filter === undefined ? {} : { filter }), + }); const invalidCursor = (cursor: string, message: string): GraphSourceError => new GraphSourceError({ @@ -117,12 +183,14 @@ const decodeBase64 = (cursor: string): string => { * 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. + * @param filter - The filter 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. + * @throws GraphSourceError with `bad-request` and `invalid-cursor` when it is not a cursor this adapter issued, was issued under another filter, names a scope the request does not contain, or has the shape of another kind of scope. */ export const decodeCursor = ( cursor: string, scopes: readonly Scope[], + filter: SyncFilter | undefined, ): Located => { const envelope = envelopeSchema.safeParse( parseJson(cursor, decodeBase64(cursor)), @@ -130,6 +198,12 @@ export const decodeCursor = ( if (!envelope.success) { throw invalidCursor(cursor, envelope.error.message); } + if (filterKey(envelope.data.filter) !== filterKey(filter)) { + throw invalidCursor( + cursor, + "it was issued under a different filter from the request's", + ); + } const { position } = envelope.data; const scope = scopes.find((candidate) => candidate.id === position.scope); if (scope === undefined) { @@ -141,12 +215,57 @@ export const decodeCursor = ( if (scope.kind === "owner" && "repositories" in position) { return { kind: "owner", scope, position }; } - if (scope.kind === "repository" && !("repositories" in position)) { + if ( + scope.kind === "repository" && + "issues" in position && + !("repositories" in position) + ) { return { kind: "repository", scope, position }; } + if (scope.kind === "project" && "items" in position) { + return { kind: "project", scope, position }; + } throw invalidCursor( cursor, `it does not fit the ${scope.kind} scope ${scope.id}`, ); }; + +/** + * Encodes where a scope listing resumes. + * @param parent - The parent scope whose children are listed, or `null` for the top-level listing. + * @param after - GitHub's cursor to read on after. + * @returns The cursor. + */ +export const encodeListingCursor = ( + parent: string | null, + after: string, +): string => encodeEnvelope({ format: cursorFormat, parent, after }); + +/** + * Decodes a listing cursor this adapter issued. + * @param cursor - The cursor from an earlier listing page's `next`. + * @param parent - The parent of the request it is used with, or `null` for the top-level listing. + * @returns GitHub's cursor to read on after. + * @throws GraphSourceError with `bad-request` and `invalid-cursor` when it is not a listing cursor this adapter issued, or was issued for another listing. + */ +export const decodeListingCursor = ( + cursor: string, + parent: string | null, +): string => { + const envelope = listingEnvelopeSchema.safeParse( + parseJson(cursor, decodeBase64(cursor)), + ); + if (!envelope.success) { + throw invalidCursor(cursor, envelope.error.message); + } + if (envelope.data.parent !== parent) { + throw invalidCursor( + cursor, + `it is for the listing of ${envelope.data.parent ?? "the top-level scopes"}`, + ); + } + + return envelope.data.after; +}; diff --git a/packages/adapters/graph/source-github-issues/src/graphql.ts b/packages/adapters/graph/source-github-issues/src/graphql.ts index b64fe01..a4a440d 100644 --- a/packages/adapters/graph/source-github-issues/src/graphql.ts +++ b/packages/adapters/graph/source-github-issues/src/graphql.ts @@ -9,12 +9,12 @@ 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 owner - The login of the account whose repositories or board the request reads, or `undefined` for a request about the token's own holder (the viewer and the organisations it belongs to), which no owner names. * @param signal - The caller's abort signal. * @returns The token. */ export type GitHubTokenPort = ( - owner: string, + owner: string | undefined, signal: AbortSignal | undefined, ) => Promise; @@ -145,8 +145,8 @@ export interface GraphqlRequest { readonly query: string; /** Its variables. */ readonly variables: Readonly>; - /** The login whose token authorises the request. */ - readonly owner: string; + /** The login whose token authorises the request, or `undefined` for a request about the token's own holder. */ + readonly owner: string | undefined; /** The schema `data` must match. */ readonly schema: Schema; } @@ -185,7 +185,7 @@ export const graphql = async ( if (response.status === unauthorised) { throw new GraphSourceError({ kind: "unauthorised", - message: `GitHub rejected the token for ${request.owner}: ${messageOf(body) ?? "HTTP 401"}`, + message: `GitHub rejected the token${request.owner === undefined ? "" : ` for ${request.owner}`}: ${messageOf(body) ?? "HTTP 401"}`, }); } if (isRateLimited(response, body, errors)) { 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 index a07716c..68c885f 100644 --- a/packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts +++ b/packages/adapters/graph/source-github-issues/src/graphql.unit.test.ts @@ -45,6 +45,7 @@ const issue = ( }, assignees: empty, blockedBy: empty, + blocking: empty, subIssues: empty, ...extra, }; diff --git a/packages/adapters/graph/source-github-issues/src/identifiers.ts b/packages/adapters/graph/source-github-issues/src/identifiers.ts index 6209a3a..e6cf45d 100644 --- a/packages/adapters/graph/source-github-issues/src/identifiers.ts +++ b/packages/adapters/graph/source-github-issues/src/identifiers.ts @@ -10,6 +10,12 @@ const prefix = `${githubSource}:`; */ const namePattern = /^[\w.-]+$/u; +/** A Projects v2 board's number as its URL shows it: a positive integer with no leading zero. */ +const numberPattern = /^[1-9]\d*$/u; + +/** The path segment between an owner and a board's number in a project scope, as in the board's own URL. */ +const projectsSegment = "projects"; + /** What one scope identifier selects. */ export type Scope = | { readonly kind: "owner"; readonly id: string; readonly owner: string } @@ -18,36 +24,54 @@ export type Scope = readonly id: string; readonly owner: string; readonly name: string; + } + | { + readonly kind: "project"; + readonly id: string; + readonly owner: string; + readonly number: number; }; +const invalidScope = (id: string): GraphSourceError => + new GraphSourceError({ + kind: "bad-request", + message: `"${id}" is not a GitHub scope; expected github:owner, github:owner/repo or github:owner/projects/number`, + reason: "invalid-scope", + value: id, + }); + /** - * Parses a scope identifier: `github:owner` selects every repository the account owns, `github:owner/repo` selects one. + * Parses a scope identifier: `github:owner` selects every repository the account owns, `github:owner/repo` one repository, and `github:owner/projects/number` the issues on an organisation's or user's Projects v2 board. A repository name cannot hold a slash, so the three forms never overlap. * @param id - The scope identifier. * @returns The parsed scope. - * @throws GraphSourceError with `bad-request` and `invalid-scope` when it is neither form. + * @throws GraphSourceError with `bad-request` and `invalid-scope` when it is none of the forms. */ 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, - }); + const [owner, name, number, ...rest] = parts; + if (owner === undefined || !namePattern.test(owner) || rest.length > 0) { + throw invalidScope(id); + } + if (name === undefined) { + return { kind: "owner", id, owner }; + } + if (number === undefined && namePattern.test(name)) { + return { kind: "repository", id, owner, name }; + } + if ( + name === projectsSegment && + number !== undefined && + numberPattern.test(number) + ) { + return { kind: "project", id, owner, number: Number(number) }; } - return name === undefined - ? { kind: "owner", id, owner } - : { kind: "repository", id, owner, name }; + throw invalidScope(id); }; +/** The scope identifier of an owner: `github:owner`. */ +export const ownerScopeId = (login: string): string => `${prefix}${login}`; + /** 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)}`; 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 index 2dfdd28..c16dd3a 100644 --- a/packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts +++ b/packages/adapters/graph/source-github-issues/src/identifiers.unit.test.ts @@ -32,6 +32,20 @@ describe("parseScope", () => { }); }); + test("reads a Projects v2 scope, and a repository named projects is still a repository", () => { + const number = 16737; + expect(parseScope("github:github/projects/16737")).toEqual({ + kind: "project", + id: "github:github/projects/16737", + owner: "github", + number, + }); + expect(parseScope("github:ExaDev/projects")).toMatchObject({ + kind: "repository", + name: "projects", + }); + }); + test.each([ "ExaDev", "github:", @@ -40,10 +54,15 @@ describe("parseScope", () => { "github:a b", "linear:team", "github:ExaDev/", + "github:o/projects/0", + "github:o/projects/01", + "github:o/projects/x", + "github:o/projects/1/2", + "github:o/boards/1", ])("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`, + message: `"${scope}" is not a GitHub scope; expected github:owner, github:owner/repo or github:owner/projects/number`, reason: "invalid-scope", value: scope, }); diff --git a/packages/adapters/graph/source-github-issues/src/index.ts b/packages/adapters/graph/source-github-issues/src/index.ts index 57544fa..fa1a479 100644 --- a/packages/adapters/graph/source-github-issues/src/index.ts +++ b/packages/adapters/graph/source-github-issues/src/index.ts @@ -1,5 +1,5 @@ /** - * 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. + * The GitHub issues `GraphSource` adapter. `createGitHubIssuesSource` returns a pager for `createGraphSourceRouter` from `@exadev/process-graph-source-contract`, with a scope listing. The token comes from an injected port: `storedTokenPort` chooses among tokens a host keeps, per owner, and `githubTokenFromEnvironment` in the `./node` entry point reads `GITHUB_TOKEN` or the GitHub CLI's token. */ export { createGitHubIssuesSource, @@ -14,7 +14,14 @@ export { accountId, githubSource, issueId, + ownerScopeId, parseScope, repositoryId, type Scope, } from "./identifiers.ts"; +export { + selectStoredToken, + type StoredGitHubToken, + storedTokenPort, + type StoredTokenPortOptions, +} from "./tokens.ts"; diff --git a/packages/adapters/graph/source-github-issues/src/listing.unit.test.ts b/packages/adapters/graph/source-github-issues/src/listing.unit.test.ts new file mode 100644 index 0000000..9a0987c --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/listing.unit.test.ts @@ -0,0 +1,190 @@ +import { createGraphSourceRouter } from "@exadev/process-graph-source-contract"; +import { inProcessClient } from "@exadev/process-graph-source-contract/conformance"; +import { describe, expect, test } from "vitest"; +import type { GraphqlFetch } from "./graphql.ts"; +import { createGitHubIssuesSource } from "./source.ts"; +import { graphqlRequestOf } from "./test-support/recording.ts"; +import { fakeToken, fixedNow } from "./test-support/sources.ts"; + +/** + * The viewer's own listing over written responses in the shape of GitHub's documentation examples (`octocat`, `octo-org`), since a recording would name the recording account's organisations. + */ + +const ok = 200; +const pageSize = 2; + +const viewerPage = ( + organisations: readonly unknown[], + endCursor: string | null, +) => ({ + data: { + viewer: { + login: "octocat", + name: "The Octocat", + url: "https://github.com/octocat", + organizations: { + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: organisations, + }, + }, + }, +}); + +const octoOrg = { + login: "octo-org", + name: "Octo Org", + description: "The demonstration organisation", + url: "https://github.com/octo-org", + archivedAt: null, +}; +const retired = { + login: "octo-retired", + name: null, + description: null, + url: "https://github.com/octo-retired", + archivedAt: "2025-01-01T00:00:00Z", +}; +const later = { + login: "octo-later", + name: "Octo Later", + description: null, + url: "https://github.com/octo-later", + archivedAt: null, +}; + +/** Answers the viewer listing by its `after` variable: two organisations, then one more. */ +const viewerFetch: GraphqlFetch = async (_url, init) => { + const { variables } = graphqlRequestOf(init.body); + const body = + variables.after === null + ? viewerPage([octoOrg, retired], "page-two") + : viewerPage([later], null); + + return Promise.resolve(new Response(JSON.stringify(body), { status: ok })); +}; + +/** The adapter over `fetch`, recording which owner each token request named. */ +const routerWith = (fetch: GraphqlFetch) => { + const owners: (string | undefined)[] = []; + const router = createGraphSourceRouter( + createGitHubIssuesSource({ + token: async (owner) => { + owners.push(owner); + + return Promise.resolve(fakeToken); + }, + fetch, + now: () => fixedNow, + pageSize, + }), + ); + + return { client: inProcessClient(router), owners }; +}; + +describe("the viewer's top-level scopes", () => { + test("lead with the viewer's own account, then its organisations, archived ones marked", async () => { + const { client } = routerWith(viewerFetch); + const first = await client.v1.scopes.roots({}); + expect(first.entries).toEqual([ + { + scope: "github:octocat", + label: "The Octocat", + url: "https://github.com/octocat", + archived: false, + }, + { + scope: "github:octo-org", + label: "Octo Org", + description: "The demonstration organisation", + url: "https://github.com/octo-org", + archived: false, + }, + { + scope: "github:octo-retired", + label: "octo-retired", + url: "https://github.com/octo-retired", + archived: true, + }, + ]); + expect(first.gaps).toEqual([]); + await expect( + client.v1.scopes.roots({ after: first.next ?? "" }), + ).resolves.toEqual({ + entries: [ + { + scope: "github:octo-later", + label: "Octo Later", + url: "https://github.com/octo-later", + archived: false, + }, + ], + gaps: [], + }); + }); + + test("ask the token port for the token's own holder, which no owner names", async () => { + const { client, owners } = routerWith(viewerFetch); + await client.v1.scopes.roots({}); + expect(owners).toEqual([undefined]); + }); + + test("refuse a cursor from an owner's listing", async () => { + const { client } = routerWith(viewerFetch); + const first = await client.v1.scopes.roots({}); + await expect( + client.v1.scopes.children({ + scope: "github:octo-org", + after: first.next ?? "", + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor" }, + }); + }); +}); + +describe("an owner's repositories", () => { + test("mark an archived repository and ask for the owner's token", async () => { + const { client, owners } = routerWith(async () => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + repositoryOwner: { + __typename: "Organization", + login: "octo-org", + repositories: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + name: "octo-attic", + description: null, + url: "https://github.com/octo-org/octo-attic", + isArchived: true, + }, + ], + }, + }, + }, + }), + { status: ok }, + ), + ), + ); + await expect( + client.v1.scopes.children({ scope: "github:octo-org" }), + ).resolves.toEqual({ + entries: [ + { + scope: "github:octo-org/octo-attic", + label: "octo-attic", + url: "https://github.com/octo-org/octo-attic", + archived: true, + }, + ], + gaps: [], + }); + expect(owners).toEqual(["octo-org"]); + }); +}); 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 index 1cc5d4a..960924f 100644 --- a/packages/adapters/graph/source-github-issues/src/live.integration.test.ts +++ b/packages/adapters/graph/source-github-issues/src/live.integration.test.ts @@ -5,12 +5,19 @@ import { } from "@exadev/process-graph-source-contract"; import { graphSourceConformance, + graphSourceListingConformance, 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"; +import { + fixtureListingParent, + fixturePageSize, + fixtureProjectScope, + 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. @@ -23,6 +30,15 @@ const liveRouter = () => createGitHubIssuesSource({ token: githubTokenFromEnvironment() }), ); +/** At the fixture page size, so the listing parent's repositories span two pages, as they do in the recording. */ +const smallPageRouter = () => + createGraphSourceRouter( + createGitHubIssuesSource({ + token: githubTokenFromEnvironment(), + pageSize: fixturePageSize, + }), + ); + /** * 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. */ @@ -40,6 +56,30 @@ describe.skipIf(!live)( malformedScope: "ExaDev/document-schema.js", }); + graphSourceListingConformance({ + name: "GitHub issues, live", + createClient: () => inProcessClient(smallPageRouter()), + roots: true, + parent: fixtureListingParent, + missingParent: missingOwnerScope, + malformedParent: "octo-org", + }); + + test("a Projects v2 board syncs its issues", async () => { + const pages: SyncPage[] = []; + const client = inProcessClient(liveRouter()); + for await (const page of await client.v1.sync.stream({ + scope: [fixtureProjectScope], + })) { + pages.push(page); + } + const { model, gaps } = assembleSync(pages); + expect(gaps).toEqual([]); + expect(model.elements.map((element) => element.id)).toContain( + "github:github/semantic#480", + ); + }); + test("a sync across two organisations keeps the transition between them", async () => { const pages: SyncPage[] = []; const client = inProcessClient(liveRouter()); diff --git a/packages/adapters/graph/source-github-issues/src/page-builder.ts b/packages/adapters/graph/source-github-issues/src/page-builder.ts index 5bff894..1b41803 100644 --- a/packages/adapters/graph/source-github-issues/src/page-builder.ts +++ b/packages/adapters/graph/source-github-issues/src/page-builder.ts @@ -21,6 +21,8 @@ export interface PageBuilder { 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 an issue the given one blocks, and the transition from the given one to it: the same transition `blockedBy` adds from the other side, so a dependency read from both ends is one transition. */ + readonly blocking: (issue: string, blocked: Readonly) => void; /** Adds a sub-issue and the `child-of` relation to its parent. */ readonly subIssue: (parent: string, child: Readonly) => void; /** Records a gap. */ @@ -72,6 +74,12 @@ export const createPageBuilder = (): PageBuilder => { return id; }; + /** The transition from a prerequisite to the work that waits on it, identified by both ends so either side's read gives the same one. */ + const depend = (source: string, target: string): void => { + const id = `${source}->${target}`; + transitions.set(id, { id, source, target }); + }; + const issue = (ref: Readonly): string => { const { owner, name } = ref.repository; const actor = repository(owner, name); @@ -98,9 +106,10 @@ export const createPageBuilder = (): PageBuilder => { 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 }); + depend(issue(blocker), issueIdentifier); + }, + blocking: (issueIdentifier, blocked) => { + depend(issueIdentifier, issue(blocked)); }, subIssue: (parent, child) => { relate({ kind: "child-of", from: issue(child), to: parent }); diff --git a/packages/adapters/graph/source-github-issues/src/queries.ts b/packages/adapters/graph/source-github-issues/src/queries.ts index 6c87acc..a36466e 100644 --- a/packages/adapters/graph/source-github-issues/src/queries.ts +++ b/packages/adapters/graph/source-github-issues/src/queries.ts @@ -37,6 +37,7 @@ const userSchema = z.object({ login: z.string() }); const issueSchema = issueRefSchema.extend({ assignees: connectionOf(userSchema), blockedBy: connectionOf(issueRefSchema), + blocking: connectionOf(issueRefSchema), subIssues: connectionOf(issueRefSchema), }); @@ -50,18 +51,22 @@ 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) { +/** An issue with the first page of each of its connections: assignees, the issues blocking it, the issues it blocks, and its sub-issues. */ +const issueFields = `${issueRefFields} + assignees(first: $first) { ${pageInfoFields} nodes { login } } + blockedBy(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } + blocking(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } + subIssues(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } }`; + +/** A page of one repository's issues, in the states, labels and order the variables give, each with the first page of its connections. */ +export const repositoryIssuesQuery = `query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) { repository(owner: $owner, name: $name) { name owner { ${accountFields} } - issues(first: $first, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) { + issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) { ${pageInfoFields} nodes { - ${issueRefFields} - assignees(first: $first) { ${pageInfoFields} nodes { login } } - blockedBy(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } - subIssues(first: $first) { ${pageInfoFields} nodes { ${issueRefFields} } } + ${issueFields} } } } @@ -77,29 +82,115 @@ export const repositoryIssuesSchema = z.object({ .nullable(), }); -/** A page of the repositories an account owns, by name. */ +/** A page of the repositories an account owns, by name, with what a scope picker shows of each. A sync reads the names; a scope listing reads the rest too, so both share one request. */ 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 } + nodes { name description url isArchived } } } }`; +const repositoryEntrySchema = z.object({ + name: z.string(), + description: z.string().nullable(), + url: z.url(), + isArchived: z.boolean(), +}); + +export type RepositoryEntry = z.infer; + export const ownerRepositoriesSchema = z.object({ repositoryOwner: z .object({ __typename: z.enum(["Organization", "User"]), login: z.string(), - repositories: connectionOf(z.object({ name: z.string() })), + repositories: connectionOf(repositoryEntrySchema), + }) + .nullable(), +}); + +/** The viewer's own account and a page of the organisations it belongs to, as a scope picker shows them. */ +export const viewerScopesQuery = `query ViewerScopes($first: Int!, $after: String) { + viewer { + login + name + url + organizations(first: $first, after: $after) { + ${pageInfoFields} + nodes { login name description url archivedAt } + } + } +}`; + +const organisationEntrySchema = z.object({ + login: z.string(), + name: z.string().nullable(), + description: z.string().nullable(), + url: z.url(), + archivedAt: z.string().nullable(), +}); + +export type OrganisationEntry = z.infer; + +export const viewerScopesSchema = z.object({ + viewer: z.object({ + login: z.string(), + name: z.string().nullable(), + url: z.url(), + organizations: connectionOf(organisationEntrySchema), + }), +}); + +/** A page of a Projects v2 board's items, each issue with the first page of its connections. Other kinds of item (pull requests, draft issues) are named only by type, since they are not issues. */ +export const projectItemsQuery = `query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) { + repositoryOwner(login: $login) { + ${accountFields} + ... on ProjectV2Owner { + projectV2(number: $number) { + items(first: $first, after: $after) { + ${pageInfoFields} + nodes { + content { + __typename + ... on Issue { + ${issueFields} + } + } + } + } + } + } + } +}`; + +/** An item's content: an issue, or another kind of item named only by its type. */ +const itemContentSchema = z.union([ + issueSchema.extend({ __typename: z.literal("Issue") }), + z.object({ __typename: z.enum(["PullRequest", "DraftIssue"]) }), +]); + +export const projectItemsSchema = z.object({ + repositoryOwner: z + .object({ + __typename: z.enum(["Organization", "User"]), + login: z.string(), + projectV2: z + .object({ + items: connectionOf( + z.object({ content: itemContentSchema.nullable() }), + ), + }) + .nullable(), }) .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"; +export type IssueConnection = + "assignees" | "blockedBy" | "blocking" | "subIssues"; /** The rest of one issue connection after `after`. */ export const issueConnectionQuery = (connection: IssueConnection): string => { @@ -126,6 +217,9 @@ export const assigneesConnectionSchema = issueConnectionDataSchema({ export const blockedByConnectionSchema = issueConnectionDataSchema({ blockedBy: connectionOf(issueRefSchema), }); +export const blockingConnectionSchema = issueConnectionDataSchema({ + blocking: 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 index a51fa81..b8ec6d7 100644 --- a/packages/adapters/graph/source-github-issues/src/source.ts +++ b/packages/adapters/graph/source-github-issues/src/source.ts @@ -1,13 +1,21 @@ import { + type Gap, + GraphSourceError, type GraphSourcePager, + type GraphSourceScopes, type PageRequest, paginate, + type ScopeEntry, + type ScopeListing, + type SyncFilter, type SyncPage, } from "@exadev/process-graph-source-contract"; import type { z } from "zod"; import { decodeCursor, + decodeListingCursor, encodeCursor, + encodeListingCursor, type Position, startOf, } from "./cursor.ts"; @@ -18,20 +26,34 @@ import { type GraphqlTransport, type Problem, } from "./graphql.ts"; -import { githubSource, issueId, parseScope } from "./identifiers.ts"; +import { + githubSource, + issueId, + ownerScopeId, + parseScope, + repositoryId, + type Scope, +} from "./identifiers.ts"; import { createPageBuilder, type PageBuilder } from "./page-builder.ts"; import { assigneesConnectionSchema, blockedByConnectionSchema, + blockingConnectionSchema, type Issue, type IssueConnection, issueConnectionQuery, + type OrganisationEntry, ownerRepositoriesQuery, ownerRepositoriesSchema, type PageInfo, + projectItemsQuery, + projectItemsSchema, repositoryIssuesQuery, repositoryIssuesSchema, + type RepositoryEntry, subIssuesConnectionSchema, + viewerScopesQuery, + viewerScopesSchema, } from "./queries.ts"; /** GitHub's largest allowed `first` on a connection; the API rejects larger values. */ @@ -42,6 +64,41 @@ const githubMaxPageSize = 100; */ const defaultPageSize = githubMaxPageSize / 2; +/** GitHub's `IssueState` for each run state a filter selects by: an issue closed for any reason is done. */ +const issueStates = { open: "OPEN", done: "CLOSED" } as const; + +/** GitHub's `IssueOrderField` for each order field a filter names. */ +const issueOrderFields = { + created: "CREATED_AT", + updated: "UPDATED_AT", + comments: "COMMENTS", +} as const; + +/** GitHub's `OrderDirection` for each direction a filter names. */ +const orderDirections = { ascending: "ASC", descending: "DESC" } as const; + +/** + * The issue listing's variables for a filter. With no filter, or where it says nothing, every issue is listed, oldest first: creation order keeps a cursor's position valid as new issues are opened. + */ +const issueListing = ( + filter: SyncFilter | undefined, +): Readonly> => ({ + states: (filter?.states ?? (["open", "done"] as const)).map( + (state) => issueStates[state], + ), + labels: filter?.labels ?? null, + orderBy: + filter?.order === undefined + ? { + field: issueOrderFields.created, + direction: orderDirections.ascending, + } + : { + field: issueOrderFields[filter.order.field], + direction: orderDirections[filter.order.direction], + }, +}); + /** 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`. */ @@ -82,6 +139,8 @@ interface RepositoryRead { 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 sync's filter, which narrows and orders the listing. */ + readonly filter: SyncFilter | undefined; } /** The issue whose connection is being read. */ @@ -99,9 +158,29 @@ const endCursorOf = (pageInfo: Readonly): string | undefined => ? pageInfo.endCursor : undefined; +/** Where gaps are recorded: a page being built, or a listing. */ +type GapSink = Pick; + +/** A list a listing's gaps are recorded into. */ +const gapList = (): { + readonly gaps: readonly Gap[]; + readonly sink: GapSink; +} => { + const gaps: Gap[] = []; + + return { + gaps, + sink: { + gap: (gap) => { + gaps.push(gap); + }, + }, + }; +}; + /** Records the problems of a response as one gap on `target`, when there are any. */ const recordProblems = ( - builder: PageBuilder, + builder: GapSink, target: string, problems: readonly Problem[], ): void => { @@ -119,7 +198,7 @@ const recordProblems = ( * 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, + builder: GapSink, target: string, problems: readonly Problem[], ): void => { @@ -135,9 +214,11 @@ const recordAbsence = ( }; /** - * 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`. + * The GitHub issues `GraphSourcePager`. A page is one page of one repository's issues, or of one board's items: each issue becomes a bound activity (`github:owner/repo#n`) whose actor is its repository, each `blockedBy` and `blocking` entry a transition from the blocker to the blocked issue (one transition, whichever side it is read from), each sub-issue a `child-of` relation, each assignee an `assigned-to` relation, and the repository `part-of` its owner. Blockers, blocked issues 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. + * Scopes are read in request order, each once: `github:owner/repo` for one repository, `github:owner` for every repository the account owns, by name, and `github:owner/projects/number` for the issues on an organisation's or user's Projects v2 board, in the board's order (its pull requests and draft issues are not issues, so they are passed over). A repository, owner or board that does not exist or that the token cannot see is a gap, and the sync continues. A filter narrows and orders each repository's issue listing on GitHub's side; a board has no such listing, so a filter with a board scope is a `bad-request` with `invalid-scope`. + * + * The pager also lists scopes: the viewer's own account and organisations at the top, and an owner's repositories, by name, inside it. * @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. @@ -214,7 +295,7 @@ export const createGitHubIssuesSource = ( name: issue.repository.name, number: issue.number, }; - const [assignees, blockers, children] = await Promise.all([ + const [assignees, blockers, blocked, children] = await Promise.all([ allOf( { key, @@ -235,6 +316,16 @@ export const createGitHubIssuesSource = ( }, signal, ), + allOf( + { + key, + connection: "blocking", + first: issue.blocking, + schema: blockingConnectionSchema, + extract: (data) => data.repository?.issue?.blocking, + }, + signal, + ), allOf( { key, @@ -252,6 +343,9 @@ export const createGitHubIssuesSource = ( for (const blocker of blockers) { builder.blockedBy(id, blocker); } + for (const issueBlocked of blocked) { + builder.blocking(id, issueBlocked); + } for (const child of children) { builder.subIssue(id, child); } @@ -266,12 +360,17 @@ export const createGitHubIssuesSource = ( read: RepositoryRead, signal: AbortSignal | undefined, ): Promise => { - const { target, repository, after } = read; + const { target, repository, after, filter } = read; const { data, problems } = await graphql( transport, { query: repositoryIssuesQuery, - variables: { ...repository, first: pageSize, after }, + variables: { + ...repository, + first: pageSize, + after, + ...issueListing(filter), + }, owner: repository.owner, schema: repositoryIssuesSchema, }, @@ -293,24 +392,90 @@ export const createGitHubIssuesSource = ( return data.repository.issues.pageInfo; }; + /** + * Adds one page of a board's items to the page: each issue on it, with its connections. + * @returns The items' page info, or `undefined` when the owner or board was a gap. + */ + const readProject = async ( + builder: PageBuilder, + project: Extract, + after: string | null, + signal: AbortSignal | undefined, + ): Promise => { + const { data, problems } = await graphql( + transport, + { + query: projectItemsQuery, + variables: { + login: project.owner, + number: project.number, + first: pageSize, + after, + }, + owner: project.owner, + schema: projectItemsSchema, + }, + signal, + ); + const board = data.repositoryOwner?.projectV2; + if (board === null || board === undefined) { + recordAbsence(builder, project.id, problems); + + return undefined; + } + recordProblems(builder, project.id, problems); + const issues = present(board.items).flatMap(({ content }) => + content?.__typename === "Issue" ? [content] : [], + ); + await Promise.all( + issues.map(async (issue) => addIssue(builder, issue, signal)), + ); + + return board.items.pageInfo; + }; + const read = async ({ scope, cursor, signal, + filter, }: 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 board = scopes.find((candidate) => candidate.kind === "project"); + if (filter !== undefined && board !== undefined) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${board.id}" is a Projects v2 board, which lists its items in the board's own order and cannot be filtered; sync it without a filter`, + reason: "invalid-scope", + value: board.id, + }); + } const located = - cursor === undefined ? startOf(firstScope) : decodeCursor(cursor, scopes); + cursor === undefined + ? startOf(firstScope) + : decodeCursor(cursor, scopes, filter); 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)); + builder.build( + next === undefined ? undefined : encodeCursor(next, filter), + ); + + if (located.kind === "project") { + const { scope: current, position } = located; + const items = await readProject(builder, current, position.items, signal); + const after = items === undefined ? undefined : endCursorOf(items); + + return build( + after === undefined ? nextScope : { scope: current.id, items: after }, + ); + } if (located.kind === "repository") { const { scope: current, position } = located; @@ -320,6 +485,7 @@ export const createGitHubIssuesSource = ( target: current.id, repository: { owner: current.owner, name: current.name }, after: position.issues, + filter, }, signal, ); @@ -375,6 +541,7 @@ export const createGitHubIssuesSource = ( target: `${current.id}/${repository.name}`, repository: { owner: owner.login, name: repository.name }, after: position.issues, + filter, }, signal, ); @@ -391,5 +558,144 @@ export const createGitHubIssuesSource = ( ); }; - return { source: githubSource, page: read }; + /** A listing page from its entries, gaps and GitHub's page info, resuming under `parent`. */ + const listingOf = ( + parent: string | null, + entries: readonly ScopeEntry[], + gaps: readonly Gap[], + pageInfo: PageInfo | undefined, + ): ScopeListing => { + const after = pageInfo === undefined ? undefined : endCursorOf(pageInfo); + + return after === undefined + ? { entries, gaps } + : { entries, gaps, next: encodeListingCursor(parent, after) }; + }; + + /** A scope entry, with the description only when GitHub has one. */ + const entryOf = ( + scope: string, + label: string, + details: { + readonly description: string | null; + readonly url: string; + readonly archived: boolean; + }, + ): ScopeEntry => ({ + scope, + label, + ...(details.description === null + ? {} + : { description: details.description }), + url: details.url, + archived: details.archived, + }); + + const organisationEntry = ( + organisation: Readonly, + ): ScopeEntry => + entryOf( + ownerScopeId(organisation.login), + organisation.name ?? organisation.login, + { + description: organisation.description, + url: organisation.url, + archived: organisation.archivedAt !== null, + }, + ); + + const repositoryEntry = ( + owner: string, + repository: Readonly, + ): ScopeEntry => + entryOf(repositoryId(owner, repository.name), repository.name, { + description: repository.description, + url: repository.url, + archived: repository.isArchived, + }); + + const scopes: GraphSourceScopes = { + roots: async ({ cursor, signal }) => { + const after = + cursor === undefined ? null : decodeListingCursor(cursor, null); + const { data, problems } = await graphql( + transport, + { + query: viewerScopesQuery, + variables: { first: pageSize, after }, + owner: undefined, + schema: viewerScopesSchema, + }, + signal, + ); + const { viewer } = data; + const { gaps, sink } = gapList(); + recordProblems(sink, ownerScopeId(viewer.login), problems); + // The viewer's own account leads the first page: its repositories are a scope like any organisation's. + const own = + after === null + ? [ + entryOf(ownerScopeId(viewer.login), viewer.name ?? viewer.login, { + description: null, + url: viewer.url, + archived: false, + }), + ] + : []; + + return listingOf( + null, + [...own, ...present(viewer.organizations).map(organisationEntry)], + gaps, + viewer.organizations.pageInfo, + ); + }, + children: async ({ scope, cursor, signal }) => { + const parent = parseScope(scope); + // Only an owner has scopes inside it; a repository or a board is a leaf, so it lists nothing and never issues a cursor. + if (parent.kind !== "owner") { + if (cursor !== undefined) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${cursor}" is not a cursor this source issued: ${scope} has no scopes inside it`, + reason: "invalid-cursor", + value: cursor, + }); + } + + return { entries: [], gaps: [] }; + } + const after = + cursor === undefined ? null : decodeListingCursor(cursor, scope); + const { data, problems } = await graphql( + transport, + { + query: ownerRepositoriesQuery, + variables: { login: parent.owner, first: pageSize, after }, + owner: parent.owner, + schema: ownerRepositoriesSchema, + }, + signal, + ); + const { gaps, sink } = gapList(); + const owner = data.repositoryOwner; + if (owner === null) { + recordAbsence(sink, scope, problems); + + return { entries: [], gaps }; + } + recordProblems(sink, scope, problems); + + return listingOf( + scope, + present(owner.repositories).map((repository) => + repositoryEntry(owner.login, repository), + ), + gaps, + owner.repositories.pageInfo, + ); + }, + }; + + return { source: githubSource, page: read, scopes }; }; 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 index c32401c..03ba74e 100644 --- a/packages/adapters/graph/source-github-issues/src/source.unit.test.ts +++ b/packages/adapters/graph/source-github-issues/src/source.unit.test.ts @@ -1,5 +1,6 @@ import { assembleSync, + type SyncFilter, type SyncPage, } from "@exadev/process-graph-source-contract"; import { @@ -12,18 +13,32 @@ import { createGitHubIssuesSource } from "./source.ts"; import { encodeCursor } from "./cursor.ts"; import { fakeToken, recordedRouter } from "./test-support/sources.ts"; import { + fixtureFilter, + fixtureFilteredScope, + fixtureListingParent, + fixturePageSize, + fixtureProjectScope, fixtureScope, missingOwnerScope, missingRepositoryScope, unauthorisedScope, } from "./test-support/scopes.ts"; +/** The viewer's own listing is never recorded, since it would name the recording account's organisations; `graphql.unit.test.ts` covers it over written responses, and the live tests run it. */ +const listing = { + roots: false, + parent: fixtureListingParent, + missingParent: missingOwnerScope, + malformedParent: "octo-org", +}; + graphSourceConformance({ name: "GitHub issues over recorded fixtures, in process", createClient: () => inProcessClient(recordedRouter().router), scope: fixtureScope, missingScope: missingOwnerScope, malformedScope: "ExaDev/document-schema.js", + listing, }); graphSourceConformance({ @@ -32,14 +47,18 @@ graphSourceConformance({ scope: fixtureScope, missingScope: missingRepositoryScope, malformedScope: "github:ExaDev/document-schema.js/extra", + listing: { ...listing, malformedParent: "github:octo-org/a/b/c" }, }); const syncAll = async ( scope: readonly string[], + filter?: SyncFilter, ): Promise => { const client = inProcessClient(recordedRouter().router); const pages: SyncPage[] = []; - for await (const page of await client.v1.sync.stream({ scope })) { + for await (const page of await client.v1.sync.stream( + filter === undefined ? { scope } : { scope, filter }, + )) { pages.push(page); } @@ -114,6 +133,31 @@ describe("the recorded sync", async () => { ]); }); + test("reads what an issue blocks from its own side, so work blocked in a repository outside the scopes is found", () => { + expect(transitionIds).toEqual( + expect.arrayContaining([ + "github:ExaDev/document-schema.js#12->github:ExaDev/documents.js#569", + "github:ExaDev/document-schema.js#13->github:ExaDev/markdown-codec#54", + "github:ExaDev/document-schema.js#13->github:ExaDev/odf.js#52", + "github:ExaDev/document-schema.js#13->github:ExaDev/ooxml.js#51", + ]), + ); + expect(model.elements.map((element) => element.id)).toContain( + "github:ExaDev/documents.js#569", + ); + }); + + test("a dependency read from both of its ends is one transition", () => { + expect(transitionIds).toEqual([...new Set(transitionIds)]); + expect( + transitionIds.filter( + (id) => + id === + "github:ExaDev/document-schema.js#13->github:ExaDev/document-schema.js#14", + ), + ).toHaveLength(1); + }); + 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"); @@ -150,6 +194,165 @@ describe("the recorded sync", async () => { }); }); +describe("a filtered sync", () => { + test("lists only the issues in the filter's states and labels, in its order, on GitHub's side", async () => { + const { router, requests } = recordedRouter(); + const client = inProcessClient(router); + const pages: SyncPage[] = []; + for await (const page of await client.v1.sync.stream({ + scope: [fixtureFilteredScope], + filter: fixtureFilter, + })) { + pages.push(page); + } + expect(pages.map((page) => page.activities[0]?.id)).toEqual([ + "github:Mearman/agent-permissions#79", + "github:Mearman/agent-permissions#63", + "github:Mearman/agent-permissions#56", + "github:Mearman/agent-permissions#38", + ]); + const [first] = pages; + expect( + first?.activities + .slice(0, fixturePageSize) + .map((activity) => activity.id), + ).toEqual([ + "github:Mearman/agent-permissions#79", + "github:Mearman/agent-permissions#65", + "github:Mearman/agent-permissions#64", + ]); + for (const activity of first?.activities.slice(0, fixturePageSize) ?? []) { + expect(first?.overlay[activity.id]).toBe("done"); + } + expect(requests[0]).toContain('["labels",["bug"]]'); + expect(requests[0]).toContain( + '["orderBy",{"field":"CREATED_AT","direction":"DESC"}]', + ); + expect(requests[0]).toContain('["states",["CLOSED"]]'); + }); + + test("a filter with a Projects v2 board is a BAD_REQUEST, since a board's items cannot be filtered", async () => { + const client = inProcessClient(recordedRouter().router); + await expect( + client.v1.sync.page({ + scope: [fixtureFilteredScope, fixtureProjectScope], + filter: fixtureFilter, + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-scope", value: fixtureProjectScope }, + }); + }); +}); + +describe("a Projects v2 board", () => { + test("syncs the issues on the board in its order, passing over pull requests and draft issues", async () => { + const pages = await syncAll([fixtureProjectScope]); + const issues = [ + "github:github/semantic#480", + "github:github/semantic#482", + "github:github/semantic#481", + "github:github/semantic#173", + "github:github/semantic#172", + "github:github/semantic#146", + "github:github/semantic#126", + ]; + expect( + pages.flatMap((page) => page.activities.map((activity) => activity.id)), + ).toEqual(issues); + expect(pages.length).toBeGreaterThan(2); + const { model, gaps } = assembleSync(pages); + expect(model.elements).toContainEqual({ + kind: "activity", + id: "github:github/semantic#480", + source: "github:github/semantic#480", + label: "Precise ASTs don\u2019t support comments", + actor: "github:github/semantic", + }); + expect(gaps).toEqual([]); + }); + + test("a board cursor resumes the board", async () => { + const client = inProcessClient(recordedRouter().router); + const first = await client.v1.sync.page({ scope: [fixtureProjectScope] }); + const second = await client.v1.sync.page({ + scope: [fixtureProjectScope], + after: first.next ?? "", + }); + expect(second.activities.map((activity) => activity.id)).toEqual([ + "github:github/semantic#173", + "github:github/semantic#172", + "github:github/semantic#146", + ]); + }); +}); + +describe("the scope listing", () => { + test("lists an owner's repositories by name, with their descriptions and URLs, a page at a time", async () => { + const client = inProcessClient(recordedRouter().router); + const first = await client.v1.scopes.children({ + scope: fixtureListingParent, + }); + expect(first.entries).toEqual([ + { + scope: "github:octo-org/hello-world-javascript-action", + label: "hello-world-javascript-action", + url: "https://github.com/octo-org/hello-world-javascript-action", + archived: false, + }, + { + scope: "github:octo-org/octo-repo-go", + label: "octo-repo-go", + description: "fork of octodemo/advanced-security-go", + url: "https://github.com/octo-org/octo-repo-go", + archived: false, + }, + { + scope: "github:octo-org/octo-template", + label: "octo-template", + description: "Welcome to the Octo template.", + url: "https://github.com/octo-org/octo-template", + archived: false, + }, + ]); + await expect( + client.v1.scopes.children({ + scope: fixtureListingParent, + after: first.next ?? "", + }), + ).resolves.toEqual({ + entries: [ + { + scope: "github:octo-org/sabrowning1-test", + label: "sabrowning1-test", + url: "https://github.com/octo-org/sabrowning1-test", + archived: false, + }, + ], + gaps: [], + }); + }); + + test("a repository or a board has nothing inside it, and no cursor for it", async () => { + const client = inProcessClient(recordedRouter().router); + await expect( + client.v1.scopes.children({ scope: fixtureFilteredScope }), + ).resolves.toEqual({ entries: [], gaps: [] }); + await expect( + client.v1.scopes.children({ scope: fixtureProjectScope }), + ).resolves.toEqual({ entries: [], gaps: [] }); + await expect( + client.v1.scopes.children({ + scope: fixtureProjectScope, + after: "anything", + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: "anything" }, + }); + }); +}); + 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]); @@ -191,7 +394,10 @@ describe("gaps and failures", () => { 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 }); + const cursor = encodeCursor( + { scope: "github:ExaDev/other", issues: null }, + undefined, + ); await expect( client.v1.sync.page({ scope: ["github:ExaDev/document-schema.js"], @@ -205,7 +411,10 @@ describe("cursors", () => { 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 }); + const cursor = encodeCursor( + { scope: "github:octo-org", issues: null }, + undefined, + ); await expect( client.v1.sync.page({ scope: ["github:octo-org"], after: cursor }), ).rejects.toMatchObject({ @@ -214,14 +423,31 @@ describe("cursors", () => { }); }); - test("a cursor past the repositories an owner now has carries on after the listing", async () => { + test("a board cursor used with a repository scope is invalid", async () => { const client = inProcessClient(recordedRouter().router); - const cursor = encodeCursor({ - scope: "github:octo-org", - repositories: null, - repository: 50, - issues: null, + const cursor = encodeCursor( + { scope: fixtureFilteredScope, items: null }, + undefined, + ); + await expect( + client.v1.sync.page({ scope: [fixtureFilteredScope], 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, + }, + undefined, + ); const page = await client.v1.sync.page({ scope: ["github:octo-org", "github:ExaDev/document-schema.js"], after: cursor, @@ -257,7 +483,7 @@ describe("createGitHubIssuesSource", () => { ); test("asks the token port for the owner of the repository being read", async () => { - const owners: string[] = []; + const owners: (string | undefined)[] = []; const source = createGitHubIssuesSource({ token: async (owner) => { owners.push(owner); 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 index e5fbf1d..2750582 100644 --- 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 @@ -43,6 +43,49 @@ } } }, + { + "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 blocking(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": 13, + "first": 3, + "after": "Mw" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "issue": { + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "NA" + }, + "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" + } + } + } + ] + } + } + } + } + } + } + }, { "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}", @@ -179,7 +222,7 @@ }, { "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}", + "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 description url isArchived }\n }\n }\n}", "variables": { "login": "octo-org", "first": 3, @@ -200,7 +243,10 @@ }, "nodes": [ { - "name": "sabrowning1-test" + "name": "sabrowning1-test", + "description": null, + "url": "https://github.com/octo-org/sabrowning1-test", + "isArchived": false } ] } @@ -211,7 +257,7 @@ }, { "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}", + "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 description url isArchived }\n }\n }\n}", "variables": { "login": "not.a.login", "first": 3, @@ -229,7 +275,7 @@ }, { "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}", + "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 description url isArchived }\n }\n }\n}", "variables": { "login": "octo-org", "first": 3, @@ -250,13 +296,22 @@ }, "nodes": [ { - "name": "hello-world-javascript-action" + "name": "hello-world-javascript-action", + "description": null, + "url": "https://github.com/octo-org/hello-world-javascript-action", + "isArchived": false }, { - "name": "octo-repo-go" + "name": "octo-repo-go", + "description": "fork of octodemo/advanced-security-go", + "url": "https://github.com/octo-org/octo-repo-go", + "isArchived": false }, { - "name": "octo-template" + "name": "octo-template", + "description": "Welcome to the Octo template.", + "url": "https://github.com/octo-org/octo-template", + "isArchived": false } ] } @@ -267,12 +322,518 @@ }, { "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}", + "query": "query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n ... on ProjectV2Owner {\n projectV2(number: $number) {\n items(first: $first, after: $after) {\n pageInfo { hasNextPage endCursor }\n nodes {\n content {\n __typename\n ... on Issue {\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 blocking(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 }\n }\n }\n }\n}", + "variables": { + "login": "github", + "number": 16737, + "first": 3, + "after": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMDgzMzMzMzMzMzMzMzMzM84EaseP" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "github", + "projectV2": { + "items": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMDcxNDI4NTcxNDI4NTcxNM4EaseS" + }, + "nodes": [ + { + "content": { + "__typename": "DraftIssue" + } + }, + { + "content": { + "__typename": "DraftIssue" + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n ... on ProjectV2Owner {\n projectV2(number: $number) {\n items(first: $first, after: $after) {\n pageInfo { hasNextPage endCursor }\n nodes {\n content {\n __typename\n ... on Issue {\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 blocking(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 }\n }\n }\n }\n}", + "variables": { + "login": "github", + "number": 16737, + "first": 3, + "after": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMTExMTExMTExMTExMTExMc4EaseM" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "github", + "projectV2": { + "items": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMDgzMzMzMzMzMzMzMzMzM84EaseP" + }, + "nodes": [ + { + "content": { + "__typename": "PullRequest" + } + }, + { + "content": { + "__typename": "DraftIssue" + } + }, + { + "content": { + "__typename": "DraftIssue" + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n ... on ProjectV2Owner {\n projectV2(number: $number) {\n items(first: $first, after: $after) {\n pageInfo { hasNextPage endCursor }\n nodes {\n content {\n __typename\n ... on Issue {\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 blocking(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 }\n }\n }\n }\n}", + "variables": { + "login": "github", + "number": 16737, + "first": 3, + "after": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMTY2NjY2NjY2NjY2NjY2Ns4EaseJ" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "github", + "projectV2": { + "items": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMTExMTExMTExMTExMTExMc4EaseM" + }, + "nodes": [ + { + "content": { + "__typename": "PullRequest" + } + }, + { + "content": { + "__typename": "PullRequest" + } + }, + { + "content": { + "__typename": "Issue", + "number": 126, + "title": "Document the process for increasing language support", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n ... on ProjectV2Owner {\n projectV2(number: $number) {\n items(first: $first, after: $after) {\n pageInfo { hasNextPage endCursor }\n nodes {\n content {\n __typename\n ... on Issue {\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 blocking(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 }\n }\n }\n }\n}", + "variables": { + "login": "github", + "number": 16737, + "first": 3, + "after": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMzMzMzMzMzMzMzMzMzMzM84EaseE" + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "github", + "projectV2": { + "items": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMTY2NjY2NjY2NjY2NjY2Ns4EaseJ" + }, + "nodes": [ + { + "content": { + "__typename": "Issue", + "number": 173, + "title": "Document the changes to the process for increasing language support due to precise ASTs", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + }, + { + "content": { + "__typename": "Issue", + "number": 172, + "title": "Remove ts-parse CLI command", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + }, + { + "content": { + "__typename": "Issue", + "number": 146, + "title": "Tag computation only supports à la carte ASTs", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + }, + "nodes": [ + { + "login": "robrix" + } + ] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + } + ] + } + } + } + } + } + } + }, + { + "request": { + "query": "query ProjectItems($login: String!, $number: Int!, $first: Int!, $after: String) {\n repositoryOwner(login: $login) {\n __typename login\n ... on ProjectV2Owner {\n projectV2(number: $number) {\n items(first: $first, after: $after) {\n pageInfo { hasNextPage endCursor }\n nodes {\n content {\n __typename\n ... on Issue {\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 blocking(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 }\n }\n }\n }\n}", + "variables": { + "login": "github", + "number": 16737, + "first": 3, + "after": null + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repositoryOwner": { + "__typename": "Organization", + "login": "github", + "projectV2": { + "items": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK5MDAwMDAwMDAuMzMzMzMzMzMzMzMzMzMzM84EaseE" + }, + "nodes": [ + { + "content": { + "__typename": "Issue", + "number": 480, + "title": "Precise ASTs don’t support comments", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + }, + { + "content": { + "__typename": "Issue", + "number": 482, + "title": "Precise AST datatypes don’t support diffing", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + }, + { + "content": { + "__typename": "Issue", + "number": 481, + "title": "Precise ASTs don’t support parse errors", + "state": "CLOSED", + "repository": { + "name": "semantic", + "owner": { + "__typename": "Organization", + "login": "github" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xN1QwODowNTo0N1rPAAAAATQjF5A=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -316,6 +877,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -349,6 +917,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": true, @@ -419,6 +994,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -436,12 +1018,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xOFQwNzowNjoyMVrPAAAAATSyvhU=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -522,6 +1110,13 @@ } ] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -555,6 +1150,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -572,12 +1174,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0wM1QwNTowMzo1NVrPAAAAAT3XU9w=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -621,6 +1229,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -654,6 +1269,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -687,6 +1309,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -704,12 +1333,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo0Mzo1NVrPAAAAAU-2-PE=", + "states": ["CLOSED"], + "labels": ["bug"], + "orderBy": { + "field": "CREATED_AT", + "direction": "DESC" + } } }, "response": { @@ -725,12 +1360,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozOVrPAAAAAU-7caY=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1OVrPAAAAAU-uPKc=" }, "nodes": [ { - "number": 64, - "title": "fix(codec): the other codecs still drop or loosen rules on encode without reporting it", + "number": 56, + "title": "fix(ci): the audit job discards its fixes when the override prune install fails", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -753,6 +1388,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -762,8 +1404,8 @@ } }, { - "number": 65, - "title": "fix(codec): Codex path rules are written to the filesystem root instead of the project", + "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", @@ -786,6 +1428,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -795,8 +1444,8 @@ } }, { - "number": 66, - "title": "feat(codec): write Codex execpolicy rules for command deny and ask rules", + "number": 39, + "title": "fix: deduplicateRules drops rules that differ only in their when condition", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -819,6 +1468,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -836,12 +1492,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo1OToxM1rPAAAAAU-56QY=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -857,12 +1519,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToxMVrPAAAAAU-uSGI=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozOVrPAAAAAU-7caY=" }, "nodes": [ { - "number": 40, - "title": "fix: pnpm-lock.yaml on main has a duplicated zod entry and a frozen install fails", + "number": 64, + "title": "fix(codec): the other codecs still drop or loosen rules on encode without reporting it", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -885,6 +1547,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -894,8 +1563,8 @@ } }, { - "number": 41, - "title": "feat: return the matching rule and its source layer from evaluate and check", + "number": 65, + "title": "fix(codec): Codex path rules are written to the filesystem root instead of the project", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -918,6 +1587,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -927,8 +1603,8 @@ } }, { - "number": 42, - "title": "feat: compile a policy once for repeated evaluation", + "number": 66, + "title": "feat(codec): write Codex execpolicy rules for command deny and ask rules", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -951,6 +1627,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -968,12 +1651,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1OVrPAAAAAU-uPKc=", + "states": ["CLOSED"], + "labels": ["bug"], + "orderBy": { + "field": "CREATED_AT", + "direction": "DESC" + } } }, "response": { @@ -988,13 +1677,13 @@ }, "issues": { "pageInfo": { - "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToyNlrPAAAAAU-uVY0=" + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1MlrPAAAAAU-uNjY=" }, "nodes": [ { - "number": 43, - "title": "feat: add env and remote conditions to when", + "number": 38, + "title": "fix: a prefix or wildcard allow rule matches a whole compound command", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1017,33 +1706,7 @@ }, "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": { + "blocking": { "pageInfo": { "hasNextPage": false, "endCursor": null @@ -1059,8 +1722,8 @@ } }, { - "number": 45, - "title": "feat: hide the tool of a deny rule marked hidden", + "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", @@ -1083,6 +1746,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1100,12 +1770,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMDo1OVrPAAAAAU-uPKc=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1121,12 +1797,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMTozOFrPAAAAAU-uYCQ=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToxMVrPAAAAAU-uSGI=" }, "nodes": [ { - "number": 46, - "title": "feat: encode actors, roles, approvers and grantability, with fail-closed codecs", + "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", @@ -1149,6 +1825,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1158,8 +1841,8 @@ } }, { - "number": 47, - "title": "feat: serve evaluation as a permission-prompt MCP tool", + "number": 41, + "title": "feat: return the matching rule and its source layer from evaluate and check", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1182,6 +1865,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1191,8 +1881,8 @@ } }, { - "number": 48, - "title": "feat: replay a policy over a transcript and suggest allow rules", + "number": 42, + "title": "feat: compile a policy once for repeated evaluation", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1215,6 +1905,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1232,12 +1929,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToxMVrPAAAAAU-uSGI=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1253,12 +1956,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDozNTo1MFrPAAAAAU-1Rv4=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToyNlrPAAAAAU-uVY0=" }, "nodes": [ { - "number": 49, - "title": "feat: generate read and edit confinement rules for a set of directories", + "number": 43, + "title": "feat: add env and remote conditions to when", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1281,6 +1984,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1290,8 +2000,8 @@ } }, { - "number": 50, - "title": "feat: enforce the delegation block in evaluate", + "number": 44, + "title": "feat: load an ordered chain of layers with provenance, ceilings and profile extends", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1314,6 +2024,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1323,8 +2040,8 @@ } }, { - "number": 56, - "title": "fix(ci): the audit job discards its fixes when the override prune install fails", + "number": 45, + "title": "feat: hide the tool of a deny rule marked hidden", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1347,6 +2064,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1364,12 +2088,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMToyNlrPAAAAAU-uVY0=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1385,12 +2115,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo1OToxM1rPAAAAAU-56QY=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMTozOFrPAAAAAU-uYCQ=" }, "nodes": [ { - "number": 59, - "title": "fix(ci): the audit job cannot open its fix PR because Actions may not create pull requests", + "number": 46, + "title": "feat: encode actors, roles, approvers and grantability, with fail-closed codecs", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1413,6 +2143,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1422,8 +2159,8 @@ } }, { - "number": 60, - "title": "fix(release): the release notes step fails with conventionalcommits preset 10", + "number": 47, + "title": "feat: serve evaluation as a permission-prompt MCP tool", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1446,6 +2183,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1455,8 +2199,8 @@ } }, { - "number": 63, - "title": "fix(release): the release commit push is rejected by the main branch ruleset", + "number": 48, + "title": "feat: replay a policy over a transcript and suggest allow rules", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1479,6 +2223,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1496,12 +2247,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDowMTozOFrPAAAAAU-uYCQ=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1517,12 +2274,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNzowNjowMFrPAAAAAU_Szl8=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDozNTo1MFrPAAAAAU-1Rv4=" }, "nodes": [ { - "number": 73, - "title": "feat: accept trilean predicate trees in when", + "number": 49, + "title": "feat: generate read and edit confinement rules for a set of directories", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1545,7 +2302,14 @@ }, "nodes": [] }, - "subIssues": { + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { "pageInfo": { "hasNextPage": false, "endCursor": null @@ -1554,8 +2318,8 @@ } }, { - "number": 79, - "title": "fix(loader): loadPolicy silently skips a policy file it cannot use", + "number": 50, + "title": "feat: enforce the delegation block in evaluate", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1578,6 +2342,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1587,8 +2358,8 @@ } }, { - "number": 82, - "title": "feat(sync): sync Oh My Pi's bash.patterns", + "number": 56, + "title": "fix(ci): the audit job discards its fixes when the override prune install fails", "state": "CLOSED", "repository": { "name": "agent-permissions", @@ -1611,6 +2382,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1628,12 +2406,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDozNTo1MFrPAAAAAU-1Rv4=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1649,12 +2433,12 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0MVrPAAAAAU_vpQU=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo1OToxM1rPAAAAAU-56QY=" }, "nodes": [ { - "number": 83, - "title": "feat(codec): map the canonical default mode to Oh My Pi's approval mode", + "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", @@ -1677,6 +2461,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1686,9 +2477,9 @@ } }, { - "number": 93, - "title": "feat(codec): report the canonical fields a codec does not carry", - "state": "OPEN", + "number": 60, + "title": "fix(release): the release notes step fails with conventionalcommits preset 10", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1710,6 +2501,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1719,9 +2517,9 @@ } }, { - "number": 101, - "title": "Codex codec: support profile allow-list extends", - "state": "OPEN", + "number": 63, + "title": "fix(release): the release commit push is rejected by the main branch ruleset", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1743,6 +2541,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1760,12 +2565,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozNVrPAAAAAU-7bW8=", + "states": ["CLOSED"], + "labels": ["bug"], + "orderBy": { + "field": "CREATED_AT", + "direction": "DESC" + } } }, "response": { @@ -1781,13 +2592,13 @@ "issues": { "pageInfo": { "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0NlrPAAAAAU_vqPs=" + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNDo0Mzo1NVrPAAAAAU-2-PE=" }, "nodes": [ { - "number": 102, - "title": "Codex codec: command rules are not caught inside redirections or command substitutions", - "state": "OPEN", + "number": 63, + "title": "fix(release): the release commit push is rejected by the main branch ruleset", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1809,6 +2620,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1818,9 +2636,9 @@ } }, { - "number": 103, - "title": "sync: write Codex config and rules files", - "state": "OPEN", + "number": 60, + "title": "fix(release): the release notes step fails with conventionalcommits preset 10", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1842,6 +2660,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1851,9 +2676,9 @@ } }, { - "number": 104, - "title": "Codex decoding rejects glob_scan_max_depth", - "state": "OPEN", + "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": { @@ -1875,6 +2700,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1892,12 +2724,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozOVrPAAAAAU-7caY=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -1912,14 +2750,14 @@ }, "issues": { "pageInfo": { - "hasNextPage": false, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo1MlrPAAAAAU_vriI=" + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNzowNjowMFrPAAAAAU_Szl8=" }, "nodes": [ { - "number": 105, - "title": "Oh My Pi codec: merge the two entries written for a prefix rule on decode", - "state": "OPEN", + "number": 73, + "title": "feat: accept trilean predicate trees in when", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1941,6 +2779,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1950,9 +2795,9 @@ } }, { - "number": 106, - "title": "Oh My Pi: read named profiles", - "state": "OPEN", + "number": 79, + "title": "fix(loader): loadPolicy silently skips a policy file it cannot use", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -1974,6 +2819,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -1983,9 +2835,9 @@ } }, { - "number": 107, - "title": "Oh My Pi codec: prompt for reads with per-tool approval entries", - "state": "OPEN", + "number": 82, + "title": "feat(sync): sync Oh My Pi's bash.patterns", + "state": "CLOSED", "repository": { "name": "agent-permissions", "owner": { @@ -2007,6 +2859,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2024,12 +2883,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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", + "owner": "Mearman", + "name": "agent-permissions", "first": 3, - "after": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yMlQxNzo1MzoxNFrOvLeAFA==" + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNzowNjowMFrPAAAAAU_Szl8=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -2037,26 +2902,66 @@ "body": { "data": { "repository": { - "name": "octo-template", + "name": "agent-permissions", "owner": { - "__typename": "Organization", - "login": "octo-org" + "__typename": "User", + "login": "Mearman" }, "issues": { "pageInfo": { - "hasNextPage": false, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yM1QwMjowNToxN1rOvL1TAA==" + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0MVrPAAAAAU_vpQU=" }, "nodes": [ { - "number": 7, - "title": "is everything unblock", + "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": [] + }, + "blocking": { + "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": "octo-template", + "name": "agent-permissions", "owner": { - "__typename": "Organization", - "login": "octo-org" + "__typename": "User", + "login": "Mearman" } }, "assignees": { @@ -2073,6 +2978,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2082,14 +2994,14 @@ } }, { - "number": 8, - "title": "Draft Issue", + "number": 101, + "title": "Codex codec: support profile allow-list extends", "state": "OPEN", "repository": { - "name": "octo-template", + "name": "agent-permissions", "owner": { - "__typename": "Organization", - "login": "octo-org" + "__typename": "User", + "login": "Mearman" } }, "assignees": { @@ -2106,6 +3018,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2123,12 +3042,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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 + "after": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0M1rPAAAAAU_vphg=", + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } } }, "response": { @@ -2143,14 +3068,14 @@ }, "issues": { "pageInfo": { - "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0wM1QwNTowMzo1NVrPAAAAAT3XU9w=" + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTowNDowN1rPAAAAAU_oDFY=" }, "nodes": [ { - "number": 12, - "title": "feat: add a fail-closed OMP permission codec", - "state": "CLOSED", + "number": 101, + "title": "Codex codec: support profile allow-list extends", + "state": "OPEN", "repository": { "name": "agent-permissions", "owner": { @@ -2172,6 +3097,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2181,9 +3113,9 @@ } }, { - "number": 13, - "title": "fix: reject canonical command rules Codex cannot enforce", - "state": "CLOSED", + "number": 93, + "title": "feat(codec): report the canonical fields a codec does not carry", + "state": "OPEN", "repository": { "name": "agent-permissions", "owner": { @@ -2205,23 +3137,1404 @@ }, "nodes": [] }, - "subIssues": { + "blocking": { "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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=", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0OFrPAAAAAU_vqyw=", + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0M1rPAAAAAU_vphg=" + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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==", + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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==", + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyMy0wMy0yOVQyMDowNTozMVrOYiLJyA==" + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["CLOSED"], + "labels": ["bug"], + "orderBy": { + "field": "CREATED_AT", + "direction": "DESC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxNTowNjozNVrPAAAAAU-7bW8=" + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "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", + "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" + } + } + } + ] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "agent-permissions", + "owner": { + "__typename": "User", + "login": "Mearman" + }, + "issues": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOS0yOVQxOTo0NTo0OFrPAAAAAU_vqyw=" + }, + "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": [] + }, + "blocking": { + "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": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "subIssues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + }, + "nodes": [ + { + "number": 569, + "title": "Populate the unified DocumentPackage shape once document-schema.js defines it", + "state": "CLOSED", + "repository": { + "name": "documents.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + }, + "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": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "Mw" + }, + "nodes": [ + { + "number": 54, + "title": "Populate headingLevel on read; switch the write-side clamp to the shared helper", + "state": "CLOSED", + "repository": { + "name": "markdown-codec", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 52, + "title": "Expose the already-parsed text:outline-level as headingLevel, not just re-encoded into styleId", + "state": "CLOSED", + "repository": { + "name": "odf.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + }, + { + "number": 51, + "title": "Populate headingLevel on docx paragraphs from the style's own w:outlineLvl", + "state": "CLOSED", + "repository": { + "name": "ooxml.js", + "owner": { + "__typename": "Organization", + "login": "ExaDev" + } + } + } + ] + }, + "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": "User", - "login": "Mearman" + "__typename": "Organization", + "login": "ExaDev" } }, "assignees": { @@ -2238,11 +4551,11 @@ }, "nodes": [ { - "number": 11, - "title": "Add portable pattern-matching builders (prefix, wildcard, hierarchical glob) compiling to textCompare", + "number": 13, + "title": "Add a canonical headingLevel field to the paragraph content model", "state": "CLOSED", "repository": { - "name": "trilean", + "name": "document-schema.js", "owner": { "__typename": "Organization", "login": "ExaDev" @@ -2251,6 +4564,13 @@ } ] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2268,12 +4588,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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 + "after": null, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } } }, "response": { @@ -2288,19 +4614,158 @@ }, "issues": { "pageInfo": { - "hasNextPage": true, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyNi0wOC0xN1QwODowNTo0N1rPAAAAATQjF5A=" + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } + } + }, + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-repo-go", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyMy0wNC0yMVQxMzoyOTo1OFrOZAxO3A==" }, "nodes": [ { - "number": 12, - "title": "Make DocumentPackage the unified content+layout tree, not a sibling container", - "state": "CLOSED", + "number": 1, + "title": "Fix code scanning alert - Database query built from user-controlled sources", + "state": "OPEN", "repository": { - "name": "document-schema.js", + "name": "octo-repo-go", "owner": { "__typename": "Organization", - "login": "ExaDev" + "login": "octo-org" } }, "assignees": { @@ -2317,6 +4782,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2326,14 +4798,14 @@ } }, { - "number": 13, - "title": "Add a canonical headingLevel field to the paragraph content model", - "state": "CLOSED", + "number": 2, + "title": "Fix code scanning alert - Database query built from user-controlled sources", + "state": "OPEN", "repository": { - "name": "document-schema.js", + "name": "octo-repo-go", "owner": { "__typename": "Organization", - "login": "ExaDev" + "login": "octo-org" } }, "assignees": { @@ -2350,6 +4822,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2357,16 +4836,95 @@ }, "nodes": [] } - }, + } + ] + } + } + } + } + } + }, + { + "request": { + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } + } + }, + "response": { + "status": 200, + "body": { + "data": { + "repository": { + "name": "octo-repo-go", + "owner": { + "__typename": "Organization", + "login": "octo-org" + }, + "issues": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyMy0wNC0yMVQxMzozMTo0NFrOZAxO3A==" + }, + "nodes": [ { - "number": 14, - "title": "New sibling package: build a heading-based hierarchy over ContentDocument", - "state": "CLOSED", + "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": [] + }, + "blocking": { + "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": "document-schema.js", + "name": "octo-repo-go", "owner": { "__typename": "Organization", - "login": "ExaDev" + "login": "octo-org" } }, "assignees": { @@ -2379,22 +4937,16 @@ "blockedBy": { "pageInfo": { "hasNextPage": false, - "endCursor": "MQ" + "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" - } - } - } - ] + "nodes": [] + }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] }, "subIssues": { "pageInfo": { @@ -2413,87 +4965,42 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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", + "name": "octo-template", "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": [] - } - } + "after": null, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" } } - } - }, - { - "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", + "name": "octo-template", "owner": { "__typename": "Organization", "login": "octo-org" }, "issues": { "pageInfo": { - "hasNextPage": false, - "endCursor": "Y3Vyc29yOnYyOpK0MjAyMy0wNC0yMVQxMzoyOTo1OFrOZAxO3A==" + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK0MjAyNS0wNi0yMlQxNzo1MzoxNFrOvLeAFA==" }, "nodes": [ { "number": 1, - "title": "Fix code scanning alert - Database query built from user-controlled sources", + "title": "Objectionable content!", "state": "OPEN", "repository": { - "name": "octo-repo-go", + "name": "octo-template", "owner": { "__typename": "Organization", "login": "octo-org" @@ -2513,6 +5020,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2523,10 +5037,50 @@ }, { "number": 2, - "title": "Fix code scanning alert - Database query built from user-controlled sources", + "title": "I don't think this belongs here...", "state": "OPEN", "repository": { - "name": "octo-repo-go", + "name": "octo-template", + "owner": { + "__typename": "Organization", + "login": "octo-org" + } + }, + "assignees": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blockedBy": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, + "blocking": { + "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" @@ -2546,6 +5100,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2563,12 +5124,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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 + "after": null, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } } }, "response": { @@ -2588,8 +5155,8 @@ }, "nodes": [ { - "number": 1, - "title": "Objectionable content!", + "number": 8, + "title": "Draft Issue", "state": "OPEN", "repository": { "name": "octo-template", @@ -2612,6 +5179,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2621,8 +5195,8 @@ } }, { - "number": 2, - "title": "I don't think this belongs here...", + "number": 7, + "title": "is everything unblock", "state": "OPEN", "repository": { "name": "octo-template", @@ -2645,6 +5219,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2678,6 +5259,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, @@ -2695,12 +5283,18 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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 + "after": null, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } } }, "response": { @@ -2727,12 +5321,97 @@ }, { "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}", + "query": "query RepositoryIssues($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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 + "after": null, + "states": ["OPEN", "CLOSED"], + "labels": null, + "orderBy": { + "field": "CREATED_AT", + "direction": "ASC" + } + } + }, + "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": [] + }, + "blocking": { + "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, $states: [IssueState!]!, $labels: [String!], $orderBy: IssueOrder!) {\n repository(owner: $owner, name: $name) {\n name\n owner { __typename login }\n issues(first: $first, after: $after, states: $states, labels: $labels, orderBy: $orderBy) {\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 blocking(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, + "states": ["OPEN"], + "labels": null, + "orderBy": { + "field": "UPDATED_AT", + "direction": "DESC" + } } }, "response": { @@ -2776,6 +5455,13 @@ }, "nodes": [] }, + "blocking": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "nodes": [] + }, "subIssues": { "pageInfo": { "hasNextPage": false, 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 index 68e7f44..323d11a 100644 --- a/packages/adapters/graph/source-github-issues/src/test-support/scopes.ts +++ b/packages/adapters/graph/source-github-issues/src/test-support/scopes.ts @@ -7,6 +7,22 @@ export const fixtureScope: readonly string[] = [ "github:Mearman/agent-permissions", ]; +/** A public Projects v2 board in GitHub's own organisation, holding issues, pull requests and draft issues, so passing over what is not an issue is exercised. */ +export const fixtureProjectScope = "github:github/projects/16737"; + +/** An owner whose repositories span more than one listing page at the fixture page size. */ +export const fixtureListingParent = "github:octo-org"; + +/** A repository synced under a filter that narrows by state and label and orders newest first. */ +export const fixtureFilteredScope = "github:Mearman/agent-permissions"; + +/** The filter `fixtureFilteredScope` is synced under. */ +export const fixtureFilter = { + states: ["done"], + labels: ["bug"], + order: { field: "created", direction: "descending" }, +} as const; + /** An owner that cannot exist: GitHub logins cannot contain a dot. */ export const missingOwnerScope = "github:not.a.login"; diff --git a/packages/adapters/graph/source-github-issues/src/tokens.ts b/packages/adapters/graph/source-github-issues/src/tokens.ts new file mode 100644 index 0000000..d8b8453 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/tokens.ts @@ -0,0 +1,87 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import type { GitHubTokenPort } from "./graphql.ts"; + +/** A token a host keeps, such as one a person entered in a browser, with what it may be used for. */ +export interface StoredGitHubToken { + /** The token itself. It is only ever returned to the adapter, which sends it in the `Authorization` header. */ + readonly token: string; + /** The logins of the owners it is for, or `"any"` for a token that serves every owner. */ + readonly owners: readonly string[] | "any"; + /** When it was last used, as an ISO 8601 instant; absent when it never has been. */ + readonly lastUsedAt?: string; +} + +/** Orders tokens most recently used first, a token never used last. ISO 8601 instants in UTC order as text. */ +const byRecency = ( + left: Readonly, + right: Readonly, +): number => (right.lastUsedAt ?? "").localeCompare(left.lastUsedAt ?? ""); + +/** + * Picks the stored token for a request about one owner. A token scoped to that owner wins over one scoped to any owner; among owner-scoped tokens the one naming the fewest owners wins, being the most specific, then the most recently used; among tokens for any owner the most recently used wins. A request about the token's own holder (`owner` undefined) can only use a token for any owner. Logins match without regard to case, as GitHub's do. + * @param tokens - The stored tokens. + * @param owner - The login the request is about, or `undefined` for the token's own holder. + * @returns The token to use, or `undefined` when none fits. + */ +export const selectStoredToken = ( + tokens: readonly StoredGitHubToken[], + owner: string | undefined, +): StoredGitHubToken | undefined => { + const login = owner?.toLowerCase(); + const scoped = tokens + .flatMap((candidate) => + candidate.owners !== "any" && + login !== undefined && + candidate.owners.some((named) => named.toLowerCase() === login) + ? [{ candidate, breadth: candidate.owners.length }] + : [], + ) + .sort( + (left, right) => + left.breadth - right.breadth || + byRecency(left.candidate, right.candidate), + ); + const [best] = scoped; + if (best !== undefined) { + return best.candidate; + } + + return tokens + .filter((candidate) => candidate.owners === "any") + .sort(byRecency) + .at(0); +}; + +/** Where `storedTokenPort` reads the host's tokens, and how it reports one being used. */ +export interface StoredTokenPortOptions { + /** Reads the stored tokens; called for every request, so a token added or removed takes effect on the next one. */ + readonly list: ( + signal: AbortSignal | undefined, + ) => Promise; + /** Told of each token chosen, so the host can record when it was used and keep the recency order current. */ + readonly used?: (token: StoredGitHubToken) => void; +} + +/** + * A token port over tokens a host keeps, choosing one per request with `selectStoredToken`. + * @param options - How to read the stored tokens, and how to report one being used. + * @returns The token port. + * @throws GraphSourceError with `unauthorised`, from the port, when no stored token fits the owner; the message names the owner and never a token. + */ +export const storedTokenPort = + (options: StoredTokenPortOptions): GitHubTokenPort => + async (owner, signal) => { + const chosen = selectStoredToken(await options.list(signal), owner); + if (chosen === undefined) { + throw new GraphSourceError({ + kind: "unauthorised", + message: + owner === undefined + ? "no stored GitHub token serves any owner, which a request about the token's holder needs" + : `no stored GitHub token is for ${owner} or for any owner`, + }); + } + options.used?.(chosen); + + return chosen.token; + }; diff --git a/packages/adapters/graph/source-github-issues/src/tokens.unit.test.ts b/packages/adapters/graph/source-github-issues/src/tokens.unit.test.ts new file mode 100644 index 0000000..3527807 --- /dev/null +++ b/packages/adapters/graph/source-github-issues/src/tokens.unit.test.ts @@ -0,0 +1,113 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import { describe, expect, test } from "vitest"; +import { + selectStoredToken, + type StoredGitHubToken, + storedTokenPort, +} from "./tokens.ts"; + +// Made-up values: none of these is a credential. +const narrow: StoredGitHubToken = { + token: "narrow", + owners: ["octo-org"], + lastUsedAt: "2026-01-01T00:00:00Z", +}; +const broad: StoredGitHubToken = { + token: "broad", + owners: ["octo-org", "octocat"], + lastUsedAt: "2026-06-01T00:00:00Z", +}; +const recentAny: StoredGitHubToken = { + token: "recent-any", + owners: "any", + lastUsedAt: "2026-09-01T00:00:00Z", +}; +const staleAny: StoredGitHubToken = { + token: "stale-any", + owners: "any", + lastUsedAt: "2025-01-01T00:00:00Z", +}; +const unusedAny: StoredGitHubToken = { token: "unused-any", owners: "any" }; + +describe("selectStoredToken", () => { + test("prefers the token naming the fewest owners, however recently the others were used", () => { + expect( + selectStoredToken([recentAny, broad, narrow], "octo-org")?.token, + ).toBe("narrow"); + }); + + test("breaks a tie in specificity by recency", () => { + const newer = { + ...narrow, + token: "newer", + lastUsedAt: "2026-02-01T00:00:00Z", + }; + expect(selectStoredToken([narrow, newer], "octo-org")?.token).toBe("newer"); + }); + + test("matches logins without regard to case", () => { + expect(selectStoredToken([broad], "OCTOCAT")?.token).toBe("broad"); + }); + + test("falls back to the most recently used token for any owner, never one scoped to another owner", () => { + expect( + selectStoredToken([narrow, unusedAny, staleAny, recentAny], "github") + ?.token, + ).toBe("recent-any"); + expect(selectStoredToken([narrow, broad], "github")).toBeUndefined(); + }); + + test("serves a request about the token's own holder only with a token for any owner", () => { + expect(selectStoredToken([narrow, unusedAny], undefined)?.token).toBe( + "unused-any", + ); + expect(selectStoredToken([narrow], undefined)).toBeUndefined(); + }); +}); + +describe("storedTokenPort", () => { + test("reads the tokens on every request and reports the one it used", async () => { + const stores: (readonly StoredGitHubToken[])[] = [[staleAny], [narrow]]; + const used: string[] = []; + const port = storedTokenPort({ + list: async () => Promise.resolve(stores.shift() ?? []), + used: (token) => { + used.push(token.token); + }, + }); + await expect(port("octo-org", undefined)).resolves.toBe("stale-any"); + await expect(port("octo-org", undefined)).resolves.toBe("narrow"); + expect(used).toEqual(["stale-any", "narrow"]); + }); + + test("passes the caller's signal to the store", async () => { + const controller = new AbortController(); + const signals: (AbortSignal | undefined)[] = []; + const port = storedTokenPort({ + list: async (signal) => { + signals.push(signal); + + return Promise.resolve([recentAny]); + }, + }); + await port("octo-org", controller.signal); + expect(signals).toEqual([controller.signal]); + }); + + test("is unauthorised when no token fits, naming the owner and no token", async () => { + const port = storedTokenPort({ + list: async () => Promise.resolve([narrow]), + }); + const failure = port("github", undefined); + await expect(failure).rejects.toBeInstanceOf(GraphSourceError); + await expect(failure).rejects.toMatchObject({ + failure: { + kind: "unauthorised", + message: "no stored GitHub token is for github or for any owner", + }, + }); + await expect(port(undefined, undefined)).rejects.toMatchObject({ + failure: { kind: "unauthorised" }, + }); + }); +}); 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 index 4f35a38..d3332bf 100644 --- a/packages/adapters/graph/source-github-issues/test/package.smoke.test.ts +++ b/packages/adapters/graph/source-github-issues/test/package.smoke.test.ts @@ -2,7 +2,11 @@ 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 { + createGitHubIssuesSource, + parseScope, + storedTokenPort, +} from "../dist/index.js"; import { githubTokenFromEnvironment } from "../dist/node.js"; test("the built package reads a page through its fetch port", async () => { @@ -45,3 +49,15 @@ test("the built node entry point reads GITHUB_TOKEN", async () => { }); await expect(port("org", undefined)).resolves.toBe("from-env"); }); + +test("the built package chooses a stored token per owner", async () => { + const port = storedTokenPort({ + list: async () => + Promise.resolve([ + { token: "for-org", owners: ["org"] }, + { token: "for-any", owners: "any" }, + ]), + }); + await expect(port("org", undefined)).resolves.toBe("for-org"); + await expect(port(undefined, undefined)).resolves.toBe("for-any"); +}); diff --git a/packages/adapters/graph/store-sqlite/README.md b/packages/adapters/graph/store-sqlite/README.md index d293c24..2908394 100644 --- a/packages/adapters/graph/store-sqlite/README.md +++ b/packages/adapters/graph/store-sqlite/README.md @@ -18,7 +18,7 @@ const store = createRouterClient( `src/tables.ts` is the one table definition. Every record a model declares is a row keyed by model id and record id, with a `position` column keeping the declared order; enumerated columns take their values from graph-schema's own vocabulary, and nested values are JSON text. Rows never reach the contract: `src/rows.ts` flattens a model into rows and reassembles it, parsing the result with graph-schema's `modelSchema`, so what leaves the store is checked to be a model. A unit test derives the row schemas with `drizzle-orm/zod` and asserts they agree with the model schema on every shared scalar field. -Each transition row records whether graph-domain's `loopTransitions` counts it as a decision's loop edge, so the structural queries can leave loop edges out without loading the model. No foreign keys are declared, because SQLite enforces them only on connections that enable them; the store writes and deletes a model's rows together in one transaction instead. +Relations kept beside a model (`child-of`, `assigned-to`, `part-of`) are rows of their own, keyed by model, kind and both ends so a relation sent twice is stored once, with a position keeping the order they were first stored. Each transition row records whether graph-domain's `loopTransitions` counts it as a decision's loop edge, so the structural queries can leave loop edges out without loading the model. No foreign keys are declared, because SQLite enforces them only on connections that enable them; the store writes and deletes a model's rows together in one transaction instead. ## Migrations @@ -30,7 +30,7 @@ The build embeds them in the package (`scripts/embed-migrations.ts`, parsed by d Every write runs `checkStored` from the contract before it commits, takes the next value of the store-wide revision sequence, rewrites the model's rows in one transaction and publishes the change to the store's `ChangeFeed` once it has committed. A failed check or an aborted signal rolls the transaction back. -`query.ready`, `query.blockedChain` and `query.impact` are single `WITH RECURSIVE` queries through Drizzle's `sql` template over `transitions`, `elements` and `overlay`, with the contract's semantics (control elements passed through, loop edges left out, traversal stopping at done work). The walks cap distances at the model's element count, which is longer than any shortest path and ends the recursion on a cycle no decision closes. +`query.ready`, `query.blockedChain` and `query.impact` are single `WITH RECURSIVE` queries through Drizzle's `sql` template over `transitions`, `elements` and `overlay`, with the contract's semantics (control elements passed through, loop edges left out, traversal stopping at done work). `query.descendants` and `query.ancestors` are the same kind of query over the `child-of` rows of `relations`, and `query.children` a plain one. The walks cap distances at the model's element count, which is longer than any shortest path and ends the recursion on a cycle no decision closes, or a cycle of `child-of` relations. Transactions are opened with plain `BEGIN IMMEDIATE`, `COMMIT` and `ROLLBACK` statements rather than Drizzle's `transaction`, whose callback has to be synchronous on a synchronous driver and asynchronous on an async one. Each store operation therefore runs with the connection to itself: by default the store serialises its own operations, and a host that shares the database across contexts passes `exclusive` to hold its cross-context lock as well, and `feed` to bridge the other contexts' changes. diff --git a/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/migration.sql b/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/migration.sql new file mode 100644 index 0000000..cd4cb76 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE `relations` ( + `model_id` text NOT NULL, + `kind` text NOT NULL, + `from_id` text NOT NULL, + `to_id` text NOT NULL, + `position` integer NOT NULL, + CONSTRAINT `relations_pk` PRIMARY KEY(`model_id`, `kind`, `from_id`, `to_id`) +); +--> statement-breakpoint +CREATE INDEX `relations_by_to` ON `relations` (`model_id`,`kind`,`to_id`); \ No newline at end of file diff --git a/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/snapshot.json b/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/snapshot.json new file mode 100644 index 0000000..ec79632 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/drizzle/20261011023034_relations/snapshot.json @@ -0,0 +1,929 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "730e6eb0-db67-4d79-bf62-9711195facf8", + "prevIds": ["b3109b41-ac6f-47ae-adbc-359bd373f3d3"], + "ddl": [ + { + "name": "actions", + "entityType": "tables" + }, + { + "name": "artefacts", + "entityType": "tables" + }, + { + "name": "elements", + "entityType": "tables" + }, + { + "name": "events", + "entityType": "tables" + }, + { + "name": "fragments", + "entityType": "tables" + }, + { + "name": "models", + "entityType": "tables" + }, + { + "name": "overlay", + "entityType": "tables" + }, + { + "name": "participants", + "entityType": "tables" + }, + { + "name": "projections", + "entityType": "tables" + }, + { + "name": "relations", + "entityType": "tables" + }, + { + "name": "sequences", + "entityType": "tables" + }, + { + "name": "transitions", + "entityType": "tables" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "actions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deliverable", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "elements" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "affects", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "consumes", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "produces", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "exit", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "from_actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "to_actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mode", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extensions", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "events" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operator", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operands", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "models" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "models" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "views", + "entityType": "columns", + "table": "models" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "element", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "participants" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "projections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "notation", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scenario", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "relations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "relations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "from_id", + "entityType": "columns", + "table": "relations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "to_id", + "entityType": "columns", + "table": "relations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "relations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "sequences" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "sequences" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trigger", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "guard", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "condition", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "effect", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "loop", + "entityType": "columns", + "table": "transitions" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "actions_pk", + "entityType": "pks", + "table": "actions" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "artefacts_pk", + "entityType": "pks", + "table": "artefacts" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "elements_pk", + "entityType": "pks", + "table": "elements" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "events_pk", + "entityType": "pks", + "table": "events" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "fragments_pk", + "entityType": "pks", + "table": "fragments" + }, + { + "columns": ["model_id", "element"], + "nameExplicit": false, + "name": "overlay_pk", + "entityType": "pks", + "table": "overlay" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "participants_pk", + "entityType": "pks", + "table": "participants" + }, + { + "columns": ["model_id", "position"], + "nameExplicit": false, + "name": "projections_pk", + "entityType": "pks", + "table": "projections" + }, + { + "columns": ["model_id", "kind", "from_id", "to_id"], + "nameExplicit": false, + "name": "relations_pk", + "entityType": "pks", + "table": "relations" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "transitions_pk", + "entityType": "pks", + "table": "transitions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "models_pk", + "table": "models", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "sequences_pk", + "table": "sequences", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "model_id", + "isExpression": false + }, + { + "value": "kind", + "isExpression": false + }, + { + "value": "to_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "relations_by_to", + "entityType": "indexes", + "table": "relations" + }, + { + "columns": [ + { + "value": "model_id", + "isExpression": false + }, + { + "value": "target", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "transitions_by_target", + "entityType": "indexes", + "table": "transitions" + }, + { + "columns": [ + { + "value": "model_id", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "transitions_by_source", + "entityType": "indexes", + "table": "transitions" + } + ], + "renames": [] +} diff --git a/packages/adapters/graph/store-sqlite/src/queries.ts b/packages/adapters/graph/store-sqlite/src/queries.ts index 12dee6d..cd59549 100644 --- a/packages/adapters/graph/store-sqlite/src/queries.ts +++ b/packages/adapters/graph/store-sqlite/src/queries.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import type { SqliteDatabase } from "./database.ts"; /** - * The structural queries of the `GraphStore` contract in SQL, each one recursive query over `transitions`, `elements` and `overlay` through Drizzle's `sql` template. They read the stored `loop` flag rather than the model, so they never load or evaluate it, and they answer exactly as the contract's reference implementation (`readyOf`, `blockedChainOf`, `impactOf`) does, which the conformance kit checks. + * The structural queries of the `GraphStore` contract in SQL, each one recursive query over `transitions`, `elements` and `overlay` (or, for the hierarchy, `relations` and `elements`) through Drizzle's `sql` template. They read the stored `loop` flag rather than the model, so they never load or evaluate it, and they answer exactly as the contract's reference implementation (`readyOf`, `blockedChainOf`, `impactOf`, `childrenOf`, `descendantsOf`, `ancestorsOf`) does, which the conformance kit checks. */ /** A query about one element of one model. */ @@ -144,3 +144,87 @@ export const ready = async ( return rows.map((row) => idRow.parse(row)[0]); }; + +/** Which way a hierarchy walk follows `child-of` relations: from a parent (`to_id`) to its children (`from_id`), or from a child to its parents. */ +const down: Direction = { from: sql.raw("to_id"), to: sql.raw("from_id") }; +const up: Direction = { from: sql.raw("from_id"), to: sql.raw("to_id") }; + +/** + * Every element reachable from `element` along `child-of` relations in one direction, with the fewest relations it takes, by that distance and then model order. The element itself is never reported. + * + * Distances are capped at the model's element count: no shortest path is longer, and the cap ends the recursion on a cycle of `child-of` relations. + */ +const hierarchyWalk = async ( + db: SqliteDatabase, + { modelId, element }: ElementQuery, + { from, to }: Direction, +): Promise => { + const rows = await db.values(sql` + WITH RECURSIVE + bound(n) AS (SELECT count(*) FROM elements WHERE model_id = ${modelId}), + walk(element, distance) AS ( + SELECT r.${to}, 1 FROM relations r + WHERE r.model_id = ${modelId} AND r.kind = 'child-of' AND r.${from} = ${element} + UNION + SELECT r.${to}, w.distance + 1 FROM walk w + JOIN relations r ON r.model_id = ${modelId} AND r.kind = 'child-of' AND r.${from} = w.element + WHERE w.distance < (SELECT n FROM bound) + ) + SELECT w.element, min(w.distance) AS distance + FROM walk w + JOIN elements e ON e.model_id = ${modelId} AND e.id = w.element + WHERE w.element <> ${element} + GROUP BY w.element, e.position + ORDER BY distance, e.position + `); + + return rows.map((row) => { + const [reached, distance] = reachRow.parse(row); + + return { element: reached, distance }; + }); +}; + +/** + * The direct children of an element: every element related to it by `child-of`, other than itself. + * @param db - The database. + * @param request - The model and the element, which the caller has checked exist. + * @returns The children, in model order. + */ +export const children = async ( + db: SqliteDatabase, + request: ElementQuery, +): Promise => { + const { modelId, element } = request; + const rows = await db.values(sql` + SELECT r.from_id FROM relations r + JOIN elements e ON e.model_id = ${modelId} AND e.id = r.from_id + WHERE r.model_id = ${modelId} AND r.kind = 'child-of' AND r.to_id = ${element} + AND r.from_id <> ${element} + ORDER BY e.position + `); + + return rows.map((row) => idRow.parse(row)[0]); +}; + +/** + * Everything beneath an element along `child-of` relations. + * @param db - The database. + * @param request - The model and the element, which the caller has checked exist. + * @returns The descendants, by distance and then model order. + */ +export const descendants = async ( + db: SqliteDatabase, + request: ElementQuery, +): Promise => hierarchyWalk(db, request, down); + +/** + * Everything above an element along `child-of` relations. + * @param db - The database. + * @param request - The model and the element, which the caller has checked exist. + * @returns The ancestors, by distance and then model order. + */ +export const ancestors = async ( + db: SqliteDatabase, + request: ElementQuery, +): Promise => hierarchyWalk(db, request, up); diff --git a/packages/adapters/graph/store-sqlite/src/store.ts b/packages/adapters/graph/store-sqlite/src/store.ts index bf15e60..297c767 100644 --- a/packages/adapters/graph/store-sqlite/src/store.ts +++ b/packages/adapters/graph/store-sqlite/src/store.ts @@ -6,9 +6,13 @@ import { emptyModel, GraphStoreError, mergeBoundItems, + mergeRelations, overlayWithin, + relationIssues, + relationsWithin, type ChangeKind, type GraphStore, + type Relation, type StoredModel, } from "@exadev/process-graph-store-contract"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; @@ -21,7 +25,14 @@ import { type SqliteDatabase, } from "./database.ts"; import { migrate } from "./migrate.ts"; -import { blockedChain, impact, ready } from "./queries.ts"; +import { + ancestors, + blockedChain, + children, + descendants, + impact, + ready, +} from "./queries.ts"; import { fromRows, overlayFromRows, @@ -38,11 +49,12 @@ import { overlay as overlayTable, participants, projections, + relations as relationsTable, sequences, transitions, } from "./tables.ts"; -/** The tables that hold a model's own records, every one keyed by `modelId`. */ +/** The tables that hold a model's own records and the relations kept beside it, every one keyed by `modelId`. */ const recordTables = [ participants, events, @@ -52,6 +64,7 @@ const recordTables = [ transitions, fragments, projections, + relationsTable, ] as const; /** The name of the store-wide revision sequence's row in `sequences`. */ @@ -141,6 +154,17 @@ export const createSqliteGraphStore = ( .where(eq(overlayTable.modelId, modelId)), ); + const readRelations = async (modelId: string): Promise => + db + .select({ + kind: relationsTable.kind, + from: relationsTable.from, + to: relationsTable.to, + }) + .from(relationsTable) + .where(eq(relationsTable.modelId, modelId)) + .orderBy(asc(relationsTable.position)); + const read = async (modelId: string): Promise => { const [model] = await db .select() @@ -244,18 +268,24 @@ export const createSqliteGraphStore = ( readonly kind: ChangeKind; readonly model: Model; readonly overlay: Overlay; + readonly relations: readonly Relation[]; readonly signal: AbortSignal | undefined; } - /** Replaces a model's rows and overlay in one transaction and publishes the change once it commits. */ + /** Replaces a model's rows, overlay and relations in one transaction and publishes the change once it commits. */ const write = async ({ modelId, kind, model, overlay, + relations, signal, }: Write): Promise => { checkStored(model, overlay); + const issues = relationIssues(model, relations); + if (issues.length > 0) { + throw new GraphStoreError({ kind: "invalid", issues }); + } const revision = await inTransaction(db, async () => { const taken = await nextRevision(); const rows = toRows( @@ -280,6 +310,15 @@ export const createSqliteGraphStore = ( if (entries.length > 0) { await db.insert(overlayTable).values(entries); } + if (relations.length > 0) { + await db.insert(relationsTable).values( + relations.map((relation, position) => ({ + modelId, + ...relation, + position, + })), + ); + } signal?.throwIfAborted(); return taken; @@ -300,16 +339,20 @@ export const createSqliteGraphStore = ( ), saveModel: async ({ modelId, model, signal }) => operation(async () => { - const existing = - (await revisionOf(modelId)) === undefined - ? {} - : await readOverlay(modelId); + const stored = (await revisionOf(modelId)) !== undefined; return write({ modelId, kind: "saved", model, - overlay: overlayWithin(model, existing), + overlay: overlayWithin( + model, + stored ? await readOverlay(modelId) : {}, + ), + relations: relationsWithin( + model, + stored ? await readRelations(modelId) : [], + ), signal, }); }), @@ -373,16 +416,44 @@ export const createSqliteGraphStore = ( operation(async () => { const stored = (await revisionOf(modelId)) === undefined - ? { model: emptyModel, overlay: {} } - : await read(modelId); + ? { model: emptyModel, overlay: {}, relations: [] } + : { + ...(await read(modelId)), + relations: await readRelations(modelId), + }; return write({ modelId, kind: "items", ...mergeBoundItems(stored.model, stored.overlay, items), + relations: mergeRelations(stored.relations, items.relations ?? []), signal, }); }), + relations: async ({ modelId }) => + operation(async () => { + await requireModel(modelId); + + return readRelations(modelId); + }), + children: async ({ modelId, element }) => + operation(async () => { + await requireElement(modelId, element); + + return children(db, { modelId, element }); + }), + descendants: async ({ modelId, element }) => + operation(async () => { + await requireElement(modelId, element); + + return descendants(db, { modelId, element }); + }), + ancestors: async ({ modelId, element }) => + operation(async () => { + await requireElement(modelId, element); + + return ancestors(db, { modelId, element }); + }), ready: async ({ modelId }) => operation(async () => { await requireModel(modelId); diff --git a/packages/adapters/graph/store-sqlite/src/tables.ts b/packages/adapters/graph/store-sqlite/src/tables.ts index fd9ea8a..db3145e 100644 --- a/packages/adapters/graph/store-sqlite/src/tables.ts +++ b/packages/adapters/graph/store-sqlite/src/tables.ts @@ -7,6 +7,7 @@ import { participantKinds, pseudoRoles, } from "@exadev/process-graph-schema"; +import { relationKinds } from "@exadev/process-graph-store-contract"; import { index, integer, @@ -170,3 +171,25 @@ export const overlay = sqliteTable( }, (table) => [primaryKey({ columns: [table.modelId, table.element] })], ); + +/** + * Relations kept beside a model, one row per kind and pair of ends, so a relation sent twice is stored once, with `position` keeping the order relations were first stored. The ends are identifiers of the model's elements or participants, by kind. + */ +export const relations = sqliteTable( + "relations", + { + modelId: text("model_id").notNull(), + kind: text("kind", { enum: relationKinds }).notNull(), + from: text("from_id").notNull(), + to: text("to_id").notNull(), + position: integer("position").notNull(), + }, + (table) => [ + // The key also serves the walk from a child up to its parents, which starts from `from_id`. + primaryKey({ + columns: [table.modelId, table.kind, table.from, table.to], + }), + // The walk from a parent down to its children starts from `to_id`. + index("relations_by_to").on(table.modelId, table.kind, table.to), + ], +); diff --git a/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts b/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts index 354ec51..aa883b7 100644 --- a/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts +++ b/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts @@ -1,4 +1,5 @@ import { modelSchema, overlaySchema } from "@exadev/process-graph-schema"; +import { relationSchema } from "@exadev/process-graph-store-contract"; import { createSelectSchema } from "drizzle-orm/zod"; import { describe, expect, test } from "vitest"; import { z } from "zod"; @@ -11,6 +12,7 @@ import { overlay, participants, projections, + relations, transitions, } from "./tables.ts"; @@ -168,6 +170,17 @@ describe("the row schemas Drizzle derives and the model schema", () => { ); }); + test("the relation kind column holds exactly the contract's relation kinds, and the ends are text", () => { + const row = createSelectSchema(relations).shape; + const relation = relationSchema.unwrap().shape; + expect(described(row.kind)).toEqual(z.toJSONSchema(relation.kind)); + // The contract also requires the ends to be non-empty, which its input schema enforces before a write reaches the store. + expect([described(row.from), described(row.to)]).toEqual([ + z.toJSONSchema(z.string()), + z.toJSONSchema(z.string()), + ]); + }); + test("the overlay status column holds exactly the overlay's statuses", () => { expect(described(createSelectSchema(overlay).shape.status)).toEqual( z.toJSONSchema(overlaySchema.unwrap().valueType), diff --git a/packages/core/graph/source-contract/README.md b/packages/core/graph/source-contract/README.md index 65cbeca..ef21284 100644 --- a/packages/core/graph/source-contract/README.md +++ b/packages/core/graph/source-contract/README.md @@ -30,13 +30,15 @@ const { model, overlay, relations, gaps } = assembleSync(pages); `graphSourceContract` is keyed by version. A breaking change adds `v2` beside `v1`, so one server serves both while clients move across. -| Procedure | Input | Output | -| ---------------- | ------------------- | -------------------------------------------- | -| `v1.describe` | none | `{ source }`: the prefix of every identifier | -| `v1.sync.page` | `{ scope, after? }` | one `SyncPage` | -| `v1.sync.stream` | `{ scope, after? }` | an event iterator of `SyncPage` | +| Procedure | Input | Output | +| -------------------- | ---------------------------- | ----------------------------------------------- | +| `v1.describe` | none | `{ source }`: the prefix of every identifier | +| `v1.sync.page` | `{ scope, after?, filter? }` | one `SyncPage` | +| `v1.sync.stream` | `{ scope, after?, filter? }` | an event iterator of `SyncPage` | +| `v1.scopes.roots` | `{ after? }` | one `ScopeListing` of the top-level scopes | +| `v1.scopes.children` | `{ scope, after? }` | one `ScopeListing` of the scopes inside `scope` | -`scope` lists source-qualified scope identifiers, read in order, each once: for GitHub, `github:org` for every repository an owner has and `github:org/repo` for one. `after` is a cursor from an earlier page's `next`. +`scope` lists source-qualified scope identifiers, read in order, each once: for GitHub, `github:org` for every repository an owner has and `github:org/repo` for one. `after` is a cursor from an earlier page's `next`, valid only with the scope list and filter it was issued for. A `SyncPage` is a slice of a model: bound activities, the participants they name, the transitions between them, relations, run state as an `Overlay`, and gaps. Its record schemas are taken from `modelSchema`, so a page can never drift from the model. @@ -46,6 +48,22 @@ A `SyncPage` is a slice of a model: bound activities, the participants they name - **Gap.** Part of the scope the source could not read (`not-found` or `forbidden`), reported as data so the rest of the sync still arrives. - **`next`.** The cursor to resume after this page; absent on the last page. +## Filters + +`filter` narrows and orders what each scope lists, on the source's side where the source supports it. Every field is optional, and an absent field does not narrow. + +| Field | Keeps or orders | +| -------- | ---------------------------------------------------------------------------------------- | +| `states` | Records whose run state is one of `open` and `done`. | +| `labels` | Records carrying at least one of the labels. | +| `order` | `{ field, direction }`: `created`, `updated` or `comments`, `ascending` or `descending`. | + +A filter narrows what a scope lists, not what the listed records reach: a blocker of a listed record is read whatever its own state or labels, because the work the filter kept depends on it. + +## Listing scopes + +`scopes.roots` lists the top-level scopes the credentials reach (for GitHub, the viewer's own account and organisations) and `scopes.children` the scopes inside one (for GitHub, an owner's repositories), so a scope picker can offer them. A `ScopeListing` holds `entries`, `gaps` and `next`; each `ScopeEntry` has the `scope` identifier to pass to `sync`, a `label`, the source's `description` and `url` when it has them, and whether it is `archived`. Listings page and resume by cursor like `sync.page`, take the abort signal, and report a parent that does not exist or cannot be seen as a gap on an empty page. A source that offers no listing answers `NOT_IMPLEMENTED`. + ## Resuming Cursors are stable: the same scope and cursor give the same page while the source is unchanged, and a cursor holds no in-memory state, so a sync interrupted in one process resumes in another. `sync.stream` reads pages only as the consumer pulls them, so a slow consumer applies backpressure and closing the iterator stops the reads. Each event carries its `next` cursor as the event id, so over an event-stream transport a reconnecting client's `lastEventId` resumes there; an explicit `after` takes precedence. @@ -59,17 +77,24 @@ Failures that end a call are typed oRPC errors with their detail as data, using | `UNAUTHORIZED` | `{}` | The source rejected the credentials. | | `TOO_MANY_REQUESTS` | `{ resetAt? }` | The source is rate limiting; resume after `resetAt`. | | `BAD_REQUEST` | `{ reason: "invalid-cursor" \| "invalid-scope", value }` | A cursor the source did not issue, or a malformed scope. | +| `NOT_IMPLEMENTED` | `{}` | A scope listing from a source that offers none. | What a source can partly read is not an error: it is a page with gaps. Every call takes the caller's abort signal through the oRPC client options and passes it to the source. ## Writing an adapter -An adapter implements `GraphSourcePager`, one page at a time, and throws `GraphSourceError` for the failures above. `createGraphSourceRouter(pager)` builds the whole port on it, so paging, streaming, resumption and error mapping are the same for every source. +An adapter implements `GraphSourcePager`, one page at a time, and throws `GraphSourceError` for the failures above. `createGraphSourceRouter(pager)` builds the whole port on it, so paging, streaming, resumption and error mapping are the same for every source. A pager that can list its scopes also supplies `scopes`, a `GraphSourceScopes` with `roots` and `children`. ```ts const pager: GraphSourcePager = { source: "linear", - page: async ({ scope, cursor, signal }) => readOnePage(scope, cursor, signal), + page: async ({ scope, cursor, signal, filter }) => + readOnePage(scope, cursor, signal, filter), + scopes: { + roots: async ({ cursor, signal }) => listTeams(cursor, signal), + children: async ({ scope, cursor, signal }) => + listProjects(scope, cursor, signal), + }, }; ``` @@ -89,24 +114,31 @@ graphSourceConformance({ scope: ["linear:team-a"], // must span at least two pages missingScope: "linear:no-such-team", malformedScope: "team-a", + // Optional: exercise the scope listing too. + listing: { + roots: true, + parent: "linear:team-a", // its children must span at least two pages + missingParent: "linear:no-such-team", + malformedParent: "team-a", + }, }); ``` -The entry point also exports `inProcessClient(router)` and `messagePortClient(router)`, the two transports this package's own tests run the kit over; a `messagePortClient` closes its channel after the test file. +`graphSourceListingConformance` runs the listing's share on its own, for a client that differs from the sync's, such as one with a smaller page size so the parent spans two pages. The entry point also exports `conformanceFilter`, the filter the kit syncs with, so an adapter that replays recorded responses can record them, and `inProcessClient(router)` and `messagePortClient(router)`, the two transports this package's own tests run the kit over; a `messagePortClient` closes its channel after the test file. -It checks that paging and streaming agree; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a cursor never repeats; that a sync is deterministic; that the assembled model and overlay pass the schema's validation and every identifier carries the source prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed errors; and that an aborted signal ends both procedures. +It checks that paging and streaming agree; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a cursor never repeats; that a sync is deterministic; that the assembled model and overlay pass the schema's validation and every identifier carries the source prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed errors; that a filtered sync pages consistently, reads nothing the unfiltered sync does not and refuses a cursor issued under another filter; and that an aborted signal ends both procedures. With `listing`, it also checks the scope listing's paging and resumption, the gap for an unknown parent, its typed errors and aborts, and that a listed child syncs. ## Functions -| Export | What it is | -| -------------------------------- | ------------------------------------------------------------------------------------------- | -| `graphSourceContract` | The router contract. | -| `GraphSourceClient` | The client type for the contract, over any transport. | -| `createGraphSourceRouter(pager)` | The implemented router over a pager. | -| `assembleSync(pages)` | Merges a sync's pages into one model, overlay, relation list and gap list; later pages win. | -| `paginate(first, read)` | An async iterable over a cursor-linked sequence, reading each page only as it is pulled. | -| `createFakeGraphSource(options)` | The reference in-memory pager. | -| `GraphSourceError` | What a pager throws to end a call with a typed failure. | +| Export | What it is | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphSourceContract` | The router contract. | +| `GraphSourceClient` | The client type for the contract, over any transport. | +| `createGraphSourceRouter(pager)` | The implemented router over a pager. | +| `assembleSync(pages)` | Merges a sync's pages into one model, overlay, relation list and gap list; later pages win. | +| `paginate(first, read)` | An async iterable over a cursor-linked sequence, reading each page only as it is pulled. | +| `createFakeGraphSource(options)` | The reference in-memory pager: fixed pages per scope, a filter applied to each page as though only the kept activities were listed, and a scope listing served from `roots` and `children`. | +| `GraphSourceError` | What a pager throws to end a call with a typed failure. | ## Commands diff --git a/packages/core/graph/source-contract/src/conformance.ts b/packages/core/graph/source-contract/src/conformance.ts index ae8e42b..083bf1e 100644 --- a/packages/core/graph/source-contract/src/conformance.ts +++ b/packages/core/graph/source-contract/src/conformance.ts @@ -3,7 +3,7 @@ import { afterAll, describe, expect, test } from "vitest"; import { assembleSync } from "./assemble.ts"; import type { GraphSourceClient } from "./contract.ts"; import { paginate } from "./paginate.ts"; -import type { SyncPage } from "./schema.ts"; +import type { ScopeListing, SyncFilter, SyncPage } from "./schema.ts"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/message-port"; import { createRouterClient } from "@orpc/server"; @@ -22,8 +22,28 @@ export interface GraphSourceConformanceOptions { readonly missingScope: string; /** A string the source cannot parse as a scope identifier, which must be rejected. */ readonly malformedScope: string; + /** What to check of the scope listing, when the source offers one; absent, the listing is not exercised. */ + readonly listing?: ScopeListingConformance; } +/** The parts of a scope listing the conformance kit exercises. */ +export interface ScopeListingConformance { + /** Whether to exercise `scopes.roots`, which needs credentials whose top-level scopes are readable where the test runs. */ + readonly roots: boolean; + /** A scope whose children span at least two listing pages, so resumption is exercised between pages. */ + readonly parent: string; + /** A well-formed scope identifier naming nothing, whose listing must be a `not-found` gap. */ + readonly missingParent: string; + /** A string the source cannot parse as a scope identifier, which must be rejected. */ + readonly malformedParent: string; +} + +/** The filter the kit syncs with: open work, most recently changed first. Every source can express it, and it narrows and reorders what a scope lists. Exported so an adapter that replays recorded responses can record the requests it makes. */ +export const conformanceFilter: SyncFilter = { + states: ["open"], + order: { field: "updated", direction: "descending" }, +}; + /** A string no source issues as a cursor: it is the kit's own, outside any source's identifier syntax. */ const foreignCursor = "conformance-kit:not-a-cursor"; @@ -38,15 +58,20 @@ const recordCursor = (seen: Set, next: string | undefined): void => { seen.add(next); }; -/** Reads every page by following `next` through `sync.page`. */ +/** Reads every page by following `next` through `sync.page`, under `filter` when one is given. */ const walkPages = async ( client: GraphSourceClient, scope: readonly string[], + filter?: SyncFilter, ): Promise => { const pages: SyncPage[] = []; const seen = new Set(); const paged = paginate(undefined, async (after) => - client.v1.sync.page(after === undefined ? { scope } : { scope, after }), + client.v1.sync.page({ + scope, + ...(after === undefined ? {} : { after }), + ...(filter === undefined ? {} : { filter }), + }), ); for await (const page of paged) { pages.push(page); @@ -66,7 +91,11 @@ interface StreamOptions { /** Reads `sync.stream` to its end, or only its first `limit` events, closing the stream early in that case. */ const streamPages = async ( client: GraphSourceClient, - input: { readonly scope: readonly string[]; readonly after?: string }, + input: { + readonly scope: readonly string[]; + readonly after?: string; + readonly filter?: SyncFilter; + }, { limit, signal }: StreamOptions = {}, ): Promise => { const pages: SyncPage[] = []; @@ -87,7 +116,9 @@ const streamPages = async ( }; /** The cursor to resume after `page`, failing the test when the page is the last. */ -const nextOf = (page: SyncPage | undefined): string => { +const nextOf = ( + page: { readonly next?: string | undefined } | undefined, +): string => { if (page?.next === undefined) { throw new Error("expected a page that is not the last"); } @@ -95,6 +126,60 @@ const nextOf = (page: SyncPage | undefined): string => { return page.next; }; +/** One listing request: the top-level scopes, or the children of `parent`. */ +type ListingCall = (after: string | undefined) => Promise; + +const rootsCall = + (client: GraphSourceClient): ListingCall => + async (after) => + client.v1.scopes.roots(after === undefined ? {} : { after }); + +const childrenCall = + (client: GraphSourceClient, parent: string): ListingCall => + async (after) => + client.v1.scopes.children( + after === undefined ? { scope: parent } : { scope: parent, after }, + ); + +/** Reads every page of a listing by following `next`. */ +const walkListing = async ( + call: ListingCall, +): Promise => { + const pages: ScopeListing[] = []; + const seen = new Set(); + for await (const page of paginate(undefined, call)) { + pages.push(page); + recordCursor(seen, page.next); + } + + return pages; +}; + +/** Checks the paging rules every listing keeps: only the last page lacks `next`, every page resumes from the `next` before it, a second walk returns the same pages, and every entry is a scope of this source. */ +const expectListingPages = async ( + client: GraphSourceClient, + call: ListingCall, +): Promise => { + const { source } = await client.v1.describe(); + const pages = await walkListing(call); + expect(pages.map((page) => page.next === undefined)).toEqual( + pages.map((_, index) => index === pages.length - 1), + ); + await Promise.all( + pages + .slice(1) + .map(async (page, index) => + expect(call(nextOf(pages[index]))).resolves.toEqual(page), + ), + ); + await expect(walkListing(call)).resolves.toEqual(pages); + for (const entry of pages.flatMap((page) => page.entries)) { + expect(entry.scope.startsWith(`${source}:`)).toBe(true); + } + + return pages; +}; + const identifiersOf = (pages: readonly SyncPage[]): readonly string[] => pages.flatMap((page) => [ ...page.participants.map((participant) => participant.id), @@ -102,16 +187,129 @@ const identifiersOf = (pages: readonly SyncPage[]): readonly string[] => ...page.transitions.map((transition) => transition.id), ]); +/** What the scope listing's conformance tests need: a name and client factory, as for the whole kit, and the listing's own scopes. */ +export interface GraphSourceListingConformanceOptions extends ScopeListingConformance { + /** A name for the `describe` block, such as the adapter and transport under test. */ + readonly name: string; + /** Makes a client for the source under test, once per test. */ + readonly createClient: () => GraphSourceClient; +} + +/** + * The scope listing's share of the kit, as Vitest tests: paging and resumption, determinism, the gap for an unknown parent, typed errors, aborts, and that a listed child syncs. `graphSourceConformance` runs it when given `listing`; call it on its own to exercise the listing with a different client, such as one with a smaller page size so the parent spans two pages. + * @param options - The client factory and the listing's scopes. + */ +export const graphSourceListingConformance = ( + options: GraphSourceListingConformanceOptions, +): void => { + const { name, createClient, roots, parent, missingParent, malformedParent } = + options; + describe(`GraphSource scope listing conformance: ${name}`, () => { + test("a parent's children span at least two pages, resume from every next, and are deterministic", async () => { + const client = createClient(); + const pages = await expectListingPages( + client, + childrenCall(client, parent), + ); + expect(pages.length).toBeGreaterThanOrEqual(2); + expect(pages.flatMap((page) => page.gaps)).toEqual([]); + }); + + test("a listed child is a scope that syncs", async () => { + const client = createClient(); + const [first] = await walkListing(childrenCall(client, parent)); + const [entry] = first?.entries ?? []; + if (entry === undefined) { + throw new Error(`expected ${parent} to list a child`); + } + await expect( + client.v1.sync.page({ scope: [entry.scope] }), + ).resolves.toMatchObject({ gaps: [] }); + }); + + test.skipIf(!roots)( + "the top-level scopes resume from every next and are deterministic", + async () => { + const client = createClient(); + const pages = await expectListingPages(client, rootsCall(client)); + expect(pages.flatMap((page) => page.entries).length).toBeGreaterThan(0); + }, + ); + + test("the children of an unknown scope are a not-found gap on an empty page", async () => { + const client = createClient(); + await expect( + client.v1.scopes.children({ scope: missingParent }), + ).resolves.toEqual({ + entries: [], + gaps: [ + expect.objectContaining({ + target: missingParent, + reason: "not-found", + }), + ], + }); + }); + + test("a malformed parent is a BAD_REQUEST with reason invalid-scope", async () => { + await expect( + createClient().v1.scopes.children({ scope: malformedParent }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-scope", value: malformedParent }, + }); + }); + + test("a cursor the listing did not issue is a BAD_REQUEST with reason invalid-cursor", async () => { + const client = createClient(); + const rejection = { + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: foreignCursor }, + }; + await expect( + client.v1.scopes.children({ scope: parent, after: foreignCursor }), + ).rejects.toMatchObject(rejection); + await expect( + client.v1.scopes.roots({ after: foreignCursor }), + ).rejects.toMatchObject(rejection); + }); + + test("a cursor for one parent is refused for another", async () => { + const client = createClient(); + const first = await client.v1.scopes.children({ scope: parent }); + const after = nextOf(first); + await expect( + client.v1.scopes.children({ scope: missingParent, after }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: after }, + }); + }); + + test("an aborted signal ends both listings", async () => { + const client = createClient(); + const controller = new AbortController(); + controller.abort(); + const { signal } = controller; + await expect( + client.v1.scopes.children({ scope: parent }, { signal }), + ).rejects.toThrow(); + await expect(client.v1.scopes.roots({}, { signal })).rejects.toThrow(); + }); + }); +}; + /** * The behaviour every `GraphSource` must have, as Vitest tests run against a client. An adapter's own test calls this once per transport it supports; because the tests talk only to the oRPC client, the same assertions hold in process, over a message port or over HTTP. * - * The tests check that paging and streaming return the same pages; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a sync is deterministic; that the assembled model and overlay are valid and every identifier carries the source's prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed `BAD_REQUEST` errors; and that an aborted signal ends both procedures. + * The tests check that paging and streaming return the same pages; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a sync is deterministic; that the assembled model and overlay are valid and every identifier carries the source's prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed `BAD_REQUEST` errors; that a filtered sync pages consistently, reads nothing the unfiltered one does not, and refuses a cursor issued under another filter; and that an aborted signal ends both procedures. With `listing`, they also check the scope listing: paging and resumption, gaps for an unknown parent, typed errors, aborts, and that a listed child syncs. * @param options - The client factory and the scopes to exercise. */ export const graphSourceConformance = ( options: GraphSourceConformanceOptions, ): void => { - const { name, createClient, scope, missingScope, malformedScope } = options; + const { name, createClient, scope, missingScope, malformedScope, listing } = + options; describe(`GraphSource conformance: ${name}`, () => { test("the scope spans at least two pages, so resumption is exercised", async () => { @@ -224,6 +422,41 @@ export const graphSourceConformance = ( ).rejects.toMatchObject(rejection); }); + test("a filtered sync pages and streams the same pages, with cursors that never repeat", async () => { + const client = createClient(); + const paged = await walkPages(client, scope, conformanceFilter); + await expect( + streamPages(client, { scope, filter: conformanceFilter }), + ).resolves.toEqual(paged); + await expect( + walkPages(client, scope, conformanceFilter), + ).resolves.toEqual(paged); + }); + + test("a filtered sync reads nothing the unfiltered sync does not", async () => { + const client = createClient(); + const all = assembleSync(await walkPages(client, scope)); + const narrowed = assembleSync( + await walkPages(client, scope, conformanceFilter), + ); + const everyActivity = new Set(all.model.elements.map(({ id }) => id)); + for (const element of narrowed.model.elements) { + expect(everyActivity).toContain(element.id); + } + }); + + test("a cursor is refused under a filter other than the one it was issued for", async () => { + const client = createClient(); + const first = await client.v1.sync.page({ scope }); + const after = nextOf(first); + await expect( + client.v1.sync.page({ scope, after, filter: conformanceFilter }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: after }, + }); + }); + test("an aborted signal ends both procedures", async () => { const client = createClient(); const controller = new AbortController(); @@ -236,6 +469,10 @@ export const graphSourceConformance = ( ).rejects.toThrow(); }); }); + + if (listing !== undefined) { + graphSourceListingConformance({ ...listing, name, createClient }); + } }; /** diff --git a/packages/core/graph/source-contract/src/contract.ts b/packages/core/graph/source-contract/src/contract.ts index 027cd04..73d9126 100644 --- a/packages/core/graph/source-contract/src/contract.ts +++ b/packages/core/graph/source-contract/src/contract.ts @@ -9,6 +9,9 @@ import { import { z } from "zod"; import { badRequestReasons, + scopeChildrenInputSchema, + scopeListingSchema, + scopeRootsInputSchema, sourceDescriptionSchema, syncInputSchema, syncPageSchema, @@ -39,6 +42,19 @@ const graphSourceErrors = { type GraphSourceErrors = typeof graphSourceErrors; +/** + * The scope listings' errors: every procedure's, and `NOT_IMPLEMENTED` for a source that offers no listing, which oRPC's common code answers with HTTP 501 over HTTP. + */ +const scopeListingErrors = { + ...graphSourceErrors, + NOT_IMPLEMENTED: { + message: "The source does not list its scopes.", + data: z.strictObject({}), + }, +}; + +type ScopeListingErrors = typeof scopeListingErrors; + /** The event iterator of pages `sync.stream` returns, typed by name so declarations stay small. */ const syncPageEvents: ReturnType< typeof eventIterator @@ -67,6 +83,18 @@ interface GraphSourceV1Contract { GraphSourceErrors >; }; + readonly scopes: { + readonly roots: ProcedureContract< + typeof scopeRootsInputSchema, + typeof scopeListingSchema, + ScopeListingErrors + >; + readonly children: ProcedureContract< + typeof scopeChildrenInputSchema, + typeof scopeListingSchema, + ScopeListingErrors + >; + }; } /** @@ -75,6 +103,7 @@ interface GraphSourceV1Contract { * - `describe` names the source, which is the prefix of every identifier it emits. * - `sync.page` returns one page of the scope, starting after `after` when given. * - `sync.stream` yields every page from `after` onwards (or from the start) as an event iterator, each carrying the `next` cursor to resume after it. Pages are produced on demand as the consumer pulls, so a slow consumer applies backpressure. Over an event-stream transport each event's id is its `next` cursor, so a reconnecting client's `lastEventId` resumes there too; an explicit `after` takes precedence. + * - `scopes.roots` lists, a page at a time, the top-level scopes the credentials reach (for GitHub, the viewer's own account and organisations), and `scopes.children` the scopes inside one scope (for GitHub, an owner's repositories), each entry ready to pass to `sync`. Both page and resume by cursor like `sync.page`, and a source that offers no listing answers `NOT_IMPLEMENTED`. * * Every call takes the caller's abort signal through the oRPC client options, and every implementation passes it on to its source. */ @@ -86,6 +115,16 @@ const graphSourceV1Contract: GraphSourceV1Contract = oc page: oc.input(syncInputSchema).output(syncPageSchema), stream: oc.input(syncInputSchema).output(syncPageEvents), }, + scopes: { + roots: oc + .errors(scopeListingErrors) + .input(scopeRootsInputSchema) + .output(scopeListingSchema), + children: oc + .errors(scopeListingErrors) + .input(scopeChildrenInputSchema) + .output(scopeListingSchema), + }, }); /** The `GraphSource` port: an oRPC router contract, keyed by version. */ diff --git a/packages/core/graph/source-contract/src/fake.ts b/packages/core/graph/source-contract/src/fake.ts index a22cf8b..435e5fb 100644 --- a/packages/core/graph/source-contract/src/fake.ts +++ b/packages/core/graph/source-contract/src/fake.ts @@ -1,6 +1,16 @@ import { GraphSourceError } from "./errors.ts"; -import type { GraphSourcePager, PageRequest } from "./router.ts"; -import type { SyncPage } from "./schema.ts"; +import type { + GraphSourcePager, + GraphSourceScopes, + ListingRequest, + PageRequest, +} from "./router.ts"; +import type { + ScopeEntry, + ScopeListing, + SyncFilter, + SyncPage, +} from "./schema.ts"; /** A page's content without its cursor, which the fake derives. */ export type FakePage = Omit; @@ -11,6 +21,12 @@ export interface FakeGraphSourceOptions { readonly source: string; /** The pages of each known scope, keyed by scope identifier. A scope with no pages is known and empty. */ readonly scopes: Readonly>; + /** What `scopes.roots` lists, in order; nothing when absent. */ + readonly roots?: readonly ScopeEntry[]; + /** What `scopes.children` lists for each parent scope, in order. A parent named here, in `scopes` or among `roots` is known; any other parent is a `not-found` gap. */ + readonly children?: Readonly>; + /** Entries per listing page, a positive integer; absent, a listing is one page. */ + readonly listingPageSize?: number; } /** One position in a sync: a page of a known scope, or the single gap page of an unknown one. */ @@ -20,16 +36,79 @@ interface Position { } const gapIndex = -1; -const cursorPattern = /^(?[^@]+)@(?-1|0|[1-9]\d*)$/u; +const cursorPattern = + /^(?[^@;]+)@(?-1|0|[1-9]\d*)(?:;(?.+))?$/u; -const encodeCursor = (position: Position): string => - `${encodeURIComponent(position.scope)}@${String(position.index)}`; +/** A filter as one string with its fields in a fixed order, so the same filter always gives the same cursor and two filters compare by value. */ +const filterKey = (filter: SyncFilter | undefined): string | undefined => + filter === undefined + ? undefined + : encodeURIComponent( + JSON.stringify([ + filter.states ?? null, + filter.labels ?? null, + filter.order === undefined + ? null + : [filter.order.field, filter.order.direction], + ]), + ); + +const encodeCursor = (position: Position, filter: string | undefined): string => + `${encodeURIComponent(position.scope)}@${String(position.index)}${filter === undefined ? "" : `;${filter}`}`; + +/** + * Applies a filter to a page as though the source had listed only the activities it keeps: an activity is kept when its run state is one of `states` and it carries one of `labels`. The fake's activities carry no labels, so a filter naming labels keeps none. What a dropped activity was part of (transitions, relations, run state) is dropped with it. `order` does not change the fake's fixed page order. + */ +const filtered = (page: FakePage, filter: SyncFilter | undefined): FakePage => { + if (filter === undefined) { + return page; + } + const { states, labels } = filter; + const kept = new Set( + page.activities + .filter((activity) => { + const state = page.overlay[activity.id]; + const stateKept = + states === undefined || + (state !== undefined && states.some((wanted) => wanted === state)); + + return stateKept && labels === undefined; + }) + .map((activity) => activity.id), + ); + const dropped = new Set( + page.activities + .map((activity) => activity.id) + .filter((id) => !kept.has(id)), + ); + + return { + ...page, + activities: page.activities.filter((activity) => kept.has(activity.id)), + transitions: page.transitions.filter( + (transition) => + !dropped.has(transition.source) && !dropped.has(transition.target), + ), + relations: page.relations.filter( + (relation) => !dropped.has(relation.from) && !dropped.has(relation.to), + ), + overlay: Object.fromEntries( + Object.entries(page.overlay).filter(([id]) => !dropped.has(id)), + ), + }; +}; + +/** The cursor of a listing page: which listing (`roots`, or the encoded parent scope, which always holds an encoded colon) and the offset of the next entry. */ +const listingCursorPattern = /^(?[^@]+)@(?[1-9]\d*)$/u; +const rootsListing = "roots"; const samePosition = (a: Position, b: Position): boolean => a.scope === b.scope && a.index === b.index; /** - * The reference in-memory `GraphSourcePager`: a fixed set of pages per scope, served in scope order. A scope that is well formed but unknown yields one page holding a `not-found` gap, so a sync over several scopes still returns the rest. Cursors encode the scope identifier and page index, so they are stable and resume in any instance built from the same pages. + * The reference in-memory `GraphSourcePager`: a fixed set of pages per scope, served in scope order. A scope that is well formed but unknown yields one page holding a `not-found` gap, so a sync over several scopes still returns the rest. Cursors encode the scope identifier, page index and filter, so they are stable, resume in any instance built from the same pages, and are refused under a different filter. A filter is applied to each page as described at `filtered`. + * + * Its scope listing serves `roots` and `children` from the options, `listingPageSize` entries a page. * @param options - The source prefix and the pages of each scope. * @returns A pager for `createGraphSourceRouter`. */ @@ -63,13 +142,16 @@ export const createFakeGraphSource = ( const startOf = ( positions: readonly Position[], cursor: string | undefined, + filter: string | undefined, ): number => { if (cursor === undefined) { return 0; } const groups = cursorPattern.exec(cursor)?.groups; const after = - groups?.scope === undefined || groups.index === undefined + groups?.scope === undefined || + groups.index === undefined || + groups.filter !== filter ? undefined : { scope: decodeURIComponent(groups.scope), @@ -117,26 +199,123 @@ export const createFakeGraphSource = ( }; }; - const read = ({ scope, cursor, signal }: PageRequest): SyncPage => { + const read = ({ scope, cursor, signal, filter }: PageRequest): SyncPage => { signal?.throwIfAborted(); + const key = filterKey(filter); const positions = positionsOf(scope); - const start = startOf(positions, cursor); + const start = startOf(positions, cursor, key); const position = positions[start]; if (position === undefined) { throw new Error( "unreachable: startOf returns an index within the positions", ); } - const page = pageAt(position); + const page = filtered(pageAt(position), filter); return start === positions.length - 1 ? page - : { ...page, next: encodeCursor(position) }; + : { ...page, next: encodeCursor(position, key) }; + }; + + const listingPageSize = options.listingPageSize; + if ( + listingPageSize !== undefined && + (!Number.isInteger(listingPageSize) || listingPageSize < 1) + ) { + throw new RangeError( + `listingPageSize must be a positive integer, not ${String(listingPageSize)}`, + ); + } + const roots = options.roots ?? []; + const children = options.children ?? {}; + + /** One page of `entries` from the cursor's offset, after checking the cursor belongs to `listing`. */ + const listingPage = ( + listing: string, + entries: readonly ScopeEntry[], + { cursor, signal }: ListingRequest, + ): ScopeListing => { + signal?.throwIfAborted(); + const size = listingPageSize ?? Math.max(entries.length, 1); + const groups = + cursor === undefined + ? undefined + : listingCursorPattern.exec(cursor)?.groups; + const offset = + cursor === undefined + ? 0 + : groups?.listing === listing && groups.offset !== undefined + ? Number(groups.offset) + : undefined; + // Only the offsets this fake hands out are cursors: a page boundary inside the listing. + if ( + offset === undefined || + offset % size !== 0 || + (offset > 0 && offset >= entries.length) + ) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${String(cursor)}" is not a cursor for this listing`, + reason: "invalid-cursor", + value: String(cursor), + }); + } + const end = offset + size; + const page = { entries: entries.slice(offset, end), gaps: [] }; + + return end < entries.length + ? { ...page, next: `${listing}@${String(end)}` } + : page; + }; + + const listing: GraphSourceScopes = { + roots: async (request) => + Promise.resolve(request).then(() => + listingPage(rootsListing, roots, request), + ), + children: async (request) => + Promise.resolve(request).then(() => { + const { scope } = request; + if (!scope.startsWith(prefix) || scope.length === prefix.length) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${scope}" is not a ${source} scope identifier`, + reason: "invalid-scope", + value: scope, + }); + } + const known = + Object.hasOwn(children, scope) || + Object.hasOwn(scopes, scope) || + roots.some((entry) => entry.scope === scope); + // An unknown parent never had a page, so no cursor is valid for it. + if (!known && request.cursor === undefined) { + request.signal?.throwIfAborted(); + + return { + entries: [], + gaps: [ + { + target: scope, + reason: "not-found", + message: `${scope} does not exist`, + }, + ], + }; + } + + return listingPage( + encodeURIComponent(scope), + children[scope] ?? [], + request, + ); + }), }; return { source, // Async, so a throw from `read` becomes a rejection, as it would from a source that reads over a network. page: async (request) => Promise.resolve(read(request)), + scopes: listing, }; }; diff --git a/packages/core/graph/source-contract/src/fake.unit.test.ts b/packages/core/graph/source-contract/src/fake.unit.test.ts index ad44527..173717d 100644 --- a/packages/core/graph/source-contract/src/fake.unit.test.ts +++ b/packages/core/graph/source-contract/src/fake.unit.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "vitest"; -import { graphSourceConformance } from "./conformance.ts"; +import { + conformanceFilter, + graphSourceConformance, + graphSourceListingConformance, + type GraphSourceListingConformanceOptions, + type ScopeListingConformance, +} from "./conformance.ts"; import { createFakeGraphSource } from "./fake.ts"; import { createGraphSourceRouter } from "./router.ts"; import { fakeOptions } from "./test-support/pages.ts"; @@ -8,12 +14,20 @@ import { inProcessClient, messagePortClient } from "./conformance.ts"; const router = createGraphSourceRouter(createFakeGraphSource(fakeOptions)); const scope = ["fake:acme/alpha", "fake:acme/beta"]; +const listing: ScopeListingConformance = { + roots: true, + parent: "fake:acme", + missingParent: "fake:umbrella", + malformedParent: "other:acme", +}; + graphSourceConformance({ name: "in-memory fake, in process", createClient: () => inProcessClient(router), scope, missingScope: "fake:acme/gamma", malformedScope: "other:acme/alpha", + listing, }); graphSourceConformance({ @@ -24,6 +38,15 @@ graphSourceConformance({ malformedScope: "fake:", }); +// The listing's share on its own, over the same transport, as an adapter whose listing needs a different client runs it. +const portListing: GraphSourceListingConformanceOptions = { + ...listing, + name: "in-memory fake, over a message port", + createClient: () => messagePortClient(router), + malformedParent: "fake:", +}; +graphSourceListingConformance(portListing); + describe("createFakeGraphSource", () => { test("serves the scopes in the order requested, one page per call", async () => { const client = inProcessClient(router); @@ -76,6 +99,130 @@ describe("createFakeGraphSource", () => { }); }); + test("the kit's filter keeps open work only, so the fake drops the done activity", async () => { + const page = await inProcessClient(router).v1.sync.page({ + scope: ["fake:acme/alpha"], + filter: conformanceFilter, + }); + expect(page.activities.map((activity) => activity.id)).toEqual([ + "fake:acme/alpha#2", + ]); + }); + + test("a states filter keeps the activities in those states and drops what they were part of", async () => { + const page = await inProcessClient(router).v1.sync.page({ + scope: ["fake:acme/alpha"], + filter: { states: ["open"] }, + }); + expect(page.activities.map((activity) => activity.id)).toEqual([ + "fake:acme/alpha#2", + ]); + expect(page.transitions).toEqual([]); + expect(page.relations).toEqual([ + { kind: "part-of", from: "fake:acme/alpha", to: "fake:acme" }, + ]); + expect(page.overlay).toEqual({ "fake:acme/alpha#2": "open" }); + expect(page.next).toBe( + `fake%3Aacme%2Falpha@0;${encodeURIComponent('[["open"],null,null]')}`, + ); + }); + + test("a labels filter keeps nothing, since the fake's activities carry no labels", async () => { + const page = await inProcessClient(router).v1.sync.page({ + scope: ["fake:acme/beta"], + filter: { labels: ["bug"] }, + }); + expect(page.activities).toEqual([]); + expect(page.transitions).toEqual([]); + }); + + test("a filtered cursor resumes only under the same filter", async () => { + const client = inProcessClient(router); + const filter = { states: ["open", "done"] } as const; + const first = await client.v1.sync.page({ scope, filter }); + const second = await client.v1.sync.page({ + scope, + filter, + after: first.next ?? "", + }); + expect(second.activities.map((activity) => activity.id)).toEqual([ + "fake:acme/alpha#3", + ]); + await expect( + client.v1.sync.page({ scope, after: first.next ?? "" }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor" }, + }); + }); + + test("lists the top-level scopes and an organisation's children a page at a time", async () => { + const client = inProcessClient(router); + await expect(client.v1.scopes.roots({})).resolves.toEqual({ + entries: [{ scope: "fake:acme", label: "Acme", archived: false }], + gaps: [], + next: "roots@1", + }); + const first = await client.v1.scopes.children({ scope: "fake:acme" }); + expect(first).toEqual({ + entries: [ + { + scope: "fake:acme/alpha", + label: "alpha", + description: "The store", + url: "https://example.com/acme/alpha", + archived: false, + }, + ], + gaps: [], + next: "fake%3Aacme@1", + }); + await expect( + client.v1.scopes.children({ scope: "fake:acme", after: "fake%3Aacme@2" }), + ).resolves.toEqual({ + entries: [{ scope: "fake:acme/legacy", label: "legacy", archived: true }], + gaps: [], + }); + }); + + test("a known scope with nothing inside lists an empty page, and a cursor past the end is refused", async () => { + const client = inProcessClient(router); + await expect( + client.v1.scopes.children({ scope: "fake:acme/beta" }), + ).resolves.toEqual({ entries: [], gaps: [] }); + await expect( + client.v1.scopes.children({ scope: "fake:acme", after: "fake%3Aacme@3" }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: "fake%3Aacme@3" }, + }); + }); + + test("without a page size a listing is one page", async () => { + const entries = [ + { scope: "fake:acme/alpha", label: "alpha", archived: false }, + { scope: "fake:acme/beta", label: "beta", archived: false }, + ]; + const client = inProcessClient( + createGraphSourceRouter( + createFakeGraphSource({ + source: "fake", + scopes: {}, + children: { "fake:acme": entries }, + }), + ), + ); + await expect( + client.v1.scopes.children({ scope: "fake:acme" }), + ).resolves.toEqual({ entries, gaps: [] }); + }); + + test("a listing page size that is not a positive integer is refused", () => { + expect(() => + createFakeGraphSource({ ...fakeOptions, listingPageSize: 0 }), + ).toThrow(RangeError); + }); + test("describe names the source", async () => { await expect(inProcessClient(router).v1.describe()).resolves.toEqual({ source: "fake", diff --git a/packages/core/graph/source-contract/src/index.ts b/packages/core/graph/source-contract/src/index.ts index 2d26846..5ccc365 100644 --- a/packages/core/graph/source-contract/src/index.ts +++ b/packages/core/graph/source-contract/src/index.ts @@ -7,32 +7,52 @@ export { graphSourceContract, type GraphSourceClient } from "./contract.ts"; export { badRequestReasons, boundActivitySchema, + filterStates, gapReasons, gapSchema, + orderDirections, + orderFields, participantSchema, relationKinds, relationSchema, + scopeChildrenInputSchema, + scopeEntrySchema, + scopeListingSchema, + scopeRootsInputSchema, sourceDescriptionSchema, + syncFilterSchema, syncInputSchema, syncPageSchema, transitionSchema, type BadRequestReason, type BoundActivity, + type FilterState, type Gap, type GapReason, + type OrderDirection, + type OrderField, type Relation, type RelationKind, + type ScopeChildrenInput, + type ScopeEntry, + type ScopeListing, + type ScopeListingInput, + type ScopeRootsInput, type SourceDescription, type SourceParticipant, type SourceTransition, + type SyncFilter, type SyncInput, type SyncPage, } from "./schema.ts"; export { GraphSourceError, type GraphSourceFailure } from "./errors.ts"; export { createGraphSourceRouter, + type ChildrenRequest, type GraphSourcePager, type GraphSourceRouter, + type GraphSourceScopes, + type ListingRequest, type PageRequest, } from "./router.ts"; export { assembleSync, type AssembledSync } from "./assemble.ts"; diff --git a/packages/core/graph/source-contract/src/router.ts b/packages/core/graph/source-contract/src/router.ts index 88f3d18..1eda3c4 100644 --- a/packages/core/graph/source-contract/src/router.ts +++ b/packages/core/graph/source-contract/src/router.ts @@ -8,7 +8,12 @@ import { import { graphSourceContract } from "./contract.ts"; import { GraphSourceError } from "./errors.ts"; import { paginate } from "./paginate.ts"; -import type { SyncInput, SyncPage } from "./schema.ts"; +import type { + ScopeListing, + SyncFilter, + SyncInput, + SyncPage, +} from "./schema.ts"; /** One request for a page, as a pager receives it. */ export interface PageRequest { @@ -18,6 +23,42 @@ export interface PageRequest { readonly cursor: string | undefined; /** The caller's abort signal. A pager passes it to every request it makes and stops when it fires. */ readonly signal: AbortSignal | undefined; + /** What narrows and orders each scope's listing; absent or `undefined`, nothing narrows it. A cursor issued under one filter is not valid under another. */ + readonly filter?: SyncFilter | undefined; +} + +/** One request for a page of a scope listing. */ +export interface ListingRequest { + /** Where to start: the `next` of the page before, or `undefined` for the first page. */ + readonly cursor: string | undefined; + /** The caller's abort signal, passed to every request the listing makes. */ + readonly signal: AbortSignal | undefined; +} + +/** One request for a page of the scopes inside a scope. */ +export interface ChildrenRequest extends ListingRequest { + /** The scope identifier whose child scopes to list. */ + readonly scope: string; +} + +/** + * How a source lists the scopes a person can choose to sync, one page at a time. Cursors follow the pager's rules: stable, holding no in-memory state, and valid only for the listing (and parent) that issued them, so a foreign one throws `GraphSourceError` with `bad-request` and `invalid-cursor`. + */ +export interface GraphSourceScopes { + /** + * Lists the top-level scopes the credentials reach. + * @param request - The cursor to start after, and the abort signal. + * @returns One page of entries, with `next` absent on the last. + * @throws GraphSourceError for a failure that ends the call. + */ + readonly roots: (request: ListingRequest) => Promise; + /** + * Lists the scopes inside one scope. A parent that does not exist, or that the credentials cannot see, is a gap on an empty page; a scope with nothing inside it is an empty page; a parent that is not a scope identifier of this source throws `bad-request` with `invalid-scope`. + * @param request - The parent scope, the cursor to start after, and the abort signal. + * @returns One page of entries, with `next` absent on the last. + * @throws GraphSourceError for a failure that ends the call. + */ + readonly children: (request: ChildrenRequest) => Promise; } /** @@ -35,6 +76,8 @@ export interface GraphSourcePager { * @throws GraphSourceError for a failure that ends the call. */ readonly page: (request: PageRequest) => Promise; + /** Lists the scopes a person can choose from. Absent, the source offers no listing and the router answers its procedures with `NOT_IMPLEMENTED`. */ + readonly scopes?: GraphSourceScopes; } const os = implement(graphSourceContract); @@ -80,18 +123,45 @@ const toContractError = ( }); }; +/** Runs one call into the pager after checking the signal, as the contract's typed error when it fails. */ +const guarded = async ( + signal: AbortSignal | undefined, + errors: Readonly, + read: () => Promise, +): Promise => { + signal?.throwIfAborted(); + try { + return await read(); + } catch (error) { + throw toContractError(error, errors); + } +}; + /** Reads one page from the pager, as the contract's typed error when it fails. */ const readPage = async ( pager: GraphSourcePager, request: Readonly, errors: Readonly, -): Promise => { - request.signal?.throwIfAborted(); - try { - return await pager.page(request); - } catch (error) { - throw toContractError(error, errors); +): Promise => + guarded(request.signal, errors, async () => pager.page(request)); + +type ListingErrors = ORPCErrorConstructorMap< + (typeof graphSourceContract.v1.scopes.roots)["~orpc"]["errorMap"] +>; + +/** The pager's scope listing, or the contract's `NOT_IMPLEMENTED` when it has none. */ +const listingOf = ( + pager: GraphSourcePager, + errors: Readonly, +): GraphSourceScopes => { + if (pager.scopes === undefined) { + throw errors.NOT_IMPLEMENTED({ + message: `The ${pager.source} source does not list its scopes.`, + data: {}, + }); } + + return pager.scopes; }; /** @@ -109,7 +179,12 @@ export const createGraphSourceRouter = ( page: os.v1.sync.page.handler(async ({ input, signal, errors }) => readPage( pager, - { scope: input.scope, cursor: input.after, signal }, + { + scope: input.scope, + cursor: input.after, + signal, + filter: input.filter, + }, errors, ), ), @@ -120,7 +195,11 @@ export const createGraphSourceRouter = ( errors, }) { const pages = paginate(input.after ?? lastEventId, async (cursor) => - readPage(pager, { scope: input.scope, cursor, signal }, errors), + readPage( + pager, + { scope: input.scope, cursor, signal, filter: input.filter }, + errors, + ), ); for await (const page of pages) { yield page.next === undefined @@ -129,5 +208,27 @@ export const createGraphSourceRouter = ( } }), }, + scopes: { + roots: os.v1.scopes.roots.handler(async ({ input, signal, errors }) => { + const listing = listingOf(pager, errors); + + return guarded(signal, errors, async () => + listing.roots({ cursor: input.after, signal }), + ); + }), + children: os.v1.scopes.children.handler( + async ({ input, signal, errors }) => { + const listing = listingOf(pager, errors); + + return guarded(signal, errors, async () => + listing.children({ + scope: input.scope, + cursor: input.after, + signal, + }), + ); + }, + ), + }, }, }); diff --git a/packages/core/graph/source-contract/src/router.unit.test.ts b/packages/core/graph/source-contract/src/router.unit.test.ts index c7daf4f..5030e89 100644 --- a/packages/core/graph/source-contract/src/router.unit.test.ts +++ b/packages/core/graph/source-contract/src/router.unit.test.ts @@ -107,6 +107,47 @@ describe.each([ }); }); +describe("the scope listing", () => { + test("a pager without one answers NOT_IMPLEMENTED", async () => { + const client = inProcessClient( + createGraphSourceRouter({ source: "fake", page: fake.page }), + ); + const notImplemented = { + code: "NOT_IMPLEMENTED", + defined: true, + data: {}, + }; + await expect(client.v1.scopes.roots({})).rejects.toMatchObject( + notImplemented, + ); + await expect( + client.v1.scopes.children({ scope: "fake:acme" }), + ).rejects.toMatchObject(notImplemented); + }); + + test("passes the filter to the pager", async () => { + const seen: PageRequest["filter"][] = []; + const recording: GraphSourcePager = { + source: "fake", + page: async (request: PageRequest) => { + seen.push(request.filter); + + return await fake.page(request); + }, + }; + const client = inProcessClient(createGraphSourceRouter(recording)); + const filter = { labels: ["bug"] }; + await client.v1.sync.page({ scope, filter }); + for await (const page of await client.v1.sync.stream({ scope, filter })) { + expect(page.activities).toEqual([]); + } + expect(seen.length).toBeGreaterThan(1); + expect(seen.every((received) => received?.labels?.[0] === "bug")).toBe( + true, + ); + }); +}); + describe("the abort signal", () => { test("reaches the pager, and aborting it mid-stream ends the stream", async () => { const seen: (AbortSignal | undefined)[] = []; diff --git a/packages/core/graph/source-contract/src/schema.ts b/packages/core/graph/source-contract/src/schema.ts index c9423a7..b2f71ee 100644 --- a/packages/core/graph/source-contract/src/schema.ts +++ b/packages/core/graph/source-contract/src/schema.ts @@ -72,13 +72,85 @@ export const syncPageSchema = z }) .readonly(); +/** The run states a sync filter can select by: whether the work is still open or done. */ +export const filterStates = ["open", "done"] as const; + +/** What a sync filter can order the listed records by: when they were created, when they last changed, or how much discussion they have. */ +export const orderFields = ["created", "updated", "comments"] as const; + +/** Which way a sync filter orders the listed records. */ +export const orderDirections = ["ascending", "descending"] as const; + /** - * What to sync and where to resume. `scope` names what to read as source-qualified scope identifiers (for GitHub, `github:org` for every repository an owner has, or `github:org/repo`), read in the order given. `after` is a cursor from an earlier page's `next`: the sync continues with the page that followed it. + * Narrows and orders the records each scope lists, applied on the source's side where the source supports it. Every field is optional, and an absent field does not narrow. + * + * - `states` keeps records whose run state is one of those given. + * - `labels` keeps records carrying at least one of the labels given. + * - `order` is the order the records are listed in; absent, the source's own stable order. + * + * A filter narrows what a scope lists, not what the listed records reach: a blocker of a listed record is read whatever its own state or labels, because it is a dependency of work the filter kept. + */ +export const syncFilterSchema = z + .strictObject({ + states: z.array(z.enum(filterStates)).min(1).readonly().exactOptional(), + labels: z.array(z.string().min(1)).min(1).readonly().exactOptional(), + order: z + .strictObject({ + field: z.enum(orderFields), + direction: z.enum(orderDirections), + }) + .readonly() + .exactOptional(), + }) + .readonly(); + +/** + * What to sync and where to resume. `scope` names what to read as source-qualified scope identifiers (for GitHub, `github:org` for every repository an owner has, or `github:org/repo`), read in the order given. `after` is a cursor from an earlier page's `next`: the sync continues with the page that followed it. A cursor is valid only with the scope list and the filter it was issued for. + * + * `filter` narrows and orders what each scope lists (see `syncFilterSchema`). */ export const syncInputSchema = z .strictObject({ scope: z.array(z.string().min(1)).min(1).readonly(), after: z.string().min(1).exactOptional(), + filter: syncFilterSchema.exactOptional(), + }) + .readonly(); + +/** + * A scope a person can choose to sync, as a picker lists it. `scope` is the scope identifier to pass to `sync` (for GitHub, `github:org` for an organisation, `github:org/repo` for a repository), `label` its display name, `description` the source's own description when it has one, `url` where it lives in the source's own interface, and `archived` whether the source has archived it. + */ +export const scopeEntrySchema = z + .strictObject({ + scope: z.string().min(1), + label: z.string(), + description: z.string().exactOptional(), + url: z.url().exactOptional(), + archived: z.boolean(), + }) + .readonly(); + +/** + * One page of a scope listing. `entries` are in the source's stable listing order, `gaps` name a parent the source could not read (it does not exist, or the credentials cannot see it), and `next` is the cursor to resume after this page, absent on the last. + */ +export const scopeListingSchema = z + .strictObject({ + entries: z.array(scopeEntrySchema).readonly(), + gaps: z.array(gapSchema).readonly(), + next: z.string().min(1).exactOptional(), + }) + .readonly(); + +/** Where a listing of the top-level scopes resumes: a cursor from an earlier page's `next`. */ +export const scopeRootsInputSchema = z + .strictObject({ after: z.string().min(1).exactOptional() }) + .readonly(); + +/** The scope whose child scopes to list (for GitHub, the repositories of `github:owner`), and where to resume. A cursor is valid only for the parent it was issued for. */ +export const scopeChildrenInputSchema = z + .strictObject({ + scope: z.string().min(1), + after: z.string().min(1).exactOptional(), }) .readonly(); @@ -101,5 +173,15 @@ export type SyncPage = z.output; /** A page as an implementation supplies it, before parsing. */ export type SyncPageInput = z.input; export type SyncInput = z.infer; +export type SyncFilter = z.infer; +export type FilterState = (typeof filterStates)[number]; +export type OrderField = (typeof orderFields)[number]; +export type OrderDirection = (typeof orderDirections)[number]; +export type ScopeEntry = z.infer; +export type ScopeListing = z.output; +/** A listing page as an implementation supplies it, before parsing. */ +export type ScopeListingInput = z.input; +export type ScopeRootsInput = z.infer; +export type ScopeChildrenInput = z.infer; export type SourceDescription = z.infer; export type BadRequestReason = (typeof badRequestReasons)[number]; diff --git a/packages/core/graph/source-contract/src/test-support/pages.ts b/packages/core/graph/source-contract/src/test-support/pages.ts index 3d6ddb1..7f0cfd8 100644 --- a/packages/core/graph/source-contract/src/test-support/pages.ts +++ b/packages/core/graph/source-contract/src/test-support/pages.ts @@ -23,9 +23,27 @@ const activity = ( return { kind: "activity", id, source: id, label, actor: repo.id } as const; }; -/** Two repositories of one organisation: alpha spans two pages, beta one, and beta's work waits on alpha's. */ +const entry = (scope: string, label: string, archived = false) => + ({ scope, label, archived }) as const; + +/** + * Two repositories of one organisation: alpha spans two pages, beta one, and beta's work waits on alpha's. The listing has the organisation and a person at the top, and the organisation's repositories, one entry a page, one of them archived. + */ export const fakeOptions: FakeGraphSourceOptions = { source: "fake", + roots: [entry("fake:acme", "Acme"), entry("fake:ada", "ada")], + children: { + "fake:acme": [ + { + ...entry("fake:acme/alpha", "alpha"), + description: "The store", + url: "https://example.com/acme/alpha", + }, + entry("fake:acme/beta", "beta"), + entry("fake:acme/legacy", "legacy", true), + ], + }, + listingPageSize: 1, scopes: { "fake:acme/alpha": [ { diff --git a/packages/core/graph/store-contract/README.md b/packages/core/graph/store-contract/README.md index b3b7bfa..2ef9656 100644 --- a/packages/core/graph/store-contract/README.md +++ b/packages/core/graph/store-contract/README.md @@ -36,9 +36,13 @@ The contract is keyed by version: `graphStoreContract.v1`. A breaking change add | `models.delete` | `modelId` | Whether there was a model to delete | | `overlay.set` | `modelId`, `overlay` | The revision the change took | | `items.upsert` | `modelId`, `items` | The revision the change took | +| `relations.list` | `modelId` | The relations kept beside the model | | `query.ready` | `modelId` | The ready elements, in model order | | `query.blockedChain` | `modelId`, `element` | The unfinished work upstream | | `query.impact` | `modelId`, `element` | The unfinished work downstream | +| `query.children` | `modelId`, `element` | The element's direct children | +| `query.descendants` | `modelId`, `element` | Everything beneath it, nearest first | +| `query.ancestors` | `modelId`, `element` | Everything above it, nearest first | | `changes` | `modelId`, optionally `since` | An event iterator of changes | Every call takes the caller's abort signal through the oRPC client options. Failures are the contract's typed errors: `NOT_FOUND` (with `modelId`, and `element` for a query about an element the model lacks) and `UNPROCESSABLE_CONTENT` (with the `issues` that `validate` and `validateOverlay` report). Input that does not match the schemas is oRPC's `BAD_REQUEST`. @@ -47,9 +51,13 @@ Every call takes the caller's abort signal through the oRPC client options. Fail Every write is atomic and checked: a change that would leave the model invalid, or the overlay naming an element the model does not declare, is rejected whole and the store is unchanged. A write whose signal has fired before it commits changes nothing. -- `models.save` replaces the model and keeps the run state only of elements the new model still declares. +- `models.save` replaces the model and keeps the run state only of elements the new model still declares, and the relations only of what it still declares. - `overlay.set` sets the run state of the elements it names and leaves the others. -- `items.upsert` merges items read from a source: participants, bound activities and transitions replace the record with the same id in its place, or are appended in their given order; overlay entries replace the run state of the elements they name. It creates the model from an empty one when there is none. A bound activity's `source` is required and equals its id. +- `items.upsert` merges items read from a source: participants, bound activities and transitions replace the record with the same id in its place, or are appended in their given order; overlay entries replace the run state of the elements they name; each relation not already stored is appended. It creates the model from an empty one when there is none. A bound activity's `source` is required and equals its id. + +## Relations + +A relation is a structural link the model has no field for, kept beside it: `child-of` from a sub-task element to its parent element, `assigned-to` from an element to a person participant, and `part-of` from one participant to the participant that contains it. These are the relations a `GraphSource` reports, so a sync's `relations` go straight into `items.upsert`. A relation is identified by its kind and its two ends, and is stored once however often it is sent. Each end must name what the model declares in the space its kind requires, or the write is rejected whole with an `unknown-reference` issue. Relations carry no ordering: the dependency queries never read them, so a parent does not wait on its children unless a transition says so. Each write takes the next revision from one store-wide sequence, so revisions only grow, a model's revision is the revision of its last change, and a model deleted and saved again never reuses an earlier one. @@ -63,6 +71,7 @@ The queries are structural: they read the stored transitions and run state, and - `query.ready` is every element whose run state is absent or `open`, that is not a control element, and whose blocked chain is empty. On a model with no conditions and no artefacts it agrees with `graph-domain`'s `readySet`. - `query.blockedChain` is everything not done upstream of the element, each with the fewest transitions to reach it, ordered by that distance and then by model order. A done element waits on nothing. - `query.impact` is the same downstream, whatever the element's own state. +- `query.children` is every element related to the element by `child-of`, in model order. `query.descendants` and `query.ancestors` walk `child-of` relations down and up from it, each element reported once with the fewest relations it takes to reach it, ordered by that distance and then by model order. The walks never report the element asked about, so they end on a cycle of `child-of` relations, and they read run state not at all. ## Changes @@ -74,7 +83,7 @@ A store's `ChangeFeed` only sees the writes made through that store. Where sever ## Implementing a store -An adapter implements `GraphStore` (plain async functions taking a request object with the abort signal) and serves it with `createGraphStoreRouter`, which owns abort handling, error mapping and subscriptions. `checkStored`, `mergeBoundItems` and `overlayWithin` give every store the same write rules, and `structureOf`, `readyOf`, `blockedChainOf` and `impactOf` are the reference queries `createFakeGraphStore` uses. +An adapter implements `GraphStore` (plain async functions taking a request object with the abort signal) and serves it with `createGraphStoreRouter`, which owns abort handling, error mapping and subscriptions. `checkStored`, `mergeBoundItems`, `overlayWithin`, `mergeRelations`, `relationsWithin` and `relationIssues` give every store the same write rules, and `structureOf`, `readyOf`, `blockedChainOf`, `impactOf`, `childrenOf`, `descendantsOf` and `ancestorsOf` are the reference queries `createFakeGraphStore` uses. Every adapter runs the conformance kit, through a real oRPC client: diff --git a/packages/core/graph/store-contract/src/conformance-relations.ts b/packages/core/graph/store-contract/src/conformance-relations.ts new file mode 100644 index 0000000..fec6685 --- /dev/null +++ b/packages/core/graph/store-contract/src/conformance-relations.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "vitest"; +import { + type ClientFactory, + failureOf, + hierarchyItems, + withClient, +} from "./conformance-support.ts"; + +/** + * The relations' share of the conformance kit: what `items.upsert` stores beside a model, how writes keep it consistent, and the hierarchy queries over `child-of`. `graphStoreConformance` runs it inside its own `describe` block. + * @param createClient - Makes a client for an empty store under test. + */ +export const relationConformance = (createClient: ClientFactory): void => { + describe("relations", () => { + test( + "relations from items are kept beside the model, each distinct one once, in the order first stored", + withClient(createClient, async (client) => { + const [first, second, third, ...rest] = hierarchyItems.relations ?? []; + if ( + first === undefined || + second === undefined || + third === undefined + ) { + throw new Error( + "the hierarchy fixture holds at least three relations", + ); + } + await client.v1.items.upsert({ + modelId: "work", + items: { ...hierarchyItems, relations: [first, second] }, + }); + await client.v1.items.upsert({ + modelId: "work", + items: { + ...hierarchyItems, + relations: [second, third, ...rest, third], + }, + }); + + await expect( + client.v1.relations.list({ modelId: "work" }), + ).resolves.toEqual({ relations: [first, second, third, ...rest] }); + }), + ); + + test( + "a relation naming what the model does not declare, or an end of the wrong kind, is rejected whole", + withClient(createClient, async (client) => { + const { revision } = await client.v1.items.upsert({ + modelId: "work", + items: hierarchyItems, + }); + + await expect( + failureOf( + client.v1.items.upsert({ + modelId: "work", + items: { + ...hierarchyItems, + relations: [ + { + kind: "child-of", + from: "github:org/repo#2", + to: "github:org/repo#99", + }, + { + kind: "assigned-to", + from: "github:org/repo#2", + to: "github:org/repo#3", + }, + ], + }, + }), + ), + ).resolves.toMatchObject({ + code: "UNPROCESSABLE_CONTENT", + data: { + issues: [ + { + code: "unknown-reference", + message: + 'child-of relation to refers to unknown element id "github:org/repo#99"', + }, + { + code: "unknown-reference", + message: + 'assigned-to relation to refers to unknown participant id "github:org/repo#3"', + }, + ], + }, + }); + await expect( + client.v1.relations.list({ modelId: "work" }), + ).resolves.toEqual({ relations: hierarchyItems.relations }); + await expect(client.v1.models.list()).resolves.toEqual([ + { modelId: "work", revision }, + ]); + }), + ); + + test( + "run state leaves relations alone; saving a model keeps only the relations of what it still declares; deleting it removes them", + withClient(createClient, async (client) => { + await client.v1.items.upsert({ + modelId: "work", + items: hierarchyItems, + }); + await client.v1.overlay.set({ + modelId: "work", + overlay: { "github:org/repo#4": "done" }, + }); + await expect( + client.v1.relations.list({ modelId: "work" }), + ).resolves.toEqual({ relations: hierarchyItems.relations }); + + const { model } = await client.v1.models.load({ modelId: "work" }); + await client.v1.models.save({ + modelId: "work", + model: { + ...model, + participants: model.participants.filter( + ({ id }) => id !== "github:@ada", + ), + elements: model.elements.filter( + ({ id }) => id !== "github:org/repo#4", + ), + }, + }); + await expect( + client.v1.relations.list({ modelId: "work" }), + ).resolves.toEqual({ + relations: (hierarchyItems.relations ?? []).filter( + ({ from, to }) => + from !== "github:org/repo#4" && to !== "github:@ada", + ), + }); + + await client.v1.models.delete({ modelId: "work" }); + await client.v1.items.upsert({ + modelId: "work", + items: { ...hierarchyItems, relations: [] }, + }); + await expect( + client.v1.relations.list({ modelId: "work" }), + ).resolves.toEqual({ relations: [] }); + }), + ); + + test( + "children, descendants and ancestors follow child-of relations only, nearest first, in model order", + withClient(createClient, async (client) => { + await client.v1.items.upsert({ + modelId: "work", + items: hierarchyItems, + }); + const ask = { modelId: "work", element: "github:org/repo#1" }; + + await expect(client.v1.query.children(ask)).resolves.toEqual({ + children: ["github:org/repo#2", "github:org/repo#3"], + }); + await expect(client.v1.query.descendants(ask)).resolves.toEqual({ + descendants: [ + { element: "github:org/repo#2", distance: 1 }, + { element: "github:org/repo#3", distance: 1 }, + { element: "github:org/repo#4", distance: 2 }, + ], + }); + await expect( + client.v1.query.ancestors({ + modelId: "work", + element: "github:org/repo#4", + }), + ).resolves.toEqual({ + ancestors: [ + { element: "github:org/repo#2", distance: 1 }, + { element: "github:org/repo#1", distance: 2 }, + ], + }); + await expect(client.v1.query.ancestors(ask)).resolves.toEqual({ + ancestors: [], + }); + await expect( + client.v1.query.children({ + modelId: "work", + element: "github:org/repo#4", + }), + ).resolves.toEqual({ children: [] }); + }), + ); + + test( + "hierarchy walks terminate on a cycle of child-of relations, never reporting the element asked about", + withClient(createClient, async (client) => { + await client.v1.items.upsert({ + modelId: "work", + items: hierarchyItems, + }); + const ask = { modelId: "work", element: "github:org/repo#5" }; + + await expect(client.v1.query.descendants(ask)).resolves.toEqual({ + descendants: [{ element: "github:org/repo#6", distance: 1 }], + }); + await expect(client.v1.query.ancestors(ask)).resolves.toEqual({ + ancestors: [{ element: "github:org/repo#6", distance: 1 }], + }); + await expect(client.v1.query.children(ask)).resolves.toEqual({ + children: ["github:org/repo#6"], + }); + }), + ); + + test( + "relations and hierarchy queries about a missing model or element are NOT_FOUND", + withClient(createClient, async (client) => { + await client.v1.items.upsert({ + modelId: "work", + items: hierarchyItems, + }); + + await expect( + failureOf(client.v1.relations.list({ modelId: "missing" })), + ).resolves.toMatchObject({ + code: "NOT_FOUND", + data: { modelId: "missing" }, + }); + const participant = { modelId: "work", element: "github:@ada" }; + await Promise.all( + [ + client.v1.query.children(participant), + client.v1.query.descendants(participant), + client.v1.query.ancestors(participant), + ].map(async (query) => + expect(failureOf(query)).resolves.toMatchObject({ + code: "NOT_FOUND", + data: participant, + }), + ), + ); + }), + ); + }); +}; diff --git a/packages/core/graph/store-contract/src/conformance-support.ts b/packages/core/graph/store-contract/src/conformance-support.ts index 814e898..e1efb19 100644 --- a/packages/core/graph/store-contract/src/conformance-support.ts +++ b/packages/core/graph/store-contract/src/conformance-support.ts @@ -1,4 +1,5 @@ import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import type { GraphStoreClient } from "./contract.ts"; import type { BoundItems, StoreChange } from "./schema.ts"; /** The settled error of a call that must fail, so its code and data can be asserted. */ @@ -56,6 +57,46 @@ export const issueItems: BoundItems = { overlay: { "github:org/repo#1": "done" }, }; +/** An activity bound to its own identifier, as a source sends it. */ +const bound = (id: string, label: string) => + ({ + kind: "activity", + id, + label, + actor: "github:org/repo", + source: id, + }) as const; + +/** + * A work hierarchy as a source sends it: an epic with two children, one of which has a child of its own, an assignee, the repository inside its organisation, and a pair of activities each the parent of the other, so a walk over a cycle of `child-of` relations is exercised. + */ +export const hierarchyItems: BoundItems = { + participants: [ + { id: "github:@org", label: "org", kind: "system" }, + { id: "github:org/repo", label: "org/repo", kind: "system" }, + { id: "github:@ada", label: "ada", kind: "role" }, + ], + activities: [ + bound("github:org/repo#1", "Epic"), + bound("github:org/repo#2", "Store"), + bound("github:org/repo#3", "Client"), + bound("github:org/repo#4", "Migrations"), + bound("github:org/repo#5", "Loop one"), + bound("github:org/repo#6", "Loop two"), + ], + transitions: [], + overlay: {}, + relations: [ + { kind: "child-of", from: "github:org/repo#3", to: "github:org/repo#1" }, + { kind: "child-of", from: "github:org/repo#2", to: "github:org/repo#1" }, + { kind: "child-of", from: "github:org/repo#4", to: "github:org/repo#2" }, + { kind: "assigned-to", from: "github:org/repo#2", to: "github:@ada" }, + { kind: "part-of", from: "github:org/repo", to: "github:@org" }, + { kind: "child-of", from: "github:org/repo#5", to: "github:org/repo#6" }, + { kind: "child-of", from: "github:org/repo#6", to: "github:org/repo#5" }, + ], +}; + /** Reads a subscription up to and including the first event `last` accepts, then stops it. */ export const takeUntil = async ( events: Readonly>, @@ -125,3 +166,24 @@ export const silentLosses = ( return lost; }; + +/** Makes a client for a store with no models in it, and the function that closes it once a test ends. */ +export type ClientFactory = () => Promise<{ + readonly client: GraphStoreClient; + readonly close: () => Promise; +}>; + +/** Runs a test body against a fresh client and closes it afterwards, pass or fail. */ +export const withClient = + ( + createClient: ClientFactory, + body: (client: GraphStoreClient) => Promise, + ) => + async (): Promise => { + const { client, close } = await createClient(); + try { + await body(client); + } finally { + await close(); + } + }; diff --git a/packages/core/graph/store-contract/src/conformance.ts b/packages/core/graph/store-contract/src/conformance.ts index 7d299e5..9044f9e 100644 --- a/packages/core/graph/store-contract/src/conformance.ts +++ b/packages/core/graph/store-contract/src/conformance.ts @@ -1,8 +1,10 @@ import type { Model, OpenIds } from "@exadev/process-graph-schema"; import { describe, expect, test } from "vitest"; -import type { GraphStoreClient } from "./contract.ts"; +import { relationConformance } from "./conformance-relations.ts"; import { + type ClientFactory, failureOf, + withClient, firstOf, inSequence, issueItems, @@ -163,27 +165,9 @@ export interface GraphStoreConformanceOptions { /** * Makes a client for a store with no models in it. Called once per test, so every test starts empty and any transport can be used, in process or over a link. `close` is called when the test ends. */ - readonly createClient: () => Promise<{ - readonly client: GraphStoreClient; - readonly close: () => Promise; - }>; + readonly createClient: ClientFactory; } -/** Runs a test body against a fresh client and closes it afterwards, pass or fail. */ -const withClient = - ( - options: GraphStoreConformanceOptions, - body: (client: GraphStoreClient) => Promise, - ) => - async (): Promise => { - const { client, close } = await options.createClient(); - try { - await body(client); - } finally { - await close(); - } - }; - /** * The behaviour every `GraphStore` adapter must show, as Vitest tests. Call it at the top level of a test file; every test runs through the oRPC client the options create, so the kit checks the port as consumers see it, transport included. * @param options - How to make a client for an empty store under test. @@ -194,14 +178,14 @@ export const graphStoreConformance = ( describe(`GraphStore conformance: ${options.name}`, () => { test( "a new store lists no models", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await expect(client.v1.models.list()).resolves.toEqual([]); }), ); test( "a saved model loads back unchanged, every field and order kept", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const { revision } = await client.v1.models.save({ modelId: "orders", model: everyFieldModel, @@ -220,7 +204,7 @@ export const graphStoreConformance = ( test( "loading a model that was never saved is NOT_FOUND", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await expect( failureOf(client.v1.models.load({ modelId: "missing" })), ).resolves.toMatchObject({ @@ -232,7 +216,7 @@ export const graphStoreConformance = ( test( "an invalid model is rejected whole with its issues", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const duplicated: Model = { ...pairModel, elements: [...pairModel.elements, ...pairModel.elements], @@ -257,7 +241,7 @@ export const graphStoreConformance = ( test( "every write takes a later revision, store-wide, and list reports each model's last", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const first = await client.v1.models.save({ modelId: "b", model: pairModel, @@ -282,7 +266,7 @@ export const graphStoreConformance = ( test( "saving over a model replaces it and drops the run state of elements it no longer declares", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "pair", model: pairModel }); await client.v1.overlay.set({ modelId: "pair", @@ -311,7 +295,7 @@ export const graphStoreConformance = ( test( "an overlay sets the elements it names and leaves the rest", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "pair", model: pairModel }); await client.v1.overlay.set({ modelId: "pair", @@ -333,7 +317,7 @@ export const graphStoreConformance = ( test( "an overlay naming an element the model lacks is rejected and changes nothing", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const { revision } = await client.v1.models.save({ modelId: "pair", model: pairModel, @@ -365,7 +349,7 @@ export const graphStoreConformance = ( test( "an overlay for a model that does not exist is NOT_FOUND", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await expect( failureOf( client.v1.overlay.set({ @@ -382,7 +366,7 @@ export const graphStoreConformance = ( test( "deleting removes a model once, and it no longer loads", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const saved = await client.v1.models.save({ modelId: "pair", model: pairModel, @@ -404,7 +388,7 @@ export const graphStoreConformance = ( test( "upserting items into a missing model creates it from the items", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const { revision } = await client.v1.items.upsert({ modelId: "issues", items: issueItems, @@ -432,7 +416,7 @@ export const graphStoreConformance = ( test( "upserting replaces records with the same id in place and appends the rest", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const model: Model = { ...pairModel, elements: [ @@ -475,7 +459,7 @@ export const graphStoreConformance = ( test( "an upsert that would break the model is rejected and changes nothing", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const { revision } = await client.v1.models.save({ modelId: "pair", model: pairModel, @@ -520,7 +504,7 @@ export const graphStoreConformance = ( test( "a bound activity whose id is not its source is refused before it reaches the store", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await expect( failureOf( client.v1.items.upsert({ @@ -545,7 +529,7 @@ export const graphStoreConformance = ( test( "ready lists open work with nothing unfinished upstream, passing through control elements and loop edges", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "q", model: queryModel }); await client.v1.overlay.set({ modelId: "q", @@ -569,7 +553,7 @@ export const graphStoreConformance = ( test( "the blocked chain is the unfinished work upstream, nearest first, stopping at done work", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "q", model: queryModel }); await client.v1.overlay.set({ modelId: "q", @@ -597,7 +581,7 @@ export const graphStoreConformance = ( test( "impact is the unfinished work downstream, nearest first, whatever the element's own state", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "q", model: queryModel }); await client.v1.overlay.set({ modelId: "q", @@ -623,7 +607,7 @@ export const graphStoreConformance = ( test( "queries terminate on a cycle no decision closes, and never report the element asked about", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const cycle: Model = { ...pairModel, elements: [ @@ -665,7 +649,7 @@ export const graphStoreConformance = ( test( "a query about an element the model lacks is NOT_FOUND naming it", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "q", model: queryModel }); await expect( @@ -683,9 +667,11 @@ export const graphStoreConformance = ( }), ); + relationConformance(options.createClient); + test( "a write whose signal has already fired is refused and changes nothing", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const controller = new AbortController(); controller.abort(); @@ -701,7 +687,7 @@ export const graphStoreConformance = ( test( "a subscriber receives every later change to its model, in order, and none of another's", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "pair", model: pairModel }); const events = await client.v1.changes({ modelId: "pair" }); const received = takeUntil(events, ({ kind }) => kind === "deleted"); @@ -736,7 +722,7 @@ export const graphStoreConformance = ( test( "a subscriber that stops reading under sustained writes is never left unaware of a change it lost", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { await client.v1.models.save({ modelId: "pair", model: pairModel }); const events = await client.v1.changes({ modelId: "pair" }); // More writes than a subscription holds by default, so a store that buffers per subscription has to overflow; the property checked holds for any capacity. @@ -767,7 +753,7 @@ export const graphStoreConformance = ( test( "a subscriber that names a revision the model has moved past is told it missed changes", - withClient(options, async (client) => { + withClient(options.createClient, async (client) => { const first = await client.v1.models.save({ modelId: "pair", model: pairModel, diff --git a/packages/core/graph/store-contract/src/contract.ts b/packages/core/graph/store-contract/src/contract.ts index 09224b7..b38c125 100644 --- a/packages/core/graph/store-contract/src/contract.ts +++ b/packages/core/graph/store-contract/src/contract.ts @@ -7,6 +7,7 @@ import { modelIdSchema, modelSummarySchema, reachSchema, + relationSchema, revisionSchema, storedModelSchema, storeChangeSchema, @@ -41,7 +42,9 @@ const written = z.strictObject({ revision: revisionSchema }).readonly(); * - `models.list`, `models.save`, `models.load` and `models.delete` keep whole models. A save replaces the model and drops the run state of elements it no longer declares. * - `overlay.set` sets the run state of the elements it names. * - `items.upsert` merges items read from a source into a model by identifier, creating the model when it does not exist. + * - `relations.list` returns the relations kept beside a model, which `items.upsert` adds to. * - `query.ready`, `query.blockedChain` and `query.impact` answer structural questions from the stored transitions and run state alone (see the package README for exactly what they read), so a store can answer them without loading the model or evaluating conditions. + * - `query.children`, `query.descendants` and `query.ancestors` walk the stored `child-of` relations below and above an element. * - `changes` yields every later change to one model as an event iterator. A subscriber that falls behind by more than the store's subscription capacity gets one `missed` in place of the changes it let pile up, so a store's memory stays bounded and the subscriber knows to load the model again. * * Every write is atomic and checked: a change that would leave the model or overlay invalid (`validate`, `validateOverlay`) is rejected whole with `UNPROCESSABLE_CONTENT`. Every call takes the caller's abort signal through the oRPC client options; a call aborted before it writes leaves the store unchanged. @@ -87,6 +90,15 @@ const graphStoreV1Contract = oc.errors(graphStoreErrors).router({ ) .output(written), }, + relations: { + list: oc + .input(modelRef) + .output( + z + .strictObject({ relations: z.array(relationSchema).readonly() }) + .readonly(), + ), + }, query: { ready: oc .input(modelRef) @@ -105,6 +117,25 @@ const graphStoreV1Contract = oc.errors(graphStoreErrors).router({ .strictObject({ impacted: z.array(reachSchema).readonly() }) .readonly(), ), + children: oc + .input(elementRef) + .output( + z.strictObject({ children: z.array(z.string()).readonly() }).readonly(), + ), + descendants: oc + .input(elementRef) + .output( + z + .strictObject({ descendants: z.array(reachSchema).readonly() }) + .readonly(), + ), + ancestors: oc + .input(elementRef) + .output( + z + .strictObject({ ancestors: z.array(reachSchema).readonly() }) + .readonly(), + ), }, changes: oc .input( diff --git a/packages/core/graph/store-contract/src/fake.ts b/packages/core/graph/store-contract/src/fake.ts index eee03e8..68db092 100644 --- a/packages/core/graph/store-contract/src/fake.ts +++ b/packages/core/graph/store-contract/src/fake.ts @@ -1,13 +1,21 @@ import type { Model, OpenIds, Overlay } from "@exadev/process-graph-schema"; import { GraphStoreError } from "./errors.ts"; import { ChangeFeed } from "./feed.ts"; +import { + ancestorsOf, + childrenOf, + descendantsOf, + mergeRelations, + relationIssues, + relationsWithin, +} from "./hierarchy.ts"; import { checkStored, emptyModel, mergeBoundItems, overlayWithin, } from "./merge.ts"; -import type { ChangeKind, StoredModel } from "./schema.ts"; +import type { ChangeKind, Relation, StoredModel } from "./schema.ts"; import type { GraphStore } from "./store.ts"; import { blockedChainOf, impactOf, readyOf, structureOf } from "./structure.ts"; @@ -21,6 +29,7 @@ const later = interface Entry { readonly model: Model; readonly overlay: Overlay; + readonly relations: readonly Relation[]; readonly revision: number; } @@ -77,14 +86,26 @@ export const createFakeGraphStore = (): GraphStore => { readonly kind: ChangeKind; readonly model: Model; readonly overlay: Overlay; + readonly relations: readonly Relation[]; readonly signal: AbortSignal | undefined; } - const write = ({ modelId, kind, model, overlay, signal }: Write): number => { + const write = ({ + modelId, + kind, + model, + overlay, + relations, + signal, + }: Write): number => { checkStored(model, overlay); + const issues = relationIssues(model, relations); + if (issues.length > 0) { + throw new GraphStoreError({ kind: "invalid", issues }); + } signal?.throwIfAborted(); sequence += 1; - entries.set(modelId, { model, overlay, revision: sequence }); + entries.set(modelId, { model, overlay, relations, revision: sequence }); feed.publish({ modelId, kind, revision: sequence }); return sequence; @@ -97,15 +118,18 @@ export const createFakeGraphStore = (): GraphStore => { .map(([modelId, { revision }]) => ({ modelId, revision })) .sort((left, right) => byCodePoint(left.modelId, right.modelId)), ), - saveModel: later(({ modelId, model, signal }) => - write({ + saveModel: later(({ modelId, model, signal }) => { + const existing = entries.get(modelId); + + return write({ modelId, kind: "saved", model, - overlay: overlayWithin(model, entries.get(modelId)?.overlay ?? {}), + overlay: overlayWithin(model, existing?.overlay ?? {}), + relations: relationsWithin(model, existing?.relations ?? []), signal, - }), - ), + }); + }), loadModel: later(({ modelId }): StoredModel => { const { model, overlay, revision } = entryOf(modelId); @@ -130,19 +154,41 @@ export const createFakeGraphStore = (): GraphStore => { kind: "overlay", model: entry.model, overlay: { ...entry.overlay, ...overlay }, + relations: entry.relations, signal, }); }), upsertItems: later(({ modelId, items, signal }) => { - const entry = entries.get(modelId) ?? { model: emptyModel, overlay: {} }; + const entry = entries.get(modelId) ?? { + model: emptyModel, + overlay: {}, + relations: [], + }; return write({ modelId, kind: "items", ...mergeBoundItems(entry.model, entry.overlay, items), + relations: mergeRelations(entry.relations, items.relations ?? []), signal, }); }), + relations: later(({ modelId }) => entryOf(modelId).relations), + children: later(({ modelId, element }) => { + const { model, relations } = elementOf(modelId, element); + + return childrenOf(model, relations, element); + }), + descendants: later(({ modelId, element }) => { + const { model, relations } = elementOf(modelId, element); + + return descendantsOf(model, relations, element); + }), + ancestors: later(({ modelId, element }) => { + const { model, relations } = elementOf(modelId, element); + + return ancestorsOf(model, relations, element); + }), ready: later(({ modelId }) => { const { model, overlay } = entryOf(modelId); diff --git a/packages/core/graph/store-contract/src/hierarchy.ts b/packages/core/graph/store-contract/src/hierarchy.ts new file mode 100644 index 0000000..55c95b3 --- /dev/null +++ b/packages/core/graph/store-contract/src/hierarchy.ts @@ -0,0 +1,203 @@ +import type { Issue, Model, OpenIds } from "@exadev/process-graph-schema"; +import type { Reach, Relation, RelationKind } from "./schema.ts"; + +/** Which identifier space each end of a relation of each kind names: a sub-task and its parent are elements, an assignee and a containing organisation are participants. */ +const endpoints: Readonly< + Record +> = { + "child-of": { from: "element", to: "element" }, + "assigned-to": { from: "element", to: "participant" }, + "part-of": { from: "participant", to: "participant" }, +}; + +type Space = "element" | "participant"; + +/** The identity of a relation: one relation of a kind between two identifiers, however often it is reported. */ +const relationKey = (relation: Readonly): string => + JSON.stringify([relation.kind, relation.from, relation.to]); + +/** + * Adds relations to a stored list: one already there is kept in its place, and a new one is appended in its incoming order, each distinct relation once. + * @param current - The stored relations. + * @param incoming - The relations to add. + * @returns The merged relations. + */ +export const mergeRelations = ( + current: readonly Relation[], + incoming: readonly Relation[], +): readonly Relation[] => { + // A map keeps a key at its first insertion, so a relation already stored stays in its place and a repeat adds nothing. + const merged = new Map( + [...current, ...incoming].map((relation) => [ + relationKey(relation), + relation, + ]), + ); + + return [...merged.values()]; +}; + +/** The identifiers a model declares in each space a relation can name. */ +const spacesOf = ( + model: Model, +): Readonly>> => ({ + element: new Set(model.elements.map(({ id }) => id)), + participant: new Set(model.participants.map(({ id }) => id)), +}); + +/** + * Keeps only the relations both of whose ends the model still declares, in the space their kind names, so replacing a model drops the relations of what it no longer has. + * @param model - The model the relations are for. + * @param relations - The relations to narrow. + * @returns The relations whose ends are both declared. + */ +export const relationsWithin = ( + model: Model, + relations: readonly Relation[], +): readonly Relation[] => { + const spaces = spacesOf(model); + + return relations.filter( + (relation) => + spaces[endpoints[relation.kind].from].has(relation.from) && + spaces[endpoints[relation.kind].to].has(relation.to), + ); +}; + +/** + * The issues with a model's relations: an end that names nothing the model declares in the space its kind requires is an `unknown-reference`, as an unknown reference anywhere else in a model is. + * @param model - The model the relations are for. + * @param relations - The relations to check. + * @returns One issue per end that names nothing, empty when every end resolves. + */ +export const relationIssues = ( + model: Model, + relations: readonly Relation[], +): readonly Issue[] => { + const spaces = spacesOf(model); + + return relations.flatMap((relation) => + (["from", "to"] as const).flatMap((end) => { + const space = endpoints[relation.kind][end]; + const id = relation[end]; + + return spaces[space].has(id) + ? [] + : [ + { + code: "unknown-reference" as const, + message: `${relation.kind} relation ${end} refers to unknown ${space} id "${id}"`, + }, + ]; + }), + ); +}; + +/** + * Orders elements by their position in the model. Every element a hierarchy query reports is the end of a stored `child-of` relation, which `relationIssues` has checked the model declares. + * @throws Error for an element the model does not declare, which only a store that skipped that check can cause. + */ +const byModelOrder = ( + model: Model, +): ((left: string, right: string) => number) => { + const order = new Map(model.elements.map(({ id }, index) => [id, index])); + const positionOf = (element: string): number => { + const position = order.get(element); + if (position === undefined) { + throw new Error(`No element "${element}" in the model`); + } + + return position; + }; + + return (left, right) => positionOf(left) - positionOf(right); +}; + +/** Which way a hierarchy walk goes: from a parent to its children, or from a child to its parents. */ +type Direction = "down" | "up"; + +/** + * Breadth-first walk along `child-of` relations from `start`, with the fewest steps it takes to reach each element, by that distance and then model order. `start` itself is never reported, so a cycle of `child-of` relations through it ends the walk. + */ +const walk = ( + model: Model, + relations: readonly Relation[], + start: string, + direction: Direction, +): readonly Reach[] => { + const next = new Map(); + for (const { kind, from, to } of relations) { + if (kind === "child-of") { + const [key, value] = direction === "down" ? [to, from] : [from, to]; + next.set(key, [...(next.get(key) ?? []), value]); + } + } + const compare = byModelOrder(model); + const distance = new Map([[start, 0]]); + let frontier = [start]; + for (let depth = 1; frontier.length > 0; depth += 1) { + const reached = [ + ...new Set(frontier.flatMap((element) => next.get(element) ?? [])), + ].filter((element) => !distance.has(element)); + for (const element of reached) { + distance.set(element, depth); + } + frontier = reached; + } + distance.delete(start); + + return [...distance] + .map(([element, steps]) => ({ element, distance: steps })) + .sort( + (left, right) => + left.distance - right.distance || compare(left.element, right.element), + ); +}; + +/** + * The direct children of an element: every element related to it by `child-of`. + * @param model - The model, which declares `element`. + * @param relations - The model's relations. + * @param element - The parent. + * @returns The children, in model order. + */ +export const childrenOf = ( + model: Model, + relations: readonly Relation[], + element: string, +): readonly string[] => + [ + ...new Set( + relations + .filter(({ kind, to }) => kind === "child-of" && to === element) + .map(({ from }) => from), + ), + ] + .filter((child) => child !== element) + .sort(byModelOrder(model)); + +/** + * Everything beneath an element: its children, their children and so on, each with the fewest `child-of` steps between them. + * @param model - The model, which declares `element`. + * @param relations - The model's relations. + * @param element - The element at the top. + * @returns The descendants, by distance and then model order. + */ +export const descendantsOf = ( + model: Model, + relations: readonly Relation[], + element: string, +): readonly Reach[] => walk(model, relations, element, "down"); + +/** + * Everything above an element: its parents, their parents and so on, nearest first. + * @param model - The model, which declares `element`. + * @param relations - The model's relations. + * @param element - The element at the bottom. + * @returns The ancestors, by distance and then model order. + */ +export const ancestorsOf = ( + model: Model, + relations: readonly Relation[], + element: string, +): readonly Reach[] => walk(model, relations, element, "up"); diff --git a/packages/core/graph/store-contract/src/index.ts b/packages/core/graph/store-contract/src/index.ts index 44ed2e4..004c1df 100644 --- a/packages/core/graph/store-contract/src/index.ts +++ b/packages/core/graph/store-contract/src/index.ts @@ -12,6 +12,8 @@ export { modelIdSchema, modelSummarySchema, reachSchema, + relationKinds, + relationSchema, revisionSchema, storeChangeSchema, storedModelSchema, @@ -21,6 +23,8 @@ export { type ModelId, type ModelSummary, type Reach, + type Relation, + type RelationKind, type Revision, type StoreChange, type StoredModel, @@ -40,6 +44,14 @@ export { mergeBoundItems, overlayWithin, } from "./merge.ts"; +export { + ancestorsOf, + childrenOf, + descendantsOf, + mergeRelations, + relationIssues, + relationsWithin, +} from "./hierarchy.ts"; export { blockedChainOf, impactOf, diff --git a/packages/core/graph/store-contract/src/router.ts b/packages/core/graph/store-contract/src/router.ts index adb24b4..ed96810 100644 --- a/packages/core/graph/store-contract/src/router.ts +++ b/packages/core/graph/store-contract/src/router.ts @@ -99,6 +99,13 @@ export const createGraphStoreRouter = (store: GraphStore): GraphStoreRouter => })), ), }, + relations: { + list: os.v1.relations.list.handler(async ({ input, signal, errors }) => + call(signal, errors, async () => ({ + relations: await store.relations({ ...input, signal }), + })), + ), + }, query: { ready: os.v1.query.ready.handler(async ({ input, signal, errors }) => call(signal, errors, async () => ({ @@ -116,6 +123,24 @@ export const createGraphStoreRouter = (store: GraphStore): GraphStoreRouter => impacted: await store.impact({ ...input, signal }), })), ), + children: os.v1.query.children.handler( + async ({ input, signal, errors }) => + call(signal, errors, async () => ({ + children: await store.children({ ...input, signal }), + })), + ), + descendants: os.v1.query.descendants.handler( + async ({ input, signal, errors }) => + call(signal, errors, async () => ({ + descendants: await store.descendants({ ...input, signal }), + })), + ), + ancestors: os.v1.query.ancestors.handler( + async ({ input, signal, errors }) => + call(signal, errors, async () => ({ + ancestors: await store.ancestors({ ...input, signal }), + })), + ), }, changes: os.v1.changes.handler(async ({ input, signal, errors }) => { // Subscribe before anything is awaited and before the iterator is returned: a caller that has the iterator in hand is already subscribed, over any transport, so a write it makes next is never missed. diff --git a/packages/core/graph/store-contract/src/schema.ts b/packages/core/graph/store-contract/src/schema.ts index 45ebf37..c865fbe 100644 --- a/packages/core/graph/store-contract/src/schema.ts +++ b/packages/core/graph/store-contract/src/schema.ts @@ -29,8 +29,22 @@ export const boundActivitySchema = activityElementSchema }) .readonly(); +/** The relation kinds a store keeps beside a model, the same kinds a source reports. */ +export const relationKinds = ["child-of", "assigned-to", "part-of"] as const; + +/** + * A structural link the model has no field for, kept beside it: `child-of` from a sub-task element to its parent element, `assigned-to` from an element to a person participant, `part-of` from one participant to the participant that contains it (a repository to its organisation). A relation is identified by its kind and its two ends. Relations carry no ordering, so the structural queries never treat a parent as waiting on its children. + */ +export const relationSchema = z + .strictObject({ + kind: z.enum(relationKinds), + from: z.string().min(1), + to: z.string().min(1), + }) + .readonly(); + /** - * Items read from a source, to be merged into a stored model by identifier. Each participant, activity and transition replaces the one with the same id or is appended after the existing ones; the overlay's entries replace the run state of the elements they name. Everything else in the model is left as it is. + * Items read from a source, to be merged into a stored model by identifier. Each participant, activity and transition replaces the one with the same id or is appended after the existing ones; the overlay's entries replace the run state of the elements they name; each relation not already stored is appended. Everything else in the model is left as it is. */ export const boundItemsSchema = z .strictObject({ @@ -38,6 +52,7 @@ export const boundItemsSchema = z activities: z.array(boundActivitySchema).readonly(), transitions: z.array(modelShape.transitions.unwrap().element).readonly(), overlay: overlaySchema, + relations: z.array(relationSchema).readonly().exactOptional(), }) .readonly(); @@ -89,6 +104,8 @@ export type ModelId = z.infer; export type Revision = z.infer; export type BoundActivity = z.infer; export type BoundItems = z.infer; +export type Relation = z.infer; +export type RelationKind = (typeof relationKinds)[number]; export type StoredModel = z.infer; export type ModelSummary = z.infer; export type Reach = z.infer; diff --git a/packages/core/graph/store-contract/src/store.ts b/packages/core/graph/store-contract/src/store.ts index 500b828..e2995dd 100644 --- a/packages/core/graph/store-contract/src/store.ts +++ b/packages/core/graph/store-contract/src/store.ts @@ -4,6 +4,7 @@ import type { BoundItems, ModelSummary, Reach, + Relation, Revision, StoredModel, } from "./schema.ts"; @@ -23,7 +24,7 @@ export interface ElementRequest extends ModelRequest { /** * What a store adapter implements. `createGraphStoreRouter` builds the whole port on it, so validation, error mapping, abort handling and change subscriptions behave identically for every store. * - * Every write is atomic: it checks the result with `checkStored` and throws `GraphStoreError` with `invalid` before anything is visible, takes the next store-wide revision, and publishes the change to `feed` after it commits. A read or query of a model that does not exist, or of an element the model does not declare, throws `GraphStoreError` with `not-found`. + * Every write is atomic: it checks the result with `checkStored` (and `relationIssues` for the relations it stores) and throws `GraphStoreError` with `invalid` before anything is visible, takes the next store-wide revision, and publishes the change to `feed` after it commits. A read or query of a model that does not exist, or of an element the model does not declare, throws `GraphStoreError` with `not-found`. */ export interface GraphStore { /** The feed this store publishes its committed changes to. */ @@ -36,7 +37,7 @@ export interface GraphStore { readonly signal: AbortSignal | undefined; }) => Promise; /** - * Stores a model under an id, replacing any model stored there and dropping the run state of elements the new model does not declare. + * Stores a model under an id, replacing any model stored there and dropping the run state and relations of what the new model does not declare (see `relationsWithin`). * @returns The revision the save took. */ readonly saveModel: ( @@ -48,7 +49,7 @@ export interface GraphStore { */ readonly loadModel: (request: ModelRequest) => Promise; /** - * Removes a model and its run state. + * Removes a model, its run state and its relations. * @returns The revision the deletion took, or `undefined` when there was no such model and nothing changed. */ readonly deleteModel: ( @@ -62,12 +63,32 @@ export interface GraphStore { request: ModelRequest & { readonly overlay: Overlay }, ) => Promise; /** - * Merges items read from a source into a model by id (see `mergeBoundItems`), creating the model from an empty one when it does not exist. + * Merges items read from a source into a model by id (see `mergeBoundItems`), and adds their relations (see `mergeRelations`), creating the model from an empty one when it does not exist. Every relation stored must name what the merged model declares (`relationIssues`); otherwise nothing is written. * @returns The revision the change took. */ readonly upsertItems: ( request: ModelRequest & { readonly items: BoundItems }, ) => Promise; + /** + * The relations kept beside a model. + * @returns The relations, in the order they were first stored. + */ + readonly relations: (request: ModelRequest) => Promise; + /** + * The direct children of an element (see `childrenOf`). + * @returns The children, in model order. + */ + readonly children: (request: ElementRequest) => Promise; + /** + * Everything beneath an element along `child-of` relations (see `descendantsOf`). + * @returns The descendants, by distance and then model order. + */ + readonly descendants: (request: ElementRequest) => Promise; + /** + * Everything above an element along `child-of` relations (see `ancestorsOf`). + * @returns The ancestors, by distance and then model order. + */ + readonly ancestors: (request: ElementRequest) => Promise; /** * The work that can start now (see `readyOf`). * @returns The ready elements, in model order.