Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,16 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config

## Packages

| Package | What it is |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. |
| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. |
| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. |
| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |
| Package | What it is |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. |
| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. |
| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. |
| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. |
| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |

## Conventions

Expand Down
4 changes: 4 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const config: KnipConfig = {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/adapters/*/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/targets/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
Expand Down
1 change: 1 addition & 0 deletions packages/adapters/graph/source-github-issues/AGENTS.md
1 change: 1 addition & 0 deletions packages/adapters/graph/source-github-issues/CLAUDE.md
95 changes: 95 additions & 0 deletions packages/adapters/graph/source-github-issues/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# @exadev/process-graph-source-github-issues

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-source-github-issues) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

The GitHub issues adapter for the `GraphSource` port in `@exadev/process-graph-source-contract`. It reads issues, their blockers and sub-issues through GitHub's GraphQL API across any number of organisations and repositories, and returns them as pages of the process model. It passes the port's conformance kit. The main entry point imports nothing Node-only; `./node` adds a token helper for Node.

```ts
import { createRouterClient } from "@orpc/server";
import {
assembleSync,
createGraphSourceRouter,
} from "@exadev/process-graph-source-contract";
import { createGitHubIssuesSource } from "@exadev/process-graph-source-github-issues";
import { githubTokenFromEnvironment } from "@exadev/process-graph-source-github-issues/node";

const client = createRouterClient(
createGraphSourceRouter(
createGitHubIssuesSource({ token: githubTokenFromEnvironment() }),
),
);

const pages = [];
for await (const page of await client.v1.sync.stream({
scope: ["github:ExaDev", "github:Mearman/agent-permissions"],
})) {
pages.push(page);
}
const { model, overlay, relations, gaps } = assembleSync(pages);
```

## Scopes

| Scope | Reads |
| ------------------- | -------------------------------------------------------------------- |
| `github:owner/repo` | One repository's issues. |
| `github:owner` | The issues of every repository the account owns, by repository name. |

Scopes are read in the order given, each once. A repository or account that does not exist, or that the token cannot see, is a gap on that scope and the sync carries on; anything else that is not one of the two forms is a `BAD_REQUEST` with reason `invalid-scope`.

## The model it produces

| GitHub | Model |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| Issue `owner/repo#n` | A bound activity `github:owner/repo#n`, labelled with its title, its actor the repository |
| Open or closed | `open` or `done` in the overlay |
| `blockedBy` | A transition from the blocker to the issue it blocks, `<blocker>-><issue>` |
| Sub-issue | A `child-of` relation from the sub-issue to its parent |
| Assignee | A `role` participant `github:@login` and an `assigned-to` relation from the issue |
| Repository | A `system` participant `github:owner/repo`, `part-of` its owner |
| Organisation or user account | A participant `github:@login`: an organisation is a `system`, a user a `role` |

Blockers and sub-issues in other repositories or organisations are included with their own repository and owner, so a dependency across organisations is a transition between their activities, and a hand-off between actors in the model. Every connection is read to its end: when an issue has more assignees, blockers or sub-issues than one request returns, the rest are read on their own.

## Paging and resuming

A page is one page of one repository's issues, oldest first. The cursor in a page's `next` records the scope, the position in an owner's repository listing and GitHub's own cursor into the issues, as plain data, so a sync resumes in another process. Issues are listed by creation time and repositories by name, so a position stays valid as new issues are opened. If an owner has fewer repositories than when the cursor was issued, the sync carries on after that listing page.

## Errors and rate limits

| GitHub's answer | Result |
| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| HTTP 401 | `UNAUTHORIZED` |
| HTTP 429, HTTP 403 with no requests remaining or naming a secondary rate limit, or a `RATE_LIMITED` GraphQL error | `TOO_MANY_REQUESTS` with `resetAt` when GitHub gives one (`x-ratelimit-reset`, or `retry-after` from the injected clock) |
| A `NOT_FOUND` or `FORBIDDEN` GraphQL error | A gap, with GitHub's message |
| Anything else | An error that ends the call |

A rate-limited sync resumes from the last page's `next` once the limit resets.

## Tokens

The token comes from a `GitHubTokenPort`, `(owner, signal) => Promise<string>`, asked for the owner of each repository read, so different organisations can use different credentials. The token is sent only in the `Authorization` header and never logged or put in an error.

`githubTokenFromEnvironment()` from `./node` uses `GITHUB_TOKEN` when it is set and not empty, and otherwise asks the GitHub CLI once (`gh auth token`). A failure is reported as `unauthorised` without the CLI's output.

## Options

| Option | Default | Meaning |
| ---------- | ----------------------------- | -------------------------------------------------- |
| `token` | required | The token port. |
| `fetch` | the global `fetch` | How requests reach GitHub. |
| `now` | `Date.now` | The clock, for `retry-after`. |
| `pageSize` | half of GitHub's limit of 100 | Issues per page, and connection items per request. |

## Tests

The offline tests replay GraphQL exchanges recorded from public repositories (`src/test-support/fixtures/github.json`): GitHub's demo organisation as an owner scope, and two repositories whose issues block each other across repositories and organisations. The fixture holds request documents, variables, statuses and bodies, never headers or tokens. Re-record it after a build with `node scripts/record-fixtures.ts`, with a token in `GITHUB_TOKEN` or the GitHub CLI logged in.

The live tests run the conformance kit and a cross-organisation sync against the real API when `PROCESS_GRAPH_LIVE_GITHUB=1` is set, and are reported as skipped otherwise.

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests, including the conformance kit over the recorded fixtures
PROCESS_GRAPH_LIVE_GITHUB=1 pnpm test:integration # the live tests
pnpm test:smoke # the built dist/ exercised as a real artefact
```
11 changes: 11 additions & 0 deletions packages/adapters/graph/source-github-issues/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
// The Node entry point: it runs the GitHub CLI for a token, so it is Node-only by purpose and published as its own `./node` export.
isomorphicExemptions: ["src/node.ts"],
}),
);
81 changes: 81 additions & 0 deletions packages/adapters/graph/source-github-issues/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"name": "@exadev/process-graph-source-github-issues",
"version": "0.0.0",
"description": "The GitHub issues GraphSource adapter: issues as bound activities, blockedBy as transitions, sub-issues, repositories, organisations and assignees, read through the GraphQL API with pagination, rate-limit handling and typed errors across organisations and repositories.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/adapters/graph/source-github-issues"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/adapters/graph/source-github-issues#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"registry": "https://registry.npmjs.org/"
},
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:integration": "turbo run _test:integration",
"_test:integration": "vitest run --project integration",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only"
},
"keywords": [
"process-model",
"github",
"issues",
"graphql",
"dependencies",
"orpc"
],
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@types/node": "26.6.4",
"@vitest/coverage-v8": "5.0.3",
"eslint": "10.12.0",
"publint": "0.3.25",
"typescript": "6.0.3",
"vitest": "5.0.3"
},
"dependencies": {
"@exadev/process-graph-source-contract": "1.0.0",
"zod": "4.6.5"
}
}
Loading
Loading