@osdlc/typed-gh 0.5.0 → 0.7.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/parse.ts DELETED
@@ -1,252 +0,0 @@
1
- /**
2
- * `gh --json` payloads → the typed model.
3
- *
4
- * Everything here is pure, so an async fan-out can reuse it without going
5
- * near a subprocess, and so the awkward cases can be tested as data.
6
- *
7
- * Two shape hazards this file exists to absorb:
8
- *
9
- * 1. **Author logins differ by transport.** GraphQL (`gh pr view --json`)
10
- * returns a suffix-less login (`github-actions`); REST (`gh api`) returns
11
- * the suffixed one (`github-actions[bot]`). Comparing one against the
12
- * other silently never matches, which is how bot noise gets treated as
13
- * human review feedback. Logins are normalized to the suffix-less form
14
- * here, once.
15
- * 2. **`statusCheckRollup` holds two different node types.** A modern
16
- * `CheckRun` carries `name`/`status`/`conclusion`; a legacy
17
- * `StatusContext` carries `context`/`state`. They are folded onto one
18
- * verdict axis so a caller never has to know which it got.
19
- */
20
-
21
- import type {
22
- Actor,
23
- CheckConclusion,
24
- CheckRun,
25
- Comment,
26
- Label,
27
- MergeableState,
28
- PullRequest,
29
- Review,
30
- ReviewDecision,
31
- } from './types.ts'
32
-
33
- // ── primitives ──────────────────────────────────────────────────────────────
34
-
35
- function isRecord(x: unknown): x is Record<string, unknown> {
36
- return typeof x === 'object' && x !== null && !Array.isArray(x)
37
- }
38
-
39
- function str(x: unknown, fallback = ''): string {
40
- return typeof x === 'string' ? x : fallback
41
- }
42
-
43
- function strOrNull(x: unknown): string | null {
44
- return typeof x === 'string' && x !== '' ? x : null
45
- }
46
-
47
- function bool(x: unknown): boolean {
48
- return x === true
49
- }
50
-
51
- /**
52
- * A comment/review id: GraphQL sends a string node id, REST a numeric one —
53
- * stringify either. A numeric id of 2^53 or more has already lost precision
54
- * in `JSON.parse` by the time it gets here.
55
- */
56
- function idOrNull(x: unknown): string | null {
57
- if (typeof x === 'string' && x !== '') return x
58
- if (typeof x === 'number' && Number.isFinite(x)) return String(x)
59
- return null
60
- }
61
-
62
- function list(x: unknown): unknown[] {
63
- return Array.isArray(x) ? x : []
64
- }
65
-
66
- // ── actors ──────────────────────────────────────────────────────────────────
67
-
68
- /**
69
- * Strip a single trailing `[bot]` suffix — REST shape folded to GraphQL shape.
70
- *
71
- * The one normalization that makes a login from either transport comparable.
72
- */
73
- export function normalizeAuthorLogin(login: string): string {
74
- return login.endsWith('[bot]') ? login.slice(0, -'[bot]'.length) : login
75
- }
76
-
77
- /**
78
- * Parse an author node from either transport (`author` or REST's `user`).
79
- * Null when there is no login, or the login is empty once `[bot]` is removed.
80
- */
81
- export function parseActor(x: unknown): Actor | null {
82
- if (!isRecord(x)) return null
83
- const raw = str(x['login'])
84
- const login = normalizeAuthorLogin(raw)
85
- if (login === '') return null
86
- return {
87
- login,
88
- // `is_bot` is REST/GraphQL-dependent; the suffix is the portable signal.
89
- isBot: bool(x['is_bot']) || raw.endsWith('[bot]'),
90
- }
91
- }
92
-
93
- // ── checks ──────────────────────────────────────────────────────────────────
94
-
95
- /**
96
- * Conclusions that mean the check failed.
97
- *
98
- * `ACTION_REQUIRED` is deliberately not here: it means a human must do
99
- * something, not that the build broke, and the existing consumers treat it as
100
- * neutral. Widening this set changes triage verdicts, so it is a decision to
101
- * make on purpose rather than by adding a plausible-looking member.
102
- */
103
- const FAILED_CONCLUSIONS = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE'])
104
-
105
- /** Legacy StatusContext `state` values that mean failure. */
106
- const FAILED_STATES = new Set(['FAILURE', 'ERROR'])
107
-
108
- /** One `statusCheckRollup` entry, whichever node type it is. */
109
- export function parseCheckRun(x: unknown): CheckRun | null {
110
- if (!isRecord(x)) return null
111
-
112
- // Legacy StatusContext: `context` + `state`, no conclusion.
113
- const context = strOrNull(x['context'])
114
- if (context !== null) {
115
- const state = str(x['state']).toUpperCase()
116
- return {
117
- name: context,
118
- status: null,
119
- conclusion: null,
120
- verdict: FAILED_STATES.has(state) ? 'failure' : state === 'SUCCESS' ? 'success' : 'pending',
121
- url: strOrNull(x['targetUrl']),
122
- }
123
- }
124
-
125
- const name = strOrNull(x['name'])
126
- if (name === null) return null
127
- const status = str(x['status']).toUpperCase()
128
- const conclusion = strOrNull(x['conclusion']) as CheckConclusion
129
- // `detailsUrl` is the modern CheckRun field; `targetUrl` is the legacy
130
- // StatusContext one — a raw payload occasionally carries either under a
131
- // `name`-shaped node, so read both rather than assume the field follows
132
- // the node-type split strictly.
133
- const url = strOrNull(x['detailsUrl']) ?? strOrNull(x['targetUrl'])
134
-
135
- // Still running: not a pass. Reading "no failure yet" as green merges red.
136
- if (status !== '' && status !== 'COMPLETED') {
137
- return { name, status, conclusion, verdict: 'pending', url }
138
- }
139
- const verdict =
140
- conclusion === null
141
- ? 'pending'
142
- : FAILED_CONCLUSIONS.has(conclusion)
143
- ? 'failure'
144
- : conclusion === 'SUCCESS'
145
- ? 'success'
146
- : 'neutral'
147
- return { name, status: status === '' ? null : status, conclusion, verdict, url }
148
- }
149
-
150
- /** True iff any check in the rollup failed. */
151
- export function anyCheckFailed(checks: readonly CheckRun[]): boolean {
152
- return checks.some((c) => c.verdict === 'failure')
153
- }
154
-
155
- // ── the pull request ────────────────────────────────────────────────────────
156
-
157
- /** Parse a `labels` field (PR, issue, or a bare `gh label list` array) into {@link Label} rows — the one label-array parse every `gh` caller shares. Skips an item with no name, or an empty-string name (indistinguishable from absent). */
158
- export function parseLabels(x: unknown): Label[] {
159
- const out: Label[] = []
160
- for (const item of list(x)) {
161
- if (!isRecord(item)) continue
162
- const name = strOrNull(item['name'])
163
- if (name === null) continue
164
- out.push({ name, color: str(item['color']), description: str(item['description']) })
165
- }
166
- return out
167
- }
168
-
169
- function parseComments(x: unknown): Comment[] {
170
- const out: Comment[] = []
171
- for (const item of list(x)) {
172
- if (!isRecord(item)) continue
173
- out.push({
174
- id: idOrNull(item['id']),
175
- // GraphQL says `author`, REST says `user`.
176
- author: parseActor(item['author'] ?? item['user']),
177
- body: str(item['body']),
178
- createdAt: strOrNull(item['createdAt'] ?? item['created_at']),
179
- })
180
- }
181
- return out
182
- }
183
-
184
- function parseReviews(x: unknown): Review[] {
185
- const out: Review[] = []
186
- for (const item of list(x)) {
187
- if (!isRecord(item)) continue
188
- out.push({
189
- id: idOrNull(item['id']),
190
- author: parseActor(item['author'] ?? item['user']),
191
- body: str(item['body']),
192
- state: str(item['state']),
193
- submittedAt: strOrNull(item['submittedAt'] ?? item['submitted_at']),
194
- })
195
- }
196
- return out
197
- }
198
-
199
- /** `mergeable` is tri-state; anything unrecognized (or absent) is UNKNOWN. */
200
- function parseMergeable(x: unknown): MergeableState {
201
- const v = str(x).toUpperCase()
202
- return v === 'MERGEABLE' || v === 'CONFLICTING' ? v : 'UNKNOWN'
203
- }
204
-
205
- /** gh returns `""` for "no decision yet"; the model says `null`. */
206
- function parseReviewDecision(x: unknown): ReviewDecision {
207
- const v = strOrNull(x)
208
- return v === 'APPROVED' || v === 'CHANGES_REQUESTED' || v === 'REVIEW_REQUIRED' ? v : null
209
- }
210
-
211
- /**
212
- * One PR from a `gh --json` object, or null when it carries no usable number.
213
- *
214
- * Total by construction: a field `gh` was not asked for lands at its "not
215
- * known" value rather than `undefined`, so the result is safe to build from
216
- * any field subset.
217
- */
218
- export function parsePullRequest(raw: unknown): PullRequest | null {
219
- if (!isRecord(raw)) return null
220
- const number = raw['number']
221
- if (typeof number !== 'number' || !Number.isInteger(number)) return null
222
-
223
- const checks: CheckRun[] = []
224
- for (const node of list(raw['statusCheckRollup'])) {
225
- const c = parseCheckRun(node)
226
- if (c !== null) checks.push(c)
227
- }
228
-
229
- return {
230
- number,
231
- state: str(raw['state']).toUpperCase(),
232
- title: str(raw['title']),
233
- body: str(raw['body']),
234
- url: str(raw['url']),
235
- isDraft: bool(raw['isDraft']),
236
- baseRefName: str(raw['baseRefName']),
237
- headRefName: str(raw['headRefName']),
238
- headRefOid: strOrNull(raw['headRefOid']),
239
- isCrossRepository: bool(raw['isCrossRepository']),
240
- mergeable: parseMergeable(raw['mergeable']),
241
- mergeStateStatus: str(raw['mergeStateStatus'], 'UNKNOWN').toUpperCase(),
242
- reviewDecision: parseReviewDecision(raw['reviewDecision']),
243
- author: parseActor(raw['author'] ?? raw['user']),
244
- labels: parseLabels(raw['labels']),
245
- checks,
246
- comments: parseComments(raw['comments']),
247
- reviews: parseReviews(raw['reviews']),
248
- mergedAt: strOrNull(raw['mergedAt'] ?? raw['merged_at']),
249
- createdAt: strOrNull(raw['createdAt'] ?? raw['created_at']),
250
- updatedAt: strOrNull(raw['updatedAt'] ?? raw['updated_at']),
251
- }
252
- }
package/repo.ts DELETED
@@ -1,59 +0,0 @@
1
- /**
2
- * Turning a git remote into a GitHub coordinate.
3
- *
4
- * This is `@osdlc/typed-git`'s `parseGitUrl` applied to the one question a
5
- * GitHub client always ends up asking. It lives here rather than in each
6
- * caller because the hand-rolled version is always a regex over
7
- * `remote.origin.url`, and that regex is always missing a URL shape —
8
- * scp-style, a trailing `.git`, an `ssh://` port, a bare `owner/repo`.
9
- */
10
-
11
- import { parseGitUrl } from '@osdlc/typed-git'
12
-
13
- import type { OwnerRepo } from './types.ts'
14
-
15
- /**
16
- * `{owner, repo}` from any git remote reference, or null when it names no
17
- * project. Handles https, ssh, scp-style, and bare `owner/repo`.
18
- */
19
- export function parseOwnerRepo(remoteUrl: string): OwnerRepo | null {
20
- const info = parseGitUrl(remoteUrl)
21
- if (info === null) return null
22
- const { user, project } = info
23
- if (!user || !project) return null
24
- return { owner: user, repo: project }
25
- }
26
-
27
- /** `owner/repo`, the form `gh api` paths and `--repo` both take. */
28
- export function ownerRepoSlug(r: OwnerRepo): string {
29
- return `${r.owner}/${r.repo}`
30
- }
31
-
32
- /** A pull request coordinate: the repository and the PR's number within it. */
33
- export interface PullRequestRef extends OwnerRepo {
34
- number: number
35
- }
36
-
37
- /**
38
- * The `{owner, repo, number}` a GitHub pull-request URL names, or null when
39
- * `url` is not one.
40
- *
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>`
45
- * 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
- export function parsePrUrl(url: string): PullRequestRef | null {
51
- const m =
52
- /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
53
- url.trim(),
54
- )
55
- if (m === null) return null
56
- const number = Number(m[3])
57
- if (!Number.isSafeInteger(number) || number <= 0) return null
58
- return { owner: m[1]!, repo: m[2]!.replace(/\.git$/, ''), number }
59
- }
package/sync.ts DELETED
@@ -1,326 +0,0 @@
1
- /**
2
- * `Gh` — the synchronous GitHub client, the companion of `@osdlc/typed-git`'s
3
- * `Git`, bound to one working directory and one {@link CommandRunner}.
4
- *
5
- * Authentication is `gh`'s, not ours: no token is read, stored, or passed.
6
- * That is the whole reason this shells out to the CLI instead of speaking
7
- * REST — the credential already exists in the user's keychain, and a library
8
- * that reaches for `GITHUB_TOKEN` is a library that can leak one.
9
- *
10
- * Tri-modal, same as the git client: queries return parsed values and never
11
- * throw, mutations throw `CommandFailed`, and `raw`/`rawChecked` are the
12
- * escape hatches.
13
- */
14
-
15
- import {
16
- runChecked,
17
- resolveRunner,
18
- spawnRunner,
19
- type CommandResult,
20
- type CommandRunner,
21
- type CommandRunOptions,
22
- } from '@osdlc/command-seam'
23
-
24
- import * as v from './verbs.ts'
25
- import type {
26
- ApiOptions,
27
- ApiResponse,
28
- AuthStatus,
29
- GhVerb,
30
- IssueListOptions,
31
- PrListOptions,
32
- } from './verbs.ts'
33
- import { parseOwnerRepo } from './repo.ts'
34
- import type { IssueRow, Label, OwnerRepo, PullRequest } from './types.ts'
35
-
36
- /**
37
- * Factory of per-binary runners — the injectable subprocess seam a CLI tail
38
- * threads through its call graph. Production uses `spawnRunner`; tests supply
39
- * a recording factory so a whole tail runs offline.
40
- */
41
- export type RunnerFactory = (binary: string) => CommandRunner
42
-
43
- /** Default factory: real, spawnSync-backed runners. */
44
- export const defaultRunnerFactory: RunnerFactory = spawnRunner
45
-
46
- /** How long to wait between merge-state retries, in milliseconds. */
47
- const MERGE_STATE_RETRY_DELAY_MS = 1_500
48
-
49
- /**
50
- * A typed gh client bound to one working directory.
51
- *
52
- * Every `timeoutMs` option is a hard cap on the call. An invalid value
53
- * throws, as in command-seam: `RangeError` for 0, a negative value or `NaN`;
54
- * `TypeError` for a non-number.
55
- */
56
- export class Gh {
57
- readonly cwd: string
58
- private readonly runner: CommandRunner
59
-
60
- constructor(cwd: string, opts: { runner?: CommandRunner | undefined } = {}) {
61
- this.cwd = cwd
62
- this.runner = resolveRunner('gh', opts.runner)
63
- }
64
-
65
- /** The same client rooted at a different directory (same runner). */
66
- at(cwd: string): Gh {
67
- return new Gh(cwd, { runner: this.runner })
68
- }
69
-
70
- // ── escape hatches ────────────────────────────────────────────────────────
71
-
72
- /** Unchecked passthrough for verbs not yet lifted into the API. */
73
- raw(argv: string[], opts: { timeoutMs?: number | undefined } = {}): CommandResult {
74
- return this.runner.run(argv, { cwd: this.cwd, ...opts })
75
- }
76
-
77
- /** Checked passthrough — throws `CommandFailed` on a non-zero exit. */
78
- rawChecked(argv: string[], opts: { timeoutMs?: number | undefined } = {}): CommandResult {
79
- return runChecked(this.runner, argv, { cwd: this.cwd, ...opts })
80
- }
81
-
82
- /** Run one verb from the table, honouring its checked/unchecked contract. */
83
- private exec<T>(verb: GhVerb<T>, timeoutMs?: number | undefined): T {
84
- const opts: CommandRunOptions = { cwd: this.cwd }
85
- if (verb.input !== undefined) opts.input = verb.input
86
- if (timeoutMs !== undefined) opts.timeoutMs = timeoutMs
87
- const result = verb.checked
88
- ? runChecked(this.runner, verb.argv, opts)
89
- : this.runner.run(verb.argv, opts)
90
- return verb.parse(result)
91
- }
92
-
93
- // ── queries ───────────────────────────────────────────────────────────────
94
-
95
- /** 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)
98
- }
99
-
100
- /** One PR with the heavy fields, or null when gh failed. */
101
- prView(
102
- number: number,
103
- opts: { fields?: readonly string[] | undefined; timeoutMs?: number | undefined } = {},
104
- ): PullRequest | null {
105
- return this.exec(v.prView(number, opts.fields ?? v.PR_VIEW_FIELDS), opts.timeoutMs)
106
- }
107
-
108
- /**
109
- * The open PR whose head exactly equals `branch`, or null.
110
- *
111
- * Defaults to {@link PR_REF_FIELDS}: identifying a PR must not cost a
112
- * merge-state computation. Pass `opts.fields` to ask for more (e.g.
113
- * `isDraft`) in the SAME call, when a caller needs a field beyond identity
114
- * before deciding whether to act on the PR at all — asking `prList`/`prView`
115
- * separately would cost a second round trip for the common case where the
116
- * caller already knows it wants that field up front.
117
- */
118
- openPrFor(
119
- branch: string,
120
- opts: { limit?: number | undefined; fields?: readonly string[] | undefined } = {},
121
- ): PullRequest | null {
122
- const rows = this.prList({
123
- head: branch,
124
- limit: opts.limit ?? 5,
125
- fields: opts.fields ?? v.PR_REF_FIELDS,
126
- })
127
- return v.pickPrForBranch(rows, branch)
128
- }
129
-
130
- /**
131
- * {@link prView}, re-asked until GitHub settles the merge state.
132
- *
133
- * GitHub computes mergeability lazily: the first query kicks it off and
134
- * answers `UNKNOWN` while it runs. A caller that believes that first answer
135
- * mis-triages every PR that is merely slow to compute, so this re-asks
136
- * `attempts` times before returning what it has. The last answer is
137
- * returned whether or not it settled — `UNKNOWN` is a legitimate final
138
- * verdict and callers must still handle it.
139
- *
140
- * `sleep` is injected so a test does not spend real seconds waiting.
141
- */
142
- prViewSettled(
143
- number: number,
144
- opts: {
145
- attempts?: number | undefined
146
- fields?: readonly string[] | undefined
147
- sleep?: ((ms: number) => void) | undefined
148
- timeoutMs?: number | undefined
149
- } = {},
150
- ): PullRequest | null {
151
- const attempts = Math.max(1, opts.attempts ?? 3)
152
- const sleep = opts.sleep ?? sleepSync
153
- let last: PullRequest | null = null
154
- for (let i = 0; i < attempts; i += 1) {
155
- last = this.prView(number, opts)
156
- if (last === null || !v.needsMergeStateRetry(last)) return last
157
- if (i < attempts - 1) sleep(MERGE_STATE_RETRY_DELAY_MS)
158
- }
159
- return last
160
- }
161
-
162
- /** A PR's body text, or null when gh failed. */
163
- prBody(number: number, opts: { timeoutMs?: number | undefined } = {}): string | null {
164
- return this.exec(v.prBody(number), opts.timeoutMs)
165
- }
166
-
167
- /**
168
- * A REST GET through `gh api`. Never throws: failure is `ok: false` on the
169
- * returned {@link ApiResponse}.
170
- */
171
- api(endpoint: string, opts: ApiOptions & { timeoutMs?: number | undefined } = {}): ApiResponse {
172
- return this.exec(v.api(endpoint, opts), opts.timeoutMs)
173
- }
174
-
175
- /** Every label on the repository (up to `opts.limit`, default 1000), or null when gh failed. */
176
- labelList(
177
- opts: { limit?: number | undefined; timeoutMs?: number | undefined } = {},
178
- ): Label[] | null {
179
- return this.exec(v.labelList(opts), opts.timeoutMs)
180
- }
181
-
182
- /** 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)
185
- }
186
-
187
- /** One issue with its labels, or null when gh failed. */
188
- issueView(number: number, opts: { timeoutMs?: number | undefined } = {}): IssueRow | null {
189
- return this.exec(v.issueView(number), opts.timeoutMs)
190
- }
191
-
192
- /** True iff the `gh` binary is on PATH and runs. */
193
- installed(): boolean {
194
- return this.exec(v.installed())
195
- }
196
-
197
- /**
198
- * True iff `gh` holds credentials. Asked on its own only by a caller that
199
- * reports the two halves separately — "gh is missing" and "gh is not logged
200
- * in" are different things to tell a person, and a report that collapses
201
- * them sends someone to install software they already have.
202
- */
203
- authenticated(): boolean {
204
- return this.authStatus().ok
205
- }
206
-
207
- /** Login state and what `gh auth status` printed; `authenticated()` is its `ok`. */
208
- authStatus(): AuthStatus {
209
- return this.exec(v.authStatus())
210
- }
211
-
212
- /**
213
- * True iff `gh` is installed AND authenticated — the two-step probe.
214
- *
215
- * Short-circuits: an absent binary cannot be asked about credentials.
216
- */
217
- ready(): boolean {
218
- return this.installed() && this.authenticated()
219
- }
220
-
221
- /**
222
- * `{owner, repo}` parsed from the remote URL you pass, or null when it is
223
- * null or names no project.
224
- *
225
- * Pure: it reads no git config, starts no process and does not ask GitHub.
226
- */
227
- ownerRepo(remoteUrl: string | null): OwnerRepo | null {
228
- return remoteUrl === null ? null : parseOwnerRepo(remoteUrl)
229
- }
230
-
231
- // ── mutations (checked) ───────────────────────────────────────────────────
232
-
233
- /** Open a PR and return its URL ('' if gh printed none). */
234
- createPr(opts: v.CreatePrOptions): string {
235
- return this.exec(v.createPr(opts))
236
- }
237
-
238
- /** Retitle or rewrite a PR body. */
239
- editPr(number: number, opts: { title?: string | undefined; body?: string | undefined }): void {
240
- this.exec(v.editPr(number, opts))
241
- }
242
-
243
- /** The PR's unified diff; throws `CommandFailed` when gh cannot produce it. */
244
- prDiff(number: number): string {
245
- return this.exec(v.prDiff(number))
246
- }
247
-
248
- /** Post one conversation comment on a PR; returns its URL ('' if gh printed none). */
249
- prComment(number: number, body: string): string {
250
- return this.exec(v.prComment(number, body))
251
- }
252
-
253
- /**
254
- * Create a `COMMENT` review with inline comments; returns the review's URL ('' if none came back).
255
- *
256
- * @experimental
257
- */
258
- createReview(number: number, opts: v.CreateReviewOptions): string {
259
- return this.exec(v.createReview(number, opts))
260
- }
261
-
262
- /** Rewrite just a PR's body. */
263
- editPrBody(number: number, body: string): void {
264
- this.exec(v.editPrBody(number, body))
265
- }
266
-
267
- /** Bring a PR's head branch up to date with its base. */
268
- prUpdateBranch(number: number, style: 'merge' | 'rebase' = 'merge'): void {
269
- this.exec(v.prUpdateBranch(number, style))
270
- }
271
-
272
- /** Mark a PR ready for review (`draft: false`) or convert it to a draft (`draft: true`). */
273
- prSetDraft(number: number, draft: boolean): void {
274
- this.exec(v.prSetDraft(number, draft))
275
- }
276
-
277
- /**
278
- * Rewrite one PR conversation comment's body. `commentId` is the numeric REST id; anything else throws `RangeError`.
279
- *
280
- * @experimental
281
- */
282
- issueCommentEdit(commentId: string, body: string): void {
283
- this.exec(v.issueCommentEdit(commentId, body))
284
- }
285
-
286
- /** Merge a PR; GitHub refuses when its head is no longer `opts.headSha`. */
287
- prMerge(number: number, opts: { method: v.PrMergeMethod; headSha: string }): void {
288
- this.exec(v.prMerge(number, opts))
289
- }
290
-
291
- /** A PR's review threads, or null when they could not be read completely. */
292
- prReviewThreads(number: number): v.ReviewThread[] | null {
293
- return this.exec(v.prReviewThreads(number))
294
- }
295
-
296
- /** Create a label (or force it to `opts`'s color/description if it already exists). */
297
- createLabel(opts: v.CreateLabelOptions): void {
298
- this.exec(v.createLabel(opts))
299
- }
300
-
301
- /** Recolor and/or redescribe an existing label. */
302
- editLabel(
303
- name: string,
304
- opts: { color?: string | undefined; description?: string | undefined },
305
- ): void {
306
- this.exec(v.editLabel(name, opts))
307
- }
308
-
309
- /**
310
- * Swap an issue's label: remove `currentLabel` (when given) and add
311
- * `newLabel`, in a single `gh issue edit` call — never a briefly-unlabeled
312
- * or briefly-double-labeled intermediate state.
313
- */
314
- setIssueLabel(number: number, currentLabel: string | null, newLabel: string): void {
315
- this.exec(v.setIssueLabel(number, currentLabel, newLabel))
316
- }
317
- }
318
-
319
- /**
320
- * Block the calling thread. Crude on purpose: the synchronous client has no
321
- * event loop to await on, and the alternative — making every caller async to
322
- * accommodate one retry delay — colors the whole call graph.
323
- */
324
- function sleepSync(ms: number): void {
325
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
326
- }