@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// How aggressively the canvas hides code. One implementation for the server (re-exported by
|
|
3
|
+
// src/contract/review-artifact.ts) and the browser, so the validator and the page agree on what
|
|
4
|
+
// a level hides.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The levels, from the least hiding to the most. They nest: a fold hides at its own level and at
|
|
8
|
+
* every level above it, so `light` folds are hidden in all three modes. The page opens at the
|
|
9
|
+
* level saved in the settings file, `light` until the reader picks another, and a change from
|
|
10
|
+
* the control holds for the session only.
|
|
11
|
+
*/
|
|
12
|
+
export const FOLD_LEVELS = /** @type {const} */ (['light', 'moderate', 'aggressive'])
|
|
13
|
+
|
|
14
|
+
/** @typedef {(typeof FOLD_LEVELS)[number]} FoldLevel */
|
|
15
|
+
|
|
16
|
+
/** The level a review opens at until the reader saves another, and the level of an older canvas's folds. */
|
|
17
|
+
export const DEFAULT_FOLD_LEVEL = 'light'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {value is FoldLevel}
|
|
22
|
+
*/
|
|
23
|
+
export function isFoldLevel(value) {
|
|
24
|
+
return FOLD_LEVELS.some(level => level === value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a level sits in the order, so two levels can be compared.
|
|
29
|
+
* @param {FoldLevel} level
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
export function foldLevelRank(level) {
|
|
33
|
+
return FOLD_LEVELS.indexOf(level)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether code marked with `level` is hidden while the reader is at `current`.
|
|
38
|
+
* @param {FoldLevel} level
|
|
39
|
+
* @param {FoldLevel} current
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function hidesAt(level, current) {
|
|
43
|
+
return foldLevelRank(level) <= foldLevelRank(current)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The level after `level`, back to the first after the last, so one key can step through them.
|
|
48
|
+
* @param {FoldLevel} level
|
|
49
|
+
* @returns {FoldLevel}
|
|
50
|
+
*/
|
|
51
|
+
export function nextFoldLevel(level) {
|
|
52
|
+
return FOLD_LEVELS[(foldLevelRank(level) + 1) % FOLD_LEVELS.length] ?? DEFAULT_FOLD_LEVEL
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {{ side: 'new' | 'old', startLine: number, endLine: number }} Span
|
|
57
|
+
* @typedef {Span & { level: FoldLevel }} Range
|
|
58
|
+
* @typedef {{
|
|
59
|
+
* collapsed?: FoldLevel | undefined,
|
|
60
|
+
* annotations: readonly Span[],
|
|
61
|
+
* folds?: readonly Range[] | undefined,
|
|
62
|
+
* hunks: readonly string[],
|
|
63
|
+
* }} FoldedFile
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether `inner` sits wholly inside `outer` without being the same range. The validator allows
|
|
68
|
+
* this only when the inner fold has the lower level; the page then draws the outer one alone.
|
|
69
|
+
* @param {Span} outer
|
|
70
|
+
* @param {Span} inner
|
|
71
|
+
*/
|
|
72
|
+
export function contains(outer, inner) {
|
|
73
|
+
return (
|
|
74
|
+
outer !== inner &&
|
|
75
|
+
outer.side === inner.side &&
|
|
76
|
+
outer.startLine <= inner.startLine &&
|
|
77
|
+
inner.endLine <= outer.endLine &&
|
|
78
|
+
(outer.startLine < inner.startLine || inner.endLine < outer.endLine)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The folds that hide at `level`, with every fold nested inside another kept fold dropped. Only
|
|
84
|
+
* the outermost fold is drawn, so raising the level replaces a set of small titles with one.
|
|
85
|
+
* @template {Range} T
|
|
86
|
+
* @param {readonly T[]} folds
|
|
87
|
+
* @param {FoldLevel} level
|
|
88
|
+
* @returns {T[]}
|
|
89
|
+
*/
|
|
90
|
+
export function foldsForLevel(folds, level) {
|
|
91
|
+
const applicable = folds.filter(fold => hidesAt(fold.level, level))
|
|
92
|
+
return applicable.filter(fold => !applicable.some(other => contains(other, fold)))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a file's whole body starts hidden at this level. An annotated file never collapses: a
|
|
97
|
+
* fold over an annotation still shows the annotation's text, a collapsed file shows only its path.
|
|
98
|
+
* @param {FoldedFile} file
|
|
99
|
+
* @param {FoldLevel} level
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function collapsesAt(file, level) {
|
|
103
|
+
return file.collapsed !== undefined && hidesAt(file.collapsed, level) && file.annotations.length === 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rows a set of ranges covers, each row counted once where ranges nest or touch. The one count
|
|
108
|
+
* the page's counters and the validator's thresholds share.
|
|
109
|
+
* @param {readonly Span[]} ranges
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
export function coveredRows(ranges) {
|
|
113
|
+
let total = 0
|
|
114
|
+
for (const side of /** @type {const} */ (['new', 'old'])) {
|
|
115
|
+
const sorted = ranges
|
|
116
|
+
.filter(range => range.side === side)
|
|
117
|
+
.map(range => ({ start: range.startLine, end: range.endLine }))
|
|
118
|
+
.sort((a, b) => a.start - b.start)
|
|
119
|
+
/** @type {{ start: number, end: number } | null} */
|
|
120
|
+
let current = null
|
|
121
|
+
for (const range of sorted) {
|
|
122
|
+
if (current !== null && range.start <= current.end + 1) {
|
|
123
|
+
current.end = Math.max(current.end, range.end)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
total += current === null ? 0 : current.end - current.start + 1
|
|
127
|
+
current = { ...range }
|
|
128
|
+
}
|
|
129
|
+
total += current === null ? 0 : current.end - current.start + 1
|
|
130
|
+
}
|
|
131
|
+
return total
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Rows a file's hunks draw, near enough for a counter: context lines are shared by both sides,
|
|
136
|
+
* so the longer side is the row count when nothing else is known about the patch.
|
|
137
|
+
* @param {FoldedFile} file
|
|
138
|
+
* @param {ReadonlyArray<{ id: string, oldLines: number, newLines: number }>} hunks every hunk of the file
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
export function fileRows(file, hunks) {
|
|
142
|
+
return hunks
|
|
143
|
+
.filter(hunk => file.hunks.includes(hunk.id))
|
|
144
|
+
.reduce((sum, hunk) => sum + Math.max(hunk.oldLines, hunk.newLines), 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the review's discussion keeps open in one file card: `keepsOpen` when a comment or an
|
|
149
|
+
* attention point stops the card collapsing, `discussed` when a thread or a pending draft in its
|
|
150
|
+
* chunks stops every fold. The page decides both once per card, and the card and its counter read
|
|
151
|
+
* the answer.
|
|
152
|
+
* @typedef {{ keepsOpen: boolean, discussed: boolean }} Discussion
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
/** A file nobody has discussed yet, as the validator sees every file of a fresh generation. */
|
|
156
|
+
export const UNDISCUSSED = /** @type {Discussion} */ ({ keepsOpen: false, discussed: false })
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How many diff lines a file shows, and how many of them `level` hides. Measured from the model,
|
|
160
|
+
* so the counter reads the same before and after a card draws its diff.
|
|
161
|
+
* @param {FoldedFile} file
|
|
162
|
+
* @param {ReadonlyArray<{ id: string, oldLines: number, newLines: number }>} hunks every hunk of the file
|
|
163
|
+
* @param {FoldLevel} level
|
|
164
|
+
* @param {Discussion} discussion
|
|
165
|
+
* @returns {{ total: number, hidden: number }}
|
|
166
|
+
*/
|
|
167
|
+
export function hiddenLines(file, hunks, level, discussion) {
|
|
168
|
+
const total = fileRows(file, hunks)
|
|
169
|
+
if (collapsesAt(file, level) && !discussion.keepsOpen) {
|
|
170
|
+
return { total, hidden: total }
|
|
171
|
+
}
|
|
172
|
+
if (discussion.discussed) {
|
|
173
|
+
return { total, hidden: 0 }
|
|
174
|
+
}
|
|
175
|
+
return { total, hidden: coveredRows(foldsForLevel(file.folds ?? [], level)) }
|
|
176
|
+
}
|
package/static/js/header.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
import { setDisabledReason } from './composer.js'
|
|
5
5
|
import { esc, timeAgo } from './dom.js'
|
|
6
6
|
import { authorProfileUrl, currentHost, hostLabel } from './host.js'
|
|
7
|
-
import { refreshRail } from './layers.js'
|
|
7
|
+
import { canvasHiddenLines, getFoldLevel, refreshRail } from './layers.js'
|
|
8
|
+
import { pendingBarHtml, pendingCount } from './pending.js'
|
|
8
9
|
import { progressSummary } from './progress.js'
|
|
10
|
+
import { foldLevelControlHtml } from './reading-level.js'
|
|
9
11
|
import { approveBlockedReason } from './signoff.js'
|
|
10
12
|
import { skinLabel } from './skin.js'
|
|
11
13
|
import { themeLabel } from './theme.js'
|
|
@@ -84,7 +86,23 @@ export function renderHeader(bundle, opts) {
|
|
|
84
86
|
const refreshTitle = local
|
|
85
87
|
? 'Snapshot the working tree again and redraw'
|
|
86
88
|
: `Fetch the latest PR, comments, and shared canvas from ${esc(hostLabel())}`
|
|
87
|
-
const progress = ready ? progressHtml(artifact, bundle.state) : ''
|
|
89
|
+
const progress = ready ? progressHtml(artifact, bundle.state, pr.headSha) : ''
|
|
90
|
+
const level = getFoldLevel()
|
|
91
|
+
const reading = ready
|
|
92
|
+
? foldLevelControlHtml(
|
|
93
|
+
level,
|
|
94
|
+
canvasHiddenLines(
|
|
95
|
+
{
|
|
96
|
+
artifact,
|
|
97
|
+
files: bundle.files,
|
|
98
|
+
comments: bundle.comments.reviewComments,
|
|
99
|
+
state: bundle.state,
|
|
100
|
+
headSha: pr.headSha,
|
|
101
|
+
},
|
|
102
|
+
level
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
: ''
|
|
88
106
|
const risk = ready ? riskLineHtml(artifact.risk) : ''
|
|
89
107
|
return (
|
|
90
108
|
'<header class="hdr">' +
|
|
@@ -93,7 +111,7 @@ export function renderHeader(bundle, opts) {
|
|
|
93
111
|
`<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for ${local ? 'this local work' : 'this PR'}" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
|
|
94
112
|
`<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on ${esc(hostLabel())}"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
|
|
95
113
|
`<button class="cmd" type="button" id="refresh" title="${refreshTitle}">refresh</button>` +
|
|
96
|
-
|
|
114
|
+
'<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog" title="Configure the default reading level and the AI chat agent, model, and limits">settings</button>' +
|
|
97
115
|
'<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
|
|
98
116
|
`<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
|
|
99
117
|
`<button class="cmd" type="button" id="theme-toggle" title="Switch between Light, Dark, and Auto themes">${esc(themeLabel(opts.theme))}</button>` +
|
|
@@ -104,27 +122,36 @@ export function renderHeader(bundle, opts) {
|
|
|
104
122
|
`<p class="meta"><span>by ${authorHtml(pr.author, local)}</span>` +
|
|
105
123
|
`<span class="mono">${esc(pr.headRef)} → ${esc(pr.baseRef)}</span>${statePill(pr)}` +
|
|
106
124
|
`<span class="diffstat"><span class="ok">+${pr.additions}</span> <span class="bad">−${pr.deletions}</span></span>${agent}</p>` +
|
|
107
|
-
`${largePrNoticeHtml(bundle)}${risk}${progress}</div></header>`
|
|
125
|
+
`${largePrNoticeHtml(bundle)}${risk}${reading}${progress}</div></header>`
|
|
108
126
|
)
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
/**
|
|
112
|
-
* The thin line, its text, and the
|
|
113
|
-
* reason until every layer that is not Other has been marked reviewed
|
|
130
|
+
* The thin line, its text, the pending-review bar, and the three sign-off commands. Approve stays
|
|
131
|
+
* disabled with the reason until every layer that is not Other has been marked reviewed; a
|
|
132
|
+
* comment-only review and a request for changes are always allowed, since neither claims the
|
|
133
|
+
* change set was read in full.
|
|
114
134
|
* @param {import('./contract-types.js').ReviewArtifact} artifact
|
|
115
135
|
* @param {import('./contract-types.js').PrState} state
|
|
116
136
|
*/
|
|
117
|
-
export function progressHtml(artifact, state) {
|
|
137
|
+
export function progressHtml(artifact, state, headSha = artifact.pr.headSha) {
|
|
118
138
|
const p = progressSummary(artifact, state)
|
|
119
139
|
const blocked = approveBlockedReason(artifact, state)
|
|
140
|
+
const approveTitle = `Write and preview an approving review on ${esc(hostLabel())}`
|
|
120
141
|
const approve =
|
|
121
|
-
`<button class="cmd" type="button" id="approve" data-tooltip="
|
|
122
|
-
`${blocked === null ? ` title="
|
|
142
|
+
`<button class="cmd" type="button" id="approve" data-tooltip="${approveTitle}" data-act="signoff" data-event="APPROVE" data-needs-post` +
|
|
143
|
+
`${blocked === null ? ` title="${approveTitle}"` : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on ${esc(currentHost().kind)}</button>`
|
|
144
|
+
const commentTitle = `Write and preview a review with no verdict on ${esc(hostLabel())}`
|
|
123
145
|
return (
|
|
124
146
|
`<div class="progress"><div class="pline" role="progressbar" aria-valuenow="${p.done}" aria-valuemin="0" aria-valuemax="${p.total}" aria-label="Layers reviewed"><span style="width:${p.percent}%"></span></div>` +
|
|
125
147
|
`<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
|
|
148
|
+
`<div class="pending-bar-host${pendingCount(state) > 0 ? ' has-pending' : ''}">${pendingBarHtml(
|
|
149
|
+
pendingCount(state),
|
|
150
|
+
state.pending.filter(draft => draft.headSha !== headSha)
|
|
151
|
+
)}</div>` +
|
|
126
152
|
`<div class="signoff">${approve}` +
|
|
127
153
|
`<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on ${esc(hostLabel())}" title="Write and preview a review requesting changes on ${esc(hostLabel())}" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>` +
|
|
154
|
+
`<button class="cmd" type="button" id="comment-review" data-tooltip="${commentTitle}" title="${commentTitle}" data-act="signoff" data-event="COMMENT" data-needs-post>comment</button>` +
|
|
128
155
|
'<span class="capability-note" role="status"></span></div>'
|
|
129
156
|
)
|
|
130
157
|
}
|