@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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d558059: Reach a repository without a clone, find a pull request from what a person typed, read every page of a list, and list repositories and search pull requests. Some changes break callers of 0.5. They are marked `Breaking:`.
8
+
9
+ - `Gh` and `AsyncGh` take optional `repo` (`owner/name`) and `hostname` options, and `withRepo(repo)` returns the same client aimed at another repository. The target goes into each call's argv: `--repo host/owner/name` on `pr`, `issue` and `label` calls, `--hostname` and a filled `{owner}/{repo}` on `api` calls, and `--hostname` on `authStatus`. Every verb builder takes an optional `GhTarget` as its last argument. An invalid `repo` or `hostname` throws `RangeError`.
10
+ - `hostname` is any GitHub host: `github.com`, GitHub Enterprise Server, or a `<tenant>.ghe.com` host.
11
+ - Breaking: `parsePrUrl` accepts any GitHub host and returns `hostname` in its `PullRequestRef`. Before, it returned null for a host other than `github.com`.
12
+ - `prView` takes a number, a branch or a URL. A URL is passed without the client's target. A string that starts with `-` throws `RangeError`.
13
+ - `prFind(input)` returns the one PR that `input` names, and `parsePrRef(input)` reads it without running `gh`: `42`, `#42`, `owner/name#42`, a URL, or a branch name. Bare digits are a number first and then a branch. A branch gives its open PR, else its newest closed or merged one.
14
+ - Breaking: `prList({ head })` matches the head branch exactly, through `gh pr list --head`. Before, it passed `--search head:<branch>`, which matched by prefix.
15
+ - Breaking: `prList`, `issueList` and `labelList` return `{ rows, truncated } | null` instead of an array or null. Each asks `gh` for one row more than `limit`, and `truncated` is true when it came back. A `limit` that is not a positive integer throws `RangeError`. `openPrsForBranch` returns the same shape. The new `ListResult` type names it.
16
+ - `api` takes `paginate: true` to fetch every page; pages of arrays are joined into one `body`. With `etag` or `includeHeaders` it throws `RangeError`. An endpoint that starts with `-` throws `RangeError`.
17
+ - `graphql(query, variables, { paginate })` runs a GraphQL query and returns each page's `data`, or null when the call failed or any page has `errors` or no `data`. It refuses a mutation. `nodesAt(pages, path)` joins one connection's nodes across pages. Both are experimental, with `GraphqlOptions` and `GraphqlVariable`.
18
+ - Every `GhVerb` carries `readOnly`: true for every query and `prDiff`, false for every mutation. `GhVerb` also has an optional `env`.
19
+ - `repoList(owner)` and `repoView(repo?)` return the new `Repository` type. `searchPrs(query)` returns `PrSearchHit` rows, since `gh search prs` has its own field set. `REPO_FIELDS` and `SEARCH_PR_FIELDS` name their fields. `gh repo list` and `gh search` take no host flag, so the host reaches them as `GH_HOST`.
20
+ - New types: `GhTarget`, `GhOptions`, `AsyncGhOptions`, `ListResult`, `PrRef`, `Repository`, `RepositoryVisibility`, `PrSearchHit`, `RepoListOptions` and `SearchPrsOptions`.
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies [4d13bd9]
25
+ - @osdlc/typed-git@0.5.3
26
+
3
27
  ## 0.5.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -25,7 +25,7 @@ import { Gh, type PrListOptions, type PullRequest } from '@osdlc/typed-gh'
25
25
  const gh = new Gh(process.cwd())
26
26
 
27
27
  const filter: PrListOptions = { state: 'open', base: 'main' }
