@osdlc/typed-gh 0.16.0 → 0.17.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @osdlc/typed-gh
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 61b12ce: `graphql` (experimental) reads a partial answer with `{ partial: true }`. Each page comes back as a `GraphqlPage`, its `data` with its `errors` (`GraphqlError`: `message`, `type`, `path`), so a batch of aliased lookups keeps the ones that worked when one is gone. gh exits 1 for such a response; the partial form reads it as the answer on the plain method, `try` and `soft`. It still fails when there is no `data` to read, when gh failed for another reason, or when `paginate` stopped at a page with errors that has a next page. `nodesAt(pages, path, { partial: true })` joins one connection across partial pages and returns `PartialNodes`: the nodes and every page's errors. Without `partial`, nothing changes. There is no page cap: `paginate` follows every page, and a caller that needs to stop early pages with `$endCursor` itself, as the README shows. The `try` and `soft` types now keep both overloads of an overloaded method.
8
+
9
+ ## 0.16.1
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [25ccd9e]
14
+ - @osdlc/typed-git@0.13.0
15
+
3
16
  ## 0.16.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -492,7 +492,7 @@ const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).bod
492
492
  ```
493
493
 
494
494
  `graphql` (experimental) runs a query and returns each page's `data`. It throws when the
495
- call failed or any page has `errors` or no `data` (`soft.graphql` is `null`). A partial answer counts as a failure. `nodesAt` joins
495
+ call failed or any page has `errors` or no `data` (`soft.graphql` is `null`). A partial answer counts as a failure, unless you pass `partial: true` (see [Partial answers](#partial-answers)). `nodesAt` joins
496
496
  one connection's `nodes` across the pages, and is `null` when any page lacks them.
497
497
  `fromTarget('owner')` and `fromTarget('repo')` fill a variable from the client's repo, or let gh
498
498
  fill it from the clone. A plain string is always sent as written, so a real value of `{owner}` stays
@@ -518,6 +518,70 @@ const prs = nodesAt(pages, ['repository', 'pullRequests'])
518
518
  With `paginate`, the query must take `$endCursor` and page one connection. gh follows only that
519
519
  one. `graphql` refuses a document holding a mutation or subscription with `RangeError`. Send one through `raw`.
520
520
 
521
+ ### Partial answers
522
+
523
+ When part of a query fails, GitHub still answers the rest: an aliased pull request that was
524
+ deleted is `null` in `data`, and an entry in `errors` names its path. gh prints that response and
525
+ exits 1. Pass `partial: true` to read it. Each page comes back as a `GraphqlPage`, its `data` with
526
+ its `errors` (each a `GraphqlError` of `message`, `type` and `path`). `errors` is empty for a page
527
+ that came back whole.
528
+
529
+ ```ts
530
+ const [page] = gh.graphql(
531
+ `query($owner: String!, $name: String!) {
532
+ repository(owner: $owner, name: $name) {
533
+ a: pullRequest(number: 1) { title }
534
+ b: pullRequest(number: 2) { title }
535
+ }
536
+ }`,
537
+ { owner: fromTarget('owner'), name: fromTarget('repo') },
538
+ { partial: true },
539
+ )
540
+ const missing = page?.errors.map((e) => e.path.join('.')) // ['repository.b'] when #2 is gone
541
+ ```
542
+
543
+ It still throws (`soft.graphql` is `null`) when there is no `data` to read: a failed call, a
544
+ response with no `data` or `data: null` (a rate limit, a query that does not validate), or output
545
+ that does not parse. With `paginate`, gh stops at the first page with errors. When that page still has a next
546
+ page, the pages after it were never fetched, so that throws too, rather than return fewer rows.
547
+ `nodesAt(pages, path, { partial: true })` joins one connection across partial pages and returns
548
+ `PartialNodes`: the `nodes`, where a node GitHub could not resolve is `null`, and every page's
549
+ `errors`.
550
+
551
+ ### Stopping early
552
+
553
+ `paginate` has no page cap: gh follows the connection to its end. To read at most a few pages, or
554
+ to carry on past a page with errors (add `partial: true` and read each page's `data`), leave
555
+ `paginate` off and pass the cursor yourself:
556
+
557
+ ```ts
558
+ const query = `query($endCursor: String) {
559
+ repository(owner: "acme", name: "widgets") {
560
+ pullRequests(first: 100, after: $endCursor) {
561
+ pageInfo { hasNextPage endCursor }
562
+ nodes { number }
563
+ }
564
+ }
565
+ }`
566
+ interface PageInfo {
567
+ hasNextPage: boolean
568
+ endCursor: string | null
569
+ }
570
+ type Page = { repository: { pullRequests: { pageInfo: PageInfo } } }
571
+ const prs: unknown[] = []
572
+ let endCursor: string | null = null
573
+ for (let n = 0; n < 3; n += 1) {
574
+ const [data]: unknown[] = gh.graphql(query, { endCursor })
575
+ prs.push(...(nodesAt([data], ['repository', 'pullRequests']) ?? []))
576
+ const info: PageInfo = (data as Page).repository.pullRequests.pageInfo
577
+ if (!info.hasNextPage) break
578
+ endCursor = info.endCursor
579
+ }
580
+ ```
581
+
582
+ The decision is in
583
+ [docs/planning/decisions/graphql-partial-and-page-cap.md](docs/planning/decisions/graphql-partial-and-page-cap.md).
584
+
521
585
  ## Repositories and search
522
586
 
523
587
  ```ts
