@vintasoftware/pr-review-canvas 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -27,13 +27,25 @@ import { flash, scrollIntoViewSafe } from './dom.js'
27
27
  import { refreshProgress } from './header.js'
28
28
  import { keyAction, openHelpDialog } from './keyboard.js'
29
29
  import { pointAnchorId, sanitizeKey } from './keys.js'
30
- import { getRenderContext, pathSet, setCardRenderedHook, setRenderContext, updateRenderState } from './layers.js'
30
+ import {
31
+ getRenderContext,
32
+ pathSet,
33
+ setCardRenderedHook,
34
+ setRenderContext,
35
+ updateRenderState,
36
+ } from './layers.js'
31
37
  import { buildNavOrder, layerOf, nextFile, nextLayer, prevFile, prevLayer } from './nav.js'
32
38
  import { issueCommentHtml } from './overview.js'
33
39
  import { applyDismissed, pointToMarkdown, postedUrls } from './points.js'
34
40
  import { layerProgress } from './progress.js'
35
41
  import { lineRefFromEvent, markSelection, selectionReducer } from './selection.js'
36
- import { fillSignoffDialog, openSignoffDialog, showSignoffError, showSignoffResult, signoffBody } from './signoff.js'
42
+ import {
43
+ fillSignoffDialog,
44
+ openSignoffDialog,
45
+ showSignoffError,
46
+ showSignoffResult,
47
+ signoffBody,
48
+ } from './signoff.js'
37
49
 
38
50
  /** @typedef {NonNullable<ReturnType<typeof import('./chat.js').wireChat>>} ChatHandle */
39
51
 
@@ -148,7 +160,8 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
148
160
  if (item === undefined || item.kind !== kind || item.other) {
149
161
  continue
150
162
  }
151
- const id = item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
163
+ const id =
164
+ item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
152
165
  if (!session.isReviewed(id)) {
153
166
  return root.querySelector(`#${cssEscape(item.id)}`)
154
167
  }
@@ -167,7 +180,13 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
167
180
  */
