@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/dist/verbs.js CHANGED
@@ -1,28 +1,39 @@
1
1
  /**
2
- * The verb table — every `gh` invocation this library knows, as data.
2
+ * The verb table: every `gh` call this library knows, as data.
3
3
  *
4
- * Same shape and same reasoning as `@osdlc/typed-git`'s: a {@link GhVerb}
5
- * pairs the argv to run with the function that reads the result, and nothing
6
- * here spawns anything. That is what lets a synchronous client and an
7
- * asynchronous fan-out drive the same definitions without either duplicating
8
- * the other's parsing.
4
+ * It has the same shape as `@osdlc/typed-git`'s. A {@link GhVerb} pairs the
5
+ * argv to run with the function that reads the result, and nothing here
6
+ * starts a process. So the sync client, the async client and callers with
7
+ * their own runner all use the same definitions and the same parsing.
9
8
  *
10
9
  * `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`.
10
+ * stderr. That is every mutation, and `prDiff`, which is a read. Other
11
+ * queries run unchecked, and `parse` turns a failure into `null`, which is
12
+ * what `soft` returns. The clients' methods that throw read them through
13
+ * `settle` (see `doors.ts`) instead. There a failure throws, except where the
14
+ * verb declares `absent` or is a `probe`.
16
15
  */
17
16
  import { stderrOrError } from '@osdlc/command-seam';
18
17
  import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from "./parse.js";
19
18
  import { isAbsent } from "./failure.js";
20
19
  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. */
20
+ /** Whether a failed run says GitHub has no such `thing`. Reads the same text the classifier reads. */
22
21
  function noSuch(thing) {
23
22
  return (r) => isAbsent(thing, `${r.stderr}\n${r.stdout}`);
24
23
  }
24
+ /**
25
+ * A bare host, with an optional port: letters, digits, dots and hyphens, that
26
+ * starts and ends with a letter or digit, then optionally `:` and digits
27
+ * (`github.com`, `ghe.acme.com`, `localhost:8080`). No scheme, path, spaces or
28
+ * leading `-`, so the value cannot be read by gh as a flag or a URL. It does
29
+ * not check that the host exists or that its labels are well formed, and it
30
+ * does not accept IPv6 literals.
31
+ */
25
32
  const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d+)?$/;
