@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/CHANGELOG.md +24 -0
- package/README.md +168 -36
- package/async.ts +131 -37
- package/dist/async.d.ts +43 -15
- package/dist/async.d.ts.map +1 -1
- package/dist/async.js +92 -30
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/parse.d.ts +5 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +48 -0
- package/dist/repo.d.ts +42 -10
- package/dist/repo.d.ts.map +1 -1
- package/dist/repo.js +76 -12
- package/dist/sync.d.ts +64 -12
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +107 -29
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verbs.d.ts +194 -41
- package/dist/verbs.d.ts.map +1 -1
- package/dist/verbs.js +482 -83
- package/index.ts +26 -3
- package/package.json +3 -3
- package/parse.ts +50 -0
- package/repo.ts +87 -13
- package/sync.ts +146 -34
- package/types.ts +40 -0
- package/verbs.ts +606 -81
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
|
|
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
|
|
197
|
-
*
|
|
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(
|
|
210
|
-
|
|
211
|
-
|
|
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('--
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
/** `
|
|
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
|
-
|
|
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 =
|
|
370
|
+
const asked = withFields(fields, ['number'])
|
|
371
|
+
const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null
|
|
229
372
|
return {
|
|
230
|
-
argv: [
|
|
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
|
|
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.
|
|
241
|
-
*
|
|
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(
|
|
244
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
303
|
-
|
|
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`
|
|
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(
|
|
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(
|
|
706
|
+
String(limit + 1),
|
|
394
707
|
],
|
|
395
708
|
checked: false,
|
|
396
|
-
|
|
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(
|
|
410
|
-
|
|
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(
|
|
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(
|
|
479
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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 {
|
|
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}`
|
|
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(
|
|
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
|
-
*
|
|
782
|
-
*
|
|
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
|
*
|