@osdlc/typed-gh 0.6.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,40 @@
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
2
38
 
3
39
  ## 0.6.0
4
40
 
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
 
@@ -88,18 +90,69 @@ 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'
92
124
 
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. |
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
+ ```
98
130
 
99
- A query returns `null`, never an empty list on failure. `prList()` returning
100
- `{ rows: [], truncated: false }` means *this repository has no open PRs*.
101
- `null` means *we could not ask*. If the two were the same, an expired
102
- credential would read as "nothing to do".
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.
103
156
 
104
157
  ## A list says when its limit cut it short
105
158
 
@@ -108,9 +161,9 @@ one row more than `limit` and returns `{ rows, truncated }`: at most `limit`
108
161
  rows, and `truncated: true` when there were more.
109
162
 
110
163
  ```ts
111
- const page: ListResult<PullRequest> | null = gh.prList({ limit: 50 })
164
+ const page: ListResult<PullRequest> = gh.prList({ limit: 50 })
112
165
 
113
- if (page?.truncated) console.warn('more than 50 open PRs; raise the limit')
166
+ if (page.truncated) console.warn('more than 50 open PRs; raise the limit')
114
167
  ```
115
168
 
116
169
  `prList`, `issueList`, `labelList`, `repoList` and `searchPrs` all work this
@@ -130,7 +183,7 @@ acme.withRepo('acme/gadgets').prList()
130
183
  acme.api('repos/{owner}/{repo}/branches')
131
184
  ```
132
185
 
133
- The target goes into the argv, so a `CommandFailed` shows which repository a
186
+ The target goes into the argv, so a failure's `argv` shows which repository a
134
187
  call hit: `pr`, `issue` and `label` calls get `--repo github.acme.com/acme/widgets`,
135
188
  `api` calls get `--hostname` and a filled `{owner}/{repo}`, and `authStatus`
136
189
  asks about that host. `repoList` and `searchPrs` have no host flag in gh, so
@@ -213,7 +266,7 @@ about staleness, never the measure of it. Compute that from the commit graph
213
266
 
214
267
  ## Changing pull requests
215
268
 
216
- 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.
217
270
 
218
271
  ```ts
219
272
  const url = gh.createPr(options)
@@ -241,23 +294,23 @@ gh.prMerge(9, { method, headSha: 'abc1234' })
241
294
  ```
242
295
 
243
296
  `prReviewThreads(n)` reads the review threads, so you can look for open ones before you merge. It
