@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The pending review: the comments the reviewer has written and not submitted yet. They live in
|
|
3
|
+
// the local state, are drawn on the diff with a "pending" badge, and are counted in a bar that
|
|
4
|
+
// stays on screen for as long as any of them is waiting.
|
|
5
|
+
/** @typedef {import('./contract-types.js').PendingComment} PendingComment */
|
|
6
|
+
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
7
|
+
/** @typedef {import('./contract-types.js').Side} Side */
|
|
8
|
+
import { esc, timeAgo } from './dom.js'
|
|
9
|
+
import { hostLabel } from './host.js'
|
|
10
|
+
import { renderMarkdown } from './markdown.js'
|
|
11
|
+
|
|
12
|
+
export const PENDING_BAR_ID = 'pending-bar'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The drafts of one target, oldest first. A state written by an older tool version has no list
|
|
16
|
+
* at all, which reads as none waiting.
|
|
17
|
+
* @param {PrState | null | undefined} state
|
|
18
|
+
* @returns {ReadonlyArray<PendingComment>}
|
|
19
|
+
*/
|
|
20
|
+
export function pendingComments(state) {
|
|
21
|
+
return state?.pending ?? []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {PrState | null | undefined} state
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function pendingCount(state) {
|
|
29
|
+
return pendingComments(state).length
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The drafts anchored to one file, by the diff key the rows carry.
|
|
34
|
+
* @param {PrState | null | undefined} state
|
|
35
|
+
* @param {string} path
|
|
36
|
+
* @returns {ReadonlyArray<PendingComment>}
|
|
37
|
+
*/
|
|
38
|
+
export function pendingForPath(state, path) {
|
|
39
|
+
return pendingComments(state).filter(p => p.path === path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The draft an attention point was added to the review as, when it was. A point is queued at most
|
|
44
|
+
* once: its fingerprint is what ties the two together.
|
|
45
|
+
* @param {PrState | null | undefined} state
|
|
46
|
+
* @param {string} fingerprint
|
|
47
|
+
* @returns {PendingComment | undefined}
|
|
48
|
+
*/
|
|
49
|
+
export function pendingForPoint(state, fingerprint) {
|
|
50
|
+
return pendingComments(state).find(p => p.pointFingerprint === fingerprint)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {number} count */
|
|
54
|
+
export function pendingLabel(count) {
|
|
55
|
+
return count === 1 ? '1 pending comment' : `${count} pending comments`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a draft sits, said the way the selection toolbar says it.
|
|
60
|
+
* @param {PendingComment} p
|
|
61
|
+
*/
|
|
62
|
+
export function pendingRange(p) {
|
|
63
|
+
const range = p.startLine !== undefined && p.startLine !== p.line ? `${p.startLine}–${p.line}` : `${p.line}`
|
|
64
|
+
return `${p.path}:${range}${p.side === 'old' ? ' (old)' : ''}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One draft as it is drawn under the line it comments on: the body, when it was written, and the
|
|
69
|
+
* commands that edit or drop it. The badge is what tells the reviewer it is not on the forge yet.
|
|
70
|
+
* @param {PendingComment} p
|
|
71
|
+
* @param {Date} now
|
|
72
|
+
*/
|
|
73
|
+
export function pendingCommentHtml(p, now) {
|
|
74
|
+
// The same two-column shape a posted comment has, so drafts and comments line up on the diff:
|
|
75
|
+
// the marker takes the avatar's place, and the commands span both columns under them.
|
|
76
|
+
return (
|
|
77
|
+
`<div class="cmt pending-cmt" data-pending-id="${esc(p.id)}">` +
|
|
78
|
+
'<span class="av pending-av" aria-hidden="true">…</span>' +
|
|
79
|
+
`<span class="who"><span class="pill pending">pending</span> ` +
|
|
80
|
+
`<span class="muted">${esc(timeAgo(p.createdAt, now))} · not posted to ${esc(hostLabel())} yet</span></span>` +
|
|
81
|
+
`<div class="prose">${renderMarkdown(p.body, { github: true })}</div>` +
|
|
82
|
+
'<span class="tbtns">' +
|
|
83
|
+
`<button class="cmd" type="button" data-act="pending-edit" data-pending-id="${esc(p.id)}">edit</button>` +
|
|
84
|
+
`<button class="cmd" type="button" data-act="pending-delete" data-pending-id="${esc(p.id)}">delete</button>` +
|
|
85
|
+
'</span></div>'
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The row the draft is drawn in, so it can sit under its line in the diff table.
|
|
91
|
+
* @param {ReadonlyArray<PendingComment>} drafts the drafts anchored to one line
|
|
92
|
+
* @param {Date} now
|
|
93
|
+
*/
|
|
94
|
+
export function pendingRowHtml(drafts, now) {
|
|
95
|
+
return (
|
|
96
|
+
'<tr class="pending-row"><td class="code x" colspan="4">' +
|
|
97
|
+
drafts.map(p => pendingCommentHtml(p, now)).join('') +
|
|
98
|
+
'</td></tr>'
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The bar that says a review is being written. It is drawn only while something is waiting, and
|
|
104
|
+
* carries the two ways out: submit the review, or throw the drafts away.
|
|
105
|
+
* @param {number} count
|
|
106
|
+
* @param {ReadonlyArray<PendingComment>} [earlier]
|
|
107
|
+
*/
|
|
108
|
+
export function pendingBarHtml(count, earlier = []) {
|
|
109
|
+
if (count === 0) {
|
|
110
|
+
return ''
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
`<div class="pending-bar" id="${PENDING_BAR_ID}" role="status">` +
|
|
114
|
+
`<span class="pending-mark" aria-hidden="true"></span>` +
|
|
115
|
+
`<span class="pending-text"><b>${esc(pendingLabel(count))}</b> waiting in your review. ` +
|
|
116
|
+
`Nothing is on ${esc(hostLabel())} until you submit it.</span>` +
|
|
117
|
+
'<span class="pending-actions">' +
|
|
118
|
+
'<button class="cmd fill" type="button" data-act="pending-finish" data-needs-post>finish your review</button>' +
|
|
119
|
+
'<button class="cmd" type="button" data-act="pending-discard">discard</button>' +
|
|
120
|
+
'</span></div>' +
|
|
121
|
+
(earlier.length === 0
|
|
122
|
+
? ''
|
|
123
|
+
: '<details class="earlier-pending"><summary>Comments written on earlier commits</summary>' +
|
|
124
|
+
'<p>These drafts are kept at their original locations. They can be submitted only if the diff is unchanged. Otherwise copy the text, delete the draft, and comment on the current code.</p>' +
|
|
125
|
+
earlier
|
|
126
|
+
.map(
|
|
127
|
+
p =>
|
|
128
|
+
`<div class="earlier-draft"><p>${esc(pendingRange(p))} · commit ${esc(p.headSha.slice(0, 7))}</p>` +
|
|
129
|
+
`<div class="prose">${renderMarkdown(p.body, { github: true })}</div>` +
|
|
130
|
+
`<button class="cmd" type="button" data-copy="${esc(p.body)}">copy</button>` +
|
|
131
|
+
`<button class="cmd" type="button" data-act="pending-delete" data-pending-id="${esc(p.id)}">delete</button></div>`
|
|
132
|
+
)
|
|
133
|
+
.join('') +
|
|
134
|
+
'</details>')
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Draws the bar from the current state, adding it when the first draft appears and taking it away
|
|
140
|
+
* with the last one. The host element is where the bar lives; it is emptied when nothing waits.
|
|
141
|
+
* @param {ParentNode} root
|
|
142
|
+
* @param {PrState} state
|
|
143
|
+
* @param {string} [headSha]
|
|
144
|
+
* @returns {number} how many drafts are waiting
|
|
145
|
+
*/
|
|
146
|
+
export function refreshPendingBar(root, state, headSha) {
|
|
147
|
+
const count = pendingCount(state)
|
|
148
|
+
const host = root.querySelector('.pending-bar-host')
|
|
149
|
+
if (host !== null) {
|
|
150
|
+
const earlier = headSha === undefined ? [] : state.pending.filter(p => p.headSha !== headSha)
|
|
151
|
+
const signature = JSON.stringify([count, earlier])
|
|
152
|
+
if (host.getAttribute('data-pending-state') !== signature) {
|
|
153
|
+
const open = host.querySelector('details')?.open ?? false
|
|
154
|
+
host.innerHTML = pendingBarHtml(count, earlier)
|
|
155
|
+
host.setAttribute('data-pending-state', signature)
|
|
156
|
+
if (open) host.querySelector('details')?.setAttribute('open', '')
|
|
157
|
+
}
|
|
158
|
+
host.classList.toggle('has-pending', count > 0)
|
|
159
|
+
}
|
|
160
|
+
return count
|
|
161
|
+
}
|
package/static/js/points.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
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'
|
|
9
|
+
import { postToLabel } from './host.js'
|
|
8
10
|
import { layerAnchorId, pointAnchorId } from './keys.js'
|
|
9
11
|
import { renderMarkdown } from './markdown.js'
|
|
10
12
|
|
|
@@ -64,11 +66,33 @@ export function pointContext(p) {
|
|
|
64
66
|
return { kind: 'point', fingerprint: p.fingerprint }
|
|
65
67
|
}
|
|
66
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
|
+
|
|
67
90
|
/**
|
|
68
91
|
* The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
|
|
69
|
-
* 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.
|
|
70
94
|
* @param {Point} p
|
|
71
|
-
* @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
|
|
95
|
+
* @param {{ dismissed?: boolean, postedUrl?: string | undefined, queued?: boolean }} [opts]
|
|
72
96
|
* @returns {string}
|
|
73
97
|
*/
|
|
74
98
|
export function pointCommandsHtml(p, opts = {}) {
|
|
@@ -77,17 +101,24 @@ export function pointCommandsHtml(p, opts = {}) {
|
|
|
77
101
|
? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
|
|
78
102
|
: `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
|
|
79
103
|
return (
|
|
80
|
-
|
|
104
|
+
`<span class="tbtns" data-queued="${opts.queued === true ? '1' : '0'}">` +
|
|
81
105
|
`<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
|
|
82
|
-
(opts
|
|
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)) +
|
|
106
|
+
pointSendHtml(p, opts) +
|
|
85
107
|
askButtonHtml(pointContext(p)) +
|
|
86
108
|
toggle +
|
|
87
109
|
'</span>'
|
|
88
110
|
)
|
|
89
111
|
}
|
|
90
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
|
+
|
|
91
122
|
/**
|
|
92
123
|
* The comment this point was posted as, when it was.
|
|
93
124
|
* @param {Point} p
|
|
@@ -129,7 +160,7 @@ export function pointCardHtml(p, ctx) {
|
|
|
129
160
|
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
130
161
|
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
131
162
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
132
|
-
`${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
|
|
163
|
+
`${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</div></li>`
|
|
133
164
|
)
|
|
134
165
|
}
|
|
135
166
|
|
|
@@ -153,7 +184,7 @@ export function dismissedListHtml(points, state, ctx, expanded = false) {
|
|
|
153
184
|
`<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
|
|
154
185
|
`<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
|
|
155
186
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
156
|
-
`${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
|
|
187
|
+
`${pointCommandsHtml(p, { dismissed: true, postedUrl: posted, queued: queuedFor(p, { state }) })}</div></li>`
|
|
157
188
|
)
|
|
158
189
|
})
|
|
159
190
|
.join('')
|
|
@@ -182,6 +213,7 @@ export function applyDismissed(root, points, state, ctx) {
|
|
|
182
213
|
}
|
|
183
214
|
const dismissed = state.dismissed[point.fingerprint] !== undefined
|
|
184
215
|
el.toggleAttribute('hidden', dismissed)
|
|
216
|
+
refreshPointCommands(el, point, { dismissed, state, ...ctx })
|
|
185
217
|
}
|
|
186
218
|
for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
|
|
187
219
|
const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
|
|
@@ -211,6 +243,35 @@ export function applyDismissed(root, points, state, ctx) {
|
|
|
211
243
|
}
|
|
212
244
|
}
|
|
213
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
|
+
|
|
214
275
|
/**
|
|
215
276
|
* The inline row under a diff line.
|
|
216
277
|
* @param {Point} p
|
|
@@ -223,7 +284,7 @@ export function pointRowHtml(p, ctx) {
|
|
|
223
284
|
`<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
|
|
224
285
|
`<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
|
|
225
286
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
226
|
-
`${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
|
|
287
|
+
`${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</td></tr>`
|
|
227
288
|
)
|
|
228
289
|
}
|
|
229
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,17 +29,22 @@ 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
|
|
|
25
39
|
/**
|
|
26
40
|
* @typedef {{
|
|
27
|
-
* prNumber:
|
|
41
|
+
* prNumber: import('./contract-types.js').ReviewKey,
|
|
28
42
|
* artifact: ReviewArtifact,
|
|
29
43
|
* files: ReadonlyArray<FileEntry>,
|
|
30
44
|
* state: PrState,
|
|
31
45
|
* capabilities: Capabilities,
|
|
32
46
|
* headSha: string,
|
|
47
|
+
* canvasSha?: string,
|
|
33
48
|
* api?: Partial<SessionApi>,
|
|
34
49
|
* onState?: (state: PrState) => void,
|
|
35
50
|
* }} SessionOptions
|
|
@@ -37,22 +52,29 @@ import { sanitizeKey } from './keys.js'
|
|
|
37
52
|
|
|
38
53
|
/** @param {Partial<SessionApi> | undefined} overrides */
|
|
39
54
|
function withDefaults(overrides) {
|
|
40
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
putReviewed,
|
|
57
|
+
putDismissed,
|
|
58
|
+
putThreadHidden,
|
|
59
|
+
postComment,
|
|
60
|
+
postReview,
|
|
61
|
+
addPending,
|
|
62
|
+
editPending,
|
|
63
|
+
deletePending,
|
|
64
|
+
discardPending,
|
|
65
|
+
...overrides,
|
|
66
|
+
}
|
|
41
67
|
}
|
|
42
68
|
|
|
43
|
-
|
|
44
|
-
* @param {string} layerId
|
|
45
|
-
* @param {string} [path]
|
|
46
|
-
*/
|
|
47
|
-
export function reviewedId(layerId, path) {
|
|
48
|
-
return path === undefined ? `layer:${layerId}` : `layer:${layerId}/file:${sanitizeKey(path)}`
|
|
49
|
-
}
|
|
69
|
+
export { reviewedId } from './keys.js'
|
|
50
70
|
|
|
51
71
|
/** @param {SessionOptions} options */
|
|
52
72
|
export function createReviewSession(options) {
|
|
53
73
|
const api = withDefaults(options.api)
|
|
54
74
|
/** @type {PrState} */
|
|
55
75
|
let state = options.state
|
|
76
|
+
/** @type {ReviewComment[]} */
|
|
77
|
+
let submittedComments = []
|
|
56
78
|
/** @type {Capabilities} */
|
|
57
79
|
let capabilities = options.capabilities
|
|
58
80
|
const keyToPath = new Map(options.files.map(f => [f.key, f.path]))
|
|
@@ -130,6 +152,28 @@ export function createReviewSession(options) {
|
|
|
130
152
|
}
|
|
131
153
|
}
|
|
132
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
|
+
|
|
133
177
|
/**
|
|
134
178
|
* @param {PrState} current
|
|
135
179
|
* @param {'reviewed' | 'dismissed' | 'hiddenThreads'} field
|
|
@@ -193,7 +237,11 @@ export function createReviewSession(options) {
|
|
|
193
237
|
return change(
|
|
194
238
|
current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
|
|
195
239
|
current => withEntry(current, 'reviewed', id, before),
|
|
196
|
-
() =>
|
|
240
|
+
() =>
|
|
241
|
+
api.putReviewed(options.prNumber, id, reviewed, {
|
|
242
|
+
headSha: options.headSha,
|
|
243
|
+
...(options.canvasSha === undefined ? {} : { canvasSha: options.canvasSha }),
|
|
244
|
+
})
|
|
197
245
|
)
|
|
198
246
|
},
|
|
199
247
|
/**
|
|
@@ -229,27 +277,63 @@ export function createReviewSession(options) {
|
|
|
229
277
|
* @returns {Promise<PostCommentResponse>}
|
|
230
278
|
*/
|
|
231
279
|
async postComment(input) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
settle()
|
|
242
|
-
throw err
|
|
243
|
-
}
|
|
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 ?? []
|
|
244
289
|
},
|
|
245
290
|
/**
|
|
246
|
-
*
|
|
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
|
|
247
322
|
* @param {string} [body]
|
|
248
|
-
* @
|
|
323
|
+
* @param {{ includePending?: boolean }} [opts]
|
|
324
|
+
* @returns {Promise<import('./contract-types.js').PostReviewResponse>}
|
|
249
325
|
*/
|
|
250
|
-
async postReview(event, body) {
|
|
251
|
-
const input =
|
|
252
|
-
|
|
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
|
+
})
|
|
253
337
|
},
|
|
254
338
|
}
|
|
255
339
|
}
|