@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/CHANGELOG.md +61 -1
- package/CONTRIBUTING.md +4 -3
- package/README.md +247 -56
- package/dist/async.d.ts +70 -28
- package/dist/async.js +127 -44
- package/dist/doors.d.ts +82 -0
- package/dist/doors.js +183 -0
- package/dist/failure.d.ts +59 -0
- package/dist/failure.js +93 -0
- package/dist/index.d.ts +12 -8
- package/dist/index.js +9 -4
- package/dist/parse.d.ts +5 -2
- package/dist/parse.js +48 -0
- package/dist/repo.d.ts +42 -11
- package/dist/repo.js +76 -12
- package/dist/sync.d.ts +104 -28
- package/dist/sync.js +155 -46
- package/dist/types.d.ts +37 -1
- package/dist/verbs.d.ts +213 -45
- package/dist/verbs.js +504 -86
- package/package.json +13 -17
- package/async.ts +0 -285
- package/dist/async.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/parse.d.ts.map +0 -1
- package/dist/repo.d.ts.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/verbs.d.ts.map +0 -1
- package/index.ts +0 -97
- package/parse.ts +0 -252
- package/repo.ts +0 -59
- package/sync.ts +0 -326
- package/types.ts +0 -186
- package/verbs.ts +0 -799
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
|
-
}
|