@vintasoftware/pr-review-canvas 0.3.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 +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -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 +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- 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/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -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 +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- 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/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
package/static/js/composer.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { cssEscape } from './anchors.js'
|
|
7
7
|
import { renderMarkdown } from './markdown.js'
|
|
8
8
|
import { esc } from './dom.js'
|
|
9
|
+
import { noPostingTitle, postToLabel } from './host.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @typedef {{
|
|
@@ -19,6 +20,8 @@ import { esc } from './dom.js'
|
|
|
19
20
|
* inReplyToId?: number,
|
|
20
21
|
* pointFingerprint?: string,
|
|
21
22
|
* body?: string,
|
|
23
|
+
* pendingActive?: boolean,
|
|
24
|
+
* pendingId?: string,
|
|
22
25
|
* }} ComposerOptions
|
|
23
26
|
*/
|
|
24
27
|
|
|
@@ -32,6 +35,8 @@ function dataAttributes(opts) {
|
|
|
32
35
|
['data-start-line', opts.startLine],
|
|
33
36
|
['data-in-reply-to', opts.inReplyToId],
|
|
34
37
|
['data-fingerprint', opts.pointFingerprint],
|
|
38
|
+
['data-pending-id', opts.pendingId],
|
|
39
|
+
['data-pending-active', opts.pendingActive === true ? '1' : '0'],
|
|
35
40
|
]
|
|
36
41
|
return pairs
|
|
37
42
|
.filter(([, value]) => value !== undefined)
|
|
@@ -40,7 +45,57 @@ function dataAttributes(opts) {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/**
|
|
43
|
-
* The
|
|
48
|
+
* The commands under the box, which are the ones the forge's own page offers.
|
|
49
|
+
*
|
|
50
|
+
* With no review open, a comment on a diff line can go either way: into a review it starts, or
|
|
51
|
+
* straight out on its own. Once a review is open it can only join it. Posting one comment on its
|
|
52
|
+
* own would publish it while the rest of the review is still held back, so that way is closed for
|
|
53
|
+
* as long as something is waiting, and the drafts are submitted together.
|
|
54
|
+
*
|
|
55
|
+
* A draft being edited has neither: it is already in the review, so it is only saved.
|
|
56
|
+
* @param {ComposerOptions} opts
|
|
57
|
+
*/
|
|
58
|
+
function commandsHtml(opts) {
|
|
59
|
+
const cancel = '<button class="cmd" type="button" data-act="composer-cancel">cancel</button>'
|
|
60
|
+
if (opts.pendingId !== undefined) {
|
|
61
|
+
return `<button class="cmd fill" type="button" data-act="pending-save">save</button>${cancel}`
|
|
62
|
+
}
|
|
63
|
+
const post = (lead = false) =>
|
|
64
|
+
`<button class="cmd${lead ? ' fill' : ''}" type="button" data-act="composer-post" data-needs-post>${postToLabel()}</button>`
|
|
65
|
+
if (opts.kind !== 'inline') {
|
|
66
|
+
return `${post(true)}${cancel}`
|
|
67
|
+
}
|
|
68
|
+
if (opts.pendingActive === true) {
|
|
69
|
+
return `<button class="cmd fill" type="button" data-act="composer-queue">add review comment</button>${cancel}`
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
`<button class="cmd fill" type="button" data-act="composer-queue">start a review</button>` +
|
|
73
|
+
post() +
|
|
74
|
+
cancel
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Keep an open inline composer's commands in step when another action starts a review.
|
|
79
|
+
* @param {ParentNode} root
|
|
80
|
+
* @param {boolean} active
|
|
81
|
+
*/
|
|
82
|
+
export function refreshComposerCommands(root, active) {
|
|
83
|
+
for (const box of root.querySelectorAll('.composer-box[data-kind="inline"]')) {
|
|
84
|
+
if (
|
|
85
|
+
box.hasAttribute('data-pending-id') ||
|
|
86
|
+
box.hasAttribute('data-posting') ||
|
|
87
|
+
(box.getAttribute('data-pending-active') === '1') === active
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
const actions = box.querySelector('.composer-actions')
|
|
91
|
+
if (actions !== null)
|
|
92
|
+
actions.innerHTML = commandsHtml({ id: box.id, label: '', kind: 'inline', pendingActive: active })
|
|
93
|
+
box.setAttribute('data-pending-active', active ? '1' : '0')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The box itself. The textarea carries the draft; the commands act on the nearest
|
|
44
99
|
* `.composer-box` ancestor.
|
|
45
100
|
* @param {ComposerOptions} opts
|
|
46
101
|
*/
|
|
@@ -50,8 +105,7 @@ export function composerHtml(opts) {
|
|
|
50
105
|
`<label class="sr" for="${esc(opts.id)}-t">${esc(opts.label)}</label>` +
|
|
51
106
|
previewControlsHtml() +
|
|
52
107
|
`<textarea id="${esc(opts.id)}-t" rows="3" placeholder="${esc(opts.label)}">${esc(opts.body ?? '')}</textarea>` +
|
|
53
|
-
|
|
54
|
-
'<button class="cmd" type="button" data-act="composer-cancel">cancel</button></div></div>'
|
|
108
|
+
`<div class="composer-actions">${commandsHtml(opts)}</div></div>`
|
|
55
109
|
)
|
|
56
110
|
}
|
|
57
111
|
|
|
@@ -80,6 +134,24 @@ export function composerBody(box) {
|
|
|
80
134
|
return textarea instanceof HTMLTextAreaElement ? textarea.value.trim() : ''
|
|
81
135
|
}
|
|
82
136
|
|
|
137
|
+
/**
|
|
138
|
+
* What this composer would add to the pending review, or null when it is empty or is not a
|
|
139
|
+
* comment on a diff line. Only inline comments can wait: a reply and a PR-level comment have
|
|
140
|
+
* no place in a forge review's comment list.
|
|
141
|
+
* @param {Element} box
|
|
142
|
+
* @returns {Omit<import('./contract-types.js').AddPendingInput, 'headSha'> | null}
|
|
143
|
+
*/
|
|
144
|
+
export function composerPendingInput(box) {
|
|
145
|
+
const input = composerInput(box)
|
|
146
|
+
return input === null || input.kind !== 'inline' ? null : stripKind(input)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {Extract<PostCommentInput, { kind: 'inline' }>} input */
|
|
150
|
+
function stripKind(input) {
|
|
151
|
+
const { kind: _kind, ...rest } = input
|
|
152
|
+
return rest
|
|
153
|
+
}
|
|
154
|
+
|
|
83
155
|
/**
|
|
84
156
|
* The request that posts what this composer holds.
|
|
85
157
|
* @param {Element} box
|
|
@@ -130,8 +202,41 @@ export function focusComposer(root, id) {
|
|
|
130
202
|
return textarea
|
|
131
203
|
}
|
|
132
204
|
|
|
205
|
+
/** What a composer stands in for is marked with this while the box is open. */
|
|
206
|
+
const CONCEALED = 'data-composer-concealed'
|
|
207
|
+
|
|
208
|
+
/** The parts of a concealed host that the box stands in for. */
|
|
209
|
+
const CONCEALED_PARTS = ':scope > .prose, :scope > .tbtns'
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Hides what a composer edits while the box is open, so the reader sees one of the two rather
|
|
213
|
+
* than the draft and its editor at once. The mark is what lets any close put it back.
|
|
214
|
+
* @param {Element} host the element the composer was appended to
|
|
215
|
+
*/
|
|
216
|
+
export function concealForComposer(host) {
|
|
217
|
+
host.setAttribute(CONCEALED, '')
|
|
218
|
+
for (const part of Array.from(host.querySelectorAll(CONCEALED_PARTS))) {
|
|
219
|
+
part.setAttribute('hidden', '')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
133
223
|
/**
|
|
134
|
-
*
|
|
224
|
+
* Puts back everything a composer was concealing. Closing is the only way a box goes away, so
|
|
225
|
+
* doing this here covers cancel, Escape, and opening another box alike.
|
|
226
|
+
* @param {ParentNode} root
|
|
227
|
+
*/
|
|
228
|
+
export function revealConcealed(root) {
|
|
229
|
+
for (const host of Array.from(root.querySelectorAll(`[${CONCEALED}]`))) {
|
|
230
|
+
host.removeAttribute(CONCEALED)
|
|
231
|
+
for (const part of Array.from(host.querySelectorAll(CONCEALED_PARTS))) {
|
|
232
|
+
part.removeAttribute('hidden')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Removes every open composer under `root`, so only one is open at a time, and reveals whatever
|
|
239
|
+
* they were standing in for.
|
|
135
240
|
* @param {ParentNode} root
|
|
136
241
|
*/
|
|
137
242
|
export function closeComposers(root) {
|
|
@@ -141,15 +246,13 @@ export function closeComposers(root) {
|
|
|
141
246
|
;(row ?? box).remove()
|
|
142
247
|
closed++
|
|
143
248
|
}
|
|
249
|
+
revealConcealed(root)
|
|
144
250
|
return closed
|
|
145
251
|
}
|
|
146
252
|
|
|
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
253
|
/**
|
|
151
254
|
* Disables everything that posts when the probe said no, and enables it otherwise. A token
|
|
152
|
-
* whose rights cannot be read ('unknown') stays enabled:
|
|
255
|
+
* whose rights cannot be read ('unknown') stays enabled: the host answers for itself.
|
|
153
256
|
*
|
|
154
257
|
* A command can be disabled for a reason of its own (the approve command before every layer is
|
|
155
258
|
* read, a command whose request is still running). Those keep their state: this only adds and
|
|
@@ -159,7 +262,7 @@ export const NO_POSTING_TITLE = 'this GitHub login cannot post on this repositor
|
|
|
159
262
|
*/
|
|
160
263
|
export function applyCapabilityGating(root, capabilities) {
|
|
161
264
|
const blocked = capabilities.canComment === false
|
|
162
|
-
const reason = capabilities.reason ??
|
|
265
|
+
const reason = capabilities.reason ?? noPostingTitle()
|
|
163
266
|
for (const el of Array.from(root.querySelectorAll('[data-needs-post]'))) {
|
|
164
267
|
if (!(el instanceof HTMLButtonElement) || el.getAttribute('aria-busy') === 'true') {
|
|
165
268
|
continue
|
|
@@ -222,15 +325,19 @@ export function setDisabledReason(el, reason) {
|
|
|
222
325
|
}
|
|
223
326
|
}
|
|
224
327
|
|
|
328
|
+
/** One command that swaps the box between writing and previewing; its label is the mode it goes to. */
|
|
225
329
|
export function previewControlsHtml() {
|
|
226
|
-
return '<div class="preview-controls"
|
|
330
|
+
return '<div class="preview-controls"><button class="cmd" type="button" data-act="markdown-toggle">preview</button></div><div class="markdown-preview prose" hidden></div>'
|
|
227
331
|
}
|
|
228
332
|
|
|
229
|
-
/**
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Puts one editor into write or preview mode.
|
|
335
|
+
* @param {Element} host the `.composer-box` or `.signoff-dialog` that holds the editor
|
|
336
|
+
* @param {boolean} preview
|
|
337
|
+
*/
|
|
338
|
+
export function setMarkdownPreview(host, preview) {
|
|
339
|
+
const textarea = host.querySelector('textarea')
|
|
340
|
+
const output = host.querySelector('.markdown-preview')
|
|
234
341
|
if (!(textarea instanceof HTMLTextAreaElement) || !(output instanceof HTMLElement)) return
|
|
235
342
|
if (preview)
|
|
236
343
|
output.innerHTML = textarea.value.trim()
|
|
@@ -238,7 +345,15 @@ export function toggleMarkdownPreview(button, preview) {
|
|
|
238
345
|
: '<p class="muted">Nothing to preview.</p>'
|
|
239
346
|
textarea.hidden = preview
|
|
240
347
|
output.hidden = !preview
|
|
241
|
-
host
|
|
242
|
-
|
|
348
|
+
const toggle = host.querySelector('[data-act="markdown-toggle"]')
|
|
349
|
+
if (toggle !== null) toggle.textContent = preview ? 'write' : 'preview'
|
|
243
350
|
if (!preview) textarea.focus()
|
|
244
351
|
}
|
|
352
|
+
|
|
353
|
+
/** @param {HTMLElement} button */
|
|
354
|
+
export function toggleMarkdownPreview(button) {
|
|
355
|
+
const host = button.closest('.composer-box, .signoff-dialog')
|
|
356
|
+
if (host === null) return
|
|
357
|
+
const output = host.querySelector('.markdown-preview')
|
|
358
|
+
setMarkdownPreview(host, output instanceof HTMLElement && output.hidden)
|
|
359
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Type-only bridge so the browser modules (JSDoc) see the same contract as the server.
|
|
2
2
|
|
|
3
3
|
export type {
|
|
4
|
+
CarriedOverInfo,
|
|
4
5
|
CanvasRelation,
|
|
5
6
|
Capabilities,
|
|
6
7
|
ChatStatus,
|
|
@@ -10,7 +11,9 @@ export type {
|
|
|
10
11
|
PostCommentResponse,
|
|
11
12
|
PostReviewResponse,
|
|
12
13
|
PrBundle,
|
|
14
|
+
PublicHost,
|
|
13
15
|
ReviewBodyResponse,
|
|
16
|
+
ReviewBootstrap,
|
|
14
17
|
ReviewSummary,
|
|
15
18
|
SharedCanvasFetchResponse,
|
|
16
19
|
SharedCanvasInfo,
|
|
@@ -23,6 +26,7 @@ export type {
|
|
|
23
26
|
ChatThreadsResponse,
|
|
24
27
|
ChatTurn,
|
|
25
28
|
} from '../../src/contract/chat.js'
|
|
29
|
+
export type { ReviewKey } from '../../src/contract/review-key.js'
|
|
26
30
|
export type {
|
|
27
31
|
CommentsPayload,
|
|
28
32
|
IssueComment,
|
|
@@ -34,6 +38,7 @@ export type {
|
|
|
34
38
|
CodeFold,
|
|
35
39
|
Diagram,
|
|
36
40
|
FileEntry,
|
|
41
|
+
FoldLevel,
|
|
37
42
|
Hunk,
|
|
38
43
|
Layer,
|
|
39
44
|
LayerFile,
|
|
@@ -57,3 +62,5 @@ export type {
|
|
|
57
62
|
Theme,
|
|
58
63
|
} from '../../src/contract/settings.js'
|
|
59
64
|
export type { PrState } from '../../src/contract/state.js'
|
|
65
|
+
export type { AddPendingInput, PendingComment } from '../../src/contract/pending.js'
|
|
66
|
+
export type { ReviewEvent } from '../../src/contract/reviews.js'
|
|
@@ -9,6 +9,7 @@ import { findRow, nearestRow } from './anchors.js'
|
|
|
9
9
|
import { viewCommentHtml } from './comment-link.js'
|
|
10
10
|
import { esc, fragment, avatarHtml, timeAgo } from './dom.js'
|
|
11
11
|
import { renderMarkdown } from './markdown.js'
|
|
12
|
+
import { pendingRowHtml } from './pending.js'
|
|
12
13
|
import { pointRowHtml } from './points.js'
|
|
13
14
|
|
|
14
15
|
const DECORATION = 'data-decoration'
|
|
@@ -146,6 +147,66 @@ export function insertThreadRow(card, key, t, opts) {
|
|
|
146
147
|
return true
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The drafts of one file, drawn under the lines they comment on. A draft whose line is not on the
|
|
152
|
+
* page (a fold, or a file drawn short) goes under the nearest row, like every other decoration.
|
|
153
|
+
* @param {HTMLElement} card
|
|
154
|
+
* @param {string} key
|
|
155
|
+
* @param {ReadonlyArray<import('./contract-types.js').PendingComment>} drafts
|
|
156
|
+
* @param {Date} now
|
|
157
|
+
* @returns {{ placed: number, missed: number }}
|
|
158
|
+
*/
|
|
159
|
+
export function insertPendingRows(card, key, drafts, now) {
|
|
160
|
+
let placed = 0
|
|
161
|
+
let missed = 0
|
|
162
|
+
for (const draft of drafts) {
|
|
163
|
+
const near = nearestRow(card, key, draft.side, draft.line)
|
|
164
|
+
if (near === null) {
|
|
165
|
+
missed++
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const row = firstRow(pendingRowHtml([draft], now))
|
|
169
|
+
row.setAttribute(DECORATION, 'pending')
|
|
170
|
+
row.dataset['pendingId'] = draft.id
|
|
171
|
+
row.dataset['pendingBody'] = draft.body
|
|
172
|
+
if (near.approx) row.classList.add('is-approx')
|
|
173
|
+
let after = near.row
|
|
174
|
+
while (
|
|
175
|
+
after.nextElementSibling instanceof HTMLTableRowElement &&
|
|
176
|
+
after.nextElementSibling.getAttribute(DECORATION) === 'pending'
|
|
177
|
+
) {
|
|
178
|
+
after = after.nextElementSibling
|
|
179
|
+
}
|
|
180
|
+
after.insertAdjacentElement('afterend', row)
|
|
181
|
+
placed++
|
|
182
|
+
}
|
|
183
|
+
return { placed, missed }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Update draft rows without replacing thread replies, annotations, or active editors.
|
|
187
|
+
* @param {HTMLElement} card
|
|
188
|
+
* @param {string} key
|
|
189
|
+
* @param {ReadonlyArray<import('./contract-types.js').PendingComment>} drafts
|
|
190
|
+
* @param {Date} now
|
|
191
|
+
*/
|
|
192
|
+
export function refreshPendingRows(card, key, drafts, now) {
|
|
193
|
+
const remaining = new Map(drafts.map(p => [p.id, p]))
|
|
194
|
+
for (const row of card.querySelectorAll('tr[data-decoration="pending"]')) {
|
|
195
|
+
const draft = remaining.get(row.getAttribute('data-pending-id') ?? '')
|
|
196
|
+
if (draft === undefined) {
|
|
197
|
+
row.remove()
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
remaining.delete(draft.id)
|
|
201
|
+
if (row.getAttribute('data-pending-body') !== draft.body) {
|
|
202
|
+
const prose = row.querySelector('.pending-cmt > .prose')
|
|
203
|
+
if (prose !== null) prose.innerHTML = renderMarkdown(draft.body, { github: true })
|
|
204
|
+
row.setAttribute('data-pending-body', draft.body)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
insertPendingRows(card, key, [...remaining.values()], now)
|
|
208
|
+
}
|
|
209
|
+
|
|
149
210
|
/**
|
|
150
211
|
* @param {string} html
|
|
151
212
|
* @returns {HTMLTableRowElement}
|
|
@@ -166,7 +227,7 @@ function firstRow(html) {
|
|
|
166
227
|
* right after the anchor row, so they are added in reverse).
|
|
167
228
|
* @param {HTMLElement} card
|
|
168
229
|
* @param {string} key
|
|
169
|
-
* @param {{ annotations: ReadonlyArray<Annotation>, points: ReadonlyArray<Point>, threads: ReadonlyArray<Thread>, paths: ReadonlySet<string>, now: Date, state?: import('./contract-types.js').PrState, posted?: ReadonlyMap<string, string>, hiddenThreads?: ReadonlySet<number> }} data
|
|
230
|
+
* @param {{ annotations: ReadonlyArray<Annotation>, points: ReadonlyArray<Point>, threads: ReadonlyArray<Thread>, pending?: ReadonlyArray<import('./contract-types.js').PendingComment>, paths: ReadonlySet<string>, now: Date, state?: import('./contract-types.js').PrState, posted?: ReadonlyMap<string, string>, hiddenThreads?: ReadonlySet<number> }} data
|
|
170
231
|
* @returns {{ placed: number, missed: number }}
|
|
171
232
|
*/
|
|
172
233
|
export function applyDecorations(card, key, data) {
|
|
@@ -185,6 +246,11 @@ export function applyDecorations(card, key, data) {
|
|
|
185
246
|
missed++
|
|
186
247
|
}
|
|
187
248
|
}
|
|
249
|
+
// Drafts are added before the threads, so under one line they end up after them: the comment
|
|
250
|
+
// that is already on the forge reads first, the one still being written reads last.
|
|
251
|
+
const drafts = insertPendingRows(card, key, data.pending ?? [], data.now)
|
|
252
|
+
placed += drafts.placed
|
|
253
|
+
missed += drafts.missed
|
|
188
254
|
for (const t of [...data.threads].reverse()) {
|
|
189
255
|
count(
|
|
190
256
|
insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false })
|
|
@@ -855,7 +855,7 @@ export function renderDiff(file, patch, opts = {}) {
|
|
|
855
855
|
highlightHunk(h, file.lang)
|
|
856
856
|
tables.push(
|
|
857
857
|
`<table class="diff" id="${esc(hunkAnchorId(file.key, n))}" data-hunk="${esc(id)}" data-key="${esc(file.key)}">` +
|
|
858
|
-
`<caption class="sr">
|
|
858
|
+
`<caption class="sr">Chunk ${n} of ${esc(file.path)}</caption>${THEAD}<tbody>${buildRows(h, file.key)}</tbody></table>`
|
|
859
859
|
)
|
|
860
860
|
})
|
|
861
861
|
if (tables.length === 0) {
|
package/static/js/download.js
CHANGED
|
@@ -26,7 +26,7 @@ export function saveBlob(blob, filename, deps = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* @param {
|
|
29
|
+
* @param {import('./contract-types.js').ReviewKey} prNumber
|
|
30
30
|
* @param {{ headSha?: string, fetchImpl?: typeof fetch } & SaveDeps} [opts]
|
|
31
31
|
* @returns {Promise<string>} the file name the browser saved
|
|
32
32
|
*/
|
package/static/js/empty-state.js
CHANGED
|
@@ -41,18 +41,42 @@ export function dropReducer(state, event) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* A looser form of the export name grammar `parseCanvasZipName` (src/canvas/name.ts) holds for the
|
|
45
|
+
* server: enough to catch the wrong file before it is uploaded, never a second copy of the check.
|
|
46
|
+
*/
|
|
47
|
+
const CANVAS_NAME_RE = /^(?:pr-([1-9]\d*)|ref)-.+-canvas\.zip$/i
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The file the user picked has to be a canvas zip for this pull request. The name is only a first
|
|
51
|
+
* check; the server reads the zip itself and answers CANVAS_INVALID or CANVAS_PR_MISMATCH when it
|
|
52
|
+
* is not one. A canvas exported before the PR existed is named `ref-` and names no PR.
|
|
46
53
|
* @param {string} filename
|
|
54
|
+
* @param {number} prNumber the PR the page is on
|
|
47
55
|
* @returns {string | null} the problem, or null when the name is fine
|
|
48
56
|
*/
|
|
49
|
-
export function validateCanvasFilename(filename) {
|
|
57
|
+
export function validateCanvasFilename(filename, prNumber) {
|
|
50
58
|
if (!filename.toLowerCase().endsWith('.zip')) {
|
|
51
59
|
return 'that is not a zip file'
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
const match = CANVAS_NAME_RE.exec(filename)
|
|
62
|
+
if (!match) {
|
|
63
|
+
return 'that zip is not a review canvas export'
|
|
64
|
+
}
|
|
65
|
+
const named = match[1] === undefined ? undefined : Number(match[1])
|
|
66
|
+
if (named !== undefined && named !== prNumber) {
|
|
67
|
+
return `that canvas was exported for PR #${named}, not #${prNumber}`
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The refusals no retry can clear: the zip is a canvas, of the wrong thing. Anything else the
|
|
74
|
+
* discovery reports is a download that failed, which the link and the drop zone can still rescue.
|
|
75
|
+
* @type {Record<string, string>}
|
|
76
|
+
*/
|
|
77
|
+
const SHARED_CANVAS_REFUSALS = {
|
|
78
|
+
'pr-mismatch': 'it was exported for another pull request',
|
|
79
|
+
'name-mismatch': 'it was exported from another repository',
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
/**
|
|
@@ -68,9 +92,15 @@ export function sharedCanvasCalloutHtml(bundle) {
|
|
|
68
92
|
if (shared.downloadable) {
|
|
69
93
|
return `<div class="callout" role="status">A canvas is attached to this PR (${name}) — importing…</div>`
|
|
70
94
|
}
|
|
95
|
+
const refused = SHARED_CANVAS_REFUSALS[shared.reason ?? '']
|
|
96
|
+
const problem = refused ?? `could not be downloaded (${esc(shared.reason ?? 'unknown')})`
|
|
97
|
+
const next =
|
|
98
|
+
refused === undefined
|
|
99
|
+
? `<a href="${esc(shared.url)}" target="_blank" rel="noopener noreferrer">Download it</a> and drop it here. `
|
|
100
|
+
: 'Ask its author for a canvas of this pull request, or generate one. '
|
|
71
101
|
return (
|
|
72
|
-
`<div class="callout warn" role="status">A canvas is attached to this PR (${name}) but
|
|
73
|
-
|
|
102
|
+
`<div class="callout warn" role="status">A canvas is attached to this PR (${name}) but ${problem}. ` +
|
|
103
|
+
next +
|
|
74
104
|
'<button class="cmd" type="button" id="fetch-shared">fetch again</button></div>'
|
|
75
105
|
)
|
|
76
106
|
}
|
|
@@ -86,6 +116,16 @@ function skillCommandHtml(bundle) {
|
|
|
86
116
|
)
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/**
|
|
120
|
+
* What each screen with no canvas is called. A local review names the work it describes, because
|
|
121
|
+
* `branch` and `uncommitted` are two different reads of the same clone.
|
|
122
|
+
* @type {Record<string, string>}
|
|
123
|
+
*/
|
|
124
|
+
const LOCAL_HEADINGS = {
|
|
125
|
+
branch: 'No review canvas for this branch yet',
|
|
126
|
+
uncommitted: 'No review canvas for your uncommitted work yet',
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
/** @returns {string} */
|
|
90
130
|
function dropZoneHtml() {
|
|
91
131
|
return (
|
|
@@ -97,18 +137,20 @@ function dropZoneHtml() {
|
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
/**
|
|
100
|
-
* The `missing` screen: the skill command with its copy command, the drop zone, the callout.
|
|
140
|
+
* The `missing` screen: the skill command with its copy command, the drop zone, the callout. A
|
|
141
|
+
* local review has no pull request to carry an attached canvas, so it shows neither of the last two.
|
|
101
142
|
* @param {PrBundle} bundle
|
|
102
143
|
* @returns {string}
|
|
103
144
|
*/
|
|
104
145
|
export function renderEmptyState(bundle) {
|
|
146
|
+
const heading = LOCAL_HEADINGS[bundle.local ?? ''] ?? 'No review canvas for this PR yet'
|
|
147
|
+
const transfer = bundle.local ? '' : dropZoneHtml() + sharedCanvasCalloutHtml(bundle)
|
|
105
148
|
return (
|
|
106
149
|
'<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
|
|
107
|
-
|
|
150
|
+
`<div class="panel-h"><h2 id="es-h">${heading}</h2></div>` +
|
|
108
151
|
'<div class="body center">' +
|
|
109
152
|
skillCommandHtml(bundle) +
|
|
110
|
-
|
|
111
|
-
sharedCanvasCalloutHtml(bundle) +
|
|
153
|
+
transfer +
|
|
112
154
|
'</div></section>'
|
|
113
155
|
)
|
|
114
156
|
}
|
|
@@ -116,11 +158,15 @@ export function renderEmptyState(bundle) {
|
|
|
116
158
|
/**
|
|
117
159
|
* How far the canvas is from the head, in words.
|
|
118
160
|
* @param {NonNullable<PrBundle['stale']>} stale
|
|
161
|
+
* @param {PrBundle['local']} [local] which local review this is, when it is one
|
|
119
162
|
* @returns {string}
|
|
120
163
|
*/
|
|
121
|
-
export function staleSummary(stale) {
|
|
164
|
+
export function staleSummary(stale, local) {
|
|
122
165
|
const canvas = stale.canvasHeadSha.slice(0, 7)
|
|
123
166
|
const head = stale.currentHeadSha.slice(0, 7)
|
|
167
|
+
if (local !== undefined) {
|
|
168
|
+
return `The canvas is for ${canvas}; your work has moved on to ${head} since it was generated.`
|
|
169
|
+
}
|
|
124
170
|
if (stale.relation === 'unrelated') {
|
|
125
171
|
return `The canvas is for ${canvas}, which is not in this branch any more; the head is ${head}.`
|
|
126
172
|
}
|
|
@@ -132,15 +178,53 @@ export function staleSummary(stale) {
|
|
|
132
178
|
* The bar that stays on screen while a stale canvas is shown, so the reader is never misled about
|
|
133
179
|
* which commit the diffs come from.
|
|
134
180
|
* @param {NonNullable<PrBundle['stale']>} stale
|
|
181
|
+
* @param {PrBundle['local']} [local]
|
|
135
182
|
* @returns {string}
|
|
136
183
|
*/
|
|
137
|
-
export function staleBarHtml(stale) {
|
|
184
|
+
export function staleBarHtml(stale, local) {
|
|
138
185
|
return (
|
|
139
|
-
`<div class="stale-bar" role="status"><strong>Canvas is outdated.</strong> You are reading an older commit. ${esc(staleSummary(stale))} ` +
|
|
186
|
+
`<div class="stale-bar" role="status"><strong>Canvas is outdated.</strong> You are reading an older commit. ${esc(staleSummary(stale, local))} ` +
|
|
140
187
|
'<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>'
|
|
141
188
|
)
|
|
142
189
|
}
|
|
143
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The note above a canvas that was generated for another commit of the pull request whose diff is
|
|
193
|
+
* identical to the head's, so the canvas still applies.
|
|
194
|
+
* @param {NonNullable<PrBundle['carriedOver']>} carried
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
export function carriedOverBarHtml(carried) {
|
|
198
|
+
const canvas = carried.canvasHeadSha.slice(0, 7)
|
|
199
|
+
const head = carried.currentHeadSha.slice(0, 7)
|
|
200
|
+
// How far the head moved, when the bundle knows: a head that reached the same diff by a rebase
|
|
201
|
+
// is no number of commits away from the canvas's commit, so the note says nothing about it.
|
|
202
|
+
const behind = carried.commitsBehind
|
|
203
|
+
const distance = behind === undefined ? '' : `, ${behind} commit${behind === 1 ? '' : 's'} later,`
|
|
204
|
+
return (
|
|
205
|
+
`<div class="stale-bar carried-over-bar" role="status"><strong>Canvas still applies.</strong> ` +
|
|
206
|
+
`${esc(`It was generated for ${canvas}; the head ${head}${distance} has the identical diff.`)} ` +
|
|
207
|
+
'<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">regenerate anyway</button></div>'
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The note above a canvas that some of the reviewer's progress followed onto. Without it, the
|
|
213
|
+
* already-ticked layers look like a bug. The sha is the canvas the marks were made on, which may
|
|
214
|
+
* be several generations back when the reviewer marked nothing on the canvases in between.
|
|
215
|
+
* @param {string} markedCanvasSha
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
export function marksCarriedBarHtml(markedCanvasSha) {
|
|
219
|
+
return (
|
|
220
|
+
'<div class="stale-bar carried-over-bar" role="status"><strong>Review progress carried over.</strong> ' +
|
|
221
|
+
esc(
|
|
222
|
+
`You marked these on the canvas of ${markedCanvasSha.slice(0, 7)}; a file whose diff has not changed since keeps its mark, and a layer keeps its own when it still holds exactly those files.`
|
|
223
|
+
) +
|
|
224
|
+
'</div>'
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
144
228
|
/**
|
|
145
229
|
* The `stale` screen: the same card as the empty state, plus the two ways forward.
|
|
146
230
|
* @param {PrBundle} bundle
|
|
@@ -148,16 +232,16 @@ export function staleBarHtml(stale) {
|
|
|
148
232
|
*/
|
|
149
233
|
export function renderStaleState(bundle) {
|
|
150
234
|
const stale = bundle.stale
|
|
235
|
+
const transfer = bundle.local ? '' : dropZoneHtml() + sharedCanvasCalloutHtml(bundle)
|
|
151
236
|
return (
|
|
152
237
|
'<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
|
|
153
238
|
'<div class="panel-h"><h2 id="es-h">Canvas is outdated</h2></div>' +
|
|
154
239
|
'<div class="body center">' +
|
|
155
|
-
`<p class="hint">${stale ? esc(staleSummary(stale)) : ''}</p>` +
|
|
240
|
+
`<p class="hint">${stale ? esc(staleSummary(stale, bundle.local)) : ''}</p>` +
|
|
156
241
|
'<div class="cmdbox"><button class="cmd fill" type="button" id="view-stale">view stale canvas</button>' +
|
|
157
242
|
'<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>' +
|
|
158
243
|
skillCommandHtml(bundle) +
|
|
159
|
-
|
|
160
|
-
sharedCanvasCalloutHtml(bundle) +
|
|
244
|
+
transfer +
|
|
161
245
|
'</div></section>'
|
|
162
246
|
)
|
|
163
247
|
}
|
package/static/js/errors.js
CHANGED
|
@@ -36,8 +36,8 @@ export const ERROR_CARDS = {
|
|
|
36
36
|
action: 'Start pr-review from a clone, or pass --repo <dir>.',
|
|
37
37
|
},
|
|
38
38
|
NO_ORIGIN: {
|
|
39
|
-
title: 'No GitHub origin',
|
|
40
|
-
action: 'Add an origin remote that points at github.com, then restart the server.',
|
|
39
|
+
title: 'No GitHub or GitLab origin',
|
|
40
|
+
action: 'Add an origin remote that points at github.com or GitLab, then restart the server.',
|
|
41
41
|
},
|
|
42
42
|
GIT_ERROR: {
|
|
43
43
|
title: 'A git command failed',
|
|
@@ -55,6 +55,18 @@ export const ERROR_CARDS = {
|
|
|
55
55
|
title: 'GitHub refused the request',
|
|
56
56
|
action: 'Retry in a moment. A rate limit or an outage both read like this.',
|
|
57
57
|
},
|
|
58
|
+
GLAB_MISSING: {
|
|
59
|
+
title: 'GitLab CLI is not installed',
|
|
60
|
+
action: 'Install glab from https://gitlab.com/gitlab-org/cli and log in, then retry.',
|
|
61
|
+
},
|
|
62
|
+
GLAB_UNAUTHENTICATED: {
|
|
63
|
+
title: 'GitLab CLI is not logged in',
|
|
64
|
+
action: 'Run glab auth login in a terminal, then retry.',
|
|
65
|
+
},
|
|
66
|
+
GITLAB_API_ERROR: {
|
|
67
|
+
title: 'GitLab refused the request',
|
|
68
|
+
action: 'Retry in a moment. A rate limit or an outage both read like this.',
|
|
69
|
+
},
|
|
58
70
|
PR_NOT_FOUND: {
|
|
59
71
|
title: 'Pull request not found',
|
|
60
72
|
action: 'Check the number, and that this clone’s origin is the right repository.',
|
|
@@ -71,6 +83,10 @@ export const ERROR_CARDS = {
|
|
|
71
83
|
title: 'That canvas belongs to another repository',
|
|
72
84
|
action: 'Import a canvas exported from this repository.',
|
|
73
85
|
},
|
|
86
|
+
CANVAS_PR_MISMATCH: {
|
|
87
|
+
title: 'That canvas belongs to another pull request',
|
|
88
|
+
action: 'Import the canvas exported for this PR, or generate one for it with the skill.',
|
|
89
|
+
},
|
|
74
90
|
CANVAS_TOO_LARGE: {
|
|
75
91
|
title: 'That canvas is too large',
|
|
76
92
|
action: 'A canvas zip holds two JSON files and stays under 20 MB. Export it again.',
|
|
@@ -88,12 +104,12 @@ export const ERROR_CARDS = {
|
|
|
88
104
|
action: 'Remove it, or run pr-review install-skill --force.',
|
|
89
105
|
},
|
|
90
106
|
COMMENT_FORBIDDEN: {
|
|
91
|
-
title: 'This
|
|
92
|
-
action: '
|
|
107
|
+
title: 'This login cannot post here',
|
|
108
|
+
action: 'Check the account’s repository access and token permissions, then retry.',
|
|
93
109
|
},
|
|
94
110
|
COMMENT_LINE_NOT_IN_DIFF: {
|
|
95
|
-
title: '
|
|
96
|
-
action: 'Pick a line inside a
|
|
111
|
+
title: 'Comments must sit on lines the diff shows',
|
|
112
|
+
action: 'Pick a line inside a chunk of this pull request.',
|
|
97
113
|
},
|
|
98
114
|
SIGNOFF_INCOMPLETE: {
|
|
99
115
|
title: 'Some layers are not reviewed yet',
|