244
- returns `null` when it could not read all of them (a failed call, an unexpected payload, or more
245
- 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".
246
299
 
247
300
  ```ts
248
- const threads: ReviewThread[] | null = gh.prReviewThreads(9)
301
+ const threads: ReviewThread[] = gh.prReviewThreads(9)
249
302
 
250
- threads?.filter((t) => !t.isResolved)
303
+ threads.filter((t) => !t.isResolved)
251
304
  ```
252
305
 
253
306
  ## Issues and labels
254
307
 
255
308
  ```ts
256
- const issue: IssueRow | null = gh.issueView(7)
257
- const triage: ListResult<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 })
258
311
  gh.setIssueLabel(7, 'status:triage', 'status:define')
259
312
 
260
- const labels: ListResult<Label> | null = gh.labelList({ limit: 100 })
313
+ const labels: ListResult<Label> = gh.labelList({ limit: 100 })
261
314
  gh.createLabel({ name: 'status:triage', color: '1f6feb', description: 'Triage stage' })
262
315
  gh.editLabel('status:triage', { description: 'Waiting for triage' })
263
316
  ```
@@ -279,7 +332,7 @@ const review = gh.createReview(9, {
279
332
  gh.issueCommentEdit('4204154439', 'Updated: CI is green.')
280
333
  ```
281
334
 
282
- `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.
283
336
 
284
337
  `createReview` is experimental. It posts a review of kind `COMMENT`: it cannot approve or request
285
338
  changes. Each inline comment sits on one line of the new side of the diff. It returns the review's
@@ -307,9 +360,9 @@ const prs = verb.parse(result)
307
360
  ## `AsyncGh`
308
361
 
309
362
  The awaitable form of `Gh`, with the same methods, for a fan-out that must not block the event loop.
310
- Same tri-modal contract (queries resolve to a value or `null` and never
311
- reject, mutations reject with `CommandFailed`, `raw`/`rawChecked` are the
312
- 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
313
366
  `AsyncCommandRunner` and defaults to `spawnRunnerAsync("gh")`.
314
367
 
315
368
  ```ts
@@ -317,7 +370,7 @@ import { AsyncGh } from '@osdlc/typed-gh'
317
370
 
318
371
  const gh = new AsyncGh('/repo')
319
372
  const prs = await Promise.all(branches.map((b) => gh.openPrFor(b)))
320
- const body = await gh.prBody(42) // string | null
373
+ const body = await gh.prBody(42) // string; rejects when gh fails
321
374
  await gh.editPrBody(42, `${body}\n\nfooter`)
322
375
  await gh.prComment(42, 'rebased') // the comment's URL, or ''
323
376
  await gh.prUpdateBranch(42, 'rebase')
@@ -346,8 +399,8 @@ without one. If `gh` prints more than one header block, the last one is read. `{
346
399
  const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).body
347
400
  ```
348
401
 
349
- `graphql` (experimental) runs a query and returns each page's `data`. It returns `null` when the
350
- call failed or any page has `errors` or no `data`: a partial answer is no answer. `nodesAt` joins
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
351
404
  one connection's `nodes` across the pages, and is `null` when any page lacks them.
352
405
 
353
406
  ```ts
@@ -369,9 +422,9 @@ one. `graphql` refuses a mutation with `RangeError`; send one through `raw`.
369
422
  ## Repositories and search
370
423
 
371
424
  ```ts
372
- const repos: ListResult<Repository> | null = gh.repoList('acme', { archived: false, limit: 200 })
373
- const repo: Repository | null = gh.repoView('acme/widgets')
374
- const hits: ListResult<PrSearchHit> | null = gh.searchPrs('TICKET-123', { owner: 'acme' })
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' })
375
428
  ```
376
429
 
377
430
  A `Repository` carries `nameWithOwner`, `owner`, `name`, `description`, `defaultBranch`,
@@ -422,9 +475,15 @@ Methods on both clients:
422
475
  - Issue and label queries: `issueList`, `issueView` and `labelList`.
423
476
  - Repository queries: `repoList` and `repoView`.
424
477
  - Issue and label changes: `setIssueLabel`, `createLabel`, `editLabel` and `issueCommentEdit`.
425
- - REST and GraphQL: `api` is a GET through `gh api` and `graphql` a query; neither 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.
426
479
  - Setup checks: `installed`, `authenticated`, `authStatus` and `ready`.
427
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`.
428
487
 
429
488
  ### Verbs
430
489
 
@@ -473,7 +532,7 @@ the escape hatches, `at`, `withRepo`, `prViewSettled`, `openPrFor`, `prFind`, `a
473
532
 
474
533
  ### From the command seam
475
534
 
476
- - `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.
477
536
  - `CommandRunner`, `AsyncCommandRunner`, `CommandResult` and `CommandRunOptions`: the runner types
478
537
  that appear in signatures.
479
538
 
@@ -514,4 +573,4 @@ shell, so nothing in it is specific to one shell.
514
573
 
515
574
  ## Licence
516
575
 
517
- `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).
package/dist/async.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * `AsyncGh` — the awaitable GitHub client, with the same methods as {@link Gh}.
3
3
  *
4
- * Same tri-modal contract, promised: queries resolve to parsed values and
5
- * never reject on a gh failure, mutations reject with `CommandFailed`, and
6
- * `raw`/`rawChecked` are the hatches. It is driven by the same pure verb
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
7
9
  * table as `Gh`, so the two cannot drift on argv or parsing. Use it where the
8
10
  * sync client would block the event loop: a fan-out of per-PR queries.
9
11
  */
10
12
  import { type AsyncCommandRunner, type CommandResult } from '@osdlc/command-seam';
13
+ import { type Soft, type TriedAsync } from './doors.ts';
11
14
  import type { IssueRow, Label, OwnerRepo, PrSearchHit, PullRequest, Repository } from './types.ts';
12
15
  import * as v from './verbs.ts';
13
16
  import type { ApiOptions, ApiResponse, AuthStatus, GhTarget, IssueListOptions, ListResult, PrListOptions } from './verbs.ts';
@@ -40,19 +43,31 @@ export declare class AsyncGh {
40
43
  readonly hostname: string | undefined;
41
44
  private readonly target;
42
45
  private readonly runner;
46
+ private tried;
47
+ private softened;
43
48
  constructor(cwd: string, opts?: AsyncGhOptions);
44
49
  /** The same client rooted at a different directory (same runner, repo and host). */
45
50
  at(cwd: string): AsyncGh;
46
51
  /** The same client aimed at another repository (same runner, directory and host). */
47
52
  withRepo(repo: string): AsyncGh;
53
+ /**
54
+ * The whole surface again, resolving to a `Result<T, GhFailure>` instead of
55
+ * rejecting. A query for one thing that finds nothing is `ok: true` with
56
+ * `null`. See `Gh.try`.
57
+ */
58
+ get try(): TriedAsync<AsyncGh>;
59
+ /** Each query again, resolving `null` instead of rejecting when `gh` fails. See `Gh.soft`. */
60
+ get soft(): Soft<AsyncGh>;
48
61
  /** Unchecked passthrough for verbs not yet lifted into the API. */
49
62
  raw(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
50
- /** Checked passthrough — rejects with `CommandFailed` on a non-zero exit. */
63
+ /** Checked passthrough — rejects with the classified failure on a non-zero exit. */
51
64
  rawChecked(argv: string[], opts?: AsyncCallOptions): Promise<CommandResult>;
52
65
  /** Run one verb from the table, honouring its checked/unchecked contract. */
53
66
  private exec;
54
- /** Pull requests matching `opts`, or null when gh failed. */
55
- prList(opts?: PrListOptions & AsyncCallOptions): Promise<ListResult<PullRequest> | null>;
67
+ /** {@link exec} for a verb that never answers `null` as a value: a failed or unreadable run rejects. */
68
+ private read;
69
+ /** Pull requests matching `opts`. Rejects when gh fails; `soft.prList` resolves null instead. */
70
+ prList(opts?: PrListOptions & AsyncCallOptions): Promise<ListResult<PullRequest>>;
56
71
  /** One PR by number, branch or URL, with the heavy fields, or null. See `Gh.prView`. */
57
72
  prView(ref: number | string, opts?: {
58
73
  fields?: readonly string[] | undefined;
@@ -72,32 +87,32 @@ export declare class AsyncGh {
72
87
  fields?: readonly string[] | undefined;
73
88
  sleep?: ((ms: number) => Promise<void>) | undefined;
74
89
  } & AsyncCallOptions): Promise<PullRequest | null>;
75
- /** A PR's body text, or null when gh failed. */
76
- prBody(number: number, opts?: AsyncCallOptions): Promise<string | null>;
90
+ /** A PR's body text. Rejects when gh fails. */
91
+ prBody(number: number, opts?: AsyncCallOptions): Promise<string>;
77
92
  /** A REST GET through `gh api`; failure is `ok: false`, never a rejection. */
78
93
  api(endpoint: string, opts?: ApiOptions & AsyncCallOptions): Promise<ApiResponse>;
79
94
  /**
80
- * A GraphQL query; each page's `data`, or null. See `Gh.graphql`.
95
+ * A GraphQL query; each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
81
96
  *
82
97
  * @experimental
83
98
  */
84
- graphql(query: string, variables?: Record<string, v.GraphqlVariable>, opts?: v.GraphqlOptions & AsyncCallOptions): Promise<unknown[] | null>;
85
- /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
99
+ graphql(query: string, variables?: Record<string, v.GraphqlVariable>, opts?: v.GraphqlOptions & AsyncCallOptions): Promise<unknown[]>;
100
+ /** Every label on the repository (up to `opts.limit`, default 1000). Rejects when gh fails. */
86
101
  labelList(opts?: {
87
102
  limit?: number | undefined;
88
- } & AsyncCallOptions): Promise<ListResult<Label> | null>;
89
- /** Issues matching `opts`, or null when gh failed. */
90
- issueList(opts?: IssueListOptions & AsyncCallOptions): Promise<ListResult<IssueRow> | null>;
91
- /** One issue with its labels, or null when gh failed. */
103
+ } & AsyncCallOptions): Promise<ListResult<Label>>;
104
+ /** Issues matching `opts`. Rejects when gh fails. */
105
+ issueList(opts?: IssueListOptions & AsyncCallOptions): Promise<ListResult<IssueRow>>;
106
+ /** One issue with its labels; null when there is no such issue. */
92
107
  issueView(number: number, opts?: AsyncCallOptions): Promise<IssueRow | null>;
93
- /** An owner's repositories, or null when gh failed. See `Gh.repoList`. */
94
- repoList(owner: string, opts?: v.RepoListOptions & AsyncCallOptions): Promise<ListResult<Repository> | null>;
95
- /** One repository, or null when gh failed. See `Gh.repoView`. */
108
+ /** An owner's repositories. Rejects when gh fails. See `Gh.repoList`. */
109
+ repoList(owner: string, opts?: v.RepoListOptions & AsyncCallOptions): Promise<ListResult<Repository>>;
110
+ /** One repository; null when there is no such repository. See `Gh.repoView`. */
96
111
  repoView(repo?: string | undefined, opts?: {
97
112
  fields?: readonly string[] | undefined;
98
113
  } & AsyncCallOptions): Promise<Repository | null>;
99
- /** Pull requests across repositories matching a search query, or null. See `Gh.searchPrs`. */
100
- searchPrs(query: string, opts?: v.SearchPrsOptions & AsyncCallOptions): Promise<ListResult<PrSearchHit> | null>;
114
+ /** Pull requests across repositories matching a search query. Rejects when gh fails. See `Gh.searchPrs`. */
115
+ searchPrs(query: string, opts?: v.SearchPrsOptions & AsyncCallOptions): Promise<ListResult<PrSearchHit>>;
101
116
  /** True iff the `gh` binary is on PATH and runs. */
102
117
  installed(): Promise<boolean>;
103
118
  /** True iff `gh` holds credentials. */
@@ -120,7 +135,7 @@ export declare class AsyncGh {
120
135
  }): Promise<void>;
121
136
  /** Rewrite just a PR's body. */
122
137
  editPrBody(number: number, body: string): Promise<void>;
123
- /** The PR's unified diff; rejects with `CommandFailed` when gh cannot produce it. */
138
+ /** The PR's unified diff; rejects with the classified failure when gh cannot produce it. */
124
139
  prDiff(number: number): Promise<string>;
125
140
  /** Post one conversation comment on a PR; resolves to its URL ('' if gh printed none). */
126
141
  prComment(number: number, body: string): Promise<string>;
@@ -146,7 +161,7 @@ export declare class AsyncGh {
146
161
  headSha: string;
147
162
  }): Promise<void>;
148
163
  /** A PR's review threads, or null when they could not be read completely. */
149
- prReviewThreads(number: number): Promise<v.ReviewThread[] | null>;
164
+ prReviewThreads(number: number): Promise<v.ReviewThread[]>;
150
165
  /** Create a label (or force it to `opts`'s color/description if it exists). */
151
166
  createLabel(opts: v.CreateLabelOptions): Promise<void>;
152
167
  /** Recolor and/or redescribe an existing label. */
@@ -157,4 +172,3 @@ export declare class AsyncGh {
157
172
  /** Swap an issue's label in a single `gh issue edit` call. */
158
173
  setIssueLabel(number: number, currentLabel: string | null, newLabel: string): Promise<void>;
159
174
  }
160
- //# sourceMappingURL=async.d.ts.map
package/dist/async.js CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * `AsyncGh` — the awaitable GitHub client, with the same methods as {@link Gh}.
3
3
  *
4
- * Same tri-modal contract, promised: queries resolve to parsed values and
5
- * never reject on a gh failure, mutations reject with `CommandFailed`, and
6
- * `raw`/`rawChecked` are the hatches. It is driven by the same pure verb
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
7
9
  * table as `Gh`, so the two cannot drift on argv or parsing. Use it where the
8
10
  * sync client would block the event loop: a fan-out of per-PR queries.
9
11
  */
10
- import { runCheckedAsync, resolveRunnerAsync, } from '@osdlc/command-seam';
12
+ import { resolveRunnerAsync, } from '@osdlc/command-seam';
13
+ import { runCheckedGhAsync, settle, softAsync, triedAsync, } from "./doors.js";
11
14
  import { ownerRepoSlug, parseOwnerRepo, parsePrRef } from "./repo.js";
12
15
  import * as v from "./verbs.js";
13
16
  /** How long to wait between merge-state retries, in milliseconds. */
@@ -22,6 +25,8 @@ export class AsyncGh {
22
25
  hostname;
23
26
  target;
24
27
  runner;
28
+ tried;
29
+ softened;
25
30
  constructor(cwd, opts = {}) {
26
31
  v.checkTarget(opts);
27
32
  this.cwd = cwd;
@@ -38,14 +43,26 @@ export class AsyncGh {
38
43
  withRepo(repo) {
39
44
  return new AsyncGh(this.cwd, { runner: this.runner, repo, hostname: this.hostname });
40
45
  }
46
+ /**
47
+ * The whole surface again, resolving to a `Result<T, GhFailure>` instead of
48
+ * rejecting. A query for one thing that finds nothing is `ok: true` with
49
+ * `null`. See `Gh.try`.
50
+ */
51
+ get try() {
52
+ return (this.tried ??= triedAsync(this));
53
+ }
54
+ /** Each query again, resolving `null` instead of rejecting when `gh` fails. See `Gh.soft`. */
55
+ get soft() {
56
+ return (this.softened ??= softAsync(this));
57
+ }
41
58
  // ── escape hatches ────────────────────────────────────────────────────────
42
59
  /** Unchecked passthrough for verbs not yet lifted into the API. */
43
60
  raw(argv, opts = {}) {
44
61
  return this.runner.run(argv, { cwd: this.cwd, ...opts });
45
62
  }
46
- /** Checked passthrough — rejects with `CommandFailed` on a non-zero exit. */
63
+ /** Checked passthrough — rejects with the classified failure on a non-zero exit. */
47
64
  rawChecked(argv, opts = {}) {
48
- return runCheckedAsync(this.runner, argv, { cwd: this.cwd, ...opts });
65
+ return runCheckedGhAsync(this.runner, argv, { cwd: this.cwd, ...opts });
49
66
  }
50
67
  /** Run one verb from the table, honouring its checked/unchecked contract. */
51
68
  async exec(verb, call = {}) {
@@ -59,14 +76,18 @@ export class AsyncGh {
59
76
  if (call.signal !== undefined)
60
77
  opts.signal = call.signal;
61
78
  const result = verb.checked
62
- ? await runCheckedAsync(this.runner, verb.argv, opts)
79
+ ? await runCheckedGhAsync(this.runner, verb.argv, opts)
63
80
  : await this.runner.run(verb.argv, opts);
64
- return verb.parse(result);
81
+ return settle(verb, result, this.runner.binary);
82
+ }
83
+ /** {@link exec} for a verb that never answers `null` as a value: a failed or unreadable run rejects. */
84
+ async read(verb, call = {}) {
85
+ return (await this.exec(verb, call));
65
86
  }
66
87
  // ── queries ───────────────────────────────────────────────────────────────
67
- /** Pull requests matching `opts`, or null when gh failed. */
88
+ /** Pull requests matching `opts`. Rejects when gh fails; `soft.prList` resolves null instead. */
68
89
  prList(opts = {}) {
69
- return this.exec(v.prList(opts, this.target), opts);
90
+ return this.read(v.prList(opts, this.target), opts);
70
91
  }
71
92
  /** One PR by number, branch or URL, with the heavy fields, or null. See `Gh.prView`. */
72
93
  prView(ref, opts = {}) {
@@ -95,7 +116,7 @@ export class AsyncGh {
95
116
  timeoutMs: opts.timeoutMs,
96
117
  signal: opts.signal,
97
118
  });
98
- return v.pickPrForBranch(rows?.rows ?? null, branch);
119
+ return v.pickPrForBranch(rows.rows, branch);
99
120
  }
100
121
  /** {@link prView}, re-asked until GitHub settles the merge state. See `Gh.prViewSettled`. */
101
122
  async prViewSettled(number, opts = {}) {
@@ -111,45 +132,45 @@ export class AsyncGh {
111
132
  }
112
133
  return last;
113
134
  }
114
- /** A PR's body text, or null when gh failed. */
135
+ /** A PR's body text. Rejects when gh fails. */
115
136
  prBody(number, opts = {}) {
116
- return this.exec(v.prBody(number, this.target), opts);
137
+ return this.read(v.prBody(number, this.target), opts);
117
138
  }
118
139
  /** A REST GET through `gh api`; failure is `ok: false`, never a rejection. */
119
140
  api(endpoint, opts = {}) {
120
141
  return this.exec(v.api(endpoint, opts, this.target), opts);
121
142
  }
122
143
  /**
123
- * A GraphQL query; each page's `data`, or null. See `Gh.graphql`.
144
+ * A GraphQL query; each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
124
145
  *
125
146
  * @experimental
126
147
  */
127
148
  graphql(query, variables = {}, opts = {}) {
128
- return this.exec(v.graphql(query, variables, opts, this.target), opts);
149
+ return this.read(v.graphql(query, variables, opts, this.target), opts);
129
150
  }
130
- /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
151
+ /** Every label on the repository (up to `opts.limit`, default 1000). Rejects when gh fails. */
131
152
  labelList(opts = {}) {
132
- return this.exec(v.labelList(opts, this.target), opts);
153
+ return this.read(v.labelList(opts, this.target), opts);
133
154
  }
134
- /** Issues matching `opts`, or null when gh failed. */
155
+ /** Issues matching `opts`. Rejects when gh fails. */
135
156
  issueList(opts = {}) {
136
- return this.exec(v.issueList(opts, this.target), opts);
157
+ return this.read(v.issueList(opts, this.target), opts);
137
158
  }
138
- /** One issue with its labels, or null when gh failed. */
159
+ /** One issue with its labels; null when there is no such issue. */
139
160
  issueView(number, opts = {}) {
140
161
  return this.exec(v.issueView(number, this.target), opts);
141
162
  }
142
- /** An owner's repositories, or null when gh failed. See `Gh.repoList`. */
163
+ /** An owner's repositories. Rejects when gh fails. See `Gh.repoList`. */
143
164
  repoList(owner, opts = {}) {
144
- return this.exec(v.repoList(owner, opts, this.target), opts);
165
+ return this.read(v.repoList(owner, opts, this.target), opts);
145
166
  }
146
- /** One repository, or null when gh failed. See `Gh.repoView`. */
167
+ /** One repository; null when there is no such repository. See `Gh.repoView`. */
147
168
  repoView(repo, opts = {}) {
148
169
  return this.exec(v.repoView(repo, opts.fields ?? v.REPO_FIELDS, this.target), opts);
149
170
  }
150
- /** Pull requests across repositories matching a search query, or null. See `Gh.searchPrs`. */
171
+ /** Pull requests across repositories matching a search query. Rejects when gh fails. See `Gh.searchPrs`. */
151
172
  searchPrs(query, opts = {}) {
152
- return this.exec(v.searchPrs(query, opts, this.target), opts);
173
+ return this.read(v.searchPrs(query, opts, this.target), opts);
153
174
  }
154
175
  /** True iff the `gh` binary is on PATH and runs. */
155
176
  installed() {
@@ -187,7 +208,7 @@ export class AsyncGh {
187
208
  editPrBody(number, body) {
188
209
  return this.exec(v.editPrBody(number, body, this.target));
189
210
  }
190
- /** The PR's unified diff; rejects with `CommandFailed` when gh cannot produce it. */
211
+ /** The PR's unified diff; rejects with the classified failure when gh cannot produce it. */
191
212
  prDiff(number) {
192
213
  return this.exec(v.prDiff(number, this.target));
193
214
  }
@@ -226,7 +247,7 @@ export class AsyncGh {
226
247
  }
227
248
  /** A PR's review threads, or null when they could not be read completely. */
228
249
  prReviewThreads(number) {
229
- return this.exec(v.prReviewThreads(number, this.target));
250
+ return this.read(v.prReviewThreads(number, this.target));
230
251
  }
231
252
  /** Create a label (or force it to `opts`'s color/description if it exists). */
232
253
  createLabel(opts) {