@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/index.ts CHANGED
@@ -17,9 +17,9 @@
17
17
  * Authentication is `gh`'s. No token is read, stored, or forwarded.
18
18
  */
19
19
 
20
- export { AsyncGh, type AsyncCallOptions } from './async.ts'
20
+ export { AsyncGh, type AsyncCallOptions, type AsyncGhOptions } from './async.ts'
21
21
 
22
- export { Gh, defaultRunnerFactory, type RunnerFactory } from './sync.ts'
22
+ export { Gh, defaultRunnerFactory, type GhOptions, type RunnerFactory } from './sync.ts'
23
23
 
24
24
  export {
25
25
  api,
@@ -27,6 +27,7 @@ export {
27
27
  createPr,
28
28
  editPr,
29
29
  editPrBody,
30
+ graphql,
30
31
  createLabel,
31
32
  createReview,
32
33
  editLabel,
@@ -34,6 +35,7 @@ export {
34
35
  issueList,
35
36
  issueView,
36
37
  labelList,
38
+ nodesAt,
37
39
  openPrsForBranch,
38
40
  pickPrForBranch,
39
41
  prBody,
@@ -46,27 +48,45 @@ export {
46
48
  prReviewThreads,
47
49
  prUpdateBranch,
48
50
  prView,
51
+ repoList,
52
+ repoView,
53
+ searchPrs,
49
54
  setIssueLabel,
50
55
  PR_LIST_FIELDS,
51
56
  PR_REF_FIELDS,
52
57
  PR_VIEW_FIELDS,
58
+ REPO_FIELDS,
59
+ SEARCH_PR_FIELDS,
53
60
  type ApiOptions,
54
61
  type ApiResponse,
55
62
  type AuthStatus,
56
63
  type CreateLabelOptions,
57
64
  type CreatePrOptions,
58
65
  type CreateReviewOptions,
66
+ type GhTarget,
67
+ type GraphqlOptions,
68
+ type GraphqlVariable,
59
69
  type GhVerb,
60
70
  type IssueListOptions,
71
+ type ListResult,
61
72
  type PrListOptions,
62
73
  type PrMergeMethod,
74
+ type RepoListOptions,
63
75
  type ReviewComment,
64
76
  type ReviewThread,
77
+ type SearchPrsOptions,
65
78
  } from './verbs.ts'
66
79
 
67
80
  export { anyCheckFailed, normalizeAuthorLogin, parsePullRequest } from './parse.ts'
68
81
 
69
- export { ownerRepoSlug, parseOwnerRepo, parsePrUrl, type PullRequestRef } from './repo.ts'
82
+ export {
83
+ ownerRepoSlug,
84
+ parseOwnerRepo,
85
+ parsePrRef,
86
+ parsePrUrl,
87
+ type PrRef,
88
+ type PullRequestRef,
89
+ } from './repo.ts'
70
90
 
71
91
  export type {
72
92
  Actor,
@@ -79,7 +99,10 @@ export type {
79
99
  MergeStateStatus,
80
100
  OwnerRepo,
81
101
  PullRequest,
102
+ PrSearchHit,
82
103
  PullRequestState,
104
+ Repository,
105
+ RepositoryVisibility,
83
106
  Review,
84
107
  ReviewDecision,
85
108
  ReviewState,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/typed-gh",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  "LICENSE-APACHE"
30
30
  ],
31
31
  "dependencies": {
32
- "@osdlc/command-seam": "^0.8.0",
33
- "@osdlc/typed-git": "^0.5.2"
32
+ "@osdlc/typed-git": "^0.5.3",
33
+ "@osdlc/command-seam": "^0.8.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=22"
package/parse.ts CHANGED
@@ -25,7 +25,9 @@ import type {
25
25
  Comment,
26
26
  Label,
27
27
  MergeableState,
28
+ PrSearchHit,
28
29
  PullRequest,
30
+ Repository,
29
31
  Review,
30
32
  ReviewDecision,
31
33
  } from './types.ts'
@@ -250,3 +252,51 @@ export function parsePullRequest(raw: unknown): PullRequest | null {
250
252
  updatedAt: strOrNull(raw['updatedAt'] ?? raw['updated_at']),
251
253
  }
252
254
  }
255
+
256
+ // ── repositories and search ─────────────────────────────────────────────────
257
+
258
+ /** A `gh repo list`/`view --json` row as a {@link Repository}, or null without `nameWithOwner`. */
259
+ export function parseRepository(raw: unknown): Repository | null {
260
+ if (!isRecord(raw)) return null
261
+ const nameWithOwner = str(raw['nameWithOwner'])
262
+ const slash = nameWithOwner.indexOf('/')
263
+ if (slash <= 0 || slash === nameWithOwner.length - 1) return null
264
+ const branch = raw['defaultBranchRef']
265
+ return {
266
+ nameWithOwner,
267
+ owner: nameWithOwner.slice(0, slash),
268
+ name: nameWithOwner.slice(slash + 1),
269
+ description: str(raw['description']),
270
+ defaultBranch: isRecord(branch) ? strOrNull(branch['name']) : null,
271
+ visibility: strOrNull(raw['visibility']),
272
+ isArchived: bool(raw['isArchived']),
273
+ url: str(raw['url']),
274
+ }
275
+ }
276
+
277
+ /** A `gh search prs --json` row as a {@link PrSearchHit}, or null without a `number`. */
278
+ export function parsePrSearchHit(raw: unknown): PrSearchHit | null {
279
+ if (!isRecord(raw)) return null
280
+ const number = raw['number']
281
+ if (typeof number !== 'number') return null
282
+ const repo = raw['repository']
283
+ const slug = isRecord(repo) ? str(repo['nameWithOwner']) : ''
284
+ const slash = slug.indexOf('/')
285
+ return {
286
+ number,
287
+ title: str(raw['title']),
288
+ // Search answers in lower case; `PullRequest.state` is upper case.
289
+ state: str(raw['state']).toUpperCase(),
290
+ url: str(raw['url']),
291
+ isDraft: bool(raw['isDraft']),
292
+ repository:
293
+ slash > 0 && slash < slug.length - 1
294
+ ? { owner: slug.slice(0, slash), repo: slug.slice(slash + 1) }
295
+ : null,
296
+ author: parseActor(raw['author']),
297
+ labels: parseLabels(raw['labels']),
298
+ createdAt: strOrNull(raw['createdAt']),
299
+ updatedAt: strOrNull(raw['updatedAt']),
300
+ closedAt: strOrNull(raw['closedAt']),
301
+ }
302
+ }
package/repo.ts CHANGED
@@ -29,31 +29,105 @@ export function ownerRepoSlug(r: OwnerRepo): string {
29
29
  return `${r.owner}/${r.repo}`
30
30
  }
31
31
 
32
- /** A pull request coordinate: the repository and the PR's number within it. */
32
+ /** A pull request coordinate: the host, the repository and the PR's number within it. */
33
33
  export interface PullRequestRef extends OwnerRepo {
34
+ /** The GitHub host, lower-cased: `github.com`, or an Enterprise host. */
35
+ hostname: string
34
36
  number: number
35
37
  }
36
38
 
37
39
  /**
38
- * The `{owner, repo, number}` a GitHub pull-request URL names, or null when
39
- * `url` is not one.
40
+ * The `{hostname, owner, repo, number}` a GitHub pull-request URL names, or
41
+ * null when `url` is not one.
40
42
  *
41
- * Accepts `https://github.com/<owner>/<repo>/pull/<n>` with or without a
42
- * scheme, a `www.` prefix, a trailing slash, a sub-page (`/files`,
43
- * `/commits`), a query string or a fragment — every shape a browser's address
44
- * bar hands over. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
43
+ * Accepts `https://<host>/<owner>/<repo>/pull/<n>` with or without a scheme,
44
+ * a trailing slash, a sub-page (`/files`, `/commits`), a query string or a
45
+ * fragment — every shape a browser's address bar hands over. The host is
46
+ * `github.com` (a `www.` prefix is dropped) or a GitHub Enterprise host such
47
+ * as `github.acme.com` or `acme.ghe.com`; without a scheme the host must
48
+ * contain a dot. `parseGitUrl` is the wrong tool here: it reads `/pull/<n>`
45
49
  * as part of the project path.
46
- *
47
- * GitHub.com only. A GitHub Enterprise host is a different coordinate space
48
- * (the `gh` CLI needs `--hostname`), so it is not silently accepted.
49
50
  */
50
51
  export function parsePrUrl(url: string): PullRequestRef | null {
51
52
  const m =
52
- /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
53
+ /^(?:(https?:)\/\/)?([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
53
54
  url.trim(),
54
55
  )
55
56
  if (m === null) return null
56
- const number = Number(m[3])
57
+ const host = m[2]!.toLowerCase()
58
+ if (m[1] === undefined && !host.includes('.')) return null
59
+ const number = Number(m[5])
57
60
  if (!Number.isSafeInteger(number) || number <= 0) return null
58
- return { owner: m[1]!, repo: m[2]!.replace(/\.git$/, ''), number }
61
+ return {
62
+ hostname: host === 'www.github.com' ? 'github.com' : host,
63
+ owner: m[3]!,
64
+ repo: m[4]!.replace(/\.git$/, ''),
65
+ number,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * What a person typed to name a pull request, read by {@link parsePrRef}.
71
+ *
72
+ * - `number`: `42`, `#42`, or `owner/name#42` (then `repo` is set). For bare
73
+ * digits, `orBranch` holds the same text, since a branch can be named `42`.
74
+ * - `url`: a pull request URL, with what {@link parsePrUrl} read from it.
75
+ * - `branch`: any other text that is a valid branch name.
76
+ */
77
+ export type PrRef =
78
+ | { kind: 'number'; number: number; repo: OwnerRepo | null; orBranch: string | null }
79
+ | ({ kind: 'url'; url: string } & PullRequestRef)
80
+ | { kind: 'branch'; name: string }
81
+
82
+ /**
83
+ * Read what a person typed to name a pull request: a number, `#42`,
84
+ * `owner/name#42`, a URL, or a branch name. Null when it is none of these.
85
+ *
86
+ * Pure: it starts no process. Digits are read as a number first, the way
87
+ * `gh pr view 42` reads them; `orBranch` keeps the branch reading. The
88
+ * clients' `prFind` tries both.
89
+ */
90
+ export function parsePrRef(input: string): PrRef | null {
91
+ const text = input.trim()
92
+ const digits = /^(#?)(\d+)$/.exec(text)
93
+ if (digits !== null) {
94
+ const number = Number(digits[2])
95
+ if (Number.isSafeInteger(number) && number > 0) {
96
+ return { kind: 'number', number, repo: null, orBranch: digits[1] === '' ? text : null }
97
+ }
98
+ }
99
+ const qualified = /^([^/\s#]+)\/([^/\s#]+)#(\d+)$/.exec(text)
100
+ if (qualified !== null) {
101
+ const number = Number(qualified[3])
102
+ if (!Number.isSafeInteger(number) || number <= 0) return null
103
+ if (!isRepoSlug(`${qualified[1]}/${qualified[2]}`)) return null
104
+ return {
105
+ kind: 'number',
106
+ number,
107
+ repo: { owner: qualified[1]!, repo: qualified[2]! },
108
+ orBranch: null,
109
+ }
110
+ }
111
+ const url = parsePrUrl(text)
112
+ if (url !== null) return { kind: 'url', url: text, ...url }
113
+ return isBranchName(text) ? { kind: 'branch', name: text } : null
114
+ }
115
+
116
+ /** True for `owner/name` as GitHub spells it. */
117
+ export function isRepoSlug(text: string): boolean {
118
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(text)
119
+ }
120
+
121
+ /**
122
+ * True when `name` is a branch name git would accept (`git check-ref-format
123
+ * --branch`), and does not start with `-`, so it cannot read as a flag.
124
+ */
125
+ function isBranchName(name: string): boolean {
126
+ if (name === '' || name === '@' || name.startsWith('-') || name.startsWith('/')) return false
127
+ if (name.endsWith('/') || name.endsWith('.')) return false
128
+ // Control characters, space, and the characters git reserves.
129
+ // oxlint-disable-next-line no-control-regex
130
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(name)) return false
131
+ if (name.includes('..') || name.includes('@{') || name.includes('//')) return false
132
+ return name.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock'))
59
133
  }
package/sync.ts CHANGED
@@ -26,12 +26,14 @@ import type {
26
26
  ApiOptions,
27
27
  ApiResponse,
28
28
  AuthStatus,
29
+ GhTarget,
29
30
  GhVerb,
30
31
  IssueListOptions,
32
+ ListResult,
31
33
  PrListOptions,
32
34
  } from './verbs.ts'
33
- import { parseOwnerRepo } from './repo.ts'
34
- import type { IssueRow, Label, OwnerRepo, PullRequest } from './types.ts'
35
+ import { ownerRepoSlug, parseOwnerRepo, parsePrRef } from './repo.ts'
36
+ import type { IssueRow, Label, OwnerRepo, PrSearchHit, PullRequest, Repository } from './types.ts'
35
37
 
36
38
  /**
37
39
  * Factory of per-binary runners — the injectable subprocess seam a CLI tail
@@ -46,8 +48,20 @@ export const defaultRunnerFactory: RunnerFactory = spawnRunner
46
48
  /** How long to wait between merge-state retries, in milliseconds. */
47
49
  const MERGE_STATE_RETRY_DELAY_MS = 1_500
48
50
 
51
+ /** What a client is built with: the runner, and the repository and host it talks to. */
52
+ export interface GhOptions extends GhTarget {
53
+ runner?: CommandRunner | undefined
54
+ }
55
+
49
56
  /**
50
- * A typed gh client bound to one working directory.
57
+ * A typed gh client bound to one working directory, and optionally to one
58
+ * repository and host.
59
+ *
60
+ * With `repo`, every call names that repository (`--repo`, or the
61
+ * `{owner}/{repo}` of an `api` endpoint), so no clone is needed. Without it,
62
+ * gh uses the repository of the clone at `cwd`. With `hostname`, every call
63
+ * goes to that GitHub host. An invalid `repo` or `hostname` throws
64
+ * `RangeError`.
51
65
  *
52
66
  * Every `timeoutMs` option is a hard cap on the call. An invalid value
53
67
  * throws, as in command-seam: `RangeError` for 0, a negative value or `NaN`;
@@ -55,16 +69,28 @@ const MERGE_STATE_RETRY_DELAY_MS = 1_500
55
69
  */
56
70
  export class Gh {
57
71
  readonly cwd: string
72
+ readonly repo: string | undefined
73
+ readonly hostname: string | undefined
74
+ private readonly target: GhTarget
58
75
  private readonly runner: CommandRunner
59
76
 
60
- constructor(cwd: string, opts: { runner?: CommandRunner | undefined } = {}) {
77
+ constructor(cwd: string, opts: GhOptions = {}) {
78
+ v.checkTarget(opts)
61
79
  this.cwd = cwd
80
+ this.repo = opts.repo
81
+ this.hostname = opts.hostname
82
+ this.target = { repo: opts.repo, hostname: opts.hostname }
62
83
  this.runner = resolveRunner('gh', opts.runner)
63
84
  }
64
85
 
65
- /** The same client rooted at a different directory (same runner). */
86
+ /** The same client rooted at a different directory (same runner, repo and host). */
66
87
  at(cwd: string): Gh {
67
- return new Gh(cwd, { runner: this.runner })
88
+ return new Gh(cwd, { runner: this.runner, repo: this.repo, hostname: this.hostname })
89
+ }
90
+
91
+ /** The same client aimed at another repository (same runner, directory and host). */
92
+ withRepo(repo: string): Gh {
93
+ return new Gh(this.cwd, { runner: this.runner, repo, hostname: this.hostname })
68
94
  }
69
95
 
70
96
  // ── escape hatches ────────────────────────────────────────────────────────
@@ -83,6 +109,7 @@ export class Gh {
83
109
  private exec<T>(verb: GhVerb<T>, timeoutMs?: number | undefined): T {
84
110
  const opts: CommandRunOptions = { cwd: this.cwd }
85
111
  if (verb.input !== undefined) opts.input = verb.input
112
+ if (verb.env !== undefined) opts.env = verb.env
86
113
  if (timeoutMs !== undefined) opts.timeoutMs = timeoutMs
87
114
  const result = verb.checked
88
115
  ? runChecked(this.runner, verb.argv, opts)
@@ -93,16 +120,45 @@ export class Gh {
93
120
  // ── queries ───────────────────────────────────────────────────────────────
94
121
 
95
122
  /** Pull requests matching `opts`, or null when gh failed. */
96
- prList(opts: PrListOptions & { timeoutMs?: number | undefined } = {}): PullRequest[] | null {
97
- return this.exec(v.prList(opts), opts.timeoutMs)
123
+ prList(
124
+ opts: PrListOptions & { timeoutMs?: number | undefined } = {},
125
+ ): ListResult<PullRequest> | null {
126
+ return this.exec(v.prList(opts, this.target), opts.timeoutMs)
98
127
  }
99
128
 
100
- /** One PR with the heavy fields, or null when gh failed. */
129
+ /**
130
+ * One PR by number, branch or URL, with the heavy fields; null when gh
131
+ * failed or there is no such PR.
132
+ */
101
133
  prView(
102
- number: number,
134
+ ref: number | string,
135
+ opts: { fields?: readonly string[] | undefined; timeoutMs?: number | undefined } = {},
136
+ ): PullRequest | null {
137
+ return this.exec(v.prView(ref, opts.fields ?? v.PR_VIEW_FIELDS, this.target), opts.timeoutMs)
138
+ }
139
+
140
+ /**
141
+ * The one PR that `input` names, or null: what a person typed, such as `42`,
142
+ * `#42`, `owner/name#42`, a URL or a branch (read by `parsePrRef`).
143
+ *
144
+ * Digits are a PR number first; when no such PR comes back, they are tried
145
+ * as a branch. A branch gives its open PR, or else its newest closed or
146
+ * merged one, so a tool run after the merge still finds it. Fields default
147
+ * to {@link PR_LIST_FIELDS}.
148
+ */
149
+ prFind(
150
+ input: string,
103
151
  opts: { fields?: readonly string[] | undefined; timeoutMs?: number | undefined } = {},
104
152
  ): PullRequest | null {
105
- return this.exec(v.prView(number, opts.fields ?? v.PR_VIEW_FIELDS), opts.timeoutMs)
153
+ const ref = parsePrRef(input)
154
+ if (ref === null) return null
155
+ const fields = opts.fields ?? v.PR_LIST_FIELDS
156
+ const call = { fields, timeoutMs: opts.timeoutMs }
157
+ if (ref.kind === 'url') return this.prView(ref.url, call)
158
+ if (ref.kind === 'branch') return prForBranchOn(this, ref.name, call)
159
+ const client = ref.repo === null ? this : this.withRepo(ownerRepoSlug(ref.repo))
160
+ const pr = client.prView(ref.number, call)
161
+ return pr !== null || ref.orBranch === null ? pr : prForBranchOn(this, ref.orBranch, call)
106
162
  }
107
163
 
108
164
  /**
@@ -124,7 +180,7 @@ export class Gh {
124
180
  limit: opts.limit ?? 5,
125
181
  fields: opts.fields ?? v.PR_REF_FIELDS,
126
182
  })
127
- return v.pickPrForBranch(rows, branch)
183
+ return v.pickPrForBranch(rows?.rows ?? null, branch)
128
184
  }
129
185
 
130
186
  /**
@@ -161,7 +217,7 @@ export class Gh {
161
217
 
162
218
  /** A PR's body text, or null when gh failed. */
163
219
  prBody(number: number, opts: { timeoutMs?: number | undefined } = {}): string | null {
164
- return this.exec(v.prBody(number), opts.timeoutMs)
220
+ return this.exec(v.prBody(number, this.target), opts.timeoutMs)
165
221
  }
166
222
 
167
223
  /**
@@ -169,24 +225,64 @@ export class Gh {
169
225
  * returned {@link ApiResponse}.
170
226
  */
171
227
  api(endpoint: string, opts: ApiOptions & { timeoutMs?: number | undefined } = {}): ApiResponse {
172
- return this.exec(v.api(endpoint, opts), opts.timeoutMs)
228
+ return this.exec(v.api(endpoint, opts, this.target), opts.timeoutMs)
229
+ }
230
+
231
+ /**
232
+ * A GraphQL query; each page's `data`, or null on a failed or partial
233
+ * result. See the `graphql` verb.
234
+ *
235
+ * @experimental
236
+ */
237
+ graphql(
238
+ query: string,
239
+ variables: Record<string, v.GraphqlVariable> = {},
240
+ opts: v.GraphqlOptions & { timeoutMs?: number | undefined } = {},
241
+ ): unknown[] | null {
242
+ return this.exec(v.graphql(query, variables, opts, this.target), opts.timeoutMs)
173
243
  }
174
244
 
175
245
  /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
176
246
  labelList(
177
247
  opts: { limit?: number | undefined; timeoutMs?: number | undefined } = {},
178
- ): Label[] | null {
179
- return this.exec(v.labelList(opts), opts.timeoutMs)
248
+ ): ListResult<Label> | null {
249
+ return this.exec(v.labelList(opts, this.target), opts.timeoutMs)
180
250
  }
181
251
 
182
252
  /** Issues matching `opts` (open by default, up to 200), or null when gh failed. */
183
- issueList(opts: IssueListOptions & { timeoutMs?: number | undefined } = {}): IssueRow[] | null {
184
- return this.exec(v.issueList(opts), opts.timeoutMs)
253
+ issueList(
254
+ opts: IssueListOptions & { timeoutMs?: number | undefined } = {},
255
+ ): ListResult<IssueRow> | null {
256
+ return this.exec(v.issueList(opts, this.target), opts.timeoutMs)
185
257
  }
186
258
 
187
259
  /** One issue with its labels, or null when gh failed. */
188
260
  issueView(number: number, opts: { timeoutMs?: number | undefined } = {}): IssueRow | null {
189
- return this.exec(v.issueView(number), opts.timeoutMs)
261
+ return this.exec(v.issueView(number, this.target), opts.timeoutMs)
262
+ }
263
+
264
+ /** An owner's repositories (up to `opts.limit`, default 100), or null when gh failed. */
265
+ repoList(
266
+ owner: string,
267
+ opts: v.RepoListOptions & { timeoutMs?: number | undefined } = {},
268
+ ): ListResult<Repository> | null {
269
+ return this.exec(v.repoList(owner, opts, this.target), opts.timeoutMs)
270
+ }
271
+
272
+ /** One repository: `repo`, else the client's repo, else the clone at `cwd`. Null when gh failed. */
273
+ repoView(
274
+ repo?: string | undefined,
275
+ opts: { fields?: readonly string[] | undefined; timeoutMs?: number | undefined } = {},
276
+ ): Repository | null {
277
+ return this.exec(v.repoView(repo, opts.fields ?? v.REPO_FIELDS, this.target), opts.timeoutMs)
278
+ }
279
+
280
+ /** Pull requests across repositories matching a search query, or null when gh failed. */
281
+ searchPrs(
282
+ query: string,
283
+ opts: v.SearchPrsOptions & { timeoutMs?: number | undefined } = {},
284
+ ): ListResult<PrSearchHit> | null {
285
+ return this.exec(v.searchPrs(query, opts, this.target), opts.timeoutMs)
190
286
  }
191
287
 
192
288
  /** True iff the `gh` binary is on PATH and runs. */
@@ -206,7 +302,7 @@ export class Gh {
206
302
 
207
303
  /** Login state and what `gh auth status` printed; `authenticated()` is its `ok`. */
208
304
  authStatus(): AuthStatus {
209
- return this.exec(v.authStatus())
305
+ return this.exec(v.authStatus(this.target))
210
306
  }
211
307
 
212
308
  /**
@@ -232,22 +328,22 @@ export class Gh {
232
328
 
233
329
  /** Open a PR and return its URL ('' if gh printed none). */
234
330
  createPr(opts: v.CreatePrOptions): string {
235
- return this.exec(v.createPr(opts))
331
+ return this.exec(v.createPr(opts, this.target))
236
332
  }
237
333
 
238
334
  /** Retitle or rewrite a PR body. */
239
335
  editPr(number: number, opts: { title?: string | undefined; body?: string | undefined }): void {
240
- this.exec(v.editPr(number, opts))
336
+ this.exec(v.editPr(number, opts, this.target))
241
337
  }
242
338
 
243
339
  /** The PR's unified diff; throws `CommandFailed` when gh cannot produce it. */
244
340
  prDiff(number: number): string {
245
- return this.exec(v.prDiff(number))
341
+ return this.exec(v.prDiff(number, this.target))
246
342
  }
247
343
 
248
344
  /** Post one conversation comment on a PR; returns its URL ('' if gh printed none). */
249
345
  prComment(number: number, body: string): string {
250
- return this.exec(v.prComment(number, body))
346
+ return this.exec(v.prComment(number, body, this.target))
251
347
  }
252
348
 
253
349
  /**
@@ -256,22 +352,22 @@ export class Gh {
256
352
  * @experimental
257
353
  */
258
354
  createReview(number: number, opts: v.CreateReviewOptions): string {
259
- return this.exec(v.createReview(number, opts))
355
+ return this.exec(v.createReview(number, opts, this.target))
260
356
  }
261
357
 
262
358
  /** Rewrite just a PR's body. */
263
359
  editPrBody(number: number, body: string): void {
264
- this.exec(v.editPrBody(number, body))
360
+ this.exec(v.editPrBody(number, body, this.target))
265
361
  }
266
362
 
267
363
  /** Bring a PR's head branch up to date with its base. */
268
364
  prUpdateBranch(number: number, style: 'merge' | 'rebase' = 'merge'): void {
269
- this.exec(v.prUpdateBranch(number, style))
365
+ this.exec(v.prUpdateBranch(number, style, this.target))
270
366
  }
271
367
 
272
368
  /** Mark a PR ready for review (`draft: false`) or convert it to a draft (`draft: true`). */
273
369
  prSetDraft(number: number, draft: boolean): void {
274
- this.exec(v.prSetDraft(number, draft))
370
+ this.exec(v.prSetDraft(number, draft, this.target))
275
371
  }
276
372
 
277
373
  /**
@@ -280,22 +376,22 @@ export class Gh {
280
376
  * @experimental
281
377
  */
282
378
  issueCommentEdit(commentId: string, body: string): void {
283
- this.exec(v.issueCommentEdit(commentId, body))
379
+ this.exec(v.issueCommentEdit(commentId, body, this.target))
284
380
  }
285
381
 
286
382
  /** Merge a PR; GitHub refuses when its head is no longer `opts.headSha`. */
287
383
  prMerge(number: number, opts: { method: v.PrMergeMethod; headSha: string }): void {
288
- this.exec(v.prMerge(number, opts))
384
+ this.exec(v.prMerge(number, opts, this.target))
289
385
  }
290
386
 
291
387
  /** A PR's review threads, or null when they could not be read completely. */
292
388
  prReviewThreads(number: number): v.ReviewThread[] | null {
293
- return this.exec(v.prReviewThreads(number))
389
+ return this.exec(v.prReviewThreads(number, this.target))
294
390
  }
295
391
 
296
392
  /** Create a label (or force it to `opts`'s color/description if it already exists). */
297
393
  createLabel(opts: v.CreateLabelOptions): void {
298
- this.exec(v.createLabel(opts))
394
+ this.exec(v.createLabel(opts, this.target))
299
395
  }
300
396
 
301
397
  /** Recolor and/or redescribe an existing label. */
@@ -303,7 +399,7 @@ export class Gh {
303
399
  name: string,
304
400
  opts: { color?: string | undefined; description?: string | undefined },
305
401
  ): void {
306
- this.exec(v.editLabel(name, opts))
402
+ this.exec(v.editLabel(name, opts, this.target))
307
403
  }
308
404
 
309
405
  /**
@@ -312,10 +408,26 @@ export class Gh {
312
408
  * or briefly-double-labeled intermediate state.
313
409
  */
314
410
  setIssueLabel(number: number, currentLabel: string | null, newLabel: string): void {
315
- this.exec(v.setIssueLabel(number, currentLabel, newLabel))
411
+ this.exec(v.setIssueLabel(number, currentLabel, newLabel, this.target))
316
412
  }
317
413
  }
318
414
 
415
+ /** The open PR for `branch`, else its newest closed or merged one. */
416
+ function prForBranchOn(
417
+ gh: Gh,
418
+ branch: string,
419
+ call: { fields: readonly string[]; timeoutMs?: number | undefined },
420
+ ): PullRequest | null {
421
+ const rows = gh.prList({
422
+ head: branch,
423
+ state: 'all',
424
+ limit: 20,
425
+ fields: v.branchPrFields(call.fields),
426
+ timeoutMs: call.timeoutMs,
427
+ })
428
+ return v.branchPr(rows)
429
+ }
430
+
319
431
  /**
320
432
  * Block the calling thread. Crude on purpose: the synchronous client has no
321
433
  * event loop to await on, and the alternative — making every caller async to
package/types.ts CHANGED
@@ -184,3 +184,43 @@ export interface OwnerRepo {
184
184
  owner: string
185
185
  repo: string
186
186
  }
187
+
188
+ /** Who can see a repository. */
189
+ export type RepositoryVisibility = 'PUBLIC' | 'PRIVATE' | 'INTERNAL' | (string & {})
190
+
191
+ /**
192
+ * A repository, off `gh repo list`/`gh repo view --json`. A field not asked
193
+ * for comes back at its "not known" value. `nameWithOwner` is the only field
194
+ * without one: a row without it is dropped.
195
+ */
196
+ export interface Repository {
197
+ /** `owner/name`. */
198
+ nameWithOwner: string
199
+ owner: string
200
+ name: string
201
+ description: string
202
+ /** The default branch; null when not asked for, or the repository is empty. */
203
+ defaultBranch: string | null
204
+ visibility: RepositoryVisibility | null
205
+ isArchived: boolean
206
+ url: string
207
+ }
208
+
209
+ /**
210
+ * One pull request found by `gh search prs`. Search has its own field set:
211
+ * it names the repository but carries no head or base branch, so this is not
212
+ * a {@link PullRequest}. `state` is upper-cased to match `PullRequest`.
213
+ */
214
+ export interface PrSearchHit {
215
+ number: number
216
+ title: string
217
+ state: PullRequestState
218
+ url: string
219
+ isDraft: boolean
220
+ repository: OwnerRepo | null
221
+ author: Actor | null
222
+ labels: Label[]
223
+ createdAt: string | null
224
+ updatedAt: string | null
225
+ closedAt: string | null
226
+ }