@osdlc/typed-gh 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,64 @@
1
- # Changelog
1
+ # @osdlc/typed-gh
2
+
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d9b0f77: **Breaking:** the plain door throws the classified failure. A failed `gh` (a mutation, `prDiff`, `rawChecked`, a query) now throws a `FailureError` whose `failure` is the `GhFailure` and whose `failure.cause` is the `CommandFailed`, where it threw the `CommandFailed` itself. The bare `CommandFailed` only had command-seam's generic `exit` kind, so a signed-out `gh` during a write exited 1 from `runMain` instead of 23. Read `error.failure.argv` and `error.failure.cause.result` where you read `error.argv` and `error.result`.
8
+
9
+ **Breaking:** the single-thing queries no longer treat every `Could not resolve to a …` as absence. `prView` answers `null` only for a missing pull request, `issueView` only for a missing issue and `repoView` only for a missing repository; a mistyped `--repo` is now a `not-found` failure, and a bare `HTTP 404` is a failure. The absence test reads stderr and stdout, as the classifier does.
10
+
11
+ An `HTTP 403` is now `not-authenticated` (SAML, a missing scope), the same as Forgejo's 401 and 403, unless the text says it is a rate limit, which stays `rate-limited`. The `try` and `soft` doors recognise a thrown failure by shape, so two installed copies of `@osdlc/command-seam` or `@osdlc/result` agree.
12
+ - d9b0f77: `Gh` and `AsyncGh` get `typed-git`'s three doors. **Breaking:** a query no longer answers `null` for a failure. The plain method returns its value or throws `CommandFailed` (a `FailureError` carrying a `GhFailure` of kind `unreadable` when `gh` exited 0 but its output could not be read). A query for one thing (`prView`, `issueView`, `repoView`, and `prFind`, `openPrFor`, `prViewSettled` over them) still answers `null` when GitHub says there is no such thing. `prList`, `prBody`, `graphql`, `labelList`, `issueList`, `repoList`, `searchPrs` and `prReviewThreads` now return a non-null value and throw. `installed`, `authenticated`, `authStatus`, `ready` and `api` are unchanged: their failure is part of their value.
13
+
14
+ New: `gh.try.*` returns `Result<T, GhFailure>` from `@osdlc/result`; `gh.soft.*` returns `null` for a failed query, which is the old query behaviour, now opt-in (`TriedSync`, `TriedAsync`, `Soft`). `GhFailure` carries a `kind`, a one-line `message`, a `hint` for a person, `detail`, `argv`, `exitCode` and the `CommandFailed` as `cause`. `GhFailureKind` is every `@osdlc/command-seam` kind plus `not-authenticated`, `network`, `not-found`, `rate-limited` and `unreadable`, read from `gh`'s stderr. `classifyGhFailure(commandFailed)` is exported. `@osdlc/result` is now a dependency.
15
+
16
+ ### Patch Changes
17
+
18
+ - fb712e0: Ship the compiled `dist/` only. The `bun` export condition and the TypeScript source are no longer in the package, so Bun, Node and every bundler load the same compiled ESM and `.d.ts` files. Bun still works through the compiled `dist/`.
19
+ - fb712e0: Make every package follow the same conventions. The published manifests and READMEs change; the runtime code and the `dist/` files do not.
20
+
21
+ - `author` is `sksizer` in every manifest (`@osdlc/typed-gh` had none).
22
+ - `engines.node` is no wider than any dependency's. `@osdlc/typed-gh` and `@osdlc/code-host` now declare `^22.22.2 || ^24.15.0 || >=26.0.0`, the range of `@osdlc/typed-git` and its dependency `hosted-git-info`. `@osdlc/cli-tool` and `@osdlc/trust-repo` declare `>=22`, not `>=22.18`: a `dist/` install needs no TypeScript stripping.
23
+ - `@osdlc/tsconfig` and `@osdlc/prettier-config` are ESM with `sideEffects: false`, and ship SECURITY.md and CONTRIBUTING.md. `@osdlc/prettier-config` has an `exports` map (`.` is `index.json`) in place of `main`.
24
+ - Every README has `## Install` and `## Licence` sections, and every CHANGELOG starts `# @osdlc/<name>`.
25
+ - `@osdlc/color` and `@osdlc/markdown-contract` no longer set a coverage threshold.
26
+ - `@osdlc/yaml-splice` reaches its WebAssembly glue through a `#wasm` entry in `imports`. The shipped `dist/` files are the same.
27
+ - Updated dependencies [d9b0f77]
28
+ - Updated dependencies [d9b0f77]
29
+ - Updated dependencies [fb712e0]
30
+ - Updated dependencies [fb712e0]
31
+ - Updated dependencies [d9b0f77]
32
+ - Updated dependencies [d9b0f77]
33
+ - Updated dependencies [d9b0f77]
34
+ - Updated dependencies [d9b0f77]
35
+ - @osdlc/command-seam@0.9.0
36
+ - @osdlc/typed-git@0.6.0
37
+ - @osdlc/result@0.1.0
38
+
39
+ ## 0.6.0
40
+
41
+ ### Minor Changes
42
+
43
+ - d558059: Reach a repository without a clone, find a pull request from what a person typed, read every page of a list, and list repositories and search pull requests. Some changes break callers of 0.5. They are marked `Breaking:`.
44
+
45
+ - `Gh` and `AsyncGh` take optional `repo` (`owner/name`) and `hostname` options, and `withRepo(repo)` returns the same client aimed at another repository. The target goes into each call's argv: `--repo host/owner/name` on `pr`, `issue` and `label` calls, `--hostname` and a filled `{owner}/{repo}` on `api` calls, and `--hostname` on `authStatus`. Every verb builder takes an optional `GhTarget` as its last argument. An invalid `repo` or `hostname` throws `RangeError`.
46
+ - `hostname` is any GitHub host: `github.com`, GitHub Enterprise Server, or a `<tenant>.ghe.com` host.
47
+ - Breaking: `parsePrUrl` accepts any GitHub host and returns `hostname` in its `PullRequestRef`. Before, it returned null for a host other than `github.com`.
48
+ - `prView` takes a number, a branch or a URL. A URL is passed without the client's target. A string that starts with `-` throws `RangeError`.
49
+ - `prFind(input)` returns the one PR that `input` names, and `parsePrRef(input)` reads it without running `gh`: `42`, `#42`, `owner/name#42`, a URL, or a branch name. Bare digits are a number first and then a branch. A branch gives its open PR, else its newest closed or merged one.
50
+ - Breaking: `prList({ head })` matches the head branch exactly, through `gh pr list --head`. Before, it passed `--search head:<branch>`, which matched by prefix.
51
+ - Breaking: `prList`, `issueList` and `labelList` return `{ rows, truncated } | null` instead of an array or null. Each asks `gh` for one row more than `limit`, and `truncated` is true when it came back. A `limit` that is not a positive integer throws `RangeError`. `openPrsForBranch` returns the same shape. The new `ListResult` type names it.
52
+ - `api` takes `paginate: true` to fetch every page; pages of arrays are joined into one `body`. With `etag` or `includeHeaders` it throws `RangeError`. An endpoint that starts with `-` throws `RangeError`.
53
+ - `graphql(query, variables, { paginate })` runs a GraphQL query and returns each page's `data`, or null when the call failed or any page has `errors` or no `data`. It refuses a mutation. `nodesAt(pages, path)` joins one connection's nodes across pages. Both are experimental, with `GraphqlOptions` and `GraphqlVariable`.
54
+ - Every `GhVerb` carries `readOnly`: true for every query and `prDiff`, false for every mutation. `GhVerb` also has an optional `env`.
55
+ - `repoList(owner)` and `repoView(repo?)` return the new `Repository` type. `searchPrs(query)` returns `PrSearchHit` rows, since `gh search prs` has its own field set. `REPO_FIELDS` and `SEARCH_PR_FIELDS` name their fields. `gh repo list` and `gh search` take no host flag, so the host reaches them as `GH_HOST`.
56
+ - New types: `GhTarget`, `GhOptions`, `AsyncGhOptions`, `ListResult`, `PrRef`, `Repository`, `RepositoryVisibility`, `PrSearchHit`, `RepoListOptions` and `SearchPrsOptions`.
57
+
58
+ ### Patch Changes
59
+
60
+ - Updated dependencies [4d13bd9]
61
+ - @osdlc/typed-git@0.5.3
2
62
 
