@osdlc/typed-gh 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/verbs.ts DELETED
@@ -1,799 +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, parsePullRequest } from './parse.ts'
18
- import type { IssueRow, Label, PullRequest } from './types.ts'
19
-
20
- /** One `gh` invocation: what to run, and how to read what came back. */
21
- export interface GhVerb<T> {
22
- argv: string[]
23
- /** True for every mutation and for `prDiff` — throw on a non-zero exit instead of parsing. */
24
- checked: boolean
25
- /** Written to the child's stdin, for a verb whose payload is too large or structured for argv. */
26
- input?: string | undefined
27
- parse(result: CommandResult): T
28
- }
29
-
30
- /**
31
- * The cheapest useful set: enough to identify a PR and nothing more.
32
- *
33
- * Reach for this whenever the question is "which PR is this branch?".
34
- * `mergeable` and `mergeStateStatus` are absent on purpose — GitHub computes
35
- * them per PR on demand, so asking for them across a fan-out of branches
36
- * turns a cheap listing into a rate-limit incident.
37
- */
38
- export const PR_REF_FIELDS = ['number', 'state', 'headRefName', 'url'] as const
39
-
40
- /**
41
- * The light field set for triage: everything needed to decide what to do with
42
- * a PR without paying for its body, comments, reviews, or check rollup.
43
- *
44
- * `mergeable` and `mergeStateStatus` are the expensive pair — GitHub computes
45
- * them lazily and answers `UNKNOWN` until it has, so a cold query over many
46
- * PRs commonly needs a retry. `prViewSettled` on the clients does it.
47
- */
48
- export const PR_LIST_FIELDS = [
49
- 'number',
50
- 'state',
51
- 'title',
52
- 'url',
53
- 'isDraft',
54
- 'baseRefName',
55
- 'headRefName',
56
- 'headRefOid',
57
- 'isCrossRepository',
58
- 'mergeable',
59
- 'mergeStateStatus',
60
- 'reviewDecision',
61
- 'author',
62
- 'labels',
63
- 'mergedAt',
64
- 'createdAt',
65
- 'updatedAt',
66
- ] as const
67
-
68
- /** {@link PR_LIST_FIELDS} plus the heavy ones a single PR can afford. */
69
- export const PR_VIEW_FIELDS = [
70
- ...PR_LIST_FIELDS,
71
- 'body',
72
- 'comments',
73
- 'reviews',
74
- 'statusCheckRollup',
75
- ] as const
76
-
77
- /** Parse a `--json` payload that is a single object. */
78
- function oneOrNull(r: CommandResult): PullRequest | null {
79
- if (r.exitCode !== 0) return null
80
- let data: unknown
81
- try {
82
- data = JSON.parse(r.stdout)
83
- } catch {
84
- return null
85
- }
86
- return parsePullRequest(data)
87
- }
88
-
89
- /**
90
- * Parse a `--json` payload that is an array. Null — not `[]` — when the call
91
- * failed: a caller must be able to tell "no PRs" from "couldn't ask".
92
- */
93
- function manyOrNull(r: CommandResult): PullRequest[] | null {
94
- if (r.exitCode !== 0) return null
95
- let data: unknown
96
- try {
97
- data = JSON.parse(r.stdout || '[]')
98
- } catch {
99
- return null
100
- }
101
- if (!Array.isArray(data)) return null
102
- const rows: PullRequest[] = []
103
- for (const item of data) {
104
- const pr = parsePullRequest(item)
105
- if (pr !== null) rows.push(pr)
106
- }
107
- return rows
108
- }
109
-
110
- /** A mutation's result carries no value. */
111
- function nothing(): void {}
112
-
113
- /**
114
- * Parse a `gh label list --json name,color,description` payload. Null — not
115
- * `[]` — when the call failed, same "no rows" vs. "couldn't ask" split as
116
- * {@link manyOrNull}: a repo with genuinely zero labels and a `gh` call that
117
- * never ran must not collapse onto the same value.
118
- */
119
- function manyLabelsOrNull(r: CommandResult): Label[] | null {
120
- if (r.exitCode !== 0) return null
121
- let data: unknown
122
- try {
123
- data = JSON.parse(r.stdout || '[]')
124
- } catch {
125
- return null
126
- }
127
- if (!Array.isArray(data)) return null
128
- return parseLabels(data)
129
- }
130
-
131
- /** The `gh issue list`/`view --json` field set the `issues` surface needs. */
132
- const ISSUE_FIELDS = 'number,title,body,labels,state'
133
-
134
- /** Parse one `gh issue list`/`view --json` row into an {@link IssueRow}. */
135
- function parseIssueRow(data: unknown): IssueRow | null {
136
- if (data === null || typeof data !== 'object') return null
137
- const rec = data as Record<string, unknown>
138
- const number = rec['number']
139
- if (typeof number !== 'number') return null
140
- return {
141
- number,
142
- title: typeof rec['title'] === 'string' ? rec['title'] : '',
143
- body: typeof rec['body'] === 'string' ? rec['body'] : '',
144
- labels: parseLabels(rec['labels']),
145
- state: typeof rec['state'] === 'string' ? rec['state'] : '',
146
- }
147
- }
148
-
149
- /** Parse a `gh issue view --json` payload that is a single object. */
150
- function oneIssueOrNull(r: CommandResult): IssueRow | null {
151
- if (r.exitCode !== 0) return null
152
- let data: unknown
153
- try {
154
- data = JSON.parse(r.stdout)
155
- } catch {
156
- return null
157
- }
158
- return parseIssueRow(data)
159
- }
160
-
161
- /**
162
- * Parse a `gh issue list --json` payload that is an array. Null — not `[]` —
163
- * on failure, the same "no rows" vs. "couldn't ask" split {@link manyOrNull}
164
- * makes for PRs.
165
- */
166
- function manyIssuesOrNull(r: CommandResult): IssueRow[] | null {
167
- if (r.exitCode !== 0) return null
168
- let data: unknown
169
- try {
170
- data = JSON.parse(r.stdout || '[]')
171
- } catch {
172
- return null
173
- }
174
- if (!Array.isArray(data)) return null
175
- const rows: IssueRow[] = []
176
- for (const item of data) {
177
- const row = parseIssueRow(item)
178
- if (row !== null) rows.push(row)
179
- }
180
- return rows
181
- }
182
-
183
- // ── queries ─────────────────────────────────────────────────────────────────
184
-
185
- /** What to narrow a listing to. Every filter is optional; none means "all open". */
186
- export interface PrListOptions {
187
- /**
188
- * `owner/name` to list from, instead of the repository at the client's cwd.
189
- * The only way to reach a repository this machine has no checkout of.
190
- */
191
- repo?: string | undefined
192
- state?: 'open' | 'closed' | 'merged' | 'all' | undefined
193
- /** Exact base branch filter (gh-side). */
194
- base?: string | undefined
195
- /**
196
- * Head branch filter. gh matches this as a prefix, so a caller needing an
197
- * exact match filters the rows itself — {@link pickPrForBranch} does.
198
- */
199
- head?: string | undefined
200
- /** Raw `--search` query, for anything the flags do not cover. */
201
- search?: string | undefined
202
- author?: string | undefined
203
- limit?: number | undefined
204
- /** Field subset; defaults to {@link PR_LIST_FIELDS}. */
205
- fields?: readonly string[] | undefined
206
- }
207
-
208
- /** `gh pr list …` — open PRs by default. */
209
- export function prList(opts: PrListOptions = {}): GhVerb<PullRequest[] | null> {
210
- const argv = ['pr', 'list', '--state', opts.state ?? 'open']
211
- if (opts.repo !== undefined) argv.push('--repo', opts.repo)
212
- if (opts.base !== undefined) argv.push('--base', opts.base)
213
- if (opts.head !== undefined) argv.push('--search', `head:${opts.head}`)
214
- if (opts.search !== undefined) argv.push('--search', opts.search)
215
- if (opts.author !== undefined) argv.push('--author', opts.author)
216
- argv.push('--json', (opts.fields ?? PR_LIST_FIELDS).join(','))
217
- argv.push('--limit', String(opts.limit ?? 100))
218
- return { argv, checked: false, parse: manyOrNull }
219
- }
220
-
221
- /** `gh pr view <n> --json …` — one PR, with the heavy fields by default. */
222
- export function prView(
223
- number: number,
224
- fields: readonly string[] = PR_VIEW_FIELDS,
225
- ): GhVerb<PullRequest | null> {
226
- // `parsePullRequest` yields null without a `number`, so a field subset that
227
- // omits it would read as "no such PR". Always ask for it, and each field once.
228
- const asked = [...new Set(fields.includes('number') ? fields : ['number', ...fields])]
229
- return {
230
- argv: ['pr', 'view', String(number), '--json', asked.join(',')],
231
- checked: false,
232
- parse: oneOrNull,
233
- }
234
- }
235
-
236
- /**
237
- * Open PRs whose head *starts with* `branch` — gh's own search semantics.
238
- *
239
- * The common shape behind "which PR is this branch?", bundling the cheap
240
- * field set with a small limit. Pair it with {@link pickPrForBranch}, which
241
- * is what turns the prefix match into an exact one.
242
- */
243
- export function openPrsForBranch(branch: string, limit = 5): GhVerb<PullRequest[] | null> {
244
- return prList({ head: branch, limit, fields: PR_REF_FIELDS })
245
- }
246
-
247
- /**
248
- * `gh pr view <n> --json body -q .body` — just the PR body. Null when gh
249
- * failed. gh's `-q` output ends with a newline that is not part of the body,
250
- * so every trailing `\r` and `\n` is trimmed (including any that were part of
251
- * the body, such as the `\r\n` of a body saved with CRLF line endings).
252
- */
253
- export function prBody(number: number): GhVerb<string | null> {
254
- return {
255
- argv: ['pr', 'view', String(number), '--json', 'body', '-q', '.body'],
256
- checked: false,
257
- parse: (r) => (r.exitCode === 0 ? r.stdout.replace(/[\r\n]+$/, '') : null),
258
- }
259
- }
260
-
261
- /** One `gh api` answer: HTTP status, entity tag, and the decoded JSON body. */
262
- export interface ApiResponse {
263
- /** True iff gh exited 0 and the status is below 400 (2xx, or a 304 on a conditional GET). */
264
- ok: boolean
265
- /** HTTP status; read off the header block under `-i`. Without `-i` it is synthesized (200 on exit 0, 0 on failure), not observed. */
266
- status: number
267
- /** The response `ETag`, when headers were requested and one came back. */
268
- etag: string | null
269
- /** The decoded JSON body; null when empty (e.g. a 304), unparseable, or the call failed. */
270
- body: unknown
271
- /** True when a non-empty body arrived but was not valid JSON (`body` is then null). */
272
- malformed: boolean
273
- /** gh's stderr, trimmed. */
274
- stderr: string
275
- /** The runner's own message when gh never ran or was stopped (a failed spawn, a timeout). */
276
- error?: string | undefined
277
- }
278
-
279
- /**
280
- * Options for {@link api}. Both are optional; with neither, the call is a plain
281
- * `gh api <endpoint>` and the response carries no status or ETag.
282
- */
283
- export interface ApiOptions {
284
- /** Send `If-None-Match: <etag>` (implies `includeHeaders`). */
285
- etag?: string | null | undefined
286
- /** Pass `-i` and read status/ETag off the header block (the last one, if several). */
287
- includeHeaders?: boolean | undefined
288
- }
289
-
290
- /**
291
- * `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
292
- * `{repo}` placeholders in the endpoint are filled by gh from the cwd's repo.
293
- * Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
294
- * from an outage.
295
- */
296
- export function api(endpoint: string, opts: ApiOptions = {}): GhVerb<ApiResponse> {
297
- const conditional = opts.etag !== undefined && opts.etag !== null && opts.etag !== ''
298
- const withHeaders = conditional || opts.includeHeaders === true
299
- const argv = ['api']
300
- if (withHeaders) argv.push('-i')
301
- if (conditional) argv.push('-H', `If-None-Match: ${opts.etag}`)
302
- argv.push(endpoint)
303
- return { argv, checked: false, parse: (r) => parseApi(r, withHeaders) }
304
- }
305
-
306
- /** One header block off the front of `text`: the block, and what follows its blank line. */
307
- function takeHeaderBlock(text: string): { block: string; rest: string } {
308
- const crlf = text.indexOf('\r\n\r\n')
309
- const lf = text.indexOf('\n\n')
310
- const useCrlf = crlf >= 0 && (lf < 0 || crlf < lf)
311
- const sepIdx = useCrlf ? crlf : lf
312
- if (sepIdx < 0) return { block: text, rest: '' }
313
- return { block: text.slice(0, sepIdx), rest: text.slice(sepIdx + (useCrlf ? 4 : 2)) }
314
- }
315
-
316
- const STATUS_LINE = /^HTTP\/[\d.]+\s+\d{3}/i
317
-
318
- /**
319
- * Split a `gh api -i` payload (status line + headers, blank line, body) and decode it.
320
- * A real `gh` prints one block. As a guard, if several are stacked, the last one is read:
321
- * its status and ETag, and the body that follows it.
322
- */
323
- function parseApi(r: CommandResult, withHeaders: boolean): ApiResponse {
324
- let status = r.exitCode === 0 ? 200 : 0
325
- let etag: string | null = null
326
- let bodyText = r.stdout
327
- if (withHeaders) {
328
- let { block: headerBlock, rest } = takeHeaderBlock(r.stdout)
329
- while (STATUS_LINE.test(rest)) ({ block: headerBlock, rest } = takeHeaderBlock(rest))
330
- bodyText = rest.replace(/^[\r\n]+/, '')
331
- const statusMatch = headerBlock.match(/HTTP\/[\d.]+\s+(\d{3})/i)
332
- if (statusMatch) status = Number(statusMatch[1])
333
- const etagMatch = headerBlock.match(/^etag:\s*(.+)$/im)
334
- if (etagMatch) etag = etagMatch[1]!.trim()
335
- }
336
- let body: unknown = null
337
- let malformed = false
338
- if (r.exitCode === 0 && bodyText.trim() !== '') {
339
- try {
340
- body = JSON.parse(bodyText)
341
- } catch {
342
- malformed = true
343
- }
344
- }
345
- const ok = r.exitCode === 0 && status >= 200 && status < 400
346
- return {
347
- ok,
348
- status,
349
- etag,
350
- body,
351
- malformed,
352
- stderr: r.stderr.trim(),
353
- ...(r.error === undefined ? {} : { error: r.error }),
354
- }
355
- }
356
-
357
- /** `gh --version` — the install probe. True when `gh` runs. */
358
- export function installed(): GhVerb<boolean> {
359
- return { argv: ['--version'], checked: false, parse: (r) => r.exitCode === 0 }
360
- }
361
-
362
- /**
363
- * What `gh auth status` said: whether it passed, and its output (stderr, else the runner's
364
- * `error`, else stdout), trimmed.
365
- */
366
- export interface AuthStatus {
367
- ok: boolean
368
- detail: string
369
- }
370
-
371
- /** `gh auth status` — the credential probe. */
372
- export function authStatus(): GhVerb<AuthStatus> {
373
- return {
374
- argv: ['auth', 'status'],
375
- checked: false,
376
- parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
377
- }
378
- }
379
-
380
- /**
381
- * `gh label list --json name,color,description` — every label on the repo.
382
- * `limit` defaults to 1000. `gh`'s own default is 30, which would cut off a
383
- * repository with more labels than that without saying so.
384
- */
385
- export function labelList(opts: { limit?: number | undefined } = {}): GhVerb<Label[] | null> {
386
- return {
387
- argv: [
388
- 'label',
389
- 'list',
390
- '--json',
391
- 'name,color,description',
392
- '--limit',
393
- String(opts.limit ?? 1000),
394
- ],
395
- checked: false,
396
- parse: manyLabelsOrNull,
397
- }
398
- }
399
-
400
- /** What to narrow an issue listing to. Every filter is optional. */
401
- export interface IssueListOptions {
402
- /** Exact label filter, applied by `gh`. */
403
- label?: string | undefined
404
- /** The most issues to return. Defaults to 200. */
405
- limit?: number | undefined
406
- }
407
-
408
- /** `gh issue list --json …` — open issues by default, optionally by label. */
409
- export function issueList(opts: IssueListOptions = {}): GhVerb<IssueRow[] | null> {
410
- const argv = ['issue', 'list']
411
- if (opts.label !== undefined) argv.push('--label', opts.label)
412
- argv.push('--json', ISSUE_FIELDS, '--limit', String(opts.limit ?? 200))
413
- return { argv, checked: false, parse: manyIssuesOrNull }
414
- }
415
-
416
- /** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
417
- export function issueView(number: number): GhVerb<IssueRow | null> {
418
- return {
419
- argv: ['issue', 'view', String(number), '--json', ISSUE_FIELDS],
420
- checked: false,
421
- parse: oneIssueOrNull,
422
- }
423
- }
424
-
425
- // ── mutations (checked — the caller throws CommandFailed on non-zero) ────────
426
-
427
- /**
428
- * Options for {@link createPr}. `head` is the branch to merge from and `base`
429
- * the branch to merge into. `draft` opens the PR as a draft.
430
- */
431
- export interface CreatePrOptions {
432
- head: string
433
- base: string
434
- title: string
435
- body: string
436
- draft?: boolean | undefined
437
- }
438
-
439
- /**
440
- * `gh pr create …`, returning the new PR's URL: the last line gh printed that
441
- * starts with `http://` or `https://`. `''` when it printed none, as
442
- * {@link prComment} does. A line after the URL, such as a tip, is skipped.
443
- */
444
- export function createPr(opts: CreatePrOptions): GhVerb<string> {
445
- const argv = ['pr', 'create', '--head', opts.head, '--base', opts.base]
446
- if (opts.draft) argv.push('--draft')
447
- argv.push('--title', opts.title, '--body', opts.body)
448
- return {
449
- argv,
450
- checked: true,
451
- parse(r) {
452
- const urls = r.stdout
453
- .split('\n')
454
- .map((l) => l.trim())
455
- .filter((l) => /^https?:\/\//.test(l))
456
- return urls[urls.length - 1] ?? ''
457
- },
458
- }
459
- }
460
-
461
- /** `gh pr edit <n>` — retitle or rewrite the body. */
462
- export function editPr(
463
- number: number,
464
- opts: { title?: string | undefined; body?: string | undefined },
465
- ): GhVerb<void> {
466
- const argv = ['pr', 'edit', String(number)]
467
- if (opts.title !== undefined) argv.push('--title', opts.title)
468
- if (opts.body !== undefined) argv.push('--body', opts.body)
469
- return { argv, checked: true, parse: nothing }
470
- }
471
-
472
- /** `gh pr edit <n> --body` — rewrite just the body. */
473
- export function editPrBody(number: number, body: string): GhVerb<void> {
474
- return editPr(number, { body })
475
- }
476
-
477
- /** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
478
- export function prUpdateBranch(number: number, style: 'merge' | 'rebase' = 'merge'): GhVerb<void> {
479
- const argv = ['pr', 'update-branch', String(number)]
480
- if (style === 'rebase') argv.push('--rebase')
481
- return { argv, checked: true, parse: nothing }
482
- }
483
-
484
- /** `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)]
487
- if (draft) argv.push('--undo')
488
- return { argv, checked: true, parse: nothing }
489
- }
490
-
491
- /**
492
- * `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…` — rewrite one
493
- * conversation comment.
494
- *
495
- * `commentId` is the comment's numeric REST id, as text: the `id` that
496
- * `gh api repos/{owner}/{repo}/issues/<n>/comments` returns. It is not the
497
- * GraphQL node id (such as `IC_kw…`) that `gh pr view --json comments` gives
498
- * in `Comment.id`; the REST path answers 404 for a node id.
499
- *
500
- * Throws `RangeError` when `commentId` is not all digits, so a node id fails
501
- * at once and no text can change which endpoint the PATCH reaches.
502
- *
503
- * @experimental
504
- */
505
- export function issueCommentEdit(commentId: string, body: string): GhVerb<void> {
506
- if (!/^[0-9]+$/.test(commentId)) {
507
- throw new RangeError(
508
- `issueCommentEdit: commentId must be a numeric REST id, got ${JSON.stringify(commentId)}`,
509
- )
510
- }
511
- return {
512
- argv: [
513
- 'api',
514
- '-X',
515
- 'PATCH',
516
- `repos/{owner}/{repo}/issues/comments/${commentId}`,
517
- '-f',
518
- `body=${body}`,
519
- ],
520
- checked: true,
521
- parse: nothing,
522
- }
523
- }
524
-
525
- /** How {@link prMerge} lands the commits: `squash` into one, `merge` with a merge commit, or `rebase`. */
526
- export type PrMergeMethod = 'squash' | 'merge' | 'rebase'
527
-
528
- /**
529
- * `gh pr merge <n> --<method> --match-head-commit <sha>`. The head sha is
530
- * required, not optional: GitHub refuses the merge when the PR head moved
531
- * since the caller judged it, so a push that lands between a gate and the
532
- * merge can never ride in unreviewed. The branch is left alone (no
533
- * `--delete-branch`); deleting it is the caller's job.
534
- */
535
- export function prMerge(
536
- number: number,
537
- opts: { method: PrMergeMethod; headSha: string },
538
- ): GhVerb<void> {
539
- const argv = [
540
- 'pr',
541
- 'merge',
542
- String(number),
543
- `--${opts.method}`,
544
- '--match-head-commit',
545
- opts.headSha,
546
- ]
547
- return { argv, checked: true, parse: nothing }
548
- }
549
-
550
- /** One review thread: whether it is resolved and who spoke last. */
551
- export interface ReviewThread {
552
- isResolved: boolean
553
- isOutdated: boolean
554
- /** Login of the thread's newest comment; null when the account is gone. */
555
- lastCommentAuthor: string | null
556
- }
557
-
558
- const REVIEW_THREADS_QUERY =
559
- '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}}}}}}}}'
560
-
561
- /**
562
- * A PR's review threads over GraphQL (`gh pr view --json` has no thread
563
- * field). Null — never `[]` — when the call failed, the payload was not the
564
- * expected shape, or more than 100 threads exist: a caller deciding whether a
565
- * PR is clear to merge must read "could not tell" differently from "no
566
- * threads".
567
- */
568
- export function prReviewThreads(number: number): GhVerb<ReviewThread[] | null> {
569
- return {
570
- argv: [
571
- 'api',
572
- 'graphql',
573
- '-F',
574
- 'owner={owner}',
575
- '-F',
576
- 'name={repo}',
577
- '-F',
578
- `number=${number}`,
579
- '-f',
580
- `query=${REVIEW_THREADS_QUERY}`,
581
- ],
582
- checked: false,
583
- parse(r) {
584
- if (r.exitCode !== 0) return null
585
- let data: unknown
586
- try {
587
- data = JSON.parse(r.stdout)
588
- } catch {
589
- return null
590
- }
591
- type Node = {
592
- isResolved?: unknown
593
- isOutdated?: unknown
594
- comments?: { nodes?: { author?: { login?: unknown } | null }[] }
595
- }
596
- const threads = (
597
- data as {
598
- data?: {
599
- repository?: {
600
- pullRequest?: {
601
- reviewThreads?: { pageInfo?: { hasNextPage?: unknown }; nodes?: Node[] }
602
- } | null
603
- } | null
604
- }
605
- }
606
- )?.data?.repository?.pullRequest?.reviewThreads
607
- if (threads === undefined || !Array.isArray(threads.nodes)) return null
608
- if (threads.pageInfo?.hasNextPage === true) return null
609
- return threads.nodes.map((n) => {
610
- const login = n.comments?.nodes?.[0]?.author?.login
611
- return {
612
- isResolved: n.isResolved === true,
613
- isOutdated: n.isOutdated === true,
614
- lastCommentAuthor: typeof login === 'string' ? login : null,
615
- }
616
- })
617
- },
618
- }
619
- }
620
-
621
- /** A label to create. `color` is hex digits such as `d73a4a`. */
622
- export interface CreateLabelOptions {
623
- name: string
624
- color: string
625
- description: string
626
- }
627
-
628
- /**
629
- * `gh label create <name> --color <hex> --description <text> --force`.
630
- * `--force` makes it safe to repeat: when the label already exists, its color
631
- * and description are set to these instead of failing.
632
- */
633
- export function createLabel(opts: CreateLabelOptions): GhVerb<void> {
634
- const argv = [
635
- 'label',
636
- 'create',
637
- opts.name,
638
- '--color',
639
- opts.color,
640
- '--description',
641
- opts.description,
642
- '--force',
643
- ]
644
- return { argv, checked: true, parse: nothing }
645
- }
646
-
647
- /** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
648
- export function editLabel(
649
- name: string,
650
- opts: { color?: string | undefined; description?: string | undefined },
651
- ): GhVerb<void> {
652
- const argv = ['label', 'edit', name]
653
- if (opts.color !== undefined) argv.push('--color', opts.color)
654
- if (opts.description !== undefined) argv.push('--description', opts.description)
655
- return { argv, checked: true, parse: nothing }
656
- }
657
-
658
- /**
659
- * `gh issue edit <n> [--remove-label <current>] --add-label <new>` — swap
660
- * one label for another on an issue in ONE call, so the issue is never
661
- * briefly unlabeled or briefly holding both labels BETWEEN the remove and the
662
- * add — that atomicity is real and is this one call's own guarantee. It does
663
- * NOT cover the window before the call: `currentLabel` is whatever the caller
664
- * already read (perhaps minutes earlier, from a `gh issue list` snapshot),
665
- * never re-read here. If the issue's live label changed since then (a human,
666
- * or another process, relabeled it in between), `--remove-label
667
- * <currentLabel>` targets a label the issue no longer carries — `gh` no-ops
668
- * that removal silently rather than erroring — so the call still adds
669
- * `newLabel`, and the issue ends up holding BOTH the label it actually has
670
- * now and `newLabel`. A caller that cares about that race re-reads the live
671
- * label immediately before calling this. `currentLabel` is `null` when the
672
- * issue carries no prior label to remove — the `--remove-label` flag is then
673
- * omitted rather than passed empty.
674
- */
675
- export function setIssueLabel(
676
- number: number,
677
- currentLabel: string | null,
678
- newLabel: string,
679
- ): GhVerb<void> {
680
- const argv = ['issue', 'edit', String(number)]
681
- if (currentLabel !== null) argv.push('--remove-label', currentLabel)
682
- argv.push('--add-label', newLabel)
683
- return { argv, checked: true, parse: nothing }
684
- }
685
-
686
- /** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
687
- export function prDiff(number: number): GhVerb<string> {
688
- return { argv: ['pr', 'diff', String(number)], checked: true, parse: (r) => r.stdout }
689
- }
690
-
691
- /** `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> {
693
- return {
694
- argv: ['pr', 'comment', String(number), '--body', body],
695
- checked: true,
696
- parse(r) {
697
- const lines = r.stdout
698
- .split('\n')
699
- .map((l) => l.trim())
700
- .filter((l) => l !== '')
701
- const last = lines[lines.length - 1] ?? ''
702
- return /^https?:\/\//.test(last) ? last : ''
703
- },
704
- }
705
- }
706
-
707
- /**
708
- * One inline comment of a review: a line on the right (new) side of the diff.
709
- *
710
- * @experimental
711
- */
712
- export interface ReviewComment {
713
- path: string
714
- /** Line number in the new version of the file. */
715
- line: number
716
- body: string
717
- }
718
-
719
- /**
720
- * What {@link createReview} posts: the commit, the summary and the inline comments.
721
- *
722
- * @experimental
723
- */
724
- export interface CreateReviewOptions {
725
- /** The commit the review is anchored to; line numbers refer to the diff at this sha. */
726
- commitId: string
727
- /** The review summary. GitHub rejects a review whose body and comments are both empty. */
728
- body: string
729
- comments: readonly ReviewComment[]
730
- }
731
-
732
- /**
733
- * Create a PR review with inline comments, returning the review's URL (empty
734
- * when GitHub's reply carried none). The event is always `COMMENT`: this
735
- * library does not offer a way to approve or request changes. Goes through
736
- * `gh api` with the payload on stdin, because `gh pr review` cannot attach
737
- * line-anchored comments; `{owner}`/`{repo}` are filled in by `gh` from the
738
- * client's working directory.
739
- *
740
- * @experimental
741
- */
742
- export function createReview(number: number, opts: CreateReviewOptions): GhVerb<string> {
743
- const payload = {
744
- commit_id: opts.commitId,
745
- body: opts.body,
746
- event: 'COMMENT',
747
- comments: opts.comments.map((c) => ({
748
- path: c.path,
749
- line: c.line,
750
- side: 'RIGHT',
751
- body: c.body,
752
- })),
753
- }
754
- return {
755
- argv: [
756
- 'api',
757
- '--method',
758
- 'POST',
759
- `repos/{owner}/{repo}/pulls/${number}/reviews`,
760
- '--input',
761
- '-',
762
- ],
763
- checked: true,
764
- input: JSON.stringify(payload),
765
- parse(r) {
766
- try {
767
- const url = (JSON.parse(r.stdout) as { html_url?: unknown }).html_url
768
- return typeof url === 'string' ? url : ''
769
- } catch {
770
- return ''
771
- }
772
- },
773
- }
774
- }
775
-
776
- // ── helpers over the parsed rows ────────────────────────────────────────────
777
-
778
- /**
779
- * The row whose `headRefName` exactly equals `branch`, or null.
780
- *
781
- * gh's `head:` search is a prefix match, so `task/foo` also returns
782
- * `task/foo-2`. Every caller that means one branch needs this filter.
783
- */
784
- export function pickPrForBranch(rows: PullRequest[] | null, branch: string): PullRequest | null {
785
- if (rows === null) return null
786
- return rows.find((r) => r.headRefName === branch) ?? null
787
- }
788
-
789
- /**
790
- * True when GitHub has not finished computing this PR's mergeability.
791
- *
792
- * The answer is not stable on a cold query: GitHub kicks off the merge
793
- * computation when asked and answers `UNKNOWN` until it lands. A caller that
794
- * treats the first `UNKNOWN` as final will mis-triage a PR that is merely
795
- * slow, so re-ask a few times before believing it.
796
- */
797
- export function needsMergeStateRetry(pr: PullRequest): boolean {
798
- return pr.mergeable === 'UNKNOWN' || pr.mergeStateStatus === 'UNKNOWN'
799
- }