@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
@@ -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
  }
@@ -61,8 +62,8 @@ export function composerHtml(opts) {
61
62
  */
62
63
  export function composerRowHtml(opts) {
63
64
  return (
64
- `<tr class="composer" data-decoration="composer"><td class="ln" colspan="3"></td>` +
65
- `<td class="code x">${composerHtml(opts)}</td></tr>`
65
+ `<tr class="composer" data-decoration="composer">` +
66
+ `<td class="code x" colspan="4">${composerHtml(opts)}</td></tr>`
66
67
  )
67
68
  }
68
69
 
@@ -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,20 +220,35 @@ 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
- if (preview) output.innerHTML = textarea.value.trim() ? renderMarkdown(textarea.value, { github: true }) : '<p class="muted">Nothing to preview.</p>'
237
+ if (preview)
238
+ output.innerHTML = textarea.value.trim()
239
+ ? renderMarkdown(textarea.value, { github: true })
240
+ : '<p class="muted">Nothing to preview.</p>'
236
241
  textarea.hidden = preview
237
242
  output.hidden = !preview
238
- host?.querySelector('[data-act="markdown-write"]')?.setAttribute('aria-pressed', String(!preview))
239
- 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'
240
245
  if (!preview) textarea.focus()
241
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,
@@ -554,7 +554,8 @@ export function initDiagrams(root, opts = {}) {
554
554
  if (activateNodeLink(event)) {
555
555
  return
556
556
  }
557
- const chevron = event.target instanceof Element ? event.target.closest('.diagram > .diagram-h > .chev') : null
557
+ const chevron =
558
+ event.target instanceof Element ? event.target.closest('.diagram > .diagram-h > .chev') : null
558
559
  const node = chevron?.closest('.diagram')
559
560
  if (node instanceof HTMLElement) {
560
561
  void toggleDiagram(node, pass)
@@ -41,7 +41,7 @@ export function insertNoteRow(card, key, a) {
41
41
  }
42
42
  const range = a.startLine === a.endLine ? `line ${a.startLine}` : `lines ${a.startLine}–${a.endLine}`
43
43
  const html =
44
- `<tr class="annot ann${approx ? ' is-approx' : ''}" ${DECORATION}="note"><td class="ln" colspan="3"></td><td class="code x">` +
44
+ `<tr class="annot ann${approx ? ' is-approx' : ''}" ${DECORATION}="note"><td class="code x" colspan="4">` +
45
45
  `<span class="lbl">Annotation · ${esc(range)}${a.side === 'old' ? ' (old)' : ''}</span>` +
46
46
  `<div class="prose">${renderMarkdown(a.text)}</div></td></tr>`
47
47
  last.insertAdjacentElement('afterend', firstRow(html))
@@ -102,7 +102,7 @@ export function threadRowHtml(t, opts) {
102
102
  : `<button class="cmd" type="button" data-act="thread-hide" data-thread="${rootId}">hide</button>`
103
103
  return (
104
104
  `<tr class="thread${t.resolved ? ' resolved' : ''}${hidden ? ' hidden-thread' : ''}" data-thread="${rootId}">` +
105
- '<td class="ln" colspan="3"></td><td class="code x">' +
105
+ '<td class="code x" colspan="4">' +
106
106
  `<div class="thread-full"${collapsed ? ' hidden' : ''}>${comments}` +
107
107
  `<span class="tbtns"><button class="cmd" type="button" data-act="thread-reply" data-thread="${rootId}" data-needs-post>reply</button>${hide}</span></div>` +
108
108
  `<div class="thread-collapsed"${collapsed ? '' : ' hidden'}>${label} · ` +
@@ -186,7 +186,9 @@ export function applyDecorations(card, key, data) {
186
186
  }
187
187
  }
188
188
  for (const t of [...data.threads].reverse()) {
189
- count(insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false }))
189
+ count(
190
+ insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false })
191
+ )
190
192
  }