28
- const prs: PullRequest[] = gh.prList(filter) ?? []
28
+ const prs: PullRequest[] = gh.prList(filter)?.rows ?? []
29
29
  ```
30
30
 
31
31
  The `examples` folder in the repository runs the same calls as tests.
@@ -36,8 +36,8 @@ The `examples` folder in the repository runs the same calls as tests.
36
36
  Octokit.
37
37
  - **You sign in with a token, a GitHub App or OAuth.** This package reads no token.
38
38
  - **You make many calls or poll in a loop.** Each call starts a `gh` process.
39
- - **You need the whole API typed.** This package covers the calls listed below. `api()` is a GET.
40
- Anything else goes through `raw`.
39
+ - **You need the whole API typed.** This package covers the calls listed below. `api()` is a GET
40
+ and `graphql()` is a query. Anything else goes through `raw`.
41
41
 
42
42
  ## How it compares
43
43
 
@@ -96,9 +96,79 @@ that decides whether to merge can then match every member.
96
96
  | **mutations** | throw `CommandFailed`, carrying argv and the full result. |
97
97
  | **`raw(argv)`** | hand back the raw `CommandResult`; you decide. |
98
98
 
99
- A query returns `null`, never `[]` on failure. `prList()` returning `[]` means
100
- *this repository has no open PRs*. `null` means *we could not ask*. If the two
101
- were the same, an expired credential would read as "nothing to do".
99
+ A query returns `null`, never an empty list on failure. `prList()` returning
100
+ `{ rows: [], truncated: false }` means *this repository has no open PRs*.
101
+ `null` means *we could not ask*. If the two were the same, an expired
102
+ credential would read as "nothing to do".
103
+
104
+ ## A list says when its limit cut it short
105
+
106
+ `gh --limit` cuts a list without saying so. Every list query here asks for
107
+ one row more than `limit` and returns `{ rows, truncated }`: at most `limit`
108
+ rows, and `truncated: true` when there were more.
109
+
110
+ ```ts
111
+ const page: ListResult<PullRequest> | null = gh.prList({ limit: 50 })
112
+
113
+ if (page?.truncated) console.warn('more than 50 open PRs; raise the limit')
114
+ ```
115
+
116
+ `prList`, `issueList`, `labelList`, `repoList` and `searchPrs` all work this
117
+ way. A `limit` that is not a positive integer throws `RangeError`.
118
+
119
+ ## A repository without a clone
120
+
121
+ By default a client talks to the repository of the clone at its directory.
122
+ Name a repository, a host, or both, and every call targets them instead, so
123
+ no clone is needed:
124
+
125
+ ```ts
126
+ const acme = new Gh(process.cwd(), { repo: 'acme/widgets', hostname: 'github.acme.com' })
127
+
128
+ acme.prView(42)
129
+ acme.withRepo('acme/gadgets').prList()
130
+ acme.api('repos/{owner}/{repo}/branches')
131
+ ```
132
+
133
+ The target goes into the argv, so a `CommandFailed` shows which repository a
134
+ call hit: `pr`, `issue` and `label` calls get `--repo github.acme.com/acme/widgets`,
135
+ `api` calls get `--hostname` and a filled `{owner}/{repo}`, and `authStatus`
136
+ asks about that host. `repoList` and `searchPrs` have no host flag in gh, so
137
+ they get `GH_HOST` in the environment. `withRepo` and `at` keep the runner and
138
+ the rest of the target.
139
+
140
+ `hostname` is any GitHub host: `github.com`, a GitHub Enterprise Server host,
141
+ or a `<tenant>.ghe.com` host. gh speaks only GitHub's API, so Forgejo, Gitea
142
+ and GitLab are out of reach. A `repo` that is not `owner/name`, or a
143
+ `hostname` that is not a bare host, throws `RangeError`.
144
+
145
+ ## Finding a pull request
146
+
147
+ `prView` takes a number, a branch or a URL. A URL names its own repository
148
+ and host, so the client's target is not added to it.
149
+
150
+ ```ts
151
+ gh.prView(42)
152
+ gh.prView('feature/login')
153
+ gh.prView('https://github.acme.com/acme/widgets/pull/42')
154
+ ```
155
+
156
+ `prFind` takes what a person typed and runs the lookup it needs:
157
+
158
+ ```ts
159
+ const pr: PullRequest | null = gh.prFind(process.argv[2] ?? '')
160
+ ```
161
+
162
+ It reads the text with `parsePrRef`, which starts no process: `42` and `#42`
163
+ are numbers, `owner/name#42` is a number in that repository, a URL is a URL,
164
+ and any other valid branch name is a branch. Bare digits are tried as a PR
165
+ number first and then as a branch, since a branch can be named `42`. A branch
166
+ gives its open PR, or else its newest closed or merged one, so a tool run
167
+ after the merge still finds it.
168
+
169
+ `prList({ head })` matches the head branch exactly: `task/foo` never returns
170
+ `task/foo-2`. Two forks can share a branch name; ask for
171
+ `headRepositoryOwner` to tell them apart.
102
172
 
103
173
  ## Two shape hazards it absorbs
104
174
 
@@ -184,10 +254,10 @@ threads?.filter((t) => !t.isResolved)
184
254
 
185
255
  ```ts
186
256
  const issue: IssueRow | null = gh.issueView(7)
187
- const triage: IssueRow[] | null = gh.issueList({ label: 'status:triage', limit: 50 })
257
+ const triage: ListResult<IssueRow> | null = gh.issueList({ label: 'status:triage', limit: 50 })
188
258
  gh.setIssueLabel(7, 'status:triage', 'status:define')
189
259
 
190
- const labels: Label[] | null = gh.labelList({ limit: 100 })
260
+ const labels: ListResult<Label> | null = gh.labelList({ limit: 100 })
191
261
  gh.createLabel({ name: 'status:triage', color: '1f6feb', description: 'Triage stage' })
192
262
  gh.editLabel('status:triage', { description: 'Waiting for triage' })
193
263
  ```
@@ -253,8 +323,9 @@ await gh.prComment(42, 'rebased') // the comment's URL, or ''
253
323
  await gh.prUpdateBranch(42, 'rebase')
