@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/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
- }