@osdlc/typed-gh 0.6.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 +37 -1
- package/CONTRIBUTING.md +4 -3
- package/README.md +94 -35
- package/dist/async.d.ts +37 -23
- package/dist/async.js +48 -27
- 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 +7 -3
- package/dist/index.js +7 -2
- package/dist/parse.d.ts +0 -1
- package/dist/repo.d.ts +0 -1
- package/dist/sync.d.ts +53 -29
- package/dist/sync.js +64 -33
- package/dist/types.d.ts +0 -1
- package/dist/verbs.d.ts +19 -4
- package/dist/verbs.js +23 -4
- package/package.json +13 -17
- package/async.ts +0 -379
- 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 -120
- package/parse.ts +0 -302
- package/repo.ts +0 -133
- package/sync.ts +0 -438
- package/types.ts +0 -226
- package/verbs.ts +0 -1324
package/parse.ts
DELETED
|
@@ -1,302 +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
|
-
PrSearchHit,
|
|
29
|
-
PullRequest,
|
|
30
|
-
Repository,
|
|
31
|
-
Review,
|
|
32
|
-
ReviewDecision,
|
|
33
|
-
} from './types.ts'
|
|
34
|
-
|
|
35
|
-
// ── primitives ──────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
38
|
-
return typeof x === 'object' && x !== null && !Array.isArray(x)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function str(x: unknown, fallback = ''): string {
|
|
42
|
-
return typeof x === 'string' ? x : fallback
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function strOrNull(x: unknown): string | null {
|
|
46
|
-
return typeof x === 'string' && x !== '' ? x : null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function bool(x: unknown): boolean {
|
|
50
|
-
return x === true
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A comment/review id: GraphQL sends a string node id, REST a numeric one —
|
|
55
|
-
* stringify either. A numeric id of 2^53 or more has already lost precision
|
|
56
|
-
* in `JSON.parse` by the time it gets here.
|
|
57
|
-
*/
|
|
58
|
-
function idOrNull(x: unknown): string | null {
|
|
59
|
-
if (typeof x === 'string' && x !== '') return x
|
|
60
|
-
if (typeof x === 'number' && Number.isFinite(x)) return String(x)
|
|
61
|
-
return null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function list(x: unknown): unknown[] {
|
|
65
|
-
return Array.isArray(x) ? x : []
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ── actors ──────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Strip a single trailing `[bot]` suffix — REST shape folded to GraphQL shape.
|
|
72
|
-
*
|
|
73
|
-
* The one normalization that makes a login from either transport comparable.
|
|
74
|
-
*/
|
|
75
|
-
export function normalizeAuthorLogin(login: string): string {
|
|
76
|
-
return login.endsWith('[bot]') ? login.slice(0, -'[bot]'.length) : login
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Parse an author node from either transport (`author` or REST's `user`).
|
|
81
|
-
* Null when there is no login, or the login is empty once `[bot]` is removed.
|
|
82
|
-
*/
|
|
83
|
-
export function parseActor(x: unknown): Actor | null {
|
|
84
|
-
if (!isRecord(x)) return null
|
|
85
|
-
const raw = str(x['login'])
|
|
86
|
-
const login = normalizeAuthorLogin(raw)
|
|
87
|
-
if (login === '') return null
|
|
88
|
-
return {
|
|
89
|
-
login,
|
|
90
|
-
// `is_bot` is REST/GraphQL-dependent; the suffix is the portable signal.
|
|
91
|
-
isBot: bool(x['is_bot']) || raw.endsWith('[bot]'),
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ── checks ──────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Conclusions that mean the check failed.
|
|
99
|
-
*
|
|
100
|
-
* `ACTION_REQUIRED` is deliberately not here: it means a human must do
|
|
101
|
-
* something, not that the build broke, and the existing consumers treat it as
|
|
102
|
-
* neutral. Widening this set changes triage verdicts, so it is a decision to
|
|
103
|
-
* make on purpose rather than by adding a plausible-looking member.
|
|
104
|
-
*/
|
|
105
|
-
const FAILED_CONCLUSIONS = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE'])
|
|
106
|
-
|
|
107
|
-
/** Legacy StatusContext `state` values that mean failure. */
|
|
108
|
-
const FAILED_STATES = new Set(['FAILURE', 'ERROR'])
|
|
109
|
-
|
|
110
|
-
/** One `statusCheckRollup` entry, whichever node type it is. */
|
|
111
|
-
export function parseCheckRun(x: unknown): CheckRun | null {
|
|
112
|
-
if (!isRecord(x)) return null
|
|
113
|
-
|
|
114
|
-
// Legacy StatusContext: `context` + `state`, no conclusion.
|
|
115
|
-
const context = strOrNull(x['context'])
|
|
116
|
-
if (context !== null) {
|
|
117
|
-
const state = str(x['state']).toUpperCase()
|
|
118
|
-
return {
|
|
119
|
-
name: context,
|
|
120
|
-
status: null,
|
|
121
|
-
conclusion: null,
|
|
122
|
-
verdict: FAILED_STATES.has(state) ? 'failure' : state === 'SUCCESS' ? 'success' : 'pending',
|
|
123
|
-
url: strOrNull(x['targetUrl']),
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const name = strOrNull(x['name'])
|
|
128
|
-
if (name === null) return null
|
|
129
|
-
const status = str(x['status']).toUpperCase()
|
|
130
|
-
const conclusion = strOrNull(x['conclusion']) as CheckConclusion
|
|
131
|
-
// `detailsUrl` is the modern CheckRun field; `targetUrl` is the legacy
|
|
132
|
-
// StatusContext one — a raw payload occasionally carries either under a
|
|
133
|
-
// `name`-shaped node, so read both rather than assume the field follows
|
|
134
|
-
// the node-type split strictly.
|
|
135
|
-
const url = strOrNull(x['detailsUrl']) ?? strOrNull(x['targetUrl'])
|
|
136
|
-
|
|
137
|
-
// Still running: not a pass. Reading "no failure yet" as green merges red.
|
|
138
|
-
if (status !== '' && status !== 'COMPLETED') {
|
|
139
|
-
return { name, status, conclusion, verdict: 'pending', url }
|
|
140
|
-
}
|
|
141
|
-
const verdict =
|
|
142
|
-
conclusion === null
|
|
143
|
-
? 'pending'
|
|
144
|
-
: FAILED_CONCLUSIONS.has(conclusion)
|
|
145
|
-
? 'failure'
|
|
146
|
-
: conclusion === 'SUCCESS'
|
|
147
|
-
? 'success'
|
|
148
|
-
: 'neutral'
|
|
149
|
-
return { name, status: status === '' ? null : status, conclusion, verdict, url }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** True iff any check in the rollup failed. */
|
|
153
|
-
export function anyCheckFailed(checks: readonly CheckRun[]): boolean {
|
|
154
|
-
return checks.some((c) => c.verdict === 'failure')
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── the pull request ────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
/** 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). */
|
|
160
|
-
export function parseLabels(x: unknown): Label[] {
|
|
161
|
-
const out: Label[] = []
|
|
162
|
-
for (const item of list(x)) {
|
|
163
|
-
if (!isRecord(item)) continue
|
|
164
|
-
const name = strOrNull(item['name'])
|
|
165
|
-
if (name === null) continue
|
|
166
|
-
out.push({ name, color: str(item['color']), description: str(item['description']) })
|
|
167
|
-
}
|
|
168
|
-
return out
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function parseComments(x: unknown): Comment[] {
|
|
172
|
-
const out: Comment[] = []
|
|
173
|
-
for (const item of list(x)) {
|
|
174
|
-
if (!isRecord(item)) continue
|
|
175
|
-
out.push({
|
|
176
|
-
id: idOrNull(item['id']),
|
|
177
|
-
// GraphQL says `author`, REST says `user`.
|
|
178
|
-
author: parseActor(item['author'] ?? item['user']),
|
|
179
|
-
body: str(item['body']),
|
|
180
|
-
createdAt: strOrNull(item['createdAt'] ?? item['created_at']),
|
|
181
|
-
})
|
|
182
|
-
}
|
|
183
|
-
return out
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function parseReviews(x: unknown): Review[] {
|
|
187
|
-
const out: Review[] = []
|
|
188
|
-
for (const item of list(x)) {
|
|
189
|
-
if (!isRecord(item)) continue
|
|
190
|
-
out.push({
|
|
191
|
-
id: idOrNull(item['id']),
|
|
192
|
-
author: parseActor(item['author'] ?? item['user']),
|
|
193
|
-
body: str(item['body']),
|
|
194
|
-
state: str(item['state']),
|
|
195
|
-
submittedAt: strOrNull(item['submittedAt'] ?? item['submitted_at']),
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
return out
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** `mergeable` is tri-state; anything unrecognized (or absent) is UNKNOWN. */
|
|
202
|
-
function parseMergeable(x: unknown): MergeableState {
|
|
203
|
-
const v = str(x).toUpperCase()
|
|
204
|
-
return v === 'MERGEABLE' || v === 'CONFLICTING' ? v : 'UNKNOWN'
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** gh returns `""` for "no decision yet"; the model says `null`. */
|
|
208
|
-
function parseReviewDecision(x: unknown): ReviewDecision {
|
|
209
|
-
const v = strOrNull(x)
|
|
210
|
-
return v === 'APPROVED' || v === 'CHANGES_REQUESTED' || v === 'REVIEW_REQUIRED' ? v : null
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* One PR from a `gh --json` object, or null when it carries no usable number.
|
|
215
|
-
*
|
|
216
|
-
* Total by construction: a field `gh` was not asked for lands at its "not
|
|
217
|
-
* known" value rather than `undefined`, so the result is safe to build from
|
|
218
|
-
* any field subset.
|
|
219
|
-
*/
|
|
220
|
-
export function parsePullRequest(raw: unknown): PullRequest | null {
|
|
221
|
-
if (!isRecord(raw)) return null
|
|
222
|
-
const number = raw['number']
|
|
223
|
-
if (typeof number !== 'number' || !Number.isInteger(number)) return null
|
|
224
|
-
|
|
225
|
-
const checks: CheckRun[] = []
|
|
226
|
-
for (const node of list(raw['statusCheckRollup'])) {
|
|
227
|
-
const c = parseCheckRun(node)
|
|
228
|
-
if (c !== null) checks.push(c)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
number,
|
|
233
|
-
state: str(raw['state']).toUpperCase(),
|
|
234
|
-
title: str(raw['title']),
|
|
235
|
-
body: str(raw['body']),
|
|
236
|
-
url: str(raw['url']),
|
|
237
|
-
isDraft: bool(raw['isDraft']),
|
|
238
|
-
baseRefName: str(raw['baseRefName']),
|
|
239
|
-
headRefName: str(raw['headRefName']),
|
|
240
|
-
headRefOid: strOrNull(raw['headRefOid']),
|
|
241
|
-
isCrossRepository: bool(raw['isCrossRepository']),
|
|
242
|
-
mergeable: parseMergeable(raw['mergeable']),
|
|
243
|
-
mergeStateStatus: str(raw['mergeStateStatus'], 'UNKNOWN').toUpperCase(),
|
|
244
|
-
reviewDecision: parseReviewDecision(raw['reviewDecision']),
|
|
245
|
-
author: parseActor(raw['author'] ?? raw['user']),
|
|
246
|
-
labels: parseLabels(raw['labels']),
|
|
247
|
-
checks,
|
|
248
|
-
comments: parseComments(raw['comments']),
|
|
249
|
-
reviews: parseReviews(raw['reviews']),
|
|
250
|
-
mergedAt: strOrNull(raw['mergedAt'] ?? raw['merged_at']),
|
|
251
|
-
createdAt: strOrNull(raw['createdAt'] ?? raw['created_at']),
|
|
252
|
-
updatedAt: strOrNull(raw['updatedAt'] ?? raw['updated_at']),
|
|
253
|
-
}
|
|
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
DELETED
|
@@ -1,133 +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 host, the repository and the PR's number within it. */
|
|
33
|
-
export interface PullRequestRef extends OwnerRepo {
|
|
34
|
-
/** The GitHub host, lower-cased: `github.com`, or an Enterprise host. */
|
|
35
|
-
hostname: string
|
|
36
|
-
number: number
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The `{hostname, owner, repo, number}` a GitHub pull-request URL names, or
|
|
41
|
-
* null when `url` is not one.
|
|
42
|
-
*
|
|
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>`
|
|
49
|
-
* as part of the project path.
|
|
50
|
-
*/
|
|
51
|
-
export function parsePrUrl(url: string): PullRequestRef | null {
|
|
52
|
-
const m =
|
|
53
|
-
/^(?:(https?:)\/\/)?([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
|
|
54
|
-
url.trim(),
|
|
55
|
-
)
|
|
56
|
-
if (m === null) return null
|
|
57
|
-
const host = m[2]!.toLowerCase()
|
|
58
|
-
if (m[1] === undefined && !host.includes('.')) return null
|
|
59
|
-
const number = Number(m[5])
|
|
60
|
-
if (!Number.isSafeInteger(number) || number <= 0) return null
|
|
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'))
|
|
133
|
-
}
|