33
+ /** The target's host, or `undefined` when it has none. `''` is "not set". */
34
+ function hostOf(target) {
35
+ return target?.hostname === '' ? undefined : target?.hostname;
36
+ }
26
37
  /**
27
38
  * Throw `RangeError` unless `target.repo` is `owner/name` and
28
39
  * `target.hostname` is a bare host. Run by every verb that uses a target, so
@@ -32,11 +43,12 @@ export function checkTarget(target) {
32
43
  if (target?.repo !== undefined && !isRepoSlug(target.repo)) {
33
44
  throw new RangeError(`repo must be owner/name, got ${JSON.stringify(target.repo)}`);
34
45
  }
35
- if (target?.hostname !== undefined && !HOSTNAME.test(target.hostname)) {
36
- throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(target.hostname)}`);
46
+ const hostname = hostOf(target);
47
+ if (hostname !== undefined && !HOSTNAME.test(hostname)) {
48
+ throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(hostname)}`);
37
49
  }
38
50
  }
39
- /** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs; `repo` overrides the target's. */
51
+ /** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs. `repo` overrides the target's. */
40
52
  function repoArgs(target, repo) {
41
53
  checkTarget(target);
42
54
  const slug = repo ?? target?.repo;
@@ -44,17 +56,20 @@ function repoArgs(target, repo) {
44
56
  return [];
45
57
  if (repo !== undefined)
46
58
  checkTarget({ repo });
47
- return ['--repo', target?.hostname === undefined ? slug : `${target.hostname}/${slug}`];
59
+ const hostname = hostOf(target);
60
+ return ['--repo', hostname === undefined ? slug : `${hostname}/${slug}`];
48
61
  }
49
62
  /** `--hostname <host>` for `api` and `auth` verbs. */
50
63
  function hostArgs(target) {
51
64
  checkTarget(target);
52
- return target?.hostname === undefined ? [] : ['--hostname', target.hostname];
65
+ const hostname = hostOf(target);
66
+ return hostname === undefined ? [] : ['--hostname', hostname];
53
67
  }
54
68
  /** `GH_HOST` for the verbs gh gives no host flag. */
55
69
  function hostEnv(target) {
56
70
  checkTarget(target);
57
- return target?.hostname === undefined ? undefined : { GH_HOST: target.hostname };
71
+ const hostname = hostOf(target);
72
+ return hostname === undefined ? undefined : { GH_HOST: hostname };
58
73
  }
59
74
  /**
60
75
  * `endpoint` with `{owner}` and `{repo}` filled from the target. With no
@@ -68,21 +83,21 @@ function fillRepo(endpoint, target) {
68
83
  return endpoint.replaceAll('{owner}', owner).replaceAll('{repo}', name);
69
84
  }
70
85
  /**
71
- * The cheapest useful set: enough to identify a PR and nothing more.
86
+ * The smallest useful set: enough to identify a PR and nothing more.
72
87
  *
73
- * Reach for this whenever the question is "which PR is this branch?".
74
- * `mergeable` and `mergeStateStatus` are absent on purpose — GitHub computes
75
- * them per PR on demand, so asking for them across a fan-out of branches
76
- * turns a cheap listing into a rate-limit incident.
88
+ * Use it when the question is "which PR is this branch?". `mergeable` and
89
+ * `mergeStateStatus` are left out on purpose. GitHub computes them per PR
90
+ * when asked, so asking for them across many branches can hit the rate
91
+ * limit.
77
92
  */
78
93
  export const PR_REF_FIELDS = ['number', 'state', 'headRefName', 'url'];
79
94
  /**
80
- * The light field set for triage: everything needed to decide what to do with
81
- * a PR without paying for its body, comments, reviews, or check rollup.
95
+ * The smaller field set for triage: enough to decide what to do with a PR,
96
+ * without its body, comments, reviews, or check rollup.
82
97
  *
83
- * `mergeable` and `mergeStateStatus` are the expensive pair — GitHub computes
84
- * them lazily and answers `UNKNOWN` until it has, so a cold query over many
85
- * PRs commonly needs a retry. `prViewSettled` on the clients does it.
98
+ * `mergeable` and `mergeStateStatus` are the costly pair. GitHub computes
99
+ * them only when asked and answers `UNKNOWN` until it has. So a first query
100
+ * over many PRs often needs a retry. `prViewSettled` on the clients does it.
86
101
  */
87
102
  export const PR_LIST_FIELDS = [
88
103
  'number',
@@ -103,7 +118,7 @@ export const PR_LIST_FIELDS = [
103
118
  'createdAt',
104
119
  'updatedAt',
105
120
  ];
106
- /** {@link PR_LIST_FIELDS} plus the heavy ones a single PR can afford. */
121
+ /** {@link PR_LIST_FIELDS} plus the costly fields, for a single PR. */
107
122
  export const PR_VIEW_FIELDS = [
108
123
  ...PR_LIST_FIELDS,
109
124
  'body',
@@ -125,8 +140,8 @@ function oneOrNull(r) {
125
140
  return parsePullRequest(data);
126
141
  }
127
142
  /**
128
- * Parse a `--json` payload that is an array. Null — not `[]` — when the call
129
- * failed: a caller must be able to tell "no PRs" from "couldn't ask".
143
+ * Parse a `--json` payload that is an array. Null, not `[]`, when the call
144
+ * failed. A caller must be able to tell "no PRs" from "couldn't ask".
130
145
  */
131
146
  function manyOrNull(r) {
132
147
  if (r.exitCode !== 0)
@@ -163,10 +178,9 @@ function limited(rows, limit) {
163
178
  /** A mutation's result carries no value. */
164
179
  function nothing() { }
165
180
  /**
166
- * Parse a `gh label list --json name,color,description` payload. Null — not
167
- * `[]` — when the call failed, same "no rows" vs. "couldn't ask" split as
168
- * {@link manyOrNull}: a repo with genuinely zero labels and a `gh` call that
169
- * never ran must not collapse onto the same value.
181
+ * Parse a `gh label list --json name,color,description` payload. Null, not
182
+ * `[]`, when the call failed, as in {@link manyOrNull}. A repo with no labels
183
+ * and a `gh` call that never ran must not give the same value.
170
184
  */
171
185
  function manyLabelsOrNull(r) {
172
186
  if (r.exitCode !== 0)
@@ -182,7 +196,7 @@ function manyLabelsOrNull(r) {
182
196
  return null;
183
197
  return parseLabels(data);
184
198
  }
185
- /** The `gh issue list`/`view --json` field set the `issues` surface needs. */
199
+ /** The `gh issue list`/`view --json` field set an {@link IssueRow} reads. */
186
200
  const ISSUE_FIELDS = 'number,title,body,labels,state';
187
201
  /** Parse one `gh issue list`/`view --json` row into an {@link IssueRow}. */
188
202
  function parseIssueRow(data) {
@@ -214,9 +228,8 @@ function oneIssueOrNull(r) {
214
228
  return parseIssueRow(data);
215
229
  }
216
230
  /**
217
- * Parse a `gh issue list --json` payload that is an array. Null — not `[]` —
218
- * on failure, the same "no rows" vs. "couldn't ask" split {@link manyOrNull}
219
- * makes for PRs.
231
+ * Parse a `gh issue list --json` payload that is an array. Null, not `[]`,
232
+ * on failure, as {@link manyOrNull} does for PRs.
220
233
  */
221
234
  function manyIssuesOrNull(r) {
222
235
  if (r.exitCode !== 0)
@@ -238,7 +251,7 @@ function manyIssuesOrNull(r) {
238
251
  }
239
252
  return rows;
240
253
  }
241
- /** `gh pr list …` — open PRs by default. `opts.repo` overrides the target's repo. */
254
+ /** `gh pr list …`: open PRs by default. `opts.repo` overrides the target's repo. */
242
255
  export function prList(opts = {}, target) {
243
256
  const limit = opts.limit ?? 100;
244
257
  checkLimit(limit);
@@ -251,9 +264,12 @@ export function prList(opts = {}, target) {
251
264
  argv.push('--search', opts.search);
252
265
  if (opts.author !== undefined)
253
266
  argv.push('--author', opts.author);
254
- // The exact-head filter below reads `headRefName`, so ask for it.
255
- const fields = opts.fields ?? PR_LIST_FIELDS;
256
- const asked = opts.head === undefined ? fields : withFields(fields, ['headRefName']);
267
+ // A row without `number` does not parse and is dropped, so always ask for it.
268
+ // The exact-head filter below also reads `headRefName`.
269
+ const asked = withFields(opts.fields ?? PR_LIST_FIELDS, [
270
+ 'number',
271
+ ...(opts.head === undefined ? [] : ['headRefName']),
272
+ ]);
257
273
  argv.push('--json', asked.join(','));
258
274
  argv.push('--limit', String(limit + 1));
259
275
  const head = opts.head;
@@ -283,7 +299,7 @@ function notAFlag(what, value) {
283
299
  }
284
300
  }
285
301
  /**
286
- * `gh pr view <number|branch|url> --json …` — one PR, with the heavy fields
302
+ * `gh pr view <number|branch|url> --json …`: one PR, with the full field set
287
303
  * by default.
288
304
  *
289
305
  * A branch is the PR whose head is that branch, as gh picks it. A URL names
@@ -315,15 +331,15 @@ export function prView(ref, fields = PR_VIEW_FIELDS, target) {
315
331
  /**
316
332
  * Open PRs whose head is exactly `branch`.
317
333
  *
318
- * The common shape behind "which PR is this branch?", bundling the cheap
319
- * field set with a small limit. More than one row comes back only when forks
334
+ * The usual way to ask "which PR is this branch?". It uses the smallest field
335
+ * set and a small limit. More than one row comes back only when forks
320
336
  * share the branch name.
321
337
  */
322
338
  export function openPrsForBranch(branch, limit = 5, target) {
323
339
  return prList({ head: branch, limit, fields: PR_REF_FIELDS }, target);
324
340
  }
325
341
  /**
326
- * `gh pr view <n> --json body -q .body` — just the PR body. Null when gh
342
+ * `gh pr view <n> --json body -q .body`: just the PR body. Null when gh
327
343
  * failed. gh's `-q` output ends with a newline that is not part of the body,
328
344
  * so every trailing `\r` and `\n` is trimmed (including any that were part of
329
345
  * the body, such as the `\r\n` of a body saved with CRLF line endings).
@@ -337,10 +353,10 @@ export function prBody(number, target) {
337
353
  };
338
354
  }
339
355
  /**
340
- * `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
356
+ * `gh api [-i] [-H If-None-Match: …] <endpoint>`: a GET against the REST API. `{owner}` and
341
357
  * `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
342
358
  * cwd's repo when the target names none.
343
- * Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
359
+ * Unchecked. Failure is a value (`ok: false`), so a caller can tell a 304
344
360
  * from an outage.
345
361
  */
346
362
  export function api(endpoint, opts = {}, target) {
@@ -377,16 +393,16 @@ function flattenPages(body) {
377
393
  return body.flat();
378
394
  }
379
395
  /**
380
- * `gh api graphql -f query=… [--paginate --slurp]` — a GraphQL query.
396
+ * `gh api graphql -f query=… [--paginate --slurp]`: a GraphQL query.
381
397
  *
382
- * Returns each page's `data`, one entry per page (one without `paginate`),
383
- * or null when the call failed, the output did not parse, or any page
384
- * carries `errors` or lacks `data`: a partial answer is no answer. Read one
398
+ * Returns each page's `data`, one entry per page (one without `paginate`).
399
+ * Returns null when the call failed, the output did not parse, or any page
400
+ * carries `errors` or lacks `data`. A partial answer counts as a failure. Read one
385
401
  * connection across the pages with {@link nodesAt}.
386
402
  *
387
403
  * A string variable of exactly `{owner}` or `{repo}` is filled from the
388
404
  * target's repo, or by gh from the clone. A mutation document throws
389
- * `RangeError`: this is a read, and changes go through `raw`.
405
+ * `RangeError`. This is a read, and changes go through `raw`.
390
406
  *
391
407
  * @experimental
392
408
  */
@@ -455,8 +471,8 @@ export function graphql(query, variables = {}, opts = {}, target) {
455
471
  };
456
472
  }
457
473
  /**
458
- * The `nodes` of one connection, joined across {@link graphql} pages: walk
459
- * `path` into each page and take its `nodes` array. Null when `pages` is
474
+ * The `nodes` of one connection, joined across {@link graphql} pages. It
475
+ * follows `path` into each page and takes its `nodes` array. Null when `pages` is
460
476
  * null, or any page lacks the path or a `nodes` array, so a page that came
461
477
  * back short is never read as fewer rows.
462
478
  *
@@ -482,7 +498,7 @@ export function nodesAt(pages, path) {
482
498
  }
483
499
  return out;
484
500
  }
485
- /** One header block off the front of `text`: the block, and what follows its blank line. */
501
+ /** Split one header block off the front of `text`: the block, and what follows its blank line. */
486
502
  function takeHeaderBlock(text) {
487
503
  const crlf = text.indexOf('\r\n\r\n');
488
504
  const lf = text.indexOf('\n\n');
@@ -495,7 +511,7 @@ function takeHeaderBlock(text) {
495
511
  const STATUS_LINE = /^HTTP\/[\d.]+\s+\d{3}/i;
496
512
  /**
497
513
  * Split a `gh api -i` payload (status line + headers, blank line, body) and decode it.
498
- * A real `gh` prints one block. As a guard, if several are stacked, the last one is read:
514
+ * A real `gh` prints one block. If there are several, the last one is read:
499
515
  * its status and ETag, and the body that follows it.
500
516
  */
501
517
  function parseApi(r, withHeaders) {
@@ -516,7 +532,7 @@ function parseApi(r, withHeaders) {
516
532
  }
517
533
  let body = null;
518
534
  let malformed = false;
519
- if (r.exitCode === 0 && bodyText.trim() !== '') {
535
+ if (bodyText.trim() !== '') {
520
536
  try {
521
537
  body = JSON.parse(bodyText);
522
538
  }
@@ -525,17 +541,21 @@ function parseApi(r, withHeaders) {
525
541
  }
526
542
  }
527
543
  const ok = r.exitCode === 0 && status >= 200 && status < 400;
544
+ const rawMessage = body !== null && typeof body === 'object' && !Array.isArray(body)
545
+ ? body['message']
546
+ : undefined;
528
547
  return {
529
548
  ok,
530
549
  status,
531
550
  etag,
532
551
  body,
552
+ message: typeof rawMessage === 'string' ? rawMessage : null,
533
553
  malformed,
534
554
  stderr: r.stderr.trim(),
535
555
  ...(r.error === undefined ? {} : { error: r.error }),
536
556
  };
537
557
  }
538
- /** `gh --version` — the install probe. True when `gh` runs. */
558
+ /** `gh --version`: checks that `gh` is installed. True when `gh` runs. */
539
559
  export function installed() {
540
560
  return {
541
561
  argv: ['--version'],
@@ -545,7 +565,7 @@ export function installed() {
545
565
  parse: (r) => r.exitCode === 0,
546
566
  };
547
567
  }
548
- /** `gh auth status [--hostname <host>]` — the credential probe, for one host when the target names it. */
568
+ /** `gh auth status [--hostname <host>]`: checks credentials, for one host when the target names it. */
549
569
  export function authStatus(target) {
550
570
  return {
551
571
  argv: ['auth', 'status', ...hostArgs(target)],
@@ -556,8 +576,8 @@ export function authStatus(target) {
556
576
  };
557
577
  }
558
578
  /**
559
- * `gh label list --json name,color,description` — every label on the repo,
560
- * up to `limit` (default 1000; `gh`'s own default is 30).
579
+ * `gh label list --json name,color,description`: every label on the repo,
580
+ * up to `limit` (default 1000. `gh`'s own default is 30).
561
581
  */
562
582
  export function labelList(opts = {}, target) {
563
583
  const limit = opts.limit ?? 1000;
@@ -577,7 +597,7 @@ export function labelList(opts = {}, target) {
577
597
  parse: (r) => limited(manyLabelsOrNull(r), limit),
578
598
  };
579
599
  }
580
- /** `gh issue list --json …` — open issues by default, optionally by label. */
600
+ /** `gh issue list --json …`: open issues, optionally by label. */
581
601
  export function issueList(opts = {}, target) {
582
602
  const limit = opts.limit ?? 200;
583
603
  checkLimit(limit);
@@ -587,7 +607,7 @@ export function issueList(opts = {}, target) {
587
607
  argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1));
588
608
  return { argv, checked: false, readOnly: true, parse: (r) => limited(manyIssuesOrNull(r), limit) };
589
609
  }
590
- /** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
610
+ /** `gh issue view <n> --json …`: one issue, same field set as {@link issueList}. */
591
611
  export function issueView(number, target) {
592
612
  return {
593
613
  argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
@@ -636,8 +656,8 @@ function rowsOrNull(r, parse) {
636
656
  return data.map(parse).filter((row) => row !== null);
637
657
  }
638
658
  /**
639
- * `gh repo list <owner> --json …` — an owner's repositories. The host comes
640
- * from the target (`GH_HOST`); the target's repo is not used.
659
+ * `gh repo list <owner> --json …`: an owner's repositories. The host comes
660
+ * from the target (`GH_HOST`). The target's repo is not used.
641
661
  */
642
662
  export function repoList(owner, opts = {}, target) {
643
663
  notAFlag('repoList owner', owner);
@@ -661,7 +681,7 @@ export function repoList(owner, opts = {}, target) {
661
681
  };
662
682
  }
663
683
  /**
664
- * `gh repo view [<[host/]owner/name>] --json …` — one repository: `repo`,
684
+ * `gh repo view [<[host/]owner/name>] --json …`: one repository. That is `repo`,
665
685
  * else the target's repo, else the clone at the client's cwd.
666
686
  */
667
687
  export function repoView(repo, fields = REPO_FIELDS, target) {
@@ -692,9 +712,9 @@ export function repoView(repo, fields = REPO_FIELDS, target) {
692
712
  };
693
713
  }
694
714
  /**
695
- * `gh search prs [flags] -- <query>` — pull requests across repositories,
715
+ * `gh search prs [flags] -- <query>`: pull requests across repositories,
696
716
  * with GitHub's search syntax in `query` (it may start with `-`, as in
697
- * `-label:bug`). The host comes from the target (`GH_HOST`); the target's
717
+ * `-label:bug`). The host comes from the target (`GH_HOST`). The target's
698
718
  * repo is not used, so pass `opts.repo` or `opts.owner` to narrow.
699
719
  */
700
720
  export function searchPrs(query, opts = {}, target) {
@@ -748,7 +768,7 @@ export function createPr(opts, target) {
748
768
  },
749
769
  };
750
770
  }
751
- /** `gh pr edit <n>` — retitle or rewrite the body. */
771
+ /** `gh pr edit <n>`: change the title or the body. */
752
772
  export function editPr(number, opts, target) {
753
773
  const argv = ['pr', 'edit', String(number), ...repoArgs(target)];
754
774
  if (opts.title !== undefined)
@@ -757,18 +777,18 @@ export function editPr(number, opts, target) {
757
777
  argv.push('--body', opts.body);
758
778
  return { argv, checked: true, readOnly: false, parse: nothing };
759
779
  }
760
- /** `gh pr edit <n> --body` — rewrite just the body. */
780
+ /** `gh pr edit <n> --body`: rewrite just the body. */
761
781
  export function editPrBody(number, body, target) {
762
782
  return editPr(number, { body }, target);
763
783
  }
764
- /** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
784
+ /** `gh pr update-branch <n> [--rebase]`: bring the head up to date with its base. */
765
785
  export function prUpdateBranch(number, style = 'merge', target) {
766
786
  const argv = ['pr', 'update-branch', String(number), ...repoArgs(target)];
767
787
  if (style === 'rebase')
768
788
  argv.push('--rebase');
769
789
  return { argv, checked: true, readOnly: false, parse: nothing };
770
790
  }
771
- /** `gh pr ready <n> [--undo]` — mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
791
+ /** `gh pr ready <n> [--undo]`: mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
772
792
  export function prSetDraft(number, draft, target) {
773
793
  const argv = ['pr', 'ready', String(number), ...repoArgs(target)];
774
794
  if (draft)
@@ -776,13 +796,13 @@ export function prSetDraft(number, draft, target) {
776
796
  return { argv, checked: true, readOnly: false, parse: nothing };
777
797
  }
778
798
  /**
779
- * `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…` — rewrite one
799
+ * `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…`: rewrite one
780
800
  * conversation comment.
781
801
  *
782
- * `commentId` is the comment's numeric REST id, as text: the `id` that
802
+ * `commentId` is the comment's numeric REST id, as text. That is the `id` that
783
803
  * `gh api repos/{owner}/{repo}/issues/<n>/comments` returns. It is not the
784
804
  * GraphQL node id (such as `IC_kw…`) that `gh pr view --json comments` gives
785
- * in `Comment.id`; the REST path answers 404 for a node id.
805
+ * in `Comment.id`. The REST path answers 404 for a node id.
786
806
  *
787
807
  * Throws `RangeError` when `commentId` is not all digits, so a node id fails
788
808
  * at once and no text can change which endpoint the PATCH reaches.
@@ -810,10 +830,10 @@ export function issueCommentEdit(commentId, body, target) {
810
830
  }
811
831
  /**
812
832
  * `gh pr merge <n> --<method> --match-head-commit <sha>`. The head sha is
813
- * required, not optional: GitHub refuses the merge when the PR head moved
814
- * since the caller judged it, so a push that lands between a gate and the
815
- * merge can never ride in unreviewed. The branch is left alone (no
816
- * `--delete-branch`); deleting it is the caller's job.
833
+ * required. GitHub refuses the merge when the PR head moved since the caller
834
+ * checked it. So a push that lands between the check and the merge cannot be
835
+ * merged without review. The branch is left alone (no `--delete-branch`).
836
+ * Deleting it is the caller's job.
817
837
  */
818
838
  export function prMerge(number, opts, target) {
819
839
  const argv = [
@@ -830,14 +850,13 @@ export function prMerge(number, opts, target) {
830
850
  const REVIEW_THREADS_QUERY = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){pageInfo{hasNextPage} nodes{isResolved isOutdated comments(last:1){nodes{author{login}}}}}}}}';
831
851
  /**
832
852
  * A PR's review threads over GraphQL (`gh pr view --json` has no thread
833
- * field). Null — never `[]` — when the call failed, the payload was not the
834
- * expected shape, or more than 100 threads exist: a caller deciding whether a
835
- * PR is clear to merge must read "could not tell" differently from "no
836
- * threads".
853
+ * field). Null, never `[]`, when the call failed, the payload was not the
854
+ * expected shape, or more than 100 threads exist. A caller deciding whether a
855
+ * PR is clear to merge must tell "could not tell" from "no threads".
837
856
  */
838
857
  export function prReviewThreads(number, target) {
839
858
  checkTarget(target);
840
- // `-F` lets gh fill the placeholders from the clone; real values go as plain strings.
859
+ // `-F` lets gh fill the placeholders from the clone. Real values go as plain strings.
841
860
  const [owner, name] = target?.repo?.split('/') ?? [];
842
861
  const repo = owner === undefined || name === undefined
843
862
  ? ['-F', 'owner={owner}', '-F', 'name={repo}']
@@ -883,7 +902,7 @@ export function prReviewThreads(number, target) {
883
902
  }
884
903
  /**
885
904
  * `gh label create <name> --color <hex> --description <text> --force`.
886
- * `--force` makes it safe to repeat: when the label already exists, its color
905
+ * `--force` makes it safe to repeat. When the label already exists, its color
887
906
  * and description are set to these instead of failing.
888
907
  */
889
908
  export function createLabel(opts, target) {
@@ -900,7 +919,7 @@ export function createLabel(opts, target) {
900
919
  ];
901
920
  return { argv, checked: true, readOnly: false, parse: nothing };
902
921
  }
903
- /** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
922
+ /** `gh label edit <name>`: change the color or description. A flag not given is left out. */
904
923
  export function editLabel(name, opts, target) {
905
924
  const argv = ['label', 'edit', name, ...repoArgs(target)];
906
925
  if (opts.color !== undefined)
@@ -910,21 +929,18 @@ export function editLabel(name, opts, target) {
910
929
  return { argv, checked: true, readOnly: false, parse: nothing };
911
930
  }
912
931
  /**
913
- * `gh issue edit <n> [--remove-label <current>] --add-label <new>` — swap
914
- * one label for another on an issue in ONE call, so the issue is never
915
- * briefly unlabeled or briefly holding both labels BETWEEN the remove and the
916
- * add — that atomicity is real and is this one call's own guarantee. It does
917
- * NOT cover the window before the call: `currentLabel` is whatever the caller
918
- * already read (perhaps minutes earlier, from a `gh issue list` snapshot),
919
- * never re-read here. If the issue's live label changed since then (a human,
920
- * or another process, relabeled it in between), `--remove-label
921
- * <currentLabel>` targets a label the issue no longer carries — `gh` no-ops
922
- * that removal silently rather than erroring — so the call still adds
923
- * `newLabel`, and the issue ends up holding BOTH the label it actually has
924
- * now and `newLabel`. A caller that cares about that race re-reads the live
925
- * label immediately before calling this. `currentLabel` is `null` when the
926
- * issue carries no prior label to remove — the `--remove-label` flag is then
927
- * omitted rather than passed empty.
932
+ * `gh issue edit <n> [--remove-label <current>] --add-label <new>`: swap one
933
+ * label for another on an issue in one call. The issue is never left with
934
+ * neither label or both labels between the remove and the add.
935
+ *
936
+ * That covers only the call itself. `currentLabel` is whatever the caller
937
+ * read earlier, perhaps from a `gh issue list` minutes before. It is not read
938
+ * again here. If someone relabeled the issue since then, `--remove-label
939
+ * <currentLabel>` names a label the issue no longer has. `gh` skips that
940
+ * removal with no error and still adds `newLabel`. The issue then has both
941
+ * its current label and `newLabel`. A caller that cares reads the label again
942
+ * just before calling this. Pass `null` for `currentLabel` when there is no
943
+ * label to remove. The `--remove-label` flag is then left out.
928
944
  */
929
945
  export function setIssueLabel(number, currentLabel, newLabel, target) {
930
946
  const argv = ['issue', 'edit', String(number), ...repoArgs(target)];
@@ -933,7 +949,7 @@ export function setIssueLabel(number, currentLabel, newLabel, target) {
933
949
  argv.push('--add-label', newLabel);
934
950
  return { argv, checked: true, readOnly: false, parse: nothing };
935
951
  }
936
- /** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
952
+ /** `gh pr diff <n>`: the PR's unified diff, as text. Checked, so an empty diff is a value and a failed call throws. */
937
953
  export function prDiff(number, target) {
938
954
  return {
939
955
  argv: ['pr', 'diff', String(number), ...repoArgs(target)],
@@ -942,7 +958,7 @@ export function prDiff(number, target) {
942
958
  parse: (r) => r.stdout,
943
959
  };
944
960
  }
945
- /** `gh pr comment <n> --body <body>` — post a conversation comment, returning the URL gh prints ('' if none). */
961
+ /** `gh pr comment <n> --body <body>`: post a conversation comment, returning the URL gh prints ('' if none). */
946
962
  export function prComment(number, body, target) {
947
963
  return {
948
964
  argv: ['pr', 'comment', String(number), ...repoArgs(target), '--body', body],
@@ -960,10 +976,10 @@ export function prComment(number, body, target) {
960
976
  }
961
977
  /**
962
978
  * Create a PR review with inline comments, returning the review's URL (empty
963
- * when GitHub's reply carried none). The event is always `COMMENT`: this
964
- * library does not offer a way to approve or request changes. Goes through
979
+ * when GitHub's reply carried none). The event is always `COMMENT`. This
980
+ * library does not offer a way to approve or request changes. It goes through
965
981
  * `gh api` with the payload on stdin, because `gh pr review` cannot attach
966
- * line-anchored comments; `{owner}`/`{repo}` come from the target's repo, or
982
+ * comments to lines. `{owner}`/`{repo}` come from the target's repo, or
967
983
  * are filled in by `gh` from the client's working directory.
968
984
  *
969
985
  * @experimental
@@ -1034,10 +1050,10 @@ export function branchPrFields(fields) {
1034
1050
  /**
1035
1051
  * True when GitHub has not finished computing this PR's mergeability.
1036
1052
  *
1037
- * The answer is not stable on a cold query: GitHub kicks off the merge
1038
- * computation when asked and answers `UNKNOWN` until it lands. A caller that
1039
- * treats the first `UNKNOWN` as final will mis-triage a PR that is merely
1040
- * slow, so re-ask a few times before believing it.
1053
+ * The first answer may not be final. GitHub starts the merge computation when
1054
+ * asked and answers `UNKNOWN` until it finishes. A caller that treats the
1055
+ * first `UNKNOWN` as final misjudges a PR whose computation is slow. So ask a
1056
+ * few times before trusting it.
1041
1057
  */
1042
1058
  export function needsMergeStateRetry(pr) {
1043
1059
  return pr.mergeable === 'UNKNOWN' || pr.mergeStateStatus === 'UNKNOWN';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.7.0",
3
+ "version": "0.9.1",
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
6
  "author": "sksizer",
@@ -22,8 +22,8 @@
22
22
  "LICENSE-APACHE"
23
23
  ],
24
24
  "dependencies": {
25
- "@osdlc/command-seam": "^0.9.0",
26
- "@osdlc/typed-git": "^0.6.0",
25
+ "@osdlc/command-seam": "^0.9.1",
26
+ "@osdlc/typed-git": "^0.7.0",
27
27
  "@osdlc/result": "^0.1.0"
28
28
  },
29
29
  "engines": {
@@ -54,8 +54,8 @@
54
54
  "prettier": "3.9.6",
55
55
  "typescript": "~6.0.3",
56
56
  "vitest": "^5.0.5",
57
- "@osdlc/prettier-config": "^0.3.1",
58
- "@osdlc/tsconfig": "^0.4.1"
57
+ "@osdlc/tsconfig": "^0.4.1",
58
+ "@osdlc/prettier-config": "^0.3.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsc -p tsconfig.build.json",