@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/verbs.ts CHANGED
@@ -14,19 +14,95 @@
14
14
 
15
15
  import { stderrOrError, type CommandResult } from '@osdlc/command-seam'
16
16
 
17
- import { parseLabels, parsePullRequest } from './parse.ts'
18
- import type { IssueRow, Label, PullRequest } from './types.ts'
17
+ import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from './parse.ts'
18
+ import { isRepoSlug, parsePrUrl } from './repo.ts'
19
+ import type { IssueRow, Label, PrSearchHit, PullRequest, Repository } from './types.ts'
19
20
 
20
21
  /** One `gh` invocation: what to run, and how to read what came back. */
21
22
  export interface GhVerb<T> {
22
23
  argv: string[]
23
24
  /** True for every mutation and for `prDiff` — throw on a non-zero exit instead of parsing. */
24
25
  checked: boolean
26
+ /**
27
+ * True when the call only reads: it changes nothing on GitHub. Every query
28
+ * is read-only, and so is `prDiff`; every mutation is not. A caller that
29
+ * keeps an allowlist of safe calls can check this before running a verb.
30
+ */
31
+ readOnly: boolean
25
32
  /** Written to the child's stdin, for a verb whose payload is too large or structured for argv. */
26
33
  input?: string | undefined
34
+ /**
35
+ * Variables to set on the child, for a target gh takes no flag for: `GH_HOST`
36
+ * on `gh search` and `gh repo list`.
37
+ */
38
+ env?: Record<string, string> | undefined
27
39
  parse(result: CommandResult): T
28
40
  }
29
41
 
