@osdlc/typed-gh 0.9.6 → 0.12.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 +43 -0
- package/README.md +48 -6
- package/SECURITY.md +4 -3
- package/dist/async.js +29 -27
- package/dist/doors.d.ts +17 -4
- package/dist/doors.js +43 -48
- package/dist/failure.js +3 -8
- package/dist/parse.js +9 -1
- package/dist/repo.d.ts +9 -4
- package/dist/repo.js +12 -12
- package/dist/sync.d.ts +1 -0
- package/dist/sync.js +1 -0
- package/dist/testing.d.ts +216 -0
- package/dist/testing.js +186 -0
- package/dist/verbs.d.ts +11 -6
- package/dist/verbs.js +93 -73
- package/package.json +8 -4
package/dist/verbs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `settle` (see `doors.ts`) instead. There a failure throws, except where the
|
|
14
14
|
* verb declares `absent` or is a `probe`.
|
|
15
15
|
*/
|
|
16
|
-
import { stderrOrError } from '@osdlc/command-seam';
|
|
16
|
+
import { notAnOption, stderrOrError } from '@osdlc/command-seam';
|
|
17
17
|
import { parseLabels, parsePrSearchHit, parsePullRequest, parseRepository } from "./parse.js";
|
|
18
18
|
import { isAbsent } from "./failure.js";
|
|
19
19
|
import { isRepoSlug, parsePrUrl } from "./repo.js";
|
|
@@ -48,16 +48,26 @@ export function checkTarget(target) {
|
|
|
48
48
|
throw new RangeError(`hostname must be a bare host, got ${JSON.stringify(hostname)}`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* `[host/]owner/name` for `repo` (else the target's repo), the form `--repo`
|
|
53
|
+
* and `gh repo view` take, or `undefined` when neither names one. The host is
|
|
54
|
+
* the target's, through {@link hostOf}, so a hostname of `''` is "not set".
|
|
55
|
+
* Both are checked first.
|
|
56
|
+
*/
|
|
57
|
+
function qualifiedRepo(target, repo) {
|
|
53
58
|
checkTarget(target);
|
|
54
|
-
const slug = repo ?? target?.repo;
|
|
55
|
-
if (slug === undefined)
|
|
56
|
-
return [];
|
|
57
59
|
if (repo !== undefined)
|
|
58
60
|
checkTarget({ repo });
|
|
61
|
+
const slug = repo ?? target?.repo;
|
|
62
|
+
if (slug === undefined)
|
|
63
|
+
return undefined;
|
|
59
64
|
const hostname = hostOf(target);
|
|
60
|
-
return
|
|
65
|
+
return hostname === undefined ? slug : `${hostname}/${slug}`;
|
|
66
|
+
}
|
|
67
|
+
/** `--repo [host/]owner/name` for `pr`, `issue` and `label` verbs. `repo` overrides the target's. */
|
|
68
|
+
function repoArgs(target, repo) {
|
|
69
|
+
const qualified = qualifiedRepo(target, repo);
|
|
70
|
+
return qualified === undefined ? [] : ['--repo', qualified];
|
|
61
71
|
}
|
|
62
72
|
/** `--hostname <host>` for `api` and `auth` verbs. */
|
|
63
73
|
function hostArgs(target) {
|
|
@@ -144,24 +154,7 @@ function oneOrNull(r) {
|
|
|
144
154
|
* failed. A caller must be able to tell "no PRs" from "couldn't ask".
|
|
145
155
|
*/
|
|
146
156
|
function manyOrNull(r) {
|
|
147
|
-
|
|
148
|
-
return null;
|
|
149
|
-
let data;
|
|
150
|
-
try {
|
|
151
|
-
data = JSON.parse(r.stdout || '[]');
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
if (!Array.isArray(data))
|
|
157
|
-
return null;
|
|
158
|
-
const rows = [];
|
|
159
|
-
for (const item of data) {
|
|
160
|
-
const pr = parsePullRequest(item);
|
|
161
|
-
if (pr !== null)
|
|
162
|
-
rows.push(pr);
|
|
163
|
-
}
|
|
164
|
-
return rows;
|
|
157
|
+
return rowsOrNull(r, (item) => parsePullRequest(item));
|
|
165
158
|
}
|
|
166
159
|
/** Throw `RangeError` unless `limit` is a positive integer. */
|
|
167
160
|
function checkLimit(limit) {
|
|
@@ -169,11 +162,23 @@ function checkLimit(limit) {
|
|
|
169
162
|
throw new RangeError(`limit must be a positive integer, got ${limit}`);
|
|
170
163
|
}
|
|
171
164
|
}
|
|
172
|
-
/**
|
|
173
|
-
|
|
165
|
+
/**
|
|
166
|
+
* The most results GitHub search returns for one query. A call that goes
|
|
167
|
+
* through search and gets this many rows may have been cut off by search,
|
|
168
|
+
* whatever the `limit`. gh uses search for `pr list` with a non-empty
|
|
169
|
+
* `--search` or with `--author`, for `issue list` with `--label`, and for
|
|
170
|
+
* `search prs`.
|
|
171
|
+
*/
|
|
172
|
+
const SEARCH_RESULT_CAP = 1000;
|
|
173
|
+
/**
|
|
174
|
+
* `rows` cut to `limit`, with whether there were more. With `cap`, reaching
|
|
175
|
+
* it counts as more too, since the source stops there. Null stays null.
|
|
176
|
+
*/
|
|
177
|
+
function limited(rows, limit, cap) {
|
|
174
178
|
if (rows === null)
|
|
175
179
|
return null;
|
|
176
|
-
|
|
180
|
+
const truncated = rows.length > limit || (cap !== undefined && rows.length >= cap);
|
|
181
|
+
return { rows: rows.slice(0, limit), truncated };
|
|
177
182
|
}
|
|
178
183
|
/** A mutation's result carries no value. */
|
|
179
184
|
function nothing() { }
|
|
@@ -232,24 +237,7 @@ function oneIssueOrNull(r) {
|
|
|
232
237
|
* on failure, as {@link manyOrNull} does for PRs.
|
|
233
238
|
*/
|
|
234
239
|
function manyIssuesOrNull(r) {
|
|
235
|
-
|
|
236
|
-
return null;
|
|
237
|
-
let data;
|
|
238
|
-
try {
|
|
239
|
-
data = JSON.parse(r.stdout || '[]');
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
if (!Array.isArray(data))
|
|
245
|
-
return null;
|
|
246
|
-
const rows = [];
|
|
247
|
-
for (const item of data) {
|
|
248
|
-
const row = parseIssueRow(item);
|
|
249
|
-
if (row !== null)
|
|
250
|
-
rows.push(row);
|
|
251
|
-
}
|
|
252
|
-
return rows;
|
|
240
|
+
return rowsOrNull(r, parseIssueRow);
|
|
253
241
|
}
|
|
254
242
|
/** `gh pr list …`: open PRs by default. `opts.repo` overrides the target's repo. */
|
|
255
243
|
export function prList(opts = {}, target) {
|
|
@@ -278,7 +266,8 @@ export function prList(opts = {}, target) {
|
|
|
278
266
|
checked: false,
|
|
279
267
|
readOnly: true,
|
|
280
268
|
parse(r) {
|
|
281
|
-
|
|
269
|
+
// A search stops at 1000 results, so 1000 rows may be a cut, not the end.
|
|
270
|
+
const result = limited(manyOrNull(r), limit, (opts.search ?? '') !== '' || opts.author !== undefined ? SEARCH_RESULT_CAP : undefined);
|
|
282
271
|
if (result === null || head === undefined)
|
|
283
272
|
return result;
|
|
284
273
|
return { ...result, rows: result.rows.filter((pr) => pr.headRefName === head) };
|
|
@@ -294,9 +283,8 @@ function withFields(fields, required) {
|
|
|
294
283
|
* argument would read as a flag.
|
|
295
284
|
*/
|
|
296
285
|
function notAFlag(what, value) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
286
|
+
const message = (v) => `${what} must not start with "-", got ${JSON.stringify(v)}`;
|
|
287
|
+
notAnOption(value, (v) => new RangeError(message(v)));
|
|
300
288
|
}
|
|
301
289
|
/**
|
|
302
290
|
* `gh pr view <number|branch|url> --json …`: one PR, with the full field set
|
|
@@ -313,15 +301,14 @@ export function prView(ref, fields = PR_VIEW_FIELDS, target) {
|
|
|
313
301
|
// omits it would read as "no such PR". Always ask for it, and each field once.
|
|
314
302
|
const asked = withFields(fields, ['number']);
|
|
315
303
|
const isUrl = typeof ref === 'string' && parsePrUrl(ref) !== null;
|
|
304
|
+
// gh reads a selector as a URL only with an http(s) scheme; a scheme-less
|
|
305
|
+
// one (`github.com/o/r/pull/7`, which parsePrUrl accepts) would be taken as
|
|
306
|
+
// a branch name.
|
|
307
|
+
const selector = typeof ref === 'string' && isUrl && !/^https?:\/\//i.test(ref.trim())
|
|
308
|
+
? `https://${ref.trim()}`
|
|
309
|
+
: String(ref);
|
|
316
310
|
return {
|
|
317
|
-
argv: [
|
|
318
|
-
'pr',
|
|
319
|
-
'view',
|
|
320
|
-
String(ref),
|
|
321
|
-
...(isUrl ? [] : repoArgs(target)),
|
|
322
|
-
'--json',
|
|
323
|
-
asked.join(','),
|
|
324
|
-
],
|
|
311
|
+
argv: ['pr', 'view', selector, ...(isUrl ? [] : repoArgs(target)), '--json', asked.join(',')],
|
|
325
312
|
checked: false,
|
|
326
313
|
readOnly: true,
|
|
327
314
|
absent: noSuch('pr'),
|
|
@@ -392,6 +379,27 @@ function flattenPages(body) {
|
|
|
392
379
|
return body;
|
|
393
380
|
return body.flat();
|
|
394
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* True when any operation in a GraphQL document is a `mutation` or a
|
|
384
|
+
* `subscription`. Strings and comments are dropped first, and only the words
|
|
385
|
+
* outside every `{…}` and `(…)` are read, which is where an operation's
|
|
386
|
+
* keyword stands. So a leading fragment, a leading `,` (an ignored token) or a
|
|
387
|
+
* second operation after a query cannot hide one.
|
|
388
|
+
*/
|
|
389
|
+
function writesOrStreams(document) {
|
|
390
|
+
const code = document.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n]*/g, ' ');
|
|
391
|
+
let depth = 0;
|
|
392
|
+
for (const token of code.matchAll(/[{}()]|[_A-Za-z][_0-9A-Za-z]*/g)) {
|
|
393
|
+
const t = token[0];
|
|
394
|
+
if (t === '{' || t === '(')
|
|
395
|
+
depth += 1;
|
|
396
|
+
else if (t === '}' || t === ')')
|
|
397
|
+
depth = Math.max(0, depth - 1);
|
|
398
|
+
else if (depth === 0 && (t === 'mutation' || t === 'subscription'))
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
395
403
|
/**
|
|
396
404
|
* `gh api graphql -f query=… [--paginate --slurp]`: a GraphQL query.
|
|
397
405
|
*
|
|
@@ -401,15 +409,16 @@ function flattenPages(body) {
|
|
|
401
409
|
* connection across the pages with {@link nodesAt}.
|
|
402
410
|
*
|
|
403
411
|
* A string variable of exactly `{owner}` or `{repo}` is filled from the
|
|
404
|
-
* target's repo, or by gh from the clone. A mutation
|
|
405
|
-
* `RangeError
|
|
412
|
+
* target's repo, or by gh from the clone. A document holding any mutation or
|
|
413
|
+
* subscription throws `RangeError`, as does a variable named `query` or
|
|
414
|
+
* `operationName`, which gh would send as the request's own fields. This is a
|
|
415
|
+
* read, and changes go through `raw`.
|
|
406
416
|
*
|
|
407
417
|
* @experimental
|
|
408
418
|
*/
|
|
409
419
|
export function graphql(query, variables = {}, opts = {}, target) {
|
|
410
420
|
checkTarget(target);
|
|
411
|
-
|
|
412
|
-
if (/^mutation\b/.test(body) || /^subscription\b/.test(body)) {
|
|
421
|
+
if (writesOrStreams(query)) {
|
|
413
422
|
throw new RangeError('graphql: only queries are allowed; send a mutation through raw');
|
|
414
423
|
}
|
|
415
424
|
const paginate = opts.paginate === true;
|
|
@@ -421,7 +430,10 @@ export function graphql(query, variables = {}, opts = {}, target) {
|
|
|
421
430
|
argv.push('--paginate', '--slurp');
|
|
422
431
|
const [owner, name] = target?.repo?.split('/') ?? [];
|
|
423
432
|
for (const [key, value] of Object.entries(variables)) {
|
|
424
|
-
|
|
433
|
+
// gh lifts `query` and `operationName` out of the variables into the
|
|
434
|
+
// request itself, so either would replace the checked document or pick
|
|
435
|
+
// another operation in it.
|
|
436
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key === 'query' || key === 'operationName') {
|
|
425
437
|
throw new RangeError(`graphql: bad variable name ${JSON.stringify(key)}`);
|
|
426
438
|
}
|
|
427
439
|
if (typeof value === 'string') {
|
|
@@ -605,7 +617,14 @@ export function issueList(opts = {}, target) {
|
|
|
605
617
|
if (opts.label !== undefined)
|
|
606
618
|
argv.push('--label', opts.label);
|
|
607
619
|
argv.push('--json', ISSUE_FIELDS, '--limit', String(limit + 1));
|
|
608
|
-
|
|
620
|
+
// gh lists issues by label through search, which stops at 1000 results.
|
|
621
|
+
const cap = opts.label === undefined ? undefined : SEARCH_RESULT_CAP;
|
|
622
|
+
return {
|
|
623
|
+
argv,
|
|
624
|
+
checked: false,
|
|
625
|
+
readOnly: true,
|
|
626
|
+
parse: (r) => limited(manyIssuesOrNull(r), limit, cap),
|
|
627
|
+
};
|
|
609
628
|
}
|
|
610
629
|
/** `gh issue view <n> --json …`: one issue, same field set as {@link issueList}. */
|
|
611
630
|
export function issueView(number, target) {
|
|
@@ -688,14 +707,11 @@ export function repoList(owner, opts = {}, target) {
|
|
|
688
707
|
* `owner` and `name` are in GitHub's casing, not as passed.
|
|
689
708
|
*/
|
|
690
709
|
export function repoView(repo, fields = REPO_FIELDS, target) {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
checkTarget({ repo });
|
|
694
|
-
const slug = repo ?? target?.repo;
|
|
710
|
+
// The same `[host/]owner/name` rule as `--repo`.
|
|
711
|
+
const qualified = qualifiedRepo(target, repo);
|
|
695
712
|
const argv = ['repo', 'view'];
|
|
696
|
-
if (
|
|
697
|
-
argv.push(
|
|
698
|
-
}
|
|
713
|
+
if (qualified !== undefined)
|
|
714
|
+
argv.push(qualified);
|
|
699
715
|
argv.push('--json', withFields(fields, ['nameWithOwner']).join(','));
|
|
700
716
|
return {
|
|
701
717
|
argv,
|
|
@@ -737,7 +753,9 @@ export function searchPrs(query, opts = {}, target) {
|
|
|
737
753
|
else if (opts.state !== undefined)
|
|
738
754
|
argv.push('--state', opts.state);
|
|
739
755
|
argv.push('--json', withFields(opts.fields ?? SEARCH_PR_FIELDS, ['number']).join(','));
|
|
740
|
-
|
|
756
|
+
// gh refuses a search limit over 1000, so ask for at most that. 1000 rows
|
|
757
|
+
// then read as truncated: search may hold more.
|
|
758
|
+
argv.push('--limit', String(Math.min(limit + 1, SEARCH_RESULT_CAP)));
|
|
741
759
|
if (query !== '')
|
|
742
760
|
argv.push('--', query);
|
|
743
761
|
return {
|
|
@@ -745,7 +763,7 @@ export function searchPrs(query, opts = {}, target) {
|
|
|
745
763
|
checked: false,
|
|
746
764
|
readOnly: true,
|
|
747
765
|
env: hostEnv(target),
|
|
748
|
-
parse: (r) => limited(rowsOrNull(r, parsePrSearchHit), limit),
|
|
766
|
+
parse: (r) => limited(rowsOrNull(r, parsePrSearchHit), limit, SEARCH_RESULT_CAP),
|
|
749
767
|
};
|
|
750
768
|
}
|
|
751
769
|
/**
|
|
@@ -909,6 +927,7 @@ export function prReviewThreads(number, target) {
|
|
|
909
927
|
* and description are set to these instead of failing.
|
|
910
928
|
*/
|
|
911
929
|
export function createLabel(opts, target) {
|
|
930
|
+
notAFlag('createLabel name', opts.name);
|
|
912
931
|
const argv = [
|
|
913
932
|
'label',
|
|
914
933
|
'create',
|
|
@@ -924,6 +943,7 @@ export function createLabel(opts, target) {
|
|
|
924
943
|
}
|
|
925
944
|
/** `gh label edit <name>`: change the color or description. A flag not given is left out. */
|
|
926
945
|
export function editLabel(name, opts, target) {
|
|
946
|
+
notAFlag('editLabel name', name);
|
|
927
947
|
const argv = ['label', 'edit', name, ...repoArgs(target)];
|
|
928
948
|
if (opts.color !== undefined)
|
|
929
949
|
argv.push('--color', opts.color);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/typed-gh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "A typed GitHub client for TypeScript over the gh CLI. A total PullRequest/Review/CheckRun model, pure argv builders and parsers, and a tri-modal client — queries return parsed values, mutations throw, raw() is the escape hatch. Uses gh's own credentials; reads no token.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": "sksizer",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./dist/testing.d.ts",
|
|
16
|
+
"default": "./dist/testing.js"
|
|
13
17
|
}
|
|
14
18
|
},
|
|
15
19
|
"files": [
|
|
@@ -22,9 +26,9 @@
|
|
|
22
26
|
"LICENSE-APACHE"
|
|
23
27
|
],
|
|
24
28
|
"dependencies": {
|
|
25
|
-
"@osdlc/command-seam": "^0.
|
|
26
|
-
"@osdlc/
|
|
27
|
-
"@osdlc/
|
|
29
|
+
"@osdlc/command-seam": "^0.14.0",
|
|
30
|
+
"@osdlc/typed-git": "^0.10.0",
|
|
31
|
+
"@osdlc/result": "^0.1.0"
|
|
28
32
|
},
|
|
29
33
|
"engines": {
|
|
30
34
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|