@osdlc/typed-gh 0.7.0 → 0.9.1

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,5 +1,30 @@
1
1
  # @osdlc/typed-gh
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 96385dc: An empty `hostname` now means no host, the same as leaving it out. It used to throw `RangeError`, so a caller that reads the host from its own config had to write `hostname: value || undefined`. Passing `hostname: 'github.com'` is unchanged: it still reaches gh as given, because naming github.com explicitly keeps a call there when `GH_HOST` or gh's default host points at another host. The README now says so.
8
+
9
+ ## 0.9.0
10
+
11
+ ### Minor Changes
12
+
13
+ - cc2abe4: `api` now keeps GitHub's error body when a request fails. `body` is parsed even when gh exits with an error, and a new `message` field holds `body.message`. `ApiResponse` gains this `message` field as required. `malformed` is now also set when a failed call prints something that is not JSON. The docs now say that `status` is only filled in when headers were requested.
14
+
15
+ ### Patch Changes
16
+
17
+ - 72f5049: `prList` always requests the `number` field. Before, a `fields` list without `number` returned no rows.
18
+ - Updated dependencies [b5aac07]
19
+ - Updated dependencies [41ef520]
20
+ - @osdlc/typed-git@0.7.0
21
+
22
+ ## 0.8.0
23
+
24
+ ### Minor Changes
25
+
26
+ - ae400e5: Added `runVerb` and `runVerbAsync`, with `RunVerbOptions` and `RunVerbAsyncOptions`. They run one verb through a runner the caller owns and apply what the verb carries: `env` (the target host, as `GH_HOST`, for `repoList` and `searchPrs`), `input` (the payload `createReview` sends on stdin), `timeoutMs` and the checked contract. Running `verb.parse(await run(verb.argv))` yourself dropped `env` and `input` without an error. `Gh` and `AsyncGh` now call them, so there is one copy of that logic.
27
+
3
28
  ## 0.7.0
4
29
 
5
30
  ### Minor Changes
package/CONTRIBUTING.md CHANGED
@@ -68,7 +68,7 @@ version and writes the CHANGELOG from the pending changesets. See
68
68
  the package by name. A test fails if an exported function is not called, or
69
69
  an exported type not imported, in `examples/`. `tests/exports.test.ts` lists
70
70
  the few exports that have no example yet.
71
- - Shell out only through the command seam, never with a raw `child_process`
71
+ - Run commands only through `@osdlc/command-seam`, never with a raw `child_process`
72
72
  call.
73
73
  - Add a changeset for a change users can see. See above.
74
74
  - Record a design choice in
package/README.md CHANGED
@@ -17,8 +17,8 @@ npm install @osdlc/typed-gh
17
17
 
18
18
  `pnpm add @osdlc/typed-gh` and `yarn add @osdlc/typed-gh` work too.
19
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`.
21
- It is supported on macOS and Linux. Windows is not supported yet; it will be once a Windows CI job
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/`. It also needs the `gh` CLI on `PATH`, signed in with `gh auth login`.
21
+ It is supported on macOS and Linux. Windows is not supported yet. It will be once a Windows CI job
22
22
  runs the tests.
23
23
 
24
24
  ```ts
@@ -34,11 +34,11 @@ The `examples` folder in the repository runs the same calls as tests.
34
34
 
35
35
  ## When not to use it
36
36
 
37
- - **`gh` is not installed.** In a browser or a serverless function, use an HTTP client such as
37
+ - `gh` is not installed. In a browser or a serverless function, use an HTTP client such as
38
38
  Octokit.
39
- - **You sign in with a token, a GitHub App or OAuth.** This package reads no token.
40
- - **You make many calls or poll in a loop.** Each call starts a `gh` process.
41
- - **You need the whole API typed.** This package covers the calls listed below. `api()` is a GET
39
+ - You sign in with a token, a GitHub App or OAuth. This package reads no token.
40
+ - You make many calls or poll in a loop. Each call starts a `gh` process.
41
+ - You need the whole API typed. This package covers the calls listed below. `api()` is a GET
42
42
  and `graphql()` is a query. Anything else goes through `raw`.
43
43
 
44
44
  ## How it compares
@@ -60,15 +60,14 @@ and `@osdlc/typed-git`.
60
60
 
61
61
  ## Authentication is `gh`'s
62
62
 
63
- No token is read, stored, or forwarded. That is the reason this shells out to
64
- the CLI rather than speaking REST: the credential already exists in the user's
65
- keychain, and a library that reaches for `GITHUB_TOKEN` is a library that can
66
- leak one.
63
+ No token is read, stored, or forwarded. That is why this package runs the CLI
64
+ instead of calling the REST API. The credential is already in the user's
65
+ keychain. A library that reads `GITHUB_TOKEN` can leak it.
67
66
 
68
67
  ## A total model
69
68
 
70
- `gh --json` returns only the fields you asked for, so every field on
71
- `PullRequest` has a "not known" value and none of them is `undefined`:
69
+ `gh --json` returns only the fields you asked for. So every field on
70
+ `PullRequest` has a "not known" value, and none of them is `undefined`:
72
71
 
73
72
  ```ts
