@vintasoftware/pr-review-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,145 @@
1
+ // @ts-check
2
+ // Small DOM helpers. Everything that builds HTML from data goes through esc().
3
+
4
+ /**
5
+ * @param {unknown} value
6
+ * @returns {string}
7
+ */
8
+ export function esc(value) {
9
+ return String(value)
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;')
15
+ }
16
+
17
+ /**
18
+ * The `>` chevron before a card title is its collapse toggle. It points right while the card is
19
+ * closed and turns down while it is open (CSS reads aria-expanded). The delegated click handler
20
+ * in interactions.js reads `data-act` and toggles the card this button belongs to.
21
+ * @param {string} label
22
+ * @param {boolean} [expanded]
23
+ * @param {{ act?: string }} [opts] `act` is omitted inside a <details>, which toggles itself
24
+ */
25
+ export function chevronHtml(label, expanded = true, opts = {}) {
26
+ const act = opts.act === undefined ? '' : ` data-act="${esc(opts.act)}"`
27
+ return `<button class="chev" type="button"${act} aria-expanded="${expanded ? 'true' : 'false'}" aria-label="${esc(label)}">&gt;</button>`
28
+ }
29
+
30
+ /**
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.
34
+ * @param {string} titleHtml already escaped
35
+ * @param {string} label the chevron's accessible name
36
+ * @param {{ open?: boolean }} [opts]
37
+ */
38
+ export function detailsSummaryHtml(titleHtml, label, opts = {}) {
39
+ return `<summary>${chevronHtml(label, opts.open === true)}${titleHtml}</summary>`
40
+ }
41
+
42
+ /**
43
+ * Parses an HTML string into a fragment. Only for strings built with esc().
44
+ * @param {string} html
45
+ * @returns {DocumentFragment}
46
+ */
47
+ export function fragment(html) {
48
+ const t = document.createElement('template')
49
+ t.innerHTML = html
50
+ return t.content
51
+ }
52
+
53
+ /**
54
+ * @template {Element} T
55
+ * @param {string} selector
56
+ * @param {ParentNode} [root]
57
+ * @returns {T | null}
58
+ */
59
+ export function qs(selector, root = document) {
60
+ return /** @type {T | null} */ (root.querySelector(selector))
61
+ }
62
+
63
+ /**
64
+ * Highlights an element for two seconds by toggling `.is-target`.
65
+ * @param {Element} el
66
+ * @param {number} [ms]
67
+ */
68
+ export function flash(el, ms = 2000) {
69
+ el.classList.add('is-target')
70
+ setTimeout(() => el.classList.remove('is-target'), ms)
71
+ }
72
+
73
+ /**
74
+ * scrollIntoView without throwing in environments that lack it.
75
+ * @param {Element} el
76
+ */
77
+ export function scrollIntoViewSafe(el) {
78
+ el.dispatchEvent(new Event('reveal-code', { bubbles: true }))
79
+ if (typeof el.scrollIntoView === 'function') {
80
+ el.scrollIntoView({ block: 'center' })
81
+ }
82
+ }
83
+
84
+ /**
85
+ * "3 min ago" style text for a timestamp.
86
+ * @param {string} iso
87
+ * @param {Date} [now]
88
+ * @returns {string}
89
+ */
90
+ export function timeAgo(iso, now = new Date()) {
91
+ const t = Date.parse(iso)
92
+ if (Number.isNaN(t)) {
93
+ return ''
94
+ }
95
+ const s = Math.max(0, Math.round((now.getTime() - t) / 1000))
96
+ if (s < 60) {
97
+ return 'just now'
98
+ }
99
+ const m = Math.round(s / 60)
100
+ if (m < 60) {
101
+ return `${m} min ago`
102
+ }
103
+ const hrs = Math.round(m / 60)
104
+ if (hrs < 48) {
105
+ return `${hrs} h ago`
106
+ }
107
+ return `${Math.round(hrs / 24)} d ago`
108
+ }
109
+
110
+ /**
111
+ * Two-letter initials for the avatar square.
112
+ * @param {string} login
113
+ */
114
+ export function initials(login) {
115
+ const parts = login
116
+ .replace(/\[bot\]$/, '')
117
+ .split(/[-_.\s]+/)
118
+ .filter(Boolean)
119
+ const a = parts[0]?.[0] ?? '?'
120
+ const b = parts[1]?.[0] ?? parts[0]?.[1] ?? ''
121
+ return (a + b).toUpperCase()
122
+ }
123
+
124
+ /**
125
+ * Writes text to the clipboard. Throws when the page has no clipboard access (plain http on a
126
+ * non-localhost origin, or an old browser), so the command shows the failure inline.
127
+ * @param {string} text
128
+ * @param {Clipboard | null} [clipboard] defaults to the page's; null means none
129
+ */
130
+ export async function copyToClipboard(text, clipboard) {
131
+ const target = clipboard === undefined ? globalThis.navigator?.clipboard : clipboard
132
+ if (!target || typeof target.writeText !== 'function') {
133
+ throw new Error('clipboard is not available; copy the command by hand')
134
+ }
135
+ await target.writeText(text)
136
+ }
137
+
138
+ /** @param {{ author: string, avatarUrl?: string | undefined }} comment */
139
+ export function avatarHtml(comment) {
140
+ const url = comment.avatarUrl
141
+ if (url && /^https:\/\/avatars\.githubusercontent\.com\//i.test(url)) {
142
+ return `<img class="av" src="${esc(url)}" alt="${esc(comment.author)} avatar" width="24" height="24" loading="lazy" referrerpolicy="no-referrer">`
143
+ }
144
+ return `<span class="av" aria-hidden="true">${esc(initials(comment.author))}</span>`
145
+ }
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ // Handing a file to the browser. The export command fetches the zip itself, so it can show a
3
+ // pending state and an error; this module only does the save at the end.
4
+ import { fetchCanvasZip } from './api.js'
5
+
6
+ /**
7
+ * @typedef {{ doc?: Document, urls?: { createObjectURL: (b: Blob) => string, revokeObjectURL: (u: string) => void } }} SaveDeps
8
+ */
9
+
10
+ /**
11
+ * @param {Blob} blob
12
+ * @param {string} filename
13
+ * @param {SaveDeps} [deps]
14
+ */
15
+ export function saveBlob(blob, filename, deps = {}) {
16
+ const doc = deps.doc ?? document
17
+ const urls = deps.urls ?? URL
18
+ const href = urls.createObjectURL(blob)
19
+ const link = doc.createElement('a')
20
+ link.href = href
21
+ link.download = filename
22
+ doc.body.appendChild(link)
23
+ link.click()
24
+ link.remove()
25
+ urls.revokeObjectURL(href)
26
+ }
27
+
28
+ /**
29
+ * @param {number} prNumber
30
+ * @param {{ headSha?: string, fetchImpl?: typeof fetch } & SaveDeps} [opts]
31
+ * @returns {Promise<string>} the file name the browser saved
32
+ */
33
+ export async function exportCanvasZip(prNumber, opts = {}) {
34
+ /** @type {{ headSha?: string, fetchImpl?: typeof fetch }} */
35
+ const request = {}
36
+ if (opts.headSha !== undefined) {
37
+ request.headSha = opts.headSha
38
+ }
39
+ if (opts.fetchImpl !== undefined) {
40
+ request.fetchImpl = opts.fetchImpl
41
+ }
42
+ const { blob, filename } = await fetchCanvasZip(prNumber, request)
43
+ const deps = {}
44
+ if (opts.doc !== undefined) {
45
+ deps.doc = opts.doc
46
+ }
47
+ if (opts.urls !== undefined) {
48
+ deps.urls = opts.urls
49
+ }
50
+ saveBlob(blob, filename, deps)
51
+ return filename
52
+ }
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
3
+ import { esc } from './dom.js'
4
+
5
+ export const DROP_ZONE_ID = 'zip'
6
+
7
+ /**
8
+ * What the drop zone shows. `over` is a file dragged across it, `busy` an upload in flight, and
9
+ * `error` the last failure, which stays until the next attempt.
10
+ * @typedef {{ phase: 'idle' | 'over' | 'busy', progress: number, error: string | null }} DropState
11
+ */
12
+
13
+ /** @type {DropState} */
14
+ export const INITIAL_DROP_STATE = { phase: 'idle', progress: 0, error: null }
15
+
16
+ /**
17
+ * @typedef {{ type: 'enter' } | { type: 'leave' } | { type: 'start' } | { type: 'progress', fraction: number }
18
+ * | { type: 'done' } | { type: 'fail', message: string }} DropEvent
19
+ */
20
+
21
+ /**
22
+ * @param {DropState} state
23
+ * @param {DropEvent} event
24
+ * @returns {DropState}
25
+ */
26
+ export function dropReducer(state, event) {
27
+ switch (event.type) {
28
+ case 'enter':
29
+ return state.phase === 'busy' ? state : { ...state, phase: 'over' }
30
+ case 'leave':
31
+ return state.phase === 'busy' ? state : { ...state, phase: 'idle' }
32
+ case 'start':
33
+ return { phase: 'busy', progress: 0, error: null }
34
+ case 'progress':
35
+ return state.phase === 'busy' ? { ...state, progress: Math.min(Math.max(event.fraction, 0), 1) } : state
36
+ case 'done':
37
+ return { phase: 'idle', progress: 1, error: null }
38
+ case 'fail':
39
+ return { phase: 'idle', progress: 0, error: event.message }
40
+ }
41
+ }
42
+
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.
46
+ * @param {string} filename
47
+ * @returns {string | null} the problem, or null when the name is fine
48
+ */
49
+ export function validateCanvasFilename(filename) {
50
+ if (!filename.toLowerCase().endsWith('.zip')) {
51
+ return 'that is not a zip file'
52
+ }
53
+ return filename.toLowerCase().startsWith('pr-review-canvas-') ? null : 'that zip is not a review canvas export'
54
+ }
55
+
56
+ /**
57
+ * @param {PrBundle} bundle
58
+ * @returns {string}
59
+ */
60
+ export function sharedCanvasCalloutHtml(bundle) {
61
+ const shared = bundle.sharedCanvas
62
+ if (!shared) {
63
+ return ''
64
+ }
65
+ const name = `<span class="path">${esc(shared.name)}</span>`
66
+ if (shared.downloadable) {
67
+ return `<div class="callout" role="status">A canvas is attached to this PR (${name}) &mdash; importing&hellip;</div>`
68
+ }
69
+ 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. ` +
72
+ '<button class="cmd" type="button" id="fetch-shared">fetch again</button></div>'
73
+ )
74
+ }
75
+
76
+ /**
77
+ * @param {PrBundle} bundle
78
+ * @returns {string}
79
+ */
80
+ function skillCommandHtml(bundle) {
81
+ return (
82
+ '<p class="hint">Run this in Claude Code or Codex from this repo. The page updates when the canvas is published.</p>' +
83
+ `<div class="cmdbox"><code>${esc(bundle.skillCommand)}</code><button class="cmd fill" type="button" data-copy="${esc(bundle.skillCommand)}">copy</button></div>`
84
+ )
85
+ }
86
+
87
+ /** @returns {string} */
88
+ function dropZoneHtml() {
89
+ return (
90
+ '<p class="hint">or import a canvas a teammate attached to the PR</p>' +
91
+ `<label class="drop" for="${DROP_ZONE_ID}">Drop a canvas zip here or <span class="link">choose file</span>` +
92
+ `<input class="sr" id="${DROP_ZONE_ID}" type="file" accept=".zip"></label>` +
93
+ '<p class="drop-status" role="status" aria-live="polite"></p>'
94
+ )
95
+ }
96
+
97
+ /**
98
+ * The `missing` screen: the skill command with its copy command, the drop zone, the callout.
99
+ * @param {PrBundle} bundle
100
+ * @returns {string}
101
+ */
102
+ export function renderEmptyState(bundle) {
103
+ return (
104
+ '<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>' +
106
+ '<div class="body center">' +
107
+ skillCommandHtml(bundle) +
108
+ dropZoneHtml() +
109
+ sharedCanvasCalloutHtml(bundle) +
110
+ '</div></section>'
111
+ )
112
+ }
113
+
114
+ /**
115
+ * How far the canvas is from the head, in words.
116
+ * @param {NonNullable<PrBundle['stale']>} stale
117
+ * @returns {string}
118
+ */
119
+ export function staleSummary(stale) {
120
+ const canvas = stale.canvasHeadSha.slice(0, 7)
121
+ const head = stale.currentHeadSha.slice(0, 7)
122
+ if (stale.relation === 'unrelated') {
123
+ return `The canvas is for ${canvas}, which is not in this branch any more; the head is ${head}.`
124
+ }
125
+ const behind = stale.commitsBehind ?? 0
126
+ return `The canvas is for ${canvas}, ${behind} commit${behind === 1 ? '' : 's'} behind the head ${head}.`
127
+ }
128
+
129
+ /**
130
+ * The bar that stays on screen while a stale canvas is shown, so the reader is never misled about
131
+ * which commit the diffs come from.
132
+ * @param {NonNullable<PrBundle['stale']>} stale
133
+ * @returns {string}
134
+ */
135
+ export function staleBarHtml(stale) {
136
+ return (
137
+ `<div class="stale-bar" role="status"><strong>Canvas is outdated.</strong> You are reading an older commit. ${esc(staleSummary(stale))} ` +
138
+ '<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>'
139
+ )
140
+ }
141
+
142
+ /**
143
+ * The `stale` screen: the same card as the empty state, plus the two ways forward.
144
+ * @param {PrBundle} bundle
145
+ * @returns {string}
146
+ */
147
+ export function renderStaleState(bundle) {
148
+ const stale = bundle.stale
149
+ return (
150
+ '<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
151
+ '<div class="panel-h"><h2 id="es-h">Canvas is outdated</h2></div>' +
152
+ '<div class="body center">' +
153
+ `<p class="hint">${stale ? esc(staleSummary(stale)) : ''}</p>` +
154
+ '<div class="cmdbox"><button class="cmd fill" type="button" id="view-stale">view stale canvas</button>' +
155
+ '<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>' +
156
+ skillCommandHtml(bundle) +
157
+ dropZoneHtml() +
158
+ sharedCanvasCalloutHtml(bundle) +
159
+ '</div></section>'
160
+ )
161
+ }
@@ -0,0 +1,135 @@
1
+ // @ts-check
2
+ // Error cards for the review page, keyed by the server's error code. Each card says what happened,
3
+ // what to do, and offers a retry. Every code in the server's list has one; errors.test.js checks
4
+ // that, so a new code cannot ship without its card.
5
+ /** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
6
+ import { esc } from './dom.js'
7
+
8
+ /** @typedef {{ title: string, action: string }} ErrorCard */
9
+
10
+ /** @type {ErrorCard} */
11
+ const FALLBACK = {
12
+ title: 'Something went wrong',
13
+ action: 'Retry. If it keeps failing, check the server log in the terminal running pr-review.',
14
+ }
15
+
16
+ /** @type {Record<string, ErrorCard>} */
17
+ export const ERROR_CARDS = {
18
+ BAD_REQUEST: {
19
+ title: 'The page asked for something the server could not read',
20
+ action: 'Check the URL. If you got here from a link inside the page, retry and report it.',
21
+ },
22
+ NOT_FOUND: {
23
+ title: 'Not found',
24
+ action: 'Retry. A missing diff usually means the PR head is not in this clone yet.',
25
+ },
26
+ FORBIDDEN_HOST: {
27
+ title: 'This server only answers to localhost',
28
+ action: 'Open the page as http://localhost:3010 or http://127.0.0.1:3010.',
29
+ },
30
+ CROSS_ORIGIN: {
31
+ title: 'The request did not come from this page',
32
+ action: 'Reload the page and try again. Do not drive this server from another site.',
33
+ },
34
+ NOT_A_REPO: {
35
+ title: 'Not a git repository',
36
+ action: 'Start pr-review from a clone, or pass --repo <dir>.',
37
+ },
38
+ NO_ORIGIN: {
39
+ title: 'No GitHub origin',
40
+ action: 'Add an origin remote that points at github.com, then restart the server.',
41
+ },
42
+ GIT_ERROR: {
43
+ title: 'A git command failed',
44
+ action: 'Check the message, then retry. Fetching the PR head by hand often clears it.',
45
+ },
46
+ GH_MISSING: {
47
+ title: 'GitHub CLI is not installed',
48
+ action: 'Install gh from https://cli.github.com and log in, then retry.',
49
+ },
50
+ GH_UNAUTHENTICATED: {
51
+ title: 'GitHub CLI is not logged in',
52
+ action: 'Run gh auth login in a terminal, then retry.',
53
+ },
54
+ GITHUB_API_ERROR: {
55
+ title: 'GitHub refused the request',
56
+ action: 'Retry in a moment. A rate limit or an outage both read like this.',
57
+ },
58
+ PR_NOT_FOUND: {
59
+ title: 'Pull request not found',
60
+ action: 'Check the number, and that this clone’s origin is the right repository.',
61
+ },
62
+ CANVAS_NOT_FOUND: {
63
+ title: 'No canvas for this pull request',
64
+ action: 'Generate one with the pr-review-canvas skill, or import a zip a teammate attached.',
65
+ },
66
+ CANVAS_INVALID: {
67
+ title: 'That canvas could not be read',
68
+ action: 'Generate the canvas again for the current head, or import another zip.',
69
+ },
70
+ CANVAS_REPO_MISMATCH: {
71
+ title: 'That canvas belongs to another repository',
72
+ action: 'Import a canvas exported from this repository.',
73
+ },
74
+ CANVAS_TOO_LARGE: {
75
+ title: 'That canvas is too large',
76
+ action: 'A canvas zip holds two JSON files and stays under 20 MB. Export it again.',
77
+ },
78
+ CANVAS_STALE: {
79
+ title: 'The pull request moved on',
80
+ action: 'Reload the page: the head commit changed since this page was drawn.',
81
+ },
82
+ MODEL_INVALID: {
83
+ title: 'The generated canvas did not pass validation',
84
+ action: 'Run the pr-review-canvas skill again; it prints one line per problem.',
85
+ },
86
+ SKILL_DIR_EXISTS: {
87
+ title: 'The skill directory is already taken',
88
+ action: 'Remove it, or run pr-review install-skill --force.',
89
+ },
90
+ COMMENT_FORBIDDEN: {
91
+ title: 'This GitHub login cannot post here',
92
+ action: 'Run gh auth refresh -h github.com -s repo, then retry.',
93
+ },
94
+ 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.',
97
+ },
98
+ SIGNOFF_INCOMPLETE: {
99
+ title: 'Some layers are not reviewed yet',
100
+ action: 'Mark every layer outside Other as reviewed, then approve.',
101
+ },
102
+ CHAT_BUSY: {
103
+ title: 'The chat is answering another message',
104
+ action: 'Wait for the answer, or stop it, then send again.',
105
+ },
106
+ NOT_IMPLEMENTED: {
107
+ title: 'That part is not built yet',
108
+ action: 'Nothing to do here; the feature is not in this version.',
109
+ },
110
+ INTERNAL: FALLBACK,
111
+ }
112
+
113
+ /**
114
+ * @param {ErrorEnvelope['error']} error
115
+ * @returns {ErrorCard}
116
+ */
117
+ export function errorCardFor(error) {
118
+ return ERROR_CARDS[error.code] ?? FALLBACK
119
+ }
120
+
121
+ /**
122
+ * @param {ErrorEnvelope['error']} error
123
+ * @returns {string}
124
+ */
125
+ export function errorCardHtml(error) {
126
+ const card = errorCardFor(error)
127
+ return (
128
+ '<main id="main" class="home">' +
129
+ `<section class="panel error-card" data-code="${esc(error.code)}" aria-labelledby="err-h">` +
130
+ `<div class="panel-h"><h2 id="err-h">${esc(card.title)} <span class="mono muted">${esc(error.code)}</span></h2></div>` +
131
+ `<div class="body"><p>${esc(error.message)}</p>${error.hint ? `<p class="muted">${esc(error.hint)}</p>` : ''}` +
132
+ `<p>${esc(card.action)}</p>` +
133
+ '<p><button class="cmd fill" type="button" id="retry">retry</button></p></div></section></main>'
134
+ )
135
+ }
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ // Splits a markdown text around fenced blocks with one info string. Two callers: mermaid
3
+ // diagrams and the chat's proposed-comment cards.
4
+
5
+ /** @typedef {{ type: 'markdown' | 'block', text: string }} FenceSegment */
6
+
7
+ /** A fence line: up to three spaces, the marker, then the info string. */
8
+ const FENCE_RE = /^([ \t]{0,3})(`{3,}|~{3,})[ \t]*(.*)$/
9
+
10
+ /**
11
+ * The fence a line opens or closes, or null when the line is ordinary text. A backtick fence
12
+ * carries no backtick in its info string, which is what tells a fence from inline code.
13
+ * @param {string} line
14
+ * @returns {{ marker: string, info: string } | null}
15
+ */
16
+ function fenceOf(line) {
17
+ const match = FENCE_RE.exec(line)
18
+ if (match === null) {
19
+ return null
20
+ }
21
+ const marker = match[2] ?? ''
22
+ const info = (match[3] ?? '').trim()
23
+ if (marker.startsWith('`') && info.includes('`')) {
24
+ return null
25
+ }
26
+ return { marker, info }
27
+ }
28
+
29
+ /**
30
+ * The pieces of a markdown text in order: prose and the blocks whose info string is `info`. A
31
+ * fence inside another fence is plain code, and an unclosed block is prose again, opening line
32
+ * and all, because the reader never sees it as a block either.
33
+ * @param {string} markdown
34
+ * @param {string} info the info string to pick out, compared case-insensitively
35
+ * @returns {FenceSegment[]}
36
+ */
37
+ export function splitFences(markdown, info) {
38
+ const wanted = info.toLowerCase()
39
+ /** @type {FenceSegment[]} */
40
+ const out = []
41
+ /** @type {string[]} */
42
+ let prose = []
43
+ /** @type {string[]} */
44
+ let block = []
45
+ /** @type {{ marker: string, wanted: boolean, line: string } | null} */
46
+ let open = null
47
+ const flushProse = () => {
48
+ if (prose.length > 0) {
49
+ out.push({ type: 'markdown', text: prose.join('\n') })
50
+ prose = []
51
+ }
52
+ }
53
+ // CRLF text is read as LF, so a fence is found the same way whatever wrote the markdown.
54
+ for (const line of markdown.replace(/\r\n/g, '\n').split('\n')) {
55
+ const fence = fenceOf(line)
56
+ if (open === null) {
57
+ if (fence !== null) {
58
+ open = { marker: fence.marker, wanted: fence.info.toLowerCase() === wanted, line }
59
+ if (open.wanted) {
60
+ block = []
61
+ continue
62
+ }
63
+ }
64
+ prose.push(line)
65
+ continue
66
+ }
67
+ const closer = fence !== null && fence.info === '' ? fence.marker : ''
68
+ const closes = closer.length >= open.marker.length && closer[0] === open.marker[0]
69
+ if (closes) {
70
+ if (open.wanted) {
71
+ flushProse()
72
+ out.push({ type: 'block', text: block.join('\n') })
73
+ } else {
74
+ prose.push(line)
75
+ }
76
+ open = null
77
+ continue
78
+ }
79
+ if (open.wanted) {
80
+ block.push(line)
81
+ } else {
82
+ prose.push(line)
83
+ }
84
+ }
85
+ if (open?.wanted === true) {
86
+ prose.push(open.line, ...block)
87
+ }
88
+ flushProse()
89
+ return out
90
+ }