@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,247 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('./contract-types.js').Layer} Layer */
|
|
3
|
+
/** @typedef {import('./contract-types.js').Point} Point */
|
|
4
|
+
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
5
|
+
import { askButtonHtml } from './ask.js'
|
|
6
|
+
import { viewCommentHtml } from './comment-link.js'
|
|
7
|
+
import { esc } from './dom.js'
|
|
8
|
+
import { layerAnchorId, pointAnchorId } from './keys.js'
|
|
9
|
+
import { renderMarkdown } from './markdown.js'
|
|
10
|
+
|
|
11
|
+
export const LEVELS = /** @type {const} */ (['decide', 'check', 'fyi'])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {ReadonlyArray<Point>} points
|
|
15
|
+
* @returns {Record<'decide' | 'check' | 'fyi', Point[]>}
|
|
16
|
+
*/
|
|
17
|
+
export function pointsByLevel(points) {
|
|
18
|
+
/** @type {Record<'decide' | 'check' | 'fyi', Point[]>} */
|
|
19
|
+
const out = { decide: [], check: [], fyi: [] }
|
|
20
|
+
for (const p of points) {
|
|
21
|
+
out[p.level].push(p)
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `#line:` link for a point's anchor.
|
|
28
|
+
* @param {Point} p
|
|
29
|
+
*/
|
|
30
|
+
export function pointLink(p) {
|
|
31
|
+
const range = p.endLine !== undefined && p.endLine !== p.line ? `${p.line}-${p.endLine}` : `${p.line}`
|
|
32
|
+
return `#line:${p.path}:${range}${p.side === 'old' ? ':old' : ''}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {Point} p */
|
|
36
|
+
export function pointLocation(p) {
|
|
37
|
+
const range = p.endLine !== undefined && p.endLine !== p.line ? `${p.line}-${p.endLine}` : `${p.line}`
|
|
38
|
+
return `${p.path}:${range}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {Point} p
|
|
43
|
+
*/
|
|
44
|
+
export function squareHtml(p) {
|
|
45
|
+
return `<span class="sq ${p.level}" role="img" aria-label="${p.level}" title="${p.level}"></span>`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The point as a GitHub comment: the title, the body, and where it is anchored.
|
|
50
|
+
* @param {Point} p
|
|
51
|
+
*/
|
|
52
|
+
export function pointToMarkdown(p) {
|
|
53
|
+
return `**${p.title}**\n\n${p.body}\n\n_${pointLocation(p)} · ${p.kind} · ${p.level} · from the pr-review canvas_`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the chat is asked about when a point's `[ ask ]` is used: the point itself. The server
|
|
58
|
+
* resolves the fingerprint against the canvas and sends the agent the point's text along with
|
|
59
|
+
* the lines it sits on, so the agent sees what the reader is reacting to.
|
|
60
|
+
* @param {Point} p
|
|
61
|
+
* @returns {import('./chat-context.js').ChatContext}
|
|
62
|
+
*/
|
|
63
|
+
export function pointContext(p) {
|
|
64
|
+
return { kind: 'point', fingerprint: p.fingerprint }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
|
|
69
|
+
* opens nothing and posts it at the anchor, `dismiss` takes the point off the page.
|
|
70
|
+
* @param {Point} p
|
|
71
|
+
* @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function pointCommandsHtml(p, opts = {}) {
|
|
75
|
+
const fp = esc(p.fingerprint)
|
|
76
|
+
const toggle = opts.dismissed
|
|
77
|
+
? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
|
|
78
|
+
: `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
|
|
79
|
+
return (
|
|
80
|
+
'<span class="tbtns">' +
|
|
81
|
+
`<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
|
|
82
|
+
(opts.postedUrl === undefined
|
|
83
|
+
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>post to github</button>`
|
|
84
|
+
: viewCommentHtml(opts.postedUrl)) +
|
|
85
|
+
askButtonHtml(pointContext(p)) +
|
|
86
|
+
toggle +
|
|
87
|
+
'</span>'
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The comment this point was posted as, when it was.
|
|
93
|
+
* @param {Point} p
|
|
94
|
+
* @param {{ posted?: ReadonlyMap<string, string> }} ctx
|
|
95
|
+
*/
|
|
96
|
+
export function postedFor(p, ctx) {
|
|
97
|
+
return ctx.posted?.get(p.fingerprint)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where each point's posted comment lives, read from the state and the comments the page holds.
|
|
102
|
+
* @param {import('./contract-types.js').PrState} state
|
|
103
|
+
* @param {ReadonlyArray<{ id: number, url: string }>} comments
|
|
104
|
+
* @returns {Map<string, string>} point fingerprint → comment url
|
|
105
|
+
*/
|
|
106
|
+
export function postedUrls(state, comments) {
|
|
107
|
+
const urls = new Map(comments.map(c => [c.id, c.url]))
|
|
108
|
+
/** @type {Map<string, string>} */
|
|
109
|
+
const out = new Map()
|
|
110
|
+
for (const entry of state.posted) {
|
|
111
|
+
const url = urls.get(entry.commentId)
|
|
112
|
+
if (entry.pointFingerprint !== undefined && url !== undefined) {
|
|
113
|
+
out.set(entry.pointFingerprint, url)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One attention point card, shown in the layer that owns the point.
|
|
121
|
+
* @param {Point} p
|
|
122
|
+
* @param {{ paths: ReadonlySet<string>, state?: PrState, posted?: ReadonlyMap<string, string> }} ctx
|
|
123
|
+
*/
|
|
124
|
+
export function pointCardHtml(p, ctx) {
|
|
125
|
+
const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
|
|
126
|
+
const posted = postedFor(p, ctx)
|
|
127
|
+
return (
|
|
128
|
+
`<li class="finding" id="${esc(pointAnchorId(p.id))}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}>${squareHtml(p)}<div>` +
|
|
129
|
+
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
130
|
+
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
131
|
+
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
132
|
+
`${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The muted line under the overview that holds the points the reader set aside.
|
|
138
|
+
* @param {ReadonlyArray<Point>} points
|
|
139
|
+
* @param {PrState} state
|
|
140
|
+
* @param {{ paths: ReadonlySet<string>, posted?: ReadonlyMap<string, string> }} ctx
|
|
141
|
+
* @param {boolean} [expanded]
|
|
142
|
+
*/
|
|
143
|
+
export function dismissedListHtml(points, state, ctx, expanded = false) {
|
|
144
|
+
const dismissed = points.filter(p => state.dismissed[p.fingerprint] !== undefined)
|
|
145
|
+
if (dismissed.length === 0) {
|
|
146
|
+
return '<div class="dismissed-list" hidden></div>'
|
|
147
|
+
}
|
|
148
|
+
const rows = dismissed
|
|
149
|
+
.map(p => {
|
|
150
|
+
const posted = postedFor(p, ctx)
|
|
151
|
+
return (
|
|
152
|
+
`<li class="finding" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}">${squareHtml(p)}<div>` +
|
|
153
|
+
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
154
|
+
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
155
|
+
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
156
|
+
`${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
.join('')
|
|
160
|
+
return (
|
|
161
|
+
'<div class="dismissed-list">' +
|
|
162
|
+
`<p class="muted dismissed-line">${dismissed.length} dismissed <button class="cmd" type="button" data-act="show-dismissed" aria-expanded="${expanded}">${expanded ? 'hide' : 'show'}</button></p>` +
|
|
163
|
+
`<ol class="findings dismissed"${expanded ? '' : ' hidden'}>${rows}</ol></div>`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Shows the points that are active and hides the ones the reader dismissed, wherever they are
|
|
169
|
+
* on the page, and rebuilds the dismissed list. Calling it again with the same state changes
|
|
170
|
+
* nothing.
|
|
171
|
+
* @param {ParentNode} root
|
|
172
|
+
* @param {ReadonlyArray<Point>} points
|
|
173
|
+
* @param {PrState} state
|
|
174
|
+
* @param {{ paths: ReadonlySet<string>, layers?: ReadonlyArray<Layer>, posted?: ReadonlyMap<string, string> }} ctx
|
|
175
|
+
*/
|
|
176
|
+
export function applyDismissed(root, points, state, ctx) {
|
|
177
|
+
const byId = new Map(points.map(p => [p.id, p]))
|
|
178
|
+
for (const el of Array.from(root.querySelectorAll('[data-point]'))) {
|
|
179
|
+
const point = byId.get(el.getAttribute('data-point') ?? '')
|
|
180
|
+
if (point === undefined || el.closest('.dismissed-list') !== null) {
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
const dismissed = state.dismissed[point.fingerprint] !== undefined
|
|
184
|
+
el.toggleAttribute('hidden', dismissed)
|
|
185
|
+
}
|
|
186
|
+
for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
|
|
187
|
+
const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
|
|
188
|
+
const own = points.filter(p => p.layerId === layerId && state.dismissed[p.fingerprint] === undefined)
|
|
189
|
+
counter.textContent = String(own.length)
|
|
190
|
+
}
|
|
191
|
+
const host = root.querySelector('.dismissed-list')
|
|
192
|
+
if (host !== null) {
|
|
193
|
+
const expanded = host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
|
|
194
|
+
const template = document.createElement('template')
|
|
195
|
+
template.innerHTML = dismissedListHtml(points, state, ctx, expanded)
|
|
196
|
+
const next = template.content.firstElementChild
|
|
197
|
+
if (next !== null) {
|
|
198
|
+
host.replaceWith(next)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const sevsum = root.querySelector('.sevsum')
|
|
202
|
+
if (sevsum !== null) {
|
|
203
|
+
const active = points.filter(p => state.dismissed[p.fingerprint] === undefined)
|
|
204
|
+
const template = document.createElement('template')
|
|
205
|
+
template.innerHTML = sevsumHtml(active, ctx.layers ?? [])
|
|
206
|
+
const next = template.content.firstElementChild
|
|
207
|
+
if (next !== null) {
|
|
208
|
+
sevsum.replaceWith(next)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The inline row under a diff line.
|
|
215
|
+
* @param {Point} p
|
|
216
|
+
* @param {{ paths: ReadonlySet<string>, state?: PrState, posted?: ReadonlyMap<string, string> }} ctx
|
|
217
|
+
*/
|
|
218
|
+
export function pointRowHtml(p, ctx) {
|
|
219
|
+
const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
|
|
220
|
+
const posted = postedFor(p, ctx)
|
|
221
|
+
return (
|
|
222
|
+
`<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="ln" colspan="3"></td><td class="code x">` +
|
|
223
|
+
`<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
|
|
224
|
+
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
225
|
+
`${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The three squares with counts in the overview header. Each count links to the first layer that
|
|
231
|
+
* has a point of that level, so the overview only counts and the layers hold the cards.
|
|
232
|
+
* @param {ReadonlyArray<Point>} points
|
|
233
|
+
* @param {ReadonlyArray<Layer>} [layers] artifact order; omitted when there is nothing to link to
|
|
234
|
+
*/
|
|
235
|
+
export function sevsumHtml(points, layers = []) {
|
|
236
|
+
const by = pointsByLevel(points)
|
|
237
|
+
const cell = /** @param {'decide' | 'check' | 'fyi'} level */ level => {
|
|
238
|
+
const n = by[level].length
|
|
239
|
+
const square = `<span class="sq ${level}" role="img" aria-label="${n} ${level}" title="${n} ${level}"></span>${n}`
|
|
240
|
+
const target = layers.find(l => by[level].some(p => p.layerId === l.id))
|
|
241
|
+
return target ? `<a href="#${esc(layerAnchorId(target.key))}">${square}</a>` : `<span>${square}</span>`
|
|
242
|
+
}
|
|
243
|
+
return (
|
|
244
|
+
`<span class="sevsum" tabindex="0" role="group" aria-label="Attention points by level">${cell('decide')}${cell('check')}${cell('fyi')}` +
|
|
245
|
+
'<span class="legend" aria-hidden="true"><span><span class="sq decide"></span>decide</span><span><span class="sq check"></span>check</span><span><span class="sq fyi"></span>fyi</span></span></span>'
|
|
246
|
+
)
|
|
247
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Reviewed state per layer. Other changes never count.
|
|
3
|
+
/** @typedef {import('./contract-types.js').Layer} Layer */
|
|
4
|
+
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
5
|
+
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
6
|
+
import { sanitizeKey } from './keys.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Layer} layer
|
|
10
|
+
* @param {PrState} state
|
|
11
|
+
* @returns {'done' | 'partial' | 'none'}
|
|
12
|
+
*/
|
|
13
|
+
export function layerProgress(layer, state) {
|
|
14
|
+
if (state.reviewed[`layer:${layer.id}`] === true) {
|
|
15
|
+
return 'done'
|
|
16
|
+
}
|
|
17
|
+
const files = layer.files.length
|
|
18
|
+
let reviewedFiles = 0
|
|
19
|
+
for (const f of layer.files) {
|
|
20
|
+
if (state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true) {
|
|
21
|
+
reviewedFiles++
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (files > 0 && reviewedFiles === files) {
|
|
25
|
+
return 'done'
|
|
26
|
+
}
|
|
27
|
+
return reviewedFiles > 0 ? 'partial' : 'none'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {ReviewArtifact} artifact
|
|
32
|
+
* @param {PrState} state
|
|
33
|
+
* @returns {{ done: number, total: number, percent: number }}
|
|
34
|
+
*/
|
|
35
|
+
export function progressSummary(artifact, state) {
|
|
36
|
+
const layers = artifact.layers.filter(l => l.kind !== 'other')
|
|
37
|
+
const done = layers.filter(l => layerProgress(l, state) === 'done').length
|
|
38
|
+
const total = layers.length
|
|
39
|
+
return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Files reviewed in a layer, for the rail meta ("2 of 3 files").
|
|
44
|
+
* @param {Layer} layer
|
|
45
|
+
* @param {PrState} state
|
|
46
|
+
*/
|
|
47
|
+
export function filesReviewed(layer, state) {
|
|
48
|
+
return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true).length
|
|
49
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// A ```comment block in an assistant turn is a comment the agent thinks a human should post.
|
|
3
|
+
// It is model output, so every field is checked against the pull request's own files before the
|
|
4
|
+
// card offers to post it; a block that does not check out stays a code block.
|
|
5
|
+
import { splitFences } from './fences.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ path: string, line: number, side: 'new' | 'old', startLine?: number, body: string }} ProposedComment
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ type: 'markdown', text: string }
|
|
13
|
+
* | { type: 'comment', comment: ProposedComment }
|
|
14
|
+
* | { type: 'invalid', text: string, reason: string }} ChatSegment
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The comment body a card will show; longer than this is not a review comment. */
|
|
18
|
+
export const PROPOSED_BODY_MAX = 4000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a target has to satisfy to be postable: the file is in this pull request, and the line is
|
|
22
|
+
* one the diff shows on that side.
|
|
23
|
+
* @typedef {{ hasPath: (path: string) => boolean, hasLine: (path: string, side: 'new' | 'old', line: number) => boolean }} CommentTargets
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads one block. Returns the comment, or the reason the block is not one.
|
|
28
|
+
* @param {string} source the text inside the fence
|
|
29
|
+
* @param {CommentTargets} [targets]
|
|
30
|
+
* @returns {{ comment: ProposedComment } | { reason: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function parseProposedComment(source, targets) {
|
|
33
|
+
/** @type {unknown} */
|
|
34
|
+
let raw
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(source)
|
|
37
|
+
} catch {
|
|
38
|
+
return { reason: 'the block is not JSON' }
|
|
39
|
+
}
|
|
40
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
41
|
+
return { reason: 'the block is not a JSON object' }
|
|
42
|
+
}
|
|
43
|
+
const record = /** @type {Record<string, unknown>} */ (raw)
|
|
44
|
+
const field = /** @param {string} key */ key => record[key]
|
|
45
|
+
const path = field('path')
|
|
46
|
+
const line = field('line')
|
|
47
|
+
const body = field('body')
|
|
48
|
+
const sideRaw = field('side')
|
|
49
|
+
const startRaw = field('startLine')
|
|
50
|
+
if (typeof path !== 'string' || path === '') {
|
|
51
|
+
return { reason: 'the block has no path' }
|
|
52
|
+
}
|
|
53
|
+
if (typeof line !== 'number' || !Number.isInteger(line) || line <= 0) {
|
|
54
|
+
return { reason: 'the block has no line number' }
|
|
55
|
+
}
|
|
56
|
+
if (typeof body !== 'string' || body.trim() === '') {
|
|
57
|
+
return { reason: 'the block has no body' }
|
|
58
|
+
}
|
|
59
|
+
if (body.length > PROPOSED_BODY_MAX) {
|
|
60
|
+
return { reason: 'the body is too long to post' }
|
|
61
|
+
}
|
|
62
|
+
if (sideRaw !== undefined && sideRaw !== 'new' && sideRaw !== 'old') {
|
|
63
|
+
return { reason: 'side must be "new" or "old"' }
|
|
64
|
+
}
|
|
65
|
+
const side = sideRaw === 'old' ? 'old' : 'new'
|
|
66
|
+
if (startRaw !== undefined && (typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)) {
|
|
67
|
+
return { reason: 'startLine must be a line number' }
|
|
68
|
+
}
|
|
69
|
+
const startLine = typeof startRaw === 'number' ? startRaw : undefined
|
|
70
|
+
if (startLine !== undefined && startLine > line) {
|
|
71
|
+
return { reason: 'startLine comes after line' }
|
|
72
|
+
}
|
|
73
|
+
if (targets !== undefined) {
|
|
74
|
+
if (!targets.hasPath(path)) {
|
|
75
|
+
return { reason: `${path} is not a file of this pull request` }
|
|
76
|
+
}
|
|
77
|
+
// Both ends of a range have to be on screen, or the comment lands where nobody looked.
|
|
78
|
+
for (const at of startLine === undefined ? [line] : [startLine, line]) {
|
|
79
|
+
if (!targets.hasLine(path, side, at)) {
|
|
80
|
+
return { reason: `${path}:${at} is not a line the diff shows` }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** @type {ProposedComment} */
|
|
85
|
+
const comment = { path, line, side, body }
|
|
86
|
+
if (startLine !== undefined) {
|
|
87
|
+
comment.startLine = startLine
|
|
88
|
+
}
|
|
89
|
+
return { comment }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One assistant turn split into what to render: prose, comment cards, and blocks that claimed to
|
|
94
|
+
* be comments but are not.
|
|
95
|
+
* @param {string} markdown
|
|
96
|
+
* @param {CommentTargets} [targets]
|
|
97
|
+
* @returns {ChatSegment[]}
|
|
98
|
+
*/
|
|
99
|
+
export function splitChatAnswer(markdown, targets) {
|
|
100
|
+
return splitFences(markdown, 'comment').map(segment => {
|
|
101
|
+
if (segment.type === 'markdown') {
|
|
102
|
+
return /** @type {ChatSegment} */ ({ type: 'markdown', text: segment.text })
|
|
103
|
+
}
|
|
104
|
+
const parsed = parseProposedComment(segment.text, targets)
|
|
105
|
+
return 'comment' in parsed
|
|
106
|
+
? /** @type {ChatSegment} */ ({ type: 'comment', comment: parsed.comment })
|
|
107
|
+
: /** @type {ChatSegment} */ ({ type: 'invalid', text: segment.text, reason: parsed.reason })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The lines a diff shows, as the card's check needs them: every hunk's line numbers per side.
|
|
113
|
+
* @param {ReadonlyArray<import('./contract-types.js').FileEntry>} files
|
|
114
|
+
* @returns {CommentTargets}
|
|
115
|
+
*/
|
|
116
|
+
export function targetsFromFiles(files) {
|
|
117
|
+
const byPath = new Map(files.map(f => [f.path, f]))
|
|
118
|
+
return {
|
|
119
|
+
hasPath: path => byPath.has(path),
|
|
120
|
+
hasLine: (path, side, line) => {
|
|
121
|
+
const entry = byPath.get(path)
|
|
122
|
+
if (entry === undefined) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
return entry.hunks.some(h => {
|
|
126
|
+
const start = side === 'new' ? h.newStart : h.oldStart
|
|
127
|
+
// A hunk that adds lines shows none on the old side, and the other way round.
|
|
128
|
+
const count = side === 'new' ? h.newLines : h.oldLines
|
|
129
|
+
return count > 0 && line >= start && line < start + count
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The small menu that opens on an `[ ask ]` command: four questions that are worth asking about
|
|
3
|
+
// almost anything, plus a way to write your own. Picking one sets the context to that target and
|
|
4
|
+
// sends, so the common questions cost one click.
|
|
5
|
+
|
|
6
|
+
import { QUICK_QUESTIONS } from './chat.js'
|
|
7
|
+
import { chatContextFromElement } from './chat-context.js'
|
|
8
|
+
import { esc, qs } from './dom.js'
|
|
9
|
+
|
|
10
|
+
/** A menu that opened on the way to a click would be in the way, so it waits. */
|
|
11
|
+
export const HOVER_DELAY_MS = 150
|
|
12
|
+
/** The pointer crosses a gap between the command and the menu, so closing waits out the crossing. */
|
|
13
|
+
export const CLOSE_DELAY_MS = 200
|
|
14
|
+
export const QQ_MENU_ID = 'qq-menu'
|
|
15
|
+
export const ASK_SOMETHING_ELSE = 'ask something else…'
|
|
16
|
+
|
|
17
|
+
/** @returns {string} */
|
|
18
|
+
export function quickMenuHtml() {
|
|
19
|
+
const items = [...QUICK_QUESTIONS, ASK_SOMETHING_ELSE]
|
|
20
|
+
.map(
|
|
21
|
+
(q, i) => `<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
|
|
22
|
+
)
|
|
23
|
+
.join('')
|
|
24
|
+
return `<div class="qq-menu" id="${QQ_MENU_ID}" role="menu" aria-label="Quick questions" hidden>${items}</div>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* onPick: (context: import('./chat-context.js').ChatContext, question: string | null) => void,
|
|
30
|
+
* delayMs?: number,
|
|
31
|
+
* doc?: Document,
|
|
32
|
+
* }} QuickQuestionOptions
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wires one menu for every `[ ask ]` command under `root` and for the context chip. The menu is
|
|
37
|
+
* a single element that moves to whichever trigger is in play.
|
|
38
|
+
* @param {HTMLElement} root
|
|
39
|
+
* @param {QuickQuestionOptions} options
|
|
40
|
+
*/
|
|
41
|
+
export function wireQuickQuestions(root, options) {
|
|
42
|
+
const doc = options.doc ?? document
|
|
43
|
+
const delayMs = options.delayMs ?? HOVER_DELAY_MS
|
|
44
|
+
if (qs(`#${QQ_MENU_ID}`, root) === null) {
|
|
45
|
+
root.insertAdjacentHTML('beforeend', quickMenuHtml())
|
|
46
|
+
}
|
|
47
|
+
const menu = qs(`#${QQ_MENU_ID}`, root)
|
|
48
|
+
if (!(menu instanceof HTMLElement)) {
|
|
49
|
+
return { stop: () => undefined }
|
|
50
|
+
}
|
|
51
|
+
/** @type {HTMLElement | null} */
|
|
52
|
+
let trigger = null
|
|
53
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
54
|
+
let timer = null
|
|
55
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
56
|
+
let closeTimer = null
|
|
57
|
+
/**
|
|
58
|
+
* The command Escape closed the menu on. Focus goes back to it, and that focus must not open
|
|
59
|
+
* the menu again.
|
|
60
|
+
*/
|
|
61
|
+
/** @type {HTMLElement | null} */
|
|
62
|
+
let dismissed = null
|
|
63
|
+
|
|
64
|
+
const items = () => Array.from(menu.querySelectorAll('.qq-item')).filter(el => el instanceof HTMLElement)
|
|
65
|
+
|
|
66
|
+
const close = () => {
|
|
67
|
+
menu.hidden = true
|
|
68
|
+
trigger?.setAttribute('aria-expanded', 'false')
|
|
69
|
+
trigger = null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {HTMLElement} el */
|
|
73
|
+
const open = el => {
|
|
74
|
+
dismissed = null
|
|
75
|
+
trigger = el
|
|
76
|
+
el.setAttribute('aria-expanded', 'true')
|
|
77
|
+
menu.hidden = false
|
|
78
|
+
const rect = el.getBoundingClientRect()
|
|
79
|
+
menu.style.left = `${Math.round(rect.left)}px`
|
|
80
|
+
menu.style.top = `${Math.round(rect.bottom + 4)}px`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const clearTimer = () => {
|
|
84
|
+
if (timer !== null) {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
timer = null
|
|
87
|
+
}
|
|
88
|
+
if (closeTimer !== null) {
|
|
89
|
+
clearTimeout(closeTimer)
|
|
90
|
+
closeTimer = null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {EventTarget | null} target */
|
|
95
|
+
const triggerOf = target => {
|
|
96
|
+
const el = target instanceof Element ? target.closest('[data-ask], [data-ask-menu]') : null
|
|
97
|
+
return el instanceof HTMLElement ? el : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {Event} event */
|
|
101
|
+
const onTriggerOver = event => {
|
|
102
|
+
const el = triggerOf(event.target)
|
|
103
|
+
if (el === null) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
// Being back on a command calls off the countdown its own pointer-out started.
|
|
107
|
+
cancelClose()
|
|
108
|
+
if (el === trigger) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
clearTimer()
|
|
112
|
+
timer = setTimeout(() => open(el), delayMs)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Leaving starts a short countdown rather than closing at once: the pointer has to cross the
|
|
117
|
+
* few pixels between the command and the menu to reach it.
|
|
118
|
+
* @param {Event} event
|
|
119
|
+
*/
|
|
120
|
+
const onOut = event => {
|
|
121
|
+
const to = /** @type {MouseEvent} */ (event).relatedTarget
|
|
122
|
+
if (to instanceof Node && (menu.contains(to) || trigger?.contains(to) === true)) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
clearTimer()
|
|
126
|
+
if (trigger !== null) {
|
|
127
|
+
closeTimer = setTimeout(close, CLOSE_DELAY_MS)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cancelClose = () => {
|
|
132
|
+
if (closeTimer !== null) {
|
|
133
|
+
clearTimeout(closeTimer)
|
|
134
|
+
closeTimer = null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Reaching the menu calls the countdown off. */
|
|
139
|
+
const onOver = cancelClose
|
|
140
|
+
|
|
141
|
+
/** @param {Event} event */
|
|
142
|
+
const onFocusIn = event => {
|
|
143
|
+
const el = triggerOf(event.target)
|
|
144
|
+
if (el !== null && el === dismissed) {
|
|
145
|
+
dismissed = null
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (el !== null && el !== trigger) {
|
|
149
|
+
clearTimer()
|
|
150
|
+
open(el)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {Event} event */
|
|
155
|
+
const onClick = event => {
|
|
156
|
+
const item = event.target instanceof Element ? event.target.closest('.qq-item') : null
|
|
157
|
+
if (!(item instanceof HTMLElement) || trigger === null) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
const index = Number(item.getAttribute('data-qq'))
|
|
162
|
+
const question = QUICK_QUESTIONS[index] ?? null
|
|
163
|
+
const context = chatContextFromElement(trigger)
|
|
164
|
+
close()
|
|
165
|
+
options.onPick(context, question)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {KeyboardEvent} event */
|
|
169
|
+
const onKeyDown = event => {
|
|
170
|
+
if (menu.hidden) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const list = items()
|
|
174
|
+
const at = list.indexOf(/** @type {HTMLElement} */ (doc.activeElement))
|
|
175
|
+
if (event.key === 'Escape') {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
const back = trigger
|
|
178
|
+
close()
|
|
179
|
+
dismissed = back
|
|
180
|
+
back?.focus()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
184
|
+
event.preventDefault()
|
|
185
|
+
const step = event.key === 'ArrowDown' ? 1 : -1
|
|
186
|
+
const next = list[(at + step + list.length) % list.length] ?? list[0]
|
|
187
|
+
next?.focus()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
root.addEventListener('pointerover', onTriggerOver)
|
|
192
|
+
root.addEventListener('pointerout', onOut)
|
|
193
|
+
menu.addEventListener('pointerover', onOver)
|
|
194
|
+
root.addEventListener('focusin', onFocusIn)
|
|
195
|
+
menu.addEventListener('click', onClick)
|
|
196
|
+
doc.addEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
/** Opens the menu on a target without waiting, which is what a tap and the `a` key do. */
|
|
200
|
+
openFor(/** @type {HTMLElement} */ el) {
|
|
201
|
+
clearTimer()
|
|
202
|
+
open(el)
|
|
203
|
+
items()[0]?.focus()
|
|
204
|
+
},
|
|
205
|
+
isOpen: () => !menu.hidden,
|
|
206
|
+
stop() {
|
|
207
|
+
clearTimer()
|
|
208
|
+
root.removeEventListener('pointerover', onTriggerOver)
|
|
209
|
+
root.removeEventListener('pointerout', onOut)
|
|
210
|
+
menu.removeEventListener('pointerover', onOver)
|
|
211
|
+
root.removeEventListener('focusin', onFocusIn)
|
|
212
|
+
menu.removeEventListener('click', onClick)
|
|
213
|
+
doc.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|