74
73
  const pr = parsePullRequest({ number: 7 })
@@ -90,34 +89,35 @@ and `ReviewDecision` (`APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`,
90
89
  `null`). A value GitHub adds to either reads as `UNKNOWN` or `null`. A check
91
90
  that decides whether to merge can then match every member.
92
91
 
93
- ## Three doors, as in `typed-git`, so failure is never ambiguous
92
+ ## Three ways to call, as in `typed-git`, so a failure is never mistaken for an answer
94
93
 
95
94
  | | a value | on failure |
96
95
  | --- | --- | --- |
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 |
96
+ | `gh.query()` (the method that throws) | the value | throws a `FailureError` carrying the classified `GhFailure` (kind `unreadable` when `gh` succeeded but its output cannot be read) |
97
+ | `gh.try.query()` | `{ ok: true, value }` | `{ ok: false, error: GhFailure }` |
98
+ | `gh.soft.query()` | the value | `null` (queries only. A mutation has no soft form.) |
99
+ | `raw(argv)` | the raw `CommandResult`. You decide. | the same |
101
100
 
102
101
  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
102
+ `prList()` returning `{ rows: [], truncated: false }` means this repository
103
+ has no open PRs. A failed call throws, or is `{ ok: false }` through `try`, or
104
+ `null` through `soft`. That means we could not ask. If the two were the same, an
106
105
  expired credential would read as "nothing to do".
107
106
 
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
107
+ A missing item is an answer, not a failure. A query for one thing (`prView`, `issueView`,
108
+ `repoView`, and `prFind` and `openPrFor`, which use them) returns `null` in all
109
+ three call styles when GitHub says there is no such thing: "Could not resolve to a
111
110
  PullRequest" or "no pull requests found for branch" for `prView`, "Could not
112
111
  resolve to an Issue" for `issueView`, "Could not resolve to a Repository" for
113
112
  `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
113
+ failure is a failure, including a wrong repository. `prView(7)` against a
115
114
  mistyped `--repo` is a `not-found` failure, not an absent PR. The check reads
116
115
  stderr and stdout, as the classifier does.
117
116
 
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.
117
+ Setup checks return their failure as a value. `installed()`, `authenticated()`,
118
+ `authStatus()`, `ready()` and `api()` return their failure as part of their
119
+ value, in all three call styles. For them, "gh is not installed" is the answer
120
+ to the question they ask.
121
121
 
122
122
  ```ts
123
123
  import { describe } from '@osdlc/result'
