@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
package/static/js/dom.js
ADDED
|
@@ -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, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, ''')
|
|
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)}">></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}) — importing…</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
|
+
}
|