@vintasoftware/pr-review-canvas 0.2.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.
Files changed (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -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
+ }