191
193
  for (const p of [...data.points].reverse()) {
192
194
  count(
@@ -113,7 +113,12 @@ export function parsePatch(patch) {
113
113
  prevDel = true
114
114
  prevAdd = false
115
115
  } else {
116
- cur.entries.push({ ...base, code: line.startsWith(' ') ? line.slice(1) : line, oldLine: oL, newLine: nL })
116
+ cur.entries.push({
117
+ ...base,
118
+ code: line.startsWith(' ') ? line.slice(1) : line,
119
+ oldLine: oL,
120
+ newLine: nL,
121
+ })
117
122
  oL++
118
123
  nL++
119
124
  prevAdd = false
@@ -850,7 +855,7 @@ export function renderDiff(file, patch, opts = {}) {
850
855
  highlightHunk(h, file.lang)
851
856
  tables.push(
852
857
  `<table class="diff" id="${esc(hunkAnchorId(file.key, n))}" data-hunk="${esc(id)}" data-key="${esc(file.key)}">` +
853
- `<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>`
854
859
  )
855
860
  })
856
861
  if (tables.length === 0) {
package/static/js/dom.js CHANGED
@@ -20,7 +20,7 @@ export function esc(value) {
20
20
  * in interactions.js reads `data-act` and toggles the card this button belongs to.
21
21
  * @param {string} label
22
22
  * @param {boolean} [expanded]
23
- * @param {{ act?: string }} [opts] `act` is omitted inside a <details>, which toggles itself
23
+ * @param {{ act?: string }} [opts]
24
24
  */
25
25
  export function chevronHtml(label, expanded = true, opts = {}) {
26
26
  const act = opts.act === undefined ? '' : ` data-act="${esc(opts.act)}"`
@@ -29,14 +29,12 @@ export function chevronHtml(label, expanded = true, opts = {}) {
29
29
 
30
30
  /**
31
31
  * The <summary> of a collapsible <details> block: a chevron, then the title. The block relies on the
32
- * native <details> toggle, so a click anywhere on the summary opens or closes it. Pass `open` when
33
- * the <details> carries the `open` attribute so the chevron reports the same state.
32
+ * native <details> toggle. The decorative chevron lets clicks activate the summary.
34
33
  * @param {string} titleHtml already escaped
35
- * @param {string} label the chevron's accessible name
36
- * @param {{ open?: boolean }} [opts]
34
+ * @param {string} label the summary's accessible name
37
35
  */
38
- export function detailsSummaryHtml(titleHtml, label, opts = {}) {
39
- return `<summary>${chevronHtml(label, opts.open === true)}${titleHtml}</summary>`
36
+ export function detailsSummaryHtml(titleHtml, label) {
37
+ return `<summary aria-label="${esc(label)}"><span class="chev" aria-hidden="true">&gt;</span>${titleHtml}</summary>`
40
38
  }
41
39
 
42
40
  /**
@@ -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
  */
@@ -41,16 +41,42 @@ export function dropReducer(state, event) {
41
41
  }
42
42
 
43
43
  /**
44
- * The file the user picked has to be a canvas zip. The name is only a first check; the server
45
- * reads the zip itself and answers CANVAS_INVALID when it is not one.
44
+ * A looser form of the export name grammar `parseCanvasZipName` (src/canvas/name.ts) holds for the
45
+ * server: enough to catch the wrong file before it is uploaded, never a second copy of the check.
46
+ */
47
+ const CANVAS_NAME_RE = /^(?:pr-([1-9]\d*)|ref)-.+-canvas\.zip$/i
48
+
49
+ /**
50
+ * The file the user picked has to be a canvas zip for this pull request. The name is only a first
51
+ * check; the server reads the zip itself and answers CANVAS_INVALID or CANVAS_PR_MISMATCH when it
52
+ * is not one. A canvas exported before the PR existed is named `ref-` and names no PR.
46
53
  * @param {string} filename
54
+ * @param {number} prNumber the PR the page is on
47
55
  * @returns {string | null} the problem, or null when the name is fine
48
56
  */
49
- export function validateCanvasFilename(filename) {
57
+ export function validateCanvasFilename(filename, prNumber) {
50
58
  if (!filename.toLowerCase().endsWith('.zip')) {
51
59
  return 'that is not a zip file'
52
60
  }
53
- return /^(?:pr-[1-9]\d*|ref)-.+-canvas\.zip$/i.test(filename) ? null : 'that zip is not a review canvas export'
61
+ const match = CANVAS_NAME_RE.exec(filename)
62
+ if (!match) {
63
+ return 'that zip is not a review canvas export'
64
+ }
65
+ const named = match[1] === undefined ? undefined : Number(match[1])
66
+ if (named !== undefined && named !== prNumber) {
67
+ return `that canvas was exported for PR #${named}, not #${prNumber}`
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * The refusals no retry can clear: the zip is a canvas, of the wrong thing. Anything else the
74
+ * discovery reports is a download that failed, which the link and the drop zone can still rescue.
75
+ * @type {Record<string, string>}
76
+ */
77
+ const SHARED_CANVAS_REFUSALS = {
78
+ 'pr-mismatch': 'it was exported for another pull request',
79
+ 'name-mismatch': 'it was exported from another repository',
54
80
  }
55
81
 
56
82
  /**
@@ -66,9 +92,15 @@ export function sharedCanvasCalloutHtml(bundle) {
66
92
  if (shared.downloadable) {
67
93
  return `<div class="callout" role="status">A canvas is attached to this PR (${name}) &mdash; importing&hellip;</div>`
68
94
  }
95
+ const refused = SHARED_CANVAS_REFUSALS[shared.reason ?? '']
96
+ const problem = refused ?? `could not be downloaded (${esc(shared.reason ?? 'unknown')})`
97
+ const next =
98
+ refused === undefined
99
+ ? `<a href="${esc(shared.url)}" target="_blank" rel="noopener noreferrer">Download it</a> and drop it here. `
100
+ : 'Ask its author for a canvas of this pull request, or generate one. '
69
101
  return (
70
- `<div class="callout warn" role="status">A canvas is attached to this PR (${name}) but could not be downloaded (${esc(shared.reason ?? 'unknown')}). ` +
71
- `<a href="${esc(shared.url)}" target="_blank" rel="noopener noreferrer">Download it</a> and drop it here. ` +
102
+ `<div class="callout warn" role="status">A canvas is attached to this PR (${name}) but ${problem}. ` +
103
+ next +
72
104
  '<button class="cmd" type="button" id="fetch-shared">fetch again</button></div>'
73
105
  )
74
106
  }
@@ -84,6 +116,16 @@ function skillCommandHtml(bundle) {
84
116
  )
85
117
  }
86
118
 
119
+ /**
120
+ * What each screen with no canvas is called. A local review names the work it describes, because
121
+ * `branch` and `uncommitted` are two different reads of the same clone.
122
+ * @type {Record<string, string>}
123
+ */
124
+ const LOCAL_HEADINGS = {
125
+ branch: 'No review canvas for this branch yet',
126
+ uncommitted: 'No review canvas for your uncommitted work yet',
127
+ }
128
+
87
129
  /** @returns {string} */
88
130
  function dropZoneHtml() {
89
131
  return (
@@ -95,18 +137,20 @@ function dropZoneHtml() {
95
137
  }
96
138
 
97
139
  /**
98
- * The `missing` screen: the skill command with its copy command, the drop zone, the callout.
140
+ * The `missing` screen: the skill command with its copy command, the drop zone, the callout. A
141
+ * local review has no pull request to carry an attached canvas, so it shows neither of the last two.
99
142
  * @param {PrBundle} bundle
100
143
  * @returns {string}
101
144
  */
102
145
  export function renderEmptyState(bundle) {
146
+ const heading = LOCAL_HEADINGS[bundle.local ?? ''] ?? 'No review canvas for this PR yet'
147
+ const transfer = bundle.local ? '' : dropZoneHtml() + sharedCanvasCalloutHtml(bundle)
103
148
  return (
104
149
  '<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
105
- '<div class="panel-h"><h2 id="es-h">No review canvas for this PR yet</h2></div>' +
150
+ `<div class="panel-h"><h2 id="es-h">${heading}</h2></div>` +
106
151
  '<div class="body center">' +
107
152
  skillCommandHtml(bundle) +
108
- dropZoneHtml() +
109
- sharedCanvasCalloutHtml(bundle) +
153
+ transfer +
110
154
  '</div></section>'
111
155
  )
112
156
  }
@@ -114,11 +158,15 @@ export function renderEmptyState(bundle) {
114
158
  /**
115
159
  * How far the canvas is from the head, in words.
116
160
  * @param {NonNullable<PrBundle['stale']>} stale
161
+ * @param {PrBundle['local']} [local] which local review this is, when it is one
117
162
  * @returns {string}
118
163
  */
119
- export function staleSummary(stale) {
164
+ export function staleSummary(stale, local) {
120
165
  const canvas = stale.canvasHeadSha.slice(0, 7)
121
166
  const head = stale.currentHeadSha.slice(0, 7)
167
+ if (local !== undefined) {
168
+ return `The canvas is for ${canvas}; your work has moved on to ${head} since it was generated.`
169
+ }
122
170
  if (stale.relation === 'unrelated') {
123
171
  return `The canvas is for ${canvas}, which is not in this branch any more; the head is ${head}.`
124
172
  }
@@ -130,15 +178,36 @@ export function staleSummary(stale) {
130
178
  * The bar that stays on screen while a stale canvas is shown, so the reader is never misled about
131
179
  * which commit the diffs come from.
132
180
  * @param {NonNullable<PrBundle['stale']>} stale
181
+ * @param {PrBundle['local']} [local]
133
182
  * @returns {string}
134
183
  */
135
- export function staleBarHtml(stale) {
184
+ export function staleBarHtml(stale, local) {
136
185
  return (
137
- `<div class="stale-bar" role="status"><strong>Canvas is outdated.</strong> You are reading an older commit. ${esc(staleSummary(stale))} ` +
186
+ `<div class="stale-bar" role="status"><strong>Canvas is outdated.</strong> You are reading an older commit. ${esc(staleSummary(stale, local))} ` +
138
187
  '<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>'
139
188
  )
140
189
  }
141
190
 
191
+ /**
192
+ * The note above a canvas that was generated for another commit of the pull request whose diff is
193
+ * identical to the head's, so the canvas still applies.
194
+ * @param {NonNullable<PrBundle['carriedOver']>} carried
195
+ * @returns {string}
196
+ */
197
+ export function carriedOverBarHtml(carried) {
198
+ const canvas = carried.canvasHeadSha.slice(0, 7)
199
+ const head = carried.currentHeadSha.slice(0, 7)
200
+ // How far the head moved, when the bundle knows: a head that reached the same diff by a rebase
201
+ // is no number of commits away from the canvas's commit, so the note says nothing about it.
202
+ const behind = carried.commitsBehind
203
+ const distance = behind === undefined ? '' : `, ${behind} commit${behind === 1 ? '' : 's'} later,`
204
+ return (
205
+ `<div class="stale-bar carried-over-bar" role="status"><strong>Canvas still applies.</strong> ` +
206
+ `${esc(`It was generated for ${canvas}; the head ${head}${distance} has the identical diff.`)} ` +
207
+ '<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">regenerate anyway</button></div>'
208
+ )
209
+ }
210
+
142
211
  /**
143
212
  * The `stale` screen: the same card as the empty state, plus the two ways forward.
144
213
  * @param {PrBundle} bundle
@@ -146,16 +215,16 @@ export function staleBarHtml(stale) {
146
215
  */
147
216
  export function renderStaleState(bundle) {
148
217
  const stale = bundle.stale
218
+ const transfer = bundle.local ? '' : dropZoneHtml() + sharedCanvasCalloutHtml(bundle)
149
219
  return (
150
220
  '<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
151
221
  '<div class="panel-h"><h2 id="es-h">Canvas is outdated</h2></div>' +
152
222
  '<div class="body center">' +
153
- `<p class="hint">${stale ? esc(staleSummary(stale)) : ''}</p>` +
223
+ `<p class="hint">${stale ? esc(staleSummary(stale, bundle.local)) : ''}</p>` +
154
224
  '<div class="cmdbox"><button class="cmd fill" type="button" id="view-stale">view stale canvas</button>' +
155
225
  '<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>' +
156
226
  skillCommandHtml(bundle) +
157
- dropZoneHtml() +
158
- sharedCanvasCalloutHtml(bundle) +
227
+ transfer +
159
228
  '</div></section>'
160
229
  )
161
230
  }
@@ -36,8 +36,8 @@ export const ERROR_CARDS = {
36
36
  action: 'Start pr-review from a clone, or pass --repo <dir>.',
37
37
  },
38
38
  NO_ORIGIN: {
39
- title: 'No GitHub origin',
40
- action: 'Add an origin remote that points at github.com, then restart the server.',
39
+ title: 'No GitHub or GitLab origin',
40
+ action: 'Add an origin remote that points at github.com or GitLab, then restart the server.',
41
41
  },
42
42
  GIT_ERROR: {
43
43
  title: 'A git command failed',
@@ -55,6 +55,18 @@ export const ERROR_CARDS = {
55
55
  title: 'GitHub refused the request',
56
56
  action: 'Retry in a moment. A rate limit or an outage both read like this.',
57
57
  },
58
+ GLAB_MISSING: {
59
+ title: 'GitLab CLI is not installed',
60
+ action: 'Install glab from https://gitlab.com/gitlab-org/cli and log in, then retry.',
61
+ },
62
+ GLAB_UNAUTHENTICATED: {
63
+ title: 'GitLab CLI is not logged in',
64
+ action: 'Run glab auth login in a terminal, then retry.',
65
+ },
66
+ GITLAB_API_ERROR: {
67
+ title: 'GitLab refused the request',
68
+ action: 'Retry in a moment. A rate limit or an outage both read like this.',
69
+ },
58
70
  PR_NOT_FOUND: {
59
71
  title: 'Pull request not found',
60
72
  action: 'Check the number, and that this clone’s origin is the right repository.',
@@ -71,6 +83,10 @@ export const ERROR_CARDS = {
71
83
  title: 'That canvas belongs to another repository',
72
84
  action: 'Import a canvas exported from this repository.',
73
85
  },
86
+ CANVAS_PR_MISMATCH: {
87
+ title: 'That canvas belongs to another pull request',
88
+ action: 'Import the canvas exported for this PR, or generate one for it with the skill.',
89
+ },
74
90
  CANVAS_TOO_LARGE: {
75
91
  title: 'That canvas is too large',
76
92
  action: 'A canvas zip holds two JSON files and stays under 20 MB. Export it again.',
@@ -88,12 +104,12 @@ export const ERROR_CARDS = {
88
104
  action: 'Remove it, or run pr-review install-skill --force.',
89
105
  },
90
106
  COMMENT_FORBIDDEN: {
91
- title: 'This GitHub login cannot post here',
92
- action: 'Run gh auth refresh -h github.com -s repo, then retry.',
107
+ title: 'This login cannot post here',
108
+ action: 'Check the account’s repository access and token permissions, then retry.',
93
109
  },
94
110
  COMMENT_LINE_NOT_IN_DIFF: {
95
- title: 'GitHub takes comments only on lines the diff shows',
96
- action: 'Pick a line inside a hunk of this pull request.',
111
+ title: 'Comments must sit on lines the diff shows',
112
+ action: 'Pick a line inside a chunk of this pull request.',
97
113
  },
98
114
  SIGNOFF_INCOMPLETE: {
99
115
  title: 'Some layers are not reviewed yet',
@@ -3,6 +3,7 @@
3
3
  /** @typedef {import('./contract-types.js').RiskTag} RiskTag */
4
4
  import { setDisabledReason } from './composer.js'
5
5
  import { esc, timeAgo } from './dom.js'
6
+ import { authorProfileUrl, currentHost, hostLabel } from './host.js'
6
7
  import { refreshRail } from './layers.js'
7
8
  import { progressSummary } from './progress.js'
8
9
  import { approveBlockedReason } from './signoff.js'
@@ -41,6 +42,19 @@ export function largePrNoticeHtml(bundle) {
41
42
  )
42
43
  }
43
44
 
45
+ /**
46
+ * The author, linked to their page on the forge. A local review's author is the name this clone
47
+ * commits as, which is no forge login, so it stays plain text.
48
+ * @param {string} author
49
+ * @param {boolean} local
50
+ * @returns {string}
51
+ */
52
+ function authorHtml(author, local) {
53
+ return local
54
+ ? esc(author)
55
+ : `<a href="${esc(authorProfileUrl(author))}" target="_blank" rel="noopener noreferrer">${esc(author)}</a>`
56
+ }
57
+
44
58
  /** @param {PrBundle['pr']} pr */
45
59
  export function statePill(pr) {
46
60
  const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state
@@ -61,15 +75,24 @@ export function renderHeader(bundle, opts) {
61
75
  ? `<span class="pill agent">${esc(artifact.generator.agent)}${artifact.generator.model ? ` · ${esc(artifact.generator.model)}` : ''} · ${esc(artifact.generator.harness)}</span><span>generated ${esc(timeAgo(artifact.generatedAt, opts.now))}</span>`
62
76
  : ''
63
77
  const number = pr.number === null ? '' : `<span class="mono num">#${pr.number}</span>`
78
+ // Work that is not pushed has no page on the forge, and nothing to refresh from it either.
79
+ const local = bundle.local !== undefined
80
+ const forgeLink =
81
+ pr.url === ''
82
+ ? ''
83
+ : `<a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">${esc(currentHost().kind)}</a>`
84
+ const refreshTitle = local
85
+ ? 'Snapshot the working tree again and redraw'
86
+ : `Fetch the latest PR, comments, and shared canvas from ${esc(hostLabel())}`
64
87
  const progress = ready ? progressHtml(artifact, bundle.state) : ''
65
88
  const risk = ready ? riskLineHtml(artifact.risk) : ''
66
89
  return (
67
90
  '<header class="hdr">' +
68
- `<div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
91
+ `<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
69
92
  '<div class="hdr-actions" role="group" aria-label="Canvas actions">' +
70
- `<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for this PR" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
71
- `<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on GitHub"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
72
- '<button class="cmd" type="button" id="refresh" title="Fetch the latest PR, comments, and shared canvas from GitHub">refresh</button>' +
93
+ `<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for ${local ? 'this local work' : 'this PR'}" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
94
+ `<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on ${esc(hostLabel())}"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
95
+ `<button class="cmd" type="button" id="refresh" title="${refreshTitle}">refresh</button>` +
73
96
  `<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog"${bundle.chat.enabled || bundle.chat.acpx ? ' title="Configure the AI chat agent, model, and limits"' : ' disabled title="acpx is not installed"'}>settings</button>` +
74
97
  '<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
75
98
  `<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
@@ -77,8 +100,8 @@ export function renderHeader(bundle, opts) {
77
100
  '</div></div>' +
78
101
  '<div class="stripe" aria-hidden="true"></div>' +
79
102
  '<div class="hdr-title">' +
80
- `<div class="title"><h1>${number}${esc(pr.title)}</h1><a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">github</a></div>` +
81
- `<p class="meta"><span>by <a href="https://github.com/${esc(pr.author)}" target="_blank" rel="noopener noreferrer">${esc(pr.author)}</a></span>` +
103
+ `<div class="title"><h1>${number}${esc(pr.title)}</h1>${forgeLink}</div>` +
104
+ `<p class="meta"><span>by ${authorHtml(pr.author, local)}</span>` +
82
105
  `<span class="mono">${esc(pr.headRef)} &rarr; ${esc(pr.baseRef)}</span>${statePill(pr)}` +
83
106
  `<span class="diffstat"><span class="ok">+${pr.additions}</span> <span class="bad">&minus;${pr.deletions}</span></span>${agent}</p>` +
84
107
  `${largePrNoticeHtml(bundle)}${risk}${progress}</div></header>`
@@ -95,13 +118,13 @@ export function progressHtml(artifact, state) {
95
118
  const p = progressSummary(artifact, state)
96
119
  const blocked = approveBlockedReason(artifact, state)
97
120
  const approve =
98
- `<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on GitHub" data-act="signoff" data-event="APPROVE" data-needs-post` +
99
- `${blocked === null ? ' title="Write and preview an approving review on GitHub"' : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on github</button>`
121
+ `<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on ${esc(hostLabel())}" data-act="signoff" data-event="APPROVE" data-needs-post` +
122
+ `${blocked === null ? ` title="Write and preview an approving review on ${esc(hostLabel())}"` : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on ${esc(currentHost().kind)}</button>`
100
123
  return (
101
124
  `<div class="progress"><div class="pline" role="progressbar" aria-valuenow="${p.done}" aria-valuemin="0" aria-valuemax="${p.total}" aria-label="Layers reviewed"><span style="width:${p.percent}%"></span></div>` +
102
125
  `<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
103
126
  `<div class="signoff">${approve}` +
104
- '<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on GitHub" title="Write and preview a review requesting changes on GitHub" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>' +
127
+ `<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on ${esc(hostLabel())}" title="Write and preview a review requesting changes on ${esc(hostLabel())}" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>` +
105
128
  '<span class="capability-note" role="status"></span></div>'
106
129
  )
107
130
  }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ // The forge this server talks to, as the page shell's bootstrap names it. One server serves one
3
+ // host, so the words and links that depend on it are read from here rather than threaded through
4
+ // every renderer.
5
+ /** @typedef {import('./contract-types.js').PublicHost} PublicHost */
6
+
7
+ /** @type {PublicHost} */
8
+ const DEFAULT_HOST = { kind: 'github', label: 'GitHub', webBase: 'https://github.com' }
9
+
10
+ /** @type {PublicHost} */
11
+ let current = DEFAULT_HOST
12
+
13
+ /** @param {PublicHost | undefined} host */
14
+ export function setHost(host) {
15
+ current = host ?? DEFAULT_HOST
16
+ }
17
+
18
+ export function currentHost() {
19
+ return current
20
+ }
21
+
22
+ /** `GitHub` or `GitLab`, for sentences. */
23
+ export function hostLabel() {
24
+ return current.label
25
+ }
26
+
27
+ /** `post to github` / `post to gitlab`, the command label. */
28
+ export function postToLabel() {
29
+ return `post to ${current.kind}`
30
+ }
31
+
32
+ /** What a command says when this login may not post. */
33
+ export function noPostingTitle() {
34
+ return `this ${current.label} login cannot post on this repository`
35
+ }
36
+
37
+ /** @param {string} author */
38
+ export function authorProfileUrl(author) {
39
+ return `${current.webBase}/${author}`
40
+ }
@@ -56,7 +56,7 @@ export function wireDropZone(root, opts) {
56
56
  }
57
57
  }
58
58
  const send = async (/** @type {File} */ file) => {
59
- const problem = validateCanvasFilename(file.name)
59
+ const problem = validateCanvasFilename(file.name, opts.prNumber)
60
60
  if (problem !== null) {
61
61
  apply({ type: 'fail', message: problem })
62
62
  return