42
+ /**
43
+ * The repository and host a verb talks to. Both are optional: with no `repo`,
44
+ * gh uses the repository of the clone at the client's working directory, and
45
+ * with no `hostname`, gh's default host.
46
+ */
47
+ export interface GhTarget {
48
+ /** `owner/name`. */
49
+ repo?: string | undefined
50
+ /**
51
+ * A GitHub host: `github.com`, a GitHub Enterprise Server host such as
52
+ * `github.acme.com`, or a `<tenant>.ghe.com` host. Other forges are out of
53
+ * reach: gh speaks only GitHub's API.
54
+ */
55
+ hostname?: string | undefined
56
+ }
57
+
58
+ const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d+)?$/
59
+
60
+ /**
61
+ * Throw `RangeError` unless `target.repo` is `owner/name` and
62
+ * `target.hostname` is a bare host. Run by every verb that uses a target, so
63
+ * no text can reach gh as a flag or change the endpoint a call hits.
64
+ */
65
+ export function checkTarget(target: GhTarget | undefined): void {
66
+ if (target?.repo !== undefined && !isRepoSlug(target.repo)) {
67
+ throw new RangeError(`repo must be owner/name, got ${JSON.stringify(target.repo)}`)
68
+ }
69
+ if (target?.hostname !== undefined && !HOSTNAME.test(target.hostname)) {
70
+ throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(target.hostname)}`)
71
+ }
72
+ }
73
+
74
+ /** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs; `repo` overrides the target's. */
75
+ function repoArgs(target: GhTarget | undefined, repo?: string | undefined): string[] {
76
+ checkTarget(target)
77
+ const slug = repo ?? target?.repo
78
+ if (slug === undefined) return []
79
+ if (repo !== undefined) checkTarget({ repo })
80
+ return ['--repo', target?.hostname === undefined ? slug : `${target.hostname}/${slug}`]
81
+ }
82
+
83
+ /** `--hostname <host>` for `api` and `auth` verbs. */
84
+ function hostArgs(target: GhTarget | undefined): string[] {
85
+ checkTarget(target)
86
+ return target?.hostname === undefined ? [] : ['--hostname', target.hostname]
87
+ }
88
+
89
+ /** `GH_HOST` for the verbs gh gives no host flag. */
90
+ function hostEnv(target: GhTarget | undefined): Record<string, string> | undefined {
91
+ checkTarget(target)
92
+ return target?.hostname === undefined ? undefined : { GH_HOST: target.hostname }
93
+ }
94
+
95
+ /**
96
+ * `endpoint` with `{owner}` and `{repo}` filled from the target. With no
97
+ * target repo they are left for gh to fill from the clone.
98
+ */
99
+ function fillRepo(endpoint: string, target: GhTarget | undefined): string {
100
+ checkTarget(target)
101
+ if (target?.repo === undefined) return endpoint
102
+ const [owner, name] = target.repo.split('/') as [string, string]
103
+ return endpoint.replaceAll('{owner}', owner).replaceAll('{repo}', name)
104
+ }
105
+
30
106
  /**
31
107
  * The cheapest useful set: enough to identify a PR and nothing more.
32
108
  *
@@ -107,6 +183,29 @@ function manyOrNull(r: CommandResult): PullRequest[] | null {
107
183
  return rows
108
184
  }
109
185
 
186
+ /**
187
+ * A list query's answer: the rows, and whether `limit` cut the list short.
188
+ * The verb asks gh for one row more than `limit`; `truncated` is true when
189
+ * that row came back, and `rows` holds at most `limit`.
190
+ */
191
+ export interface ListResult<T> {
192
+ rows: T[]
193
+ truncated: boolean
194
+ }
195
+
196
+ /** Throw `RangeError` unless `limit` is a positive integer. */
197
+ function checkLimit(limit: number): void {
198
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
199
+ throw new RangeError(`limit must be a positive integer, got ${limit}`)
200
+ }
201
+ }
202
+
203
+ /** `rows` cut to `limit`, with whether there were more. Null stays null. */
204
+ function limited<T>(rows: T[] | null, limit: number): ListResult<T> | null {
205
+ if (rows === null) return null
206
+ return { rows: rows.slice(0, limit), truncated: rows.length > limit }
207
+ }
208
+
110
209
  /** A mutation's result carries no value. */
111
210
  function nothing(): void {}
112
211
 
@@ -193,8 +292,10 @@ export interface PrListOptions {
193
292
  /** Exact base branch filter (gh-side). */
194
293
  base?: string | undefined
195
294
  /**
196
- * Head branch filter. gh matches this as a prefix, so a caller needing an
197
- * exact match filters the rows itself — {@link pickPrForBranch} does.
295
+ * Head branch filter, exact: `task/foo` does not match `task/foo-2`. Passed
296
+ * as gh's `--head`, and the rows are filtered again on `headRefName`. Two
297
+ * forks can share a branch name; ask for `headRepositoryOwner` to tell them
298
+ * apart.
198
299
  */
199
300
  head?: string | undefined
200
301
  /** Raw `--search` query, for anything the flags do not cover. */
@@ -205,43 +306,97 @@ export interface PrListOptions {
205
306
  fields?: readonly string[] | undefined
206
307
  }
207
308
 
208
- /** `gh pr list …` — open PRs by default. */
209
- export function prList(opts: PrListOptions = {}): GhVerb<PullRequest[] | null> {
210
- const argv = ['pr', 'list', '--state', opts.state ?? 'open']
211
- if (opts.repo !== undefined) argv.push('--repo', opts.repo)
309
+ /** `gh pr list …` — open PRs by default. `opts.repo` overrides the target's repo. */
310
+ export function prList(
311
+ opts: PrListOptions = {},
312
+ target?: GhTarget,
313
+ ): GhVerb<ListResult<PullRequest> | null> {
314
+ const limit = opts.limit ?? 100
315
+ checkLimit(limit)
316
+ const argv = ['pr', 'list', '--state', opts.state ?? 'open', ...repoArgs(target, opts.repo)]
212
317
  if (opts.base !== undefined) argv.push('--base', opts.base)
213
- if (opts.head !== undefined) argv.push('--search', `head:${opts.head}`)
318
+ if (opts.head !== undefined) argv.push('--head', opts.head)
214
319
  if (opts.search !== undefined) argv.push('--search', opts.search)
215
320
  if (opts.author !== undefined) argv.push('--author', opts.author)
216
- argv.push('--json', (opts.fields ?? PR_LIST_FIELDS).join(','))
217
- argv.push('--limit', String(opts.limit ?? 100))
218
- return { argv, checked: false, parse: manyOrNull }
321
+ // The exact-head filter below reads `headRefName`, so ask for it.
322
+ const fields = opts.fields ?? PR_LIST_FIELDS
323
+ const asked = opts.head === undefined ? fields : withFields(fields, ['headRefName'])
324
+ argv.push('--json', asked.join(','))
325
+ argv.push('--limit', String(limit + 1))
326
+ const head = opts.head
327
+ return {
328
+ argv,
329
+ checked: false,
330
+ readOnly: true,
331
+ parse(r) {
332
+ const result = limited(manyOrNull(r), limit)
333
+ if (result === null || head === undefined) return result
334
+ return { ...result, rows: result.rows.filter((pr) => pr.headRefName === head) }
335
+ },
336
+ }
219
337
  }
220
338
 
221
- /** `gh pr view <n> --json …` — one PR, with the heavy fields by default. */
339
+ /** `fields` with each of `required` added once, ahead of the rest. */
340
+ function withFields(fields: readonly string[], required: readonly string[]): string[] {
341
+ return [...new Set([...required.filter((f) => !fields.includes(f)), ...fields])]
342
+ }
343
+
344
+ /**
345
+ * Throw `RangeError` when a string bound for gh's argv as a positional
346
+ * argument would read as a flag.
347
+ */
348
+ function notAFlag(what: string, value: string): void {
349
+ if (value.startsWith('-')) {
350
+ throw new RangeError(`${what} must not start with "-", got ${JSON.stringify(value)}`)
351
+ }
352
+ }
353
+
354
+ /**
355
+ * `gh pr view <number|branch|url> --json …` — one PR, with the heavy fields
356
+ * by default.
357
+ *
358
+ * A branch is the PR whose head is that branch, as gh picks it. A URL names
359
+ * its own repository and host, so the target is not passed with one. A
360
+ * string that starts with `-` throws `RangeError`.
361
+ */
222
362
  export function prView(
223
- number: number,
363
+ ref: number | string,
224
364
  fields: readonly string[] = PR_VIEW_FIELDS,
365
+ target?: GhTarget,
225
366
  ): GhVerb<PullRequest | null> {
367
+ if (typeof ref === 'string') notAFlag('prView ref', ref)
226
368
  // `parsePullRequest` yields null without a `number`, so a field subset that
227
369
  // omits it would read as "no such PR". Always ask for it, and each field once.
228
- const asked = [...new Set(fields.includes('number') ? fields : ['number', ...fields])]
370
+ const asked = withFields(fields, ['number'])
371
+ const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null
229
372
  return {
230
- argv: ['pr', 'view', String(number), '--json', asked.join(',')],
373
+ argv: [
374
+ 'pr',
375
+ 'view',
376
+ String(ref),
377
+ ...(isUrl ? [] : repoArgs(target)),
378
+ '--json',
379
+ asked.join(','),
380
+ ],
231
381
  checked: false,
382
+ readOnly: true,
232
383
  parse: oneOrNull,
233
384
  }
234
385
  }
235
386
 
236
387
  /**
237
- * Open PRs whose head *starts with* `branch` — gh's own search semantics.
388
+ * Open PRs whose head is exactly `branch`.
238
389
  *
239
390
  * The common shape behind "which PR is this branch?", bundling the cheap
240
- * field set with a small limit. Pair it with {@link pickPrForBranch}, which
241
- * is what turns the prefix match into an exact one.
391
+ * field set with a small limit. More than one row comes back only when forks
392
+ * share the branch name.
242
393
  */
243
- export function openPrsForBranch(branch: string, limit = 5): GhVerb<PullRequest[] | null> {
244
- return prList({ head: branch, limit, fields: PR_REF_FIELDS })
394
+ export function openPrsForBranch(
395
+ branch: string,
396
+ limit = 5,
397
+ target?: GhTarget,
398
+ ): GhVerb<ListResult<PullRequest> | null> {
399
+ return prList({ head: branch, limit, fields: PR_REF_FIELDS }, target)
245
400
  }
246
401
 
247
402
  /**
@@ -250,10 +405,11 @@ export function openPrsForBranch(branch: string, limit = 5): GhVerb<PullRequest[
250
405
  * so every trailing `\r` and `\n` is trimmed (including any that were part of
251
406
  * the body, such as the `\r\n` of a body saved with CRLF line endings).
252
407
  */
253
- export function prBody(number: number): GhVerb<string | null> {
408
+ export function prBody(number: number, target?: GhTarget): GhVerb<string | null> {
254
409
  return {
255
- argv: ['pr', 'view', String(number), '--json', 'body', '-q', '.body'],
410
+ argv: ['pr', 'view', String(number), ...repoArgs(target), '--json', 'body', '-q', '.body'],
256
411
  checked: false,
412
+ readOnly: true,
257
413
  parse: (r) => (r.exitCode === 0 ? r.stdout.replace(/[\r\n]+$/, '') : null),
258
414
  }
259
415
  }
@@ -285,22 +441,173 @@ export interface ApiOptions {
285
441
  etag?: string | null | undefined
286
442
  /** Pass `-i` and read status/ETag off the header block (the last one, if several). */
287
443
  includeHeaders?: boolean | undefined
444
+ /**
445
+ * Fetch every page (`--paginate --slurp`). `body` is then the pages joined
446
+ * into one array when every page is an array, else the list of pages.
447
+ * Cannot be combined with `etag` or `includeHeaders`: with `-i` gh prints
448
+ * a header block per page. `status` is synthesized, as without `-i`.
449
+ */
450
+ paginate?: boolean | undefined
288
451
  }
289
452
 
290
453
  /**
291
454
  * `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
292
- * `{repo}` placeholders in the endpoint are filled by gh from the cwd's repo.
455
+ * `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
456
+ * cwd's repo when the target names none.
293
457
  * Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
294
458
  * from an outage.
295
459
  */
296
- export function api(endpoint: string, opts: ApiOptions = {}): GhVerb<ApiResponse> {
460
+ export function api(
461
+ endpoint: string,
462
+ opts: ApiOptions = {},
463
+ target?: GhTarget,
464
+ ): GhVerb<ApiResponse> {
465
+ notAFlag('api endpoint', endpoint)
297
466
  const conditional = opts.etag !== undefined && opts.etag !== null && opts.etag !== ''
298
467
  const withHeaders = conditional || opts.includeHeaders === true
299
- const argv = ['api']
468
+ const paginate = opts.paginate === true
469
+ if (paginate && withHeaders) {
470
+ throw new RangeError('api: paginate cannot be combined with etag or includeHeaders')
471
+ }
472
+ const argv = ['api', ...hostArgs(target)]
300
473
  if (withHeaders) argv.push('-i')
301
474
  if (conditional) argv.push('-H', `If-None-Match: ${opts.etag}`)
302
- argv.push(endpoint)
303
- return { argv, checked: false, parse: (r) => parseApi(r, withHeaders) }
475
+ if (paginate) argv.push('--paginate', '--slurp')
476
+ argv.push(fillRepo(endpoint, target))
477
+ return {
478
+ argv,
479
+ checked: false,
480
+ readOnly: true,
481
+ parse(r) {
482
+ const response = parseApi(r, withHeaders)
483
+ return paginate ? { ...response, body: flattenPages(response.body) } : response
484
+ },
485
+ }
486
+ }
487
+
488
+ /** `--slurp` pages joined into one array when every page is an array. */
489
+ function flattenPages(body: unknown): unknown {
490
+ if (!Array.isArray(body) || !body.every(Array.isArray)) return body
491
+ return (body as unknown[][]).flat()
492
+ }
493
+
494
+ /**
495
+ * A GraphQL variable: gh sends a string with `-f` and the rest with `-F`.
496
+ *
497
+ * @experimental
498
+ */
499
+ export type GraphqlVariable = string | number | boolean | null
500
+
501
+ /**
502
+ * Options for {@link graphql}.
503
+ *
504
+ * @experimental
505
+ */
506
+ export interface GraphqlOptions {
507
+ /**
508
+ * Follow every page (`--paginate --slurp`). The query must take
509
+ * `$endCursor: String` and select `pageInfo { hasNextPage endCursor }` on
510
+ * the one connection it pages through; gh follows only that connection.
511
+ */
512
+ paginate?: boolean | undefined
513
+ }
514
+
515
+ /**
516
+ * `gh api graphql -f query=… [--paginate --slurp]` — a GraphQL query.
517
+ *
518
+ * Returns each page's `data`, one entry per page (one without `paginate`),
519
+ * or null when the call failed, the output did not parse, or any page
520
+ * carries `errors` or lacks `data`: a partial answer is no answer. Read one
521
+ * connection across the pages with {@link nodesAt}.
522
+ *
523
+ * A string variable of exactly `{owner}` or `{repo}` is filled from the
524
+ * target's repo, or by gh from the clone. A mutation document throws
525
+ * `RangeError`: this is a read, and changes go through `raw`.
526
+ *
527
+ * @experimental
528
+ */
529
+ export function graphql(
530
+ query: string,
531
+ variables: Record<string, GraphqlVariable> = {},
532
+ opts: GraphqlOptions = {},
533
+ target?: GhTarget,
534
+ ): GhVerb<unknown[] | null> {
535
+ checkTarget(target)
536
+ const body = query.replace(/^(?:\s|#[^\n]*\n?)*/, '')
537
+ if (/^mutation\b/.test(body) || /^subscription\b/.test(body)) {
538
+ throw new RangeError('graphql: only queries are allowed; send a mutation through raw')
539
+ }
540
+ const paginate = opts.paginate === true
541
+ if (paginate && !query.includes('$endCursor')) {
542
+ throw new RangeError('graphql: paginate needs a query that takes $endCursor')
543
+ }
544
+ const argv = ['api', 'graphql', ...hostArgs(target)]
545
+ if (paginate) argv.push('--paginate', '--slurp')
546
+ const [owner, name] = target?.repo?.split('/') ?? []
547
+ for (const [key, value] of Object.entries(variables)) {
548
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
549
+ throw new RangeError(`graphql: bad variable name ${JSON.stringify(key)}`)
550
+ }
551
+ if (typeof value === 'string') {
552
+ const filled = value === '{owner}' ? owner : value === '{repo}' ? name : undefined
553
+ if (filled !== undefined) argv.push('-f', `${key}=${filled}`)
554
+ else if (value === '{owner}' || value === '{repo}') argv.push('-F', `${key}=${value}`)
555
+ else argv.push('-f', `${key}=${value}`)
556
+ } else {
557
+ argv.push('-F', `${key}=${value === null ? 'null' : String(value)}`)
558
+ }
559
+ }
560
+ argv.push('-f', `query=${query}`)
561
+ return {
562
+ argv,
563
+ checked: false,
564
+ readOnly: true,
565
+ parse(r) {
566
+ if (r.exitCode !== 0) return null
567
+ let data: unknown
568
+ try {
569
+ data = JSON.parse(r.stdout)
570
+ } catch {
571
+ return null
572
+ }
573
+ const pages = paginate ? data : [data]
574
+ if (!Array.isArray(pages)) return null
575
+ const out: unknown[] = []
576
+ for (const page of pages) {
577
+ if (page === null || typeof page !== 'object') return null
578
+ const rec = page as Record<string, unknown>
579
+ if (Array.isArray(rec['errors']) && rec['errors'].length > 0) return null
580
+ if (rec['data'] === null || typeof rec['data'] !== 'object') return null
581
+ out.push(rec['data'])
582
+ }
583
+ return out
584
+ },
585
+ }
586
+ }
587
+
588
+ /**
589
+ * The `nodes` of one connection, joined across {@link graphql} pages: walk
590
+ * `path` into each page and take its `nodes` array. Null when `pages` is
591
+ * null, or any page lacks the path or a `nodes` array, so a page that came
592
+ * back short is never read as fewer rows.
593
+ *
594
+ * @experimental
595
+ */
596
+ export function nodesAt(pages: unknown[] | null, path: readonly string[]): unknown[] | null {
597
+ if (pages === null) return null
598
+ const out: unknown[] = []
599
+ for (const page of pages) {
600
+ let at: unknown = page
601
+ for (const key of path) {
602
+ if (at === null || typeof at !== 'object') return null
603
+ at = (at as Record<string, unknown>)[key]
604
+ }
605
+ if (at === null || typeof at !== 'object') return null
606
+ const nodes = (at as Record<string, unknown>)['nodes']
607
+ if (!Array.isArray(nodes)) return null
608
+ out.push(...nodes)
609
+ }
610
+ return out
304
611
  }
305
612
 
306
613
  /** One header block off the front of `text`: the block, and what follows its blank line. */
@@ -356,7 +663,7 @@ function parseApi(r: CommandResult, withHeaders: boolean): ApiResponse {
356
663
 
357
664
  /** `gh --version` — the install probe. True when `gh` runs. */
358
665
  export function installed(): GhVerb<boolean> {
359
- return { argv: ['--version'], checked: false, parse: (r) => r.exitCode === 0 }
666
+ return { argv: ['--version'], checked: false, readOnly: true, parse: (r) => r.exitCode === 0 }
360
667
  }
361
668
 
362
669
  /**
@@ -368,32 +675,39 @@ export interface AuthStatus {
368
675
  detail: string
369
676
  }
370
677
 
371
- /** `gh auth status` — the credential probe. */
372
- export function authStatus(): GhVerb<AuthStatus> {
678
+ /** `gh auth status [--hostname <host>]` — the credential probe, for one host when the target names it. */
679
+ export function authStatus(target?: GhTarget): GhVerb<AuthStatus> {
373
680
  return {
374
- argv: ['auth', 'status'],
681
+ argv: ['auth', 'status', ...hostArgs(target)],
375
682
  checked: false,
683
+ readOnly: true,
376
684
  parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
377
685
  }
378
686
  }
379
687
 
380
688
  /**
381
- * `gh label list --json name,color,description` — every label on the repo.
382
- * `limit` defaults to 1000. `gh`'s own default is 30, which would cut off a
383
- * repository with more labels than that without saying so.
689
+ * `gh label list --json name,color,description` — every label on the repo,
690
+ * up to `limit` (default 1000; `gh`'s own default is 30).
384
691
  */
385
- export function labelList(opts: { limit?: number | undefined } = {}): GhVerb<Label[] | null> {
692
+ export function labelList(
693
+ opts: { limit?: number | undefined } = {},
694
+ target?: GhTarget,
695
+ ): GhVerb<ListResult<Label> | null> {
696
+ const limit = opts.limit ?? 1000
697
+ checkLimit(limit)
386
698
  return {
387
699
  argv: [
388
700
  'label',
389
701
  'list',
702
+ ...repoArgs(target),
390
703
  '--json',
391
704
  'name,color,description',
392
705
  '--limit',
393
- String(opts.limit ?? 1000),
706
+ String(limit + 1),
394
707
  ],
395
708
  checked: false,
396
- parse: manyLabelsOrNull,
709
+ readOnly: true,
710
+ parse: (r) => limited(manyLabelsOrNull(r), limit),
397
711
  }
398
712
  }
399
713
 
@@ -406,22 +720,186 @@ export interface IssueListOptions {
406
720
  }
407
721
 
408
722
  /** `gh issue list --json …` — open issues by default, optionally by label. */
409
- export function issueList(opts: IssueListOptions = {}): GhVerb<IssueRow[] | null> {
410
- const argv = ['issue', 'list']
723
+ export function issueList(
724
+ opts: IssueListOptions = {},
725
+ target?: GhTarget,
726
+ ): GhVerb<ListResult<IssueRow> | null> {
727
+ const limit = opts.limit ?? 200
728
+ checkLimit(limit)
729
+ const argv = ['issue', 'list', ...repoArgs(target)]
411
730
  if (opts.label !== undefined) argv.push('--label', opts.label)
412
- argv.push('--json', ISSUE_FIELDS, '--limit', String(opts.limit ?? 200))
413
- return { argv, checked: false, parse: manyIssuesOrNull }
731
+ argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1))
732
+ return { argv, checked: false, readOnly: true, parse: (r) => limited(manyIssuesOrNull(r), limit) }
414
733
  }
415
734
 
416
735
  /** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
417
- export function issueView(number: number): GhVerb<IssueRow | null> {
736
+ export function issueView(number: number, target?: GhTarget): GhVerb<IssueRow | null> {
418
737
  return {
419
- argv: ['issue', 'view', String(number), '--json', ISSUE_FIELDS],
738
+ argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
420
739
  checked: false,
740
+ readOnly: true,
421
741
  parse: oneIssueOrNull,
422
742
  }
423
743
  }
424
744
 
745
+ /** The `gh repo list`/`view --json` fields a {@link Repository} reads. */
746
+ export const REPO_FIELDS = [
747
+ 'nameWithOwner',
748
+ 'description',
749
+ 'defaultBranchRef',
750
+ 'visibility',
751
+ 'isArchived',
752
+ 'url',
753
+ ] as const
754
+
755
+ /** The `gh search prs --json` fields a {@link PrSearchHit} reads. */
756
+ export const SEARCH_PR_FIELDS = [
757
+ 'number',
758
+ 'title',
759
+ 'state',
760
+ 'url',
761
+ 'isDraft',
762
+ 'repository',
763
+ 'author',
764
+ 'labels',
765
+ 'createdAt',
766
+ 'updatedAt',
767
+ 'closedAt',
768
+ ] as const
769
+
770
+ /** Parse a `--json` array payload with `parse`, dropping rows it rejects. Null on failure. */
771
+ function rowsOrNull<T>(r: CommandResult, parse: (raw: unknown) => T | null): T[] | null {
772
+ if (r.exitCode !== 0) return null
773
+ let data: unknown
774
+ try {
775
+ data = JSON.parse(r.stdout || '[]')
776
+ } catch {
777
+ return null
778
+ }
779
+ if (!Array.isArray(data)) return null
780
+ return data.map(parse).filter((row): row is T => row !== null)
781
+ }
782
+
783
+ /** What to narrow a repository listing to. Every filter is optional. */
784
+ export interface RepoListOptions {
785
+ /** True for archived repositories only, false to leave them out; both when absent. */
786
+ archived?: boolean | undefined
787
+ visibility?: 'public' | 'private' | 'internal' | undefined
788
+ /** The most repositories to return. Defaults to 100. */
789
+ limit?: number | undefined
790
+ /** Field subset; defaults to {@link REPO_FIELDS}. `nameWithOwner` is always asked for. */
791
+ fields?: readonly string[] | undefined
792
+ }
793
+
794
+ /**
795
+ * `gh repo list <owner> --json …` — an owner's repositories. The host comes
796
+ * from the target (`GH_HOST`); the target's repo is not used.
797
+ */
798
+ export function repoList(
799
+ owner: string,
800
+ opts: RepoListOptions = {},
801
+ target?: GhTarget,
802
+ ): GhVerb<ListResult<Repository> | null> {
803
+ notAFlag('repoList owner', owner)
804
+ const limit = opts.limit ?? 100
805
+ checkLimit(limit)
806
+ const argv = ['repo', 'list', owner]
807
+ if (opts.archived === true) argv.push('--archived')
808
+ if (opts.archived === false) argv.push('--no-archived')
809
+ if (opts.visibility !== undefined) argv.push('--visibility', opts.visibility)
810
+ argv.push('--json', withFields(opts.fields ?? REPO_FIELDS, ['nameWithOwner']).join(','))
811
+ argv.push('--limit', String(limit + 1))
812
+ return {
813
+ argv,
814
+ checked: false,
815
+ readOnly: true,
816
+ env: hostEnv(target),
817
+ parse: (r) => limited(rowsOrNull(r, parseRepository), limit),
818
+ }
819
+ }
820
+
821
+ /**
822
+ * `gh repo view [<[host/]owner/name>] --json …` — one repository: `repo`,
823
+ * else the target's repo, else the clone at the client's cwd.
824
+ */
825
+ export function repoView(
826
+ repo?: string | undefined,
827
+ fields: readonly string[] = REPO_FIELDS,
828
+ target?: GhTarget,
829
+ ): GhVerb<Repository | null> {
830
+ checkTarget(target)
831
+ if (repo !== undefined) checkTarget({ repo })
832
+ const slug = repo ?? target?.repo
833
+ const argv = ['repo', 'view']
834
+ if (slug !== undefined) {
835
+ argv.push(target?.hostname === undefined ? slug : `${target.hostname}/${slug}`)
836
+ }
837
+ argv.push('--json', withFields(fields, ['nameWithOwner']).join(','))
838
+ return {
839
+ argv,
840
+ checked: false,
841
+ readOnly: true,
842
+ parse(r) {
843
+ if (r.exitCode !== 0) return null
844
+ try {
845
+ return parseRepository(JSON.parse(r.stdout))
846
+ } catch {
847
+ return null
848
+ }
849
+ },
850
+ }
851
+ }
852
+
853
+ /** What to narrow a pull request search to. Every filter is optional. */
854
+ export interface SearchPrsOptions {
855
+ /** Only repositories this user or organization owns. */
856
+ owner?: string | undefined
857
+ /** Only this `owner/name` repository. */
858
+ repo?: string | undefined
859
+ /** `merged` is `--merged`; `open` and `closed` are `--state`. */
860
+ state?: 'open' | 'closed' | 'merged' | undefined
861
+ /** The most hits to return. Defaults to 100. */
862
+ limit?: number | undefined
863
+ /** Field subset; defaults to {@link SEARCH_PR_FIELDS}. `number` is always asked for. */
864
+ fields?: readonly string[] | undefined
865
+ }
866
+
867
+ /**
868
+ * `gh search prs [flags] -- <query>` — pull requests across repositories,
869
+ * with GitHub's search syntax in `query` (it may start with `-`, as in
870
+ * `-label:bug`). The host comes from the target (`GH_HOST`); the target's
871
+ * repo is not used, so pass `opts.repo` or `opts.owner` to narrow.
872
+ */
873
+ export function searchPrs(
874
+ query: string,
875
+ opts: SearchPrsOptions = {},
876
+ target?: GhTarget,
877
+ ): GhVerb<ListResult<PrSearchHit> | null> {
878
+ const limit = opts.limit ?? 100
879
+ checkLimit(limit)
880
+ const argv = ['search', 'prs']
881
+ if (opts.owner !== undefined) {
882
+ notAFlag('searchPrs owner', opts.owner)
883
+ argv.push('--owner', opts.owner)
884
+ }
885
+ if (opts.repo !== undefined) {
886
+ checkTarget({ repo: opts.repo })
887
+ argv.push('--repo', opts.repo)
888
+ }
889
+ if (opts.state === 'merged') argv.push('--merged')
890
+ else if (opts.state !== undefined) argv.push('--state', opts.state)
891
+ argv.push('--json', withFields(opts.fields ?? SEARCH_PR_FIELDS, ['number']).join(','))
892
+ argv.push('--limit', String(limit + 1))
893
+ if (query !== '') argv.push('--', query)
894
+ return {
895
+ argv,
896
+ checked: false,
897
+ readOnly: true,
898
+ env: hostEnv(target),
899
+ parse: (r) => limited(rowsOrNull(r, parsePrSearchHit), limit),
900
+ }
901
+ }
902
+
425
903
  // ── mutations (checked — the caller throws CommandFailed on non-zero) ────────
426
904
 
427
905
  /**
@@ -441,13 +919,14 @@ export interface CreatePrOptions {
441
919
  * starts with `http://` or `https://`. `''` when it printed none, as
442
920
  * {@link prComment} does. A line after the URL, such as a tip, is skipped.
443
921
  */
444
- export function createPr(opts: CreatePrOptions): GhVerb<string> {
445
- const argv = ['pr', 'create', '--head', opts.head, '--base', opts.base]
922
+ export function createPr(opts: CreatePrOptions, target?: GhTarget): GhVerb<string> {
923
+ const argv = ['pr', 'create', ...repoArgs(target), '--head', opts.head, '--base', opts.base]
446
924
  if (opts.draft) argv.push('--draft')
447
925
  argv.push('--title', opts.title, '--body', opts.body)
448
926
  return {
449
927
  argv,
450
928
  checked: true,
929
+ readOnly: false,
451
930
  parse(r) {
452
931
  const urls = r.stdout
453
932
  .split('\n')
@@ -462,30 +941,35 @@ export function createPr(opts: CreatePrOptions): GhVerb<string> {
462
941
  export function editPr(
463
942
  number: number,
464
943
  opts: { title?: string | undefined; body?: string | undefined },
944
+ target?: GhTarget,
465
945
  ): GhVerb<void> {
466
- const argv = ['pr', 'edit', String(number)]
946
+ const argv = ['pr', 'edit', String(number), ...repoArgs(target)]
467
947
  if (opts.title !== undefined) argv.push('--title', opts.title)
468
948
  if (opts.body !== undefined) argv.push('--body', opts.body)
469
- return { argv, checked: true, parse: nothing }
949
+ return { argv, checked: true, readOnly: false, parse: nothing }
470
950
  }
471
951
 
472
952
  /** `gh pr edit <n> --body` — rewrite just the body. */
473
- export function editPrBody(number: number, body: string): GhVerb<void> {
474
- return editPr(number, { body })
953
+ export function editPrBody(number: number, body: string, target?: GhTarget): GhVerb<void> {
954
+ return editPr(number, { body }, target)
475
955
  }
476
956
 
477
957
  /** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
478
- export function prUpdateBranch(number: number, style: 'merge' | 'rebase' = 'merge'): GhVerb<void> {
479
- const argv = ['pr', 'update-branch', String(number)]
958
+ export function prUpdateBranch(
959
+ number: number,
960
+ style: 'merge' | 'rebase' = 'merge',
961
+ target?: GhTarget,
962
+ ): GhVerb<void> {
963
+ const argv = ['pr', 'update-branch', String(number), ...repoArgs(target)]
480
964
  if (style === 'rebase') argv.push('--rebase')
481
- return { argv, checked: true, parse: nothing }
965
+ return { argv, checked: true, readOnly: false, parse: nothing }
482
966
  }
483
967
 
484
968
  /** `gh pr ready <n> [--undo]` — mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
485
- export function prSetDraft(number: number, draft: boolean): GhVerb<void> {
486
- const argv = ['pr', 'ready', String(number)]
969
+ export function prSetDraft(number: number, draft: boolean, target?: GhTarget): GhVerb<void> {
970
+ const argv = ['pr', 'ready', String(number), ...repoArgs(target)]
487
971
  if (draft) argv.push('--undo')
488
- return { argv, checked: true, parse: nothing }
972
+ return { argv, checked: true, readOnly: false, parse: nothing }
489
973
  }
490
974
 
491
975
  /**
@@ -502,7 +986,7 @@ export function prSetDraft(number: number, draft: boolean): GhVerb<void> {
502
986
  *
503
987
  * @experimental
504
988
  */
505
- export function issueCommentEdit(commentId: string, body: string): GhVerb<void> {
989
+ export function issueCommentEdit(commentId: string, body: string, target?: GhTarget): GhVerb<void> {
506
990
  if (!/^[0-9]+$/.test(commentId)) {
507
991
  throw new RangeError(
508
992
  `issueCommentEdit: commentId must be a numeric REST id, got ${JSON.stringify(commentId)}`,
@@ -511,13 +995,15 @@ export function issueCommentEdit(commentId: string, body: string): GhVerb<void>
511
995
  return {
512
996
  argv: [
513
997
  'api',
998
+ ...hostArgs(target),
514
999
  '-X',
515
1000
  'PATCH',
516
- `repos/{owner}/{repo}/issues/comments/${commentId}`,
1001
+ fillRepo(`repos/{owner}/{repo}/issues/comments/${commentId}`, target),
517
1002
  '-f',
518
1003
  `body=${body}`,
519
1004
  ],
520
1005
  checked: true,
1006
+ readOnly: false,
521
1007
  parse: nothing,
522
1008
  }
523
1009
  }
@@ -535,16 +1021,18 @@ export type PrMergeMethod = 'squash' | 'merge' | 'rebase'
535
1021
  export function prMerge(
536
1022
  number: number,
537
1023
  opts: { method: PrMergeMethod; headSha: string },
1024
+ target?: GhTarget,
538
1025
  ): GhVerb<void> {
539
1026
  const argv = [
540
1027
  'pr',
541
1028
  'merge',
542
1029
  String(number),
1030
+ ...repoArgs(target),
543
1031
  `--${opts.method}`,
544
1032
  '--match-head-commit',
545
1033
  opts.headSha,
546
1034
  ]
547
- return { argv, checked: true, parse: nothing }
1035
+ return { argv, checked: true, readOnly: false, parse: nothing }
548
1036
  }
549
1037
 
550
1038
  /** One review thread: whether it is resolved and who spoke last. */
@@ -565,21 +1053,27 @@ const REVIEW_THREADS_QUERY =
565
1053
  * PR is clear to merge must read "could not tell" differently from "no
566
1054
  * threads".
567
1055
  */
568
- export function prReviewThreads(number: number): GhVerb<ReviewThread[] | null> {
1056
+ export function prReviewThreads(number: number, target?: GhTarget): GhVerb<ReviewThread[] | null> {
1057
+ checkTarget(target)
1058
+ // `-F` lets gh fill the placeholders from the clone; real values go as plain strings.
1059
+ const [owner, name] = target?.repo?.split('/') ?? []
1060
+ const repo =
1061
+ owner === undefined || name === undefined
1062
+ ? ['-F', 'owner={owner}', '-F', 'name={repo}']
1063
+ : ['-f', `owner=${owner}`, '-f', `name=${name}`]
569
1064
  return {
570
1065
  argv: [
571
1066
  'api',
572
1067
  'graphql',
573
- '-F',
574
- 'owner={owner}',
575
- '-F',
576
- 'name={repo}',
1068
+ ...hostArgs(target),
1069
+ ...repo,
577
1070
  '-F',
578
1071
  `number=${number}`,
579
1072
  '-f',
580
1073
  `query=${REVIEW_THREADS_QUERY}`,
581
1074
  ],
582
1075
  checked: false,
1076
+ readOnly: true,
583
1077
  parse(r) {
584
1078
  if (r.exitCode !== 0) return null
585
1079
  let data: unknown
@@ -630,29 +1124,31 @@ export interface CreateLabelOptions {
630
1124
  * `--force` makes it safe to repeat: when the label already exists, its color
631
1125
  * and description are set to these instead of failing.
632
1126
  */
633
- export function createLabel(opts: CreateLabelOptions): GhVerb<void> {
1127
+ export function createLabel(opts: CreateLabelOptions, target?: GhTarget): GhVerb<void> {
634
1128
  const argv = [
635
1129
  'label',
636
1130
  'create',
637
1131
  opts.name,
1132
+ ...repoArgs(target),
638
1133
  '--color',
639
1134
  opts.color,
640
1135
  '--description',
641
1136
  opts.description,
642
1137
  '--force',
643
1138
  ]
644
- return { argv, checked: true, parse: nothing }
1139
+ return { argv, checked: true, readOnly: false, parse: nothing }
645
1140
  }
646
1141
 
647
1142
  /** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
648
1143
  export function editLabel(
649
1144
  name: string,
650
1145
  opts: { color?: string | undefined; description?: string | undefined },
1146
+ target?: GhTarget,
651
1147
  ): GhVerb<void> {
652
- const argv = ['label', 'edit', name]
1148
+ const argv = ['label', 'edit', name, ...repoArgs(target)]
653
1149
  if (opts.color !== undefined) argv.push('--color', opts.color)
654
1150
  if (opts.description !== undefined) argv.push('--description', opts.description)
655
- return { argv, checked: true, parse: nothing }
1151
+ return { argv, checked: true, readOnly: false, parse: nothing }
656
1152
  }
657
1153
 
658
1154
  /**
@@ -676,23 +1172,30 @@ export function setIssueLabel(
676
1172
  number: number,
677
1173
  currentLabel: string | null,
678
1174
  newLabel: string,
1175
+ target?: GhTarget,
679
1176
  ): GhVerb<void> {
680
- const argv = ['issue', 'edit', String(number)]
1177
+ const argv = ['issue', 'edit', String(number), ...repoArgs(target)]
681
1178
  if (currentLabel !== null) argv.push('--remove-label', currentLabel)
682
1179
  argv.push('--add-label', newLabel)
683
- return { argv, checked: true, parse: nothing }
1180
+ return { argv, checked: true, readOnly: false, parse: nothing }
684
1181
  }
685
1182
 
686
1183
  /** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
687
- export function prDiff(number: number): GhVerb<string> {
688
- return { argv: ['pr', 'diff', String(number)], checked: true, parse: (r) => r.stdout }
1184
+ export function prDiff(number: number, target?: GhTarget): GhVerb<string> {
1185
+ return {
1186
+ argv: ['pr', 'diff', String(number), ...repoArgs(target)],
1187
+ checked: true,
1188
+ readOnly: true,
1189
+ parse: (r) => r.stdout,
1190
+ }
689
1191
  }
690
1192
 
691
1193
  /** `gh pr comment <n> --body <body>` — post a conversation comment, returning the URL gh prints ('' if none). */
692
- export function prComment(number: number, body: string): GhVerb<string> {
1194
+ export function prComment(number: number, body: string, target?: GhTarget): GhVerb<string> {
693
1195
  return {
694
- argv: ['pr', 'comment', String(number), '--body', body],
1196
+ argv: ['pr', 'comment', String(number), ...repoArgs(target), '--body', body],
695
1197
  checked: true,
1198
+ readOnly: false,
696
1199
  parse(r) {
697
1200
  const lines = r.stdout
698
1201
  .split('\n')
@@ -734,12 +1237,16 @@ export interface CreateReviewOptions {
734
1237
  * when GitHub's reply carried none). The event is always `COMMENT`: this
735
1238
  * library does not offer a way to approve or request changes. Goes through
736
1239
  * `gh api` with the payload on stdin, because `gh pr review` cannot attach
737
- * line-anchored comments; `{owner}`/`{repo}` are filled in by `gh` from the
738
- * client's working directory.
1240
+ * line-anchored comments; `{owner}`/`{repo}` come from the target's repo, or
1241
+ * are filled in by `gh` from the client's working directory.
739
1242
  *
740
1243
  * @experimental
741
1244
  */
742
- export function createReview(number: number, opts: CreateReviewOptions): GhVerb<string> {
1245
+ export function createReview(
1246
+ number: number,
1247
+ opts: CreateReviewOptions,
1248
+ target?: GhTarget,
1249
+ ): GhVerb<string> {
743
1250
  const payload = {
744
1251
  commit_id: opts.commitId,
745
1252
  body: opts.body,
@@ -754,13 +1261,15 @@ export function createReview(number: number, opts: CreateReviewOptions): GhVerb<
754
1261
  return {
755
1262
  argv: [
756
1263
  'api',
1264
+ ...hostArgs(target),
757
1265
  '--method',
758
1266
  'POST',
759
- `repos/{owner}/{repo}/pulls/${number}/reviews`,
1267
+ fillRepo(`repos/{owner}/{repo}/pulls/${number}/reviews`, target),
760
1268
  '--input',
761
1269
  '-',
762
1270
  ],
763
1271
  checked: true,
1272
+ readOnly: false,
764
1273
  input: JSON.stringify(payload),
765
1274
  parse(r) {
766
1275
  try {
@@ -776,16 +1285,32 @@ export function createReview(number: number, opts: CreateReviewOptions): GhVerb<
776
1285
  // ── helpers over the parsed rows ────────────────────────────────────────────
777
1286
 
778
1287
  /**
779
- * The row whose `headRefName` exactly equals `branch`, or null.
1288
+ * The first row whose `headRefName` exactly equals `branch`, or null.
780
1289
  *
781
- * gh's `head:` search is a prefix match, so `task/foo` also returns
782
- * `task/foo-2`. Every caller that means one branch needs this filter.
1290
+ * `prList({ head })` already filters exactly. This is for rows from anywhere
1291
+ * else, such as a `search` query with gh's `head:` qualifier, which matches
1292
+ * `task/foo-2` for `task/foo`.
783
1293
  */
784
1294
  export function pickPrForBranch(rows: PullRequest[] | null, branch: string): PullRequest | null {
785
1295
  if (rows === null) return null
786
1296
  return rows.find((r) => r.headRefName === branch) ?? null
787
1297
  }
788
1298
 
1299
+ /**
1300
+ * The PR a branch names, from `prList({ head, state: 'all' })` rows: the open
1301
+ * one, or else the first row (gh lists newest first). Null when there are no
1302
+ * rows or the call failed.
1303
+ */
1304
+ export function branchPr(result: ListResult<PullRequest> | null): PullRequest | null {
1305
+ if (result === null) return null
1306
+ return result.rows.find((r) => r.state === 'OPEN') ?? result.rows[0] ?? null
1307
+ }
1308
+
1309
+ /** The field set {@link branchPr} needs, ahead of `fields`. */
1310
+ export function branchPrFields(fields: readonly string[]): string[] {
1311
+ return withFields(fields, ['number', 'state', 'headRefName'])
1312
+ }
1313
+
789
1314
  /**
790
1315
  * True when GitHub has not finished computing this PR's mergeability.
791
1316
  *