@osdlc/typed-gh 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +168 -36
- package/async.ts +131 -37
- package/dist/async.d.ts +43 -15
- package/dist/async.d.ts.map +1 -1
- package/dist/async.js +92 -30
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/parse.d.ts +5 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +48 -0
- package/dist/repo.d.ts +42 -10
- package/dist/repo.d.ts.map +1 -1
- package/dist/repo.js +76 -12
- package/dist/sync.d.ts +64 -12
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +107 -29
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verbs.d.ts +194 -41
- package/dist/verbs.d.ts.map +1 -1
- package/dist/verbs.js +482 -83
- package/index.ts +26 -3
- package/package.json +3 -3
- package/parse.ts +50 -0
- package/repo.ts +87 -13
- package/sync.ts +146 -34
- package/types.ts +40 -0
- package/verbs.ts +606 -81
package/dist/verbs.js
CHANGED
|
@@ -12,7 +12,53 @@
|
|
|
12
12
|
* into an absence value.
|
|
13
13
|
*/
|
|
14
14
|
import { stderrOrError } from '@osdlc/command-seam';
|
|
15
|
-
import { parseLabels, parsePullRequest } from "./parse.js";
|
|
15
|
+
import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from "./parse.js";
|
|
16
|
+
import { isRepoSlug, parsePrUrl } from "./repo.js";
|
|
17
|
+
const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d+)?$/;
|
|
18
|
+
/**
|
|
19
|
+
* Throw `RangeError` unless `target.repo` is `owner/name` and
|
|
20
|
+
* `target.hostname` is a bare host. Run by every verb that uses a target, so
|
|
21
|
+
* no text can reach gh as a flag or change the endpoint a call hits.
|
|
22
|
+
*/
|
|
23
|
+
export function checkTarget(target) {
|
|
24
|
+
if (target?.repo !== undefined && !isRepoSlug(target.repo)) {
|
|
25
|
+
throw new RangeError(`repo must be owner/name, got ${JSON.stringify(target.repo)}`);
|
|
26
|
+
}
|
|
27
|
+
if (target?.hostname !== undefined && !HOSTNAME.test(target.hostname)) {
|
|
28
|
+
throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(target.hostname)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs; `repo` overrides the target's. */
|
|
32
|
+
function repoArgs(target, repo) {
|
|
33
|
+
checkTarget(target);
|
|
34
|
+
const slug = repo ?? target?.repo;
|
|
35
|
+
if (slug === undefined)
|
|
36
|
+
return [];
|
|
37
|
+
if (repo !== undefined)
|
|
38
|
+
checkTarget({ repo });
|
|
39
|
+
return ['--repo', target?.hostname === undefined ? slug : `${target.hostname}/${slug}`];
|
|
40
|
+
}
|
|
41
|
+
/** `--hostname <host>` for `api` and `auth` verbs. */
|
|
42
|
+
function hostArgs(target) {
|
|
43
|
+
checkTarget(target);
|
|
44
|
+
return target?.hostname === undefined ? [] : ['--hostname', target.hostname];
|
|
45
|
+
}
|
|
46
|
+
/** `GH_HOST` for the verbs gh gives no host flag. */
|
|
47
|
+
function hostEnv(target) {
|
|
48
|
+
checkTarget(target);
|
|
49
|
+
return target?.hostname === undefined ? undefined : { GH_HOST: target.hostname };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `endpoint` with `{owner}` and `{repo}` filled from the target. With no
|
|
53
|
+
* target repo they are left for gh to fill from the clone.
|
|
54
|
+
*/
|
|
55
|
+
function fillRepo(endpoint, target) {
|
|
56
|
+
checkTarget(target);
|
|
57
|
+
if (target?.repo === undefined)
|
|
58
|
+
return endpoint;
|
|
59
|
+
const [owner, name] = target.repo.split('/');
|
|
60
|
+
return endpoint.replaceAll('{owner}', owner).replaceAll('{repo}', name);
|
|
61
|
+
}
|
|
16
62
|
/**
|
|
17
63
|
* The cheapest useful set: enough to identify a PR and nothing more.
|
|
18
64
|
*
|
|
@@ -94,6 +140,18 @@ function manyOrNull(r) {
|
|
|
94
140
|
}
|
|
95
141
|
return rows;
|
|
96
142
|
}
|
|
143
|
+
/** Throw `RangeError` unless `limit` is a positive integer. */
|
|
144
|
+
function checkLimit(limit) {
|
|
145
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
146
|
+
throw new RangeError(`limit must be a positive integer, got ${limit}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** `rows` cut to `limit`, with whether there were more. Null stays null. */
|
|
150
|
+
function limited(rows, limit) {
|
|
151
|
+
if (rows === null)
|
|
152
|
+
return null;
|
|
153
|
+
return { rows: rows.slice(0, limit), truncated: rows.length > limit };
|
|
154
|
+
}
|
|
97
155
|
/** A mutation's result carries no value. */
|
|
98
156
|
function nothing() { }
|
|
99
157
|
/**
|
|
@@ -172,43 +230,88 @@ function manyIssuesOrNull(r) {
|
|
|
172
230
|
}
|
|
173
231
|
return rows;
|
|
174
232
|
}
|
|
175
|
-
/** `gh pr list …` — open PRs by default. */
|
|
176
|
-
export function prList(opts = {}) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
/** `gh pr list …` — open PRs by default. `opts.repo` overrides the target's repo. */
|
|
234
|
+
export function prList(opts = {}, target) {
|
|
235
|
+
const limit = opts.limit ?? 100;
|
|
236
|
+
checkLimit(limit);
|
|
237
|
+
const argv = ['pr', 'list', '--state', opts.state ?? 'open', ...repoArgs(target, opts.repo)];
|
|
180
238
|
if (opts.base !== undefined)
|
|
181
239
|
argv.push('--base', opts.base);
|
|
182
240
|
if (opts.head !== undefined)
|
|
183
|
-
argv.push('--
|
|
241
|
+
argv.push('--head', opts.head);
|
|
184
242
|
if (opts.search !== undefined)
|
|
185
243
|
argv.push('--search', opts.search);
|
|
186
244
|
if (opts.author !== undefined)
|
|
187
245
|
argv.push('--author', opts.author);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
246
|
+
// The exact-head filter below reads `headRefName`, so ask for it.
|
|
247
|
+
const fields = opts.fields ?? PR_LIST_FIELDS;
|
|
248
|
+
const asked = opts.head === undefined ? fields : withFields(fields, ['headRefName']);
|
|
249
|
+
argv.push('--json', asked.join(','));
|
|
250
|
+
argv.push('--limit', String(limit + 1));
|
|
251
|
+
const head = opts.head;
|
|
252
|
+
return {
|
|
253
|
+
argv,
|
|
254
|
+
checked: false,
|
|
255
|
+
readOnly: true,
|
|
256
|
+
parse(r) {
|
|
257
|
+
const result = limited(manyOrNull(r), limit);
|
|
258
|
+
if (result === null || head === undefined)
|
|
259
|
+
return result;
|
|
260
|
+
return { ...result, rows: result.rows.filter((pr) => pr.headRefName === head) };
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** `fields` with each of `required` added once, ahead of the rest. */
|
|
265
|
+
function withFields(fields, required) {
|
|
266
|
+
return [...new Set([...required.filter((f) => !fields.includes(f)), ...fields])];
|
|
191
267
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Throw `RangeError` when a string bound for gh's argv as a positional
|
|
270
|
+
* argument would read as a flag.
|
|
271
|
+
*/
|
|
272
|
+
function notAFlag(what, value) {
|
|
273
|
+
if (value.startsWith('-')) {
|
|
274
|
+
throw new RangeError(`${what} must not start with "-", got ${JSON.stringify(value)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `gh pr view <number|branch|url> --json …` — one PR, with the heavy fields
|
|
279
|
+
* by default.
|
|
280
|
+
*
|
|
281
|
+
* A branch is the PR whose head is that branch, as gh picks it. A URL names
|
|
282
|
+
* its own repository and host, so the target is not passed with one. A
|
|
283
|
+
* string that starts with `-` throws `RangeError`.
|
|
284
|
+
*/
|
|
285
|
+
export function prView(ref, fields = PR_VIEW_FIELDS, target) {
|
|
286
|
+
if (typeof ref === 'string')
|
|
287
|
+
notAFlag('prView ref', ref);
|
|
194
288
|
// `parsePullRequest` yields null without a `number`, so a field subset that
|
|
195
289
|
// omits it would read as "no such PR". Always ask for it, and each field once.
|
|
196
|
-
const asked =
|
|
290
|
+
const asked = withFields(fields, ['number']);
|
|
291
|
+
const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null;
|
|
197
292
|
return {
|
|
198
|
-
argv: [
|
|
293
|
+
argv: [
|
|
294
|
+
'pr',
|
|
295
|
+
'view',
|
|
296
|
+
String(ref),
|
|
297
|
+
...(isUrl ? [] : repoArgs(target)),
|
|
298
|
+
'--json',
|
|
299
|
+
asked.join(','),
|
|
300
|
+
],
|
|
199
301
|
checked: false,
|
|
302
|
+
readOnly: true,
|
|
200
303
|
parse: oneOrNull,
|
|
201
304
|
};
|
|
202
305
|
}
|
|
203
306
|
/**
|
|
204
|
-
* Open PRs whose head
|
|
307
|
+
* Open PRs whose head is exactly `branch`.
|
|
205
308
|
*
|
|
206
309
|
* The common shape behind "which PR is this branch?", bundling the cheap
|
|
207
|
-
* field set with a small limit.
|
|
208
|
-
*
|
|
310
|
+
* field set with a small limit. More than one row comes back only when forks
|
|
311
|
+
* share the branch name.
|
|
209
312
|
*/
|
|
210
|
-
export function openPrsForBranch(branch, limit = 5) {
|
|
211
|
-
return prList({ head: branch, limit, fields: PR_REF_FIELDS });
|
|
313
|
+
export function openPrsForBranch(branch, limit = 5, target) {
|
|
314
|
+
return prList({ head: branch, limit, fields: PR_REF_FIELDS }, target);
|
|
212
315
|
}
|
|
213
316
|
/**
|
|
214
317
|
* `gh pr view <n> --json body -q .body` — just the PR body. Null when gh
|
|
@@ -216,29 +319,158 @@ export function openPrsForBranch(branch, limit = 5) {
|
|
|
216
319
|
* so every trailing `\r` and `\n` is trimmed (including any that were part of
|
|
217
320
|
* the body, such as the `\r\n` of a body saved with CRLF line endings).
|
|
218
321
|
*/
|
|
219
|
-
export function prBody(number) {
|
|
322
|
+
export function prBody(number, target) {
|
|
220
323
|
return {
|
|
221
|
-
argv: ['pr', 'view', String(number), '--json', 'body', '-q', '.body'],
|
|
324
|
+
argv: ['pr', 'view', String(number), ...repoArgs(target), '--json', 'body', '-q', '.body'],
|
|
222
325
|
checked: false,
|
|
326
|
+
readOnly: true,
|
|
223
327
|
parse: (r) => (r.exitCode === 0 ? r.stdout.replace(/[\r\n]+$/, '') : null),
|
|
224
328
|
};
|
|
225
329
|
}
|
|
226
330
|
/**
|
|
227
331
|
* `gh api [-i] [-H If-None-Match: …] <endpoint>` — a GET against the REST API. `{owner}` and
|
|
228
|
-
* `{repo}` placeholders in the endpoint are filled
|
|
332
|
+
* `{repo}` placeholders in the endpoint are filled from the target's repo, or by gh from the
|
|
333
|
+
* cwd's repo when the target names none.
|
|
229
334
|
* Unchecked: failure is a value (`ok: false`), so a caller can tell a 304
|
|
230
335
|
* from an outage.
|
|
231
336
|
*/
|
|
232
|
-
export function api(endpoint, opts = {}) {
|
|
337
|
+
export function api(endpoint, opts = {}, target) {
|
|
338
|
+
notAFlag('api endpoint', endpoint);
|
|
233
339
|
const conditional = opts.etag !== undefined && opts.etag !== null && opts.etag !== '';
|
|
234
340
|
const withHeaders = conditional || opts.includeHeaders === true;
|
|
235
|
-
const
|
|
341
|
+
const paginate = opts.paginate === true;
|
|
342
|
+
if (paginate && withHeaders) {
|
|
343
|
+
throw new RangeError('api: paginate cannot be combined with etag or includeHeaders');
|
|
344
|
+
}
|
|
345
|
+
const argv = ['api', ...hostArgs(target)];
|
|
236
346
|
if (withHeaders)
|
|
237
347
|
argv.push('-i');
|
|
238
348
|
if (conditional)
|
|
239
349
|
argv.push('-H', `If-None-Match: ${opts.etag}`);
|
|
240
|
-
|
|
241
|
-
|
|
350
|
+
if (paginate)
|
|
351
|
+
argv.push('--paginate', '--slurp');
|
|
352
|
+
argv.push(fillRepo(endpoint, target));
|
|
353
|
+
return {
|
|
354
|
+
argv,
|
|
355
|
+
checked: false,
|
|
356
|
+
readOnly: true,
|
|
357
|
+
parse(r) {
|
|
358
|
+
const response = parseApi(r, withHeaders);
|
|
359
|
+
return paginate ? { ...response, body: flattenPages(response.body) } : response;
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** `--slurp` pages joined into one array when every page is an array. */
|
|
364
|
+
function flattenPages(body) {
|
|
365
|
+
if (!Array.isArray(body) || !body.every(Array.isArray))
|
|
366
|
+
return body;
|
|
367
|
+
return body.flat();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* `gh api graphql -f query=… [--paginate --slurp]` — a GraphQL query.
|
|
371
|
+
*
|
|
372
|
+
* Returns each page's `data`, one entry per page (one without `paginate`),
|
|
373
|
+
* or null when the call failed, the output did not parse, or any page
|
|
374
|
+
* carries `errors` or lacks `data`: a partial answer is no answer. Read one
|
|
375
|
+
* connection across the pages with {@link nodesAt}.
|
|
376
|
+
*
|
|
377
|
+
* A string variable of exactly `{owner}` or `{repo}` is filled from the
|
|
378
|
+
* target's repo, or by gh from the clone. A mutation document throws
|
|
379
|
+
* `RangeError`: this is a read, and changes go through `raw`.
|
|
380
|
+
*
|
|
381
|
+
* @experimental
|
|
382
|
+
*/
|
|
383
|
+
export function graphql(query, variables = {}, opts = {}, target) {
|
|
384
|
+
checkTarget(target);
|
|
385
|
+
const body = query.replace(/^(?:\s|#[^\n]*\n?)*/, '');
|
|
386
|
+
if (/^mutation\b/.test(body) || /^subscription\b/.test(body)) {
|
|
387
|
+
throw new RangeError('graphql: only queries are allowed; send a mutation through raw');
|
|
388
|
+
}
|
|
389
|
+
const paginate = opts.paginate === true;
|
|
390
|
+
if (paginate && !query.includes('$endCursor')) {
|
|
391
|
+
throw new RangeError('graphql: paginate needs a query that takes $endCursor');
|
|
392
|
+
}
|
|
393
|
+
const argv = ['api', 'graphql', ...hostArgs(target)];
|
|
394
|
+
if (paginate)
|
|
395
|
+
argv.push('--paginate', '--slurp');
|
|
396
|
+
const [owner, name] = target?.repo?.split('/') ?? [];
|
|
397
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
398
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
399
|
+
throw new RangeError(`graphql: bad variable name ${JSON.stringify(key)}`);
|
|
400
|
+
}
|
|
401
|
+
if (typeof value === 'string') {
|
|
402
|
+
const filled = value === '{owner}' ? owner : value === '{repo}' ? name : undefined;
|
|
403
|
+
if (filled !== undefined)
|
|
404
|
+
argv.push('-f', `${key}=${filled}`);
|
|
405
|
+
else if (value === '{owner}' || value === '{repo}')
|
|
406
|
+
argv.push('-F', `${key}=${value}`);
|
|
407
|
+
else
|
|
408
|
+
argv.push('-f', `${key}=${value}`);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
argv.push('-F', `${key}=${value === null ? 'null' : String(value)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
argv.push('-f', `query=${query}`);
|
|
415
|
+
return {
|
|
416
|
+
argv,
|
|
417
|
+
checked: false,
|
|
418
|
+
readOnly: true,
|
|
419
|
+
parse(r) {
|
|
420
|
+
if (r.exitCode !== 0)
|
|
421
|
+
return null;
|
|
422
|
+
let data;
|
|
423
|
+
try {
|
|
424
|
+
data = JSON.parse(r.stdout);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const pages = paginate ? data : [data];
|
|
430
|
+
if (!Array.isArray(pages))
|
|
431
|
+
return null;
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const page of pages) {
|
|
434
|
+
if (page === null || typeof page !== 'object')
|
|
435
|
+
return null;
|
|
436
|
+
const rec = page;
|
|
437
|
+
if (Array.isArray(rec['errors']) && rec['errors'].length > 0)
|
|
438
|
+
return null;
|
|
439
|
+
if (rec['data'] === null || typeof rec['data'] !== 'object')
|
|
440
|
+
return null;
|
|
441
|
+
out.push(rec['data']);
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The `nodes` of one connection, joined across {@link graphql} pages: walk
|
|
449
|
+
* `path` into each page and take its `nodes` array. Null when `pages` is
|
|
450
|
+
* null, or any page lacks the path or a `nodes` array, so a page that came
|
|
451
|
+
* back short is never read as fewer rows.
|
|
452
|
+
*
|
|
453
|
+
* @experimental
|
|
454
|
+
*/
|
|
455
|
+
export function nodesAt(pages, path) {
|
|
456
|
+
if (pages === null)
|
|
457
|
+
return null;
|
|
458
|
+
const out = [];
|
|
459
|
+
for (const page of pages) {
|
|
460
|
+
let at = page;
|
|
461
|
+
for (const key of path) {
|
|
462
|
+
if (at === null || typeof at !== 'object')
|
|
463
|
+
return null;
|
|
464
|
+
at = at[key];
|
|
465
|
+
}
|
|
466
|
+
if (at === null || typeof at !== 'object')
|
|
467
|
+
return null;
|
|
468
|
+
const nodes = at['nodes'];
|
|
469
|
+
if (!Array.isArray(nodes))
|
|
470
|
+
return null;
|
|
471
|
+
out.push(...nodes);
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
242
474
|
}
|
|
243
475
|
/** One header block off the front of `text`: the block, and what follows its blank line. */
|
|
244
476
|
function takeHeaderBlock(text) {
|
|
@@ -295,64 +527,199 @@ function parseApi(r, withHeaders) {
|
|
|
295
527
|
}
|
|
296
528
|
/** `gh --version` — the install probe. True when `gh` runs. */
|
|
297
529
|
export function installed() {
|
|
298
|
-
return { argv: ['--version'], checked: false, parse: (r) => r.exitCode === 0 };
|
|
530
|
+
return { argv: ['--version'], checked: false, readOnly: true, parse: (r) => r.exitCode === 0 };
|
|
299
531
|
}
|
|
300
|
-
/** `gh auth status` — the credential probe. */
|
|
301
|
-
export function authStatus() {
|
|
532
|
+
/** `gh auth status [--hostname <host>]` — the credential probe, for one host when the target names it. */
|
|
533
|
+
export function authStatus(target) {
|
|
302
534
|
return {
|
|
303
|
-
argv: ['auth', 'status'],
|
|
535
|
+
argv: ['auth', 'status', ...hostArgs(target)],
|
|
304
536
|
checked: false,
|
|
537
|
+
readOnly: true,
|
|
305
538
|
parse: (r) => ({ ok: r.exitCode === 0, detail: stderrOrError(r) || r.stdout.trim() }),
|
|
306
539
|
};
|
|
307
540
|
}
|
|
308
541
|
/**
|
|
309
|
-
* `gh label list --json name,color,description` — every label on the repo
|
|
310
|
-
* `limit`
|
|
311
|
-
* repository with more labels than that without saying so.
|
|
542
|
+
* `gh label list --json name,color,description` — every label on the repo,
|
|
543
|
+
* up to `limit` (default 1000; `gh`'s own default is 30).
|
|
312
544
|
*/
|
|
313
|
-
export function labelList(opts = {}) {
|
|
545
|
+
export function labelList(opts = {}, target) {
|
|
546
|
+
const limit = opts.limit ?? 1000;
|
|
547
|
+
checkLimit(limit);
|
|
314
548
|
return {
|
|
315
549
|
argv: [
|
|
316
550
|
'label',
|
|
317
551
|
'list',
|
|
552
|
+
...repoArgs(target),
|
|
318
553
|
'--json',
|
|
319
554
|
'name,color,description',
|
|
320
555
|
'--limit',
|
|
321
|
-
String(
|
|
556
|
+
String(limit + 1),
|
|
322
557
|
],
|
|
323
558
|
checked: false,
|
|
324
|
-
|
|
559
|
+
readOnly: true,
|
|
560
|
+
parse: (r) => limited(manyLabelsOrNull(r), limit),
|
|
325
561
|
};
|
|
326
562
|
}
|
|
327
563
|
/** `gh issue list --json …` — open issues by default, optionally by label. */
|
|
328
|
-
export function issueList(opts = {}) {
|
|
329
|
-
const
|
|
564
|
+
export function issueList(opts = {}, target) {
|
|
565
|
+
const limit = opts.limit ?? 200;
|
|
566
|
+
checkLimit(limit);
|
|
567
|
+
const argv = ['issue', 'list', ...repoArgs(target)];
|
|
330
568
|
if (opts.label !== undefined)
|
|
331
569
|
argv.push('--label', opts.label);
|
|
332
|
-
argv.push('--json', ISSUE_FIELDS, '--limit', String(
|
|
333
|
-
return { argv, checked: false, parse: manyIssuesOrNull };
|
|
570
|
+
argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1));
|
|
571
|
+
return { argv, checked: false, readOnly: true, parse: (r) => limited(manyIssuesOrNull(r), limit) };
|
|
334
572
|
}
|
|
335
573
|
/** `gh issue view <n> --json …` — one issue, same field set as {@link issueList}. */
|
|
336
|
-
export function issueView(number) {
|
|
574
|
+
export function issueView(number, target) {
|
|
337
575
|
return {
|
|
338
|
-
argv: ['issue', 'view', String(number), '--json', ISSUE_FIELDS],
|
|
576
|
+
argv: ['issue', 'view', String(number), ...repoArgs(target), '--json', ISSUE_FIELDS],
|
|
339
577
|
checked: false,
|
|
578
|
+
readOnly: true,
|
|
340
579
|
parse: oneIssueOrNull,
|
|
341
580
|
};
|
|
342
581
|
}
|
|
582
|
+
/** The `gh repo list`/`view --json` fields a {@link Repository} reads. */
|
|
583
|
+
export const REPO_FIELDS = [
|
|
584
|
+
'nameWithOwner',
|
|
585
|
+
'description',
|
|
586
|
+
'defaultBranchRef',
|
|
587
|
+
'visibility',
|
|
588
|
+
'isArchived',
|
|
589
|
+
'url',
|
|
590
|
+
];
|
|
591
|
+
/** The `gh search prs --json` fields a {@link PrSearchHit} reads. */
|
|
592
|
+
export const SEARCH_PR_FIELDS = [
|
|
593
|
+
'number',
|
|
594
|
+
'title',
|
|
595
|
+
'state',
|
|
596
|
+
'url',
|
|
597
|
+
'isDraft',
|
|
598
|
+
'repository',
|
|
599
|
+
'author',
|
|
600
|
+
'labels',
|
|
601
|
+
'createdAt',
|
|
602
|
+
'updatedAt',
|
|
603
|
+
'closedAt',
|
|
604
|
+
];
|
|
605
|
+
/** Parse a `--json` array payload with `parse`, dropping rows it rejects. Null on failure. */
|
|
606
|
+
function rowsOrNull(r, parse) {
|
|
607
|
+
if (r.exitCode !== 0)
|
|
608
|
+
return null;
|
|
609
|
+
let data;
|
|
610
|
+
try {
|
|
611
|
+
data = JSON.parse(r.stdout || '[]');
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(data))
|
|
617
|
+
return null;
|
|
618
|
+
return data.map(parse).filter((row) => row !== null);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* `gh repo list <owner> --json …` — an owner's repositories. The host comes
|
|
622
|
+
* from the target (`GH_HOST`); the target's repo is not used.
|
|
623
|
+
*/
|
|
624
|
+
export function repoList(owner, opts = {}, target) {
|
|
625
|
+
notAFlag('repoList owner', owner);
|
|
626
|
+
const limit = opts.limit ?? 100;
|
|
627
|
+
checkLimit(limit);
|
|
628
|
+
const argv = ['repo', 'list', owner];
|
|
629
|
+
if (opts.archived === true)
|
|
630
|
+
argv.push('--archived');
|
|
631
|
+
if (opts.archived === false)
|
|
632
|
+
argv.push('--no-archived');
|
|
633
|
+
if (opts.visibility !== undefined)
|
|
634
|
+
argv.push('--visibility', opts.visibility);
|
|
635
|
+
argv.push('--json', withFields(opts.fields ?? REPO_FIELDS, ['nameWithOwner']).join(','));
|
|
636
|
+
argv.push('--limit', String(limit + 1));
|
|
637
|
+
return {
|
|
638
|
+
argv,
|
|
639
|
+
checked: false,
|
|
640
|
+
readOnly: true,
|
|
641
|
+
env: hostEnv(target),
|
|
642
|
+
parse: (r) => limited(rowsOrNull(r, parseRepository), limit),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* `gh repo view [<[host/]owner/name>] --json …` — one repository: `repo`,
|
|
647
|
+
* else the target's repo, else the clone at the client's cwd.
|
|
648
|
+
*/
|
|
649
|
+
export function repoView(repo, fields = REPO_FIELDS, target) {
|
|
650
|
+
checkTarget(target);
|
|
651
|
+
if (repo !== undefined)
|
|
652
|
+
checkTarget({ repo });
|
|
653
|
+
const slug = repo ?? target?.repo;
|
|
654
|
+
const argv = ['repo', 'view'];
|
|
655
|
+
if (slug !== undefined) {
|
|
656
|
+
argv.push(target?.hostname === undefined ? slug : `${target.hostname}/${slug}`);
|
|
657
|
+
}
|
|
658
|
+
argv.push('--json', withFields(fields, ['nameWithOwner']).join(','));
|
|
659
|
+
return {
|
|
660
|
+
argv,
|
|
661
|
+
checked: false,
|
|
662
|
+
readOnly: true,
|
|
663
|
+
parse(r) {
|
|
664
|
+
if (r.exitCode !== 0)
|
|
665
|
+
return null;
|
|
666
|
+
try {
|
|
667
|
+
return parseRepository(JSON.parse(r.stdout));
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* `gh search prs [flags] -- <query>` — pull requests across repositories,
|
|
677
|
+
* with GitHub's search syntax in `query` (it may start with `-`, as in
|
|
678
|
+
* `-label:bug`). The host comes from the target (`GH_HOST`); the target's
|
|
679
|
+
* repo is not used, so pass `opts.repo` or `opts.owner` to narrow.
|
|
680
|
+
*/
|
|
681
|
+
export function searchPrs(query, opts = {}, target) {
|
|
682
|
+
const limit = opts.limit ?? 100;
|
|
683
|
+
checkLimit(limit);
|
|
684
|
+
const argv = ['search', 'prs'];
|
|
685
|
+
if (opts.owner !== undefined) {
|
|
686
|
+
notAFlag('searchPrs owner', opts.owner);
|
|
687
|
+
argv.push('--owner', opts.owner);
|
|
688
|
+
}
|
|
689
|
+
if (opts.repo !== undefined) {
|
|
690
|
+
checkTarget({ repo: opts.repo });
|
|
691
|
+
argv.push('--repo', opts.repo);
|
|
692
|
+
}
|
|
693
|
+
if (opts.state === 'merged')
|
|
694
|
+
argv.push('--merged');
|
|
695
|
+
else if (opts.state !== undefined)
|
|
696
|
+
argv.push('--state', opts.state);
|
|
697
|
+
argv.push('--json', withFields(opts.fields ?? SEARCH_PR_FIELDS, ['number']).join(','));
|
|
698
|
+
argv.push('--limit', String(limit + 1));
|
|
699
|
+
if (query !== '')
|
|
700
|
+
argv.push('--', query);
|
|
701
|
+
return {
|
|
702
|
+
argv,
|
|
703
|
+
checked: false,
|
|
704
|
+
readOnly: true,
|
|
705
|
+
env: hostEnv(target),
|
|
706
|
+
parse: (r) => limited(rowsOrNull(r, parsePrSearchHit), limit),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
343
709
|
/**
|
|
344
710
|
* `gh pr create …`, returning the new PR's URL: the last line gh printed that
|
|
345
711
|
* starts with `http://` or `https://`. `''` when it printed none, as
|
|
346
712
|
* {@link prComment} does. A line after the URL, such as a tip, is skipped.
|
|
347
713
|
*/
|
|
348
|
-
export function createPr(opts) {
|
|
349
|
-
const argv = ['pr', 'create', '--head', opts.head, '--base', opts.base];
|
|
714
|
+
export function createPr(opts, target) {
|
|
715
|
+
const argv = ['pr', 'create', ...repoArgs(target), '--head', opts.head, '--base', opts.base];
|
|
350
716
|
if (opts.draft)
|
|
351
717
|
argv.push('--draft');
|
|
352
718
|
argv.push('--title', opts.title, '--body', opts.body);
|
|
353
719
|
return {
|
|
354
720
|
argv,
|
|
355
721
|
checked: true,
|
|
722
|
+
readOnly: false,
|
|
356
723
|
parse(r) {
|
|
357
724
|
const urls = r.stdout
|
|
358
725
|
.split('\n')
|
|
@@ -363,31 +730,31 @@ export function createPr(opts) {
|
|
|
363
730
|
};
|
|
364
731
|
}
|
|
365
732
|
/** `gh pr edit <n>` — retitle or rewrite the body. */
|
|
366
|
-
export function editPr(number, opts) {
|
|
367
|
-
const argv = ['pr', 'edit', String(number)];
|
|
733
|
+
export function editPr(number, opts, target) {
|
|
734
|
+
const argv = ['pr', 'edit', String(number), ...repoArgs(target)];
|
|
368
735
|
if (opts.title !== undefined)
|
|
369
736
|
argv.push('--title', opts.title);
|
|
370
737
|
if (opts.body !== undefined)
|
|
371
738
|
argv.push('--body', opts.body);
|
|
372
|
-
return { argv, checked: true, parse: nothing };
|
|
739
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
373
740
|
}
|
|
374
741
|
/** `gh pr edit <n> --body` — rewrite just the body. */
|
|
375
|
-
export function editPrBody(number, body) {
|
|
376
|
-
return editPr(number, { body });
|
|
742
|
+
export function editPrBody(number, body, target) {
|
|
743
|
+
return editPr(number, { body }, target);
|
|
377
744
|
}
|
|
378
745
|
/** `gh pr update-branch <n> [--rebase]` — bring the head up to date with its base. */
|
|
379
|
-
export function prUpdateBranch(number, style = 'merge') {
|
|
380
|
-
const argv = ['pr', 'update-branch', String(number)];
|
|
746
|
+
export function prUpdateBranch(number, style = 'merge', target) {
|
|
747
|
+
const argv = ['pr', 'update-branch', String(number), ...repoArgs(target)];
|
|
381
748
|
if (style === 'rebase')
|
|
382
749
|
argv.push('--rebase');
|
|
383
|
-
return { argv, checked: true, parse: nothing };
|
|
750
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
384
751
|
}
|
|
385
752
|
/** `gh pr ready <n> [--undo]` — mark a draft PR ready for review, or (with `draft`) convert it back to a draft. */
|
|
386
|
-
export function prSetDraft(number, draft) {
|
|
387
|
-
const argv = ['pr', 'ready', String(number)];
|
|
753
|
+
export function prSetDraft(number, draft, target) {
|
|
754
|
+
const argv = ['pr', 'ready', String(number), ...repoArgs(target)];
|
|
388
755
|
if (draft)
|
|
389
756
|
argv.push('--undo');
|
|
390
|
-
return { argv, checked: true, parse: nothing };
|
|
757
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
391
758
|
}
|
|
392
759
|
/**
|
|
393
760
|
* `gh api -X PATCH repos/{owner}/{repo}/issues/comments/<id> -f body=…` — rewrite one
|
|
@@ -403,20 +770,22 @@ export function prSetDraft(number, draft) {
|
|
|
403
770
|
*
|
|
404
771
|
* @experimental
|
|
405
772
|
*/
|
|
406
|
-
export function issueCommentEdit(commentId, body) {
|
|
773
|
+
export function issueCommentEdit(commentId, body, target) {
|
|
407
774
|
if (!/^[0-9]+$/.test(commentId)) {
|
|
408
775
|
throw new RangeError(`issueCommentEdit: commentId must be a numeric REST id, got ${JSON.stringify(commentId)}`);
|
|
409
776
|
}
|
|
410
777
|
return {
|
|
411
778
|
argv: [
|
|
412
779
|
'api',
|
|
780
|
+
...hostArgs(target),
|
|
413
781
|
'-X',
|
|
414
782
|
'PATCH',
|
|
415
|
-
`repos/{owner}/{repo}/issues/comments/${commentId}`,
|
|
783
|
+
fillRepo(`repos/{owner}/{repo}/issues/comments/${commentId}`, target),
|
|
416
784
|
'-f',
|
|
417
785
|
`body=${body}`,
|
|
418
786
|
],
|
|
419
787
|
checked: true,
|
|
788
|
+
readOnly: false,
|
|
420
789
|
parse: nothing,
|
|
421
790
|
};
|
|
422
791
|
}
|
|
@@ -427,16 +796,17 @@ export function issueCommentEdit(commentId, body) {
|
|
|
427
796
|
* merge can never ride in unreviewed. The branch is left alone (no
|
|
428
797
|
* `--delete-branch`); deleting it is the caller's job.
|
|
429
798
|
*/
|
|
430
|
-
export function prMerge(number, opts) {
|
|
799
|
+
export function prMerge(number, opts, target) {
|
|
431
800
|
const argv = [
|
|
432
801
|
'pr',
|
|
433
802
|
'merge',
|
|
434
803
|
String(number),
|
|
804
|
+
...repoArgs(target),
|
|
435
805
|
`--${opts.method}`,
|
|
436
806
|
'--match-head-commit',
|
|
437
807
|
opts.headSha,
|
|
438
808
|
];
|
|
439
|
-
return { argv, checked: true, parse: nothing };
|
|
809
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
440
810
|
}
|
|
441
811
|
const REVIEW_THREADS_QUERY = '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}}}}}}}}';
|
|
442
812
|
/**
|
|
@@ -446,21 +816,26 @@ const REVIEW_THREADS_QUERY = 'query($owner:String!,$name:String!,$number:Int!){r
|
|
|
446
816
|
* PR is clear to merge must read "could not tell" differently from "no
|
|
447
817
|
* threads".
|
|
448
818
|
*/
|
|
449
|
-
export function prReviewThreads(number) {
|
|
819
|
+
export function prReviewThreads(number, target) {
|
|
820
|
+
checkTarget(target);
|
|
821
|
+
// `-F` lets gh fill the placeholders from the clone; real values go as plain strings.
|
|
822
|
+
const [owner, name] = target?.repo?.split('/') ?? [];
|
|
823
|
+
const repo = owner === undefined || name === undefined
|
|
824
|
+
? ['-F', 'owner={owner}', '-F', 'name={repo}']
|
|
825
|
+
: ['-f', `owner=${owner}`, '-f', `name=${name}`];
|
|
450
826
|
return {
|
|
451
827
|
argv: [
|
|
452
828
|
'api',
|
|
453
829
|
'graphql',
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
'-F',
|
|
457
|
-
'name={repo}',
|
|
830
|
+
...hostArgs(target),
|
|
831
|
+
...repo,
|
|
458
832
|
'-F',
|
|
459
833
|
`number=${number}`,
|
|
460
834
|
'-f',
|
|
461
835
|
`query=${REVIEW_THREADS_QUERY}`,
|
|
462
836
|
],
|
|
463
837
|
checked: false,
|
|
838
|
+
readOnly: true,
|
|
464
839
|
parse(r) {
|
|
465
840
|
if (r.exitCode !== 0)
|
|
466
841
|
return null;
|
|
@@ -492,27 +867,28 @@ export function prReviewThreads(number) {
|
|
|
492
867
|
* `--force` makes it safe to repeat: when the label already exists, its color
|
|
493
868
|
* and description are set to these instead of failing.
|
|
494
869
|
*/
|
|
495
|
-
export function createLabel(opts) {
|
|
870
|
+
export function createLabel(opts, target) {
|
|
496
871
|
const argv = [
|
|
497
872
|
'label',
|
|
498
873
|
'create',
|
|
499
874
|
opts.name,
|
|
875
|
+
...repoArgs(target),
|
|
500
876
|
'--color',
|
|
501
877
|
opts.color,
|
|
502
878
|
'--description',
|
|
503
879
|
opts.description,
|
|
504
880
|
'--force',
|
|
505
881
|
];
|
|
506
|
-
return { argv, checked: true, parse: nothing };
|
|
882
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
507
883
|
}
|
|
508
884
|
/** `gh label edit <name>` — recolor or redescribe; omits a flag not given. */
|
|
509
|
-
export function editLabel(name, opts) {
|
|
510
|
-
const argv = ['label', 'edit', name];
|
|
885
|
+
export function editLabel(name, opts, target) {
|
|
886
|
+
const argv = ['label', 'edit', name, ...repoArgs(target)];
|
|
511
887
|
if (opts.color !== undefined)
|
|
512
888
|
argv.push('--color', opts.color);
|
|
513
889
|
if (opts.description !== undefined)
|
|
514
890
|
argv.push('--description', opts.description);
|
|
515
|
-
return { argv, checked: true, parse: nothing };
|
|
891
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
516
892
|
}
|
|
517
893
|
/**
|
|
518
894
|
* `gh issue edit <n> [--remove-label <current>] --add-label <new>` — swap
|
|
@@ -531,22 +907,28 @@ export function editLabel(name, opts) {
|
|
|
531
907
|
* issue carries no prior label to remove — the `--remove-label` flag is then
|
|
532
908
|
* omitted rather than passed empty.
|
|
533
909
|
*/
|
|
534
|
-
export function setIssueLabel(number, currentLabel, newLabel) {
|
|
535
|
-
const argv = ['issue', 'edit', String(number)];
|
|
910
|
+
export function setIssueLabel(number, currentLabel, newLabel, target) {
|
|
911
|
+
const argv = ['issue', 'edit', String(number), ...repoArgs(target)];
|
|
536
912
|
if (currentLabel !== null)
|
|
537
913
|
argv.push('--remove-label', currentLabel);
|
|
538
914
|
argv.push('--add-label', newLabel);
|
|
539
|
-
return { argv, checked: true, parse: nothing };
|
|
915
|
+
return { argv, checked: true, readOnly: false, parse: nothing };
|
|
540
916
|
}
|
|
541
917
|
/** `gh pr diff <n>` — the PR's unified diff, as text. Checked: an empty diff is a value, a failed call is not. */
|
|
542
|
-
export function prDiff(number) {
|
|
543
|
-
return {
|
|
918
|
+
export function prDiff(number, target) {
|
|
919
|
+
return {
|
|
920
|
+
argv: ['pr', 'diff', String(number), ...repoArgs(target)],
|
|
921
|
+
checked: true,
|
|
922
|
+
readOnly: true,
|
|
923
|
+
parse: (r) => r.stdout,
|
|
924
|
+
};
|
|
544
925
|
}
|
|
545
926
|
/** `gh pr comment <n> --body <body>` — post a conversation comment, returning the URL gh prints ('' if none). */
|
|
546
|
-
export function prComment(number, body) {
|
|
927
|
+
export function prComment(number, body, target) {
|
|
547
928
|
return {
|
|
548
|
-
argv: ['pr', 'comment', String(number), '--body', body],
|
|
929
|
+
argv: ['pr', 'comment', String(number), ...repoArgs(target), '--body', body],
|
|
549
930
|
checked: true,
|
|
931
|
+
readOnly: false,
|
|
550
932
|
parse(r) {
|
|
551
933
|
const lines = r.stdout
|
|
552
934
|
.split('\n')
|
|
@@ -562,12 +944,12 @@ export function prComment(number, body) {
|
|
|
562
944
|
* when GitHub's reply carried none). The event is always `COMMENT`: this
|
|
563
945
|
* library does not offer a way to approve or request changes. Goes through
|
|
564
946
|
* `gh api` with the payload on stdin, because `gh pr review` cannot attach
|
|
565
|
-
* line-anchored comments; `{owner}`/`{repo}`
|
|
566
|
-
* client's working directory.
|
|
947
|
+
* line-anchored comments; `{owner}`/`{repo}` come from the target's repo, or
|
|
948
|
+
* are filled in by `gh` from the client's working directory.
|
|
567
949
|
*
|
|
568
950
|
* @experimental
|
|
569
951
|
*/
|
|
570
|
-
export function createReview(number, opts) {
|
|
952
|
+
export function createReview(number, opts, target) {
|
|
571
953
|
const payload = {
|
|
572
954
|
commit_id: opts.commitId,
|
|
573
955
|
body: opts.body,
|
|
@@ -582,13 +964,15 @@ export function createReview(number, opts) {
|
|
|
582
964
|
return {
|
|
583
965
|
argv: [
|
|
584
966
|
'api',
|
|
967
|
+
...hostArgs(target),
|
|
585
968
|
'--method',
|
|
586
969
|
'POST',
|
|
587
|
-
`repos/{owner}/{repo}/pulls/${number}/reviews`,
|
|
970
|
+
fillRepo(`repos/{owner}/{repo}/pulls/${number}/reviews`, target),
|
|
588
971
|
'--input',
|
|
589
972
|
'-',
|
|
590
973
|
],
|
|
591
974
|
checked: true,
|
|
975
|
+
readOnly: false,
|
|
592
976
|
input: JSON.stringify(payload),
|
|
593
977
|
parse(r) {
|
|
594
978
|
try {
|
|
@@ -603,16 +987,31 @@ export function createReview(number, opts) {
|
|
|
603
987
|
}
|
|
604
988
|
// ── helpers over the parsed rows ────────────────────────────────────────────
|
|
605
989
|
/**
|
|
606
|
-
* The row whose `headRefName` exactly equals `branch`, or null.
|
|
990
|
+
* The first row whose `headRefName` exactly equals `branch`, or null.
|
|
607
991
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
992
|
+
* `prList({ head })` already filters exactly. This is for rows from anywhere
|
|
993
|
+
* else, such as a `search` query with gh's `head:` qualifier, which matches
|
|
994
|
+
* `task/foo-2` for `task/foo`.
|
|
610
995
|
*/
|
|
611
996
|
export function pickPrForBranch(rows, branch) {
|
|
612
997
|
if (rows === null)
|
|
613
998
|
return null;
|
|
614
999
|
return rows.find((r) => r.headRefName === branch) ?? null;
|
|
615
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The PR a branch names, from `prList({ head, state: 'all' })` rows: the open
|
|
1003
|
+
* one, or else the first row (gh lists newest first). Null when there are no
|
|
1004
|
+
* rows or the call failed.
|
|
1005
|
+
*/
|
|
1006
|
+
export function branchPr(result) {
|
|
1007
|
+
if (result === null)
|
|
1008
|
+
return null;
|
|
1009
|
+
return result.rows.find((r) => r.state === 'OPEN') ?? result.rows[0] ?? null;
|
|
1010
|
+
}
|
|
1011
|
+
/** The field set {@link branchPr} needs, ahead of `fields`. */
|
|
1012
|
+
export function branchPrFields(fields) {
|
|
1013
|
+
return withFields(fields, ['number', 'state', 'headRefName']);
|
|
1014
|
+
}
|
|
616
1015
|
/**
|
|
617
1016
|
* True when GitHub has not finished computing this PR's mergeability.
|
|
618
1017
|
*
|