@@ -617,7 +681,7 @@ Methods on both clients:
617
681
  - Issue and label queries: `issueList`, `issueView` and `labelList`.
618
682
  - Repository queries: `repoList` and `repoView`.
619
683
  - Issue and label changes: `setIssueLabel`, `createLabel`, `editLabel` and `issueCommentEdit`.
620
- - REST and GraphQL: `api` is a GET through `gh api` and never throws (failure is `ok: false`); `graphql` is a query and throws on a failed or partial answer.
684
+ - REST and GraphQL: `api` is a GET through `gh api` and never throws (failure is `ok: false`); `graphql` is a query and throws on a failed or partial answer, or returns a partial answer as `{ data, errors }` pages with `partial: true`.
621
685
  - Setup checks: `installed`, `authenticated`, `authStatus` and `ready`.
622
686
  - `ownerRepo` reads the owner and repository from a remote URL. It starts no process.
623
687
  - `try` and `soft`: the whole surface again as a `Result<T, GhFailure>`, and the queries again with `null` for a failure. `TriedSync`, `TriedAsync` and `Soft` are their types.
@@ -649,13 +713,15 @@ a verb before running it. Each verb takes an optional `GhTarget` last, except `r
649
713
  - `pickPrForBranch`: the first row whose head branch is exactly the one you name. `prList({ head })`
650
714
  already filters exactly. This is for rows from elsewhere, such as a `head:` search. It takes plain
651
715
  rows or a `ListResult`, so `openPrsForBranch`'s or `prList`'s parsed answer passes straight in.
652
- - `nodesAt`: one connection's nodes, joined across `graphql` pages.
716
+ - `nodesAt`: one connection's nodes, joined across `graphql` pages. With `{ partial: true }`, across
717
+ partial pages, with their errors.
653
718
  - Field lists: `PR_REF_FIELDS` (enough to identify a PR), `PR_LIST_FIELDS` (for triage),
654
719
  `PR_VIEW_FIELDS` (the full set), `REPO_FIELDS` and `SEARCH_PR_FIELDS`.
655
720
  - Option and result types: `ListResult`, `PrListOptions`, `IssueListOptions`, `RepoListOptions`,
656
721
  `SearchPrsOptions`, `CreatePrOptions`, `CreateReviewOptions`, `ReviewComment`,
657
- `CreateLabelOptions`, `ApiOptions`, `ApiResponse`, `GraphqlOptions`, `GraphqlVariable`,
658
- `AuthStatus`, `PrMergeMethod` and `ReviewThread`.
722
+ `CreateLabelOptions`, `ApiOptions`, `ApiResponse`, `GraphqlOptions`, `GraphqlPartialOptions`,
723
+ `GraphqlPage`, `GraphqlError`, `PartialNodes`, `GraphqlVariable`, `AuthStatus`, `PrMergeMethod`
724
+ and `ReviewThread`.
659
725
 
660
726
  ### Parsers
661
727
 
@@ -702,7 +768,8 @@ The experimental items:
702
768
  - `ReviewComment`
703
769
  - `graphql`, the verb and the method on both clients
704
770
  - `nodesAt`
705
- - `GraphqlOptions`
771
+ - `GraphqlOptions` and `GraphqlPartialOptions`
772
+ - `GraphqlPage`, `GraphqlError` and `PartialNodes`
706
773
  - `GraphqlVariable`
707
774
  - `fromTarget` and `FromTarget`
708
775
 
package/dist/async.d.ts CHANGED
@@ -116,10 +116,12 @@ export declare class AsyncGh {
116
116
  /** A REST GET through `gh api`. Failure is `ok: false`, never a rejection. */
117
117
  api(endpoint: string, opts?: ApiOptions & AsyncCallOptions): Promise<ApiResponse>;
118
118
  /**
119
- * A GraphQL query. Resolves to each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
119
+ * A GraphQL query. Resolves to each page's `data`. Rejects on a failed or partial result.
120
+ * With `partial: true`, resolves to each page's `data` with its `errors`. See `Gh.graphql`.
120
121
  *
121
122
  * @experimental
122
123
  */
124
+ graphql(query: string, variables: Record<string, v.GraphqlVariable> | undefined, opts: v.GraphqlPartialOptions & AsyncCallOptions): Promise<v.GraphqlPage[]>;
123
125
  graphql(query: string, variables?: Record<string, v.GraphqlVariable>, opts?: v.GraphqlOptions & AsyncCallOptions): Promise<unknown[]>;
124
126
  /** Every label on the repository (up to `opts.limit`, default 1000). Rejects when gh fails. */
125
127
  labelList(opts?: {
package/dist/async.js CHANGED
@@ -157,11 +157,6 @@ export class AsyncGh {
157
157
  async api(endpoint, opts = {}) {
158
158
  return this.exec(v.api(endpoint, opts, this.target), opts);
159
159
  }
160
- /**
161
- * A GraphQL query. Resolves to each page's `data`. Rejects on a failed or partial result. See `Gh.graphql`.
162
- *
163
- * @experimental
164
- */
165
160
  async graphql(query, variables = {}, opts = {}) {
166
161
  return this.read(v.graphql(query, variables, opts, this.target), opts);
167
162
  }
package/dist/doors.d.ts CHANGED
@@ -29,9 +29,10 @@ import type { GhVerb } from './verbs.ts';
29
29
  * A checked verb has already thrown on a non-zero exit (the runner is
30
30
  * {@link runCheckedGh}). A probe (`installed`, `authStatus`, `api`, `raw`)
31
31
  * carries its failure in its value. A read that exited non-zero throws the
32
- * classified failure, unless the verb's `absent` says gh's answer was "there
33
- * is no such thing", which `parse` turns into `null`. A read that exited 0 but
34
- * parsed to `null` could not be read completely, and throws.
32
+ * classified failure, unless the verb's `absent` says the result is still an
33
+ * answer: gh's "there is no such thing", which `parse` turns into `null`, or a
34
+ * partial `graphql` response. A read that exited 0 but parsed to `null` could
35
+ * not be read completely, and throws.
35
36
  *
36
37
  * A result flagged `outputIncomplete` throws for every verb but a probe, as
37
38
  * typed-git's `gate` does: gh exited, but the runner stopped reading at the
@@ -59,13 +60,47 @@ export declare function runCheckedGh(runner: CommandRunner, argv: string[], opts
59
60
  export declare function runCheckedGhAsync(runner: AsyncCommandRunner, argv: string[], opts: AsyncCommandRunOptions): Promise<CommandResult>;
60
61
  /** The members `try` and `soft` leave out: `raw`, `rawChecked`, helpers that run nothing, and properties. */
61
62
  type Triable<C> = Omit<C, 'raw' | 'rawChecked' | 'at' | 'withRepo' | 'ownerRepo' | 'try' | 'soft' | 'cwd' | 'repo' | 'hostname'>;
63
+ /**
64
+ * A method's call signatures as `[args, return]` pairs: its last two when it
65
+ * is overloaded (`graphql` is, on its `partial` option), and its one
66
+ * signature twice when it is not. The types below map each pair, so `try` and
67
+ * `soft` keep both of a method's overloads.
68
+ */
69
+ type Signatures<F> = F extends {
70
+ (...a: infer A1): infer R1;
71
+ (...a: infer A2): infer R2;
72
+ } ? [[A1, R1], [A2, R2]] : never;
62
73
  /** Every synchronous client method, returning a `Result<T, GhFailure>`. */
63
74
  export type TriedSync<C> = {
64
- [K in keyof Triable<C>]: Triable<C>[K] extends (...a: infer A) => infer R ? (...a: A) => Result<R, GhFailure> : never;
75
+ [K in keyof Triable<C>]: Signatures<Triable<C>[K]> extends [
76
+ [
77
+ infer A1 extends unknown[],
78
+ infer R1
79
+ ],
80
+ [
81
+ infer A2 extends unknown[],
82
+ infer R2
83
+ ]
84
+ ] ? {
85
+ (...a: A1): Result<R1, GhFailure>;
86
+ (...a: A2): Result<R2, GhFailure>;
87
+ } : never;
65
88
  };
66
89
  /** Every awaitable client method, promising a `Result<T, GhFailure>`. */
67
90
  export type TriedAsync<C> = {
68
- [K in keyof Triable<C>]: Triable<C>[K] extends (...a: infer A) => Promise<infer R> ? (...a: A) => Promise<Result<R, GhFailure>> : never;
91
+ [K in keyof Triable<C>]: Signatures<Triable<C>[K]> extends [
92
+ [
93
+ infer A1 extends unknown[],
94
+ Promise<infer R1>
95
+ ],
96
+ [
97
+ infer A2 extends unknown[],
98
+ Promise<infer R2>
99
+ ]
100
+ ] ? {
101
+ (...a: A1): Promise<Result<R1, GhFailure>>;
102
+ (...a: A2): Promise<Result<R2, GhFailure>>;
103
+ } : never;
69
104
  };
70
105
  /**
71
106
  * The queries that have a soft form. A mutation has none. If a write's failure
@@ -77,7 +112,19 @@ export type SoftMethod = (typeof SOFT_METHODS)[number];
77
112
  type OrNull<R> = R extends Promise<infer U> ? Promise<U | null> : R | null;
78
113
  /** Each queried method again, returning `null` instead of throwing. One type for both clients. */
79
114
  export type Soft<C> = {
80
- [K in SoftMethod & keyof C]: C[K] extends (...a: infer A) => infer R ? (...a: A) => OrNull<R> : never;
115
+ [K in SoftMethod & keyof C]: Signatures<C[K]> extends [
116
+ [
117
+ infer A1 extends unknown[],
118
+ infer R1
119
+ ],
120
+ [
121
+ infer A2 extends unknown[],
122
+ infer R2
123
+ ]
124
+ ] ? {
125
+ (...a: A1): OrNull<R1>;
126
+ (...a: A2): OrNull<R2>;
127
+ } : never;
81
128
  };
82
129
  /** The failure a caught `CommandFailed` or `FailureError` stands for. */
83
130
  export declare function failureOf(error: {
package/dist/doors.js CHANGED
@@ -28,9 +28,10 @@ import { classifyGhFailure, unreadableFailure } from "./failure.js";
28
28
  * A checked verb has already thrown on a non-zero exit (the runner is
29
29
  * {@link runCheckedGh}). A probe (`installed`, `authStatus`, `api`, `raw`)
30
30
  * carries its failure in its value. A read that exited non-zero throws the
31
- * classified failure, unless the verb's `absent` says gh's answer was "there
32
- * is no such thing", which `parse` turns into `null`. A read that exited 0 but
33
- * parsed to `null` could not be read completely, and throws.
31
+ * classified failure, unless the verb's `absent` says the result is still an
32
+ * answer: gh's "there is no such thing", which `parse` turns into `null`, or a
33
+ * partial `graphql` response. A read that exited 0 but parsed to `null` could
34
+ * not be read completely, and throws.
34
35
  *
35
36
  * A result flagged `outputIncomplete` throws for every verb but a probe, as
36
37
  * typed-git's `gate` does: gh exited, but the runner stopped reading at the
@@ -49,7 +50,7 @@ export function settle(verb, result, binary) {
49
50
  : runFailed(result, (r) => verb.absent?.(r) === true);
50
51
  if (failed)
51
52
  throw ghFailureError(new CommandFailed(verb.argv, result, binary));
52
- // A non-zero exit that got this far is gh's "there is no such thing".
53
+ // A non-zero exit that got this far is an answer the verb's `absent` accepted.
53
54
  if (verb.checked || result.exitCode !== 0)
54
55
  return verb.parse(result);
55
56
  const value = verb.parse(result);
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { classifyGhFailure, type GhFailure, type GhFailureKind } from './failure
23
23
  export { type Soft, type TriedAsync, type TriedSync } from './doors.ts';
24
24
  export { runVerb, runVerbAsync, type RunVerbAsyncOptions, type RunVerbOptions } from './run.ts';
25
25
  export { Gh, defaultRunnerFactory, type GhOptions, type GhRepoOptions, type RunnerFactory, } from './sync.ts';
26
- export { api, authStatus, createPr, editPr, editPrBody, fromTarget, graphql, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, nodesAt, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, raw, rawChecked, repoList, repoView, searchPrs, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, REPO_FIELDS, SEARCH_PR_FIELDS, type ApiOptions, type ApiResponse, type AuthStatus, type CreateLabelOptions, type CreatePrOptions, type CreateReviewOptions, type FromTarget, type GhTarget, type GraphqlOptions, type GraphqlVariable, type GhVerb, type IssueListOptions, type ListResult, type PrListOptions, type PrMergeMethod, type RepoListOptions, type ReviewComment, type ReviewThread, type SearchPrsOptions, } from './verbs.ts';
26
+ export { api, authStatus, createPr, editPr, editPrBody, fromTarget, graphql, createLabel, createReview, editLabel, installed, issueList, issueView, labelList, nodesAt, openPrsForBranch, pickPrForBranch, prBody, prComment, prSetDraft, issueCommentEdit, prDiff, prList, prMerge, prReviewThreads, prUpdateBranch, prView, raw, rawChecked, repoList, repoView, searchPrs, setIssueLabel, PR_LIST_FIELDS, PR_REF_FIELDS, PR_VIEW_FIELDS, REPO_FIELDS, SEARCH_PR_FIELDS, type ApiOptions, type ApiResponse, type AuthStatus, type CreateLabelOptions, type CreatePrOptions, type CreateReviewOptions, type FromTarget, type GhTarget, type GraphqlError, type GraphqlOptions, type GraphqlPage, type GraphqlPartialOptions, type GraphqlVariable, type GhVerb, type IssueListOptions, type ListResult, type PartialNodes, type PrListOptions, type PrMergeMethod, type RepoListOptions, type ReviewComment, type ReviewThread, type SearchPrsOptions, } from './verbs.ts';
27
27
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from './parse.ts';
28
28
  export { ownerRepoSlug, parseOwnerRepo, parsePrRef, parsePrUrl, type PrRef, type PullRequestRef, } from './repo.ts';
29
29
  export type { Actor, CheckConclusion, CheckRun, Comment, IssueRow, Label, MergeableState, MergeStateStatus, OwnerRepo, PullRequest, PrSearchHit, PullRequestState, Repository, RepositoryVisibility, Review, ReviewDecision, ReviewState, } from './types.ts';
package/dist/sync.d.ts CHANGED
@@ -202,10 +202,15 @@ export declare class Gh {
202
202
  }): ApiResponse;
203
203
  /**
204
204
  * A GraphQL query. Returns each page's `data`. Throws on a failed or partial
205
- * result. `soft.graphql` gives null. See the `graphql` verb.
205
+ * result. `soft.graphql` gives null. With `partial: true`, returns each
206
+ * page's `data` with its `errors`, and throws only when there is no data to
207
+ * read. See the `graphql` verb.
206
208
  *
207
209
  * @experimental
208
210
  */
211
+ graphql(query: string, variables: Record<string, v.GraphqlVariable> | undefined, opts: v.GraphqlPartialOptions & {
212
+ timeoutMs?: number | undefined;
213
+ }): v.GraphqlPage[];
209
214
  graphql(query: string, variables?: Record<string, v.GraphqlVariable>, opts?: v.GraphqlOptions & {
210
215
  timeoutMs?: number | undefined;
211
216
  }): unknown[];
package/dist/sync.js CHANGED
@@ -225,12 +225,6 @@ export class Gh {
225
225
  api(endpoint, opts = {}) {
226
226
  return this.exec(v.api(endpoint, opts, this.target), opts.timeoutMs);
227
227
  }
228
- /**
229
- * A GraphQL query. Returns each page's `data`. Throws on a failed or partial
230
- * result. `soft.graphql` gives null. See the `graphql` verb.
231
- *
232
- * @experimental
233
- */
234
228
  graphql(query, variables = {}, opts = {}) {
235
229
  return this.read(v.graphql(query, variables, opts, this.target), opts.timeoutMs);
236
230
  }
package/dist/verbs.d.ts CHANGED
@@ -42,10 +42,13 @@ export interface GhVerb<T> {
42
42
  */
43
43
  probe?: boolean | undefined;
44
44
  /**
45
- * For a read of one thing: whether a non-zero result means GitHub has no such
46
- * thing (`prView`, `issueView`, `repoView`). The plain method then returns
47
- * what `parse` makes of it, `null`, because a missing item is an answer.
48
- * Other non-zero results fail the call.
45
+ * Whether a non-zero result is still an answer. For a read of one thing, it
46
+ * says GitHub has no such thing (`prView`, `issueView`, `repoView`), and
47
+ * `parse` makes `null` of it, because a missing item is an answer. For
48
+ * `graphql` with `partial`, it says gh exited 1 over a response that
49
+ * carries `errors` alongside its `data`, and `parse` reads the pages. The
50
+ * plain method returns what `parse` makes of it. Other non-zero results
51
+ * fail the call.
49
52
  */
50
53
  absent?: ((result: CommandResult) => boolean) | undefined;
51
54
  parse(result: CommandResult): T;
@@ -261,9 +264,59 @@ export interface GraphqlOptions {
261
264
  /**
262
265
  * Follow every page (`--paginate --slurp`). The query must take
263
266
  * `$endCursor: String` and select `pageInfo { hasNextPage endCursor }` on
264
- * the one connection it pages through. gh follows only that connection.
267
+ * the one connection it pages through. gh follows only that connection, to
268
+ * its end. There is no page cap: to stop early, page yourself (see the
269
+ * README's "Reading every page").
265
270
  */
266
271
  paginate?: boolean | undefined;
272
+ /**
273
+ * Unset or `false`: a page with `errors` fails the call. Pass
274
+ * {@link GraphqlPartialOptions} to read partial pages instead.
275
+ */
276
+ partial?: false | undefined;
277
+ }
278
+ /**
279
+ * Options for {@link graphql} that read a partial answer: each page's `data`
280
+ * with the `errors` that came with it.
281
+ *
282
+ * @experimental
283
+ */
284
+ export interface GraphqlPartialOptions extends Omit<GraphqlOptions, 'partial'> {
285
+ /**
286
+ * Return each page as a {@link GraphqlPage}, so a page whose `data` came
287
+ * back with `errors` is still read. GitHub answers this way when part of a
288
+ * query fails: an aliased pull request that was deleted is `null` in
289
+ * `data`, and an error names its path.
290
+ */
291
+ partial: true;
292
+ }
293
+ /**
294
+ * One entry of a GraphQL response's `errors`.
295
+ *
296
+ * @experimental
297
+ */
298
+ export interface GraphqlError {
299
+ /** What GitHub said went wrong. `''` when the error had no message. */
300
+ readonly message: string;
301
+ /** GitHub's error type, such as `NOT_FOUND` or `FORBIDDEN`. `null` when it gave none. */
302
+ readonly type: string | null;
303
+ /**
304
+ * Where in `data` the error happened: field names (an alias, when the
305
+ * query used one) and list indexes, from the root. Empty when the error
306
+ * names no path, as one about the whole query does.
307
+ */
308
+ readonly path: readonly (string | number)[];
309
+ }
310
+ /**
311
+ * One page of a partial {@link graphql} answer: the page's `data`, and the
312
+ * `errors` that came with it. `errors` is empty for a page that came back
313
+ * whole. A field the errors name is `null` in `data`.
314
+ *
315
+ * @experimental
316
+ */
317
+ export interface GraphqlPage {
318
+ readonly data: unknown;
319
+ readonly errors: readonly GraphqlError[];
267
320
  }
268
321
  /**
269
322
  * `gh api graphql -f query=… [--paginate --slurp]`: a GraphQL query.
@@ -273,6 +326,16 @@ export interface GraphqlOptions {
273
326
  * carries `errors` or lacks `data`. A partial answer counts as a failure. Read one
274
327
  * connection across the pages with {@link nodesAt}.
275
328
  *
329
+ * With `partial: true` ({@link GraphqlPartialOptions}), returns each page as
330
+ * a {@link GraphqlPage}, its `data` with its `errors`, so a batch of aliased
331
+ * lookups keeps the ones that worked. gh exits 1 for a response with
332
+ * `errors`; the verb declares that result an answer (`absent`), so the
333
+ * methods that throw return it. It is still null when the output did not
334
+ * parse, a page has no `data` object, gh failed for another reason, or
335
+ * `paginate` stopped at a page with errors that says it has a next page. gh
336
+ * stops at the first page with errors, so the pages after it were never
337
+ * fetched.
338
+ *
276
339
  * A {@link fromTarget} variable is filled from the target's repo, or by gh
277
340
  * from the clone. A plain string is sent as written. A document holding any mutation or
278
341
  * subscription throws `RangeError`, as does a variable named `query` or
@@ -281,16 +344,41 @@ export interface GraphqlOptions {
281
344
  *
282
345
  * @experimental
283
346
  */
347
+ export declare function graphql(query: string, variables: Record<string, GraphqlVariable> | undefined, opts: GraphqlPartialOptions, target?: GhTarget): GhVerb<GraphqlPage[] | null>;
284
348
  export declare function graphql(query: string, variables?: Record<string, GraphqlVariable>, opts?: GraphqlOptions, target?: GhTarget): GhVerb<unknown[] | null>;
349
+ export declare function graphql(query: string, variables: Record<string, GraphqlVariable> | undefined, opts: GraphqlOptions | GraphqlPartialOptions, target?: GhTarget): GhVerb<unknown[] | GraphqlPage[] | null>;
285
350
  /**
286
351
  * The `nodes` of one connection, joined across {@link graphql} pages. It
287
352
  * follows `path` into each page and takes its `nodes` array. Null when `pages` is
288
353
  * null, or any page lacks the path or a `nodes` array, so a page that came
289
354
  * back short is never read as fewer rows.
290
355
  *
356
+ * With `{ partial: true }`, it reads the {@link GraphqlPage}s of a partial
357
+ * `graphql` call. It follows `path` into each page's `data`, and returns the
358
+ * joined `nodes` with every error of every page, as {@link PartialNodes}. A
359
+ * node that failed is `null` among the nodes, and its error's `path` names
360
+ * it. It is null on the same terms as the plain form: a connection that is
361
+ * itself `null` because of an error has no nodes to give, and the errors are
362
+ * on the pages.
363
+ *
291
364
  * @experimental
292
365
  */
293
- export declare function nodesAt(pages: unknown[] | null, path: readonly string[]): unknown[] | null;
366
+ export declare function nodesAt(pages: readonly GraphqlPage[] | null, path: readonly string[], opts: {
367
+ partial: true;
368
+ }): PartialNodes | null;
369
+ export declare function nodesAt(pages: readonly unknown[] | null, path: readonly string[]): unknown[] | null;
370
+ /**
371
+ * One connection's nodes across partial {@link graphql} pages, and the errors
372
+ * that came with them. What {@link nodesAt} returns with `{ partial: true }`.
373
+ *
374
+ * @experimental
375
+ */
376
+ export interface PartialNodes {
377
+ /** The joined `nodes`. A node GitHub could not resolve is `null`. */
378
+ readonly nodes: unknown[];
379
+ /** Every error of every page, in page order, including any outside `path`. */
380
+ readonly errors: readonly GraphqlError[];
381
+ }
294
382
  /** `gh --version`: checks that `gh` is installed. True when `gh` runs. */
295
383
  export declare function installed(): GhVerb<boolean>;
296
384
  /**
package/dist/verbs.js CHANGED
@@ -428,6 +428,87 @@ export function fromTarget(part) {
428
428
  }
429
429
  return Object.freeze({ fromTarget: part });
430
430
  }
431
+ /** One `errors` entry, read leniently: a string is its message, anything else counts with none. */
432
+ function readGraphqlError(entry) {
433
+ if (typeof entry === 'string')
434
+ return { message: entry, type: null, path: [] };
435
+ if (entry === null || typeof entry !== 'object')
436
+ return { message: '', type: null, path: [] };
437
+ const rec = entry;
438
+ const path = rec['path'];
439
+ const wellFormed = Array.isArray(path) && path.every((p) => typeof p === 'string' || typeof p === 'number');
440
+ return {
441
+ message: typeof rec['message'] === 'string' ? rec['message'] : '',
442
+ type: typeof rec['type'] === 'string' ? rec['type'] : null,
443
+ path: wellFormed ? path : [],
444
+ };
445
+ }
446
+ /**
447
+ * A page's `errors`. None when the field is missing or `null`. gh counts a
448
+ * string as an error, as an array of entries is, so both are read here.
449
+ */
450
+ function readGraphqlErrors(errors) {
451
+ if (errors === undefined || errors === null)
452
+ return [];
453
+ return Array.isArray(errors) ? errors.map(readGraphqlError) : [readGraphqlError(errors)];
454
+ }
455
+ /**
456
+ * True when anything in `value` says `hasNextPage: true`. gh's own test for
457
+ * another page reads the first `pageInfo` it meets. This one reads every
458
+ * `hasNextPage`, so it says yes whenever gh would have, and sometimes when it
459
+ * would not have.
460
+ */
461
+ function mentionsNextPage(value) {
462
+ if (Array.isArray(value))
463
+ return value.some(mentionsNextPage);
464
+ if (value === null || typeof value !== 'object')
465
+ return false;
466
+ const rec = value;
467
+ if (rec['hasNextPage'] === true)
468
+ return true;
469
+ return Object.values(rec).some(mentionsNextPage);
470
+ }
471
+ /**
472
+ * Read `gh api graphql` output as partial pages, or `null` when it is no
473
+ * answer. Every page must be an object whose `data` is an object.
474
+ *
475
+ * gh exits 1 when a response carries `errors`, after printing it. So a
476
+ * non-zero exit is an answer only when the last page has errors, which is
477
+ * why gh stopped. Any other non-zero exit is a failure that left some output
478
+ * behind, such as earlier pages before a network error. With `paginate`, gh
479
+ * also stops at the first page with errors. When that page says there was a
480
+ * next one, the pages after it were never fetched, so the answer is short and
481
+ * is not read.
482
+ */
483
+ function readPartialPages(r, paginate) {
484
+ let body;
485
+ try {
486
+ body = JSON.parse(r.stdout);
487
+ }
488
+ catch {
489
+ return null;
490
+ }
491
+ const pages = paginate ? body : [body];
492
+ if (!Array.isArray(pages))
493
+ return null;
494
+ const out = [];
495
+ for (const page of pages) {
496
+ if (page === null || typeof page !== 'object')
497
+ return null;
498
+ const rec = page;
499
+ if (rec['data'] === null || typeof rec['data'] !== 'object')
500
+ return null;
501
+ out.push({ data: rec['data'], errors: readGraphqlErrors(rec['errors']) });
502
+ }
503
+ if (r.exitCode !== 0) {
504
+ const last = out.at(-1);
505
+ if (last === undefined || last.errors.length === 0)
506
+ return null;
507
+ if (paginate && mentionsNextPage(last.data))
508
+ return null;
509
+ }
510
+ return out;
511
+ }
431
512
  /**
432
513
  * True when any operation in a GraphQL document is a `mutation` or a
433
514
  * `subscription`. Strings and comments are dropped first, and only the words
@@ -449,22 +530,6 @@ function writesOrStreams(document) {
449
530
  }
450
531
  return false;
451
532
  }
452
- /**
453
- * `gh api graphql -f query=… [--paginate --slurp]`: a GraphQL query.
454
- *
455
- * Returns each page's `data`, one entry per page (one without `paginate`).
456
- * Returns null when the call failed, the output did not parse, or any page
457
- * carries `errors` or lacks `data`. A partial answer counts as a failure. Read one
458
- * connection across the pages with {@link nodesAt}.
459
- *
460
- * A {@link fromTarget} variable is filled from the target's repo, or by gh
461
- * from the clone. A plain string is sent as written. A document holding any mutation or
462
- * subscription throws `RangeError`, as does a variable named `query` or
463
- * `operationName`, which gh would send as the request's own fields. This is a
464
- * read, and changes go through `raw`.
465
- *
466
- * @experimental
467
- */
468
533
  export function graphql(query, variables = {}, opts = {}, target) {
469
534
  checkTarget(target);
470
535
  if (writesOrStreams(query)) {
@@ -505,6 +570,25 @@ export function graphql(query, variables = {}, opts = {}, target) {
505
570
  }
506
571
  }
507
572
  argv.push('-f', `query=${query}`);
573
+ if (opts.partial === true) {
574
+ // `settle` calls `absent` and then `parse` on the same result, so each
575
+ // result's output is parsed once and kept here.
576
+ const read = new WeakMap();
577
+ const pagesOf = (r) => {
578
+ if (!read.has(r))
579
+ read.set(r, readPartialPages(r, paginate));
580
+ return read.get(r) ?? null;
581
+ };
582
+ return {
583
+ argv,
584
+ checked: false,
585
+ readOnly: true,
586
+ // A response with errors exits 1. When it still holds the pages, it is
587
+ // the answer, not a failure.
588
+ absent: (r) => pagesOf(r) !== null,
589
+ parse: pagesOf,
590
+ };
591
+ }
508
592
  return {
509
593
  argv,
510
594
  checked: false,
@@ -537,20 +621,25 @@ export function graphql(query, variables = {}, opts = {}, target) {
537
621
  },
538
622
  };
539
623
  }
540
- /**
541
- * The `nodes` of one connection, joined across {@link graphql} pages. It
542
- * follows `path` into each page and takes its `nodes` array. Null when `pages` is
543
- * null, or any page lacks the path or a `nodes` array, so a page that came
544
- * back short is never read as fewer rows.
545
- *
546
- * @experimental
547
- */
548
- export function nodesAt(pages, path) {
624
+ export function nodesAt(pages, path, opts = {}) {
549
625
  if (pages === null)
550
626
  return null;
551
- const out = [];
627
+ const partial = opts.partial === true;
628
+ const nodes = [];
629
+ const errors = [];
552
630
  for (const page of pages) {
553
631
  let at = page;
632
+ if (partial) {
633
+ if (page === null || typeof page !== 'object')
634
+ return null;
635
+ const { data, errors: pageErrors } = page;
636
+ if (!Array.isArray(pageErrors))
637
+ return null;
638
+ // Read each entry as `graphql` does, so a hand-built page cannot pass
639
+ // a malformed error through as a GraphqlError.
640
+ errors.push(...pageErrors.map(readGraphqlError));
641
+ at = data;
642
+ }
554
643
  for (const key of path) {
555
644
  if (at === null || typeof at !== 'object')
556
645
  return null;
@@ -558,12 +647,12 @@ export function nodesAt(pages, path) {
558
647
  }
559
648
  if (at === null || typeof at !== 'object')
560
649
  return null;
561
- const nodes = at['nodes'];
562
- if (!Array.isArray(nodes))
650
+ const found = at['nodes'];
651
+ if (!Array.isArray(found))
563
652
  return null;
564
- out.push(...nodes);
653
+ nodes.push(...found);
565
654
  }
566
- return out;
655
+ return partial ? { nodes, errors } : nodes;
567
656
  }
568
657
  /** Split one header block off the front of `text`: the block, and what follows its blank line. */
569
658
  function takeHeaderBlock(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@osdlc/command-seam": "^0.14.0",
30
- "@osdlc/typed-git": "^0.12.0",
30
+ "@osdlc/typed-git": "^0.13.0",
31
31
  "@osdlc/result": "^0.1.0"
32
32
  },
33
33
  "engines": {