@osdlc/typed-gh 0.6.0 → 0.9.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 DELETED
@@ -1,1324 +0,0 @@
1
- /**
2
- * The verb table — every `gh` invocation this library knows, as data.
3
- *
4
- * Same shape and same reasoning as `@osdlc/typed-git`'s: a {@link GhVerb}
5
- * pairs the argv to run with the function that reads the result, and nothing
6
- * here spawns anything. That is what lets a synchronous client and an
7
- * asynchronous fan-out drive the same definitions without either duplicating
8
- * the other's parsing.
9
- *
10
- * `checked` marks a verb whose failure is thrown with stderr: every mutation,
11
- * and `prDiff`, which is a read. Other queries are unchecked and fold failure
12
- * into an absence value.
13
- */
14
-
15
- import { stderrOrError, type CommandResult } from '@osdlc/command-seam'
16
-
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'
20
-
21
- /** One `gh` invocation: what to run, and how to read what came back. */
22
- export interface GhVerb<T> {
23
- argv: string[]
24
- /** True for every mutation and for `prDiff` — throw on a non-zero exit instead of parsing. */
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
32
- /** Written to the child's stdin, for a verb whose payload is too large or structured for argv. */
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
39
- parse(result: CommandResult): T
40
- }
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
-
106
- /**
107
- * The cheapest useful set: enough to identify a PR and nothing more.
108
- *
109
- * Reach for this whenever the question is "which PR is this branch?".
110
- * `mergeable` and `mergeStateStatus` are absent on purpose — GitHub computes
111
- * them per PR on demand, so asking for them across a fan-out of branches
112
- * turns a cheap listing into a rate-limit incident.
113
- */
114
- export const PR_REF_FIELDS = ['number', 'state', 'headRefName', 'url'] as const
115
-
116
- /**
117
- * The light field set for triage: everything needed to decide what to do with
118
- * a PR without paying for its body, comments, reviews, or check rollup.
119
- *
120
- * `mergeable` and `mergeStateStatus` are the expensive pair — GitHub computes
121
- * them lazily and answers `UNKNOWN` until it has, so a cold query over many
122
- * PRs commonly needs a retry. `prViewSettled` on the clients does it.
123
- */
124
- export const PR_LIST_FIELDS = [
125
- 'number',
126
- 'state',
127
- 'title',
128
- 'url',
129
- 'isDraft',
130
- 'baseRefName',
131
- 'headRefName',
132
- 'headRefOid',
133
- 'isCrossRepository',
134
- 'mergeable',
135
- 'mergeStateStatus',
136
- 'reviewDecision',
137
- 'author',
138
- 'labels',
139
- 'mergedAt',
140
- 'createdAt',
141
- 'updatedAt',
142
- ] as const
143
-
144
- /** {@link PR_LIST_FIELDS} plus the heavy ones a single PR can afford. */
145
- export const PR_VIEW_FIELDS = [
146
- ...PR_LIST_FIELDS,
147
- 'body',
148
- 'comments',
149
- 'reviews',
150
- 'statusCheckRollup',
151
- ] as const
152
-
153
- /** Parse a `--json` payload that is a single object. */
154
- function oneOrNull(r: CommandResult): PullRequest | null {
155
- if (r.exitCode !== 0) return null
156
- let data: unknown
157
- try {
158
- data = JSON.parse(r.stdout)
159
- } catch {
160
- return null
161
- }
162
- return parsePullRequest(data)
163
- }
164
-
165
- /**
166
- * Parse a `--json` payload that is an array. Null — not `[]` — when the call
167
- * failed: a caller must be able to tell "no PRs" from "couldn't ask".
168
- */
169
- function manyOrNull(r: CommandResult): PullRequest[] | null {
170
- if (r.exitCode !== 0) return null
171
- let data: unknown
172
- try {
173
- data = JSON.parse(r.stdout || '[]')
174
- } catch {
175
- return null
176
- }
177
- if (!Array.isArray(data)) return null
178
- const rows: PullRequest[] = []
179
- for (const item of data) {
180
- const pr = parsePullRequest(item)
181
- if (pr !== null) rows.push(pr)
182
- }
183
- return rows
184
- }
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
-
209
- /** A mutation's result carries no value. */
210
- function nothing(): void {}
211
-
212
- /**
213
- * Parse a `gh label list --json name,color,description` payload. Null — not
214
- * `[]` — when the call failed, same "no rows" vs. "couldn't ask" split as
215
- * {@link manyOrNull}: a repo with genuinely zero labels and a `gh` call that
216
- * never ran must not collapse onto the same value.
217
- */
218
- function manyLabelsOrNull(r: CommandResult): Label[] | null {
219
- if (r.exitCode !== 0) return null
220
- let data: unknown
221
- try {
222
- data = JSON.parse(r.stdout || '[]')
223
- } catch {
224
- return null
225
- }
226
- if (!Array.isArray(data)) return null
227
- return parseLabels(data)
228
- }
229
-
230
- /** The `gh issue list`/`view --json` field set the `issues` surface needs. */
231
- const ISSUE_FIELDS = 'number,title,body,labels,state'
232
-
233
- /** Parse one `gh issue list`/`view --json` row into an {@link IssueRow}. */
234
- function parseIssueRow(data: unknown): IssueRow | null {
235
- if (data === null || typeof data !== 'object') return null
236
- const rec = data as Record<string, unknown>
237
- const number = rec['number']
238
- if (typeof number !== 'number') return null
239
- return {
240
- number,
241
- title: typeof rec['title'] === 'string' ? rec['title'] : '',
242
- body: typeof rec['body'] === 'string' ? rec['body'] : '',
243
- labels: parseLabels(rec['labels']),
244
- state: typeof rec['state'] === 'string' ? rec['state'] : '',
245
- }
246
- }
247
-
248
- /** Parse a `gh issue view --json` payload that is a single object. */
249
- function oneIssueOrNull(r: CommandResult): IssueRow | null {
250
- if (r.exitCode !== 0) return null
251
- let data: unknown
252
- try {
253
- data = JSON.parse(r.stdout)
254
- } catch {
255
- return null
256
- }
257
- return parseIssueRow(data)
258
- }
259
-
260
- /**
261
- * Parse a `gh issue list --json` payload that is an array. Null — not `[]` —
262
- * on failure, the same "no rows" vs. "couldn't ask" split {@link manyOrNull}
263
- * makes for PRs.
264
- */
265
- function manyIssuesOrNull(r: CommandResult): IssueRow[] | null {
266
- if (r.exitCode !== 0) return null
267
- let data: unknown
268
- try {
269
- data = JSON.parse(r.stdout || '[]')
270
- } catch {
271
- return null
272
- }
273
- if (!Array.isArray(data)) return null
274
- const rows: IssueRow[] = []
275
- for (const item of data) {
276
- const row = parseIssueRow(item)
277
- if (row !== null) rows.push(row)
278
- }
279
- return rows
280
- }
281
-
282
- // ── queries ─────────────────────────────────────────────────────────────────
283
-
284
- /** What to narrow a listing to. Every filter is optional; none means "all open". */
285
- export interface PrListOptions {
286
- /**
287
- * `owner/name` to list from, instead of the repository at the client's cwd.
288
- * The only way to reach a repository this machine has no checkout of.
289
- */
290
- repo?: string | undefined
291
- state?: 'open' | 'closed' | 'merged' | 'all' | undefined
292
- /** Exact base branch filter (gh-side). */
293
- base?: string | undefined
294
- /**
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.
299
- */
300
- head?: string | undefined
301
- /** Raw `--search` query, for anything the flags do not cover. */
302
- search?: string | undefined
303
- author?: string | undefined
304
- limit?: number | undefined
305
- /** Field subset; defaults to {@link PR_LIST_FIELDS}. */
306
- fields?: readonly string[] | undefined
307
- }
308
-
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)]
317
- if (opts.base !== undefined) argv.push('--base', opts.base)
318
- if (opts.head !== undefined) argv.push('--head', opts.head)
319
- if (opts.search !== undefined) argv.push('--search', opts.search)
320
- if (opts.author !== undefined) argv.push('--author', opts.author)
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
- }
337
- }
338
-
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
- */
362
- export function prView(
363
- ref: number | string,
364
- fields: readonly string[] = PR_VIEW_FIELDS,
365
- target?: GhTarget,
366
- ): GhVerb<PullRequest | null> {
367
- if (typeof ref === 'string') notAFlag('prView ref', ref)
368
- // `parsePullRequest` yields null without a `number`, so a field subset that
369
- // omits it would read as "no such PR". Always ask for it, and each field once.
370
- const asked = withFields(fields, ['number'])
371
- const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null
372
- return {
373
- argv: [
374
- 'pr',
375
- 'view',
376
- String(ref),
377
- ...(isUrl ? [] : repoArgs(target)),
378
- '--json',
379
- asked.join(','),
380
- ],
381
- checked: false,
382
- readOnly: true,
383
- parse: oneOrNull,
384
- }
385
- }
386
-
387
- /**
388
- * Open PRs whose head is exactly `branch`.
389
- *
390
- * The common shape behind "which PR is this branch?", bundling the cheap
391
- * field set with a small limit. More than one row comes back only when forks
392
- * share the branch name.
393
- */
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)
400
- }
401
-
402
- /**
403
- * `gh pr view <n> --json body -q .body` — just the PR body. Null when gh
404
- * failed. gh's `-q` output ends with a newline that is not part of the body,
405
- * so every trailing `\r` and `\n` is trimmed (including any that were part of
406
- * the body, such as the `\r\n` of a body saved with CRLF line endings).
407
- */
408
- export function prBody(number: number, target?: GhTarget): GhVerb<string | null> {
409
- return {
410
- argv: ['pr', 'view', String(number), ...repoArgs(target), '--json', 'body', '-q', '.body'],
411
- checked: false,
412
- readOnly: true,
413
- parse: (r) => (r.exitCode === 0 ? r.stdout.replace(/[\r\n]+$/, '') : null),
414
- }
415
- }
416
-
417
- /** One `gh api` answer: HTTP status, entity tag, and the decoded JSON body. */
418
- export interface ApiResponse {
419
- /** True iff gh exited 0 and the status is below 400 (2xx, or a 304 on a conditional GET). */
420
- ok: boolean
421
- /** HTTP status; read off the header block under `-i`. Without `-i` it is synthesized (200 on exit 0, 0 on failure), not observed. */
422
- status: number
423
- /** The response `ETag`, when headers were requested and one came back. */
424
- etag: string | null
425
- /** The decoded JSON body; null when empty (e.g. a 304), unparseable, or the call failed. */
426
- body: unknown
427
- /** True when a non-empty body arrived but was not valid JSON (`body` is then null). */
428
- malformed: boolean
429
- /** gh's stderr, trimmed. */
430
- stderr: string
431
- /** The runner's own message when gh never ran or was stopped (a failed spawn, a timeout). */
432
- error?: string | undefined
433
- }
434
-
435
- /**
436
- * Options for {@link api}. Both are optional; with neither, the call is a plain
437
- * `gh api <endpoint>` and the response carries no status or ETag.
438
- */
439
- export interface ApiOptions {
440
- /** Send `If-None-Match: <etag>` (implies `includeHeaders`). */
441
- etag?: string | null | undefined
442
- /** Pass `-i` and read status/ETag off the header block (the last one, if several). */
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
451
- }
452
-
453
- /**
454
- * `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
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.
457
- * Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
458
- * from an outage.
459
- */
460
- export function api(
461
- endpoint: string,
462
- opts: ApiOptions = {},
463
- target?: GhTarget,
464
- ): GhVerb<ApiResponse> {
465
- notAFlag('api endpoint', endpoint)
466
- const conditional = opts.etag !== undefined && opts.etag !== null && opts.etag !== ''
467
- const withHeaders = conditional || opts.includeHeaders === true
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)]
473
- if (withHeaders) argv.push('-i')
474
- if (conditional) argv.push('-H', `If-None-Match: ${opts.etag}`)
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
611
- }
612
-
613
- /** One header block off the front of `text`: the block, and what follows its blank line. */
614
- function takeHeaderBlock(text: string): { block: string; rest: string } {
615
- const crlf = text.indexOf('\r\n\r\n')
616
- const lf = text.indexOf('\n\n')
617
- const useCrlf = crlf >= 0 && (lf < 0 || crlf < lf)
618
- const sepIdx = useCrlf ? crlf : lf
619
- if (sepIdx < 0) return { block: text, rest: '' }
620
- return { block: text.slice(0, sepIdx), rest: text.slice(sepIdx + (useCrlf ? 4 : 2)) }
621
- }
622
-
623
- const STATUS_LINE = /^HTTP\/[\d.]+\s+\d{3}/i
624
-
625
- /**
626
- * Split a `gh api -i` payload (status line + headers, blank line, body) and decode it.
627
- * A real `gh` prints one block. As a guard, if several are stacked, the last one is read:
628
- * its status and ETag, and the body that follows it.
629
- */
630
- function parseApi(r: CommandResult, withHeaders: boolean): ApiResponse {
631
- let status = r.exitCode === 0 ? 200 : 0
632
- let etag: string | null = null
633
- let bodyText = r.stdout
634
- if (withHeaders) {
635
- let { block: headerBlock, rest } = takeHeaderBlock(r.stdout)
636
- while (STATUS_LINE.test(rest)) ({ block: headerBlock, rest } = takeHeaderBlock(rest))
637
- bodyText = rest.replace(/^[\r\n]+/, '')
638
- const statusMatch = headerBlock.match(/HTTP\/[\d.]+\s+(\d{3})/i)
639
- if (statusMatch) status = Number(statusMatch[1])
640
- const etagMatch = headerBlock.match(/^etag:\s*(.+)$/im)
641
- if (etagMatch) etag = etagMatch[1]!.trim()
642
- }
643
- let body: unknown = null
644
- let malformed = false
645
- if (r.exitCode === 0 && bodyText.trim() !== '') {
646
- try {
647
- body = JSON.parse(bodyText)
648
- } catch {
649
- malformed = true
650
- }
651
- }
652
- const ok = r.exitCode === 0 && status >= 200 && status < 400
653
- return {
654
- ok,
655
- status,
656
- etag,
657
- body,
658
- malformed,
659
- stderr: r.stderr.trim(),
660
- ...(r.error === undefined ? {} : { error: r.error }),
661
- }
662
- }
663
-
664
- /** `gh --version` — the install probe. True when `gh` runs. */
665
- export function installed(): GhVerb<boolean> {
666
- return { argv: ['--version'], checked: false, readOnly: true, parse: (r) => r.exitCode === 0 }
667
- }
668
-
669
- /**
670
- * What `gh auth status` said: whether it passed, and its output (stderr, else the runner's
671
- * `error`, else stdout), trimmed.
672
- */
673
- export interface AuthStatus {
674
- ok: boolean
675
- detail: string
676
- }
677
-
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> {
680
- return {
681
- argv: ['auth', 'status', ...hostArgs(target)],
682
- checked: false,
683
- readOnly: true,
684
- parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
685
- }
686
- }
687
-
688
- /**
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).
691
- */
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)
698
- return {
699
- argv: [
700
- 'label',
701
- 'list',
702
- ...repoArgs(target),
703
- '--json',
704
- 'name,color,description',
705
- '--limit',
706
- String(limit + 1),
707
- ],
708
- checked: false,
709
- readOnly: true,
710
- parse: (r) => limited(manyLabelsOrNull(r), limit),
711
- }
712
- }
713
-
714
- /** What to narrow an issue listing to. Every filter is optional. */
715
- export interface IssueListOptions {
716
- /** Exact label filter, applied by `gh`. */
717
- label?: string | undefined
718
- /** The most issues to return. Defaults to 200. */
719
- limit?: number | undefined
720
- }
721
-
722
- /** `gh issue list --json …` — open issues by default, optionally by label. */
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)]
730
- if (opts.label !== undefined) argv.push('--label', opts.label)
731
- argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1))
732
- return { argv, checked: false, readOnly: true, parse: (r) => limited(manyIssuesOrNull(r), limit) }
733
- }
734
-
735
- /** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
736
- export function issueView(number: number, target?: GhTarget): GhVerb<IssueRow | null> {
737
- return {
738
- argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
739
- checked: false,
740
- readOnly: true,
741
- parse: oneIssueOrNull,
742
- }
743
- }
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
-
903
- // ── mutations (checked — the caller throws CommandFailed on non-zero) ────────
904
-
905
- /**
906
- * Options for {@link createPr}. `head` is the branch to merge from and `base`
907
- * the branch to merge into. `draft` opens the PR as a draft.
908
- */
909
- export interface CreatePrOptions {
910
- head: string
911
- base: string
912
- title: string
913
- body: string
914
- draft?: boolean | undefined
915
- }
916
-
917
- /**
918
- * `gh pr create …`, returning the new PR's URL: the last line gh printed that
919
- * starts with `http://` or `https://`. `''` when it printed none, as
920
- * {@link prComment} does. A line after the URL, such as a tip, is skipped.
921
- */
922
- export function createPr(opts: CreatePrOptions, target?: GhTarget): GhVerb<string> {
923
- const argv = ['pr', 'create', ...repoArgs(target), '--head', opts.head, '--base', opts.base]
924
- if (opts.draft) argv.push('--draft')
925
- argv.push('--title', opts.title, '--body', opts.body)
926
- return {
927
- argv,
928
- checked: true,
929
- readOnly: false,
930
- parse(r) {
931
- const urls = r.stdout
932
- .split('\n')
933
- .map((l) => l.trim())
934
- .filter((l) => /^https?:\/\//.test(l))
935
- return urls[urls.length - 1] ?? ''
936
- },
937
- }
938
- }
939
-
940
- /** `gh pr edit <n>` — retitle or rewrite the body. */
941
- export function editPr(
942
- number: number,
943
- opts: { title?: string | undefined; body?: string | undefined },
944
- target?: GhTarget,
945
- ): GhVerb<void> {
946
- const argv = ['pr', 'edit', String(number), ...repoArgs(target)]
947
- if (opts.title !== undefined) argv.push('--title', opts.title)
948
- if (opts.body !== undefined) argv.push('--body', opts.body)
949
- return { argv, checked: true, readOnly: false, parse: nothing }
950
- }
951
-
952
- /** `gh pr edit <n> --body` — rewrite just the body. */
953
- export function editPrBody(number: number, body: string, target?: GhTarget): GhVerb<void> {
954
- return editPr(number, { body }, target)
955
- }
956
-
957
- /** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
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)]
964
- if (style === 'rebase') argv.push('--rebase')
965
- return { argv, checked: true, readOnly: false, parse: nothing }
966
- }
967
-
968
- /** `gh pr ready <n> [--undo]` — mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
969
- export function prSetDraft(number: number, draft: boolean, target?: GhTarget): GhVerb<void> {
970
- const argv = ['pr', 'ready', String(number), ...repoArgs(target)]
971
- if (draft) argv.push('--undo')
972
- return { argv, checked: true, readOnly: false, parse: nothing }
973
- }
974
-
975
- /**
976
- * `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…` — rewrite one
977
- * conversation comment.
978
- *
979
- * `commentId` is the comment's numeric REST id, as text: the `id` that
980
- * `gh api repos/{owner}/{repo}/issues/<n>/comments` returns. It is not the
981
- * GraphQL node id (such as `IC_kw…`) that `gh pr view --json comments` gives
982
- * in `Comment.id`; the REST path answers 404 for a node id.
983
- *
984
- * Throws `RangeError` when `commentId` is not all digits, so a node id fails
985
- * at once and no text can change which endpoint the PATCH reaches.
986
- *
987
- * @experimental
988
- */
989
- export function issueCommentEdit(commentId: string, body: string, target?: GhTarget): GhVerb<void> {
990
- if (!/^[0-9]+$/.test(commentId)) {
991
- throw new RangeError(
992
- `issueCommentEdit: commentId must be a numeric REST id, got ${JSON.stringify(commentId)}`,
993
- )
994
- }
995
- return {
996
- argv: [
997
- 'api',
998
- ...hostArgs(target),
999
- '-X',
1000
- 'PATCH',
1001
- fillRepo(`repos/{owner}/{repo}/issues/comments/${commentId}`, target),
1002
- '-f',
1003
- `body=${body}`,
1004
- ],
1005
- checked: true,
1006
- readOnly: false,
1007
- parse: nothing,
1008
- }
1009
- }
1010
-
1011
- /** How {@link prMerge} lands the commits: `squash` into one, `merge` with a merge commit, or `rebase`. */
1012
- export type PrMergeMethod = 'squash' | 'merge' | 'rebase'
1013
-
1014
- /**
1015
- * `gh pr merge <n> --<method> --match-head-commit <sha>`. The head sha is
1016
- * required, not optional: GitHub refuses the merge when the PR head moved
1017
- * since the caller judged it, so a push that lands between a gate and the
1018
- * merge can never ride in unreviewed. The branch is left alone (no
1019
- * `--delete-branch`); deleting it is the caller's job.
1020
- */
1021
- export function prMerge(
1022
- number: number,
1023
- opts: { method: PrMergeMethod; headSha: string },
1024
- target?: GhTarget,
1025
- ): GhVerb<void> {
1026
- const argv = [
1027
- 'pr',
1028
- 'merge',
1029
- String(number),
1030
- ...repoArgs(target),
1031
- `--${opts.method}`,
1032
- '--match-head-commit',
1033
- opts.headSha,
1034
- ]
1035
- return { argv, checked: true, readOnly: false, parse: nothing }
1036
- }
1037
-
1038
- /** One review thread: whether it is resolved and who spoke last. */
1039
- export interface ReviewThread {
1040
- isResolved: boolean
1041
- isOutdated: boolean
1042
- /** Login of the thread's newest comment; null when the account is gone. */
1043
- lastCommentAuthor: string | null
1044
- }
1045
-
1046
- const REVIEW_THREADS_QUERY =
1047
- '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}}}}}}}}'
1048
-
1049
- /**
1050
- * A PR's review threads over GraphQL (`gh pr view --json` has no thread
1051
- * field). Null — never `[]` — when the call failed, the payload was not the
1052
- * expected shape, or more than 100 threads exist: a caller deciding whether a
1053
- * PR is clear to merge must read "could not tell" differently from "no
1054
- * threads".
1055
- */
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}`]
1064
- return {
1065
- argv: [
1066
- 'api',
1067
- 'graphql',
1068
- ...hostArgs(target),
1069
- ...repo,
1070
- '-F',
1071
- `number=${number}`,
1072
- '-f',
1073
- `query=${REVIEW_THREADS_QUERY}`,
1074
- ],
1075
- checked: false,
1076
- readOnly: true,
1077
- parse(r) {
1078
- if (r.exitCode !== 0) return null
1079
- let data: unknown
1080
- try {
1081
- data = JSON.parse(r.stdout)
1082
- } catch {
1083
- return null
1084
- }
1085
- type Node = {
1086
- isResolved?: unknown
1087
- isOutdated?: unknown
1088
- comments?: { nodes?: { author?: { login?: unknown } | null }[] }
1089
- }
1090
- const threads = (
1091
- data as {
1092
- data?: {
1093
- repository?: {
1094
- pullRequest?: {
1095
- reviewThreads?: { pageInfo?: { hasNextPage?: unknown }; nodes?: Node[] }
1096
- } | null
1097
- } | null
1098
- }
1099
- }
1100
- )?.data?.repository?.pullRequest?.reviewThreads
1101
- if (threads === undefined || !Array.isArray(threads.nodes)) return null
1102
- if (threads.pageInfo?.hasNextPage === true) return null
1103
- return threads.nodes.map((n) => {
1104
- const login = n.comments?.nodes?.[0]?.author?.login
1105
- return {
1106
- isResolved: n.isResolved === true,
1107
- isOutdated: n.isOutdated === true,
1108
- lastCommentAuthor: typeof login === 'string' ? login : null,
1109
- }
1110
- })
1111
- },
1112
- }
1113
- }
1114
-
1115
- /** A label to create. `color` is hex digits such as `d73a4a`. */
1116
- export interface CreateLabelOptions {
1117
- name: string
1118
- color: string
1119
- description: string
1120
- }
1121
-
1122
- /**
1123
- * `gh label create <name> --color <hex> --description <text> --force`.
1124
- * `--force` makes it safe to repeat: when the label already exists, its color
1125
- * and description are set to these instead of failing.
1126
- */
1127
- export function createLabel(opts: CreateLabelOptions, target?: GhTarget): GhVerb<void> {
1128
- const argv = [
1129
- 'label',
1130
- 'create',
1131
- opts.name,
1132
- ...repoArgs(target),
1133
- '--color',
1134
- opts.color,
1135
- '--description',
1136
- opts.description,
1137
- '--force',
1138
- ]
1139
- return { argv, checked: true, readOnly: false, parse: nothing }
1140
- }
1141
-
1142
- /** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
1143
- export function editLabel(
1144
- name: string,
1145
- opts: { color?: string | undefined; description?: string | undefined },
1146
- target?: GhTarget,
1147
- ): GhVerb<void> {
1148
- const argv = ['label', 'edit', name, ...repoArgs(target)]
1149
- if (opts.color !== undefined) argv.push('--color', opts.color)
1150
- if (opts.description !== undefined) argv.push('--description', opts.description)
1151
- return { argv, checked: true, readOnly: false, parse: nothing }
1152
- }
1153
-
1154
- /**
1155
- * `gh issue edit <n> [--remove-label <current>] --add-label <new>` — swap
1156
- * one label for another on an issue in ONE call, so the issue is never
1157
- * briefly unlabeled or briefly holding both labels BETWEEN the remove and the
1158
- * add — that atomicity is real and is this one call's own guarantee. It does
1159
- * NOT cover the window before the call: `currentLabel` is whatever the caller
1160
- * already read (perhaps minutes earlier, from a `gh issue list` snapshot),
1161
- * never re-read here. If the issue's live label changed since then (a human,
1162
- * or another process, relabeled it in between), `--remove-label
1163
- * <currentLabel>` targets a label the issue no longer carries — `gh` no-ops
1164
- * that removal silently rather than erroring — so the call still adds
1165
- * `newLabel`, and the issue ends up holding BOTH the label it actually has
1166
- * now and `newLabel`. A caller that cares about that race re-reads the live
1167
- * label immediately before calling this. `currentLabel` is `null` when the
1168
- * issue carries no prior label to remove — the `--remove-label` flag is then
1169
- * omitted rather than passed empty.
1170
- */
1171
- export function setIssueLabel(
1172
- number: number,
1173
- currentLabel: string | null,
1174
- newLabel: string,
1175
- target?: GhTarget,
1176
- ): GhVerb<void> {
1177
- const argv = ['issue', 'edit', String(number), ...repoArgs(target)]
1178
- if (currentLabel !== null) argv.push('--remove-label', currentLabel)
1179
- argv.push('--add-label', newLabel)
1180
- return { argv, checked: true, readOnly: false, parse: nothing }
1181
- }
1182
-
1183
- /** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
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
- }
1191
- }
1192
-
1193
- /** `gh pr comment <n> --body <body>` — post a conversation comment, returning the URL gh prints ('' if none). */
1194
- export function prComment(number: number, body: string, target?: GhTarget): GhVerb<string> {
1195
- return {
1196
- argv: ['pr', 'comment', String(number), ...repoArgs(target), '--body', body],
1197
- checked: true,
1198
- readOnly: false,
1199
- parse(r) {
1200
- const lines = r.stdout
1201
- .split('\n')
1202
- .map((l) => l.trim())
1203
- .filter((l) => l !== '')
1204
- const last = lines[lines.length - 1] ?? ''
1205
- return /^https?:\/\//.test(last) ? last : ''
1206
- },
1207
- }
1208
- }
1209
-
1210
- /**
1211
- * One inline comment of a review: a line on the right (new) side of the diff.
1212
- *
1213
- * @experimental
1214
- */
1215
- export interface ReviewComment {
1216
- path: string
1217
- /** Line number in the new version of the file. */
1218
- line: number
1219
- body: string
1220
- }
1221
-
1222
- /**
1223
- * What {@link createReview} posts: the commit, the summary and the inline comments.
1224
- *
1225
- * @experimental
1226
- */
1227
- export interface CreateReviewOptions {
1228
- /** The commit the review is anchored to; line numbers refer to the diff at this sha. */
1229
- commitId: string
1230
- /** The review summary. GitHub rejects a review whose body and comments are both empty. */
1231
- body: string
1232
- comments: readonly ReviewComment[]
1233
- }
1234
-
1235
- /**
1236
- * Create a PR review with inline comments, returning the review's URL (empty
1237
- * when GitHub's reply carried none). The event is always `COMMENT`: this
1238
- * library does not offer a way to approve or request changes. Goes through
1239
- * `gh api` with the payload on stdin, because `gh pr review` cannot attach
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.
1242
- *
1243
- * @experimental
1244
- */
1245
- export function createReview(
1246
- number: number,
1247
- opts: CreateReviewOptions,
1248
- target?: GhTarget,
1249
- ): GhVerb<string> {
1250
- const payload = {
1251
- commit_id: opts.commitId,
1252
- body: opts.body,
1253
- event: 'COMMENT',
1254
- comments: opts.comments.map((c) => ({
1255
- path: c.path,
1256
- line: c.line,
1257
- side: 'RIGHT',
1258
- body: c.body,
1259
- })),
1260
- }
1261
- return {
1262
- argv: [
1263
- 'api',
1264
- ...hostArgs(target),
1265
- '--method',
1266
- 'POST',
1267
- fillRepo(`repos/{owner}/{repo}/pulls/${number}/reviews`, target),
1268
- '--input',
1269
- '-',
1270
- ],
1271
- checked: true,
1272
- readOnly: false,
1273
- input: JSON.stringify(payload),
1274
- parse(r) {
1275
- try {
1276
- const url = (JSON.parse(r.stdout) as { html_url?: unknown }).html_url
1277
- return typeof url === 'string' ? url : ''
1278
- } catch {
1279
- return ''
1280
- }
1281
- },
1282
- }
1283
- }
1284
-
1285
- // ── helpers over the parsed rows ────────────────────────────────────────────
1286
-
1287
- /**
1288
- * The first row whose `headRefName` exactly equals `branch`, or null.
1289
- *
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`.
1293
- */
1294
- export function pickPrForBranch(rows: PullRequest[] | null, branch: string): PullRequest | null {
1295
- if (rows === null) return null
1296
- return rows.find((r) => r.headRefName === branch) ?? null
1297
- }
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
-
1314
- /**
1315
- * True when GitHub has not finished computing this PR's mergeability.
1316
- *
1317
- * The answer is not stable on a cold query: GitHub kicks off the merge
1318
- * computation when asked and answers `UNKNOWN` until it lands. A caller that
1319
- * treats the first `UNKNOWN` as final will mis-triage a PR that is merely
1320
- * slow, so re-ask a few times before believing it.
1321
- */
1322
- export function needsMergeStateRetry(pr: PullRequest): boolean {
1323
- return pr.mergeable === 'UNKNOWN' || pr.mergeStateStatus === 'UNKNOWN'
1324
- }