254
324
  ```
255
325
 
256
- These methods take `AsyncCallOptions`: `raw`, `rawChecked`, `prList`, `prView`, `openPrFor`, `prViewSettled`, `prBody`, `api`,
257
- `labelList`, `issueList` and `issueView`. The options are `timeoutMs` and an `AbortSignal` in
326
+ These methods take `AsyncCallOptions`: `raw`, `rawChecked`, `prList`, `prView`, `prFind`,
327
+ `openPrFor`, `prViewSettled`, `prBody`, `api`, `graphql`, `labelList`, `issueList`, `issueView`,
328
+ `repoList`, `repoView` and `searchPrs`. The options are `timeoutMs` and an `AbortSignal` in
258
329
  `signal`.
259
330
 
260
331
  `api(endpoint, opts)` (on `Gh` and `AsyncGh`) is a REST GET through `gh api`.
@@ -263,7 +334,50 @@ It never throws; the result is `{ ok, status, etag, body, malformed, stderr, err
263
334
  was stopped (a missing binary, a timeout).
264
335
  Pass `etag` for a conditional request (`If-None-Match`; a 304 comes back as
265
336
  `status: 304, body: null`), or `includeHeaders: true` to read status and ETag
266
- without one. If `gh` prints more than one header block, the last one is read. `{owner}` and `{repo}` in the endpoint are filled in by gh.
337
+ without one. If `gh` prints more than one header block, the last one is read. `{owner}` and
338
+ `{repo}` in the endpoint come from the client's repo, or are filled in by gh from the clone.
339
+
340
+ ## Reading every page
341
+
342
+ `api` with `paginate: true` fetches every page and, when each page is an array, joins them into one
343
+ `body`:
344
+
345
+ ```ts
346
+ const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).body
347
+ ```
348
+
349
+ `graphql` (experimental) runs a query and returns each page's `data`. It returns `null` when the
350
+ call failed or any page has `errors` or no `data`: a partial answer is no answer. `nodesAt` joins
351
+ one connection's `nodes` across the pages, and is `null` when any page lacks them.
352
+
353
+ ```ts
354
+ const query = `query($owner: String!, $name: String!, $endCursor: String) {
355
+ repository(owner: $owner, name: $name) {
356
+ pullRequests(first: 100, after: $endCursor) {
357
+ pageInfo { hasNextPage endCursor }
358
+ nodes { number title }
359
+ }
360
+ }
361
+ }`
362
+ const pages = gh.graphql(query, { owner: '{owner}', name: '{repo}' }, { paginate: true })
363
+ const prs = nodesAt(pages, ['repository', 'pullRequests'])
364
+ ```
365
+
366
+ With `paginate`, the query must take `$endCursor` and page one connection; gh follows only that
367
+ one. `graphql` refuses a mutation with `RangeError`; send one through `raw`.
368
+
369
+ ## Repositories and search
370
+
371
+ ```ts
372
+ const repos: ListResult<Repository> | null = gh.repoList('acme', { archived: false, limit: 200 })
373
+ const repo: Repository | null = gh.repoView('acme/widgets')
374
+ const hits: ListResult<PrSearchHit> | null = gh.searchPrs('TICKET-123', { owner: 'acme' })
375
+ ```
376
+
377
+ A `Repository` carries `nameWithOwner`, `owner`, `name`, `description`, `defaultBranch`,
378
+ `visibility`, `isArchived` and `url`. `gh search prs` has its own field set: a `PrSearchHit` names
379
+ its `repository` but has no head or base branch, so it is not a `PullRequest`. Its `state` is
380
+ upper-cased to match. The query takes GitHub's search syntax and may start with `-`.
267
381
 
268
382
  ## Injecting the subprocess seam
269
383
 
@@ -274,7 +388,7 @@ suite runs offline:
274
388
  ```ts
275
389
  const empty: CommandRunner = { run: () => ({ exitCode: 0, stdout: '[]', stderr: '' }) }
276
390
 
277
- new Gh('/repo', { runner: empty }).prList() // []
391
+ new Gh('/repo', { runner: empty }).prList() // { rows: [], truncated: false }
278
392
  ```
279
393
 
280
394
  This package re-exports `CommandFailed` and the runner and result types (`CommandRunner`,
@@ -289,42 +403,52 @@ experimental.
289
403
 
290
404
  ### Clients
291
405
 
292
- - `Gh`: the synchronous client, bound to one working directory.
406
+ - `Gh`: the synchronous client, bound to one working directory, and optionally a repository and
407
+ host.
293
408
  - `AsyncGh`: the awaitable client, with the same methods.
294
- - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by eleven methods of `AsyncGh`.
409
+ - `GhOptions` and `AsyncGhOptions`: the runner, `repo` and `hostname` a client is built with.
410
+ - `AsyncCallOptions`: `timeoutMs` and `signal`, accepted by sixteen methods of `AsyncGh`.
295
411
  - `defaultRunnerFactory` and `RunnerFactory`: how a client makes its default runner for `gh`.
296
412
 
297
413
  Methods on both clients:
298
414
 
299
415
  - Escape hatches: `raw` runs any `gh` call and returns the result. `rawChecked` throws on a failed
300
- exit. `at` returns the same client rooted at another directory.
301
- - Pull request queries: `prList`, `prView`, `openPrFor`, `prViewSettled`, `prBody` and
302
- `prReviewThreads`.
416
+ exit. `at` returns the same client rooted at another directory, and `withRepo` the same client
417
+ aimed at another repository.
418
+ - Pull request queries: `prList`, `prView`, `prFind`, `openPrFor`, `prViewSettled`, `prBody`,
419
+ `prReviewThreads` and `searchPrs`.
303
420
  - Pull request changes: `createPr`, `editPr`, `editPrBody`, `prComment`, `prUpdateBranch`,
304
421
  `prSetDraft`, `prMerge`, `prDiff` and `createReview`.
305
422
  - Issue and label queries: `issueList`, `issueView` and `labelList`.
423
+ - Repository queries: `repoList` and `repoView`.
306
424
  - Issue and label changes: `setIssueLabel`, `createLabel`, `editLabel` and `issueCommentEdit`.
307
- - REST: `api` is a GET through `gh api` that never throws.
425
+ - REST and GraphQL: `api` is a GET through `gh api` and `graphql` a query; neither throws.
308
426
  - Setup checks: `installed`, `authenticated`, `authStatus` and `ready`.
309
427
  - `ownerRepo` reads the owner and repository from a remote URL. It starts no process.
310
428
 
311
429
  ### Verbs
312
430
 
313
- A verb is plain data: argv, a `checked` flag, optional stdin and a `parse` function. Each client
314
- method has a verb of the same name, except the escape hatches, `at`, `prViewSettled`, `openPrFor`,
315
- `authenticated`, `ready` and `ownerRepo`. `openPrsForBranch` is a verb with no method.
431
+ A verb is plain data: argv, a `checked` flag, a `readOnly` flag, optional stdin and environment,
432
+ and a `parse` function. `readOnly` is true for every query and `prDiff`, and false for every
433
+ mutation, so a caller that keeps an allowlist of safe calls can check a verb before running it.
434
+ Each verb takes an optional `GhTarget` last. Each client method has a verb of the same name, except
435
+ the escape hatches, `at`, `withRepo`, `prViewSettled`, `openPrFor`, `prFind`, `authenticated`,
436
+ `ready` and `ownerRepo`. `openPrsForBranch` is a verb with no method.
316
437
 
317
- - `GhVerb`: the shape of a verb.
438
+ - `GhVerb`: the shape of a verb. `GhTarget`: the `repo` and `hostname` a verb talks to.
318
439
  - Verb builders: `prList`, `prView`, `prBody`, `openPrsForBranch`, `prReviewThreads`, `prDiff`,
319
440
  `createPr`, `editPr`, `editPrBody`, `prComment`, `prUpdateBranch`, `prSetDraft`, `prMerge`,
320
441
  `createReview`, `issueList`, `issueView`, `labelList`, `setIssueLabel`, `createLabel`,
321
- `editLabel`, `issueCommentEdit`, `api`, `installed` and `authStatus`.
322
- - `pickPrForBranch`: the row whose head branch is exactly the one you name. `gh` matches a head
323
- filter as a prefix.
324
- - Field lists: `PR_REF_FIELDS` (enough to identify a PR), `PR_LIST_FIELDS` (for triage) and
325
- `PR_VIEW_FIELDS` (the full set).
326
- - Option and result types: `PrListOptions`, `IssueListOptions`, `CreatePrOptions`,
327
- `CreateReviewOptions`, `ReviewComment`, `CreateLabelOptions`, `ApiOptions`, `ApiResponse`,
442
+ `editLabel`, `issueCommentEdit`, `repoList`, `repoView`, `searchPrs`, `api`, `graphql`,
443
+ `installed` and `authStatus`.
444
+ - `pickPrForBranch`: the first row whose head branch is exactly the one you name, for rows that
445
+ came from somewhere other than `prList({ head })`, such as a `head:` search.
446
+ - `nodesAt`: one connection's nodes, joined across `graphql` pages.
447
+ - Field lists: `PR_REF_FIELDS` (enough to identify a PR), `PR_LIST_FIELDS` (for triage),
448
+ `PR_VIEW_FIELDS` (the full set), `REPO_FIELDS` and `SEARCH_PR_FIELDS`.
449
+ - Option and result types: `ListResult`, `PrListOptions`, `IssueListOptions`, `RepoListOptions`,
450
+ `SearchPrsOptions`, `CreatePrOptions`, `CreateReviewOptions`, `ReviewComment`,
451
+ `CreateLabelOptions`, `ApiOptions`, `ApiResponse`, `GraphqlOptions`, `GraphqlVariable`,
328
452
  `AuthStatus`, `PrMergeMethod` and `ReviewThread`.
329
453
 
330
454
  ### Parsers
@@ -334,14 +458,18 @@ method has a verb of the same name, except the escape hatches, `at`, `prViewSett
334
458
  - `normalizeAuthorLogin`: drops a `[bot]` suffix from a login.
335
459
  - `parseOwnerRepo`: reads a remote URL into `{ owner, repo }`, or `null`.
336
460
  - `ownerRepoSlug`: writes `{ owner, repo }` as `owner/repo`.
337
- - `parsePrUrl`: reads a pull request URL into its owner, repository and number, or `null`.
461
+ - `parsePrUrl`: reads a pull request URL on any GitHub host into its host, owner, repository and
462
+ number, or `null`.
463
+ - `parsePrRef`: reads what a person typed to name a pull request into a `PrRef`, or `null`.
338
464
 
339
465
  ### Types
340
466
 
341
- - Model: `PullRequest`, `Review`, `Comment`, `CheckRun`, `Actor`, `Label` and `IssueRow`.
342
- - Enums: `PullRequestState`, `MergeableState`, `MergeStateStatus`, `ReviewDecision`, `ReviewState`
343
- and `CheckConclusion`.
344
- - `OwnerRepo` and `PullRequestRef`: a repository, and a pull request in one.
467
+ - Model: `PullRequest`, `Review`, `Comment`, `CheckRun`, `Actor`, `Label`, `IssueRow`,
468
+ `Repository` and `PrSearchHit`.
469
+ - Enums: `PullRequestState`, `MergeableState`, `MergeStateStatus`, `ReviewDecision`, `ReviewState`,
470
+ `CheckConclusion` and `RepositoryVisibility`.
471
+ - `OwnerRepo`, `PullRequestRef` and `PrRef`: a repository, a pull request in one, and what a person
472
+ typed to name a pull request.
345
473
 
346
474
  ### From the command seam
347
475
 
@@ -364,6 +492,10 @@ The experimental items:
364
492
  - `createReview`, the verb and the method on both clients
365
493
  - `CreateReviewOptions`
366
494
  - `ReviewComment`
495
+ - `graphql`, the verb and the method on both clients
496
+ - `nodesAt`
497
+ - `GraphqlOptions`
498
+ - `GraphqlVariable`
367
499
 
368
500
  Every other export and method is stable, `prDiff` included.
369
501
 
@@ -376,8 +508,8 @@ shell, so nothing in it is specific to one shell.
376
508
 
377
509
  - **Closing a PR and deleting a branch.** `prMerge` merges and leaves the branch alone. Closing and
378
510
  deleting belong to a caller's own policy layer, where the audit trail lives.
379
- - **A full REST client.** `api()` is a GET; anything else goes through
380
- `raw(["api", …])`.
511
+ - **A full REST or GraphQL client.** `api()` is a GET and `graphql()` a query; anything else goes
512
+ through `raw(["api", …])`.
381
513
  - **Anything that reads a token.**
382
514
 
383
515
  ## Licence
package/async.ts CHANGED
@@ -16,22 +16,24 @@ import {
16
16
  type CommandResult,
17
17
  } from '@osdlc/command-seam'
18
18
 
19
- import { parseOwnerRepo } from './repo.ts'
20
- import type { IssueRow, Label, OwnerRepo, PullRequest } from './types.ts'
19
+ import { ownerRepoSlug, parseOwnerRepo, parsePrRef } from './repo.ts'
20
+ import type { IssueRow, Label, OwnerRepo, PrSearchHit, PullRequest, Repository } from './types.ts'
21
21
  import * as v from './verbs.ts'
22
22
  import type {
23
23
  ApiOptions,
24
24
  ApiResponse,
25
25
  AuthStatus,
26
+ GhTarget,
26
27
  GhVerb,
27
28
  IssueListOptions,
29
+ ListResult,
28
30
  PrListOptions,
29
31
  } from './verbs.ts'
30
32
 
31
33
  /**
32
- * Per-call options for {@link AsyncGh}. Eleven methods accept them: `raw`, `rawChecked`,
33
- * `prList`, `prView`, `openPrFor`, `prViewSettled`, `prBody`, `api`, `labelList`,
34
- * `issueList` and `issueView`.
34
+ * Per-call options for {@link AsyncGh}. Sixteen methods accept them: `raw`, `rawChecked`,
35
+ * `prList`, `prView`, `prFind`, `openPrFor`, `prViewSettled`, `prBody`, `api`, `graphql`,
36
+ * `labelList`, `issueList`, `issueView`, `repoList`, `repoView` and `searchPrs`.
35
37
  */
36
38
  export interface AsyncCallOptions {
37
39
  /**
@@ -47,19 +49,39 @@ export interface AsyncCallOptions {
47
49
  /** How long to wait between merge-state retries, in milliseconds. */
48
50
  const MERGE_STATE_RETRY_DELAY_MS = 1_500
49
51
 
50
- /** A typed, awaitable gh client bound to one working directory. */
52
+ /** What an {@link AsyncGh} is built with: the runner, and the repository and host it talks to. */
53
+ export interface AsyncGhOptions extends GhTarget {
54
+ runner?: AsyncCommandRunner | undefined
55
+ }
56
+
57
+ /**
58
+ * A typed, awaitable gh client bound to one working directory, and optionally
59
+ * to one repository and host. `repo` and `hostname` work as on `Gh`.
60
+ */
51
61
  export class AsyncGh {
52
62
  readonly cwd: string
63
+ readonly repo: string | undefined
64
+ readonly hostname: string | undefined
65
+ private readonly target: GhTarget
53
66
  private readonly runner: AsyncCommandRunner
54
67
 
55
- constructor(cwd: string, opts: { runner?: AsyncCommandRunner | undefined } = {}) {
68
+ constructor(cwd: string, opts: AsyncGhOptions = {}) {
69
+ v.checkTarget(opts)
56
70
  this.cwd = cwd
71
+ this.repo = opts.repo
72
+ this.hostname = opts.hostname
73
+ this.target = { repo: opts.repo, hostname: opts.hostname }
57
74
  this.runner = resolveRunnerAsync('gh', opts.runner)
58
75
  }
59
76
 
60
- /** The same client rooted at a different directory (same runner). */
77
+ /** The same client rooted at a different directory (same runner, repo and host). */
61
78
  at(cwd: string): AsyncGh {
62
- return new AsyncGh(cwd, { runner: this.runner })
79
+ return new AsyncGh(cwd, { runner: this.runner, repo: this.repo, hostname: this.hostname })
80
+ }
81
+
82
+ /** The same client aimed at another repository (same runner, directory and host). */
83
+ withRepo(repo: string): AsyncGh {
84
+ return new AsyncGh(this.cwd, { runner: this.runner, repo, hostname: this.hostname })
63
85
  }
64
86
 
65
87
  // ── escape hatches ────────────────────────────────────────────────────────
@@ -78,6 +100,7 @@ export class AsyncGh {
78
100
  private async exec<T>(verb: GhVerb<T>, call: AsyncCallOptions = {}): Promise<T> {
79
101
  const opts: AsyncCommandRunOptions = { cwd: this.cwd }
80
102
  if (verb.input !== undefined) opts.input = verb.input
103
+ if (verb.env !== undefined) opts.env = verb.env
81
104
  if (call.timeoutMs !== undefined) opts.timeoutMs = call.timeoutMs
82
105
  if (call.signal !== undefined) opts.signal = call.signal
83
106
  const result = verb.checked
@@ -89,16 +112,31 @@ export class AsyncGh {
89
112
  // ── queries ───────────────────────────────────────────────────────────────
90
113
 
91
114
  /** Pull requests matching `opts`, or null when gh failed. */
92
- prList(opts: PrListOptions & AsyncCallOptions = {}): Promise<PullRequest[] | null> {
93
- return this.exec(v.prList(opts), opts)
115
+ prList(opts: PrListOptions & AsyncCallOptions = {}): Promise<ListResult<PullRequest> | null> {
116
+ return this.exec(v.prList(opts, this.target), opts)
94
117
  }
95
118
 
96
- /** One PR with the heavy fields, or null when gh failed. */
119
+ /** One PR by number, branch or URL, with the heavy fields, or null. See `Gh.prView`. */
97
120
  prView(
98
- number: number,
121
+ ref: number | string,
99
122
  opts: { fields?: readonly string[] | undefined } & AsyncCallOptions = {},
100
123
  ): Promise<PullRequest | null> {
101
- return this.exec(v.prView(number, opts.fields ?? v.PR_VIEW_FIELDS), opts)
124
+ return this.exec(v.prView(ref, opts.fields ?? v.PR_VIEW_FIELDS, this.target), opts)
125
+ }
126
+
127
+ /** The one PR that `input` names, or null. See `Gh.prFind`. */
128
+ async prFind(
129
+ input: string,
130
+ opts: { fields?: readonly string[] | undefined } & AsyncCallOptions = {},
131
+ ): Promise<PullRequest | null> {
132
+ const ref = parsePrRef(input)
133
+ if (ref === null) return null
134
+ const call = { ...opts, fields: opts.fields ?? v.PR_LIST_FIELDS }
135
+ if (ref.kind === 'url') return this.prView(ref.url, call)
136
+ if (ref.kind === 'branch') return prForBranchOn(this, ref.name, call)
137
+ const client = ref.repo === null ? this : this.withRepo(ownerRepoSlug(ref.repo))
138
+ const pr = await client.prView(ref.number, call)
139
+ return pr !== null || ref.orBranch === null ? pr : prForBranchOn(this, ref.orBranch, call)
102
140
  }
103
141
 
104
142
  /** The open PR whose head exactly equals `branch`, or null. See `Gh.openPrFor`. */
@@ -116,7 +154,7 @@ export class AsyncGh {
116
154
  timeoutMs: opts.timeoutMs,
117
155
  signal: opts.signal,
118
156
  })
119
- return v.pickPrForBranch(rows, branch)
157
+ return v.pickPrForBranch(rows?.rows ?? null, branch)
120
158
  }
121
159
 
122
160
  /** {@link prView}, re-asked until GitHub settles the merge state. See `Gh.prViewSettled`. */
@@ -141,27 +179,66 @@ export class AsyncGh {
141
179
 
142
180
  /** A PR's body text, or null when gh failed. */
143
181
  prBody(number: number, opts: AsyncCallOptions = {}): Promise<string | null> {
144
- return this.exec(v.prBody(number), opts)
182
+ return this.exec(v.prBody(number, this.target), opts)
145
183
  }
146
184
 
147
185
  /** A REST GET through `gh api`; failure is `ok: false`, never a rejection. */
148
186
  api(endpoint: string, opts: ApiOptions & AsyncCallOptions = {}): Promise<ApiResponse> {
149
- return this.exec(v.api(endpoint, opts), opts)
187
+ return this.exec(v.api(endpoint, opts, this.target), opts)
188
+ }
189
+
190
+ /**
191
+ * A GraphQL query; each page's `data`, or null. See `Gh.graphql`.
192
+ *
193
+ * @experimental
194
+ */
195
+ graphql(
196
+ query: string,
197
+ variables: Record<string, v.GraphqlVariable> = {},
198
+ opts: v.GraphqlOptions & AsyncCallOptions = {},
199
+ ): Promise<unknown[] | null> {
200
+ return this.exec(v.graphql(query, variables, opts, this.target), opts)
150
201
  }
151
202
 
152
203
  /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
153
- labelList(opts: { limit?: number | undefined } & AsyncCallOptions = {}): Promise<Label[] | null> {
154
- return this.exec(v.labelList(opts), opts)
204
+ labelList(
205
+ opts: { limit?: number | undefined } & AsyncCallOptions = {},
206
+ ): Promise<ListResult<Label> | null> {
207
+ return this.exec(v.labelList(opts, this.target), opts)
155
208
  }
156
209
 
157
210
  /** Issues matching `opts`, or null when gh failed. */
158
- issueList(opts: IssueListOptions & AsyncCallOptions = {}): Promise<IssueRow[] | null> {
159
- return this.exec(v.issueList(opts), opts)
211
+ issueList(opts: IssueListOptions & AsyncCallOptions = {}): Promise<ListResult<IssueRow> | null> {
212
+ return this.exec(v.issueList(opts, this.target), opts)
160
213
  }
161
214
 
162
215
  /** One issue with its labels, or null when gh failed. */
163
216
  issueView(number: number, opts: AsyncCallOptions = {}): Promise<IssueRow | null> {
164
- return this.exec(v.issueView(number), opts)
217
+ return this.exec(v.issueView(number, this.target), opts)
218
+ }
219
+
220
+ /** An owner's repositories, or null when gh failed. See `Gh.repoList`. */
221
+ repoList(
222
+ owner: string,
223
+ opts: v.RepoListOptions & AsyncCallOptions = {},
224
+ ): Promise<ListResult<Repository> | null> {
225
+ return this.exec(v.repoList(owner, opts, this.target), opts)
226
+ }
227
+
228
+ /** One repository, or null when gh failed. See `Gh.repoView`. */
229
+ repoView(
230
+ repo?: string | undefined,
231
+ opts: { fields?: readonly string[] | undefined } & AsyncCallOptions = {},
232
+ ): Promise<Repository | null> {
233
+ return this.exec(v.repoView(repo, opts.fields ?? v.REPO_FIELDS, this.target), opts)
234
+ }
235
+
236
+ /** Pull requests across repositories matching a search query, or null. See `Gh.searchPrs`. */
237
+ searchPrs(
238
+ query: string,
239
+ opts: v.SearchPrsOptions & AsyncCallOptions = {},
240
+ ): Promise<ListResult<PrSearchHit> | null> {
241
+ return this.exec(v.searchPrs(query, opts, this.target), opts)
165
242
  }
166
243
 
167
244
  /** True iff the `gh` binary is on PATH and runs. */
@@ -176,7 +253,7 @@ export class AsyncGh {
176
253
 
177
254
  /** Login state and what `gh auth status` printed. */
178
255
  authStatus(): Promise<AuthStatus> {
179
- return this.exec(v.authStatus())
256
+ return this.exec(v.authStatus(this.target))
180
257
  }
181
258
 
182
259
  /** True iff `gh` is installed AND authenticated; short-circuits. */
@@ -196,7 +273,7 @@ export class AsyncGh {
196
273
 
197
274
  /** Open a PR and resolve to its URL ('' if gh printed none). */
198
275
  createPr(opts: v.CreatePrOptions): Promise<string> {
199
- return this.exec(v.createPr(opts))
276
+ return this.exec(v.createPr(opts, this.target))
200
277
  }
201
278
 
202
279
  /** Retitle or rewrite a PR body. */
@@ -204,22 +281,22 @@ export class AsyncGh {
204
281
  number: number,
205
282
  opts: { title?: string | undefined; body?: string | undefined },
206
283
  ): Promise<void> {
207
- return this.exec(v.editPr(number, opts))
284
+ return this.exec(v.editPr(number, opts, this.target))
208
285
  }
209
286
 
210
287
  /** Rewrite just a PR's body. */
211
288
  editPrBody(number: number, body: string): Promise<void> {
212
- return this.exec(v.editPrBody(number, body))
289
+ return this.exec(v.editPrBody(number, body, this.target))
213
290
  }
214
291
 
215
292
  /** The PR's unified diff; rejects with `CommandFailed` when gh cannot produce it. */
216
293
  prDiff(number: number): Promise<string> {
217
- return this.exec(v.prDiff(number))
294
+ return this.exec(v.prDiff(number, this.target))
218
295
  }
219
296
 
220
297
  /** Post one conversation comment on a PR; resolves to its URL ('' if gh printed none). */
221
298
  prComment(number: number, body: string): Promise<string> {
222
- return this.exec(v.prComment(number, body))
299
+ return this.exec(v.prComment(number, body, this.target))
223
300
  }
224
301
 
225
302
  /**
@@ -228,17 +305,17 @@ export class AsyncGh {
228
305
  * @experimental
229
306
  */
230
307
  createReview(number: number, opts: v.CreateReviewOptions): Promise<string> {
231
- return this.exec(v.createReview(number, opts))
308
+ return this.exec(v.createReview(number, opts, this.target))
232
309
  }
233
310
 
234
311
  /** Bring a PR's head branch up to date with its base. */
235
312
  prUpdateBranch(number: number, style: 'merge' | 'rebase' = 'merge'): Promise<void> {
236
- return this.exec(v.prUpdateBranch(number, style))
313
+ return this.exec(v.prUpdateBranch(number, style, this.target))
237
314
  }
238
315
 
239
316
  /** Mark a PR ready for review (`draft: false`) or convert it to a draft (`draft: true`). */
240
317
  prSetDraft(number: number, draft: boolean): Promise<void> {
241
- return this.exec(v.prSetDraft(number, draft))
318
+ return this.exec(v.prSetDraft(number, draft, this.target))
242
319
  }
243
320
 
244
321
  /**
@@ -248,22 +325,22 @@ export class AsyncGh {
248
325
  */
249
326
  async issueCommentEdit(commentId: string, body: string): Promise<void> {
250
327
  // async, so a bad id rejects the Promise instead of throwing at the call.
251
- return this.exec(v.issueCommentEdit(commentId, body))
328
+ return this.exec(v.issueCommentEdit(commentId, body, this.target))
252
329
  }
253
330
 
254
331
  /** Merge a PR; GitHub refuses when its head is no longer `opts.headSha`. */
255
332
  prMerge(number: number, opts: { method: v.PrMergeMethod; headSha: string }): Promise<void> {
256
- return this.exec(v.prMerge(number, opts))
333
+ return this.exec(v.prMerge(number, opts, this.target))
257
334
  }
258
335
 
259
336
  /** A PR's review threads, or null when they could not be read completely. */
260
337
  prReviewThreads(number: number): Promise<v.ReviewThread[] | null> {
261
- return this.exec(v.prReviewThreads(number))
338
+ return this.exec(v.prReviewThreads(number, this.target))
262
339
  }
263
340
 
264
341
  /** Create a label (or force it to `opts`'s color/description if it exists). */
265
342
  createLabel(opts: v.CreateLabelOptions): Promise<void> {
266
- return this.exec(v.createLabel(opts))
343
+ return this.exec(v.createLabel(opts, this.target))
267
344
  }
268
345
 
269
346
  /** Recolor and/or redescribe an existing label. */
@@ -271,15 +348,32 @@ export class AsyncGh {
271
348
  name: string,
272
349
  opts: { color?: string | undefined; description?: string | undefined },
273
350
  ): Promise<void> {
274
- return this.exec(v.editLabel(name, opts))
351
+ return this.exec(v.editLabel(name, opts, this.target))
275
352
  }
276
353
 
277
354
  /** Swap an issue's label in a single `gh issue edit` call. */
278
355
  setIssueLabel(number: number, currentLabel: string | null, newLabel: string): Promise<void> {
279
- return this.exec(v.setIssueLabel(number, currentLabel, newLabel))
356
+ return this.exec(v.setIssueLabel(number, currentLabel, newLabel, this.target))
280
357
  }
281
358
  }
282
359
 
360
+ /** The open PR for `branch`, else its newest closed or merged one. */
361
+ async function prForBranchOn(
362
+ gh: AsyncGh,
363
+ branch: string,
364
+ call: { fields: readonly string[] } & AsyncCallOptions,
365
+ ): Promise<PullRequest | null> {
366
+ const rows = await gh.prList({
367
+ head: branch,
368
+ state: 'all',
369
+ limit: 20,
370
+ fields: v.branchPrFields(call.fields),
371
+ timeoutMs: call.timeoutMs,
372
+ signal: call.signal,
373
+ })
374
+ return v.branchPr(rows)
375
+ }
376
+
283
377
  function sleepAsync(ms: number): Promise<void> {
284
378
  return new Promise((resolve) => setTimeout(resolve, ms))
285
379
  }