168
181
  export function askTargetFor(root, focusId, focusPointId, selection) {
169
182
  if (selection !== null) {
170
- return { kind: 'lines', path: selection.path, side: selection.side, start: selection.start, end: selection.end }
183
+ return {
184
+ kind: 'lines',
185
+ path: selection.path,
186
+ side: selection.side,
187
+ start: selection.start,
188
+ end: selection.end,
189
+ }
171
190
  }
172
191
  if (focusPointId !== null) {
173
192
  const el = root.querySelector(`[data-point="${cssEscape(focusPointId)}"] [data-act="ask"]`)
@@ -213,7 +232,8 @@ export function wireReview(root, session, opts = {}) {
213
232
 
214
233
  const paths = () => pathSet(getRenderContext()?.files ?? [])
215
234
  /** The diff key of a path, which is what the row ids are built from. */
216
- const keyForPath = (/** @type {string} */ path) => getRenderContext()?.files.find(f => f.path === path)?.key ?? null
235
+ const keyForPath = (/** @type {string} */ path) =>
236
+ getRenderContext()?.files.find(f => f.path === path)?.key ?? null
217
237
  /** The time the page was drawn, which the comments it adds are timed against. */
218
238
  const renderNow = () => getRenderContext()?.now ?? new Date()
219
239
 
@@ -263,7 +283,10 @@ export function wireReview(root, session, opts = {}) {
263
283
  const answer = await session.postComment(input)
264
284
  const ctx = getRenderContext()
265
285
  if (answer.kind === 'review' && ctx !== null) {
266
- setRenderContext({ ...ctx, comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment] })
286
+ setRenderContext({
287
+ ...ctx,
288
+ comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment],
289
+ })
267
290
  onState(session.state)
268
291
  }
269
292
  return answer
@@ -325,7 +348,8 @@ export function wireReview(root, session, opts = {}) {
325
348
  */
326
349
  const openComposer = (anchor, options, shape) => {
327
350
  closeComposers(root)
328
- const node = shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
351
+ const node =
352
+ shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
329
353
  if (node === null) {
330
354
  return null
331
355
  }
@@ -364,7 +388,10 @@ export function wireReview(root, session, opts = {}) {
364
388
  }
365
389
 
366
390
  /** The inline comment that a posted review comment becomes on the page. */
367
- const insertPostedReviewComment = (/** @type {Element} */ composerNode, /** @type {ReviewComment} */ comment) => {
391
+ const insertPostedReviewComment = (
392
+ /** @type {Element} */ composerNode,
393
+ /** @type {ReviewComment} */ comment
394
+ ) => {
368
395
  const thread = root.querySelector(
369
396
  `tr.thread[data-thread="${cssEscape(String(comment.inReplyToId ?? comment.id))}"]`
370
397
  )
@@ -461,9 +488,9 @@ export function wireReview(root, session, opts = {}) {
461
488
  * @param {string} id
462
489
  * @param {boolean} reviewed
463
490
  * @param {'layer' | 'file'} kind
464
- * @param {{ quiet?: boolean }} [opts] `quiet` keeps a checkbox label intact while it runs
491
+ * @param {{ quiet?: boolean }} [reviewOptions] `quiet` keeps a checkbox label intact while it runs
465
492
  */
466
- const markReviewed = (button, id, reviewed, kind, opts = {}) => {
493
+ const markReviewed = (button, id, reviewed, kind, reviewOptions = {}) => {
467
494
  const run = async () => {
468
495
  await session.setReviewed(id, reviewed)
469
496
  const parts = cardForReviewedId(root, id)
@@ -479,7 +506,7 @@ export function wireReview(root, session, opts = {}) {
479
506
  }
480
507
  toast(root, reviewed ? `${kind} marked reviewed` : `${kind} reopened`)
481
508
  }
482
- void (opts.quiet === true
509
+ void (reviewOptions.quiet === true
483
510
  ? runControl(button, run)
484
511
  : runCommand(button, run, { pendingLabel: reviewed ? 'marking…' : 'unmarking…' }))
485
512
  }
@@ -589,8 +616,7 @@ export function wireReview(root, session, opts = {}) {
589
616
  postFromComposer(el, box)
590
617
  }
591
618
  },
592
- 'markdown-preview': el => toggleMarkdownPreview(el, true),
593
- 'markdown-write': el => toggleMarkdownPreview(el, false),
619
+ 'markdown-toggle': el => toggleMarkdownPreview(el),
594
620
  'composer-cancel': () => {
595
621
  closeComposers(root)
596
622
  },
@@ -641,7 +667,11 @@ export function wireReview(root, session, opts = {}) {
641
667
  return
642
668
  }
643
669
  composerSeq += 1
644
- openComposer(full, { id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id }, 'block')
670
+ openComposer(
671
+ full,
672
+ { id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id },
673
+ 'block'
674
+ )
645
675
  },
646
676
  'pr-comment': () => {
647
677
  const host = root.querySelector('.conversation .pr-composer-host')
@@ -733,7 +763,10 @@ export function wireReview(root, session, opts = {}) {
733
763
  }
734
764
  event.preventDefault()
735
765
  setSelection(
736
- selectionReducer(selection, event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target })
766
+ selectionReducer(
767
+ selection,
768
+ event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target }
769
+ )
737
770
  )
738
771
  if (selection !== null && !event.shiftKey) {
739
772
  setSelection(selectionReducer(selection, { type: 'drag-start', target }))
@@ -807,12 +840,18 @@ export function wireReview(root, session, opts = {}) {
807
840
  const item = order.find(i => i.id === focusId)
808
841
  const wanted = decided.action === 'reviewed-file' ? 'file' : 'layer'
809
842
  const found =
810
- item?.kind === wanted ? item : wanted === 'layer' && focusId !== null ? layerOf(order, focusId) : null
843
+ item?.kind === wanted
844
+ ? item
845
+ : wanted === 'layer' && focusId !== null
846
+ ? layerOf(order, focusId)
847
+ : null
811
848
  if (found === null || found === undefined || found.kind === 'overview') {
812
849
  break
813
850
  }
814
851
  const id =
815
- found.kind === 'file' ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}` : `layer:${found.layerId}`
852
+ found.kind === 'file'
853
+ ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}`
854
+ : `layer:${found.layerId}`
816
855
  const parts = cardForReviewedId(root, id)
817
856
  const box = parts?.card.querySelector('input[data-reviewed-id]')
818
857
  if (box instanceof HTMLElement) {
@@ -35,7 +35,10 @@ export function isTypingTarget(target) {
35
35
  }
36
36
  const tag = target.tagName.toLowerCase()
37
37
  return (
38
- tag === 'input' || tag === 'textarea' || tag === 'select' || target.closest('[contenteditable="true"]') !== null
38
+ tag === 'input' ||
39
+ tag === 'textarea' ||
40
+ tag === 'select' ||
41
+ target.closest('[contenteditable="true"]') !== null
39
42
  )
40
43
  }
41
44
 
@@ -97,7 +100,9 @@ export function keyAction(event, opts = {}) {
97
100
  export const HELP_DIALOG_ID = 'help-dialog'
98
101
 
99
102
  export function helpDialogHtml() {
100
- const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join('')
103
+ const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join(
104
+ ''
105
+ )
101
106
  return (
102
107
  `<dialog id="${HELP_DIALOG_ID}" class="help" aria-labelledby="help-h"><form method="dialog">` +
103
108
  '<h2 id="help-h">Keyboard</h2>' +
@@ -77,7 +77,7 @@ export function pathSet(files) {
77
77
  }
78
78
 
79
79
  /**
80
- * Layer title per hunk id, so a file card can say "2 more hunks in layer 4".
80
+ * Layer title per hunk id, so a file card can say "2 more chunks in layer 4".
81
81
  * @param {ReviewArtifact} artifact
82
82
  * @returns {Map<string, { layer: Layer, index: number }>}
83
83
  */
@@ -117,7 +117,8 @@ export function railInnerHtml(artifact, state, opts = {}) {
117
117
  .map((layer, i) => {
118
118
  const progress = layerProgress(layer, state)
119
119
  const dotClass = progress === 'done' ? 'dot on' : progress === 'partial' ? 'dot half' : 'dot'
120
- const dotLabel = progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
120
+ const dotLabel =
121
+ progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
121
122
  const reviewed = filesReviewed(layer, state)
122
123
  const files = layer.files.length
123
124
  const meta = reviewed > 0 && reviewed < files ? `${reviewed} of ${files} files` : fileCount(files)
@@ -206,7 +207,11 @@ export function layerDiagramHtml(layer) {
206
207
  * @param {ReadonlySet<string>} paths
207
208
  */
208
209
  export function judgmentHtml(layer, paths) {
209
- const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (cls, label, text) =>
210
+ const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (
211
+ cls,
212
+ label,
213
+ text
214
+ ) =>
210
215
  text === undefined
211
216
  ? ''
212
217
  : `<div class="judgment ${cls}"><h3 class="lbl">${label}</h3><div class="prose">${renderMarkdown(text, { paths })}</div></div>`
@@ -230,7 +235,9 @@ export function layerPointsHtml(layer, points, paths, state, posted) {
230
235
  return ''
231
236
  }
232
237
  const active = own.filter(p => state.dismissed[p.fingerprint] === undefined).length
233
- const cards = own.map(p => pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted }))
238
+ const cards = own.map(p =>
239
+ pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted })
240
+ )
234
241
  return `<h3 class="lbl sub">Attention points · <span class="point-count">${active}</span></h3><ol class="findings">${cards.join('')}</ol>`
235
242
  }
236
243
 
@@ -245,14 +252,16 @@ export function renderFileCard(lf, entry, layer, ctx) {
245
252
  const key = entry?.key ?? sanitizeKey(lf.path)
246
253
  const cardReviewedId = `layer:${layer.id}/file:${sanitizeKey(lf.path)}`
247
254
  const cardReviewed = ctx.state?.reviewed[cardReviewedId] === true
248
- const collapsed = cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
255
+ const collapsed =
256
+ cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
249
257
  const isFirst = !ctx.firstCardFor.has(key)
250
258
  ctx.firstCardFor.add(key)
251
259
  const id = isFirst ? fileAnchorId(key) : `${fileAnchorId(key)}-${layer.key}`
252
260
  const pills = entry
253
261
  ? `<span class="pill add">+${entry.additions}</span><span class="pill del">&minus;${entry.deletions}</span>`
254
262
  : ''
255
- const status = entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
263
+ const status =
264
+ entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
256
265
  const path =
257
266
  entry?.oldPath !== undefined
258
267
  ? `<span class="old">${esc(entry.oldPath)} &rarr; </span>${esc(lf.path)}`
@@ -272,7 +281,7 @@ export function renderFileCard(lf, entry, layer, ctx) {
272
281
  }
273
282
 
274
283
  /**
275
- * "2 more hunks in layer 4 · title" when a file's other hunks live in other layers.
284
+ * "2 more chunks in layer 4 · title" when a file's other hunks live in other layers.
276
285
  * @param {LayerFile} lf
277
286
  * @param {FileEntry | undefined} entry
278
287
  * @param {Layer} layer
@@ -301,7 +310,7 @@ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
301
310
  }
302
311
  const parts = [...others.values()].map(
303
312
  o =>
304
- `${o.n} more ${o.n === 1 ? 'hunk' : 'hunks'} in <a href="#${esc(layerAnchorId(o.layer.key))}">${o.layer.kind === 'other' ? esc(o.layer.title) : `layer ${o.index + 1} · ${esc(o.layer.title)}`}</a>`
313
+ `${o.n} more ${o.n === 1 ? 'chunk' : 'chunks'} in <a href="#${esc(layerAnchorId(o.layer.key))}">${o.layer.kind === 'other' ? esc(o.layer.title) : `layer ${o.index + 1} · ${esc(o.layer.title)}`}</a>`
305
314
  )
306
315
  return `<div class="more-hunks">${parts.join(' · ')}</div>`
307
316
  }
@@ -411,7 +420,8 @@ export function hydrateFileCard(card, ctx, opts = {}) {
411
420
  const entry = ctx.files.find(f => f.key === key)
412
421
  const patch = ctx.patches?.[key]
413
422
  if (!entry || patch === undefined) {
414
- host.innerHTML = '<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
423
+ host.innerHTML =
424
+ '<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
415
425
  return { rendered: false, deferred: false, placed: 0, missed: 0 }
416
426
  }
417
427
  const lines = patchLineCount(patch)
@@ -432,10 +442,10 @@ export function hydrateFileCard(card, ctx, opts = {}) {
432
442
  async () => {
433
443
  hydrateFileCard(card, ctx, { force: true })
434
444
  // The host element now holds a drawn diff, so a later link into it leaves it alone.
435
- const host = card.closest('pr-file')
436
- if (host instanceof PrFileElement) {
437
- host.rendered = true
438
- host.deferred = false
445
+ const fileHost = card.closest('pr-file')
446
+ if (fileHost instanceof PrFileElement) {
447
+ fileHost.rendered = true
448
+ fileHost.deferred = false
439
449
  }
440
450
  },
441
451
  { pendingLabel: 'drawing…' }
@@ -450,7 +460,9 @@ export function hydrateFileCard(card, ctx, opts = {}) {
450
460
  const layer = ctx.artifact.layers.find(l => l.id === layerId)
451
461
  const lf = layer?.files.find(f => f.path === entry.path)
452
462
  const annotations = lf?.annotations ?? []
453
- const points = ctx.artifact.points.filter(p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry)))
463
+ const points = ctx.artifact.points.filter(
464
+ p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry))
465
+ )
454
466
  const threads = threadsForHunks(ctx.comments, entry, hunkIds)
455
467
  const { placed, missed } = applyDecorations(card, key, {
456
468
  annotations,
@@ -99,14 +99,20 @@ export function resolveLink(link, targets) {
99
99
  return { ok: false, message: `${link.path} is not in the diff` }
100
100
  }
101
101
  if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
102
- return { ok: false, message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} hunks)` }
102
+ return {
103
+ ok: false,
104
+ message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} chunks)`,
105
+ }
103
106
  }
104
107
  if (link.kind === 'line') {
105
108
  const start = hunkForLine(file.hunks, link.side, link.start)
106
109
  const end = hunkForLine(file.hunks, link.side, link.end)
107
110
  if (start === null || start !== end) {
108
111
  const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
109
- return { ok: false, message: `${link.path}:${range} (${link.side}) is not inside one hunk of the diff (${hunkLineRanges(file.hunks, link.side)})` }
112
+ return {
113
+ ok: false,
114
+ message: `${link.path}:${range} (${link.side}) is not inside one chunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
115
+ }
110
116
  }
111
117
  }
112
118
  return { ok: true }
@@ -143,7 +149,7 @@ export function linkLabel(link) {
143
149
  case 'file':
144
150
  return link.path
145
151
  case 'hunk':
146
- return `${link.path} hunk ${link.n}`
152
+ return `${link.path} chunk ${link.n}`
147
153
  case 'line':
148
154
  return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
149
155
  }
@@ -147,6 +147,28 @@ function decorateLinks(root) {
147
147
  }
148
148
  }
149
149
 
150
+ /**
151
+ * GitHub images can require a signed-in browser session. Link to them so the browser
152
+ * can open them on their own origin, where that session is available.
153
+ * @param {HTMLImageElement} img
154
+ * @param {string} src
155
+ */
156
+ function linkGitHubImage(img, src) {
157
+ const link = document.createElement('a')
158
+ link.href = src
159
+ const alt = img.alt.trim()
160
+ link.textContent = alt ? `View image on GitHub: ${alt}` : 'View image on GitHub'
161
+ if (img.title) link.title = img.title
162
+ const enclosingLink = img.closest('a')
163
+ if (enclosingLink) {
164
+ // Keep the original destination without creating nested links.
165
+ img.replaceWith(document.createTextNode(alt || 'Image'))
166
+ enclosingLink.after(document.createTextNode(' '), link)
167
+ } else {
168
+ img.replaceWith(link)
169
+ }
170
+ }
171
+
150
172
  /**
151
173
  * One markdown text, without mermaid blocks.
152
174
  * @param {string} src markdown
@@ -172,8 +194,13 @@ function renderProse(src, opts) {
172
194
  if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
173
195
  }
174
196
  for (const img of root.querySelectorAll('img')) {
175
- if (!/^https:\/\//i.test(img.getAttribute('src') ?? '')) {
197
+ const imageSrc = img.getAttribute('src') ?? ''
198
+ if (!/^https:\/\//i.test(imageSrc)) {
176
199
  img.remove()
200
+ } else if (
201
+ /^https:\/\/(?:github\.com|(?:[a-z0-9-]+\.)*githubusercontent\.com)(?::443)?\//i.test(imageSrc)
202
+ ) {
203
+ linkGitHubImage(img, imageSrc)
177
204
  } else {
178
205
  img.loading = 'lazy'
179
206
  img.referrerPolicy = 'no-referrer'
package/static/js/nav.js CHANGED
@@ -46,7 +46,8 @@ export function buildNavOrder(artifact) {
46
46
  * @returns {NavItem | null}
47
47
  */
48
48
  function step(order, currentId, kind, direction) {
49
- const from = currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
49
+ const from =
50
+ currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
50
51
  const start = from === -1 && currentId !== null ? (direction === 1 ? -1 : order.length) : from
51
52
  for (let i = start + direction; i >= 0 && i < order.length; i += direction) {
52
53
  const item = order[i]
@@ -87,5 +88,7 @@ export function layerOf(order, id) {
87
88
  if (!item || item.kind === 'overview') {
88
89
  return null
89
90
  }
90
- return item.kind === 'layer' ? item : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
91
+ return item.kind === 'layer'
92
+ ? item
93
+ : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
91
94
  }
@@ -2,11 +2,32 @@
2
2
  /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
3
3
  /** @typedef {import('./contract-types.js').IssueComment} IssueComment */
4
4
 
5
- import { commentHtml } from './diff-decorations.js'
6
5
  import { viewCommentHtml } from './comment-link.js'
7
6
  import { detailsSummaryHtml, esc, avatarHtml, timeAgo } from './dom.js'
8
7
  import { renderMarkdown } from './markdown.js'
9
8
  import { dismissedListHtml, postedUrls, sevsumHtml } from './points.js'
9
+ import { buildThreads } from './threads.js'
10
+
11
+ /**
12
+ * @param {PrBundle['comments']['reviewComments']} comments
13
+ * @param {Date} now
14
+ */
15
+ function outdatedCommentsHtml(comments, now) {
16
+ const threads = [...buildThreads(comments).outdated.values()].flat()
17
+ if (!threads.length) return ''
18
+ const count = threads.reduce((total, thread) => total + 1 + thread.replies.length, 0)
19
+ const entries = threads
20
+ .map(thread => {
21
+ const line = thread.root.originalLine
22
+ return (
23
+ `<div class="body"><p class="muted small">${esc(thread.path)}${line === null ? '' : `:${line} (original)`}${thread.resolved ? ' · resolved' : ''}</p>` +
24
+ [thread.root, ...thread.replies].map(comment => issueCommentHtml(comment, now)).join('') +
25
+ '</div>'
26
+ )
27
+ })
28
+ .join('')
29
+ return `<details class="outdated-comments">${detailsSummaryHtml(`<span>Outdated comments · ${count}</span>`, 'Toggle outdated comments')}${entries}</details>`
30
+ }
10
31
 
11
32
  /**
12
33
  * "What changes": the PR-wide summary as one prose block.
@@ -61,7 +82,12 @@ export function conversationHtml(comments, now) {
61
82
  */
62
83
  export function renderOverview(bundle, ctx) {
63
84
  const artifact = bundle.artifact
64
- const active = artifact ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined) : []
85
+ const reviews = (bundle.comments.reviews ?? []).filter(
86
+ review => review.state !== 'COMMENTED' || review.body.trim().length > 0
87
+ )
88
+ const active = artifact
89
+ ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined)
90
+ : []
65
91
  const summary = artifact ? summaryHtml(artifact.summary, ctx.paths) : ''
66
92
  const description = bundle.pr.body.trim()
67
93
  ? `<details class="pr-desc">${detailsSummaryHtml('<span>PR description (from GitHub)</span>', 'Toggle PR description')}<div class="body prose">${renderMarkdown(bundle.pr.body, { paths: ctx.paths, github: true })}</div></details>`
@@ -72,8 +98,10 @@ export function renderOverview(bundle, ctx) {
72
98
  `<div class="body">${summary}</div>` +
73
99
  description +
74
100
  conversationHtml(bundle.comments.issueComments, ctx.now) +
75
- (bundle.comments.reviews ?? []).map(review => `<div class="body"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('') +
76
- (bundle.comments.reviewComments.length ? `<details class="body all-review-comments">${detailsSummaryHtml(`<span>All review comments · ${bundle.comments.reviewComments.length}</span>`, 'Toggle all review comments')}${bundle.comments.reviewComments.map(c => `<div><p class="muted small">${esc(c.path)}${c.line ? `:${c.line}` : ''}${c.outdated ? ' · outdated' : ''}${c.resolved ? ' · resolved' : ''}</p>${commentHtml(c, ctx.now)}</div>`).join('')}</details>` : '') +
101
+ (reviews.length
102
+ ? `<details class="review-history">${detailsSummaryHtml(`<span>Review history · ${reviews.length}</span>`, 'Toggle review history')}${reviews.map(review => `<div class="body review-entry"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('')}</details>`
103
+ : '') +
104
+ outdatedCommentsHtml(bundle.comments.reviewComments, ctx.now) +
77
105
  (artifact
78
106
  ? dismissedListHtml(artifact.points, bundle.state, {
79
107
  paths: ctx.paths,
@@ -5,6 +5,7 @@
5
5
  import { askButtonHtml } from './ask.js'
6
6
  import { viewCommentHtml } from './comment-link.js'
7
7
  import { esc } from './dom.js'
8
+ import { postToLabel } from './host.js'
8
9
  import { layerAnchorId, pointAnchorId } from './keys.js'
9
10
  import { renderMarkdown } from './markdown.js'
10
11
 
@@ -80,7 +81,7 @@ export function pointCommandsHtml(p, opts = {}) {
80
81
  '<span class="tbtns">' +
81
82
  `<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
82
83
  (opts.postedUrl === undefined
83
- ? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>post to github</button>`
84
+ ? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>`
84
85
  : viewCommentHtml(opts.postedUrl)) +
85
86
  askButtonHtml(pointContext(p)) +
86
87
  toggle +
@@ -190,7 +191,8 @@ export function applyDismissed(root, points, state, ctx) {
190
191
  }
191
192
  const host = root.querySelector('.dismissed-list')
192
193
  if (host !== null) {
193
- const expanded = host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
194
+ const expanded =
195
+ host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
194
196
  const template = document.createElement('template')
195
197
  template.innerHTML = dismissedListHtml(points, state, ctx, expanded)
196
198
  const next = template.content.firstElementChild
@@ -219,7 +221,7 @@ export function pointRowHtml(p, ctx) {
219
221
  const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
220
222
  const posted = postedFor(p, ctx)
221
223
  return (
222
- `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="ln" colspan="3"></td><td class="code x">` +
224
+ `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
223
225
  `<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
224
226
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
225
227
  `${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
@@ -45,5 +45,6 @@ export function progressSummary(artifact, state) {
45
45
  * @param {PrState} state
46
46
  */
47
47
  export function filesReviewed(layer, state) {
48
- return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true).length
48
+ return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
49
+ .length
49
50
  }
@@ -63,7 +63,10 @@ export function parseProposedComment(source, targets) {
63
63
  return { reason: 'side must be "new" or "old"' }
64
64
  }
65
65
  const side = sideRaw === 'old' ? 'old' : 'new'
66
- if (startRaw !== undefined && (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)) {
66
+ if (
67
+ startRaw !== undefined &&
68
+ (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)
69
+ ) {
67
70
  return { reason: 'startLine must be a line number' }
68
71
  }
69
72
  const startLine = typeof startRaw === 'number' ? startRaw : undefined
@@ -18,7 +18,8 @@ export const ASK_SOMETHING_ELSE = 'ask something else…'
18
18
  export function quickMenuHtml() {
19
19
  const items = [...QUICK_QUESTIONS, ASK_SOMETHING_ELSE]
20
20
  .map(
21
- (q, i) => `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
21
+ (q, i) =>
22
+ `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
22
23
  )
23
24
  .join('')
24
25
  return `<div class="qq-menu" id="${QQ_MENU_ID}" role="menu" aria-label="Quick questions" hidden>${items}</div>`
@@ -65,5 +65,8 @@ export function canvasChanged(current, next) {
65
65
  if (current.status !== 'ready' || !current.artifact || !current.canvas) {
66
66
  return true
67
67
  }
68
- return next.canvas.headSha !== current.canvas.headSha || next.artifact.generatedAt !== current.artifact.generatedAt
68
+ return (
69
+ next.canvas.headSha !== current.canvas.headSha ||
70
+ next.artifact.generatedAt !== current.artifact.generatedAt
71
+ )
69
72
  }
@@ -24,12 +24,13 @@ import { sanitizeKey } from './keys.js'
24
24
 
25
25
  /**
26
26
  * @typedef {{
27
- * prNumber: number,
27
+ * prNumber: import('./contract-types.js').ReviewKey,
28
28
  * artifact: ReviewArtifact,
29
29
  * files: ReadonlyArray<FileEntry>,
30
30
  * state: PrState,
31
31
  * capabilities: Capabilities,
32
32
  * headSha: string,
33
+ * canvasSha?: string,
33
34
  * api?: Partial<SessionApi>,
34
35
  * onState?: (state: PrState) => void,
35
36
  * }} SessionOptions
@@ -193,7 +194,11 @@ export function createReviewSession(options) {
193
194
  return change(
194
195
  current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
195
196
  current => withEntry(current, 'reviewed', id, before),
196
- () => api.putReviewed(options.prNumber, id, reviewed, { headSha: options.headSha })
197
+ () =>
198
+ api.putReviewed(options.prNumber, id, reviewed, {
199
+ headSha: options.headSha,
200
+ ...(options.canvasSha === undefined ? {} : { canvasSha: options.canvasSha }),
201
+ })
197
202
  )
198
203
  },
199
204
  /**
@@ -65,12 +65,13 @@ export function settingsDialogHtml(data, agents) {
65
65
  `<p class="muted small mono">${esc(data.file)}</p>` +
66
66
  '<div class="panel-ro"><h3>Project config (read-only)</h3>' +
67
67
  `<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
68
+ `<li>canvas kept for an identical diff: ${project.keepForIdenticalDiff ? 'yes' : 'no'}</li>` +
68
69
  `<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
69
70
  `<li>configured layer suggestions: ${project.layers}</li>` +
70
71
  `<li>high-risk patterns: ${project.highRisk}</li>` +
71
72
  `<li>max repair rounds: ${project.maxRepairRounds}</li>` +
72
73
  `<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
73
- `<li>small change set: ${project.smallPrHunks} hunks</li></ul>` +
74
+ `<li>small change set: ${project.smallPrHunks} chunks</li></ul>` +
74
75
  `<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
75
76
  '<p class="probe-result" role="status"></p>' +
76
77
  '<div class="dialog-actions">' +
@@ -4,8 +4,9 @@
4
4
  /** @typedef {import('./contract-types.js').PrState} PrState */
5
5
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
6
  /** @typedef {import('./contract-types.js').ReviewBodyResponse} ReviewBodyResponse */
7
- import { previewControlsHtml, setDisabledReason } from './composer.js'
7
+ import { previewControlsHtml, setDisabledReason, setMarkdownPreview } from './composer.js'
8
8
  import { esc } from './dom.js'
9
+ import { hostLabel } from './host.js'
9
10
  import { layerProgress } from './progress.js'
10
11
 
11
12
  export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
@@ -20,7 +21,9 @@ export const LOADING_REASON = 'the review body is still loading'
20
21
  * @returns {string | null}
21
22
  */
22
23
  export function approveBlockedReason(artifact, state) {
23
- const [first, ...rest] = artifact.layers.filter(l => l.kind !== 'other' && layerProgress(l, state) !== 'done')
24
+ const [first, ...rest] = artifact.layers.filter(
25
+ l => l.kind !== 'other' && layerProgress(l, state) !== 'done'
26
+ )
24
27
  if (first === undefined) {
25
28
  return null
26
29
  }
@@ -33,7 +36,7 @@ export function approveBlockedReason(artifact, state) {
33
36
  * @param {'APPROVE' | 'REQUEST_CHANGES'} event
34
37
  */
35
38
  export function signoffTitle(event) {
36
- return event === 'APPROVE' ? 'Approve on GitHub' : 'Request changes on GitHub'
39
+ return event === 'APPROVE' ? `Approve on ${hostLabel()}` : `Request changes on ${hostLabel()}`
37
40
  }
38
41
 
39
42
  /**
@@ -44,7 +47,7 @@ export function signoffDialogHtml(opts) {
44
47
  return (
45
48
  `<dialog id="${SIGNOFF_DIALOG_ID}" class="signoff-dialog" aria-labelledby="signoff-h">` +
46
49
  `<h2 id="signoff-h">${esc(signoffTitle(opts.event))}</h2>` +
47
- '<p class="hint">This posts a review on GitHub as you. Edit the body first if you want to.</p>' +
50
+ `<p class="hint">This posts a review on ${esc(hostLabel())} as you. Edit the body first if you want to.</p>` +
48
51
  '<p class="signoff-target muted mono"></p>' +
49
52
  '<label class="sr" for="signoff-body">Review body</label>' +
50
53
  previewControlsHtml() +
@@ -75,7 +78,7 @@ export function openSignoffDialog(root, opts) {
75
78
  if (!(dialog instanceof HTMLDialogElement)) {
76
79
  throw new Error('sign-off dialog did not render')
77
80
  }
78
- dialog.querySelector('[data-act="markdown-write"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
81
+ setMarkdownPreview(dialog, false)
79
82
  dialog.setAttribute('data-event', opts.event)
80
83
  const heading = dialog.querySelector('#signoff-h')
81
84
  if (heading !== null) {
@@ -129,7 +132,7 @@ export function showSignoffResult(dialog, review) {
129
132
  const result = dialog.querySelector('.signoff-result')
130
133
  if (result !== null) {
131
134
  result.textContent = 'Posted · '
132
- result.append(externalLink(review.url, 'see it on GitHub'))
135
+ result.append(externalLink(review.url, `see it on ${hostLabel()}`))
133
136
  }
134
137
  return result
135
138
  }