@osdlc/typed-gh 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/verbs.js CHANGED
@@ -7,12 +7,66 @@
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
- import { parseLabels, parsePullRequest } from "./parse.js";
18
+ import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from "./parse.js";
19
+ import { isAbsent } from "./failure.js";
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
+ }
25
+ const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d+)?$/;
26
+ /**
27
+ * Throw `RangeError` unless `target.repo` is `owner/name` and
28
+ * `target.hostname` is a bare host. Run by every verb that uses a target, so
29
+ * no text can reach gh as a flag or change the endpoint a call hits.
30
+ */
31
+ export function checkTarget(target) {
32
+ if (target?.repo !== undefined && !isRepoSlug(target.repo)) {
33
+ throw new RangeError(`repo must be owner/name, got ${JSON.stringify(target.repo)}`);
34
+ }
35
+ if (target?.hostname !== undefined && !HOSTNAME.test(target.hostname)) {
36
+ throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(target.hostname)}`);
37
+ }
38
+ }
39
+ /** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs; `repo` overrides the target's. */
40
+ function repoArgs(target, repo) {
41
+ checkTarget(target);
42
+ const slug = repo ?? target?.repo;
43
+ if (slug === undefined)
44
+ return [];
45
+ if (repo !== undefined)
46
+ checkTarget({ repo });
47
+ return ['--repo', target?.hostname === undefined ? slug : `${target.hostname}/${slug}`];
48
+ }
49
+ /** `--hostname <host>` for `api` and `auth` verbs. */
50
+ function hostArgs(target) {
51
+ checkTarget(target);
52
+ return target?.hostname === undefined ? [] : ['--hostname', target.hostname];
53
+ }
54
+ /** `GH_HOST` for the verbs gh gives no host flag. */
55
+ function hostEnv(target) {
56
+ checkTarget(target);
57
+ return target?.hostname === undefined ? undefined : { GH_HOST: target.hostname };
58
+ }
59
+ /**
60
+ * `endpoint` with `{owner}` and `{repo}` filled from the target. With no
61
+ * target repo they are left for gh to fill from the clone.
62
+ */
63
+ function fillRepo(endpoint, target) {
64
+ checkTarget(target);
65
+ if (target?.repo === undefined)
66
+ return endpoint;
67
+ const [owner, name] = target.repo.split('/');
68
+ return endpoint.replaceAll('{owner}', owner).replaceAll('{repo}', name);
69
+ }
16
70
  /**
17
71
  * The cheapest useful set: enough to identify a PR and nothing more.
18
72
  *
@@ -94,6 +148,18 @@ function manyOrNull(r) {
94
148
  }
95
149
  return rows;
96
150
  }
151
+ /** Throw `RangeError` unless `limit` is a positive integer. */
152
+ function checkLimit(limit) {
153
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
154
+ throw new RangeError(`limit must be a positive integer, got ${limit}`);
155
+ }
156
+ }
157
+ /** `rows` cut to `limit`, with whether there were more. Null stays null. */
158
+ function limited(rows, limit) {
159
+ if (rows === null)
160
+ return null;
161
+ return { rows: rows.slice(0, limit), truncated: rows.length > limit };
162
+ }
97
163
  /** A mutation's result carries no value. */
98
164
  function nothing() { }
99
165
  /**
@@ -172,43 +238,89 @@ function manyIssuesOrNull(r) {
172
238
  }
173
239
  return rows;
174
240
  }
175
- /** `gh pr list …` — open PRs by default. */
176
- export function prList(opts = {}) {
177
- const argv = ['pr', 'list', '--state', opts.state ?? 'open'];
178
- if (opts.repo !== undefined)
179
- argv.push('--repo', opts.repo);
241
+ /** `gh pr list …` — open PRs by default. `opts.repo` overrides the target's repo. */
242
+ export function prList(opts = {}, target) {
243
+ const limit = opts.limit ?? 100;
244
+ checkLimit(limit);
245
+ const argv = ['pr', 'list', '--state', opts.state ?? 'open', ...repoArgs(target, opts.repo)];
180
246
  if (opts.base !== undefined)
181
247
  argv.push('--base', opts.base);
182
248
  if (opts.head !== undefined)
183
- argv.push('--search', `head:${opts.head}`);
249
+ argv.push('--head', opts.head);
184
250
  if (opts.search !== undefined)
185
251
  argv.push('--search', opts.search);
186
252
  if (opts.author !== undefined)
187
253
  argv.push('--author', opts.author);
188
- argv.push('--json', (opts.fields ?? PR_LIST_FIELDS).join(','));
189
- argv.push('--limit', String(opts.limit ?? 100));
190
- return { argv, checked: false, parse: manyOrNull };
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']);
257
+ argv.push('--json', asked.join(','));
258
+ argv.push('--limit', String(limit + 1));
259
+ const head = opts.head;
260
+ return {
261
+ argv,
262
+ checked: false,
263
+ readOnly: true,
264
+ parse(r) {
265
+ const result = limited(manyOrNull(r), limit);
266
+ if (result === null || head === undefined)
267
+ return result;
268
+ return { ...result, rows: result.rows.filter((pr) => pr.headRefName === head) };
269
+ },
270
+ };
271
+ }
272
+ /** `fields` with each of `required` added once, ahead of the rest. */
273
+ function withFields(fields, required) {
274
+ return [...new Set([...required.filter((f) => !fields.includes(f)), ...fields])];
191
275
  }
192
- /** `gh pr view <n> --json …` — one PR, with the heavy fields by default. */
193
- export function prView(number, fields = PR_VIEW_FIELDS) {
276
+ /**
277
+ * Throw `RangeError` when a string bound for gh's argv as a positional
278
+ * argument would read as a flag.
279
+ */
280
+ function notAFlag(what, value) {
281
+ if (value.startsWith('-')) {
282
+ throw new RangeError(`${what} must not start with "-", got ${JSON.stringify(value)}`);
283
+ }
284
+ }
285
+ /**
286
+ * `gh pr view <number|branch|url> --json …` — one PR, with the heavy fields
287
+ * by default.
288
+ *
289
+ * A branch is the PR whose head is that branch, as gh picks it. A URL names
290
+ * its own repository and host, so the target is not passed with one. A
291
+ * string that starts with `-` throws `RangeError`.
292
+ */
293
+ export function prView(ref, fields = PR_VIEW_FIELDS, target) {
294
+ if (typeof ref === 'string')
295
+ notAFlag('prView ref', ref);
194
296
  // `parsePullRequest` yields null without a `number`, so a field subset that
195
297
  // omits it would read as "no such PR". Always ask for it, and each field once.
196
- const asked = [...new Set(fields.includes('number') ? fields : ['number', ...fields])];
298
+ const asked = withFields(fields, ['number']);
299
+ const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null;
197
300
  return {
198
- argv: ['pr', 'view', String(number), '--json', asked.join(',')],
301
+ argv: [
302
+ 'pr',
303
+ 'view',
304
+ String(ref),
305
+ ...(isUrl ? [] : repoArgs(target)),
306
+ '--json',
307
+ asked.join(','),
308
+ ],
199
309
  checked: false,
310
+ readOnly: true,
311
+ absent: noSuch('pr'),
200
312
  parse: oneOrNull,
201
313
  };
202
314
  }
203
315
  /**
204
- * Open PRs whose head *starts with* `branch` — gh's own search semantics.
316
+ * Open PRs whose head is exactly `branch`.
205
317
  *
206
318
  * The common shape behind "which PR is this branch?", bundling the cheap
207
- * field set with a small limit. Pair it with {@link pickPrForBranch}, which
208
- * is what turns the prefix match into an exact one.
319
+ * field set with a small limit. More than one row comes back only when forks
320
+ * share the branch name.
209
321
  */
210
- export function openPrsForBranch(branch, limit = 5) {
211
- return prList({ head: branch, limit, fields: PR_REF_FIELDS });
322
+ export function openPrsForBranch(branch, limit = 5, target) {
323
+ return prList({ head: branch, limit, fields: PR_REF_FIELDS }, target);
212
324
  }
213
325
  /**
214
326
  * `gh pr view <n> --json body -q .body` — just the PR body. Null when gh
@@ -216,29 +328,159 @@ export function openPrsForBranch(branch, limit = 5) {
216
328
  * so every trailing `\r` and `\n` is trimmed (including any that were part of
217
329
  * the body, such as the `\r\n` of a body saved with CRLF line endings).
218
330
  */
219
- export function prBody(number) {
331
+ export function prBody(number, target) {
220
332
  return {
221
- argv: ['pr', 'view', String(number), '--json', 'body', '-q', '.body'],
333
+ argv: ['pr', 'view', String(number), ...repoArgs(target), '--json', 'body', '-q', '.body'],
222
334
  checked: false,
335
+ readOnly: true,
223
336
  parse: (r) => (r.exitCode === 0 ? r.stdout.replace(/[\r\n]+$/, '') : null),
224
337
  };
225
338
  }
226
339
  /**
227
340
  * `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
228
- * `{repo}` placeholders in the endpoint are filled by gh from the cwd's repo.
341
+ * `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
342
+ * cwd's repo when the target names none.
229
343
  * Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
230
344
  * from an outage.
231
345
  */
232
- export function api(endpoint, opts = {}) {
346
+ export function api(endpoint, opts = {}, target) {
347
+ notAFlag('api endpoint', endpoint);
233
348
  const conditional = opts.etag !== undefined && opts.etag !== null && opts.etag !== '';
234
349
  const withHeaders = conditional || opts.includeHeaders === true;
235
- const argv = ['api'];
350
+ const paginate = opts.paginate === true;
351
+ if (paginate && withHeaders) {
352
+ throw new RangeError('api: paginate cannot be combined with etag or includeHeaders');
353
+ }
354
+ const argv = ['api', ...hostArgs(target)];
236
355
  if (withHeaders)
237
356
  argv.push('-i');
238
357
  if (conditional)
239
358
  argv.push('-H', `If-None-Match: ${opts.etag}`);
240
- argv.push(endpoint);
241
- return { argv, checked: false, parse: (r) => parseApi(r, withHeaders) };
359
+ if (paginate)
360
+ argv.push('--paginate', '--slurp');
361
+ argv.push(fillRepo(endpoint, target));
362
+ return {
363
+ argv,
364
+ checked: false,
365
+ readOnly: true,
366
+ probe: true,
367
+ parse(r) {
368
+ const response = parseApi(r, withHeaders);
369
+ return paginate ? { ...response, body: flattenPages(response.body) } : response;
370
+ },
371
+ };
372
+ }
373
+ /** `--slurp` pages joined into one array when every page is an array. */
374
+ function flattenPages(body) {
375
+ if (!Array.isArray(body) || !body.every(Array.isArray))
376
+ return body;
377
+ return body.flat();
378
+ }
379
+ /**
380
+ * `gh api graphql -f query=… [--paginate --slurp]` — a GraphQL query.
381
+ *
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
385
+ * connection across the pages with {@link nodesAt}.
386
+ *
387
+ * A string variable of exactly `{owner}` or `{repo}` is filled from the
388
+ * target's repo, or by gh from the clone. A mutation document throws
389
+ * `RangeError`: this is a read, and changes go through `raw`.
390
+ *
391
+ * @experimental
392
+ */
393
+ export function graphql(query, variables = {}, opts = {}, target) {
394
+ checkTarget(target);
395
+ const body = query.replace(/^(?:\s|#[^\n]*\n?)*/, '');
396
+ if (/^mutation\b/.test(body) || /^subscription\b/.test(body)) {
397
+ throw new RangeError('graphql: only queries are allowed; send a mutation through raw');
398
+ }
399
+ const paginate = opts.paginate === true;
400
+ if (paginate && !query.includes('$endCursor')) {
401
+ throw new RangeError('graphql: paginate needs a query that takes $endCursor');
402
+ }
403
+ const argv = ['api', 'graphql', ...hostArgs(target)];
404
+ if (paginate)
405
+ argv.push('--paginate', '--slurp');
406
+ const [owner, name] = target?.repo?.split('/') ?? [];
407
+ for (const [key, value] of Object.entries(variables)) {
408
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
409
+ throw new RangeError(`graphql: bad variable name ${JSON.stringify(key)}`);
410
+ }
411
+ if (typeof value === 'string') {
412
+ const filled = value === '{owner}' ? owner : value === '{repo}' ? name : undefined;
413
+ if (filled !== undefined)
414
+ argv.push('-f', `${key}=${filled}`);
415
+ else if (value === '{owner}' || value === '{repo}')
416
+ argv.push('-F', `${key}=${value}`);
417
+ else
418
+ argv.push('-f', `${key}=${value}`);
419
+ }
420
+ else {
421
+ argv.push('-F', `${key}=${value === null ? 'null' : String(value)}`);
422
+ }
423
+ }
424
+ argv.push('-f', `query=${query}`);
425
+ return {
426
+ argv,
427
+ checked: false,
428
+ readOnly: true,
429
+ parse(r) {
430
+ if (r.exitCode !== 0)
431
+ return null;
432
+ let data;
433
+ try {
434
+ data = JSON.parse(r.stdout);
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ const pages = paginate ? data : [data];
440
+ if (!Array.isArray(pages))
441
+ return null;
442
+ const out = [];
443
+ for (const page of pages) {
444
+ if (page === null || typeof page !== 'object')
445
+ return null;
446
+ const rec = page;
447
+ if (Array.isArray(rec['errors']) && rec['errors'].length > 0)
448
+ return null;
449
+ if (rec['data'] === null || typeof rec['data'] !== 'object')
450
+ return null;
451
+ out.push(rec['data']);
452
+ }
453
+ return out;
454
+ },
455
+ };
456
+ }
457
+ /**
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
460
+ * null, or any page lacks the path or a `nodes` array, so a page that came
461
+ * back short is never read as fewer rows.
462
+ *
463
+ * @experimental
464
+ */
465
+ export function nodesAt(pages, path) {
466
+ if (pages === null)
467
+ return null;
468
+ const out = [];
469
+ for (const page of pages) {
470
+ let at = page;
471
+ for (const key of path) {
472
+ if (at === null || typeof at !== 'object')
473
+ return null;
474
+ at = at[key];
475
+ }
476
+ if (at === null || typeof at !== 'object')
477
+ return null;
478
+ const nodes = at['nodes'];
479
+ if (!Array.isArray(nodes))
480
+ return null;
481
+ out.push(...nodes);
482
+ }
483
+ return out;
242
484
  }
243
485
  /** One header block off the front of `text`: the block, and what follows its blank line. */
244
486
  function takeHeaderBlock(text) {
@@ -295,64 +537,208 @@ function parseApi(r, withHeaders) {
295
537
  }
296
538
  /** `gh --version` — the install probe. True when `gh` runs. */
297
539
  export function installed() {
298
- return { argv: ['--version'], checked: false, 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
+ };
299
547
  }
300
- /** `gh auth status` — the credential probe. */
301
- export function authStatus() {
548
+ /** `gh auth status [--hostname <host>]` — the credential probe, for one host when the target names it. */
549
+ export function authStatus(target) {
302
550
  return {
303
- argv: ['auth', 'status'],
551
+ argv: ['auth', 'status', ...hostArgs(target)],
304
552
  checked: false,
553
+ readOnly: true,
554
+ probe: true,
305
555
  parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
306
556
  };
307
557
  }
308
558
  /**
309
- * `gh label list --json name,color,description` — every label on the repo.
310
- * `limit` defaults to 1000. `gh`'s own default is 30, which would cut off a
311
- * repository with more labels than that without saying so.
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).
312
561
  */
313
- export function labelList(opts = {}) {
562
+ export function labelList(opts = {}, target) {
563
+ const limit = opts.limit ?? 1000;
564
+ checkLimit(limit);
314
565
  return {
315
566
  argv: [
316
567
  'label',
317
568
  'list',
569
+ ...repoArgs(target),
318
570
  '--json',
319
571
  'name,color,description',
320
572
  '--limit',
321
- String(opts.limit ?? 1000),
573
+ String(limit + 1),
322
574
  ],
323
575
  checked: false,
324
- parse: manyLabelsOrNull,
576
+ readOnly: true,
577
+ parse: (r) => limited(manyLabelsOrNull(r), limit),
325
578
  };
326
579
  }
327
580
  /** `gh issue list --json …` — open issues by default, optionally by label. */
328
- export function issueList(opts = {}) {
329
- const argv = ['issue', 'list'];
581
+ export function issueList(opts = {}, target) {
582
+ const limit = opts.limit ?? 200;
583
+ checkLimit(limit);
584
+ const argv = ['issue', 'list', ...repoArgs(target)];
330
585
  if (opts.label !== undefined)
331
586
  argv.push('--label', opts.label);
332
- argv.push('--json', ISSUE_FIELDS, '--limit', String(opts.limit ?? 200));
333
- return { argv, checked: false, parse: manyIssuesOrNull };
587
+ argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1));
588
+ return { argv, checked: false, readOnly: true, parse: (r) => limited(manyIssuesOrNull(r), limit) };
334
589
  }
335
590
  /** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
336
- export function issueView(number) {
591
+ export function issueView(number, target) {
337
592
  return {
338
- argv: ['issue', 'view', String(number), '--json', ISSUE_FIELDS],
593
+ argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
339
594
  checked: false,
595
+ readOnly: true,
596
+ absent: noSuch('issue'),
340
597
  parse: oneIssueOrNull,
341
598
  };
342
599
  }
600
+ /** The `gh repo list`/`view --json` fields a {@link Repository} reads. */
601
+ export const REPO_FIELDS = [
602
+ 'nameWithOwner',
603
+ 'description',
604
+ 'defaultBranchRef',
605
+ 'visibility',
606
+ 'isArchived',
607
+ 'url',
608
+ ];
609
+ /** The `gh search prs --json` fields a {@link PrSearchHit} reads. */
610
+ export const SEARCH_PR_FIELDS = [
611
+ 'number',
612
+ 'title',
613
+ 'state',
614
+ 'url',
615
+ 'isDraft',
616
+ 'repository',
617
+ 'author',
618
+ 'labels',
619
+ 'createdAt',
620
+ 'updatedAt',
621
+ 'closedAt',
622
+ ];
623
+ /** Parse a `--json` array payload with `parse`, dropping rows it rejects. Null on failure. */
624
+ function rowsOrNull(r, parse) {
625
+ if (r.exitCode !== 0)
626
+ return null;
627
+ let data;
628
+ try {
629
+ data = JSON.parse(r.stdout || '[]');
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ if (!Array.isArray(data))
635
+ return null;
636
+ return data.map(parse).filter((row) => row !== null);
637
+ }
638
+ /**
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.
641
+ */
642
+ export function repoList(owner, opts = {}, target) {
643
+ notAFlag('repoList owner', owner);
644
+ const limit = opts.limit ?? 100;
645
+ checkLimit(limit);
646
+ const argv = ['repo', 'list', owner];
647
+ if (opts.archived === true)
648
+ argv.push('--archived');
649
+ if (opts.archived === false)
650
+ argv.push('--no-archived');
651
+ if (opts.visibility !== undefined)
652
+ argv.push('--visibility', opts.visibility);
653
+ argv.push('--json', withFields(opts.fields ?? REPO_FIELDS, ['nameWithOwner']).join(','));
654
+ argv.push('--limit', String(limit + 1));
655
+ return {
656
+ argv,
657
+ checked: false,
658
+ readOnly: true,
659
+ env: hostEnv(target),
660
+ parse: (r) => limited(rowsOrNull(r, parseRepository), limit),
661
+ };
662
+ }
663
+ /**
664
+ * `gh repo view [<[host/]owner/name>] --json …` — one repository: `repo`,
665
+ * else the target's repo, else the clone at the client's cwd.
666
+ */
667
+ export function repoView(repo, fields = REPO_FIELDS, target) {
668
+ checkTarget(target);
669
+ if (repo !== undefined)
670
+ checkTarget({ repo });
671
+ const slug = repo ?? target?.repo;
672
+ const argv = ['repo', 'view'];
673
+ if (slug !== undefined) {
674
+ argv.push(target?.hostname === undefined ? slug : `${target.hostname}/${slug}`);
675
+ }
676
+ argv.push('--json', withFields(fields, ['nameWithOwner']).join(','));
677
+ return {
678
+ argv,
679
+ checked: false,
680
+ readOnly: true,
681
+ absent: noSuch('repo'),
682
+ parse(r) {
683
+ if (r.exitCode !== 0)
684
+ return null;
685
+ try {
686
+ return parseRepository(JSON.parse(r.stdout));
687
+ }
688
+ catch {
689
+ return null;
690
+ }
691
+ },
692
+ };
693
+ }
694
+ /**
695
+ * `gh search prs [flags] -- <query>` — pull requests across repositories,
696
+ * 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
698
+ * repo is not used, so pass `opts.repo` or `opts.owner` to narrow.
699
+ */
700
+ export function searchPrs(query, opts = {}, target) {
701
+ const limit = opts.limit ?? 100;
702
+ checkLimit(limit);
703
+ const argv = ['search', 'prs'];
704
+ if (opts.owner !== undefined) {
705
+ notAFlag('searchPrs owner', opts.owner);
706
+ argv.push('--owner', opts.owner);
707
+ }
708
+ if (opts.repo !== undefined) {
709
+ checkTarget({ repo: opts.repo });
710
+ argv.push('--repo', opts.repo);
711
+ }
712
+ if (opts.state === 'merged')
713
+ argv.push('--merged');
714
+ else if (opts.state !== undefined)
715
+ argv.push('--state', opts.state);
716
+ argv.push('--json', withFields(opts.fields ?? SEARCH_PR_FIELDS, ['number']).join(','));
717
+ argv.push('--limit', String(limit + 1));
718
+ if (query !== '')
719
+ argv.push('--', query);
720
+ return {
721
+ argv,
722
+ checked: false,
723
+ readOnly: true,
724
+ env: hostEnv(target),
725
+ parse: (r) => limited(rowsOrNull(r, parsePrSearchHit), limit),
726
+ };
727
+ }
343
728
  /**
344
729
  * `gh pr create …`, returning the new PR's URL: the last line gh printed that
345
730
  * starts with `http://` or `https://`. `''` when it printed none, as
346
731
  * {@link prComment} does. A line after the URL, such as a tip, is skipped.
347
732
  */
348
- export function createPr(opts) {
349
- const argv = ['pr', 'create', '--head', opts.head, '--base', opts.base];
733
+ export function createPr(opts, target) {
734
+ const argv = ['pr', 'create', ...repoArgs(target), '--head', opts.head, '--base', opts.base];
350
735
  if (opts.draft)
351
736
  argv.push('--draft');
352
737
  argv.push('--title', opts.title, '--body', opts.body);
353
738
  return {
354
739
  argv,
355
740
  checked: true,
741
+ readOnly: false,
356
742
  parse(r) {
357
743
  const urls = r.stdout
358
744
  .split('\n')
@@ -363,31 +749,31 @@ export function createPr(opts) {
363
749
  };
364
750
  }
365
751
  /** `gh pr edit <n>` — retitle or rewrite the body. */
366
- export function editPr(number, opts) {
367
- const argv = ['pr', 'edit', String(number)];
752
+ export function editPr(number, opts, target) {
753
+ const argv = ['pr', 'edit', String(number), ...repoArgs(target)];
368
754
  if (opts.title !== undefined)
369
755
  argv.push('--title', opts.title);
370
756
  if (opts.body !== undefined)
371
757
  argv.push('--body', opts.body);
372
- return { argv, checked: true, parse: nothing };
758
+ return { argv, checked: true, readOnly: false, parse: nothing };
373
759
  }
374
760
  /** `gh pr edit <n> --body` — rewrite just the body. */
375
- export function editPrBody(number, body) {
376
- return editPr(number, { body });
761
+ export function editPrBody(number, body, target) {
762
+ return editPr(number, { body }, target);
377
763
  }
378
764
  /** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
379
- export function prUpdateBranch(number, style = 'merge') {
380
- const argv = ['pr', 'update-branch', String(number)];
765
+ export function prUpdateBranch(number, style = 'merge', target) {
766
+ const argv = ['pr', 'update-branch', String(number), ...repoArgs(target)];
381
767
  if (style === 'rebase')
382
768
  argv.push('--rebase');
383
- return { argv, checked: true, parse: nothing };
769
+ return { argv, checked: true, readOnly: false, parse: nothing };
384
770
  }
385
771
  /** `gh pr ready <n> [--undo]` — mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
386
- export function prSetDraft(number, draft) {
387
- const argv = ['pr', 'ready', String(number)];
772
+ export function prSetDraft(number, draft, target) {
773
+ const argv = ['pr', 'ready', String(number), ...repoArgs(target)];
388
774
  if (draft)
389
775
  argv.push('--undo');
390
- return { argv, checked: true, parse: nothing };
776
+ return { argv, checked: true, readOnly: false, parse: nothing };
391
777
  }
392
778
  /**
393
779
  * `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…` — rewrite one
@@ -403,20 +789,22 @@ export function prSetDraft(number, draft) {
403
789
  *
404
790
  * @experimental
405
791
  */
406
- export function issueCommentEdit(commentId, body) {
792
+ export function issueCommentEdit(commentId, body, target) {
407
793
  if (!/^[0-9]+$/.test(commentId)) {
408
794
  throw new RangeError(`issueCommentEdit: commentId must be a numeric REST id, got ${JSON.stringify(commentId)}`);
409
795
  }
410
796
  return {
411
797
  argv: [
412
798
  'api',
799
+ ...hostArgs(target),
413
800
  '-X',
414
801
  'PATCH',
415
- `repos/{owner}/{repo}/issues/comments/${commentId}`,
802
+ fillRepo(`repos/{owner}/{repo}/issues/comments/${commentId}`, target),
416
803
  '-f',
417
804
  `body=${body}`,
418
805
  ],
419
806
  checked: true,
807
+ readOnly: false,
420
808
  parse: nothing,
421
809
  };
422
810
  }
@@ -427,16 +815,17 @@ export function issueCommentEdit(commentId, body) {
427
815
  * merge can never ride in unreviewed. The branch is left alone (no
428
816
  * `--delete-branch`); deleting it is the caller's job.
429
817
  */
430
- export function prMerge(number, opts) {
818
+ export function prMerge(number, opts, target) {
431
819
  const argv = [
432
820
  'pr',
433
821
  'merge',
434
822
  String(number),
823
+ ...repoArgs(target),
435
824
  `--${opts.method}`,
436
825
  '--match-head-commit',
437
826
  opts.headSha,
438
827
  ];
439
- return { argv, checked: true, parse: nothing };
828
+ return { argv, checked: true, readOnly: false, parse: nothing };
440
829
  }
441
830
  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}}}}}}}}';
442
831
  /**
@@ -446,21 +835,26 @@ const REVIEW_THREADS_QUERY = 'query($owner:String!,$name:String!,$number:Int!){r
446
835
  * PR is clear to merge must read "could not tell" differently from "no
447
836
  * threads".
448
837
  */
449
- export function prReviewThreads(number) {
838
+ export function prReviewThreads(number, target) {
839
+ checkTarget(target);
840
+ // `-F` lets gh fill the placeholders from the clone; real values go as plain strings.
841
+ const [owner, name] = target?.repo?.split('/') ?? [];
842
+ const repo = owner === undefined || name === undefined
843
+ ? ['-F', 'owner={owner}', '-F', 'name={repo}']
844
+ : ['-f', `owner=${owner}`, '-f', `name=${name}`];
450
845
  return {
451
846
  argv: [
452
847
  'api',
453
848
  'graphql',
454
- '-F',
455
- 'owner={owner}',
456
- '-F',
457
- 'name={repo}',
849
+ ...hostArgs(target),
850
+ ...repo,
458
851
  '-F',
459
852
  `number=${number}`,
460
853
  '-f',
461
854
  `query=${REVIEW_THREADS_QUERY}`,
462
855
  ],
463
856
  checked: false,
857
+ readOnly: true,
464
858
  parse(r) {
465
859
  if (r.exitCode !== 0)
466
860
  return null;
@@ -492,27 +886,28 @@ export function prReviewThreads(number) {
492
886
  * `--force` makes it safe to repeat: when the label already exists, its color
493
887
  * and description are set to these instead of failing.
494
888
  */
495
- export function createLabel(opts) {
889
+ export function createLabel(opts, target) {
496
890
  const argv = [
497
891
  'label',
498
892
  'create',
499
893
  opts.name,
894
+ ...repoArgs(target),
500
895
  '--color',
501
896
  opts.color,
502
897
  '--description',
503
898
  opts.description,
504
899
  '--force',
505
900
  ];
506
- return { argv, checked: true, parse: nothing };
901
+ return { argv, checked: true, readOnly: false, parse: nothing };
507
902
  }
508
903
  /** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
509
- export function editLabel(name, opts) {
510
- const argv = ['label', 'edit', name];
904
+ export function editLabel(name, opts, target) {
905
+ const argv = ['label', 'edit', name, ...repoArgs(target)];
511
906
  if (opts.color !== undefined)
512
907
  argv.push('--color', opts.color);
513
908
  if (opts.description !== undefined)
514
909
  argv.push('--description', opts.description);
515
- return { argv, checked: true, parse: nothing };
910
+ return { argv, checked: true, readOnly: false, parse: nothing };
516
911
  }
517
912
  /**
518
913
  * `gh issue edit <n> [--remove-label <current>] --add-label <new>` — swap
@@ -531,22 +926,28 @@ export function editLabel(name, opts) {
531
926
  * issue carries no prior label to remove — the `--remove-label` flag is then
532
927
  * omitted rather than passed empty.
533
928
  */
534
- export function setIssueLabel(number, currentLabel, newLabel) {
535
- const argv = ['issue', 'edit', String(number)];
929
+ export function setIssueLabel(number, currentLabel, newLabel, target) {
930
+ const argv = ['issue', 'edit', String(number), ...repoArgs(target)];
536
931
  if (currentLabel !== null)
537
932
  argv.push('--remove-label', currentLabel);
538
933
  argv.push('--add-label', newLabel);
539
- return { argv, checked: true, parse: nothing };
934
+ return { argv, checked: true, readOnly: false, parse: nothing };
540
935
  }
541
936
  /** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
542
- export function prDiff(number) {
543
- return { argv: ['pr', 'diff', String(number)], checked: true, parse: (r) => r.stdout };
937
+ export function prDiff(number, target) {
938
+ return {
939
+ argv: ['pr', 'diff', String(number), ...repoArgs(target)],
940
+ checked: true,
941
+ readOnly: true,
942
+ parse: (r) => r.stdout,
943
+ };
544
944
  }
545
945
  /** `gh pr comment <n> --body <body>` — post a conversation comment, returning the URL gh prints ('' if none). */
546
- export function prComment(number, body) {
946
+ export function prComment(number, body, target) {
547
947
  return {
548
- argv: ['pr', 'comment', String(number), '--body', body],
948
+ argv: ['pr', 'comment', String(number), ...repoArgs(target), '--body', body],
549
949
  checked: true,
950
+ readOnly: false,
550
951
  parse(r) {
551
952
  const lines = r.stdout
552
953
  .split('\n')
@@ -562,12 +963,12 @@ export function prComment(number, body) {
562
963
  * when GitHub's reply carried none). The event is always `COMMENT`: this
563
964
  * library does not offer a way to approve or request changes. Goes through
564
965
  * `gh api` with the payload on stdin, because `gh pr review` cannot attach
565
- * line-anchored comments; `{owner}`/`{repo}` are filled in by `gh` from the
566
- * client's working directory.
966
+ * line-anchored comments; `{owner}`/`{repo}` come from the target's repo, or
967
+ * are filled in by `gh` from the client's working directory.
567
968
  *
568
969
  * @experimental
569
970
  */
570
- export function createReview(number, opts) {
971
+ export function createReview(number, opts, target) {
571
972
  const payload = {
572
973
  commit_id: opts.commitId,
573
974
  body: opts.body,
@@ -582,13 +983,15 @@ export function createReview(number, opts) {
582
983
  return {
583
984
  argv: [
584
985
  'api',
986
+ ...hostArgs(target),
585
987
  '--method',
586
988
  'POST',
587
- `repos/{owner}/{repo}/pulls/${number}/reviews`,
989
+ fillRepo(`repos/{owner}/{repo}/pulls/${number}/reviews`, target),
588
990
  '--input',
589
991
  '-',
590
992
  ],
591
993
  checked: true,
994
+ readOnly: false,
592
995
  input: JSON.stringify(payload),
593
996
  parse(r) {
594
997
  try {
@@ -603,16 +1006,31 @@ export function createReview(number, opts) {
603
1006
  }
604
1007
  // ── helpers over the parsed rows ────────────────────────────────────────────
605
1008
  /**
606
- * The row whose `headRefName` exactly equals `branch`, or null.
1009
+ * The first row whose `headRefName` exactly equals `branch`, or null.
607
1010
  *
608
- * gh's `head:` search is a prefix match, so `task/foo` also returns
609
- * `task/foo-2`. Every caller that means one branch needs this filter.
1011
+ * `prList({ head })` already filters exactly. This is for rows from anywhere
1012
+ * else, such as a `search` query with gh's `head:` qualifier, which matches
1013
+ * `task/foo-2` for `task/foo`.
610
1014
  */
611
1015
  export function pickPrForBranch(rows, branch) {
612
1016
  if (rows === null)
613
1017
  return null;
614
1018
  return rows.find((r) => r.headRefName === branch) ?? null;
615
1019
  }
1020
+ /**
1021
+ * The PR a branch names, from `prList({ head, state: 'all' })` rows: the open
1022
+ * one, or else the first row (gh lists newest first). Null when there are no
1023
+ * rows or the call failed.
1024
+ */
1025
+ export function branchPr(result) {
1026
+ if (result === null)
1027
+ return null;
1028
+ return result.rows.find((r) => r.state === 'OPEN') ?? result.rows[0] ?? null;
1029
+ }
1030
+ /** The field set {@link branchPr} needs, ahead of `fields`. */
1031
+ export function branchPrFields(fields) {
1032
+ return withFields(fields, ['number', 'state', 'headRefName']);
1033
+ }
616
1034
  /**
617
1035
  * True when GitHub has not finished computing this PR's mergeability.
618
1036
  *