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