@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/dist/sync.js CHANGED
@@ -7,11 +7,15 @@
7
7
  * REST — the credential already exists in the user's keychain, and a library
8
8
  * that reaches for `GITHUB_TOKEN` is a library that can leak one.
9
9
  *
10
- * Tri-modal, same as the git client: queries return parsed values and never
11
- * throw, mutations throw `CommandFailed`, and `raw`/`rawChecked` are the
12
- * escape hatches.
10
+ * The same three doors as the git client. The plain method returns a value
11
+ * or throws: a failed `gh` throws a `FailureError` carrying the classified
12
+ * `GhFailure`, output that cannot be read throws one of kind `unreadable`, and a query for one thing (`prView`, `issueView`,
13
+ * `repoView`) answers `null` when GitHub says there is no such thing.
14
+ * `gh.try.*` returns the failure as a `Result<T, GhFailure>`; `gh.soft.*` returns
15
+ * `null` for it. `raw`/`rawChecked` are the escape hatches.
13
16
  */
14
- import { runChecked, resolveRunner, spawnRunner, } from '@osdlc/command-seam';
17
+ import { resolveRunner, spawnRunner, } from '@osdlc/command-seam';
18
+ import { runCheckedGh, settle, softSync, triedSync } from "./doors.js";
15
19
  import * as v from "./verbs.js";
16
20
  import { ownerRepoSlug, parseOwnerRepo, parsePrRef } from "./repo.js";
17
21
  /** Default factory: real, spawnSync-backed runners. */
