@vintasoftware/pr-review-canvas 0.4.0 → 0.5.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/README.md +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
package/static/js/points.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { askButtonHtml } from './ask.js'
|
|
6
6
|
import { viewCommentHtml } from './comment-link.js'
|
|
7
7
|
import { esc } from './dom.js'
|
|
8
|
+
import { pendingForPoint } from './pending.js'
|
|
8
9
|
import { postToLabel } from './host.js'
|
|
9
10
|
import { layerAnchorId, pointAnchorId } from './keys.js'
|
|
10
11
|
import { renderMarkdown } from './markdown.js'
|
|
@@ -65,11 +66,33 @@ export function pointContext(p) {
|
|
|
65
66
|
return { kind: 'point', fingerprint: p.fingerprint }
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What a point offers for getting its text onto the forge: the link to the comment it was posted
|
|
71
|
+
* as, the note that it is waiting in the review, or the two ways to send it. Unlike the box on a
|
|
72
|
+
* diff line, a point keeps both ways while a review is open: its text is written in advance, so
|
|
73
|
+
* firing one off on its own is a use of its own, not a comment jumping the queue.
|
|
74
|
+
* @param {Point} p
|
|
75
|
+
* @param {{ postedUrl?: string | undefined, queued?: boolean }} opts
|
|
76
|
+
*/
|
|
77
|
+
function pointSendHtml(p, opts) {
|
|
78
|
+
if (opts.postedUrl !== undefined) {
|
|
79
|
+
return viewCommentHtml(opts.postedUrl)
|
|
80
|
+
}
|
|
81
|
+
if (opts.queued === true) {
|
|
82
|
+
return '<span class="pill pending queued">in your review</span>'
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
`<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>` +
|
|
86
|
+
`<button class="cmd" type="button" data-act="point-queue" data-point="${esc(p.id)}">add to review</button>`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
/**
|
|
69
91
|
* The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
|
|
70
|
-
* opens nothing and posts it at the anchor, `
|
|
92
|
+
* opens nothing and posts it at the anchor, `add to review` holds it as a draft instead, and
|
|
93
|
+
* `dismiss` takes the point off the page.
|
|
71
94
|
* @param {Point} p
|
|
72
|
-
* @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
|
|
95
|
+
* @param {{ dismissed?: boolean, postedUrl?: string | undefined, queued?: boolean }} [opts]
|
|
73
96
|
* @returns {string}
|
|
74
97
|
*/
|
|
75
98
|
export function pointCommandsHtml(p, opts = {}) {
|
|
@@ -78,17 +101,24 @@ export function pointCommandsHtml(p, opts = {}) {
|
|
|
78
101
|
? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
|
|
79
102
|
: `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
|
|
80
103
|
return (
|
|
81
|
-
|
|
104
|
+
`<span class="tbtns" data-queued="${opts.queued === true ? '1' : '0'}">` +
|
|
82
105
|
`<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
|
|
83
|
-
(opts
|
|
84
|
-
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>`
|
|
85
|
-
: viewCommentHtml(opts.postedUrl)) +
|
|
106
|
+
pointSendHtml(p, opts) +
|
|
86
107
|
askButtonHtml(pointContext(p)) +
|
|
87
108
|
toggle +
|
|
88
109
|
'</span>'
|
|
89
110
|
)
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Whether this point is waiting in the review, read from the state the renderer was given.
|
|
115
|
+
* @param {Point} p
|
|
116
|
+
* @param {{ state?: PrState }} ctx
|
|
117
|
+
*/
|
|
118
|
+
export function queuedFor(p, ctx) {
|
|
119
|
+
return pendingForPoint(ctx.state, p.fingerprint) !== undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
92
122
|
/**
|
|
93
123
|
* The comment this point was posted as, when it was.
|
|
94
124
|
* @param {Point} p
|
|
@@ -130,7 +160,7 @@ export function pointCardHtml(p, ctx) {
|
|
|
130
160
|
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
131
161
|
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
132
162
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
133
|
-
`${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
|
|
163
|
+
`${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</div></li>`
|
|
134
164
|
)
|
|
135
165
|
}
|
|
136
166
|
|
|
@@ -154,7 +184,7 @@ export function dismissedListHtml(points, state, ctx, expanded = false) {
|
|
|
154
184
|
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
155
185
|
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
156
186
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
157
|
-
`${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
|
|
187
|
+
`${pointCommandsHtml(p, { dismissed: true, postedUrl: posted, queued: queuedFor(p, { state }) })}</div></li>`
|
|
158
188
|
)
|
|
159
189
|
})
|
|
160
190
|
.join('')
|
|
@@ -183,6 +213,7 @@ export function applyDismissed(root, points, state, ctx) {
|
|
|
183
213
|
}
|
|
184
214
|
const dismissed = state.dismissed[point.fingerprint] !== undefined
|
|
185
215
|
el.toggleAttribute('hidden', dismissed)
|
|
216
|
+
refreshPointCommands(el, point, { dismissed, state, ...ctx })
|
|
186
217
|
}
|
|
187
218
|
for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
|
|
188
219
|
const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
|
|
@@ -212,6 +243,35 @@ export function applyDismissed(root, points, state, ctx) {
|
|
|
212
243
|
}
|
|
213
244
|
}
|
|
214
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Draws a point's commands again when it has just joined the review or just left it, wherever the
|
|
248
|
+
* point is on the page. Nothing else is touched: a command that is mid-request keeps its state,
|
|
249
|
+
* because neither posting nor dismissing changes whether the point is waiting in the review.
|
|
250
|
+
* @param {Element} el the element that carries `data-point`
|
|
251
|
+
* @param {Point} p
|
|
252
|
+
* @param {{ dismissed: boolean, state: PrState, posted?: ReadonlyMap<string, string> }} ctx
|
|
253
|
+
* @returns {boolean} true when the commands were drawn again
|
|
254
|
+
*/
|
|
255
|
+
export function refreshPointCommands(el, p, ctx) {
|
|
256
|
+
const tbtns = el.querySelector('.tbtns')
|
|
257
|
+
const queued = queuedFor(p, ctx)
|
|
258
|
+
if (tbtns === null || (tbtns.getAttribute('data-queued') === '1') === queued) {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
const template = document.createElement('template')
|
|
262
|
+
template.innerHTML = pointCommandsHtml(p, {
|
|
263
|
+
dismissed: ctx.dismissed,
|
|
264
|
+
queued,
|
|
265
|
+
...(postedFor(p, ctx) === undefined ? {} : { postedUrl: postedFor(p, ctx) }),
|
|
266
|
+
})
|
|
267
|
+
const next = template.content.firstElementChild
|
|
268
|
+
if (next === null) {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
tbtns.replaceWith(next)
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
215
275
|
/**
|
|
216
276
|
* The inline row under a diff line.
|
|
217
277
|
* @param {Point} p
|
|
@@ -224,7 +284,7 @@ export function pointRowHtml(p, ctx) {
|
|
|
224
284
|
`<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
|
|
225
285
|
`<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
|
|
226
286
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
227
|
-
`${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
|
|
287
|
+
`${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</td></tr>`
|
|
228
288
|
)
|
|
229
289
|
}
|
|
230
290
|
|
package/static/js/progress.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** @typedef {import('./contract-types.js').Layer} Layer */
|
|
4
4
|
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
5
5
|
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
6
|
-
import {
|
|
6
|
+
import { reviewedId } from './keys.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {Layer} layer
|
|
@@ -11,13 +11,13 @@ import { sanitizeKey } from './keys.js'
|
|
|
11
11
|
* @returns {'done' | 'partial' | 'none'}
|
|
12
12
|
*/
|
|
13
13
|
export function layerProgress(layer, state) {
|
|
14
|
-
if (state.reviewed[
|
|
14
|
+
if (state.reviewed[reviewedId(layer.key)] === true) {
|
|
15
15
|
return 'done'
|
|
16
16
|
}
|
|
17
17
|
const files = layer.files.length
|
|
18
18
|
let reviewedFiles = 0
|
|
19
19
|
for (const f of layer.files) {
|
|
20
|
-
if (state.reviewed[
|
|
20
|
+
if (state.reviewed[reviewedId(layer.key, f.path)] === true) {
|
|
21
21
|
reviewedFiles++
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -45,6 +45,5 @@ export function progressSummary(artifact, state) {
|
|
|
45
45
|
* @param {PrState} state
|
|
46
46
|
*/
|
|
47
47
|
export function filesReviewed(layer, state) {
|
|
48
|
-
return layer.files.filter(f => state.reviewed[
|
|
49
|
-
.length
|
|
48
|
+
return layer.files.filter(f => state.reviewed[reviewedId(layer.key, f.path)] === true).length
|
|
50
49
|
}
|
|
@@ -12,6 +12,9 @@ export const HOVER_DELAY_MS = 150
|
|
|
12
12
|
/** The pointer crosses a gap between the command and the menu, so closing waits out the crossing. */
|
|
13
13
|
export const CLOSE_DELAY_MS = 200
|
|
14
14
|
export const QQ_MENU_ID = 'qq-menu'
|
|
15
|
+
/** The gap between a command and its menu, and the room the menu keeps from the window edge. */
|
|
16
|
+
export const MENU_GAP_PX = 4
|
|
17
|
+
export const MENU_EDGE_PX = 8
|
|
15
18
|
export const ASK_SOMETHING_ELSE = 'ask something else…'
|
|
16
19
|
|
|
17
20
|
/** @returns {string} */
|
|
@@ -77,8 +80,18 @@ export function wireQuickQuestions(root, options) {
|
|
|
77
80
|
el.setAttribute('aria-expanded', 'true')
|
|
78
81
|
menu.hidden = false
|
|
79
82
|
const rect = el.getBoundingClientRect()
|
|
80
|
-
menu
|
|
81
|
-
|
|
83
|
+
// The menu is positioned against the window, so a command near an edge would push it off
|
|
84
|
+
// screen: keep it inside, and hang it above a command with no room below.
|
|
85
|
+
const view = doc.defaultView ?? window
|
|
86
|
+
// `clientWidth` leaves out the scrollbar; a document that does not report it falls back.
|
|
87
|
+
const roomAcross = doc.documentElement.clientWidth || view.innerWidth
|
|
88
|
+
const roomDown = doc.documentElement.clientHeight || view.innerHeight
|
|
89
|
+
const below = rect.bottom + MENU_GAP_PX
|
|
90
|
+
const top =
|
|
91
|
+
below + menu.offsetHeight > roomDown - MENU_EDGE_PX ? rect.top - MENU_GAP_PX - menu.offsetHeight : below
|
|
92
|
+
const left = Math.min(rect.left, roomAcross - menu.offsetWidth - MENU_EDGE_PX)
|
|
93
|
+
menu.style.left = `${Math.round(Math.max(MENU_EDGE_PX, left))}px`
|
|
94
|
+
menu.style.top = `${Math.round(Math.max(MENU_EDGE_PX, top))}px`
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
const clearTimer = () => {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The reader's control over how much code the canvas hides: the copy that says what each level
|
|
3
|
+
// takes off the screen, the control under the header, and the labels that say how much of the
|
|
4
|
+
// diff that is. The level itself, and the counts, belong to layers.js, which owns the file cards
|
|
5
|
+
// and the rule for what a card keeps open.
|
|
6
|
+
import { esc } from './dom.js'
|
|
7
|
+
import { FOLD_LEVELS } from './fold-levels.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./contract-types.js').FoldLevel} FoldLevel */
|
|
10
|
+
/** @typedef {{ total: number, hidden: number }} HiddenCounts */
|
|
11
|
+
|
|
12
|
+
export const FOLD_LEVEL_SELECT_ID = 'fold-level'
|
|
13
|
+
|
|
14
|
+
/** What each level adds to the one below it, said in the reader's terms. */
|
|
15
|
+
const FOLD_LEVEL_WHAT = {
|
|
16
|
+
light: 'the diff as always: imports, whitespace, moved blocks, and generated files',
|
|
17
|
+
moderate: 'also test bodies, helpers, wiring, templates, and boilerplate',
|
|
18
|
+
aggressive: 'only the code you have to judge stays open',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the counter says, or '' when this level hides nothing.
|
|
23
|
+
* @param {HiddenCounts} counts
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function hiddenLabel(counts) {
|
|
27
|
+
return counts.hidden === 0 ? '' : `${counts.hidden} of ${counts.total} lines hidden`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The same with the separator the Files heading needs, so an empty counter adds nothing.
|
|
32
|
+
* @param {HiddenCounts} counts
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function foldCountText(counts) {
|
|
36
|
+
const label = hiddenLabel(counts)
|
|
37
|
+
return label === '' ? '' : ` · ${label}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The line under the control: what this level hides, and how much of the diff that is. The reader
|
|
42
|
+
* changes one setting, so the page says what it did rather than leaving them to scroll and find out.
|
|
43
|
+
* @param {FoldLevel} level
|
|
44
|
+
* @param {HiddenCounts} counts what the level hides across the canvas
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function foldLevelHint(level, counts) {
|
|
48
|
+
const amount = counts.hidden === 0 ? 'nothing hidden yet' : `${hiddenLabel(counts)} of the diff`
|
|
49
|
+
return `${FOLD_LEVEL_WHAT[level]} · ${amount}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The levels as options, with one selected. The control on the page and the default field of the
|
|
54
|
+
* settings dialog are the same list.
|
|
55
|
+
* @param {FoldLevel} level
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function foldLevelOptionsHtml(level) {
|
|
59
|
+
return FOLD_LEVELS.map(
|
|
60
|
+
l => `<option value="${esc(l)}"${l === level ? ' selected' : ''}>${esc(l)}</option>`
|
|
61
|
+
).join('')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How much of the diff the page hides. One control for the whole canvas, next to the review
|
|
66
|
+
* progress rather than among the commands, because it changes what the reader sees and does not
|
|
67
|
+
* act on the pull request. The page opens at the level saved in the settings dialog; a change
|
|
68
|
+
* here holds for this page only.
|
|
69
|
+
* @param {FoldLevel} level
|
|
70
|
+
* @param {HiddenCounts} counts what the level hides across the canvas
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function foldLevelControlHtml(level, counts) {
|
|
74
|
+
const options = foldLevelOptionsHtml(level)
|
|
75
|
+
return (
|
|
76
|
+
`<div class="reading"><label for="${FOLD_LEVEL_SELECT_ID}">Hide code</label>` +
|
|
77
|
+
`<select id="${FOLD_LEVEL_SELECT_ID}" title="How much of the diff this page hides. Press f to step through the levels; the settings dialog sets the default.">${options}</select>` +
|
|
78
|
+
`<span class="fold-hint" role="status">${esc(foldLevelHint(level, counts))}</span></div>`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Points the control and its hint at a level the reader chose elsewhere, such as with the `f` key.
|
|
84
|
+
* @param {ParentNode} root
|
|
85
|
+
* @param {FoldLevel} level
|
|
86
|
+
* @param {HiddenCounts} counts what the level hides across the canvas
|
|
87
|
+
*/
|
|
88
|
+
export function refreshFoldLevel(root, level, counts) {
|
|
89
|
+
const select = root.querySelector(`#${FOLD_LEVEL_SELECT_ID}`)
|
|
90
|
+
if (select instanceof HTMLSelectElement) {
|
|
91
|
+
select.value = level
|
|
92
|
+
}
|
|
93
|
+
const hint = root.querySelector('.reading .fold-hint')
|
|
94
|
+
if (hint !== null) {
|
|
95
|
+
hint.textContent = foldLevelHint(level, counts)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -9,8 +9,18 @@
|
|
|
9
9
|
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
10
10
|
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
11
11
|
/** @typedef {import('./contract-types.js').ReviewSummary} ReviewSummary */
|
|
12
|
-
|
|
13
|
-
import {
|
|
12
|
+
/** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
|
|
13
|
+
import {
|
|
14
|
+
addPending,
|
|
15
|
+
deletePending,
|
|
16
|
+
discardPending,
|
|
17
|
+
editPending,
|
|
18
|
+
postComment,
|
|
19
|
+
postReview,
|
|
20
|
+
putDismissed,
|
|
21
|
+
putReviewed,
|
|
22
|
+
putThreadHidden,
|
|
23
|
+
} from './api.js'
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
26
|
* @typedef {{
|
|
@@ -19,6 +29,10 @@ import { sanitizeKey } from './keys.js'
|
|
|
19
29
|
* putThreadHidden: typeof putThreadHidden,
|
|
20
30
|
* postComment: typeof postComment,
|
|
21
31
|
* postReview: typeof postReview,
|
|
32
|
+
* addPending: typeof addPending,
|
|
33
|
+
* editPending: typeof editPending,
|
|
34
|
+
* deletePending: typeof deletePending,
|
|
35
|
+
* discardPending: typeof discardPending,
|
|
22
36
|
* }} SessionApi
|
|
23
37
|
*/
|
|
24
38
|
|
|
@@ -38,22 +52,29 @@ import { sanitizeKey } from './keys.js'
|
|
|
38
52
|
|
|
39
53
|
/** @param {Partial<SessionApi> | undefined} overrides */
|
|
40
54
|
function withDefaults(overrides) {
|
|
41
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
putReviewed,
|
|
57
|
+
putDismissed,
|
|
58
|
+
putThreadHidden,
|
|
59
|
+
postComment,
|
|
60
|
+
postReview,
|
|
61
|
+
addPending,
|
|
62
|
+
editPending,
|
|
63
|
+
deletePending,
|
|
64
|
+
discardPending,
|
|
65
|
+
...overrides,
|
|
66
|
+
}
|
|
42
67
|
}
|
|
43
68
|
|
|
44
|
-
|
|
45
|
-
* @param {string} layerId
|
|
46
|
-
* @param {string} [path]
|
|
47
|
-
*/
|
|
48
|
-
export function reviewedId(layerId, path) {
|
|
49
|
-
return path === undefined ? `layer:${layerId}` : `layer:${layerId}/file:${sanitizeKey(path)}`
|
|
50
|
-
}
|
|
69
|
+
export { reviewedId } from './keys.js'
|
|
51
70
|
|
|
52
71
|
/** @param {SessionOptions} options */
|
|
53
72
|
export function createReviewSession(options) {
|
|
54
73
|
const api = withDefaults(options.api)
|
|
55
74
|
/** @type {PrState} */
|
|
56
75
|
let state = options.state
|
|
76
|
+
/** @type {ReviewComment[]} */
|
|
77
|
+
let submittedComments = []
|
|
57
78
|
/** @type {Capabilities} */
|
|
58
79
|
let capabilities = options.capabilities
|
|
59
80
|
const keyToPath = new Map(options.files.map(f => [f.key, f.path]))
|
|
@@ -131,6 +152,28 @@ export function createReviewSession(options) {
|
|
|
131
152
|
}
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A change the server owns outright: the answer it sends is the new state. Unlike `change`
|
|
157
|
+
* there is nothing to show first and nothing to take back, because the page never guessed.
|
|
158
|
+
* @template {{ state: PrState }} T
|
|
159
|
+
* @param {() => Promise<T>} request
|
|
160
|
+
* @returns {Promise<T>}
|
|
161
|
+
*/
|
|
162
|
+
const run = async request => {
|
|
163
|
+
inFlight += 1
|
|
164
|
+
try {
|
|
165
|
+
const answer = await request()
|
|
166
|
+
inFlight -= 1
|
|
167
|
+
takeAnswer(answer.state)
|
|
168
|
+
settle()
|
|
169
|
+
return answer
|
|
170
|
+
} catch (err) {
|
|
171
|
+
inFlight -= 1
|
|
172
|
+
settle()
|
|
173
|
+
throw err
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
134
177
|
/**
|
|
135
178
|
* @param {PrState} current
|
|
136
179
|
* @param {'reviewed' | 'dismissed' | 'hiddenThreads'} field
|
|
@@ -234,27 +277,63 @@ export function createReviewSession(options) {
|
|
|
234
277
|
* @returns {Promise<PostCommentResponse>}
|
|
235
278
|
*/
|
|
236
279
|
async postComment(input) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
settle()
|
|
247
|
-
throw err
|
|
248
|
-
}
|
|
280
|
+
return run(() => api.postComment(options.prNumber, { ...input, headSha: options.headSha }))
|
|
281
|
+
},
|
|
282
|
+
/** Comments returned by reviews submitted during this session. */
|
|
283
|
+
get submittedComments() {
|
|
284
|
+
return submittedComments
|
|
285
|
+
},
|
|
286
|
+
/** The comments waiting in the pending review, oldest first. */
|
|
287
|
+
get pending() {
|
|
288
|
+
return state.pending ?? []
|
|
249
289
|
},
|
|
250
290
|
/**
|
|
251
|
-
*
|
|
291
|
+
* Writes one comment into the pending review. The answer carries the state that holds it, so
|
|
292
|
+
* the page draws the draft from the same place a reload would read it from.
|
|
293
|
+
* @param {Omit<import('./contract-types.js').AddPendingInput, 'headSha'>} input
|
|
294
|
+
* @returns {Promise<PrState>}
|
|
295
|
+
*/
|
|
296
|
+
async addPending(input) {
|
|
297
|
+
return (await run(() => api.addPending(options.prNumber, { ...input, headSha: options.headSha }))).state
|
|
298
|
+
},
|
|
299
|
+
/**
|
|
300
|
+
* @param {string} id
|
|
301
|
+
* @param {string} body
|
|
302
|
+
* @returns {Promise<PrState>}
|
|
303
|
+
*/
|
|
304
|
+
async editPending(id, body) {
|
|
305
|
+
return (await run(() => api.editPending(options.prNumber, id, body))).state
|
|
306
|
+
},
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} id
|
|
309
|
+
* @returns {Promise<PrState>}
|
|
310
|
+
*/
|
|
311
|
+
async deletePending(id) {
|
|
312
|
+
return (await run(() => api.deletePending(options.prNumber, id))).state
|
|
313
|
+
},
|
|
314
|
+
/** @returns {Promise<PrState>} */
|
|
315
|
+
async discardPending() {
|
|
316
|
+
return (await run(() => api.discardPending(options.prNumber))).state
|
|
317
|
+
},
|
|
318
|
+
/**
|
|
319
|
+
* Submits the review. The pending comments go out with it unless the caller says otherwise,
|
|
320
|
+
* and the answer's state is the one with them cleared.
|
|
321
|
+
* @param {import('./contract-types.js').ReviewEvent} event
|
|
252
322
|
* @param {string} [body]
|
|
253
|
-
* @
|
|
323
|
+
* @param {{ includePending?: boolean }} [opts]
|
|
324
|
+
* @returns {Promise<import('./contract-types.js').PostReviewResponse>}
|
|
254
325
|
*/
|
|
255
|
-
async postReview(event, body) {
|
|
256
|
-
const input =
|
|
257
|
-
|
|
326
|
+
async postReview(event, body, opts = {}) {
|
|
327
|
+
const input = {
|
|
328
|
+
event,
|
|
329
|
+
...(body === undefined ? {} : { body }),
|
|
330
|
+
...(opts.includePending === undefined ? {} : { includePending: opts.includePending }),
|
|
331
|
+
}
|
|
332
|
+
return run(async () => {
|
|
333
|
+
const answer = await api.postReview(options.prNumber, { ...input, headSha: options.headSha })
|
|
334
|
+
submittedComments = [...submittedComments, ...answer.comments]
|
|
335
|
+
return answer
|
|
336
|
+
})
|
|
258
337
|
},
|
|
259
338
|
}
|
|
260
339
|
}
|
package/static/js/settings.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
// The settings dialog: the personal
|
|
3
|
-
// at the project config, which is committed
|
|
2
|
+
// The settings dialog: the personal settings this browser can change, the reading level a review
|
|
3
|
+
// opens at and the chat settings, and a read-only look at the project config, which is committed
|
|
4
|
+
// and belongs to the repository.
|
|
4
5
|
/** @typedef {import('./contract-types.js').AgentsResponse} AgentsResponse */
|
|
5
6
|
/** @typedef {import('./contract-types.js').SettingsResponse} SettingsResponse */
|
|
6
7
|
import { fetchAgents, fetchSettings, probeAgent, saveSettings } from './api.js'
|
|
7
8
|
import { runCommand } from './commands.js'
|
|
8
9
|
import { esc, qs } from './dom.js'
|
|
10
|
+
import { isFoldLevel } from './fold-levels.js'
|
|
11
|
+
import { foldLevelOptionsHtml } from './reading-level.js'
|
|
9
12
|
|
|
10
13
|
export const SETTINGS_DIALOG_ID = 'settings-dialog'
|
|
11
14
|
|
|
12
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Model ids the input suggests per agent. Free text is allowed; this is only a shortcut. The
|
|
17
|
+
* server runs the newest model of whichever family is saved, so these do not go stale.
|
|
18
|
+
*/
|
|
13
19
|
export const MODEL_SUGGESTIONS = {
|
|
14
|
-
claude: ['
|
|
15
|
-
codex: ['gpt-
|
|
20
|
+
claude: ['opus', 'opus[1m]', 'sonnet', 'haiku', 'fable'],
|
|
21
|
+
codex: ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-6-astra[high]'],
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** @param {string} agent */
|
|
@@ -22,18 +28,43 @@ export function modelOptionsHtml(agent) {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
|
-
*
|
|
31
|
+
* The chat agent's fields, with the notice about acpx when it is missing.
|
|
32
|
+
* @param {SettingsResponse['settings']} settings
|
|
26
33
|
* @param {AgentsResponse} agents
|
|
27
34
|
* @returns {string}
|
|
28
35
|
*/
|
|
29
|
-
|
|
30
|
-
const { settings, overrides, project } = data
|
|
36
|
+
function chatFieldsHtml(settings, agents) {
|
|
31
37
|
const options = agents.agents
|
|
32
38
|
.map(a => {
|
|
33
39
|
const reason = a.available ? '' : ` (${a.reason ?? 'not available'})`
|
|
34
40
|
return `<option value="${esc(a.id)}"${a.id === settings.agent ? ' selected' : ''}${a.available ? '' : ' disabled'}>${esc(a.id)}${esc(reason)}</option>`
|
|
35
41
|
})
|
|
36
42
|
.join('')
|
|
43
|
+
return (
|
|
44
|
+
(agents.acpx.installed
|
|
45
|
+
? ''
|
|
46
|
+
: '<p class="notice" role="status">acpx is not on PATH, so AI Chat is off. Install acpx and reload.</p>') +
|
|
47
|
+
'<div class="field"><label for="set-agent">Agent</label>' +
|
|
48
|
+
`<select id="set-agent">${options}</select></div>` +
|
|
49
|
+
'<div class="field"><label for="set-model">Model</label>' +
|
|
50
|
+
`<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
|
|
51
|
+
`<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
|
|
52
|
+
'<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
|
|
53
|
+
`<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
|
|
54
|
+
'<div class="field"><label for="set-turns">Max turns</label>' +
|
|
55
|
+
`<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
|
|
56
|
+
'<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>'
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {SettingsResponse} data
|
|
62
|
+
* @param {AgentsResponse | null} agents null when the project turns chat off, so the dialog holds
|
|
63
|
+
* only what the page itself reads
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function settingsDialogHtml(data, agents) {
|
|
67
|
+
const { settings, overrides, project } = data
|
|
37
68
|
const overrideNote =
|
|
38
69
|
overrides.agent === undefined && overrides.model === undefined
|
|
39
70
|
? ''
|
|
@@ -49,19 +80,10 @@ export function settingsDialogHtml(data, agents) {
|
|
|
49
80
|
`<dialog id="${SETTINGS_DIALOG_ID}" class="settings" aria-labelledby="settings-h">` +
|
|
50
81
|
'<h2 id="settings-h">Settings</h2>' +
|
|
51
82
|
overrideNote +
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
`<select id="set-agent">${options}</select></div>` +
|
|
57
|
-
'<div class="field"><label for="set-model">Model</label>' +
|
|
58
|
-
`<input id="set-model" list="model-list" value="${esc(settings.model ?? '')}" placeholder="the agent's default">` +
|
|
59
|
-
`<datalist id="model-list">${modelOptionsHtml(settings.agent)}</datalist></div>` +
|
|
60
|
-
'<div class="field"><label for="set-timeout">Chat timeout (seconds)</label>' +
|
|
61
|
-
`<input id="set-timeout" type="number" min="30" max="3600" value="${esc(settings.chatTimeoutSec)}"></div>` +
|
|
62
|
-
'<div class="field"><label for="set-turns">Max turns</label>' +
|
|
63
|
-
`<input id="set-turns" type="number" min="1" max="100" value="${esc(settings.maxTurns ?? '')}" placeholder="the agent's default"></div>` +
|
|
64
|
-
'<p class="muted small">Changing the agent starts a new chat thread; the old ones stay in the list.</p>' +
|
|
83
|
+
'<div class="field"><label for="set-fold-level">Hide code by default</label>' +
|
|
84
|
+
`<select id="set-fold-level">${foldLevelOptionsHtml(settings.foldLevel)}</select></div>` +
|
|
85
|
+
'<p class="muted small">The level every review opens at. The Hide code control and the <span class="mono">f</span> key change it for one page.</p>' +
|
|
86
|
+
(agents === null ? '' : chatFieldsHtml(settings, agents)) +
|
|
65
87
|
`<p class="muted small mono">${esc(data.file)}</p>` +
|
|
66
88
|
'<div class="panel-ro"><h3>Project config (read-only)</h3>' +
|
|
67
89
|
`<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
|
|
@@ -75,7 +97,9 @@ export function settingsDialogHtml(data, agents) {
|
|
|
75
97
|
`<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
|
|
76
98
|
'<p class="probe-result" role="status"></p>' +
|
|
77
99
|
'<div class="dialog-actions">' +
|
|
78
|
-
|
|
100
|
+
(agents === null
|
|
101
|
+
? ''
|
|
102
|
+
: '<button class="cmd" type="button" data-act="settings-probe">test agent</button>') +
|
|
79
103
|
'<button class="cmd fill" type="button" data-act="settings-save">save</button>' +
|
|
80
104
|
'<button class="cmd" type="button" data-act="settings-close">close</button>' +
|
|
81
105
|
'</div></dialog>'
|
|
@@ -104,7 +128,9 @@ export async function openSettingsDialog(root, opener, opts = {}) {
|
|
|
104
128
|
const loaded = await runCommand(
|
|
105
129
|
opener,
|
|
106
130
|
async () => {
|
|
107
|
-
|
|
131
|
+
// With chat off the agent routes do not exist, and the dialog holds only the reading level.
|
|
132
|
+
const data = await api.fetchSettings()
|
|
133
|
+
const agents = data.project.chatEnabled ? await api.fetchAgents() : null
|
|
108
134
|
return { data, agents }
|
|
109
135
|
},
|
|
110
136
|
{ pendingLabel: 'loading…' }
|
|
@@ -129,12 +155,16 @@ export async function openSettingsDialog(root, opener, opts = {}) {
|
|
|
129
155
|
|
|
130
156
|
/** The values the dialog holds right now, as the PUT body. */
|
|
131
157
|
export function readSettingsForm(/** @type {ParentNode} */ dialog) {
|
|
158
|
+
const foldLevel = qs('#set-fold-level', dialog)
|
|
132
159
|
const agent = qs('#set-agent', dialog)
|
|
133
160
|
const model = qs('#set-model', dialog)
|
|
134
161
|
const timeout = qs('#set-timeout', dialog)
|
|
135
162
|
const turns = qs('#set-turns', dialog)
|
|
136
163
|
/** @type {import('./contract-types.js').SettingsInput} */
|
|
137
164
|
const input = {}
|
|
165
|
+
if (foldLevel instanceof HTMLSelectElement && isFoldLevel(foldLevel.value)) {
|
|
166
|
+
input.foldLevel = foldLevel.value
|
|
167
|
+
}
|
|
138
168
|
if (agent instanceof HTMLSelectElement && agent.value !== '') {
|
|
139
169
|
input.agent = /** @type {import('./contract-types.js').ChatAgent} */ (agent.value)
|
|
140
170
|
}
|