3
63
  ## 0.5.0
4
64
 
package/CONTRIBUTING.md CHANGED
@@ -39,8 +39,9 @@ pnpm --filter @osdlc/typed-gh smoke
39
39
 
40
40
  ## Running the tests
41
41
 
42
- [Bun](https://bun.sh) is only the test runner. pnpm installs and
43
- runs the scripts, and the `test` script runs `bun test`. The tests run offline: a fake
42
+ pnpm installs and runs the scripts, and the `test` script runs Vitest under
43
+ Node. Build first, since the workspace packages resolve each other through
44
+ `dist/`. The tests run offline: a fake
44
45
  runner stands in for `gh`, so they need no network, no login and no `gh`
45
46
  binary.
46
47
 
@@ -71,4 +72,4 @@ version and writes the CHANGELOG from the pending changesets. See
71
72
  call.
72
73
  - Add a changeset for a change users can see. See above.
73
74
  - Record a design choice in
74
- [`docs/decisions/`](https://github.com/osdlc/osdlc/tree/main/packages/ts/typed-gh/docs/decisions).
75
+ [`docs/planning/decisions/`](https://github.com/osdlc/osdlc/tree/main/packages/ts/typed-gh/docs/planning/decisions).
package/README.md CHANGED
@@ -15,7 +15,9 @@ scripts, release bots and coding agents. It is the GitHub-side companion of
15
15
  npm install @osdlc/typed-gh
16
16
  ```
17
17
 
18
- It needs Node 22 or later (or Bun), and the `gh` CLI on `PATH`, signed in with `gh auth login`.
18
+ `pnpm add @osdlc/typed-gh` and `yarn add @osdlc/typed-gh` work too.
19
+
20
+ It needs Node `^22.22.2 || ^24.15.0 || >=26.0.0`, the range of its dependency `@osdlc/typed-git` (Bun also works, through the compiled `dist/`), and the `gh` CLI on `PATH`, signed in with `gh auth login`.
19
21
  It is supported on macOS and Linux. Windows is not supported yet; it will be once a Windows CI job
20
22
  runs the tests.
21
23
 
@@ -25,7 +27,7 @@ import { Gh, type PrListOptions, type PullRequest } from '@osdlc/typed-gh'
25
27
  const gh = new Gh(process.cwd())
26
28
 
27
29
  const filter: PrListOptions = { state: 'open', base: 'main' }
28
- const prs: PullRequest[] = gh.prList(filter) ?? []
30
+ const prs: PullRequest[] = gh.prList(filter)?.rows ?? []
29
31
  ```
30
32
 
31
33
  The `examples` folder in the repository runs the same calls as tests.
@@ -36,8 +38,8 @@ The `examples` folder in the repository runs the same calls as tests.
36
38
  Octokit.
37
39
  - **You sign in with a token, a GitHub App or OAuth.** This package reads no token.
38
40
  - **You make many calls or poll in a loop.** Each call starts a `gh` process.
39
- - **You need the whole API typed.** This package covers the calls listed below. `api()` is a GET.
40
- Anything else goes through `raw`.
41
+ - **You need the whole API typed.** This package covers the calls listed below. `api()` is a GET
42
+ and `graphql()` is a query. Anything else goes through `raw`.
41
43
 
42
44
  ## How it compares
43
45
 
@@ -88,17 +90,138 @@ and `ReviewDecision` (`APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`,
88
90
  `null`). A value GitHub adds to either reads as `UNKNOWN` or `null`. A check
89
91
  that decides whether to merge can then match every member.
90
92
 
91
- ## Three modes, so failure is never ambiguous
93
+ ## Three doors, as in `typed-git`, so failure is never ambiguous
94
+
95
+ | | a value | on failure |
96
+ | --- | --- | --- |
97
+ | **`gh.query()`** (the plain method) | the value | throws a `FailureError` carrying the classified `GhFailure` (kind `unreadable` when `gh` succeeded but its output cannot be read) |
98
+ | **`gh.try.query()`** | `{ ok: true, value }` | `{ ok: false, error: GhFailure }` |
99
+ | **`gh.soft.query()`** | the value | `null` (queries only; a mutation has no soft form) |
100
+ | **`raw(argv)`** | the raw `CommandResult`; you decide | the same |
101
+
102
+ A query returns a list or a value, never an empty list on failure.
103
+ `prList()` returning `{ rows: [], truncated: false }` means *this repository
104
+ has no open PRs*. A failed call throws, or is `{ ok: false }` through `try`, or
105
+ `null` through `soft`: *we could not ask*. If the two were the same, an
106
+ expired credential would read as "nothing to do".
107
+
108
+ **Absence is an answer.** A query for one thing (`prView`, `issueView`,
109
+ `repoView`, and `prFind` and `openPrFor` over them) returns `null` on every
110
+ door when GitHub says there is no such thing: "Could not resolve to a
111
+ PullRequest" or "no pull requests found for branch" for `prView`, "Could not
112
+ resolve to an Issue" for `issueView`, "Could not resolve to a Repository" for
113
+ `repoView`. `gh.try.prView(99)` is `{ ok: true, value: null }`. Every other
114
+ failure is a failure, including a wrong repository: `prView(7)` against a
115
+ mistyped `--repo` is a `not-found` failure, not an absent PR. The check reads
116
+ stderr and stdout, as the classifier does.
117
+
118
+ **Probes answer.** `installed()`, `authenticated()`, `authStatus()`, `ready()`
119
+ and `api()` return their failure as part of their value, in every door, because
120
+ "gh is not installed" is the answer to the question they ask.
121
+
122
+ ```ts
123
+ import { describe } from '@osdlc/result'
124
+
125
+ const result = gh.try.prList({ limit: 50 })
126
+ if (!result.ok) {
127
+ console.error(describe(result.error)) // e.g. "gh is not signed in to GitHub. Run `gh auth login`."
128
+ }
129
+ ```
130
+
131
+ ### `GhFailure`
132
+
133
+ The error of `try` is a `GhFailure`, a `Failure` from `@osdlc/result`: a `kind`
134
+ to branch on, a one-line `message` and a `hint` for a person (`describe(error)`
135
+ joins them), `gh`'s own output in `detail`, the `argv` and `exitCode`, and the
136
+ `CommandFailed` it was read from in `cause`. The plain door throws the same
137
+ failure, wrapped in a `FailureError` (`error.failure`), so a CLI that reports a
138
+ thrown failure, such as `runMain` from `@osdlc/cli-tool`, shows "gh is not
139
+ signed in" for a write and exits with the same code as for a read.
140
+
141
+ | `kind` | When (gh's stderr) | `message` / `hint` |
142
+ | --- | --- | --- |
143
+ | `not-installed` | `gh` is not on `PATH` | `` `gh` is not installed or not on PATH. `` / ``Install it from https://cli.github.com, then run `gh auth login`.`` |
144
+ | `not-authenticated` | `gh auth login`, `HTTP 401`, `HTTP 403` (unless a rate limit), `Bad credentials`, `not logged in` | `gh is not signed in to GitHub.` / ``Run `gh auth login`.`` |
145
+ | `network` | `error connecting to`, `Could not resolve host`, `no such host`, `dial tcp`, `i/o timeout` | `GitHub could not be reached.` / `Check your network connection.` |
146
+ | `not-found` | `Could not resolve to a …`, `HTTP 404`, `no pull requests found for branch` | `GitHub has no such pull request, issue or repository, or you cannot see it.` / `Check the number or name, and that your account can see the repository.` |
147
+ | `rate-limited` | `API rate limit exceeded`, `secondary rate limit`, `HTTP 429` | `GitHub's API rate limit was reached.` / `Wait a few minutes and try again.` |
148
+ | `unreadable` | `gh` exited 0 but its output could not be read completely | `gh answered, but its output could not be read completely.` (no hint, no `cause`) |
149
+ | `not-executable`, `bad-cwd`, `timeout`, `output-cap`, `signal`, `aborted`, `spawn` | as in `@osdlc/command-seam` | command-seam's message, no hint |
150
+ | `exit` | any other non-zero exit | `gh`'s first stderr line |
151
+
152
+ The patterns are `gh`'s English messages; anything else is a plain `exit`,
153
+ never a wrong kind. Rate limiting is checked before authentication, then
154
+ network, then not-found. `classifyGhFailure(commandFailed)` makes the same
155
+ classification for a `CommandFailed` you caught yourself.
156
+
157
+ ## A list says when its limit cut it short
158
+
159
+ `gh --limit` cuts a list without saying so. Every list query here asks for
160
+ one row more than `limit` and returns `{ rows, truncated }`: at most `limit`
161
+ rows, and `truncated: true` when there were more.
162
+
163
+ ```ts
164
+ const page: ListResult<PullRequest> = gh.prList({ limit: 50 })
165
+
166
+ if (page.truncated) console.warn('more than 50 open PRs; raise the limit')
167
+ ```
168
+
169
+ `prList`, `issueList`, `labelList`, `repoList` and `searchPrs` all work this
170
+ way. A `limit` that is not a positive integer throws `RangeError`.
171
+
172
+ ## A repository without a clone
92
173
 
93
- | | on failure |
94
- | --- | --- |
95
- | **queries** | return the absence value, `null`. Never throw. |
96
- | **mutations** | throw `CommandFailed`, carrying argv and the full result. |
97
- | **`raw(argv)`** | hand back the raw `CommandResult`; you decide. |
174
+ By default a client talks to the repository of the clone at its directory.
175
+ Name a repository, a host, or both, and every call targets them instead, so
176
+ no clone is needed:
177
+
178
+ ```ts
179
+ const acme = new Gh(process.cwd(), { repo: 'acme/widgets', hostname: 'github.acme.com' })
180
+
181
+ acme.prView(42)
182
+ acme.withRepo('acme/gadgets').prList()
183
+ acme.api('repos/{owner}/{repo}/branches')
184
+ ```
185
+
186
+ The target goes into the argv, so a failure's `argv` shows which repository a
187
+ call hit: `pr`, `issue` and `label` calls get `--repo github.acme.com/acme/widgets`,
188
+ `api` calls get `--hostname` and a filled `{owner}/{repo}`, and `authStatus`
189
+ asks about that host. `repoList` and `searchPrs` have no host flag in gh, so
190
+ they get `GH_HOST` in the environment. `withRepo` and `at` keep the runner and
191
+ the rest of the target.
192
+
193
+ `hostname` is any GitHub host: `github.com`, a GitHub Enterprise Server host,
194
+ or a `<tenant>.ghe.com` host. gh speaks only GitHub's API, so Forgejo, Gitea
195
+ and GitLab are out of reach. A `repo` that is not `owner/name`, or a
196
+ `hostname` that is not a bare host, throws `RangeError`.
197
+
198
+ ## Finding a pull request
199
+
200
+ `prView` takes a number, a branch or a URL. A URL names its own repository
201
+ and host, so the client's target is not added to it.
202
+
203
+ ```ts
204
+ gh.prView(42)
205
+ gh.prView('feature/login')
206
+ gh.prView('https://github.acme.com/acme/widgets/pull/42')
207
+ ```
98
208
 
99
- A query returns `null`, never `[]` on failure. `prList()` returning `[]` means
100
- *this repository has no open PRs*. `null` means *we could not ask*. If the two
101
- were the same, an expired credential would read as "nothing to do".
209
+ `prFind` takes what a person typed and runs the lookup it needs:
210
+
211
+ ```ts
212
+ const pr: PullRequest | null = gh.prFind(process.argv[2] ?? '')
213
+ ```
214
+
215
+ It reads the text with `parsePrRef`, which starts no process: `42` and `#42`
216
+ are numbers, `owner/name#42` is a number in that repository, a URL is a URL,
217
+ and any other valid branch name is a branch. Bare digits are tried as a PR
218
+ number first and then as a branch, since a branch can be named `42`. A branch
219
+ gives its open PR, or else its newest closed or merged one, so a tool run
220
+ after the merge still finds it.
221
+
222
+ `prList({ head })` matches the head branch exactly: `task/foo` never returns
223
+ `task/foo-2`. Two forks can share a branch name; ask for
224
+ `headRepositoryOwner` to tell them apart.
102
225
 
103
226
  ## Two shape hazards it absorbs
104
227
 
@@ -143,7 +266,7 @@ about staleness, never the measure of it. Compute that from the commit graph
143
266
 
144
267
  ## Changing pull requests
145
268
 
146
- Mutations throw `CommandFailed` when `gh` fails.
269
+ Mutations throw the classified failure (a `FailureError`) when `gh` fails. `gh.try.editPr(…)` returns the failure as a `Result` instead; a mutation has no `soft` form.
147
270
 
148
271
  ```ts
149
272
  const url = gh.createPr(options)
@@ -171,23 +294,23 @@ gh.prMerge(9, { method, headSha: 'abc1234' })
171
294
  ```
172
295
 
173
296
  `prReviewThreads(n)` reads the review threads, so you can look for open ones before you merge. It
174
- returns `null` when it could not read all of them (a failed call, an unexpected payload, or more
175
- than 100 threads). That is not the same as "no threads".
297
+ throws when it could not read all of them (a failed call, an unexpected payload, or more
298
+ than 100 threads; `soft.prReviewThreads` is `null`). That is not the same as "no threads".
176
299
 
177
300
  ```ts
178
- const threads: ReviewThread[] | null = gh.prReviewThreads(9)
301
+ const threads: ReviewThread[] = gh.prReviewThreads(9)
179
302
 
180
- threads?.filter((t) => !t.isResolved)
303
+ threads.filter((t) => !t.isResolved)
181
304
  ```
182
305
 
183
306
  ## Issues and labels
184
307
 
185
308
  ```ts
186
- const issue: IssueRow | null = gh.issueView(7)
187
- const triage: IssueRow[] | null = gh.issueList({ label: 'status:triage', limit: 50 })
309
+ const issue: IssueRow | null = gh.issueView(7) // null: there is no issue 7
310
+ const triage: ListResult<IssueRow> = gh.issueList({ label: 'status:triage', limit: 50 })
188
311
  gh.setIssueLabel(7, 'status:triage', 'status:define')
189
312
 
190
- const labels: Label[] | null = gh.labelList({ limit: 100 })
313
+ const labels: ListResult<Label> = gh.labelList({ limit: 100 })
191
314
  gh.createLabel({ name: 'status:triage', color: '1f6feb', description: 'Triage stage' })
192
315
  gh.editLabel('status:triage', { description: 'Waiting for triage' })
193
316
  ```
@@ -209,7 +332,7 @@ const review = gh.createReview(9, {
209
332
  gh.issueCommentEdit('4204154439', 'Updated: CI is green.')
210
333
  ```
211
334
 
212
- `prDiff` returns the unified diff and throws `CommandFailed` when `gh` cannot produce it.
335
+ `prDiff` returns the unified diff and throws the classified failure when `gh` cannot produce it.
213
336
 
214
337
  `createReview` is experimental. It posts a review of kind `COMMENT`: it cannot approve or request
215
338
  changes. Each inline comment sits on one line of the new side of the diff. It returns the review's
@@ -237,9 +360,9 @@ const prs = verb.parse(result)
237
360
  ## `AsyncGh`
238
361
 
239
362
  The awaitable form of `Gh`, with the same methods, for a fan-out that must not block the event loop.
240
- Same tri-modal contract (queries resolve to a value or `null` and never
241
- reject, mutations reject with `CommandFailed`, `raw`/`rawChecked` are the
242
- hatches), driven by the same verb table, so the two cannot drift. It takes an
363
+ Same three doors, promised (the plain method rejects with the classified failure,
364
+ `try` resolves to a `Result<T, GhFailure>`, `soft` resolves `null` for a failed
365
+ query, `raw`/`rawChecked` are the hatches), driven by the same verb table, so the two cannot drift. It takes an
243
366
  `AsyncCommandRunner` and defaults to `spawnRunnerAsync("gh")`.
244
367
 
245
368
  ```ts
@@ -247,14 +370,15 @@ import { AsyncGh } from '@osdlc/typed-gh'
247
370
 
248
371
  const gh = new AsyncGh('/repo')
249
372
  const prs = await Promise.all(branches.map((b) => gh.openPrFor(b)))
250
- const body = await gh.prBody(42) // string | null
373
+ const body = await gh.prBody(42) // string; rejects when gh fails
251
374
  await gh.editPrBody(42, `${body}\n\nfooter`)
252
375
  await gh.prComment(42, 'rebased') // the comment's URL, or ''
253
376
  await gh.prUpdateBranch(42, 'rebase')
254
377
  ```
255
378
 
256
- These methods take `AsyncCallOptions`: `raw`, `rawChecked`, `prList`, `prView`, `openPrFor`, `prViewSettled`, `prBody`, `api`,
257
- `labelList`, `issueList` and `issueView`. The options are `timeoutMs` and an `AbortSignal` in
379
+ These methods take `AsyncCallOptions`: `raw`, `rawChecked`, `prList`, `prView`, `prFind`,
380
+ `openPrFor`, `prViewSettled`, `prBody`, `api`, `graphql`, `labelList`, `issueList`, `issueView`,
381
+ `repoList`, `repoView` and `searchPrs`. The options are `timeoutMs` and an `AbortSignal` in
258
382
  `signal`.
259
383
 
260
384
  `api(endpoint, opts)` (on `Gh` and `AsyncGh`) is a REST GET through `gh api`.
@@ -263,7 +387,50 @@ It never throws; the result is `{ ok, status, etag, body, malformed, stderr, err
263
387
  was stopped (a missing binary, a timeout).
264
388
  Pass `etag` for a conditional request (`If-None-Match`; a 304 comes back as
265
389
  `status: 304, body: null`), or `includeHeaders: true` to read status and ETag
266
- without one. If `gh` prints more than one header block, the last one is read. `{owner}` and `{repo}` in the endpoint are filled in by gh.
390
+ without one. If `gh` prints more than one header block, the last one is read. `{owner}` and
391
+ `{repo}` in the endpoint come from the client's repo, or are filled in by gh from the clone.
392
+
393
+ ## Reading every page
394
+
395
+ `api` with `paginate: true` fetches every page and, when each page is an array, joins them into one
396
+ `body`:
397
+
398
+ ```ts
399
+ const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).body
400
+ ```
401
+
402
+ `graphql` (experimental) runs a query and returns each page's `data`. It throws when the
403
+ call failed or any page has `errors` or no `data` (`soft.graphql` is `null`): a partial answer is no answer. `nodesAt` joins
404
+ one connection's `nodes` across the pages, and is `null` when any page lacks them.
405
+
406
+ ```ts
407
+ const query = `query($owner: String!, $name: String!, $endCursor: String) {
408
+ repository(owner: $owner, name: $name) {
409
+ pullRequests(first: 100, after: $endCursor) {
410
+ pageInfo { hasNextPage endCursor }
411
+ nodes { number title }
412
+ }
413
+ }
414
+ }`
415
+ const pages = gh.graphql(query, { owner: '{owner}', name: '{repo}' }, { paginate: true })
416
+ const prs = nodesAt(pages, ['repository', 'pullRequests'])
417
+ ```
418
+
419
+ With `paginate`, the query must take `$endCursor` and page one connection; gh follows only that
420
+ one. `graphql` refuses a mutation with `RangeError`; send one through `raw`.
421
+
422
+ ## Repositories and search
423
+
424
+ ```ts
425
+ const repos: ListResult<Repository> = gh.repoList('acme', { archived: false, limit: 200 })
426
+ const repo: Repository | null = gh.repoView('acme/widgets') // null: no such repository
427
+ const hits: ListResult<PrSearchHit> = gh.searchPrs('TICKET-123', { owner: 'acme' })
428
+ ```
429
+
430
+ A `Repository` carries `nameWithOwner`, `owner`, `name`, `description`, `defaultBranch`,
431
+ `visibility`, `isArchived` and `url`. `gh search prs` has its own field set: a `PrSearchHit` names
432
+ its `repository` but has no head or base branch, so it is not a `PullRequest`. Its `state` is
433
+ upper-cased to match. The query takes GitHub's search syntax and may start with `-`.
267
434
 
268
435
  ## Injecting the subprocess seam
269
436
 
@@ -274,7 +441,7 @@ suite runs offline:
274
441
  ```ts
275
442
  const empty: CommandRunner = { run: () => ({ exitCode: 0, stdout: '[]', stderr: '' }) }
276
443
 
277
- new Gh('/repo', { runner: empty }).prList() // []
444
+ new Gh('/repo', { runner: empty }).prList() // { rows: [], truncated: false }
278
445
  ```
279
446
 
280
447
  This package re-exports `CommandFailed` and the runner and result types (`CommandRunner`,
@@ -289,42 +456,58 @@ experimental.
289
456
 
290
457
  ### Clients
291
458
 
292
- - `Gh`: the synchronous client, bound to one working directory.
459
+ - `Gh`: the synchronous client, bound to one working directory, and optionally a repository and
460
+ host.
293
461
  - `AsyncGh`: the awaitable client, with the same methods.
294
- - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by eleven methods of `AsyncGh`.
462
+ - `GhOptions` and `AsyncGhOptions`: the runner, `repo` and `hostname` a client is built with.
463
+ - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by sixteen methods of `AsyncGh`.
295
464
  - `defaultRunnerFactory` and `RunnerFactory`: how a client makes its default runner for `gh`.
296
465
 
297
466
  Methods on both clients:
298
467
 
299
468
  - Escape hatches: `raw` runs any `gh` call and returns the result. `rawChecked` throws on a failed
300
- exit. `at` returns the same client rooted at another directory.
301
- - Pull request queries: `prList`, `prView`, `openPrFor`, `prViewSettled`, `prBody` and
302
- `prReviewThreads`.
469
+ exit. `at` returns the same client rooted at another directory, and `withRepo` the same client
470
+ aimed at another repository.
471
+ - Pull request queries: `prList`, `prView`, `prFind`, `openPrFor`, `prViewSettled`, `prBody`,
472
+ `prReviewThreads` and `searchPrs`.
303
473
  - Pull request changes: `createPr`, `editPr`, `editPrBody`, `prComment`, `prUpdateBranch`,
304
474
  `prSetDraft`, `prMerge`, `prDiff` and `createReview`.
305
475
  - Issue and label queries: `issueList`, `issueView` and `labelList`.
476
+ - Repository queries: `repoList` and `repoView`.
306
477
  - Issue and label changes: `setIssueLabel`, `createLabel`, `editLabel` and `issueCommentEdit`.
307
- - REST: `api` is a GET through `gh api` that never throws.
478
+ - REST and GraphQL: `api` is a GET through `gh api` and never throws (failure is `ok: false`); `graphql` is a query and throws on a failed or partial answer.
308
479
  - Setup checks: `installed`, `authenticated`, `authStatus` and `ready`.
309
480
  - `ownerRepo` reads the owner and repository from a remote URL. It starts no process.
481
+ - `try` and `soft`: the whole surface again as a `Result<T, GhFailure>`, and the queries again with `null` for a failure. `TriedSync`, `TriedAsync` and `Soft` are their types.
482
+
483
+ ### Failures
484
+
485
+ - `GhFailure` and `GhFailureKind`: the error of `try`, and its kinds (see "`GhFailure`" above).
486
+ - `classifyGhFailure(commandFailed)`: the `GhFailure` for a `CommandFailed`.
310
487
 
311
488
  ### Verbs
312
489
 
313
- A verb is plain data: argv, a `checked` flag, optional stdin and a `parse` function. Each client
314
- method has a verb of the same name, except the escape hatches, `at`, `prViewSettled`, `openPrFor`,
315
- `authenticated`, `ready` and `ownerRepo`. `openPrsForBranch` is a verb with no method.
490
+ A verb is plain data: argv, a `checked` flag, a `readOnly` flag, optional stdin and environment,
491
+ and a `parse` function. `readOnly` is true for every query and `prDiff`, and false for every
492
+ mutation, so a caller that keeps an allowlist of safe calls can check a verb before running it.
493
+ Each verb takes an optional `GhTarget` last. Each client method has a verb of the same name, except
494
+ the escape hatches, `at`, `withRepo`, `prViewSettled`, `openPrFor`, `prFind`, `authenticated`,
495
+ `ready` and `ownerRepo`. `openPrsForBranch` is a verb with no method.
316
496
 
317
- - `GhVerb`: the shape of a verb.
497
+ - `GhVerb`: the shape of a verb. `GhTarget`: the `repo` and `hostname` a verb talks to.
318
498
  - Verb builders: `prList`, `prView`, `prBody`, `openPrsForBranch`, `prReviewThreads`, `prDiff`,
319
499
  `createPr`, `editPr`, `editPrBody`, `prComment`, `prUpdateBranch`, `prSetDraft`, `prMerge`,
320
500
  `createReview`, `issueList`, `issueView`, `labelList`, `setIssueLabel`, `createLabel`,
321
- `editLabel`, `issueCommentEdit`, `api`, `installed` and `authStatus`.
322
- - `pickPrForBranch`: the row whose head branch is exactly the one you name. `gh` matches a head
323
- filter as a prefix.
324
- - Field lists: `PR_REF_FIELDS` (enough to identify a PR), `PR_LIST_FIELDS` (for triage) and
325
- `PR_VIEW_FIELDS` (the full set).
326
- - Option and result types: `PrListOptions`, `IssueListOptions`, `CreatePrOptions`,
327
- `CreateReviewOptions`, `ReviewComment`, `CreateLabelOptions`, `ApiOptions`, `ApiResponse`,
501
+ `editLabel`, `issueCommentEdit`, `repoList`, `repoView`, `searchPrs`, `api`, `graphql`,
502
+ `installed` and `authStatus`.
503
+ - `pickPrForBranch`: the first row whose head branch is exactly the one you name, for rows that
504
+ came from somewhere other than `prList({ head })`, such as a `head:` search.
505
+ - `nodesAt`: one connection's nodes, joined across `graphql` pages.
506
+ - Field lists: `PR_REF_FIELDS` (enough to identify a PR), `PR_LIST_FIELDS` (for triage),
507
+ `PR_VIEW_FIELDS` (the full set), `REPO_FIELDS` and `SEARCH_PR_FIELDS`.
508
+ - Option and result types: `ListResult`, `PrListOptions`, `IssueListOptions`, `RepoListOptions`,
509
+ `SearchPrsOptions`, `CreatePrOptions`, `CreateReviewOptions`, `ReviewComment`,
510
+ `CreateLabelOptions`, `ApiOptions`, `ApiResponse`, `GraphqlOptions`, `GraphqlVariable`,
328
511
  `AuthStatus`, `PrMergeMethod` and `ReviewThread`.
329
512
 
330
513
  ### Parsers
@@ -334,18 +517,22 @@ method has a verb of the same name, except the escape hatches, `at`, `prViewSett
334
517
  - `normalizeAuthorLogin`: drops a `[bot]` suffix from a login.
335
518
  - `parseOwnerRepo`: reads a remote URL into `{ owner, repo }`, or `null`.
336
519
  - `ownerRepoSlug`: writes `{ owner, repo }` as `owner/repo`.
337
- - `parsePrUrl`: reads a pull request URL into its owner, repository and number, or `null`.
520
+ - `parsePrUrl`: reads a pull request URL on any GitHub host into its host, owner, repository and
521
+ number, or `null`.
522
+ - `parsePrRef`: reads what a person typed to name a pull request into a `PrRef`, or `null`.
338
523
 
339
524
  ### Types
340
525
 
341
- - Model: `PullRequest`, `Review`, `Comment`, `CheckRun`, `Actor`, `Label` and `IssueRow`.
342
- - Enums: `PullRequestState`, `MergeableState`, `MergeStateStatus`, `ReviewDecision`, `ReviewState`
343
- and `CheckConclusion`.
344
- - `OwnerRepo` and `PullRequestRef`: a repository, and a pull request in one.
526
+ - Model: `PullRequest`, `Review`, `Comment`, `CheckRun`, `Actor`, `Label`, `IssueRow`,
527
+ `Repository` and `PrSearchHit`.
528
+ - Enums: `PullRequestState`, `MergeableState`, `MergeStateStatus`, `ReviewDecision`, `ReviewState`,
529
+ `CheckConclusion` and `RepositoryVisibility`.
530
+ - `OwnerRepo`, `PullRequestRef` and `PrRef`: a repository, a pull request in one, and what a person
531
+ typed to name a pull request.
345
532
 
346
533
  ### From the command seam
347
534
 
348
- - `CommandFailed`: thrown by a mutation. It carries the argv and the result.
535
+ - `CommandFailed`: re-exported from `@osdlc/command-seam`. A failed call throws a `FailureError` whose `failure.cause` is the `CommandFailed`, which carries the argv and the result.
349
536
  - `CommandRunner`, `AsyncCommandRunner`, `CommandResult` and `CommandRunOptions`: the runner types
350
537
  that appear in signatures.
351
538
 
@@ -364,6 +551,10 @@ The experimental items:
364
551
  - `createReview`, the verb and the method on both clients
365
552
  - `CreateReviewOptions`
366
553
  - `ReviewComment`
554
+ - `graphql`, the verb and the method on both clients
555
+ - `nodesAt`
556
+ - `GraphqlOptions`
557
+ - `GraphqlVariable`
367
558
 
368
559
  Every other export and method is stable, `prDiff` included.
369
560
 
@@ -376,10 +567,10 @@ shell, so nothing in it is specific to one shell.
376
567
 
377
568
  - **Closing a PR and deleting a branch.** `prMerge` merges and leaves the branch alone. Closing and
378
569
  deleting belong to a caller's own policy layer, where the audit trail lives.
379
- - **A full REST client.** `api()` is a GET; anything else goes through
380
- `raw(["api", …])`.
570
+ - **A full REST or GraphQL client.** `api()` is a GET and `graphql()` a query; anything else goes
571
+ through `raw(["api", …])`.
381
572
  - **Anything that reads a token.**
382
573
 
383
574
  ## Licence
384
575
 
385
- `MIT OR Apache-2.0`, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.
576
+ `MIT OR Apache-2.0`, at your option. See [LICENSE-MIT](./LICENSE-MIT) and [LICENSE-APACHE](./LICENSE-APACHE).