@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.
Files changed (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import type { ReviewKey } from '../contract/review-key.js'
2
3
  import { emptyState, type PrState, PrStateSchema } from '../contract/state.js'
3
4
  import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
4
5
  import type { PrStore } from './pr-store.js'
@@ -9,23 +10,24 @@ export interface PostedEntry {
9
10
  pointFingerprint?: string
10
11
  }
11
12
 
12
- /** Per-PR local state: reviewed cards, dismissed points, hidden threads, posted comments. */
13
+ /** Per-target local state: reviewed cards, dismissed points, hidden threads, posted comments. */
13
14
  export interface StateStore {
14
15
  /** Defaults when the file is missing or does not match the schema (an older tool version wrote it). */
15
- read(number: number): Promise<PrState>
16
+ read(key: ReviewKey): Promise<PrState>
16
17
  /**
17
- * Read, change, write. Calls for the same PR run one after another, so two requests that
18
+ * Read, change, write. Calls for the same target run one after another, so two requests that
18
19
  * arrive together both land instead of one overwriting the other.
19
20
  */
20
- update(number: number, mutate: (state: PrState) => PrState): Promise<PrState>
21
+ update(key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState>
21
22
  /**
22
- * Marks one layer or file. `headSha` is the commit the page was showing: when it differs from
23
- * the one the marks describe, the old marks are dropped, because they were about other code.
23
+ * Marks one layer or file. `canvasSha` is the commit of the canvas the page was showing: when it
24
+ * differs from the one the marks describe, the old marks are dropped, because they were about
25
+ * other code.
24
26
  */
25
- setReviewed(number: number, id: string, reviewed: boolean, headSha?: string): Promise<PrState>
26
- setDismissed(number: number, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
27
- setThreadHidden(number: number, rootCommentId: number, hidden: boolean): Promise<PrState>
28
- addPosted(number: number, entry: PostedEntry): Promise<PrState>
27
+ setReviewed(key: ReviewKey, id: string, reviewed: boolean, canvasSha?: string): Promise<PrState>
28
+ setDismissed(key: ReviewKey, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
29
+ setThreadHidden(key: ReviewKey, rootCommentId: number, hidden: boolean): Promise<PrState>
30
+ addPosted(key: ReviewKey, entry: PostedEntry): Promise<PrState>
29
31
  }
30
32
 
31
33
  /** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
@@ -42,25 +44,25 @@ function layerOf(id: string): string {
42
44
  }
43
45
 
44
46
  export function createStateStore(prs: PrStore, now: () => Date): StateStore {
45
- const file = (number: number): string => path.join(prs.prDir(number), 'state.json')
46
- const read = (number: number): Promise<PrState> =>
47
- readJsonOrDefault(file(number), PrStateSchema, () => emptyState(now().toISOString()))
47
+ const file = (key: ReviewKey): string => path.join(prs.prDir(key), 'state.json')
48
+ const read = (key: ReviewKey): Promise<PrState> =>
49
+ readJsonOrDefault(file(key), PrStateSchema, () => emptyState(now().toISOString()))
48
50
 
49
- /** One chain per PR number; each update waits for the one before it. */
50
- const chains = new Map<number, Promise<unknown>>()
51
+ /** One chain per target; each update waits for the one before it. */
52
+ const chains = new Map<ReviewKey, Promise<unknown>>()
51
53
 
52
- const update = (number: number, mutate: (state: PrState) => PrState): Promise<PrState> => {
54
+ const update = (key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState> => {
53
55
  const run = async (): Promise<PrState> => {
54
- const current = await read(number)
56
+ const current = await read(key)
55
57
  // Every write counts up, so the page can tell which of two answers was written later.
56
58
  const next = { ...mutate(current), rev: (current.rev ?? 0) + 1, updatedAt: now().toISOString() }
57
- await writeJsonAtomic(file(number), next)
59
+ await writeJsonAtomic(file(key), next)
58
60
  return next
59
61
  }
60
- const chained = (chains.get(number) ?? Promise.resolve()).then(run, run)
62
+ const chained = (chains.get(key) ?? Promise.resolve()).then(run, run)
61
63
  // The chain only orders the calls, so a failed update never blocks the next one.
62
64
  chains.set(
63
- number,
65
+ key,
64
66
  chained.catch(() => undefined)
65
67
  )
66
68
  return chained
@@ -69,28 +71,30 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
69
71
  return {
70
72
  read,
71
73
  update,
72
- setReviewed: (number, id, reviewed, headSha) =>
73
- update(number, state => {
74
- const sameHead =
75
- headSha === undefined || state.reviewedHeadSha === undefined || state.reviewedHeadSha === headSha
76
- const next = sameHead ? { ...state.reviewed } : {}
74
+ setReviewed: (key, id, reviewed, canvasSha) =>
75
+ update(key, state => {
76
+ const sameCanvas =
77
+ canvasSha === undefined ||
78
+ state.reviewedCanvasSha === undefined ||
79
+ state.reviewedCanvasSha === canvasSha
80
+ const next = sameCanvas ? { ...state.reviewed } : {}
77
81
  if (reviewed) {
78
82
  next[id] = true
79
83
  } else {
80
- for (const key of Object.keys(next)) {
84
+ for (const marked of Object.keys(next)) {
81
85
  // Reopening a layer reopens its files, and reopening a file reopens its layer:
82
86
  // both readings of "reviewed" have to agree.
83
- if (key === id || key.startsWith(`${id}/`) || key === layerOf(id)) {
84
- delete next[key]
87
+ if (marked === id || marked.startsWith(`${id}/`) || marked === layerOf(id)) {
88
+ delete next[marked]
85
89
  }
86
90
  }
87
91
  }
88
- return headSha === undefined
92
+ return canvasSha === undefined
89
93
  ? { ...state, reviewed: next }
90
- : { ...state, reviewed: next, reviewedHeadSha: headSha }
94
+ : { ...state, reviewed: next, reviewedCanvasSha: canvasSha }
91
95
  }),
92
- setDismissed: (number, fingerprint, dismissed, reason) =>
93
- update(number, state => {
96
+ setDismissed: (key, fingerprint, dismissed, reason) =>
97
+ update(key, state => {
94
98
  const next = { ...state.dismissed }
95
99
  if (dismissed) {
96
100
  next[fingerprint] =
@@ -100,8 +104,8 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
100
104
  }
101
105
  return { ...state, dismissed: next }
102
106
  }),
103
- setThreadHidden: (number, rootCommentId, hidden) =>
104
- update(number, state => {
107
+ setThreadHidden: (key, rootCommentId, hidden) =>
108
+ update(key, state => {
105
109
  const next = { ...state.hiddenThreads }
106
110
  if (hidden) {
107
111
  next[String(rootCommentId)] = { at: now().toISOString() }
@@ -110,8 +114,8 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
110
114
  }
111
115
  return { ...state, hiddenThreads: next }
112
116
  }),
113
- addPosted: (number, entry) =>
114
- update(number, state => {
117
+ addPosted: (key, entry) =>
118
+ update(key, state => {
115
119
  if (state.posted.some(p => p.commentId === entry.commentId)) {
116
120
  return state
117
121
  }
package/static/js/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  /** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
3
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
3
4
  /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
4
5
  /** @typedef {import('./contract-types.js').PatchesResponse} PatchesResponse */
5
6
 
@@ -69,17 +70,20 @@ export async function fetchJson(url, opts = {}) {
69
70
  }
70
71
 
71
72
  /**
72
- * @param {number} prNumber
73
- * @param {{ refresh?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
73
+ * The bundle for one target. `refresh` asks the server to read the forge, or the working tree,
74
+ * again; `poll` says this is the background poller, which is answered from the head the page was
75
+ * opened with rather than by snapshotting the working tree every few seconds.
76
+ * @param {ReviewKey} prNumber
77
+ * @param {{ refresh?: boolean, poll?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
74
78
  * @returns {Promise<PrBundle>}
75
79
  */
76
80
  export function fetchBundle(prNumber, opts = {}) {
77
- const q = opts.refresh ? '?refresh=1' : ''
81
+ const q = opts.refresh ? '?refresh=1' : opts.poll ? '?poll=1' : ''
78
82
  return fetchJson(`/api/prs/${prNumber}${q}`, { fetchImpl: opts.fetchImpl })
79
83
  }
80
84
 
81
85
  /**
82
- * @param {number} prNumber
86
+ * @param {ReviewKey} prNumber
83
87
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
84
88
  * @returns {Promise<PatchesResponse>}
85
89
  */
@@ -89,7 +93,7 @@ export function fetchPatches(prNumber, opts = {}) {
89
93
  }
90
94
 
91
95
  /**
92
- * @param {number} prNumber
96
+ * @param {ReviewKey} prNumber
93
97
  * @param {{ fetchImpl?: typeof fetch }} [opts]
94
98
  * @returns {Promise<import('./contract-types.js').StateResponse>}
95
99
  */
@@ -98,14 +102,18 @@ export function fetchState(prNumber, opts = {}) {
98
102
  }
99
103
 
100
104
  /**
101
- * @param {number} prNumber
105
+ * @param {ReviewKey} prNumber
102
106
  * @param {string} id `layer:<id>` or `layer:<id>/file:<key>`
103
107
  * @param {boolean} reviewed
104
- * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
108
+ * @param {{ headSha?: string, canvasSha?: string, fetchImpl?: typeof fetch }} [opts]
105
109
  * @returns {Promise<import('./contract-types.js').StateResponse>}
106
110
  */
107
111
  export function putReviewed(prNumber, id, reviewed, opts = {}) {
108
- const body = opts.headSha === undefined ? { reviewed } : { reviewed, headSha: opts.headSha }
112
+ const body = {
113
+ reviewed,
114
+ ...(opts.headSha === undefined ? {} : { headSha: opts.headSha }),
115
+ ...(opts.canvasSha === undefined ? {} : { canvasSha: opts.canvasSha }),
116
+ }
109
117
  return fetchJson(`/api/prs/${prNumber}/reviewed/${id}`, {
110
118
  method: 'PUT',
111
119
  body,
@@ -114,7 +122,7 @@ export function putReviewed(prNumber, id, reviewed, opts = {}) {
114
122
  }
115
123
 
116
124
  /**
117
- * @param {number} prNumber
125
+ * @param {ReviewKey} prNumber
118
126
  * @param {string} fingerprint
119
127
  * @param {boolean} dismissed
120
128
  * @param {{ reason?: string, fetchImpl?: typeof fetch }} [opts]
@@ -130,7 +138,7 @@ export function putDismissed(prNumber, fingerprint, dismissed, opts = {}) {
130
138
  }
131
139
 
132
140
  /**
133
- * @param {number} prNumber
141
+ * @param {ReviewKey} prNumber
134
142
  * @param {number} rootCommentId
135
143
  * @param {boolean} hidden
136
144
  * @param {{ fetchImpl?: typeof fetch }} [opts]
@@ -145,7 +153,7 @@ export function putThreadHidden(prNumber, rootCommentId, hidden, opts = {}) {
145
153
  }
146
154
 
147
155
  /**
148
- * @param {number} prNumber
156
+ * @param {ReviewKey} prNumber
149
157
  * @param {{ refresh?: boolean, fetchImpl?: typeof fetch }} [opts]
150
158
  * @returns {Promise<import('./contract-types.js').Capabilities>}
151
159
  */
@@ -155,7 +163,7 @@ export function fetchCapabilities(prNumber, opts = {}) {
155
163
  }
156
164
 
157
165
  /**
158
- * @param {number} prNumber
166
+ * @param {ReviewKey} prNumber
159
167
  * @param {import('./contract-types.js').PostCommentInput} input
160
168
  * @param {{ fetchImpl?: typeof fetch }} [opts]
161
169
  * @returns {Promise<import('./contract-types.js').PostCommentResponse>}
@@ -169,7 +177,7 @@ export function postComment(prNumber, input, opts = {}) {
169
177
  }
170
178
 
171
179
  /**
172
- * @param {number} prNumber
180
+ * @param {ReviewKey} prNumber
173
181
  * @param {{ fetchImpl?: typeof fetch }} [opts]
174
182
  * @returns {Promise<import('./contract-types.js').ReviewBodyResponse>}
175
183
  */
@@ -178,7 +186,7 @@ export function fetchReviewBody(prNumber, opts = {}) {
178
186
  }
179
187
 
180
188
  /**
181
- * @param {number} prNumber
189
+ * @param {ReviewKey} prNumber
182
190
  * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
183
191
  * @param {{ fetchImpl?: typeof fetch }} [opts]
184
192
  * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
@@ -210,7 +218,7 @@ export const POLL_SLOW_INTERVAL_MS = 15_000
210
218
  * After five minutes of waiting the gap grows to 15 s, because a generation that has not finished
211
219
  * by then takes minutes more. A failed poll is reported and polling goes on; the page never
212
220
  * blocks on it.
213
- * @param {number} prNumber
221
+ * @param {ReviewKey} prNumber
214
222
  * @param {PollOptions} [opts]
215
223
  * @returns {{ stop: () => void }}
216
224
  */
@@ -230,7 +238,7 @@ export function pollBundle(prNumber, opts = {}) {
230
238
  /** @type {PrBundle} */
231
239
  let bundle
232
240
  try {
233
- bundle = await fetchBundle(prNumber, { fetchImpl: opts.fetchImpl })
241
+ bundle = await fetchBundle(prNumber, { poll: true, fetchImpl: opts.fetchImpl })
234
242
  } catch (err) {
235
243
  if (!stopped) {
236
244
  opts.onError?.(err)
@@ -306,7 +314,7 @@ export function uploadForm(url, form, opts = {}) {
306
314
  }
307
315
 
308
316
  /**
309
- * @param {number} prNumber
317
+ * @param {ReviewKey} prNumber
310
318
  * @param {File} file
311
319
  * @param {{ force?: boolean, onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
312
320
  * @returns {Promise<import('./contract-types.js').ImportResult>}
@@ -321,7 +329,7 @@ export function importCanvas(prNumber, file, opts = {}) {
321
329
  }
322
330
 
323
331
  /**
324
- * @param {number} prNumber
332
+ * @param {ReviewKey} prNumber
325
333
  * @param {{ fetchImpl?: typeof fetch }} [opts]
326
334
  * @returns {Promise<import('./contract-types.js').SharedCanvasFetchResponse>}
327
335
  */
@@ -331,7 +339,7 @@ export function fetchSharedCanvas(prNumber, opts = {}) {
331
339
 
332
340
  /**
333
341
  * The zip as a blob plus the name the server chose, ready to hand to the browser.
334
- * @param {number} prNumber
342
+ * @param {ReviewKey} prNumber
335
343
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
336
344
  * @returns {Promise<{ blob: Blob, filename: string }>}
337
345
  */
@@ -378,7 +386,7 @@ export function saveAppearance(input, opts = {}) {
378
386
  /* ---- AI Chat and its settings ---- */
379
387
 
380
388
  /**
381
- * @param {number} prNumber
389
+ * @param {ReviewKey} prNumber
382
390
  * @param {{ fetchImpl?: typeof fetch }} [opts]
383
391
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
384
392
  */
@@ -387,7 +395,7 @@ export function fetchThreads(prNumber, opts = {}) {
387
395
  }
388
396
 
389
397
  /**
390
- * @param {number} prNumber
398
+ * @param {ReviewKey} prNumber
391
399
  * @param {{ fetchImpl?: typeof fetch }} [opts]
392
400
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
393
401
  */
@@ -400,7 +408,7 @@ export function createThread(prNumber, opts = {}) {
400
408
  }
401
409
 
402
410
  /**
403
- * @param {number} prNumber
411
+ * @param {ReviewKey} prNumber
404
412
  * @param {string} name
405
413
  * @param {{ fetchImpl?: typeof fetch }} [opts]
406
414
  * @returns {Promise<import('./contract-types.js').ChatHistoryResponse>}
@@ -412,7 +420,7 @@ export function fetchThreadHistory(prNumber, name, opts = {}) {
412
420
  }
413
421
 
414
422
  /**
415
- * @param {number} prNumber
423
+ * @param {ReviewKey} prNumber
416
424
  * @param {{ fetchImpl?: typeof fetch }} [opts]
417
425
  * @returns {Promise<{ cancelled: boolean }>}
418
426
  */
@@ -501,7 +509,7 @@ export function readSseFrames(buffer, chunk) {
501
509
  /**
502
510
  * Sends one chat message and calls `onEvent` for every frame until the turn ends. Rejects with
503
511
  * an ApiError when the server refuses the message (a busy chat, a context it cannot resolve).
504
- * @param {number} prNumber
512
+ * @param {ReviewKey} prNumber
505
513
  * @param {{ message: string, context: import('./chat-context.js').ChatContext, thread?: string }} input
506
514
  * @param {{
507
515
  * onEvent: (event: { event: string, data: unknown }) => void,
package/static/js/app.js CHANGED
@@ -19,7 +19,7 @@ import { initDeepLinks } from './deep-link.js'
19
19
  import { initDiagrams } from './diagram.js'
20
20
  import { esc, qs } from './dom.js'
21
21
  import { exportCanvasZip } from './download.js'
22
- import { renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
22
+ import { carriedOverBarHtml, renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
23
23
  import { errorCardHtml } from './errors.js'
24
24
  import { renderHeader } from './header.js'
25
25
  import { wireDropZone } from './import-zone.js'
@@ -33,8 +33,10 @@ import { initScrollSpy } from './scroll-spy.js'
33
33
  import { openSettingsDialog } from './settings.js'
34
34
  import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
35
35
  import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
36
+ import { hostLabel, setHost } from './host.js'
36
37
 
37
- /** @typedef {{ prNumber: number, owner: string, repo: string, version: string }} Bootstrap */
38
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
39
+ /** @typedef {{ prNumber: ReviewKey, owner: string, repo: string, version: string, host: import('./contract-types.js').PublicHost }} Bootstrap */
38
40
 
39
41
  /** @returns {Bootstrap | null} */
40
42
  function readBootstrap() {
@@ -57,7 +59,7 @@ function footerHtml(version, bundle) {
57
59
  const canvas = bundle?.canvas
58
60
  ? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
59
61
  : ''
60
- return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>`
62
+ return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except ${esc(hostLabel())} posts you confirm</span></footer>`
61
63
  }
62
64
 
63
65
  /** @param {ReadonlyArray<string>} warnings */
@@ -83,7 +85,7 @@ function toEnvelopeError(err) {
83
85
  /**
84
86
  * The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
85
87
  * parallel fetch can land before the patches exist; one more try after the bundle is enough.
86
- * @param {number} prNumber
88
+ * @param {ReviewKey} prNumber
87
89
  * @param {Promise<Record<string, string> | null>} started
88
90
  * @param {string} [headSha] the canvas commit, when it is not the PR head
89
91
  */
@@ -155,6 +157,7 @@ export class PrAppElement extends HTMLElement {
155
157
  this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
156
158
  return
157
159
  }
160
+ setHost(this.bootstrap.host)
158
161
  this.theme = readTheme(document.documentElement)
159
162
  this.skin = readSkin(document.documentElement)
160
163
  const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
@@ -224,7 +227,12 @@ export class PrAppElement extends HTMLElement {
224
227
  now,
225
228
  })
226
229
  defineLayerElements()
227
- const staleBar = bundle.status === 'stale' && bundle.stale ? staleBarHtml(bundle.stale) : ''
230
+ const staleBar =
231
+ bundle.status === 'stale' && bundle.stale
232
+ ? staleBarHtml(bundle.stale, bundle.local)
233
+ : bundle.carriedOver
234
+ ? carriedOverBarHtml(bundle.carriedOver)
235
+ : ''
228
236
  this.innerHTML =
229
237
  header +
230
238
  bannerHtml(bundle.warnings) +
@@ -249,6 +257,8 @@ export class PrAppElement extends HTMLElement {
249
257
  state: bundle.state,
250
258
  capabilities,
251
259
  headSha: bundle.pr.headSha,
260
+ // The marks are keyed to the canvas on the page, so every mark names it.
261
+ ...(bundle.canvas === undefined ? {} : { canvasSha: bundle.canvas.headSha }),
252
262
  })
253
263
  const interactions = wireReview(this, session, {
254
264
  chat: () => this.chat,
@@ -371,11 +381,15 @@ export class PrAppElement extends HTMLElement {
371
381
  this.viewStale = true
372
382
  void this.render(bundle)
373
383
  })
374
- wireDropZone(this, {
375
- prNumber: boot.prNumber,
376
- importImpl: importCanvas,
377
- onImported: () => void this.reload(),
378
- })
384
+ // Importing a canvas a teammate attached is a pull request thing; local work has no thread
385
+ // to attach one to, and the local screens draw no drop zone.
386
+ if (typeof boot.prNumber === 'number') {
387
+ wireDropZone(this, {
388
+ prNumber: boot.prNumber,
389
+ importImpl: importCanvas,
390
+ onImported: () => void this.reload(),
391
+ })
392
+ }
379
393
  }
380
394
 
381
395
  /**
package/static/js/chat.js CHANGED
@@ -23,6 +23,7 @@ import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
23
23
  import { esc, qs } from './dom.js'
24
24
  import { getRenderContext } from './layers.js'
25
25
  import { renderMarkdown } from './markdown.js'
26
+ import { postToLabel } from './host.js'
26
27
  import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
27
28
 
28
29
  export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
@@ -113,7 +114,7 @@ export const QUICK_QUESTIONS = [
113
114
  /**
114
115
  * @typedef {{
115
116
  * root: HTMLElement,
116
- * prNumber: number,
117
+ * prNumber: import('./contract-types.js').ReviewKey,
117
118
  * session: ReviewSession,
118
119
  * storage?: Storage | null,
119
120
  * api?: Partial<ChatApi>,
@@ -179,7 +180,7 @@ export function proposedCommentHtml(comment, id, postedUrl) {
179
180
  `<div class="prose">${renderMarkdown(comment.body)}</div>` +
180
181
  '<span class="tbtns">' +
181
182
  (postedUrl === undefined
182
- ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>post to github</button>`
183
+ ? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>${postToLabel()}</button>`
183
184
  : viewCommentHtml(postedUrl, true)) +
184
185
  `<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
185
186
  `<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
@@ -6,6 +6,7 @@
6
6
  import { cssEscape } from './anchors.js'
7
7
  import { renderMarkdown } from './markdown.js'
8
8
  import { esc } from './dom.js'
9
+ import { noPostingTitle, postToLabel } from './host.js'
9
10
 
10
11
  /**
11
12
  * @typedef {{
@@ -50,7 +51,7 @@ export function composerHtml(opts) {
50
51
  `<label class="sr" for="${esc(opts.id)}-t">${esc(opts.label)}</label>` +
51
52
  previewControlsHtml() +
52
53
  `<textarea id="${esc(opts.id)}-t" rows="3" placeholder="${esc(opts.label)}">${esc(opts.body ?? '')}</textarea>` +
53
- '<div class="composer-actions"><button class="cmd fill" type="button" data-act="composer-post" data-needs-post>post to github</button>' +
54
+ `<div class="composer-actions"><button class="cmd fill" type="button" data-act="composer-post" data-needs-post>${postToLabel()}</button>` +
54
55
  '<button class="cmd" type="button" data-act="composer-cancel">cancel</button></div></div>'
55
56
  )
56
57
  }
@@ -144,12 +145,9 @@ export function closeComposers(root) {
144
145
  return closed
145
146
  }
146
147
 
147
- /** What a command says when this GitHub login may not post. */
148
- export const NO_POSTING_TITLE = 'this GitHub login cannot post on this repository'
149
-
150
148
  /**
151
149
  * Disables everything that posts when the probe said no, and enables it otherwise. A token
152
- * whose rights cannot be read ('unknown') stays enabled: GitHub answers for itself.
150
+ * whose rights cannot be read ('unknown') stays enabled: the host answers for itself.
153
151
  *
154
152
  * A command can be disabled for a reason of its own (the approve command before every layer is
155
153
  * read, a command whose request is still running). Those keep their state: this only adds and
@@ -159,7 +157,7 @@ export const NO_POSTING_TITLE = 'this GitHub login cannot post on this repositor
159
157
  */
160
158
  export function applyCapabilityGating(root, capabilities) {
161
159
  const blocked = capabilities.canComment === false
162
- const reason = capabilities.reason ?? NO_POSTING_TITLE
160
+ const reason = capabilities.reason ?? noPostingTitle()
163
161
  for (const el of Array.from(root.querySelectorAll('[data-needs-post]'))) {
164
162
  if (!(el instanceof HTMLButtonElement) || el.getAttribute('aria-busy') === 'true') {
165
163
  continue
@@ -222,15 +220,19 @@ export function setDisabledReason(el, reason) {
222
220
  }
223
221
  }
224
222
 
223
+ /** One command that swaps the box between writing and previewing; its label is the mode it goes to. */
225
224
  export function previewControlsHtml() {
226
- return '<div class="preview-controls" role="group" aria-label="Markdown editor"><button class="cmd" type="button" data-act="markdown-write" aria-pressed="true">Write</button><button class="cmd" type="button" data-act="markdown-preview" aria-pressed="false">Preview</button></div><div class="markdown-preview prose" hidden></div>'
225
+ return '<div class="preview-controls"><button class="cmd" type="button" data-act="markdown-toggle">preview</button></div><div class="markdown-preview prose" hidden></div>'
227
226
  }
228
227
 
229
- /** @param {HTMLElement} button @param {boolean} preview */
230
- export function toggleMarkdownPreview(button, preview) {
231
- const host = button.closest('.composer-box, .signoff-dialog')
232
- const textarea = host?.querySelector('textarea')
233
- const output = host?.querySelector('.markdown-preview')
228
+ /**
229
+ * Puts one editor into write or preview mode.
230
+ * @param {Element} host the `.composer-box` or `.signoff-dialog` that holds the editor
231
+ * @param {boolean} preview
232
+ */
233
+ export function setMarkdownPreview(host, preview) {
234
+ const textarea = host.querySelector('textarea')
235
+ const output = host.querySelector('.markdown-preview')
234
236
  if (!(textarea instanceof HTMLTextAreaElement) || !(output instanceof HTMLElement)) return
235
237
  if (preview)
236
238
  output.innerHTML = textarea.value.trim()
@@ -238,7 +240,15 @@ export function toggleMarkdownPreview(button, preview) {
238
240
  : '<p class="muted">Nothing to preview.</p>'
239
241
  textarea.hidden = preview
240
242
  output.hidden = !preview
241
- host?.querySelector('[data-act="markdown-write"]')?.setAttribute('aria-pressed', String(!preview))
242
- host?.querySelector('[data-act="markdown-preview"]')?.setAttribute('aria-pressed', String(preview))
243
+ const toggle = host.querySelector('[data-act="markdown-toggle"]')
244
+ if (toggle !== null) toggle.textContent = preview ? 'write' : 'preview'
243
245
  if (!preview) textarea.focus()
244
246
  }
247
+
248
+ /** @param {HTMLElement} button */
249
+ export function toggleMarkdownPreview(button) {
250
+ const host = button.closest('.composer-box, .signoff-dialog')
251
+ if (host === null) return
252
+ const output = host.querySelector('.markdown-preview')
253
+ setMarkdownPreview(host, output instanceof HTMLElement && output.hidden)
254
+ }
@@ -1,6 +1,7 @@
1
1
  // Type-only bridge so the browser modules (JSDoc) see the same contract as the server.
2
2
 
3
3
  export type {
4
+ CarriedOverInfo,
4
5
  CanvasRelation,
5
6
  Capabilities,
6
7
  ChatStatus,
@@ -10,6 +11,7 @@ export type {
10
11
  PostCommentResponse,
11
12
  PostReviewResponse,
12
13
  PrBundle,
14
+ PublicHost,
13
15
  ReviewBodyResponse,
14
16
  ReviewSummary,
15
17
  SharedCanvasFetchResponse,
@@ -23,6 +25,7 @@ export type {
23
25
  ChatThreadsResponse,
24
26
  ChatTurn,
25
27
  } from '../../src/contract/chat.js'
28
+ export type { ReviewKey } from '../../src/contract/review-key.js'
26
29
  export type {
27
30
  CommentsPayload,
28
31
  IssueComment,
@@ -855,7 +855,7 @@ export function renderDiff(file, patch, opts = {}) {
855
855
  highlightHunk(h, file.lang)
856
856
  tables.push(
857
857
  `<table class="diff" id="${esc(hunkAnchorId(file.key, n))}" data-hunk="${esc(id)}" data-key="${esc(file.key)}">` +
858
- `<caption class="sr">Hunk ${n} of ${esc(file.path)}</caption>${THEAD}<tbody>${buildRows(h, file.key)}</tbody></table>`
858
+ `<caption class="sr">Chunk ${n} of ${esc(file.path)}</caption>${THEAD}<tbody>${buildRows(h, file.key)}</tbody></table>`
859
859
  )
860
860
  })
861
861
  if (tables.length === 0) {
@@ -26,7 +26,7 @@ export function saveBlob(blob, filename, deps = {}) {
26
26
  }
27
27
 
28
28
  /**
29
- * @param {number} prNumber
29
+ * @param {import('./contract-types.js').ReviewKey} prNumber
30
30
  * @param {{ headSha?: string, fetchImpl?: typeof fetch } & SaveDeps} [opts]
31
31
  * @returns {Promise<string>} the file name the browser saved
32
32
  */