@vintasoftware/pr-review-canvas 0.3.0 → 0.4.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/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Finding the canvas zip a human attached to the review and downloading it with the host CLI's
|
|
2
|
+
// token. The token is read per request, sent to the forge alone, and never logged or stored.
|
|
3
|
+
import { readCanvasComment } from '../canvas/comment.js'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { importCanvas } from '../canvas/import.js'
|
|
6
|
+
import { type ParsedCanvasName, parseCanvasZipName } from '../canvas/name.js'
|
|
7
|
+
import { CANVAS_ZIP_MAX_BYTES, hasZipMagic } from '../canvas/zip.js'
|
|
8
|
+
import type { ImportResult, SharedCanvasInfo } from '../contract/api.js'
|
|
9
|
+
import type { CommentsPayload } from '../contract/comments.js'
|
|
10
|
+
import type { Pr, Repo } from '../contract/review-artifact.js'
|
|
11
|
+
import { BodyTooLargeError, readCappedBody } from '../server/capped-body.js'
|
|
12
|
+
import type { AppContext } from '../server/context.js'
|
|
13
|
+
import { AppError } from '../server/errors.js'
|
|
14
|
+
import type { HostAttachments } from './host.js'
|
|
15
|
+
|
|
16
|
+
export const MAX_REDIRECTS = 3
|
|
17
|
+
export const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
18
|
+
|
|
19
|
+
export type DownloadFailure = NonNullable<SharedCanvasInfo['reason']>
|
|
20
|
+
|
|
21
|
+
export interface AttachmentLink {
|
|
22
|
+
url: string
|
|
23
|
+
name: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AttachmentCandidate extends AttachmentLink {
|
|
27
|
+
bytes?: Uint8Array
|
|
28
|
+
parsed: ParsedCanvasName
|
|
29
|
+
/**
|
|
30
|
+
* When the text carrying the link was last edited. A link added to an old comment counts from
|
|
31
|
+
* the edit, which is when the attachment appeared.
|
|
32
|
+
*/
|
|
33
|
+
postedAt: string
|
|
34
|
+
/** Position in the scan, used when two links carry the same time. */
|
|
35
|
+
order: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DiscoverySources {
|
|
39
|
+
/** The PR body and the time the pull request was last edited. */
|
|
40
|
+
body: string
|
|
41
|
+
bodyUpdatedAt: string
|
|
42
|
+
comments: CommentsPayload
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every link in the PR body and its comments whose name is a canvas zip of this repository. */
|
|
46
|
+
export function collectCandidates(
|
|
47
|
+
sources: DiscoverySources,
|
|
48
|
+
repo: Repo,
|
|
49
|
+
attachments: HostAttachments
|
|
50
|
+
): AttachmentCandidate[] {
|
|
51
|
+
const texts: Array<{ text: string; postedAt: string }> = [
|
|
52
|
+
{ text: sources.body, postedAt: sources.bodyUpdatedAt },
|
|
53
|
+
...sources.comments.issueComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
54
|
+
...sources.comments.reviewComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
55
|
+
]
|
|
56
|
+
const candidates: AttachmentCandidate[] = []
|
|
57
|
+
let order = 0
|
|
58
|
+
for (const { text, postedAt } of texts) {
|
|
59
|
+
for (const link of attachments.findLinks(text, repo)) {
|
|
60
|
+
const parsed = parseCanvasZipName(link.name, repo)
|
|
61
|
+
if (parsed !== null) {
|
|
62
|
+
candidates.push({ ...link, parsed, postedAt, order: order++ })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const comment of sources.comments.issueComments) {
|
|
67
|
+
const embedded = readCanvasComment(comment.body)
|
|
68
|
+
if (embedded === null) continue
|
|
69
|
+
const parsed = parseCanvasZipName(embedded.name, repo)
|
|
70
|
+
if (parsed !== null) {
|
|
71
|
+
candidates.push({ ...embedded, url: comment.url, parsed, postedAt: comment.updatedAt, order: order++ })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return candidates
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the name names a pull request, and it is not the one being looked at. */
|
|
78
|
+
function namesAnotherPr(c: AttachmentCandidate, prNumber: number): boolean {
|
|
79
|
+
return c.parsed.prNumber !== undefined && c.parsed.prNumber !== prNumber
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The canvas for this head wins, then one exported for this PR, then the one attached last. A name
|
|
84
|
+
* that carries another pull request's number sorts below all of them: import refuses it, so it may
|
|
85
|
+
* never take a place in the try budget from an attachment that could be used.
|
|
86
|
+
*/
|
|
87
|
+
export function rankCandidates(
|
|
88
|
+
candidates: AttachmentCandidate[],
|
|
89
|
+
target: { headSha: string; prNumber: number }
|
|
90
|
+
): AttachmentCandidate[] {
|
|
91
|
+
const shaPrefix = target.headSha.slice(0, 8)
|
|
92
|
+
const score = (c: AttachmentCandidate): number =>
|
|
93
|
+
namesAnotherPr(c, target.prNumber)
|
|
94
|
+
? -1
|
|
95
|
+
: (c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
|
|
96
|
+
return [...candidates].sort(
|
|
97
|
+
(a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How many attachments one scan tries before it reports the best one as unusable. */
|
|
102
|
+
export const MAX_CANDIDATES_TRIED = 3
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A fingerprint of everything discovery reads, so an unchanged pull request is not scanned again.
|
|
106
|
+
* It covers the text of every comment, because an edit can swap one zip link for another, and the
|
|
107
|
+
* head sha, because the ranking and `namesHead` are answers about that commit.
|
|
108
|
+
*/
|
|
109
|
+
export function discoveryFingerprint(body: string, comments: CommentsPayload, headSha: string): string {
|
|
110
|
+
const hash = createHash('sha1')
|
|
111
|
+
hash.update(`${headSha}\n${body}`)
|
|
112
|
+
for (const c of [...comments.issueComments, ...comments.reviewComments]) {
|
|
113
|
+
hash.update(`\u0000${c.id}:${c.createdAt}:${c.body}`)
|
|
114
|
+
}
|
|
115
|
+
return hash.digest('hex')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type DownloadResult = { ok: true; bytes: Uint8Array } | { ok: false; reason: DownloadFailure }
|
|
119
|
+
|
|
120
|
+
function allowedUrl(raw: string, hosts: ReadonlySet<string>, base?: string): URL | null {
|
|
121
|
+
let url: URL
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(raw, base)
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return url.protocol === 'https:' && hosts.has(url.host) ? url : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Downloads one attachment. The CLI token goes to the forge alone: a redirect leads to signed
|
|
134
|
+
* storage, which refuses a request that holds both a signature and a token, and a link that
|
|
135
|
+
* points at storage from the start is fetched bare for the same reason.
|
|
136
|
+
*/
|
|
137
|
+
export async function downloadAttachment(ctx: AppContext, rawUrl: string): Promise<DownloadResult> {
|
|
138
|
+
const { hostname, attachments } = ctx.config.host
|
|
139
|
+
const { allowedHosts, authHeader } = attachments
|
|
140
|
+
const first = allowedUrl(rawUrl, allowedHosts)
|
|
141
|
+
if (first === null) {
|
|
142
|
+
return { ok: false, reason: 'network' }
|
|
143
|
+
}
|
|
144
|
+
const token = await ctx.gh.authToken()
|
|
145
|
+
if (token === null) {
|
|
146
|
+
return { ok: false, reason: 'auth-required' }
|
|
147
|
+
}
|
|
148
|
+
let url = first
|
|
149
|
+
// One deadline for the redirects and the body together, so three slow hops cannot add up.
|
|
150
|
+
const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
151
|
+
try {
|
|
152
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
153
|
+
const accept = 'application/octet-stream'
|
|
154
|
+
const headers = url.host === hostname ? { accept, ...authHeader(token) } : { accept }
|
|
155
|
+
const res: Response = await ctx.fetch(url, {
|
|
156
|
+
headers,
|
|
157
|
+
redirect: 'manual',
|
|
158
|
+
signal: deadline,
|
|
159
|
+
})
|
|
160
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
161
|
+
const next = allowedUrl(res.headers.get('location') ?? '', allowedHosts, url.toString())
|
|
162
|
+
if (next === null) {
|
|
163
|
+
return { ok: false, reason: 'network' }
|
|
164
|
+
}
|
|
165
|
+
url = next
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
169
|
+
return { ok: false, reason: 'auth-required' }
|
|
170
|
+
}
|
|
171
|
+
if (res.status !== 200) {
|
|
172
|
+
return { ok: false, reason: 'network' }
|
|
173
|
+
}
|
|
174
|
+
let bytes: Uint8Array
|
|
175
|
+
try {
|
|
176
|
+
bytes = await readCappedBody(res, CANVAS_ZIP_MAX_BYTES)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof BodyTooLargeError) {
|
|
179
|
+
return { ok: false, reason: 'too-large' }
|
|
180
|
+
}
|
|
181
|
+
throw err
|
|
182
|
+
}
|
|
183
|
+
return hasZipMagic(bytes) ? { ok: true, bytes } : { ok: false, reason: 'not-zip' }
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// A network failure, a timeout, or a body that stopped mid-stream all read the same here.
|
|
187
|
+
return { ok: false, reason: 'network' }
|
|
188
|
+
}
|
|
189
|
+
return { ok: false, reason: 'network' }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A downloaded zip that import refused, told in the words the callout uses. */
|
|
193
|
+
export function importFailureReason(err: unknown): DownloadFailure {
|
|
194
|
+
if (!(err instanceof AppError)) {
|
|
195
|
+
return 'not-zip'
|
|
196
|
+
}
|
|
197
|
+
if (err.code === 'CANVAS_TOO_LARGE') {
|
|
198
|
+
return 'too-large'
|
|
199
|
+
}
|
|
200
|
+
if (err.code === 'CANVAS_PR_MISMATCH') {
|
|
201
|
+
return 'pr-mismatch'
|
|
202
|
+
}
|
|
203
|
+
return err.code === 'CANVAS_REPO_MISMATCH' ? 'name-mismatch' : 'not-zip'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface DiscoveryOutcome {
|
|
207
|
+
sharedCanvas: SharedCanvasInfo | null
|
|
208
|
+
imported: ImportResult | null
|
|
209
|
+
warnings: string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Looks for a canvas attached to the PR and imports the best one. A found-but-unusable zip is
|
|
214
|
+
* reported to the page, which offers the link and the drop zone instead.
|
|
215
|
+
*/
|
|
216
|
+
export async function discoverSharedCanvas(
|
|
217
|
+
ctx: AppContext,
|
|
218
|
+
pr: Pr,
|
|
219
|
+
comments: CommentsPayload
|
|
220
|
+
): Promise<DiscoveryOutcome> {
|
|
221
|
+
if (pr.number === null) {
|
|
222
|
+
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
223
|
+
}
|
|
224
|
+
const sources: DiscoverySources = { body: pr.body, bodyUpdatedAt: pr.updatedAt ?? '', comments }
|
|
225
|
+
const ranked = rankCandidates(collectCandidates(sources, ctx.config.repo, ctx.config.host.attachments), {
|
|
226
|
+
headSha: pr.headSha,
|
|
227
|
+
prNumber: pr.number,
|
|
228
|
+
})
|
|
229
|
+
const best = ranked[0]
|
|
230
|
+
if (best === undefined) {
|
|
231
|
+
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
232
|
+
}
|
|
233
|
+
const warnings: string[] = []
|
|
234
|
+
const first = await tryCandidate(ctx, pr, pr.number, best)
|
|
235
|
+
warnings.push(...first.warnings)
|
|
236
|
+
if (first.imported !== null) {
|
|
237
|
+
return { ...first, warnings }
|
|
238
|
+
}
|
|
239
|
+
// A broken attachment must not hide a good one behind it, so the next ones are tried too.
|
|
240
|
+
for (const candidate of ranked.slice(1, MAX_CANDIDATES_TRIED)) {
|
|
241
|
+
const attempt = await tryCandidate(ctx, pr, pr.number, candidate)
|
|
242
|
+
warnings.push(...attempt.warnings)
|
|
243
|
+
if (attempt.imported !== null) {
|
|
244
|
+
return { ...attempt, warnings }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Every attachment failed; the page shows the best one, its reason, and the drop zone.
|
|
248
|
+
return { ...first, warnings }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One attachment: download it and import it, or say why that did not work. */
|
|
252
|
+
async function tryCandidate(
|
|
253
|
+
ctx: AppContext,
|
|
254
|
+
pr: Pr,
|
|
255
|
+
prNumber: number,
|
|
256
|
+
candidate: AttachmentCandidate
|
|
257
|
+
): Promise<DiscoveryOutcome> {
|
|
258
|
+
const shared = {
|
|
259
|
+
url: candidate.url,
|
|
260
|
+
name: candidate.name,
|
|
261
|
+
namesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
|
|
262
|
+
}
|
|
263
|
+
// A name that carries another pull request's number is a zip attached to the wrong PR. Import
|
|
264
|
+
// would refuse it anyway, so it is reported without spending a download on it.
|
|
265
|
+
if (namesAnotherPr(candidate, prNumber)) {
|
|
266
|
+
return {
|
|
267
|
+
sharedCanvas: { ...shared, downloadable: false, reason: 'pr-mismatch' },
|
|
268
|
+
imported: null,
|
|
269
|
+
warnings: [`${candidate.name} was exported for #${candidate.parsed.prNumber}, not #${prNumber}`],
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const download: DownloadResult =
|
|
273
|
+
candidate.bytes === undefined
|
|
274
|
+
? await downloadAttachment(ctx, candidate.url)
|
|
275
|
+
: { ok: true, bytes: candidate.bytes }
|
|
276
|
+
if (!download.ok) {
|
|
277
|
+
return {
|
|
278
|
+
sharedCanvas: { ...shared, downloadable: false, reason: download.reason },
|
|
279
|
+
imported: null,
|
|
280
|
+
warnings: [],
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const imported = await importCanvas(ctx, { bytes: download.bytes, prNumber, currentHead: pr })
|
|
285
|
+
return { sharedCanvas: { ...shared, downloadable: true }, imported, warnings: imported.warnings }
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return {
|
|
288
|
+
sharedCanvas: { ...shared, downloadable: false, reason: importFailureReason(err) },
|
|
289
|
+
imported: null,
|
|
290
|
+
warnings: [err instanceof Error ? err.message : String(err)],
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Capabilities } from '../contract/api.js'
|
|
2
|
+
|
|
3
|
+
/** What a page assumes before the probe answers: posting is tried and the host decides. */
|
|
4
|
+
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
5
|
+
canComment: 'unknown',
|
|
6
|
+
tokenKind: 'unprobed',
|
|
7
|
+
login: null,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
|
|
11
|
+
export const CAPABILITY_TTL_MS = 10 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
export interface CapabilityProbe {
|
|
14
|
+
get(opts?: { refresh?: boolean }): Promise<Capabilities>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The probe with its cache. One per server process; `refresh` skips the cache after the user
|
|
19
|
+
* changed their token.
|
|
20
|
+
*/
|
|
21
|
+
export function createCapabilityProbe(
|
|
22
|
+
probe: () => Promise<Capabilities>,
|
|
23
|
+
now: () => Date,
|
|
24
|
+
ttlMs = CAPABILITY_TTL_MS
|
|
25
|
+
): CapabilityProbe {
|
|
26
|
+
let cached: { at: number; value: Capabilities } | null = null
|
|
27
|
+
return {
|
|
28
|
+
get: async (opts = {}) => {
|
|
29
|
+
const at = now().getTime()
|
|
30
|
+
if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
|
|
31
|
+
return cached.value
|
|
32
|
+
}
|
|
33
|
+
const value = await probe()
|
|
34
|
+
cached = { at, value }
|
|
35
|
+
return value
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The forge operations the tool needs, all through the host's own CLI (`gh` or `glab`) so the
|
|
6
|
+
* user's login is used and no token is ever read by this code. The two CLIs share one command
|
|
7
|
+
* grammar, so one client serves both; the spec names the binary and the instance. Routes receive
|
|
8
|
+
* an implementation through AppContext; tests use an in-memory fake.
|
|
9
|
+
*/
|
|
10
|
+
export interface HostClient {
|
|
11
|
+
/** `<cli> api --method GET <path>` with optional query params; returns the parsed JSON body. */
|
|
12
|
+
api(path: string, params?: Record<string, string>): Promise<unknown>
|
|
13
|
+
/** `<cli> api -i --method GET <path>`: the response headers as well as the body. */
|
|
14
|
+
apiWithHeaders(path: string): Promise<CliResponse>
|
|
15
|
+
/** Send JSON over stdin. Defaults to POST; comment edits use PATCH or PUT. */
|
|
16
|
+
post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT'): Promise<unknown>
|
|
17
|
+
/** `<cli> api graphql`; returns the parsed `data` object. */
|
|
18
|
+
graphql(query: string, variables: Record<string, string | number>): Promise<unknown>
|
|
19
|
+
/** `<cli> auth status`: is the CLI installed and logged in? */
|
|
20
|
+
authStatus(): Promise<{ installed: boolean; authenticated: boolean; detail: string }>
|
|
21
|
+
/**
|
|
22
|
+
* The token of the current login, or null when there is none. Callers keep it in a local
|
|
23
|
+
* variable for the length of one request; it is never logged or written to disk.
|
|
24
|
+
*/
|
|
25
|
+
authToken(): Promise<string | null>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type HostCli = 'gh' | 'glab'
|
|
29
|
+
|
|
30
|
+
/** What differs between the two CLIs. Everything else is the same argument list. */
|
|
31
|
+
export interface HostCliSpec {
|
|
32
|
+
cli: HostCli
|
|
33
|
+
/** Variables the child gets on top of the process environment; `glab` learns its instance here. */
|
|
34
|
+
env: Record<string, string>
|
|
35
|
+
/** The command that prints the current login's token. */
|
|
36
|
+
tokenArgs: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The words the doctor and error hints use for each CLI. */
|
|
40
|
+
export const CLI_INFO: Record<HostCli, { label: string; installUrl: string; loginCommand: string }> = {
|
|
41
|
+
gh: { label: 'GitHub CLI (gh)', installUrl: 'https://cli.github.com', loginCommand: 'gh auth login' },
|
|
42
|
+
glab: {
|
|
43
|
+
label: 'GitLab CLI (glab)',
|
|
44
|
+
installUrl: 'https://gitlab.com/gitlab-org/cli',
|
|
45
|
+
loginCommand: 'glab auth login',
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One HTTP answer from `<cli> api -i`: the status, the header names in lower case, and the body. */
|
|
50
|
+
export interface CliResponse {
|
|
51
|
+
status: number
|
|
52
|
+
headers: Record<string, string>
|
|
53
|
+
body: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Splits the output of `<cli> api -i` into the last header block and the body. A redirect prints
|
|
58
|
+
* one block per hop, and only the final one describes the answer.
|
|
59
|
+
*/
|
|
60
|
+
export function parseIncludedResponse(stdout: string): CliResponse {
|
|
61
|
+
const normalized = stdout.replace(/\r\n/g, '\n')
|
|
62
|
+
const parts = normalized.split('\n\n')
|
|
63
|
+
let body: unknown = null
|
|
64
|
+
const headerBlocks: string[] = []
|
|
65
|
+
for (const [i, part] of parts.entries()) {
|
|
66
|
+
if (/^HTTP\/[\d.]+ \d{3}/.test(part)) {
|
|
67
|
+
headerBlocks.push(part)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
// Everything after the last header block is the body, which may hold blank lines itself.
|
|
71
|
+
body = parseJsonOrNull(parts.slice(i).join('\n\n'))
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
const last = headerBlocks[headerBlocks.length - 1] ?? ''
|
|
75
|
+
const lines = last.split('\n').filter(l => l.trim() !== '')
|
|
76
|
+
const status = Number(/^HTTP\/[\d.]+ (\d{3})/.exec(lines[0] ?? '')?.[1] ?? 0)
|
|
77
|
+
const headers: Record<string, string> = {}
|
|
78
|
+
for (const line of lines.slice(1)) {
|
|
79
|
+
const at = line.indexOf(':')
|
|
80
|
+
if (at > 0) {
|
|
81
|
+
headers[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { status, headers, body }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseJsonOrNull(text: string): unknown {
|
|
88
|
+
if (text.trim() === '') {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(text) as unknown
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class HostCliError extends Error {
|
|
99
|
+
readonly cli: HostCli
|
|
100
|
+
readonly path: string
|
|
101
|
+
readonly stderr: string
|
|
102
|
+
readonly exitCode: number
|
|
103
|
+
/** True when the CLI binary is not on PATH. */
|
|
104
|
+
readonly missingBinary: boolean
|
|
105
|
+
|
|
106
|
+
constructor(cli: HostCli, path: string, stderr: string, exitCode: number, missingBinary = false) {
|
|
107
|
+
super(`${cli} api ${path} failed (${exitCode}): ${stderr.trim()}`)
|
|
108
|
+
this.name = 'HostCliError'
|
|
109
|
+
this.cli = cli
|
|
110
|
+
this.path = path
|
|
111
|
+
this.stderr = stderr
|
|
112
|
+
this.exitCode = exitCode
|
|
113
|
+
this.missingBinary = missingBinary
|
|
114
|
+
}
|
|
115
|
+
/** Both CLIs exit 1 and name the status in stderr for a missing resource. */
|
|
116
|
+
get notFound(): boolean {
|
|
117
|
+
return /HTTP 404|404 Not Found/i.test(this.stderr)
|
|
118
|
+
}
|
|
119
|
+
get unauthenticated(): boolean {
|
|
120
|
+
return /HTTP 401|401 Unauthorized|not logged in|auth login/i.test(this.stderr)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ExecResult {
|
|
125
|
+
stdout: string
|
|
126
|
+
stderr: string
|
|
127
|
+
code: number
|
|
128
|
+
missingBinary: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CliExecOptions {
|
|
132
|
+
binary?: string
|
|
133
|
+
env?: Record<string, string>
|
|
134
|
+
/** Written to the child's stdin, for `api --input -`. */
|
|
135
|
+
input?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function execCli(args: string[], opts: CliExecOptions = {}): Promise<ExecResult> {
|
|
139
|
+
return new Promise(resolve => {
|
|
140
|
+
const child = execFile(
|
|
141
|
+
opts.binary ?? 'gh',
|
|
142
|
+
args,
|
|
143
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...opts.env } },
|
|
144
|
+
(error, stdout, stderr) => {
|
|
145
|
+
const missingBinary = error !== null && 'code' in error && error.code === 'ENOENT'
|
|
146
|
+
const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
|
|
147
|
+
resolve({ stdout, stderr, code, missingBinary })
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
if (opts.input !== undefined) {
|
|
151
|
+
child.stdin?.on('error', () => undefined)
|
|
152
|
+
child.stdin?.end(opts.input)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type CliExec = typeof execCli
|
|
158
|
+
|
|
159
|
+
export const GH_CLI: HostCliSpec = { cli: 'gh', env: {}, tokenArgs: ['auth', 'token'] }
|
|
160
|
+
|
|
161
|
+
/** `glab` reads its instance from GITLAB_HOST, and prints a login's token through `config get`. */
|
|
162
|
+
export function glabCli(hostname: string): HostCliSpec {
|
|
163
|
+
return {
|
|
164
|
+
cli: 'glab',
|
|
165
|
+
env: { GITLAB_HOST: hostname },
|
|
166
|
+
tokenArgs: ['config', 'get', 'token', '--host', hostname],
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createHostClient(spec: HostCliSpec, exec: CliExec = execCli): HostClient {
|
|
171
|
+
const run = async (path: string, args: string[], input?: string): Promise<ExecResult> => {
|
|
172
|
+
const r = await exec(args, { binary: spec.cli, env: spec.env, ...(input === undefined ? {} : { input }) })
|
|
173
|
+
if (r.code !== 0) {
|
|
174
|
+
throw new HostCliError(
|
|
175
|
+
spec.cli,
|
|
176
|
+
path,
|
|
177
|
+
r.missingBinary ? `${spec.cli}: command not found` : r.stderr,
|
|
178
|
+
r.code,
|
|
179
|
+
r.missingBinary
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return r
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
api: async (path, params = {}) => {
|
|
186
|
+
// `api` switches to POST as soon as a field is given; these are reads, so pin GET.
|
|
187
|
+
const args = ['api', '--method', 'GET', path]
|
|
188
|
+
for (const [k, v] of Object.entries(params)) {
|
|
189
|
+
args.push('-F', `${k}=${v}`)
|
|
190
|
+
}
|
|
191
|
+
return JSON.parse((await run(path, args)).stdout) as unknown
|
|
192
|
+
},
|
|
193
|
+
apiWithHeaders: async path =>
|
|
194
|
+
parseIncludedResponse((await run(path, ['api', '-i', '--method', 'GET', path])).stdout),
|
|
195
|
+
post: async (path, body, method = 'POST') => {
|
|
196
|
+
// The payload goes over stdin, so no comment text ever appears in an argument list. The
|
|
197
|
+
// content type is named because `glab` does not infer it and GitLab answers 415 without it.
|
|
198
|
+
const args = ['api', '--method', method, path, '--input', '-', '-H', 'Content-Type: application/json']
|
|
199
|
+
return JSON.parse((await run(path, args, JSON.stringify(body))).stdout) as unknown
|
|
200
|
+
},
|
|
201
|
+
graphql: async (query, variables) => {
|
|
202
|
+
const args = ['api', 'graphql', '-f', `query=${query}`]
|
|
203
|
+
for (const [k, v] of Object.entries(variables)) {
|
|
204
|
+
args.push(typeof v === 'number' ? '-F' : '-f', `${k}=${v}`)
|
|
205
|
+
}
|
|
206
|
+
const body = JSON.parse((await run('graphql', args)).stdout) as {
|
|
207
|
+
data?: unknown
|
|
208
|
+
errors?: Array<{ message: string }>
|
|
209
|
+
}
|
|
210
|
+
if (body.errors && body.errors.length > 0) {
|
|
211
|
+
throw new HostCliError(spec.cli, 'graphql', body.errors.map(e => e.message).join('; '), 1)
|
|
212
|
+
}
|
|
213
|
+
return body.data
|
|
214
|
+
},
|
|
215
|
+
authStatus: async () => {
|
|
216
|
+
const r = await exec(['auth', 'status'], { binary: spec.cli, env: spec.env })
|
|
217
|
+
if (r.missingBinary) {
|
|
218
|
+
return { installed: false, authenticated: false, detail: `${spec.cli} is not on PATH` }
|
|
219
|
+
}
|
|
220
|
+
const detail = (r.stdout + r.stderr).trim().split('\n')[0] ?? ''
|
|
221
|
+
return { installed: true, authenticated: r.code === 0, detail }
|
|
222
|
+
},
|
|
223
|
+
authToken: async () => {
|
|
224
|
+
const r = await exec(spec.tokenArgs, { binary: spec.cli, env: spec.env })
|
|
225
|
+
const token = r.stdout.trim()
|
|
226
|
+
return r.code === 0 && token !== '' ? token : null
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const COMMENTS_PAGE_SIZE = 100
|
|
232
|
+
|
|
233
|
+
/** Every item of a REST list endpoint, following `page=` until a page comes back short. */
|
|
234
|
+
export async function fetchAllPages(client: HostClient, path: string): Promise<unknown[]> {
|
|
235
|
+
const out: unknown[] = []
|
|
236
|
+
for (let page = 1; ; page++) {
|
|
237
|
+
const batch = z
|
|
238
|
+
.array(z.unknown())
|
|
239
|
+
.parse(await client.api(path, { per_page: String(COMMENTS_PAGE_SIZE), page: String(page) }))
|
|
240
|
+
out.push(...batch)
|
|
241
|
+
if (batch.length < COMMENTS_PAGE_SIZE) {
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|