@osdlc/typed-gh 0.6.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/CHANGELOG.md +37 -1
- package/CONTRIBUTING.md +4 -3
- package/README.md +94 -35
- package/dist/async.d.ts +37 -23
- package/dist/async.js +48 -27
- package/dist/doors.d.ts +82 -0
- package/dist/doors.js +183 -0
- package/dist/failure.d.ts +59 -0
- package/dist/failure.js +93 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -2
- package/dist/parse.d.ts +0 -1
- package/dist/repo.d.ts +0 -1
- package/dist/sync.d.ts +53 -29
- package/dist/sync.js +64 -33
- package/dist/types.d.ts +0 -1
- package/dist/verbs.d.ts +19 -4
- package/dist/verbs.js +23 -4
- package/package.json +13 -17
- package/async.ts +0 -379
- package/dist/async.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/parse.d.ts.map +0 -1
- package/dist/repo.d.ts.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/verbs.d.ts.map +0 -1
- package/index.ts +0 -120
- package/parse.ts +0 -302
- package/repo.ts +0 -133
- package/sync.ts +0 -438
- package/types.ts +0 -226
- package/verbs.ts +0 -1324
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
|
-
}
|