@@ -38,6 +42,8 @@ export class Gh {
38
42
  hostname;
39
43
  target;
40
44
  runner;
45
+ tried;
46
+ softened;
41
47
  constructor(cwd, opts = {}) {
42
48
  v.checkTarget(opts);
43
49
  this.cwd = cwd;
@@ -54,14 +60,35 @@ export class Gh {
54
60
  withRepo(repo) {
55
61
  return new Gh(this.cwd, { runner: this.runner, repo, hostname: this.hostname });
56
62
  }
63
+ /**
64
+ * The whole surface again, returning a `Result<T, GhFailure>` instead of
65
+ * throwing: `gh.try.prList()` is `{ ok: true, value } | { ok: false, error }`.
66
+ * A query for one thing that finds nothing is `ok: true` with `null`. See
67
+ * `./doors.ts`.
68
+ *
69
+ * The type leaves out the escape hatches and the pure helpers; at runtime
70
+ * the proxy wraps `raw` and `rawChecked` too. Call those on the client itself.
71
+ */
72
+ get try() {
73
+ return (this.tried ??= triedSync(this));
74
+ }
75
+ /**
76
+ * Each query again, returning `null` instead of throwing when `gh` fails. A
77
+ * mutation has no soft form. Do not gate a write on a `null` from `soft`:
78
+ * it is the answer for "could not ask" as well as for "nothing there". Use
79
+ * `try` where the difference matters.
80
+ */
81
+ get soft() {
82
+ return (this.softened ??= softSync(this));
83
+ }
57
84
  // ── escape hatches ────────────────────────────────────────────────────────
58
85
  /** Unchecked passthrough for verbs not yet lifted into the API. */
59
86
  raw(argv, opts = {}) {
60
87
  return this.runner.run(argv, { cwd: this.cwd, ...opts });
61
88
  }
62
- /** Checked passthrough — throws `CommandFailed` on a non-zero exit. */
89
+ /** Checked passthrough — throws the classified failure on a non-zero exit. */
63
90
  rawChecked(argv, opts = {}) {
64
- return runChecked(this.runner, argv, { cwd: this.cwd, ...opts });
91
+ return runCheckedGh(this.runner, argv, { cwd: this.cwd, ...opts });
65
92
  }
66
93
  /** Run one verb from the table, honouring its checked/unchecked contract. */
67
94
  exec(verb, timeoutMs) {
@@ -73,18 +100,22 @@ export class Gh {
73
100
  if (timeoutMs !== undefined)
74
101
  opts.timeoutMs = timeoutMs;
75
102
  const result = verb.checked
76
- ? runChecked(this.runner, verb.argv, opts)
103
+ ? runCheckedGh(this.runner, verb.argv, opts)
77
104
  : this.runner.run(verb.argv, opts);
78
- return verb.parse(result);
105
+ return settle(verb, result, this.runner.binary);
106
+ }
107
+ /** {@link exec} for a verb that never answers `null` as a value: a failed or unreadable run throws. */
108
+ read(verb, timeoutMs) {
109
+ return this.exec(verb, timeoutMs);
79
110
  }
80
111
  // ── queries ───────────────────────────────────────────────────────────────
81
- /** Pull requests matching `opts`, or null when gh failed. */
112
+ /** Pull requests matching `opts`. Throws when gh fails; `soft.prList` gives null instead. */
82
113
  prList(opts = {}) {
83
- return this.exec(v.prList(opts, this.target), opts.timeoutMs);
114
+ return this.read(v.prList(opts, this.target), opts.timeoutMs);
84
115
  }
85
116
  /**
86
- * One PR by number, branch or URL, with the heavy fields; null when gh
87
- * failed or there is no such PR.
117
+ * One PR by number, branch or URL, with the heavy fields; null when there is
118
+ * no such PR. Throws when gh fails otherwise.
88
119
  */
89
120
  prView(ref, opts = {}) {
90
121
  return this.exec(v.prView(ref, opts.fields ?? v.PR_VIEW_FIELDS, this.target), opts.timeoutMs);
@@ -128,7 +159,7 @@ export class Gh {
128
159
  limit: opts.limit ?? 5,
129
160
  fields: opts.fields ?? v.PR_REF_FIELDS,
130
161
  });
131
- return v.pickPrForBranch(rows?.rows ?? null, branch);
162
+ return v.pickPrForBranch(rows.rows, branch);
132
163
  }
133
164
  /**
134
165
  * {@link prView}, re-asked until GitHub settles the merge state.
@@ -155,49 +186,49 @@ export class Gh {
155
186
  }
156
187
  return last;
157
188
  }
158
- /** A PR's body text, or null when gh failed. */
189
+ /** A PR's body text. Throws when gh fails. */
159
190
  prBody(number, opts = {}) {
160
- return this.exec(v.prBody(number, this.target), opts.timeoutMs);
191
+ return this.read(v.prBody(number, this.target), opts.timeoutMs);
161
192
  }
162
193
  /**
163
- * A REST GET through `gh api`. Never throws: failure is `ok: false` on the
164
- * returned {@link ApiResponse}.
194
+ * A REST GET through `gh api`. Never throws for a failed call, in any door:
195
+ * failure is `ok: false` on the returned {@link ApiResponse}.
165
196
  */
166
197
  api(endpoint, opts = {}) {
167
198
  return this.exec(v.api(endpoint, opts, this.target), opts.timeoutMs);
168
199
  }
169
200
  /**
170
- * A GraphQL query; each page's `data`, or null on a failed or partial
171
- * result. See the `graphql` verb.
201
+ * A GraphQL query; each page's `data`. Throws on a failed or partial result;
202
+ * `soft.graphql` gives null. See the `graphql` verb.
172
203
  *
173
204
  * @experimental
174
205
  */
175
206
  graphql(query, variables = {}, opts = {}) {
176
- return this.exec(v.graphql(query, variables, opts, this.target), opts.timeoutMs);
207
+ return this.read(v.graphql(query, variables, opts, this.target), opts.timeoutMs);
177
208
  }
178
- /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
209
+ /** Every label on the repository (up to `opts.limit`, default 1000). Throws when gh fails. */
179
210
  labelList(opts = {}) {
180
- return this.exec(v.labelList(opts, this.target), opts.timeoutMs);
211
+ return this.read(v.labelList(opts, this.target), opts.timeoutMs);
181
212
  }
182
- /** Issues matching `opts` (open by default, up to 200), or null when gh failed. */
213
+ /** Issues matching `opts` (open by default, up to 200). Throws when gh fails. */
183
214
  issueList(opts = {}) {
184
- return this.exec(v.issueList(opts, this.target), opts.timeoutMs);
215
+ return this.read(v.issueList(opts, this.target), opts.timeoutMs);
185
216
  }
186
- /** One issue with its labels, or null when gh failed. */
217
+ /** One issue with its labels; null when there is no such issue. Throws when gh fails otherwise. */
187
218
  issueView(number, opts = {}) {
188
219
  return this.exec(v.issueView(number, this.target), opts.timeoutMs);
189
220
  }
190
- /** An owner's repositories (up to `opts.limit`, default 100), or null when gh failed. */
221
+ /** An owner's repositories (up to `opts.limit`, default 100). Throws when gh fails. */
191
222
  repoList(owner, opts = {}) {
192
- return this.exec(v.repoList(owner, opts, this.target), opts.timeoutMs);
223
+ return this.read(v.repoList(owner, opts, this.target), opts.timeoutMs);
193
224
  }
194
- /** One repository: `repo`, else the client's repo, else the clone at `cwd`. Null when gh failed. */
225
+ /** One repository: `repo`, else the client's repo, else the clone at `cwd`. Null when there is no such repository. */
195
226
  repoView(repo, opts = {}) {
196
227
  return this.exec(v.repoView(repo, opts.fields ?? v.REPO_FIELDS, this.target), opts.timeoutMs);
197
228
  }
198
- /** Pull requests across repositories matching a search query, or null when gh failed. */
229
+ /** Pull requests across repositories matching a search query. Throws when gh fails. */
199
230
  searchPrs(query, opts = {}) {
200
- return this.exec(v.searchPrs(query, opts, this.target), opts.timeoutMs);
231
+ return this.read(v.searchPrs(query, opts, this.target), opts.timeoutMs);
201
232
  }
202
233
  /** True iff the `gh` binary is on PATH and runs. */
203
234
  installed() {
@@ -242,7 +273,7 @@ export class Gh {
242
273
  editPr(number, opts) {
243
274
  this.exec(v.editPr(number, opts, this.target));
244
275
  }
245
- /** The PR's unified diff; throws `CommandFailed` when gh cannot produce it. */
276
+ /** The PR's unified diff; throws the classified failure when gh cannot produce it. */
246
277
  prDiff(number) {
247
278
  return this.exec(v.prDiff(number, this.target));
248
279
  }
@@ -282,9 +313,9 @@ export class Gh {
282
313
  prMerge(number, opts) {
283
314
  this.exec(v.prMerge(number, opts, this.target));
284
315
  }
285
- /** A PR's review threads, or null when they could not be read completely. */
316
+ /** A PR's review threads. Throws when they could not be read completely. */
286
317
  prReviewThreads(number) {
287
- return this.exec(v.prReviewThreads(number, this.target));
318
+ return this.read(v.prReviewThreads(number, this.target));
288
319
  }
289
320
  /** Create a label (or force it to `opts`'s color/description if it already exists). */
290
321
  createLabel(opts) {
package/dist/types.d.ts CHANGED
@@ -186,4 +186,3 @@ export interface PrSearchHit {
186
186
  updatedAt: string | null;
187
187
  closedAt: string | null;
188
188
  }
189
- //# sourceMappingURL=types.d.ts.map
package/dist/verbs.d.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  * asynchronous fan-out drive the same definitions without either duplicating
8
8
  * the other's parsing.
9
9
  *
10
- * `checked` marks a verb whose failure is thrown with stderr: every mutation,
11
- * and `prDiff`, which is a read. Other queries are unchecked and fold failure
12
- * into an absence value.
10
+ * `checked` marks a verb the runner throws for on a non-zero exit, with
11
+ * stderr: every mutation, and `prDiff`, which is a read. Other queries are run
12
+ * unchecked and `parse` folds a failure into `null`, which is the verb's
13
+ * *soft* reading. The clients' plain door reads them through `settle` (see
14
+ * `doors.ts`) instead: a failure throws, except where the verb declares
15
+ * `absent` or is a `probe`.
13
16
  */
14
17
  import { type CommandResult } from '@osdlc/command-seam';
15
18
  import type { IssueRow, Label, PrSearchHit, PullRequest, Repository } from './types.ts';
@@ -31,6 +34,19 @@ export interface GhVerb<T> {
31
34
  * on `gh search` and `gh repo list`.
32
35
  */
33
36
  env?: Record<string, string> | undefined;
37
+ /**
38
+ * True for a read whose failure is part of its answer: `installed`,
39
+ * `authStatus` and `api` return what `parse` makes of any result, in every
40
+ * door. Other reads fail the plain door when gh exits non-zero.
41
+ */
42
+ probe?: boolean | undefined;
43
+ /**
44
+ * For a read of one thing: whether a non-zero result means GitHub has no such
45
+ * thing (`prView`, `issueView`, `repoView`). The plain door then returns what
46
+ * `parse` makes of it, `null`, because absence is an answer. Other non-zero
47
+ * results fail the call.
48
+ */
49
+ absent?: ((result: CommandResult) => boolean) | undefined;
34
50
  parse(result: CommandResult): T;
35
51
  }
36
52
  /**
@@ -461,4 +477,3 @@ export declare function branchPrFields(fields: readonly string[]): string[];
461
477
  * slow, so re-ask a few times before believing it.
462
478
  */
463
479
  export declare function needsMergeStateRetry(pr: PullRequest): boolean;
464
- //# sourceMappingURL=verbs.d.ts.map
package/dist/verbs.js CHANGED
@@ -7,13 +7,21 @@
7
7
  * asynchronous fan-out drive the same definitions without either duplicating
8
8
  * the other's parsing.
9
9
  *
10
- * `checked` marks a verb whose failure is thrown with stderr: every mutation,
11
- * and `prDiff`, which is a read. Other queries are unchecked and fold failure
12
- * into an absence value.
10
+ * `checked` marks a verb the runner throws for on a non-zero exit, with
11
+ * stderr: every mutation, and `prDiff`, which is a read. Other queries are run
12
+ * unchecked and `parse` folds a failure into `null`, which is the verb's
13
+ * *soft* reading. The clients' plain door reads them through `settle` (see
14
+ * `doors.ts`) instead: a failure throws, except where the verb declares
15
+ * `absent` or is a `probe`.
13
16
  */
14
17
  import { stderrOrError } from '@osdlc/command-seam';
15
18
  import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from "./parse.js";
19
+ import { isAbsent } from "./failure.js";
16
20
  import { isRepoSlug, parsePrUrl } from "./repo.js";
21
+ /** Whether a failed run says GitHub has no such `thing`: read from the same text the classifier reads. */
22
+ function noSuch(thing) {
23
+ return (r) => isAbsent(thing, `${r.stderr}\n${r.stdout}`);
24
+ }
17
25
  const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d+)?$/;
18
26
  /**
19
27
  * Throw `RangeError` unless `target.repo` is `owner/name` and
@@ -300,6 +308,7 @@ export function prView(ref, fields = PR_VIEW_FIELDS, target) {
300
308
  ],
301
309
  checked: false,
302
310
  readOnly: true,
311
+ absent: noSuch('pr'),
303
312
  parse: oneOrNull,
304
313
  };
305
314
  }
@@ -354,6 +363,7 @@ export function api(endpoint, opts = {}, target) {
354
363
  argv,
355
364
  checked: false,
356
365
  readOnly: true,
366
+ probe: true,
357
367
  parse(r) {
358
368
  const response = parseApi(r, withHeaders);
359
369
  return paginate ? { ...response, body: flattenPages(response.body) } : response;
@@ -527,7 +537,13 @@ function parseApi(r, withHeaders) {
527
537
  }
528
538
  /** `gh --version` — the install probe. True when `gh` runs. */
529
539
  export function installed() {
530
- return { argv: ['--version'], checked: false, readOnly: true, parse: (r) => r.exitCode === 0 };
540
+ return {
541
+ argv: ['--version'],
542
+ checked: false,
543
+ readOnly: true,
544
+ probe: true,
545
+ parse: (r) => r.exitCode === 0,
546
+ };
531
547
  }
532
548
  /** `gh auth status [--hostname <host>]` — the credential probe, for one host when the target names it. */
533
549
  export function authStatus(target) {
@@ -535,6 +551,7 @@ export function authStatus(target) {
535
551
  argv: ['auth', 'status', ...hostArgs(target)],
536
552
  checked: false,
537
553
  readOnly: true,
554
+ probe: true,
538
555
  parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
539
556
  };
540
557
  }
@@ -576,6 +593,7 @@ export function issueView(number, target) {
576
593
  argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
577
594
  checked: false,
578
595
  readOnly: true,
596
+ absent: noSuch('issue'),
579
597
  parse: oneIssueOrNull,
580
598
  };
581
599
  }
@@ -660,6 +678,7 @@ export function repoView(repo, fields = REPO_FIELDS, target) {
660
678
  argv,
661
679
  checked: false,
662
680
  readOnly: true,
681
+ absent: noSuch('repo'),
663
682
  parse(r) {
664
683
  if (r.exitCode !== 0)
665
684
  return null;
package/package.json CHANGED
@@ -1,26 +1,19 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A typed GitHub client for TypeScript over the gh CLI. A total PullRequest/Review/CheckRun model, pure argv builders and parsers, and a tri-modal client — queries return parsed values, mutations throw, raw() is the escape hatch. Uses gh's own credentials; reads no token.",
5
5
  "license": "MIT OR Apache-2.0",
6
+ "author": "sksizer",
6
7
  "type": "module",
7
8
  "sideEffects": false,
8
9
  "exports": {
9
10
  ".": {
10
- "bun": "./index.ts",
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
13
  }
14
14
  },
15
15
  "files": [
16
16
  "dist/",
17
- "index.ts",
18
- "sync.ts",
19
- "async.ts",
20
- "verbs.ts",
21
- "parse.ts",
22
- "repo.ts",
23
- "types.ts",
24
17
  "README.md",
25
18
  "CHANGELOG.md",
26
19
  "SECURITY.md",
@@ -29,11 +22,12 @@
29
22
  "LICENSE-APACHE"
30
23
  ],
31
24
  "dependencies": {
32
- "@osdlc/typed-git": "^0.5.3",
33
- "@osdlc/command-seam": "^0.8.0"
25
+ "@osdlc/command-seam": "^0.9.0",
26
+ "@osdlc/typed-git": "^0.6.0",
27
+ "@osdlc/result": "^0.1.0"
34
28
  },
35
29
  "engines": {
36
- "node": ">=22"
30
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
37
31
  },
38
32
  "publishConfig": {
39
33
  "access": "public"
@@ -54,22 +48,24 @@
54
48
  "dependency-injection"
55
49
  ],
56
50
  "devDependencies": {
57
- "@types/bun": "^1.1.14",
58
51
  "@types/node": "^26.1.0",
52
+ "@vitest/coverage-v8": "^5.0.5",
59
53
  "oxlint": "^1.65.0",
60
54
  "prettier": "3.9.6",
61
55
  "typescript": "~6.0.3",
62
- "@osdlc/prettier-config": "^0.3.0",
63
- "@osdlc/tsconfig": "^0.4.0"
56
+ "vitest": "^5.0.5",
57
+ "@osdlc/prettier-config": "^0.3.1",
58
+ "@osdlc/tsconfig": "^0.4.1"
64
59
  },
65
60
  "scripts": {
66
61
  "build": "tsc -p tsconfig.build.json",
67
62
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
68
- "test": "bun test",
63
+ "test": "vitest run",
69
64
  "smoke": "node tests/smoke.mjs",
70
65
  "format": "prettier --write .",
71
66
  "format:check": "prettier --check .",
72
67
  "lint": "oxlint .",
73
- "typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit"
68
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
69
+ "coverage": "vitest run --coverage"
74
70
  }
75
71
  }