@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/CHANGELOG.md +61 -1
- package/CONTRIBUTING.md +4 -3
- package/README.md +247 -56
- package/dist/async.d.ts +70 -28
- package/dist/async.js +127 -44
- 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 +12 -8
- package/dist/index.js +9 -4
- package/dist/parse.d.ts +5 -2
- package/dist/parse.js +48 -0
- package/dist/repo.d.ts +42 -11
- package/dist/repo.js +76 -12
- package/dist/sync.d.ts +104 -28
- package/dist/sync.js +155 -46
- package/dist/types.d.ts +37 -1
- package/dist/verbs.d.ts +213 -45
- package/dist/verbs.js +504 -86
- package/package.json +13 -17
- package/async.ts +0 -285
- 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 -97
- package/parse.ts +0 -252
- package/repo.ts +0 -59
- package/sync.ts +0 -326
- package/types.ts +0 -186
- package/verbs.ts +0 -799
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
|
-
}
|