@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Rows a reviewer can hide: the canvas folds the model named, and the folds the diff renderer
|
|
3
|
+
// marked (import-only and whitespace-only changes, and moved blocks). Both open again when a
|
|
4
|
+
// deep link lands inside them.
|
|
5
|
+
import { findRow } from './anchors.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('./contract-types.js').CodeFold} CodeFold */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Element | null} summary
|
|
11
|
+
* @param {readonly HTMLTableRowElement[]} rows
|
|
12
|
+
*/
|
|
13
|
+
function coveredFoldSummary(summary, rows) {
|
|
14
|
+
if (!summary?.matches('.fold')) {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let next = summary.nextElementSibling
|
|
19
|
+
while (next instanceof HTMLTableRowElement && next.matches('.folded')) {
|
|
20
|
+
if (!rows.includes(next)) {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
next = next.nextElementSibling
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Finds the rows between both anchors. A decorated or overlapping range stays open.
|
|
31
|
+
* @param {HTMLElement} card
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @param {CodeFold} fold
|
|
34
|
+
* @returns {HTMLTableRowElement[]}
|
|
35
|
+
*/
|
|
36
|
+
function foldRows(card, key, fold) {
|
|
37
|
+
const first = findRow(card, key, fold.side, fold.startLine)
|
|
38
|
+
const last = findRow(card, key, fold.side, fold.endLine)
|
|
39
|
+
|
|
40
|
+
if (first === null || last === null || first.parentElement === null) {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (last.parentElement !== first.parentElement) {
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const siblings = Array.from(first.parentElement.children)
|
|
49
|
+
const from = siblings.indexOf(first)
|
|
50
|
+
const to = siblings.indexOf(last)
|
|
51
|
+
|
|
52
|
+
if (to < from) {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
|
|
57
|
+
const hasDiscussion = rows.some(row => row.matches('.ann, [data-decoration], [data-code-fold], .code-fold'))
|
|
58
|
+
|
|
59
|
+
const hasThread = Boolean(first.closest('table')?.querySelector('[data-decoration="thread"]'))
|
|
60
|
+
|
|
61
|
+
if (hasDiscussion || hasThread || last.nextElementSibling?.matches('[data-decoration]')) {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const preceding = first.previousElementSibling
|
|
66
|
+
if (preceding instanceof HTMLTableRowElement && coveredFoldSummary(preceding, rows)) {
|
|
67
|
+
rows.unshift(preceding)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return rows
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Inserts the fold's title above its first row.
|
|
75
|
+
* @param {HTMLTableRowElement} first
|
|
76
|
+
* @param {string} title
|
|
77
|
+
*/
|
|
78
|
+
function createToggle(first, title) {
|
|
79
|
+
const header = document.createElement('tr')
|
|
80
|
+
header.className = 'more code-fold'
|
|
81
|
+
|
|
82
|
+
const cell = document.createElement('td')
|
|
83
|
+
cell.colSpan = 4
|
|
84
|
+
|
|
85
|
+
const toggle = document.createElement('button')
|
|
86
|
+
toggle.className = 'cmd'
|
|
87
|
+
toggle.type = 'button'
|
|
88
|
+
toggle.textContent = title
|
|
89
|
+
|
|
90
|
+
cell.appendChild(toggle)
|
|
91
|
+
header.appendChild(cell)
|
|
92
|
+
first.before(header)
|
|
93
|
+
|
|
94
|
+
return toggle
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Keeps the original rows in the DOM so comments and deep links retain their anchors.
|
|
99
|
+
* @param {HTMLTableRowElement} first
|
|
100
|
+
* @param {HTMLTableRowElement[]} rows
|
|
101
|
+
* @param {string} title
|
|
102
|
+
*/
|
|
103
|
+
function wireFold(first, rows, title) {
|
|
104
|
+
const toggle = createToggle(first, title)
|
|
105
|
+
const table = first.closest('table')
|
|
106
|
+
const foldSummaries = new Set(rows.filter(row => coveredFoldSummary(row, rows)))
|
|
107
|
+
toggle.setAttribute(
|
|
108
|
+
'aria-controls',
|
|
109
|
+
rows
|
|
110
|
+
.map(row => row.id)
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join(' ')
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const setExpanded = (/** @type {boolean} */ expanded) => {
|
|
116
|
+
toggle.setAttribute('aria-expanded', String(expanded))
|
|
117
|
+
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
row.hidden = !expanded || foldSummaries.has(row)
|
|
120
|
+
row.setAttribute('data-code-fold', '')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true'))
|
|
125
|
+
|
|
126
|
+
table?.addEventListener('reveal-code', event => {
|
|
127
|
+
const target = event.target
|
|
128
|
+
if (target instanceof Node && (target === table || rows.some(row => row.contains(target)))) {
|
|
129
|
+
setExpanded(true)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
setExpanded(false)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Adds title-only folds; ranges with annotations, attention points, or discussions stay open.
|
|
138
|
+
* @param {HTMLElement} card
|
|
139
|
+
* @param {string} key
|
|
140
|
+
* @param {readonly CodeFold[]} folds
|
|
141
|
+
*/
|
|
142
|
+
export function applyCodeFolds(card, key, folds) {
|
|
143
|
+
for (const fold of folds) {
|
|
144
|
+
const rows = foldRows(card, key, fold)
|
|
145
|
+
const first = rows[0]
|
|
146
|
+
|
|
147
|
+
if (first !== undefined) {
|
|
148
|
+
wireFold(first, rows, fold.title)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The `.fold` summary row that owns a folded row, or null when the row is not in a fold.
|
|
155
|
+
* @param {Element} row
|
|
156
|
+
* @returns {HTMLTableRowElement | null}
|
|
157
|
+
*/
|
|
158
|
+
function foldSummaryFor(row) {
|
|
159
|
+
if (!row.matches('tr.folded')) {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
let previous = row.previousElementSibling
|
|
163
|
+
while (previous instanceof HTMLTableRowElement && previous.matches('.folded')) {
|
|
164
|
+
previous = previous.previousElementSibling
|
|
165
|
+
}
|
|
166
|
+
return previous instanceof HTMLTableRowElement && previous.matches('.more.fold') ? previous : null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shows or hides the rows a summary row stands for, and relabels its button.
|
|
171
|
+
* @param {HTMLTableRowElement} summary
|
|
172
|
+
* @param {boolean} shown
|
|
173
|
+
*/
|
|
174
|
+
export function setFoldShown(summary, shown) {
|
|
175
|
+
let next = summary.nextElementSibling
|
|
176
|
+
while (next instanceof HTMLTableRowElement && next.matches('.folded')) {
|
|
177
|
+
next.classList.toggle('shown', shown)
|
|
178
|
+
next = next.nextElementSibling
|
|
179
|
+
}
|
|
180
|
+
const toggle = summary.querySelector('button[data-act="show-fold"]')
|
|
181
|
+
if (toggle instanceof HTMLElement) {
|
|
182
|
+
toggle.setAttribute('aria-expanded', String(shown))
|
|
183
|
+
toggle.textContent = shown ? 'hide' : 'show'
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Opens the folded rows around a deep-link target, so a line inside a fold can be reached.
|
|
189
|
+
* @param {HTMLElement} card
|
|
190
|
+
*/
|
|
191
|
+
export function wireFoldReveal(card) {
|
|
192
|
+
card.addEventListener('reveal-code', event => {
|
|
193
|
+
const target = event.target
|
|
194
|
+
const row = target instanceof Element ? target.closest('tr') : null
|
|
195
|
+
const summary = row === null ? null : foldSummaryFor(row)
|
|
196
|
+
if (summary !== null) {
|
|
197
|
+
setFoldShown(summary, true)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Every command that triggers a request goes through runCommand, so the click always shows a
|
|
3
|
+
// pending state and a failure always shows its message next to the command.
|
|
4
|
+
import { copyToClipboard } from './dom.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @template T
|
|
8
|
+
* @param {HTMLElement} el the .cmd button
|
|
9
|
+
* @param {() => Promise<T>} fn
|
|
10
|
+
* @param {{ pendingLabel?: string }} [opts] label shown while `fn` runs; default is the label plus `…`
|
|
11
|
+
* @returns {Promise<T | undefined>} the result, or undefined after a failure (already shown inline)
|
|
12
|
+
*/
|
|
13
|
+
export async function runCommand(el, fn, opts = {}) {
|
|
14
|
+
const original = el.textContent ?? ''
|
|
15
|
+
const pending = opts.pendingLabel ?? `${original}…`
|
|
16
|
+
clearCommandError(el)
|
|
17
|
+
if (el instanceof HTMLButtonElement) {
|
|
18
|
+
el.disabled = true
|
|
19
|
+
}
|
|
20
|
+
el.setAttribute('aria-busy', 'true')
|
|
21
|
+
el.textContent = pending
|
|
22
|
+
try {
|
|
23
|
+
return await fn()
|
|
24
|
+
} catch (err) {
|
|
25
|
+
showCommandError(el, err instanceof Error ? err.message : String(err))
|
|
26
|
+
return undefined
|
|
27
|
+
} finally {
|
|
28
|
+
el.textContent = original
|
|
29
|
+
el.removeAttribute('aria-busy')
|
|
30
|
+
if (el instanceof HTMLButtonElement) {
|
|
31
|
+
el.disabled = false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The same pending and error handling for a control whose label must not change: a checkbox
|
|
38
|
+
* keeps its box, so only `aria-busy` and the disabled state move.
|
|
39
|
+
* @template T
|
|
40
|
+
* @param {HTMLElement} el the element the error is shown next to
|
|
41
|
+
* @param {() => Promise<T>} fn
|
|
42
|
+
* @returns {Promise<T | undefined>}
|
|
43
|
+
*/
|
|
44
|
+
export async function runControl(el, fn) {
|
|
45
|
+
clearCommandError(el)
|
|
46
|
+
const inputs = el instanceof HTMLInputElement ? [el] : Array.from(el.querySelectorAll('input'))
|
|
47
|
+
/** @param {boolean} disabled */
|
|
48
|
+
const setDisabled = disabled => {
|
|
49
|
+
for (const input of inputs) {
|
|
50
|
+
input.disabled = disabled
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
el.setAttribute('aria-busy', 'true')
|
|
54
|
+
setDisabled(true)
|
|
55
|
+
try {
|
|
56
|
+
return await fn()
|
|
57
|
+
} catch (err) {
|
|
58
|
+
showCommandError(el, err instanceof Error ? err.message : String(err))
|
|
59
|
+
return undefined
|
|
60
|
+
} finally {
|
|
61
|
+
el.removeAttribute('aria-busy')
|
|
62
|
+
setDisabled(false)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {HTMLElement} el
|
|
68
|
+
* @param {string} message
|
|
69
|
+
*/
|
|
70
|
+
export function showCommandError(el, message) {
|
|
71
|
+
clearCommandError(el)
|
|
72
|
+
const err = document.createElement('span')
|
|
73
|
+
err.className = 'cmd-err'
|
|
74
|
+
err.setAttribute('role', 'alert')
|
|
75
|
+
err.textContent = message
|
|
76
|
+
el.insertAdjacentElement('afterend', err)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {HTMLElement} el */
|
|
80
|
+
export function clearCommandError(el) {
|
|
81
|
+
const next = el.nextElementSibling
|
|
82
|
+
if (next?.classList.contains('cmd-err')) {
|
|
83
|
+
next.remove()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const COPY_WIRED = new WeakSet()
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One delegated click handler per root for every `button[data-copy]` under it, present now or
|
|
91
|
+
* rendered later. Calling it again for the same root does nothing, so re-rendering the root's
|
|
92
|
+
* content never stacks handlers (a second handler would run runCommand twice on one click).
|
|
93
|
+
* @param {HTMLElement} root
|
|
94
|
+
* @param {(text: string) => Promise<void>} [copy]
|
|
95
|
+
* @returns {boolean} true when the handler was added by this call
|
|
96
|
+
*/
|
|
97
|
+
export function wireCopyCommands(root, copy = copyToClipboard) {
|
|
98
|
+
if (COPY_WIRED.has(root)) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
COPY_WIRED.add(root)
|
|
102
|
+
root.addEventListener('click', event => {
|
|
103
|
+
const el = event.target instanceof Element ? event.target.closest('button[data-copy]') : null
|
|
104
|
+
if (el instanceof HTMLElement) {
|
|
105
|
+
const text = el.getAttribute('data-copy') ?? ''
|
|
106
|
+
void runCommand(el, () => copy(text), { pendingLabel: 'copying…' })
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { esc } from './dom.js'
|
|
3
|
+
|
|
4
|
+
/** @param {string} url @param {boolean} [filled] */
|
|
5
|
+
export function viewCommentHtml(url, filled = false) {
|
|
6
|
+
return `<a class="cmd${filled ? ' fill' : ''}" href="${esc(url)}" target="_blank" rel="noopener noreferrer">view comment</a>`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @param {HTMLElement} button @param {string} url */
|
|
10
|
+
export function replacePostButton(button, url) {
|
|
11
|
+
const template = button.ownerDocument.createElement('template')
|
|
12
|
+
template.innerHTML = viewCommentHtml(url, button.classList.contains('fill'))
|
|
13
|
+
const link = template.content.firstElementChild
|
|
14
|
+
if (link instanceof HTMLAnchorElement) {
|
|
15
|
+
const focused = button.ownerDocument.activeElement === button
|
|
16
|
+
button.replaceWith(link)
|
|
17
|
+
if (focused) {
|
|
18
|
+
link.focus()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('./proposed-comment.js').ProposedComment} proposed
|
|
25
|
+
* @param {ReadonlyArray<import('./contract-types.js').ReviewComment>} posted
|
|
26
|
+
*/
|
|
27
|
+
export function postedCommentUrl(proposed, posted) {
|
|
28
|
+
return posted.find(
|
|
29
|
+
c =>
|
|
30
|
+
c.inReplyToId === undefined &&
|
|
31
|
+
c.path === proposed.path &&
|
|
32
|
+
c.line === proposed.line &&
|
|
33
|
+
c.side === proposed.side &&
|
|
34
|
+
(c.startLine ?? c.line) === (proposed.startLine ?? proposed.line) &&
|
|
35
|
+
c.body === proposed.body
|
|
36
|
+
)?.url
|
|
37
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The inline comment box. One markup for all three places it appears: on a diff line, under a
|
|
3
|
+
// thread as a reply, and in the overview for a PR-level comment.
|
|
4
|
+
/** @typedef {import('./contract-types.js').Side} Side */
|
|
5
|
+
/** @typedef {import('./contract-types.js').PostCommentInput} PostCommentInput */
|
|
6
|
+
import { cssEscape } from './anchors.js'
|
|
7
|
+
import { renderMarkdown } from './markdown.js'
|
|
8
|
+
import { esc } from './dom.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* id: string,
|
|
13
|
+
* label: string,
|
|
14
|
+
* kind: 'inline' | 'reply' | 'issue',
|
|
15
|
+
* path?: string,
|
|
16
|
+
* line?: number,
|
|
17
|
+
* side?: Side,
|
|
18
|
+
* startLine?: number,
|
|
19
|
+
* inReplyToId?: number,
|
|
20
|
+
* pointFingerprint?: string,
|
|
21
|
+
* body?: string,
|
|
22
|
+
* }} ComposerOptions
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @param {ComposerOptions} opts */
|
|
26
|
+
function dataAttributes(opts) {
|
|
27
|
+
const pairs = [
|
|
28
|
+
['data-kind', opts.kind],
|
|
29
|
+
['data-path', opts.path],
|
|
30
|
+
['data-line', opts.line],
|
|
31
|
+
['data-side', opts.side],
|
|
32
|
+
['data-start-line', opts.startLine],
|
|
33
|
+
['data-in-reply-to', opts.inReplyToId],
|
|
34
|
+
['data-fingerprint', opts.pointFingerprint],
|
|
35
|
+
]
|
|
36
|
+
return pairs
|
|
37
|
+
.filter(([, value]) => value !== undefined)
|
|
38
|
+
.map(([name, value]) => ` ${String(name)}="${esc(value)}"`)
|
|
39
|
+
.join('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The box itself. The textarea carries the draft; the two commands act on the nearest
|
|
44
|
+
* `.composer-box` ancestor.
|
|
45
|
+
* @param {ComposerOptions} opts
|
|
46
|
+
*/
|
|
47
|
+
export function composerHtml(opts) {
|
|
48
|
+
return (
|
|
49
|
+
`<div class="composer-box" id="${esc(opts.id)}"${dataAttributes(opts)}>` +
|
|
50
|
+
`<label class="sr" for="${esc(opts.id)}-t">${esc(opts.label)}</label>` +
|
|
51
|
+
previewControlsHtml() +
|
|
52
|
+
`<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
|
+
'<button class="cmd" type="button" data-act="composer-cancel">cancel</button></div></div>'
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The same box as a diff row, so it can sit under the line it comments on.
|
|
60
|
+
* @param {ComposerOptions} opts
|
|
61
|
+
*/
|
|
62
|
+
export function composerRowHtml(opts) {
|
|
63
|
+
return (
|
|
64
|
+
`<tr class="composer" data-decoration="composer"><td class="ln" colspan="3"></td>` +
|
|
65
|
+
`<td class="code x">${composerHtml(opts)}</td></tr>`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The pending item shown while GitHub answers, replaced by the comment it returns. */
|
|
70
|
+
export function pendingCommentHtml() {
|
|
71
|
+
return '<div class="cmt pending" data-pending="1"><span class="who muted">posting…</span></div>'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The body a composer holds, trimmed. Empty means there is nothing to post.
|
|
76
|
+
* @param {Element} box
|
|
77
|
+
*/
|
|
78
|
+
export function composerBody(box) {
|
|
79
|
+
const textarea = box.querySelector('textarea')
|
|
80
|
+
return textarea instanceof HTMLTextAreaElement ? textarea.value.trim() : ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The request that posts what this composer holds.
|
|
85
|
+
* @param {Element} box
|
|
86
|
+
* @returns {PostCommentInput | null} null when the box is empty or misses its target
|
|
87
|
+
*/
|
|
88
|
+
export function composerInput(box) {
|
|
89
|
+
const body = composerBody(box)
|
|
90
|
+
if (body === '') {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
const kind = box.getAttribute('data-kind')
|
|
94
|
+
if (kind === 'issue') {
|
|
95
|
+
return { kind: 'issue', body }
|
|
96
|
+
}
|
|
97
|
+
if (kind === 'reply') {
|
|
98
|
+
const inReplyToId = Number(box.getAttribute('data-in-reply-to'))
|
|
99
|
+
return Number.isInteger(inReplyToId) && inReplyToId > 0 ? { kind: 'reply', inReplyToId, body } : null
|
|
100
|
+
}
|
|
101
|
+
const path = box.getAttribute('data-path')
|
|
102
|
+
const line = Number(box.getAttribute('data-line'))
|
|
103
|
+
const side = box.getAttribute('data-side') === 'old' ? 'old' : 'new'
|
|
104
|
+
if (path === null || !Number.isInteger(line) || line <= 0) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
/** @type {PostCommentInput} */
|
|
108
|
+
const input = { kind: 'inline', path, line, side, body }
|
|
109
|
+
const startLine = Number(box.getAttribute('data-start-line'))
|
|
110
|
+
if (Number.isInteger(startLine) && startLine > 0 && startLine !== line) {
|
|
111
|
+
input.startLine = startLine
|
|
112
|
+
}
|
|
113
|
+
const fingerprint = box.getAttribute('data-fingerprint')
|
|
114
|
+
if (fingerprint !== null && fingerprint !== '') {
|
|
115
|
+
input.pointFingerprint = fingerprint
|
|
116
|
+
}
|
|
117
|
+
return input
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Puts the focus in the box that was just opened.
|
|
122
|
+
* @param {ParentNode} root
|
|
123
|
+
* @param {string} id
|
|
124
|
+
*/
|
|
125
|
+
export function focusComposer(root, id) {
|
|
126
|
+
const textarea = root.querySelector(`#${cssEscape(id)}-t`)
|
|
127
|
+
if (textarea instanceof HTMLTextAreaElement) {
|
|
128
|
+
textarea.focus()
|
|
129
|
+
}
|
|
130
|
+
return textarea
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Removes every open composer under `root`, so only one is open at a time.
|
|
135
|
+
* @param {ParentNode} root
|
|
136
|
+
*/
|
|
137
|
+
export function closeComposers(root) {
|
|
138
|
+
let closed = 0
|
|
139
|
+
for (const box of Array.from(root.querySelectorAll('.composer-box'))) {
|
|
140
|
+
const row = box.closest('tr.composer')
|
|
141
|
+
;(row ?? box).remove()
|
|
142
|
+
closed++
|
|
143
|
+
}
|
|
144
|
+
return closed
|
|
145
|
+
}
|
|
146
|
+
|
|
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
|
+
/**
|
|
151
|
+
* 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.
|
|
153
|
+
*
|
|
154
|
+
* A command can be disabled for a reason of its own (the approve command before every layer is
|
|
155
|
+
* read, a command whose request is still running). Those keep their state: this only adds and
|
|
156
|
+
* removes the reason it owns, which the element carries in `data-post-blocked`.
|
|
157
|
+
* @param {ParentNode} root
|
|
158
|
+
* @param {import('./contract-types.js').Capabilities} capabilities
|
|
159
|
+
*/
|
|
160
|
+
export function applyCapabilityGating(root, capabilities) {
|
|
161
|
+
const blocked = capabilities.canComment === false
|
|
162
|
+
const reason = capabilities.reason ?? NO_POSTING_TITLE
|
|
163
|
+
for (const el of Array.from(root.querySelectorAll('[data-needs-post]'))) {
|
|
164
|
+
if (!(el instanceof HTMLButtonElement) || el.getAttribute('aria-busy') === 'true') {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
const own = el.getAttribute('data-disabled-reason')
|
|
168
|
+
if (blocked) {
|
|
169
|
+
el.toggleAttribute('data-post-blocked', true)
|
|
170
|
+
el.disabled = true
|
|
171
|
+
el.title = reason
|
|
172
|
+
} else {
|
|
173
|
+
el.removeAttribute('data-post-blocked')
|
|
174
|
+
el.disabled = own !== null
|
|
175
|
+
if (own === null) {
|
|
176
|
+
const tooltip = el.getAttribute('data-tooltip')
|
|
177
|
+
if (tooltip) {
|
|
178
|
+
el.title = tooltip
|
|
179
|
+
} else {
|
|
180
|
+
el.removeAttribute('title')
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
el.title = own
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const note = root.querySelector('.capability-note')
|
|
188
|
+
if (note !== null) {
|
|
189
|
+
// A disabled command cannot take focus, so the reason is also said in plain sight.
|
|
190
|
+
note.textContent = blocked ? `Posting to GitHub is off: ${reason}` : ''
|
|
191
|
+
}
|
|
192
|
+
return blocked
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Disables one command for a reason of its own, or lifts that reason. The capability state is
|
|
197
|
+
* untouched, so a command that both rules disable stays disabled until both allow it.
|
|
198
|
+
* @param {Element | null} el
|
|
199
|
+
* @param {string | null} reason null enables the command again
|
|
200
|
+
*/
|
|
201
|
+
export function setDisabledReason(el, reason) {
|
|
202
|
+
if (!(el instanceof HTMLButtonElement)) {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (reason === null) {
|
|
206
|
+
el.removeAttribute('data-disabled-reason')
|
|
207
|
+
if (!el.hasAttribute('data-post-blocked')) {
|
|
208
|
+
el.disabled = false
|
|
209
|
+
const tooltip = el.getAttribute('data-tooltip')
|
|
210
|
+
if (tooltip) {
|
|
211
|
+
el.title = tooltip
|
|
212
|
+
} else {
|
|
213
|
+
el.removeAttribute('title')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
el.setAttribute('data-disabled-reason', reason)
|
|
219
|
+
el.disabled = true
|
|
220
|
+
if (!el.hasAttribute('data-post-blocked')) {
|
|
221
|
+
el.title = reason
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
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>'
|
|
227
|
+
}
|
|
228
|
+
|
|
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')
|
|
234
|
+
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>'
|
|
236
|
+
textarea.hidden = preview
|
|
237
|
+
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))
|
|
240
|
+
if (!preview) textarea.focus()
|
|
241
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Type-only bridge so the browser modules (JSDoc) see the same contract as the server.
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
CanvasRelation,
|
|
5
|
+
Capabilities,
|
|
6
|
+
ChatStatus,
|
|
7
|
+
ErrorEnvelope,
|
|
8
|
+
ImportResult,
|
|
9
|
+
PatchesResponse,
|
|
10
|
+
PostCommentResponse,
|
|
11
|
+
PostReviewResponse,
|
|
12
|
+
PrBundle,
|
|
13
|
+
ReviewBodyResponse,
|
|
14
|
+
ReviewSummary,
|
|
15
|
+
SharedCanvasFetchResponse,
|
|
16
|
+
SharedCanvasInfo,
|
|
17
|
+
StaleInfo,
|
|
18
|
+
StateResponse,
|
|
19
|
+
} from '../../src/contract/api.js'
|
|
20
|
+
export type {
|
|
21
|
+
ChatEvent,
|
|
22
|
+
ChatHistoryResponse,
|
|
23
|
+
ChatThreadsResponse,
|
|
24
|
+
ChatTurn,
|
|
25
|
+
} from '../../src/contract/chat.js'
|
|
26
|
+
export type {
|
|
27
|
+
CommentsPayload,
|
|
28
|
+
IssueComment,
|
|
29
|
+
PostCommentInput,
|
|
30
|
+
ReviewComment,
|
|
31
|
+
} from '../../src/contract/comments.js'
|
|
32
|
+
export type {
|
|
33
|
+
Annotation,
|
|
34
|
+
CodeFold,
|
|
35
|
+
Diagram,
|
|
36
|
+
FileEntry,
|
|
37
|
+
Hunk,
|
|
38
|
+
Layer,
|
|
39
|
+
LayerFile,
|
|
40
|
+
Point,
|
|
41
|
+
Pr,
|
|
42
|
+
ReviewArtifact,
|
|
43
|
+
RiskTag,
|
|
44
|
+
Side,
|
|
45
|
+
TestEntry,
|
|
46
|
+
} from '../../src/contract/review-artifact.js'
|
|
47
|
+
export type {
|
|
48
|
+
AgentAvailability,
|
|
49
|
+
AgentProbeResult,
|
|
50
|
+
AgentsResponse,
|
|
51
|
+
AppearanceInput,
|
|
52
|
+
AppearanceResponse,
|
|
53
|
+
ChatAgent,
|
|
54
|
+
Settings,
|
|
55
|
+
SettingsInput,
|
|
56
|
+
SettingsResponse,
|
|
57
|
+
Theme,
|
|
58
|
+
} from '../../src/contract/settings.js'
|
|
59
|
+
export type { PrState } from '../../src/contract/state.js'
|