@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,141 @@
1
+ // @ts-check
2
+ // Ids and lookups for diff rows. The id builders live in keys.js (shared with the server);
3
+ // this module adds the DOM side: finding a row, the nearest visible row, and jumping to a link.
4
+ import { flash, scrollIntoViewSafe } from './dom.js'
5
+ import { buildLineId, fileAnchorId, sanitizeKey } from './keys.js'
6
+ import { linkTargetId, parseLink } from './links.js'
7
+
8
+ export {
9
+ buildLineId,
10
+ fileAnchorId,
11
+ hunkAnchorId,
12
+ hunkId,
13
+ layerAnchorId,
14
+ parseHunkId,
15
+ parseLineId,
16
+ pointAnchorId,
17
+ sanitizeKey,
18
+ } from './keys.js'
19
+
20
+ /** @param {string} path */
21
+ export function keyFromPath(path) {
22
+ return sanitizeKey(path)
23
+ }
24
+
25
+ /**
26
+ * The row for a line, searched inside `root` (a file card or the document).
27
+ * @param {ParentNode} root
28
+ * @param {string} key
29
+ * @param {'new' | 'old'} side
30
+ * @param {number} line
31
+ * @returns {HTMLTableRowElement | null}
32
+ */
33
+ export function findRow(root, key, side, line) {
34
+ const el = root.querySelector(`#${cssEscape(buildLineId(key, side, line))}`)
35
+ if (el instanceof HTMLTableRowElement) {
36
+ return el
37
+ }
38
+ if (side === 'old') {
39
+ // Context rows carry the new-side id and the old line as data; deleted rows have the old-side id.
40
+ const ctx = root.querySelector(`table[data-key="${cssEscape(key)}"] tr[data-old="${line}"]`)
41
+ if (ctx instanceof HTMLTableRowElement) {
42
+ return ctx
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * The closest visible row at or before `line` on `side`, for anchors that fall on a folded-away
50
+ * line or in a gap between rows. Null when the table has no earlier row.
51
+ * @param {ParentNode} root
52
+ * @param {string} key
53
+ * @param {'new' | 'old'} side
54
+ * @param {number} line
55
+ * @param {number} [maxDistance]
56
+ * @returns {{ row: HTMLTableRowElement, approx: boolean } | null}
57
+ */
58
+ export function nearestRow(root, key, side, line, maxDistance = 40) {
59
+ for (let d = 0; d <= maxDistance; d++) {
60
+ for (const candidate of d === 0 ? [line] : [line - d, line + d]) {
61
+ if (candidate < 1) {
62
+ continue
63
+ }
64
+ const row = findRow(root, key, side, candidate)
65
+ if (row && isRowVisible(row)) {
66
+ return { row, approx: d !== 0 }
67
+ }
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ /**
74
+ * Can an anchor land on this row? A row the diff folded away (noise, or a block that only moved)
75
+ * is skipped, so the anchor lands nearby and reads `near line`. A row inside a canvas code fold
76
+ * carries `data-code-fold` and stays reachable: that fold opens when the anchor reveals it.
77
+ * @param {HTMLTableRowElement} row
78
+ * @returns {boolean}
79
+ */
80
+ function isRowVisible(row) {
81
+ if (row.hasAttribute('data-code-fold')) {
82
+ return true
83
+ }
84
+ return !row.classList.contains('folded') || row.classList.contains('shown')
85
+ }
86
+
87
+ /**
88
+ * Scrolls to the element a canvas link points at and highlights it. Returns false when the
89
+ * link does not parse or its target is not on the page.
90
+ * @param {string} href
91
+ * @param {ParentNode} [root]
92
+ * @returns {boolean}
93
+ */
94
+ export function jumpTo(href, root = document) {
95
+ const link = parseLink(href)
96
+ if (!link) {
97
+ return false
98
+ }
99
+ const id = linkTargetId(link, keyFromPath)
100
+ if (link.kind !== 'layer') {
101
+ drawCardOf(root, link.path)
102
+ }
103
+ const el = root.querySelector(`#${cssEscape(id)}`)
104
+ if (!(el instanceof HTMLElement)) {
105
+ return false
106
+ }
107
+ const card = el.closest('.file')
108
+ const body = card?.querySelector('.file-body')
109
+ if (body instanceof HTMLElement && body.hidden) {
110
+ body.hidden = false
111
+ }
112
+ scrollIntoViewSafe(el)
113
+ flash(el)
114
+ return true
115
+ }
116
+
117
+ /**
118
+ * Draws the diff of a file card that is waiting to be seen, so a link into it has a row to land
119
+ * on. The card draws itself (`pr-file` in layers.js); this only asks it to do so now.
120
+ * @param {ParentNode} root
121
+ * @param {string} path
122
+ */
123
+ export function drawCardOf(root, path) {
124
+ const card = root.querySelector(`#${cssEscape(fileAnchorId(keyFromPath(path)))}`)?.closest('pr-file')
125
+ const draw = /** @type {{ renderNow?: (force?: boolean) => boolean }} */ (card)?.renderNow
126
+ if (typeof draw === 'function' && card !== null && card !== undefined) {
127
+ // Force, so a card holding a huge patch behind `[ show diff ]` still has the row to land on.
128
+ draw.call(card, true)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * CSS.escape with a fallback for environments without it.
134
+ * @param {string} id
135
+ */
136
+ export function cssEscape(id) {
137
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
138
+ return CSS.escape(id)
139
+ }
140
+ return id.replace(/([^a-zA-Z0-9_-])/g, '\\$1')
141
+ }
@@ -0,0 +1,542 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
3
+ /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
4
+ /** @typedef {import('./contract-types.js').PatchesResponse} PatchesResponse */
5
+
6
+ /** The server's error envelope as a thrown value. */
7
+ export class ApiError extends Error {
8
+ /**
9
+ * @param {ErrorEnvelope['error']} error
10
+ * @param {number} status
11
+ */
12
+ constructor(error, status) {
13
+ super(error.message)
14
+ this.name = 'ApiError'
15
+ /** @type {ErrorEnvelope['error']['code']} */
16
+ this.code = error.code
17
+ /** @type {string | undefined} */
18
+ this.hint = error.hint
19
+ /** @type {number} */
20
+ this.status = status
21
+ }
22
+ }
23
+
24
+ /**
25
+ * @param {unknown} body
26
+ * @returns {body is ErrorEnvelope}
27
+ */
28
+ function isEnvelope(body) {
29
+ return (
30
+ typeof body === 'object' &&
31
+ body !== null &&
32
+ 'error' in body &&
33
+ typeof body.error === 'object' &&
34
+ body.error !== null &&
35
+ 'code' in body.error
36
+ )
37
+ }
38
+
39
+ /**
40
+ * GET/PUT/POST JSON. Non-2xx answers with the envelope become ApiError; anything else INTERNAL.
41
+ * @template T
42
+ * @param {string} url
43
+ * @param {{ method?: string, body?: unknown, fetchImpl?: typeof fetch | undefined }} [opts]
44
+ * @returns {Promise<T>}
45
+ */
46
+ export async function fetchJson(url, opts = {}) {
47
+ const doFetch = opts.fetchImpl ?? fetch
48
+ /** @type {RequestInit} */
49
+ const init = { method: opts.method ?? 'GET', headers: { accept: 'application/json' } }
50
+ if (opts.body !== undefined) {
51
+ init.headers = { ...init.headers, 'content-type': 'application/json' }
52
+ init.body = JSON.stringify(opts.body)
53
+ }
54
+ const res = await doFetch(url, init)
55
+ /** @type {unknown} */
56
+ let body = null
57
+ try {
58
+ body = await res.json()
59
+ } catch {
60
+ body = null
61
+ }
62
+ if (!res.ok) {
63
+ if (isEnvelope(body)) {
64
+ throw new ApiError(body.error, res.status)
65
+ }
66
+ throw new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
67
+ }
68
+ return /** @type {T} */ (body)
69
+ }
70
+
71
+ /**
72
+ * @param {number} prNumber
73
+ * @param {{ refresh?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
74
+ * @returns {Promise<PrBundle>}
75
+ */
76
+ export function fetchBundle(prNumber, opts = {}) {
77
+ const q = opts.refresh ? '?refresh=1' : ''
78
+ return fetchJson(`/api/prs/${prNumber}${q}`, { fetchImpl: opts.fetchImpl })
79
+ }
80
+
81
+ /**
82
+ * @param {number} prNumber
83
+ * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
84
+ * @returns {Promise<PatchesResponse>}
85
+ */
86
+ export function fetchPatches(prNumber, opts = {}) {
87
+ const q = opts.headSha ? `?headSha=${encodeURIComponent(opts.headSha)}` : ''
88
+ return fetchJson(`/api/prs/${prNumber}/patches${q}`, { fetchImpl: opts.fetchImpl })
89
+ }
90
+
91
+ /**
92
+ * @param {number} prNumber
93
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
94
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
95
+ */
96
+ export function fetchState(prNumber, opts = {}) {
97
+ return fetchJson(`/api/prs/${prNumber}/state`, { fetchImpl: opts.fetchImpl })
98
+ }
99
+
100
+ /**
101
+ * @param {number} prNumber
102
+ * @param {string} id `layer:<id>` or `layer:<id>/file:<key>`
103
+ * @param {boolean} reviewed
104
+ * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
105
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
106
+ */
107
+ export function putReviewed(prNumber, id, reviewed, opts = {}) {
108
+ const body = opts.headSha === undefined ? { reviewed } : { reviewed, headSha: opts.headSha }
109
+ return fetchJson(`/api/prs/${prNumber}/reviewed/${id}`, {
110
+ method: 'PUT',
111
+ body,
112
+ fetchImpl: opts.fetchImpl,
113
+ })
114
+ }
115
+
116
+ /**
117
+ * @param {number} prNumber
118
+ * @param {string} fingerprint
119
+ * @param {boolean} dismissed
120
+ * @param {{ reason?: string, fetchImpl?: typeof fetch }} [opts]
121
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
122
+ */
123
+ export function putDismissed(prNumber, fingerprint, dismissed, opts = {}) {
124
+ const body = opts.reason === undefined ? { dismissed } : { dismissed, reason: opts.reason }
125
+ return fetchJson(`/api/prs/${prNumber}/points/${encodeURIComponent(fingerprint)}/dismissed`, {
126
+ method: 'PUT',
127
+ body,
128
+ fetchImpl: opts.fetchImpl,
129
+ })
130
+ }
131
+
132
+ /**
133
+ * @param {number} prNumber
134
+ * @param {number} rootCommentId
135
+ * @param {boolean} hidden
136
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
137
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
138
+ */
139
+ export function putThreadHidden(prNumber, rootCommentId, hidden, opts = {}) {
140
+ return fetchJson(`/api/prs/${prNumber}/threads/${rootCommentId}/hidden`, {
141
+ method: 'PUT',
142
+ body: { hidden },
143
+ fetchImpl: opts.fetchImpl,
144
+ })
145
+ }
146
+
147
+ /**
148
+ * @param {number} prNumber
149
+ * @param {{ refresh?: boolean, fetchImpl?: typeof fetch }} [opts]
150
+ * @returns {Promise<import('./contract-types.js').Capabilities>}
151
+ */
152
+ export function fetchCapabilities(prNumber, opts = {}) {
153
+ const q = opts.refresh ? '?refresh=1' : ''
154
+ return fetchJson(`/api/prs/${prNumber}/capabilities${q}`, { fetchImpl: opts.fetchImpl })
155
+ }
156
+
157
+ /**
158
+ * @param {number} prNumber
159
+ * @param {import('./contract-types.js').PostCommentInput} input
160
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
161
+ * @returns {Promise<import('./contract-types.js').PostCommentResponse>}
162
+ */
163
+ export function postComment(prNumber, input, opts = {}) {
164
+ return fetchJson(`/api/prs/${prNumber}/comments`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
165
+ }
166
+
167
+ /**
168
+ * @param {number} prNumber
169
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
170
+ * @returns {Promise<import('./contract-types.js').ReviewBodyResponse>}
171
+ */
172
+ export function fetchReviewBody(prNumber, opts = {}) {
173
+ return fetchJson(`/api/prs/${prNumber}/review/body`, { fetchImpl: opts.fetchImpl })
174
+ }
175
+
176
+ /**
177
+ * @param {number} prNumber
178
+ * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
179
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
180
+ * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
181
+ */
182
+ export function postReview(prNumber, input, opts = {}) {
183
+ return fetchJson(`/api/prs/${prNumber}/review`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
184
+ }
185
+
186
+ /** A canvas takes minutes to generate, so the page stops asking every five seconds after a while. */
187
+ export const POLL_INTERVAL_MS = 5000
188
+ export const POLL_SLOW_AFTER_MS = 5 * 60 * 1000
189
+ export const POLL_SLOW_INTERVAL_MS = 15_000
190
+
191
+ /**
192
+ * @typedef {{
193
+ * intervalMs?: number,
194
+ * slowAfterMs?: number,
195
+ * slowIntervalMs?: number,
196
+ * now?: () => number,
197
+ * fetchImpl?: typeof fetch | undefined,
198
+ * until?: (bundle: PrBundle) => boolean,
199
+ * onBundle?: (bundle: PrBundle) => void,
200
+ * onError?: (err: unknown) => void,
201
+ * }} PollOptions
202
+ */
203
+
204
+ /**
205
+ * Re-fetches the bundle every 5 s until `until` says so (default: `ready`) or `stop()` is called.
206
+ * After five minutes of waiting the gap grows to 15 s, because a generation that has not finished
207
+ * by then takes minutes more. A failed poll is reported and polling goes on; the page never
208
+ * blocks on it.
209
+ * @param {number} prNumber
210
+ * @param {PollOptions} [opts]
211
+ * @returns {{ stop: () => void }}
212
+ */
213
+ export function pollBundle(prNumber, opts = {}) {
214
+ const interval = opts.intervalMs ?? POLL_INTERVAL_MS
215
+ const slowAfter = opts.slowAfterMs ?? POLL_SLOW_AFTER_MS
216
+ const slowInterval = opts.slowIntervalMs ?? POLL_SLOW_INTERVAL_MS
217
+ const now = opts.now ?? (() => Date.now())
218
+ const startedAt = now()
219
+ const nextGap = () => (now() - startedAt >= slowAfter ? Math.max(interval, slowInterval) : interval)
220
+ const until = opts.until ?? (b => b.status === 'ready')
221
+ let stopped = false
222
+ /** @type {ReturnType<typeof setTimeout> | null} */
223
+ let timer = null
224
+ const tick = async () => {
225
+ timer = null
226
+ /** @type {PrBundle} */
227
+ let bundle
228
+ try {
229
+ bundle = await fetchBundle(prNumber, { fetchImpl: opts.fetchImpl })
230
+ } catch (err) {
231
+ if (!stopped) {
232
+ opts.onError?.(err)
233
+ timer = setTimeout(tick, nextGap())
234
+ }
235
+ return
236
+ }
237
+ if (stopped) {
238
+ return
239
+ }
240
+ if (until(bundle)) {
241
+ stopped = true
242
+ } else {
243
+ timer = setTimeout(tick, nextGap())
244
+ }
245
+ opts.onBundle?.(bundle)
246
+ }
247
+ timer = setTimeout(tick, interval)
248
+ return {
249
+ stop: () => {
250
+ stopped = true
251
+ if (timer !== null) {
252
+ clearTimeout(timer)
253
+ timer = null
254
+ }
255
+ },
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A multipart POST through XHR, so the drop zone can show upload progress. The response is the
261
+ * server's JSON, and a non-2xx answer becomes the same ApiError as fetchJson's.
262
+ * @template T
263
+ * @param {string} url
264
+ * @param {FormData} form
265
+ * @param {{ onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
266
+ * @returns {Promise<T>}
267
+ */
268
+ export function uploadForm(url, form, opts = {}) {
269
+ const xhr = opts.xhrImpl ? opts.xhrImpl() : new XMLHttpRequest()
270
+ return new Promise((resolve, reject) => {
271
+ xhr.open('POST', url)
272
+ xhr.setRequestHeader('accept', 'application/json')
273
+ const onProgress = opts.onProgress
274
+ if (onProgress !== undefined) {
275
+ xhr.upload.onprogress = event => {
276
+ if (event.lengthComputable && event.total > 0) {
277
+ onProgress(event.loaded / event.total)
278
+ }
279
+ }
280
+ }
281
+ xhr.onerror = () => reject(new ApiError({ code: 'INTERNAL', message: 'the upload failed' }, 0))
282
+ xhr.onload = () => {
283
+ /** @type {unknown} */
284
+ let body = null
285
+ try {
286
+ body = JSON.parse(xhr.responseText)
287
+ } catch {
288
+ body = null
289
+ }
290
+ if (xhr.status >= 200 && xhr.status < 300) {
291
+ resolve(/** @type {T} */ (body))
292
+ return
293
+ }
294
+ reject(
295
+ isEnvelope(body)
296
+ ? new ApiError(body.error, xhr.status)
297
+ : new ApiError({ code: 'INTERNAL', message: `${xhr.status} ${xhr.statusText}` }, xhr.status)
298
+ )
299
+ }
300
+ xhr.send(form)
301
+ })
302
+ }
303
+
304
+ /**
305
+ * @param {number} prNumber
306
+ * @param {File} file
307
+ * @param {{ force?: boolean, onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
308
+ * @returns {Promise<import('./contract-types.js').ImportResult>}
309
+ */
310
+ export function importCanvas(prNumber, file, opts = {}) {
311
+ const form = new FormData()
312
+ form.append('file', file)
313
+ if (opts.force) {
314
+ form.append('force', '1')
315
+ }
316
+ return uploadForm(`/api/prs/${prNumber}/import`, form, opts)
317
+ }
318
+
319
+ /**
320
+ * @param {number} prNumber
321
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
322
+ * @returns {Promise<import('./contract-types.js').SharedCanvasFetchResponse>}
323
+ */
324
+ export function fetchSharedCanvas(prNumber, opts = {}) {
325
+ return fetchJson(`/api/prs/${prNumber}/shared-canvas/fetch`, { method: 'POST', fetchImpl: opts.fetchImpl })
326
+ }
327
+
328
+ /**
329
+ * The zip as a blob plus the name the server chose, ready to hand to the browser.
330
+ * @param {number} prNumber
331
+ * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
332
+ * @returns {Promise<{ blob: Blob, filename: string }>}
333
+ */
334
+ export async function fetchCanvasZip(prNumber, opts = {}) {
335
+ const doFetch = opts.fetchImpl ?? fetch
336
+ const q = opts.headSha ? `?headSha=${encodeURIComponent(opts.headSha)}` : ''
337
+ const res = await doFetch(`/api/prs/${prNumber}/export${q}`, { headers: { accept: 'application/zip' } })
338
+ if (!res.ok) {
339
+ /** @type {unknown} */
340
+ let body = null
341
+ try {
342
+ body = await res.json()
343
+ } catch {
344
+ body = null
345
+ }
346
+ throw isEnvelope(body)
347
+ ? new ApiError(body.error, res.status)
348
+ : new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
349
+ }
350
+ return { blob: await res.blob(), filename: filenameFromDisposition(res.headers.get('content-disposition')) }
351
+ }
352
+
353
+ /**
354
+ * @param {string | null} header
355
+ * @returns {string}
356
+ */
357
+ export function filenameFromDisposition(header) {
358
+ const m = header === null ? null : /filename="([^"]+)"/.exec(header)
359
+ return m?.[1] ?? 'pr-review-canvas.zip'
360
+ }
361
+
362
+ /* ---- The look of the page ---- */
363
+
364
+ /**
365
+ * Saves the skin, the theme, or both. Its own route, so it works in a repository with chat off.
366
+ * @param {import('./contract-types.js').AppearanceInput} input
367
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
368
+ * @returns {Promise<import('./contract-types.js').AppearanceResponse>}
369
+ */
370
+ export function saveAppearance(input, opts = {}) {
371
+ return fetchJson('/api/appearance', { method: 'PUT', body: input, fetchImpl: opts.fetchImpl })
372
+ }
373
+
374
+ /* ---- AI Chat and its settings ---- */
375
+
376
+ /**
377
+ * @param {number} prNumber
378
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
379
+ * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
380
+ */
381
+ export function fetchThreads(prNumber, opts = {}) {
382
+ return fetchJson(`/api/prs/${prNumber}/chat/threads`, { fetchImpl: opts.fetchImpl })
383
+ }
384
+
385
+ /**
386
+ * @param {number} prNumber
387
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
388
+ * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
389
+ */
390
+ export function createThread(prNumber, opts = {}) {
391
+ return fetchJson(`/api/prs/${prNumber}/chat/threads`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
392
+ }
393
+
394
+ /**
395
+ * @param {number} prNumber
396
+ * @param {string} name
397
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
398
+ * @returns {Promise<import('./contract-types.js').ChatHistoryResponse>}
399
+ */
400
+ export function fetchThreadHistory(prNumber, name, opts = {}) {
401
+ return fetchJson(`/api/prs/${prNumber}/chat/threads/${encodeURIComponent(name)}/history`, {
402
+ fetchImpl: opts.fetchImpl,
403
+ })
404
+ }
405
+
406
+ /**
407
+ * @param {number} prNumber
408
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
409
+ * @returns {Promise<{ cancelled: boolean }>}
410
+ */
411
+ export function cancelChat(prNumber, opts = {}) {
412
+ return fetchJson(`/api/prs/${prNumber}/chat/cancel`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
413
+ }
414
+
415
+ /**
416
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
417
+ * @returns {Promise<import('./contract-types.js').SettingsResponse>}
418
+ */
419
+ export function fetchSettings(opts = {}) {
420
+ return fetchJson('/api/settings', { fetchImpl: opts.fetchImpl })
421
+ }
422
+
423
+ /**
424
+ * @param {import('./contract-types.js').SettingsInput} input
425
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
426
+ * @returns {Promise<import('./contract-types.js').SettingsResponse>}
427
+ */
428
+ export function saveSettings(input, opts = {}) {
429
+ return fetchJson('/api/settings', { method: 'PUT', body: input, fetchImpl: opts.fetchImpl })
430
+ }
431
+
432
+ /**
433
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
434
+ * @returns {Promise<import('./contract-types.js').AgentsResponse>}
435
+ */
436
+ export function fetchAgents(opts = {}) {
437
+ return fetchJson('/api/settings/agents', { fetchImpl: opts.fetchImpl })
438
+ }
439
+
440
+ /**
441
+ * @param {string} id
442
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
443
+ * @returns {Promise<import('./contract-types.js').AgentProbeResult>}
444
+ */
445
+ export function probeAgent(id, opts = {}) {
446
+ return fetchJson(`/api/settings/agents/${encodeURIComponent(id)}/probe`, {
447
+ method: 'POST',
448
+ body: {},
449
+ fetchImpl: opts.fetchImpl,
450
+ })
451
+ }
452
+
453
+ /**
454
+ * Reads an `event:`/`data:` stream into named events. A frame the server did not finish writing
455
+ * stays in the buffer until its blank line arrives.
456
+ * @param {string} buffer what is left over from the last chunk
457
+ * @param {string} chunk
458
+ * @returns {{ events: Array<{ event: string, data: unknown }>, buffer: string }}
459
+ */
460
+ export function readSseFrames(buffer, chunk) {
461
+ const text = `${buffer}${chunk}`.replace(/\r\n/g, '\n')
462
+ const parts = text.split('\n\n')
463
+ const rest = parts.pop() ?? ''
464
+ /** @type {Array<{ event: string, data: unknown }>} */
465
+ const events = []
466
+ for (const frame of parts) {
467
+ let name = 'message'
468
+ /** @type {string[]} */
469
+ const data = []
470
+ for (const line of frame.split('\n')) {
471
+ if (line.startsWith('event:')) {
472
+ name = line.slice(6).trim()
473
+ } else if (line.startsWith('data:')) {
474
+ data.push(line.slice(5).trim())
475
+ }
476
+ }
477
+ if (data.length === 0) {
478
+ continue
479
+ }
480
+ try {
481
+ events.push({ event: name, data: JSON.parse(data.join('\n')) })
482
+ } catch {
483
+ events.push({ event: name, data: null })
484
+ }
485
+ }
486
+ return { events, buffer: rest }
487
+ }
488
+
489
+ /**
490
+ * Sends one chat message and calls `onEvent` for every frame until the turn ends. Rejects with
491
+ * an ApiError when the server refuses the message (a busy chat, a context it cannot resolve).
492
+ * @param {number} prNumber
493
+ * @param {{ message: string, context: import('./chat-context.js').ChatContext, thread?: string }} input
494
+ * @param {{
495
+ * onEvent: (event: { event: string, data: unknown }) => void,
496
+ * signal?: AbortSignal,
497
+ * fetchImpl?: typeof fetch,
498
+ * }} opts
499
+ * @returns {Promise<void>}
500
+ */
501
+ export async function streamChat(prNumber, input, opts) {
502
+ const doFetch = opts.fetchImpl ?? fetch
503
+ /** @type {RequestInit} */
504
+ const init = {
505
+ method: 'POST',
506
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
507
+ body: JSON.stringify(input),
508
+ }
509
+ if (opts.signal !== undefined) {
510
+ init.signal = opts.signal
511
+ }
512
+ const res = await doFetch(`/api/prs/${prNumber}/chat`, init)
513
+ if (!res.ok) {
514
+ /** @type {unknown} */
515
+ let body = null
516
+ try {
517
+ body = await res.json()
518
+ } catch {
519
+ body = null
520
+ }
521
+ throw isEnvelope(body)
522
+ ? new ApiError(body.error, res.status)
523
+ : new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
524
+ }
525
+ const reader = res.body?.getReader()
526
+ if (reader === undefined) {
527
+ return
528
+ }
529
+ const decoder = new TextDecoder()
530
+ let buffer = ''
531
+ for (;;) {
532
+ const { value, done } = await reader.read()
533
+ if (done) {
534
+ break
535
+ }
536
+ const read = readSseFrames(buffer, decoder.decode(value, { stream: true }))
537
+ buffer = read.buffer
538
+ for (const event of read.events) {
539
+ opts.onEvent(event)
540
+ }
541
+ }
542
+ }