@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,418 @@
1
+ // @ts-check
2
+ // Boot: read the bootstrap JSON, fetch the bundle and the patches together, render the screen for
3
+ // the bundle's status, and keep the page current: the empty screen polls until a canvas is
4
+ // published, and the regenerate dialog polls until a newer canvas replaces the one on screen.
5
+ /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
6
+ import {
7
+ ApiError,
8
+ fetchBundle,
9
+ fetchPatches,
10
+ fetchSharedCanvas,
11
+ importCanvas,
12
+ pollBundle,
13
+ saveAppearance,
14
+ } from './api.js'
15
+ import { setChatEnabled } from './ask.js'
16
+ import { readChatWidth, renderChatShell, wireChat } from './chat.js'
17
+ import { runCommand, wireCopyCommands } from './commands.js'
18
+ import { initDeepLinks } from './deep-link.js'
19
+ import { initDiagrams } from './diagram.js'
20
+ import { esc, qs } from './dom.js'
21
+ import { exportCanvasZip } from './download.js'
22
+ import { renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
23
+ import { errorCardHtml } from './errors.js'
24
+ import { renderHeader } from './header.js'
25
+ import { wireDropZone } from './import-zone.js'
26
+ import { toast, wireReview } from './interactions.js'
27
+ import { defineLayerElements, pathSet, renderLayers, renderRail, setRenderContext } from './layers.js'
28
+ import { renderOverview } from './overview.js'
29
+ import { wireQuickQuestions } from './quick-questions.js'
30
+ import { canvasChanged, openRegenerateDialog } from './regenerate.js'
31
+ import { createReviewSession } from './review-session.js'
32
+ import { initScrollSpy } from './scroll-spy.js'
33
+ import { openSettingsDialog } from './settings.js'
34
+ import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
35
+ import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
36
+
37
+ /** @typedef {{ prNumber: number, owner: string, repo: string, version: string }} Bootstrap */
38
+
39
+ /** @returns {Bootstrap | null} */
40
+ function readBootstrap() {
41
+ const el = document.getElementById('bootstrap')
42
+ if (!el?.textContent) {
43
+ return null
44
+ }
45
+ try {
46
+ return /** @type {Bootstrap} */ (JSON.parse(el.textContent))
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} version
54
+ * @param {PrBundle} [bundle]
55
+ */
56
+ function footerHtml(version, bundle) {
57
+ const canvas = bundle?.canvas
58
+ ? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
59
+ : ''
60
+ return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>`
61
+ }
62
+
63
+ /** @param {ReadonlyArray<string>} warnings */
64
+ function bannerHtml(warnings) {
65
+ return warnings.length === 0 ? '' : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
66
+ }
67
+
68
+ /**
69
+ * @param {unknown} err
70
+ * @returns {import('./contract-types.js').ErrorEnvelope['error']}
71
+ */
72
+ function toEnvelopeError(err) {
73
+ if (!(err instanceof ApiError)) {
74
+ return { code: 'INTERNAL', message: String(err) }
75
+ }
76
+ return err.hint === undefined
77
+ ? { code: err.code, message: err.message }
78
+ : { code: err.code, message: err.message, hint: err.hint }
79
+ }
80
+
81
+ /**
82
+ * The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
83
+ * parallel fetch can land before the patches exist; one more try after the bundle is enough.
84
+ * @param {number} prNumber
85
+ * @param {Promise<Record<string, string> | null>} started
86
+ * @param {string} [headSha] the canvas commit, when it is not the PR head
87
+ */
88
+ async function settlePatches(prNumber, started, headSha) {
89
+ const first = await started
90
+ if (first !== null) {
91
+ return first
92
+ }
93
+ const retry = headSha === undefined ? {} : { headSha }
94
+ return fetchPatches(prNumber, retry).then(
95
+ r => r.patches,
96
+ () => null
97
+ )
98
+ }
99
+
100
+ export class PrAppElement extends HTMLElement {
101
+ /** @type {{ stop: () => void } | null} */
102
+ poller = null
103
+ /** @type {Bootstrap | null} */
104
+ bootstrap = null
105
+ /** @type {import('./theme.js').Theme} */
106
+ theme = 'auto'
107
+ /** @type {import('./skin.js').Skin} */
108
+ skin = 'terminal'
109
+ /** @type {{ stop: () => void } | null} */
110
+ diagrams = null
111
+ /** @type {{ stop: () => void } | null} */
112
+ deepLinks = null
113
+ /** @type {{ stop: () => void } | null} */
114
+ scrollSpy = null
115
+ /** @type {{ stop: () => void } | null} */
116
+ interactions = null
117
+ /** @type {ReturnType<typeof wireChat>} */
118
+ chat = null
119
+ /** @type {{ stop: () => void } | null} */
120
+ quickQuestions = null
121
+ /** The reader asked to read the canvas of the older commit. */
122
+ viewStale = false
123
+
124
+ connectedCallback() {
125
+ // Once per element: the content is re-rendered on every flip, the element itself is not.
126
+ wireCopyCommands(this)
127
+ void this.boot()
128
+ }
129
+
130
+ disconnectedCallback() {
131
+ this.stopPolling()
132
+ this.diagrams?.stop()
133
+ this.diagrams = null
134
+ this.deepLinks?.stop()
135
+ this.deepLinks = null
136
+ this.scrollSpy?.stop()
137
+ this.scrollSpy = null
138
+ this.interactions?.stop()
139
+ this.interactions = null
140
+ this.stopChat()
141
+ }
142
+
143
+ stopChat() {
144
+ this.chat?.stop()
145
+ this.chat = null
146
+ this.quickQuestions?.stop()
147
+ this.quickQuestions = null
148
+ }
149
+
150
+ async boot() {
151
+ this.bootstrap = readBootstrap()
152
+ if (!this.bootstrap) {
153
+ this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
154
+ return
155
+ }
156
+ this.theme = readTheme(document.documentElement)
157
+ this.skin = readSkin(document.documentElement)
158
+ const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
159
+ r => r.patches,
160
+ () => null
161
+ )
162
+ /** @type {PrBundle} */
163
+ let bundle
164
+ try {
165
+ bundle = await fetchBundle(this.bootstrap.prNumber)
166
+ } catch (err) {
167
+ this.innerHTML = errorCardHtml(toEnvelopeError(err)) + footerHtml(this.bootstrap.version)
168
+ qs('#retry', this)?.addEventListener('click', () => void this.boot())
169
+ return
170
+ }
171
+ await this.render(bundle, patchesPromise)
172
+ }
173
+
174
+ /**
175
+ * @param {PrBundle} bundle
176
+ * @param {Promise<Record<string, string> | null>} [patchesPromise]
177
+ */
178
+ async render(bundle, patchesPromise) {
179
+ const boot = this.bootstrap
180
+ if (!boot) {
181
+ return
182
+ }
183
+ this.stopPolling()
184
+ this.diagrams?.stop()
185
+ this.diagrams = null
186
+ this.deepLinks?.stop()
187
+ this.deepLinks = null
188
+ this.scrollSpy?.stop()
189
+ this.scrollSpy = null
190
+ this.interactions?.stop()
191
+ this.interactions = null
192
+ this.stopChat()
193
+ const now = new Date()
194
+ const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
195
+ const chatEnabled = bundle.chat.enabled
196
+ const storage = typeof localStorage === 'undefined' ? null : localStorage
197
+ // The renderers read this while they build the cards, so it is set before the first one.
198
+ setChatEnabled(chatEnabled)
199
+ const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
200
+ if (bundle.artifact && showsCanvas) {
201
+ const { artifact } = bundle
202
+ // A stale canvas describes its own commit, so its files and diffs come from that sha.
203
+ const staleSha = bundle.status === 'stale' ? bundle.stale?.canvasHeadSha : undefined
204
+ const files = staleSha === undefined ? bundle.files : artifact.files
205
+ const paths = pathSet(files)
206
+ const patches = await settlePatches(
207
+ boot.prNumber,
208
+ patchesPromise ??
209
+ fetchPatches(boot.prNumber, staleSha === undefined ? {} : { headSha: staleSha }).then(
210
+ r => r.patches,
211
+ () => null
212
+ ),
213
+ staleSha
214
+ )
215
+ // The context is set before any <pr-file> connects, so each card renders once, when visible.
216
+ setRenderContext({
217
+ artifact,
218
+ files,
219
+ patches,
220
+ comments: bundle.comments.reviewComments,
221
+ state: bundle.state,
222
+ now,
223
+ })
224
+ defineLayerElements()
225
+ const staleBar = bundle.status === 'stale' && bundle.stale ? staleBarHtml(bundle.stale) : ''
226
+ this.innerHTML =
227
+ header +
228
+ bannerHtml(bundle.warnings) +
229
+ `<div class="layout${chatEnabled ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage) })}</div>` +
230
+ footerHtml(boot.version, bundle)
231
+ // The screen is interactive from here: reviewed state, dismissals, threads, and posting.
232
+ // A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
233
+ // number of that commit means something else on the head a comment would land on.
234
+ const capabilities =
235
+ bundle.status === 'stale'
236
+ ? {
237
+ canComment: /** @type {const} */ (false),
238
+ tokenKind: bundle.capabilities.tokenKind,
239
+ login: bundle.capabilities.login,
240
+ reason: 'this canvas is for an older commit; generate one for the current head to post',
241
+ }
242
+ : bundle.capabilities
243
+ const session = createReviewSession({
244
+ prNumber: boot.prNumber,
245
+ artifact,
246
+ files,
247
+ state: bundle.state,
248
+ capabilities,
249
+ headSha: bundle.pr.headSha,
250
+ })
251
+ const interactions = wireReview(this, session, {
252
+ chat: () => this.chat,
253
+ openSettings: el => void openSettingsDialog(this, el),
254
+ })
255
+ this.interactions = interactions
256
+ if (chatEnabled) {
257
+ this.chat = wireChat({
258
+ root: this,
259
+ prNumber: boot.prNumber,
260
+ session,
261
+ onProposed: (what, comment, el) => interactions.onProposedComment(what, comment, el),
262
+ })
263
+ this.quickQuestions = wireQuickQuestions(this, {
264
+ onPick: (context, question) => {
265
+ if (question === null) {
266
+ this.chat?.ask(context)
267
+ } else {
268
+ this.chat?.askQuestion(context, question)
269
+ }
270
+ },
271
+ })
272
+ }
273
+ if (bundle.status === 'stale') {
274
+ this.startPolling(next => next.status === 'ready')
275
+ }
276
+ } else {
277
+ const screen = bundle.status === 'stale' ? renderStaleState(bundle) : renderEmptyState(bundle)
278
+ this.innerHTML =
279
+ header +
280
+ bannerHtml(bundle.warnings) +
281
+ `<div class="layout no-chat"><nav class="rail" aria-label="Review layers"><ul class="tree"><li><a href="#empty-state" aria-current="location"><span class="t">Overview</span><span class="m">no canvas yet</span></a></li></ul></nav><main id="main">${screen}</main></div>` +
282
+ footerHtml(boot.version, bundle)
283
+ // A canvas published from the harness shows up here without a reload.
284
+ this.startPolling(next => next.status === 'ready')
285
+ }
286
+ // Mermaid is fetched only when this screen holds a diagram, and again on a theme flip.
287
+ this.diagrams = initDiagrams(this)
288
+ // The cards are in the page now, so a link in the URL has something to land on.
289
+ this.deepLinks = initDeepLinks(this)
290
+ this.scrollSpy = initScrollSpy(this)
291
+ this.wireCommands(bundle)
292
+ }
293
+
294
+ /**
295
+ * Writes the skin or the theme to `.pr-review/settings.yml`.
296
+ * @param {import('./contract-types.js').AppearanceInput} input
297
+ * @param {string} what the word the failure message names
298
+ */
299
+ save(input, what) {
300
+ void saveAppearance(input).catch(() => toast(this, `could not save the ${what} to settings.yml`))
301
+ }
302
+
303
+ /** @param {PrBundle} bundle */
304
+ wireCommands(bundle) {
305
+ // The review screen routes this through the delegated handler; the empty and stale screens
306
+ // have no such handler, so the command is wired here for every screen.
307
+ const settings = qs('#settings', this)
308
+ if (settings instanceof HTMLElement && this.interactions === null) {
309
+ settings.addEventListener('click', () => void openSettingsDialog(this, settings))
310
+ }
311
+ // Both repaint the page under the click; the settings file catches up after the round trip,
312
+ // and each says so when it cannot, because the next load would come back in the old one.
313
+ const toggle = qs('#theme-toggle', this)
314
+ toggle?.addEventListener('click', () => {
315
+ this.theme = nextTheme(this.theme)
316
+ applyTheme(this.theme, document.documentElement)
317
+ toggle.textContent = themeLabel(this.theme)
318
+ this.save({ theme: this.theme }, 'theme')
319
+ })
320
+ const skinToggle = qs('#skin-toggle', this)
321
+ skinToggle?.addEventListener('click', () => {
322
+ this.skin = nextSkin(this.skin)
323
+ applySkin(this.skin, document.documentElement)
324
+ skinToggle.textContent = skinLabel(this.skin)
325
+ this.save({ skin: this.skin }, 'skin')
326
+ })
327
+ const openDialog = () => {
328
+ openRegenerateDialog(this, bundle.skillCommand)
329
+ this.startPolling(next => canvasChanged(bundle, next))
330
+ }
331
+ qs('#regenerate', this)?.addEventListener('click', openDialog)
332
+ qs('#stale-generate', this)?.addEventListener('click', openDialog)
333
+ const boot = this.bootstrap
334
+ if (!boot) {
335
+ return
336
+ }
337
+ const exportZip = qs('#export-zip', this)
338
+ if (exportZip instanceof HTMLElement) {
339
+ exportZip.addEventListener('click', () => {
340
+ // The canvas on screen is the one to export, which is the older one on the stale screen.
341
+ const headSha = bundle.canvas?.headSha
342
+ void runCommand(exportZip, () => exportCanvasZip(boot.prNumber, headSha ? { headSha } : {}), {
343
+ pendingLabel: 'exporting…',
344
+ })
345
+ })
346
+ }
347
+ const fetchShared = qs('#fetch-shared', this)
348
+ if (fetchShared instanceof HTMLElement) {
349
+ fetchShared.addEventListener('click', () => {
350
+ void runCommand(
351
+ fetchShared,
352
+ async () => {
353
+ // Imported or not, the answer changes the callout, so the screen is drawn again.
354
+ await fetchSharedCanvas(boot.prNumber)
355
+ await this.reload()
356
+ },
357
+ { pendingLabel: 'fetching…' }
358
+ )
359
+ })
360
+ }
361
+ const refresh = qs('#refresh', this)
362
+ if (refresh instanceof HTMLElement) {
363
+ refresh.addEventListener('click', () => {
364
+ void runCommand(refresh, () => this.reload({ refresh: true }), { pendingLabel: 'refreshing…' })
365
+ })
366
+ }
367
+ const viewStale = qs('#view-stale', this)
368
+ viewStale?.addEventListener('click', () => {
369
+ this.viewStale = true
370
+ void this.render(bundle)
371
+ })
372
+ wireDropZone(this, { prNumber: boot.prNumber, importImpl: importCanvas, onImported: () => void this.reload() })
373
+ }
374
+
375
+ /**
376
+ * Re-reads the bundle and re-renders, after an import changed what the server holds. With
377
+ * `refresh` the server asks GitHub again instead of answering from its caches.
378
+ * @param {{ refresh?: boolean }} [opts]
379
+ */
380
+ async reload(opts = {}) {
381
+ const boot = this.bootstrap
382
+ if (!boot) {
383
+ return
384
+ }
385
+ const next = await fetchBundle(boot.prNumber, opts.refresh === true ? { refresh: true } : {})
386
+ await this.render(next)
387
+ }
388
+
389
+ /**
390
+ * One poller at a time. When `until` holds, the new bundle replaces the screen.
391
+ * @param {(bundle: PrBundle) => boolean} until
392
+ */
393
+ startPolling(until) {
394
+ const boot = this.bootstrap
395
+ if (!boot) {
396
+ return
397
+ }
398
+ this.stopPolling()
399
+ this.poller = pollBundle(boot.prNumber, {
400
+ until,
401
+ onBundle: next => {
402
+ if (until(next)) {
403
+ this.poller = null
404
+ void this.render(next)
405
+ }
406
+ },
407
+ })
408
+ }
409
+
410
+ stopPolling() {
411
+ this.poller?.stop()
412
+ this.poller = null
413
+ }
414
+ }
415
+
416
+ if (!customElements.get('pr-app')) {
417
+ customElements.define('pr-app', PrAppElement)
418
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ // The `[ ask ]` command that sets the AI Chat context. One builder, so a layer card, a file
3
+ // card, an attention point, and the selection toolbar all carry the same attributes and the
4
+ // quick-question menu finds them all.
5
+ import { chatContextAttrs } from './chat-context.js'
6
+ import { esc } from './dom.js'
7
+
8
+ /**
9
+ * Whether the AI Chat pane is on for this screen. The renderers read it rather than threading a
10
+ * flag through every card; `app.js` sets it once per render.
11
+ */
12
+ let chatEnabled = false
13
+
14
+ /** @param {boolean} on */
15
+ export function setChatEnabled(on) {
16
+ chatEnabled = on
17
+ }
18
+
19
+ export function isChatEnabled() {
20
+ return chatEnabled
21
+ }
22
+
23
+ /**
24
+ * The command, or nothing at all when the pane is off: a page without a chat shows no way to ask.
25
+ * @param {import('./chat-context.js').ChatContext} context
26
+ * @param {{ label?: string, enabled?: boolean }} [opts]
27
+ * @returns {string}
28
+ */
29
+ export function askButtonHtml(context, opts = {}) {
30
+ if (!(opts.enabled ?? chatEnabled)) {
31
+ return ''
32
+ }
33
+ const label = opts.label ?? 'ask'
34
+ return `<button class="cmd" type="button" data-act="ask" data-ask${chatContextAttrs(context)}>${esc(label)}</button>`
35
+ }
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ // The one thing a chat message is about. One value per message, set-not-toggle: asking about the
3
+ // same target again leaves the context as it is. Both sides use this file: the browser through a
4
+ // module import, the server through src/contract/chat.ts.
5
+
6
+ /**
7
+ * @typedef {{ kind: 'pr' }
8
+ * | { kind: 'layer', layerId: string }
9
+ * | { kind: 'file', path: string }
10
+ * | { kind: 'lines', path: string, side: 'new' | 'old', start: number, end: number }
11
+ * | { kind: 'point', fingerprint: string }} ChatContext
12
+ */
13
+
14
+ /** @type {ChatContext} */
15
+ export const WHOLE_PR = { kind: 'pr' }
16
+
17
+ /**
18
+ * True when two contexts name the same target, so setting the second one changes nothing.
19
+ * @param {ChatContext} a
20
+ * @param {ChatContext} b
21
+ */
22
+ export function sameChatContext(a, b) {
23
+ if (a.kind !== b.kind) {
24
+ return false
25
+ }
26
+ if (a.kind === 'pr') {
27
+ return true
28
+ }
29
+ if (a.kind === 'layer' && b.kind === 'layer') {
30
+ return a.layerId === b.layerId
31
+ }
32
+ if (a.kind === 'file' && b.kind === 'file') {
33
+ return a.path === b.path
34
+ }
35
+ if (a.kind === 'lines' && b.kind === 'lines') {
36
+ return a.path === b.path && a.side === b.side && a.start === b.start && a.end === b.end
37
+ }
38
+ if (a.kind === 'point' && b.kind === 'point') {
39
+ return a.fingerprint === b.fingerprint
40
+ }
41
+ return false
42
+ }
43
+
44
+ /**
45
+ * What the chip says. The label names the target exactly, so a reader always knows what the next
46
+ * message is about.
47
+ * @param {ChatContext} context
48
+ * @param {(layerId: string) => string | undefined} [layerTitle]
49
+ * @param {(fingerprint: string) => string | undefined} [pointTitle]
50
+ * @returns {string}
51
+ */
52
+ export function chatContextLabel(context, layerTitle, pointTitle) {
53
+ switch (context.kind) {
54
+ case 'pr':
55
+ return 'whole PR'
56
+ case 'layer': {
57
+ const title = layerTitle?.(context.layerId)
58
+ return title === undefined ? `layer ${context.layerId}` : `layer · ${title}`
59
+ }
60
+ case 'file':
61
+ return context.path
62
+ case 'lines': {
63
+ const side = context.side === 'old' ? ' (old side)' : ''
64
+ return context.start === context.end
65
+ ? `${context.path} line ${context.start}${side}`
66
+ : `${context.path} lines ${context.start}–${context.end}${side}`
67
+ }
68
+ case 'point': {
69
+ const title = pointTitle?.(context.fingerprint)
70
+ return title === undefined ? 'attention point' : `point · ${title}`
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The context a `[ ask ]` element names, read from its data attributes. Returns the whole-PR
77
+ * context for an element that names nothing.
78
+ * @param {Element} el
79
+ * @returns {ChatContext}
80
+ */
81
+ export function chatContextFromElement(el) {
82
+ const fingerprint = el.getAttribute('data-ask-point')
83
+ if (fingerprint !== null && fingerprint !== '') {
84
+ return { kind: 'point', fingerprint }
85
+ }
86
+ const layerId = el.getAttribute('data-ask-layer')
87
+ if (layerId !== null && layerId !== '') {
88
+ return { kind: 'layer', layerId }
89
+ }
90
+ const path = el.getAttribute('data-ask-path')
91
+ if (path === null || path === '') {
92
+ return WHOLE_PR
93
+ }
94
+ const start = Number(el.getAttribute('data-ask-start'))
95
+ const end = Number(el.getAttribute('data-ask-end'))
96
+ if (!Number.isInteger(start) || start <= 0 || !Number.isInteger(end) || end < start) {
97
+ return { kind: 'file', path }
98
+ }
99
+ return { kind: 'lines', path, side: el.getAttribute('data-ask-side') === 'old' ? 'old' : 'new', start, end }
100
+ }
101
+
102
+ /**
103
+ * The data attributes an `[ ask ]` element carries, ready to put in a template string.
104
+ * @param {ChatContext} context
105
+ * @returns {string}
106
+ */
107
+ export function chatContextAttrs(context) {
108
+ switch (context.kind) {
109
+ case 'pr':
110
+ return ''
111
+ case 'layer':
112
+ return ` data-ask-layer="${escapeAttr(context.layerId)}"`
113
+ case 'file':
114
+ return ` data-ask-path="${escapeAttr(context.path)}"`
115
+ case 'lines':
116
+ return (
117
+ ` data-ask-path="${escapeAttr(context.path)}" data-ask-side="${context.side}"` +
118
+ ` data-ask-start="${context.start}" data-ask-end="${context.end}"`
119
+ )
120
+ case 'point':
121
+ return ` data-ask-point="${escapeAttr(context.fingerprint)}"`
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Attribute-safe text. `dom.js` is the browser-only escaper; this file also runs on the server,
127
+ * so it escapes the five characters an attribute value can break on itself.
128
+ * @param {string} value
129
+ */
130
+ function escapeAttr(value) {
131
+ return value
132
+ .replace(/&/g, '&amp;')
133
+ .replace(/</g, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;')
136
+ .replace(/'/g, '&#39;')
137
+ }