@@ -130,11 +130,11 @@ if (!result.ok) {
130
130
 
131
131
  ### `GhFailure`
132
132
 
133
- The error of `try` is a `GhFailure`, a `Failure` from `@osdlc/result`: a `kind`
133
+ The error of `try` is a `GhFailure`, a `Failure` from `@osdlc/result`. It has a `kind`
134
134
  to branch on, a one-line `message` and a `hint` for a person (`describe(error)`
135
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
136
+ `CommandFailed` it was read from in `cause`. The methods that throw throw the same
137
+ failure, wrapped in a `FailureError` (`error.failure`). So a CLI that reports a
138
138
  thrown failure, such as `runMain` from `@osdlc/cli-tool`, shows "gh is not
139
139
  signed in" for a write and exits with the same code as for a read.
140
140
 
@@ -149,7 +149,7 @@ signed in" for a write and exits with the same code as for a read.
149
149
  | `not-executable`, `bad-cwd`, `timeout`, `output-cap`, `signal`, `aborted`, `spawn` | as in `@osdlc/command-seam` | command-seam's message, no hint |
150
150
  | `exit` | any other non-zero exit | `gh`'s first stderr line |
151
151
 
152
- The patterns are `gh`'s English messages; anything else is a plain `exit`,
152
+ The patterns are `gh`'s English messages. Anything else is a plain `exit`,
153
153
  never a wrong kind. Rate limiting is checked before authentication, then
154
154
  network, then not-found. `classifyGhFailure(commandFailed)` makes the same
155
155
  classification for a `CommandFailed` you caught yourself.
@@ -157,7 +157,7 @@ classification for a `CommandFailed` you caught yourself.
157
157
  ## A list says when its limit cut it short
158
158
 
159
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`
160
+ one row more than `limit` and returns `{ rows, truncated }`. That is at most `limit`
161
161
  rows, and `truncated: true` when there were more.
162
162
 
163
163
  ```ts
@@ -172,8 +172,8 @@ way. A `limit` that is not a positive integer throws `RangeError`.
172
172
  ## A repository without a clone
173
173
 
174
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:
175
+ Name a repository, a host, or both, and every call targets them instead. No
176
+ clone is needed:
177
177
 
178
178
  ```ts
179
179
  const acme = new Gh(process.cwd(), { repo: 'acme/widgets', hostname: 'github.acme.com' })
@@ -184,15 +184,18 @@ acme.api('repos/{owner}/{repo}/branches')
184
184
  ```
185
185
 
186
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`
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}`. `authStatus`
189
189
  asks about that host. `repoList` and `searchPrs` have no host flag in gh, so
190
190
  they get `GH_HOST` in the environment. `withRepo` and `at` keep the runner and
191
191
  the rest of the target.
192
192
 
193
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
194
+ or a `<tenant>.ghe.com` host. An empty string means no host, so a value read
195
+ from config can be passed as it is. `github.com` is not dropped: it goes to gh
196
+ as `--repo github.com/owner/name`, `--hostname` or `GH_HOST`, which keeps the
197
+ call on github.com even when the environment sets another default host. gh
198
+ supports only GitHub's API, so Forgejo, Gitea and GitLab are not supported. A `repo` that is not `owner/name`, or a
196
199
  `hostname` that is not a bare host, throws `RangeError`.
197
200
 
198
201
  ## Finding a pull request
@@ -212,7 +215,7 @@ gh.prView('https://github.acme.com/acme/widgets/pull/42')
212
215
  const pr: PullRequest | null = gh.prFind(process.argv[2] ?? '')
213
216
  ```
214
217
 
215
- It reads the text with `parsePrRef`, which starts no process: `42` and `#42`
218
+ It reads the text with `parsePrRef`, which starts no process. `42` and `#42`
216
219
  are numbers, `owner/name#42` is a number in that repository, a URL is a URL,
217
220
  and any other valid branch name is a branch. Bare digits are tried as a PR
218
221
  number first and then as a branch, since a branch can be named `42`. A branch
@@ -220,19 +223,19 @@ gives its open PR, or else its newest closed or merged one, so a tool run
220
223
  after the merge still finds it.
221
224
 
222
225
  `prList({ head })` matches the head branch exactly: `task/foo` never returns
223
- `task/foo-2`. Two forks can share a branch name; ask for
226
+ `task/foo-2`. Two forks can share a branch name. Ask for
224
227
  `headRepositoryOwner` to tell them apart.
225
228
 
226
- ## Two shape hazards it absorbs
229
+ ## Two payload differences it handles
227
230
 
228
- **Author logins differ by transport.** GraphQL (`gh pr view --json`) returns a
229
- login without a suffix (`github-actions`). REST (`gh api`) returns the suffixed
230
- one (`github-actions[bot]`). Comparing one with the other never matches, and
231
- no error shows it. That is how bot noise gets treated as human review
232
- feedback. Logins are cut to the suffix-less form once, when a payload is
233
- parsed.
231
+ Author logins differ between GraphQL and REST. GraphQL (`gh pr view --json`)
232
+ returns a login without a suffix (`github-actions`). REST (`gh api`) returns
233
+ the suffixed one (`github-actions[bot]`). Comparing one with the other never
234
+ matches, and no error shows it. Then a bot's comments are treated as human
235
+ review feedback. Logins are cut to the form without the suffix once, when a
236
+ payload is parsed.
234
237
 
235
- **`statusCheckRollup` holds two node types.** A modern `CheckRun` carries
238
+ `statusCheckRollup` holds two node types. A modern `CheckRun` carries
236
239
  `name`/`status`/`conclusion`. A legacy `StatusContext` carries
237
240
  `context`/`state`. Both map onto one `verdict` axis:
238
241
 
@@ -248,21 +251,20 @@ as green, a red build could merge.
248
251
 
249
252
  ## The merge state is not stable on a cold query
250
253
 
251
- GitHub computes mergeability lazily: the first query kicks it off and answers
252
- `UNKNOWN` until it lands. A caller that believes that first answer mis-triages
253
- every PR that is merely slow.
254
+ GitHub computes mergeability only when asked. The first query starts the
255
+ computation and answers `UNKNOWN` until it finishes. A caller that trusts that
256
+ first answer misjudges every PR whose computation is slow.
254
257
 
255
258
  ```ts
256
259
  const pr = gh.prViewSettled(7, { attempts: 3 }) // 3 is the default
257
260
  ```
258
261
 
259
- `UNKNOWN` is still a legitimate final verdict, so callers must handle it.
262
+ `UNKNOWN` can still be the final answer, so callers must handle it.
260
263
 
261
- Note also that `mergeStateStatus: "BEHIND"` only ever appears when branch
262
- protection *requires* an up-to-date branch. Without that rule a branch a
263
- hundred commits behind still reports `CLEAN` — so it is a corroborating hint
264
- about staleness, never the measure of it. Compute that from the commit graph
265
- (`typed-git`'s `aheadBehind`).
264
+ `mergeStateStatus: "BEHIND"` appears only when branch protection requires an
265
+ up-to-date branch. Without that rule, a branch a hundred commits behind still
266
+ reports `CLEAN`. So `BEHIND` is a hint that a branch is out of date, not a
267
+ measure of it. Compute that from the commit graph (`typed-git`'s `aheadBehind`).
266
268
 
267
269
  ## Changing pull requests
268
270
 
@@ -283,9 +285,9 @@ a draft.
283
285
 
284
286
  ## Merging
285
287
 
286
- `prMerge` takes the head commit you judged. GitHub refuses the merge if the head has moved since,
287
- so a push that lands between your check and the merge cannot ride in unreviewed. The branch is not
288
- deleted. That is yours to do.
288
+ `prMerge` takes the head commit you checked. GitHub refuses the merge if the head has moved since.
289
+ So a push that lands between your check and the merge cannot be merged without review. The branch
290
+ is not deleted. That is up to you.
289
291
 
290
292
  ```ts
291
293
  const method: PrMergeMethod = 'squash'
@@ -316,8 +318,8 @@ gh.editLabel('status:triage', { description: 'Waiting for triage' })
316
318
  ```
317
319
 
318
320
  `issueList` returns open issues, up to `limit` (default 200). `labelList` returns up to `limit`
319
- labels (default 1000; `gh`'s own default is 30). `setIssueLabel` removes one label and adds
320
- another in one `gh issue edit` call. `createLabel` passes `--force`, so it is safe to repeat: an
321
+ labels (default 1000. `gh`'s own default is 30). `setIssueLabel` removes one label and adds
322
+ another in one `gh issue edit` call. `createLabel` passes `--force`, so it is safe to repeat. An
321
323
  existing label gets the color and description you pass.
322
324
 
323
325
  ## Diffs, reviews and comments
@@ -334,20 +336,20 @@ gh.issueCommentEdit('4204154439', 'Updated: CI is green.')
334
336
 
335
337
  `prDiff` returns the unified diff and throws the classified failure when `gh` cannot produce it.
336
338
 
337
- `createReview` is experimental. It posts a review of kind `COMMENT`: it cannot approve or request
339
+ `createReview` is experimental. It posts a review of kind `COMMENT`. It cannot approve or request
338
340
  changes. Each inline comment sits on one line of the new side of the diff. It returns the review's
339
341
  URL.
340
342
 
341
- `issueCommentEdit` is experimental. It takes the comment's numeric REST id, as text: the `id` from
343
+ `issueCommentEdit` is experimental. It takes the comment's numeric REST id, as text. That is the `id` from
342
344
  `gh api repos/{owner}/{repo}/issues/<n>/comments`. The `Comment.id` that `prView` returns is a
343
345
  GraphQL node id, which this REST path does not accept. An id that is not all digits throws
344
346
  `RangeError` before `gh` runs (`AsyncGh` returns a rejected Promise).
345
347
 
346
- ## Pure halves, for an async fan-out
348
+ ## Verbs and parsers, for running calls yourself
347
349
 
348
- The verb table and every parser are side-effect free, so concurrent callers
349
- drive them through their own `AsyncCommandRunner` without duplicating a line
350
- of parsing:
350
+ The verb table and every parser have no side effects. So code that runs many
351
+ calls at once can run them through its own `AsyncCommandRunner` and reuse the
352
+ parsing:
351
353
 
352
354
  ```ts
353
355
  import { prList } from '@osdlc/typed-gh'
@@ -357,13 +359,32 @@ const result = await runner.run(verb.argv, { cwd: '/repo', timeoutMs: 5_000 })
357
359
  const prs = verb.parse(result)
358
360
  ```
359
361
 
362
+ That is only safe for a verb with no `env` or `input`. `repoList` and `searchPrs` send the target
363
+ host as `GH_HOST` (`env`), and `createReview` sends its payload on stdin (`input`). A runner call
364
+ that passes only `verb.argv` drops both, with no error. The query goes to github.com, and the review
365
+ is never sent. `runVerb` (and `runVerbAsync`, for an `AsyncCommandRunner`) run a verb the way `Gh` and
366
+ `AsyncGh` do, with `cwd`, `env`, `input`, `timeoutMs` and the checked contract applied, and return
367
+ the parsed value or throw the classified failure:
368
+
369
+ ```ts
370
+ import { repoList, runVerbAsync } from '@osdlc/typed-gh'
371
+
372
+ const repos = await runVerbAsync(repoList('acme', {}, { hostname: 'ghe.example.com' }), runner, {
373
+ cwd: '/repo',
374
+ timeoutMs: 5_000,
375
+ })
376
+ ```
377
+
378
+ They take a `CommandRunner`. A plain `(binary, argv, opts) => result` function is wrapped as
379
+ `{ run: (argv, opts) => fn('gh', argv, opts) }`.
380
+
360
381
  ## `AsyncGh`
361
382
 
362
- The awaitable form of `Gh`, with the same methods, for a fan-out that must not block the event loop.
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
366
- `AsyncCommandRunner` and defaults to `spawnRunnerAsync("gh")`.
383
+ The awaitable form of `Gh`, with the same methods, for code that runs many calls at once and must
384
+ not block the event loop. It has the same three call styles, as Promises. The plain method rejects
385
+ with the classified failure. `try` resolves to a `Result<T, GhFailure>`. `soft` resolves `null` for
386
+ a failed query. `raw` and `rawChecked` run any `gh` call. Both clients use the same verb table, so
387
+ they cannot drift apart. It takes an `AsyncCommandRunner` and defaults to `spawnRunnerAsync("gh")`.
367
388
 
368
389
  ```ts
369
390
  import { AsyncGh } from '@osdlc/typed-gh'
@@ -382,11 +403,12 @@ These methods take `AsyncCallOptions`: `raw`, `rawChecked`, `prList`, `prView`,
382
403
  `signal`.
383
404
 
384
405
  `api(endpoint, opts)` (on `Gh` and `AsyncGh`) is a REST GET through `gh api`.
385
- It never throws; the result is `{ ok, status, etag, body, malformed, stderr, error? }`.
386
- `stderr` is gh's own; `error` holds the runner's message when gh never ran or
406
+ It never throws. The result is `{ ok, status, etag, body, message, malformed, stderr, error? }`.
407
+ On failure `body` still carries GitHub's error object and `message` its text. `status` is read only when headers were requested (`etag` or `includeHeaders`).
408
+ `stderr` is gh's own. `error` holds the runner's message when gh never ran or
387
409
  was stopped (a missing binary, a timeout).
388
- Pass `etag` for a conditional request (`If-None-Match`; a 304 comes back as
389
- `status: 304, body: null`), or `includeHeaders: true` to read status and ETag
410
+ Pass `etag` for a conditional request (`If-None-Match`. A 304 comes back as
411
+ `status: 304, body: null`). Or pass `includeHeaders: true` to read status and ETag
390
412
  without one. If `gh` prints more than one header block, the last one is read. `{owner}` and
391
413
  `{repo}` in the endpoint come from the client's repo, or are filled in by gh from the clone.
392
414
 
@@ -400,7 +422,7 @@ const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).bod
400
422
  ```
401
423
 
402
424
  `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
425
+ call failed or any page has `errors` or no `data` (`soft.graphql` is `null`). A partial answer counts as a failure. `nodesAt` joins
404
426
  one connection's `nodes` across the pages, and is `null` when any page lacks them.
405
427
 
406
428
  ```ts
@@ -416,8 +438,8 @@ const pages = gh.graphql(query, { owner: '{owner}', name: '{repo}' }, { paginate
416
438
  const prs = nodesAt(pages, ['repository', 'pullRequests'])
417
439
  ```
418
440
 
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`.
441
+ With `paginate`, the query must take `$endCursor` and page one connection. gh follows only that
442
+ one. `graphql` refuses a mutation with `RangeError`. Send one through `raw`.
421
443
 
422
444
  ## Repositories and search
423
445
 
@@ -428,15 +450,15 @@ const hits: ListResult<PrSearchHit> = gh.searchPrs('TICKET-123', { owner: 'acme'
428
450
  ```
429
451
 
430
452
  A `Repository` carries `nameWithOwner`, `owner`, `name`, `description`, `defaultBranch`,
431
- `visibility`, `isArchived` and `url`. `gh search prs` has its own field set: a `PrSearchHit` names
453
+ `visibility`, `isArchived` and `url`. `gh search prs` has its own field set. A `PrSearchHit` names
432
454
  its `repository` but has no head or base branch, so it is not a `PullRequest`. Its `state` is
433
455
  upper-cased to match. The query takes GitHub's search syntax and may start with `-`.
434
456
 
435
- ## Injecting the subprocess seam
457
+ ## Passing your own runner
436
458
 
437
459
  `Gh` takes a `CommandRunner` from
438
- [`@osdlc/command-seam`](https://github.com/osdlc/osdlc/tree/main/packages/ts/command-seam), so a
439
- suite runs offline:
460
+ [`@osdlc/command-seam`](https://github.com/osdlc/osdlc/tree/main/packages/ts/command-seam), so
461
+ tests can run offline:
440
462
 
441
463
  ```ts
442
464
  const empty: CommandRunner = { run: () => ({ exitCode: 0, stdout: '[]', stderr: '' }) }
@@ -462,10 +484,12 @@ experimental.
462
484
  - `GhOptions` and `AsyncGhOptions`: the runner, `repo` and `hostname` a client is built with.
463
485
  - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by sixteen methods of `AsyncGh`.
464
486
  - `defaultRunnerFactory` and `RunnerFactory`: how a client makes its default runner for `gh`.
487
+ - `runVerb` and `runVerbAsync`, with `RunVerbOptions` and `RunVerbAsyncOptions`: run one verb through
488
+ a runner you own, with its `env`, `input`, timeout and checked contract applied.
465
489
 
466
490
  Methods on both clients:
467
491
 
468
- - Escape hatches: `raw` runs any `gh` call and returns the result. `rawChecked` throws on a failed
492
+ - Direct calls: `raw` runs any `gh` call and returns the result. `rawChecked` throws on a failed
469
493
  exit. `at` returns the same client rooted at another directory, and `withRepo` the same client
470
494
  aimed at another repository.
471
495
  - Pull request queries: `prList`, `prView`, `prFind`, `openPrFor`, `prViewSettled`, `prBody`,
@@ -491,7 +515,7 @@ A verb is plain data: argv, a `checked` flag, a `readOnly` flag, optional stdin
491
515
  and a `parse` function. `readOnly` is true for every query and `prDiff`, and false for every
492
516
  mutation, so a caller that keeps an allowlist of safe calls can check a verb before running it.
493
517
  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`,
518
+ `raw`, `rawChecked`, `at`, `withRepo`, `prViewSettled`, `openPrFor`, `prFind`, `authenticated`,
495
519
  `ready` and `ownerRepo`. `openPrsForBranch` is a verb with no method.
496
520
 
497
521
  - `GhVerb`: the shape of a verb. `GhTarget`: the `repo` and `hostname` a verb talks to.
@@ -540,9 +564,9 @@ the escape hatches, `at`, `withRepo`, `prViewSettled`, `openPrFor`, `prFind`, `a
540
564
 
541
565
  The package is at 0.x. Each item is stable or experimental.
542
566
 
543
- - **Stable** items change only with a minor release, and a breaking change is marked
567
+ - Stable items change only with a minor release, and a breaking change is marked
544
568
  **Breaking** in the CHANGELOG.
545
- - **Experimental** items may change or go in any release. Each has an `@experimental` tag in its
569
+ - Experimental items may change or go in any release. Each has an `@experimental` tag in its
546
570
  TSDoc.
547
571
 
548
572
  The experimental items:
@@ -565,11 +589,11 @@ shell, so nothing in it is specific to one shell.
565
589
 
566
590
  ## What is not here
567
591
 
568
- - **Closing a PR and deleting a branch.** `prMerge` merges and leaves the branch alone. Closing and
569
- deleting belong to a caller's own policy layer, where the audit trail lives.
570
- - **A full REST or GraphQL client.** `api()` is a GET and `graphql()` a query; anything else goes
592
+ - Closing a PR and deleting a branch. `prMerge` merges and leaves the branch alone. Closing and
593
+ deleting belong in the caller's own code, which keeps its own record of what it did.
594
+ - A full REST or GraphQL client. `api()` is a GET and `graphql()` a query. Anything else goes
571
595
  through `raw(["api", …])`.
572
- - **Anything that reads a token.**
596
+ - Anything that reads a token.
573
597
 
574
598
  ## Licence
575
599
 
package/SECURITY.md CHANGED
@@ -33,8 +33,8 @@ not intend are in scope.
33
33
  keychain entry, or `GH_TOKEN` and `GITHUB_TOKEN` in the environment. The
34
34
  child process inherits the caller's environment.
35
35
  - **No shell is involved.** The package starts `gh` directly and passes each
36
- value as one argument, so spaces and shell metacharacters in a title or a
37
- body are inert.
36
+ value as one argument. Spaces and shell metacharacters in a title or a
37
+ body have no effect.
38
38
  - **`gh` still parses its arguments.** A positional value that starts with `-`,
39
39
  such as a label name, can be read as a flag. Check untrusted input before you
40
40
  pass it.
@@ -51,5 +51,5 @@ not intend are in scope.
51
51
  and PR titles, bodies and comments travel in argv. Do not log the message
52
52
  where those are private. `createReview` sends its payload on stdin instead.
53
53
  - **`prMerge` needs the head sha.** GitHub refuses the merge when the PR head
54
- is no longer that sha, so a push made after review cannot ride in. Pass the
55
- sha you judged, not one read just before the call.
54
+ is no longer that sha, so a push made after review is not merged by this
55
+ call. Pass the sha you reviewed, not one read just before the call.
package/dist/async.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * `AsyncGh` — the awaitable GitHub client, with the same methods as {@link Gh}.
2
+ * `AsyncGh`: the awaitable GitHub client, with the same methods as {@link Gh}.
3
3
  *
4
- * Same three doors, promised: the plain method resolves to a value or rejects
5
- * (a `FailureError` carrying the classified `GhFailure` for a failed gh or output it cannot read;
6
- * a query for one thing resolves `null` when GitHub has no such thing),
7
- * `try` resolves to a `Result<T, GhFailure>`, `soft` resolves `null` for a
8
- * failure, and `raw`/`rawChecked` are the hatches. It is driven by the same pure verb
9
- * table as `Gh`, so the two cannot drift on argv or parsing. Use it where the
10
- * sync client would block the event loop: a fan-out of per-PR queries.
4
+ * It has the same three call styles, as Promises. The plain method resolves to
5
+ * a value or rejects with a `FailureError` carrying the classified `GhFailure`,
6
+ * for a failed gh or output it cannot read. A query for one thing resolves
7
+ * `null` when GitHub has no such thing. `try` resolves to a
8
+ * `Result<T, GhFailure>`. `soft` resolves `null` for a failure. `raw` and
9
+ * `rawChecked` run any `gh` call. It uses the same verb table as `Gh`, so the
10
+ * two cannot differ on argv or parsing. Use it where the sync client would
11
+ * block the event loop, such as many per-PR queries at once.
11
12
  */
12
13
  import { type AsyncCommandRunner, type CommandResult } from '@osdlc/command-seam';
13
14
  import { type Soft, type TriedAsync } from './doors.ts';
@@ -26,7 +27,7 @@ export interface AsyncCallOptions {
26
27
  * non-number.
27
28
  */
28
29
  timeoutMs?: number | undefined;
29
- /** Aborts the call; the runner stops the child. */
30
+ /** Aborts the call. The runner stops the child process. */
30
31
  signal?: AbortSignal | undefined;
31
32
  }
32
33
  /** What an {@link AsyncGh} is built with: the runner, and the repository and host it talks to. */
@@ -58,15 +59,15 @@ export declare class AsyncGh {
58
59
  get try(): TriedAsync<AsyncGh>;
59
60
  /** Each query again, resolving `null` instead of rejecting when `gh` fails. See `Gh.soft`. */
60
61
  get soft(): Soft<AsyncGh>;
61
- /** Unchecked passthrough for verbs not yet lifted into the API. */
62
+ /** Run any `gh` call and return the result, whatever the exit code. */
62
63
  raw(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
63
- /** Checked passthrough — rejects with the classified failure on a non-zero exit. */
64
+ /** Run any `gh` call. Rejects with the classified failure on a non-zero exit. */
64
65
  rawChecked(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
65
- /** Run one verb from the table, honouring its checked/unchecked contract. */
66
+ /** Run one verb from the table, checked or unchecked as the verb says. */
66
67
  private exec;
67
- /** {@link exec} for a verb that never answers `null` as a value: a failed or unreadable run rejects. */
68
+ /** {@link exec} for a verb that never returns `null` as a value. A failed or unreadable run rejects. */
68
69
  private read;
69
- /** Pull requests matching `opts`. Rejects when gh fails; `soft.prList` resolves null instead. */
70
+ /** Pull requests matching `opts`. Rejects when gh fails. `soft.prList` resolves null instead. */
70
71
  prList(opts?: PrListOptions & AsyncCallOptions): Promise<ListResult<PullRequest>>;
71
72
  /** One PR by number, branch or URL, with the heavy fields, or null. See `Gh.prView`. */
72
73
  prView(ref: number | string, opts?: {
@@ -81,7 +82,7 @@ export declare class AsyncGh {
81
82
  limit?: number | undefined;
82
83
  fields?: readonly string[] | undefined;
83
84
  } & AsyncCallOptions): Promise<PullRequest | null>;
84
- /** {@link prView}, re-asked until GitHub settles the merge state. See `Gh.prViewSettled`. */
85
+ /** {@link prView}, asked again until GitHub has computed the merge state. See `Gh.prViewSettled`. */
85
86
  prViewSettled(number: number, opts?: {
86
87
  attempts?: number | undefined;
87
88
  fields?: readonly string[] | undefined;
@@ -89,10 +90,10 @@ export declare class AsyncGh {
89
90
  } & AsyncCallOptions): Promise<PullRequest | null>;
90
91
  /** A PR's body text. Rejects when gh fails. */
91
92
  prBody(number: number, opts?: AsyncCallOptions): Promise<string>;
92
- /** A REST GET through `gh api`; failure is `ok: false`, never a rejection. */
93
+ /** A REST GET through `gh api`. Failure is `ok: false`, never a rejection. */
93
94
  api(endpoint: string, opts?: ApiOptions & AsyncCallOptions): Promise<ApiResponse>;
94
95
  /**
95
- * A GraphQL query; each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
96
+ * A GraphQL query. Resolves to each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
96
97
  *
97
98
  * @experimental
98
99
  */
@@ -103,11 +104,11 @@ export declare class AsyncGh {
103
104
  } & AsyncCallOptions): Promise<ListResult<Label>>;
104
105
  /** Issues matching `opts`. Rejects when gh fails. */
105
106
  issueList(opts?: IssueListOptions & AsyncCallOptions): Promise<ListResult<IssueRow>>;
106
- /** One issue with its labels; null when there is no such issue. */
107
+ /** One issue with its labels, or null when there is no such issue. */
107
108
  issueView(number: number, opts?: AsyncCallOptions): Promise<IssueRow | null>;
108
109
  /** An owner's repositories. Rejects when gh fails. See `Gh.repoList`. */
109
110
  repoList(owner: string, opts?: v.RepoListOptions & AsyncCallOptions): Promise<ListResult<Repository>>;
110
- /** One repository; null when there is no such repository. See `Gh.repoView`. */
111
+ /** One repository, or null when there is no such repository. See `Gh.repoView`. */
111
112
  repoView(repo?: string | undefined, opts?: {
112
113
  fields?: readonly string[] | undefined;
113
114
  } & AsyncCallOptions): Promise<Repository | null>;
@@ -119,11 +120,11 @@ export declare class AsyncGh {
119
120
  authenticated(): Promise<boolean>;
120
121
  /** Login state and what `gh auth status` printed. */
121
122
  authStatus(): Promise<AuthStatus>;
122
- /** True iff `gh` is installed AND authenticated; short-circuits. */
123
+ /** True iff `gh` is installed and authenticated. Skips the auth check when `gh` is not installed. */
123
124
  ready(): Promise<boolean>;
124
125
  /**
125
126
  * `{owner, repo}` parsed from the remote URL you pass, or null when it is
126
- * null or names no project. Pure, so it is sync on purpose.
127
+ * null or names no project. It starts no process, so it is sync on purpose.
127
128
  */
128
129
  ownerRepo(remoteUrl: string | null): OwnerRepo | null;
129
130
  /** Open a PR and resolve to its URL ('' if gh printed none). */
@@ -135,12 +136,12 @@ export declare class AsyncGh {
135
136
  }): Promise<void>;
136
137
  /** Rewrite just a PR's body. */
137
138
  editPrBody(number: number, body: string): Promise<void>;
138
- /** The PR's unified diff; rejects with the classified failure when gh cannot produce it. */
139
+ /** The PR's unified diff. Rejects with the classified failure when gh cannot produce it. */
139
140
  prDiff(number: number): Promise<string>;
140
- /** Post one conversation comment on a PR; resolves to its URL ('' if gh printed none). */
141
+ /** Post one conversation comment on a PR. Resolves to its URL ('' if gh printed none). */
141
142
  prComment(number: number, body: string): Promise<string>;
142
143
  /**
143
- * Create a `COMMENT` review with inline comments; resolves to the review's URL ('' if none came back).
144
+ * Create a `COMMENT` review with inline comments. Resolves to the review's URL ('' if none came back).
144
145
  *
145
146
  * @experimental
146
147
  */
@@ -150,17 +151,17 @@ export declare class AsyncGh {
150
151
  /** Mark a PR ready for review (`draft: false`) or convert it to a draft (`draft: true`). */
151
152
  prSetDraft(number: number, draft: boolean): Promise<void>;
152
153
  /**
153
- * Rewrite one PR conversation comment's body. `commentId` is the numeric REST id; anything else rejects with `RangeError`.
154
+ * Rewrite one PR conversation comment's body. `commentId` is the numeric REST id. Anything else rejects with `RangeError`.
154
155
  *
155
156
  * @experimental
156
157
  */
157
158
  issueCommentEdit(commentId: string, body: string): Promise<void>;
158
- /** Merge a PR; GitHub refuses when its head is no longer `opts.headSha`. */
159
+ /** Merge a PR. GitHub refuses when its head is no longer `opts.headSha`. */
159
160
  prMerge(number: number, opts: {
160
161
  method: v.PrMergeMethod;
161
162
  headSha: string;
162
163
  }): Promise<void>;
163
- /** A PR's review threads, or null when they could not be read completely. */
164
+ /** A PR's review threads. Rejects when they could not be read completely. `soft.prReviewThreads` resolves null instead. */
164
165
  prReviewThreads(number: number): Promise<v.ReviewThread[]>;
165
166
  /** Create a label (or force it to `opts`'s color/description if it exists). */
166
167
  createLabel(opts: v.CreateLabelOptions): Promise<void>;