@vintasoftware/pr-review-canvas 0.3.0 → 0.4.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 +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- 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 +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- 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 +4 -19
- 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 +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -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/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
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,36 @@ 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
|
+
|
|
144
211
|
/**
|
|
145
212
|
* The `stale` screen: the same card as the empty state, plus the two ways forward.
|
|
146
213
|
* @param {PrBundle} bundle
|
|
@@ -148,16 +215,16 @@ export function staleBarHtml(stale) {
|
|
|
148
215
|
*/
|
|
149
216
|
export function renderStaleState(bundle) {
|
|
150
217
|
const stale = bundle.stale
|
|
218
|
+
const transfer = bundle.local ? '' : dropZoneHtml() + sharedCanvasCalloutHtml(bundle)
|
|
151
219
|
return (
|
|
152
220
|
'<section class="panel empty" id="empty-state" aria-labelledby="es-h">' +
|
|
153
221
|
'<div class="panel-h"><h2 id="es-h">Canvas is outdated</h2></div>' +
|
|
154
222
|
'<div class="body center">' +
|
|
155
|
-
`<p class="hint">${stale ? esc(staleSummary(stale)) : ''}</p>` +
|
|
223
|
+
`<p class="hint">${stale ? esc(staleSummary(stale, bundle.local)) : ''}</p>` +
|
|
156
224
|
'<div class="cmdbox"><button class="cmd fill" type="button" id="view-stale">view stale canvas</button>' +
|
|
157
225
|
'<button class="cmd" type="button" id="stale-generate" aria-haspopup="dialog">generate for current head</button></div>' +
|
|
158
226
|
skillCommandHtml(bundle) +
|
|
159
|
-
|
|
160
|
-
sharedCanvasCalloutHtml(bundle) +
|
|
227
|
+
transfer +
|
|
161
228
|
'</div></section>'
|
|
162
229
|
)
|
|
163
230
|
}
|
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',
|
package/static/js/header.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/** @typedef {import('./contract-types.js').RiskTag} RiskTag */
|
|
4
4
|
import { setDisabledReason } from './composer.js'
|
|
5
5
|
import { esc, timeAgo } from './dom.js'
|
|
6
|
+
import { authorProfileUrl, currentHost, hostLabel } from './host.js'
|
|
6
7
|
import { refreshRail } from './layers.js'
|
|
7
8
|
import { progressSummary } from './progress.js'
|
|
8
9
|
import { approveBlockedReason } from './signoff.js'
|
|
@@ -41,6 +42,19 @@ export function largePrNoticeHtml(bundle) {
|
|
|
41
42
|
)
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The author, linked to their page on the forge. A local review's author is the name this clone
|
|
47
|
+
* commits as, which is no forge login, so it stays plain text.
|
|
48
|
+
* @param {string} author
|
|
49
|
+
* @param {boolean} local
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function authorHtml(author, local) {
|
|
53
|
+
return local
|
|
54
|
+
? esc(author)
|
|
55
|
+
: `<a href="${esc(authorProfileUrl(author))}" target="_blank" rel="noopener noreferrer">${esc(author)}</a>`
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/** @param {PrBundle['pr']} pr */
|
|
45
59
|
export function statePill(pr) {
|
|
46
60
|
const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state
|
|
@@ -61,15 +75,24 @@ export function renderHeader(bundle, opts) {
|
|
|
61
75
|
? `<span class="pill agent">${esc(artifact.generator.agent)}${artifact.generator.model ? ` · ${esc(artifact.generator.model)}` : ''} · ${esc(artifact.generator.harness)}</span><span>generated ${esc(timeAgo(artifact.generatedAt, opts.now))}</span>`
|
|
62
76
|
: ''
|
|
63
77
|
const number = pr.number === null ? '' : `<span class="mono num">#${pr.number}</span>`
|
|
78
|
+
// Work that is not pushed has no page on the forge, and nothing to refresh from it either.
|
|
79
|
+
const local = bundle.local !== undefined
|
|
80
|
+
const forgeLink =
|
|
81
|
+
pr.url === ''
|
|
82
|
+
? ''
|
|
83
|
+
: `<a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">${esc(currentHost().kind)}</a>`
|
|
84
|
+
const refreshTitle = local
|
|
85
|
+
? 'Snapshot the working tree again and redraw'
|
|
86
|
+
: `Fetch the latest PR, comments, and shared canvas from ${esc(hostLabel())}`
|
|
64
87
|
const progress = ready ? progressHtml(artifact, bundle.state) : ''
|
|
65
88
|
const risk = ready ? riskLineHtml(artifact.risk) : ''
|
|
66
89
|
return (
|
|
67
90
|
'<header class="hdr">' +
|
|
68
91
|
`<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
|
|
69
92
|
'<div class="hdr-actions" role="group" aria-label="Canvas actions">' +
|
|
70
|
-
`<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for this PR" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
|
|
71
|
-
`<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on
|
|
72
|
-
|
|
93
|
+
`<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
|
+
`<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
|
+
`<button class="cmd" type="button" id="refresh" title="${refreshTitle}">refresh</button>` +
|
|
73
96
|
`<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog"${bundle.chat.enabled || bundle.chat.acpx ? ' title="Configure the AI chat agent, model, and limits"' : ' disabled title="acpx is not installed"'}>settings</button>` +
|
|
74
97
|
'<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
|
|
75
98
|
`<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
|
|
@@ -77,8 +100,8 @@ export function renderHeader(bundle, opts) {
|
|
|
77
100
|
'</div></div>' +
|
|
78
101
|
'<div class="stripe" aria-hidden="true"></div>' +
|
|
79
102
|
'<div class="hdr-title">' +
|
|
80
|
-
`<div class="title"><h1>${number}${esc(pr.title)}</h1
|
|
81
|
-
`<p class="meta"><span>by
|
|
103
|
+
`<div class="title"><h1>${number}${esc(pr.title)}</h1>${forgeLink}</div>` +
|
|
104
|
+
`<p class="meta"><span>by ${authorHtml(pr.author, local)}</span>` +
|
|
82
105
|
`<span class="mono">${esc(pr.headRef)} → ${esc(pr.baseRef)}</span>${statePill(pr)}` +
|
|
83
106
|
`<span class="diffstat"><span class="ok">+${pr.additions}</span> <span class="bad">−${pr.deletions}</span></span>${agent}</p>` +
|
|
84
107
|
`${largePrNoticeHtml(bundle)}${risk}${progress}</div></header>`
|
|
@@ -95,13 +118,13 @@ export function progressHtml(artifact, state) {
|
|
|
95
118
|
const p = progressSummary(artifact, state)
|
|
96
119
|
const blocked = approveBlockedReason(artifact, state)
|
|
97
120
|
const approve =
|
|
98
|
-
`<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on
|
|
99
|
-
`${blocked === null ?
|
|
121
|
+
`<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on ${esc(hostLabel())}" data-act="signoff" data-event="APPROVE" data-needs-post` +
|
|
122
|
+
`${blocked === null ? ` title="Write and preview an approving review on ${esc(hostLabel())}"` : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on ${esc(currentHost().kind)}</button>`
|
|
100
123
|
return (
|
|
101
124
|
`<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>` +
|
|
102
125
|
`<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
|
|
103
126
|
`<div class="signoff">${approve}` +
|
|
104
|
-
|
|
127
|
+
`<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>` +
|
|
105
128
|
'<span class="capability-note" role="status"></span></div>'
|
|
106
129
|
)
|
|
107
130
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The forge this server talks to, as the page shell's bootstrap names it. One server serves one
|
|
3
|
+
// host, so the words and links that depend on it are read from here rather than threaded through
|
|
4
|
+
// every renderer.
|
|
5
|
+
/** @typedef {import('./contract-types.js').PublicHost} PublicHost */
|
|
6
|
+
|
|
7
|
+
/** @type {PublicHost} */
|
|
8
|
+
const DEFAULT_HOST = { kind: 'github', label: 'GitHub', webBase: 'https://github.com' }
|
|
9
|
+
|
|
10
|
+
/** @type {PublicHost} */
|
|
11
|
+
let current = DEFAULT_HOST
|
|
12
|
+
|
|
13
|
+
/** @param {PublicHost | undefined} host */
|
|
14
|
+
export function setHost(host) {
|
|
15
|
+
current = host ?? DEFAULT_HOST
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentHost() {
|
|
19
|
+
return current
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `GitHub` or `GitLab`, for sentences. */
|
|
23
|
+
export function hostLabel() {
|
|
24
|
+
return current.label
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `post to github` / `post to gitlab`, the command label. */
|
|
28
|
+
export function postToLabel() {
|
|
29
|
+
return `post to ${current.kind}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a command says when this login may not post. */
|
|
33
|
+
export function noPostingTitle() {
|
|
34
|
+
return `this ${current.label} login cannot post on this repository`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {string} author */
|
|
38
|
+
export function authorProfileUrl(author) {
|
|
39
|
+
return `${current.webBase}/${author}`
|
|
40
|
+
}
|
package/static/js/import-zone.js
CHANGED
|
@@ -56,7 +56,7 @@ export function wireDropZone(root, opts) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
const send = async (/** @type {File} */ file) => {
|
|
59
|
-
const problem = validateCanvasFilename(file.name)
|
|
59
|
+
const problem = validateCanvasFilename(file.name, opts.prNumber)
|
|
60
60
|
if (problem !== null) {
|
|
61
61
|
apply({ type: 'fail', message: problem })
|
|
62
62
|
return
|
|
@@ -616,8 +616,7 @@ export function wireReview(root, session, opts = {}) {
|
|
|
616
616
|
postFromComposer(el, box)
|
|
617
617
|
}
|
|
618
618
|
},
|
|
619
|
-
'markdown-
|
|
620
|
-
'markdown-write': el => toggleMarkdownPreview(el, false),
|
|
619
|
+
'markdown-toggle': el => toggleMarkdownPreview(el),
|
|
621
620
|
'composer-cancel': () => {
|
|
622
621
|
closeComposers(root)
|
|
623
622
|
},
|
package/static/js/layers.js
CHANGED
|
@@ -77,7 +77,7 @@ export function pathSet(files) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Layer title per hunk id, so a file card can say "2 more
|
|
80
|
+
* Layer title per hunk id, so a file card can say "2 more chunks in layer 4".
|
|
81
81
|
* @param {ReviewArtifact} artifact
|
|
82
82
|
* @returns {Map<string, { layer: Layer, index: number }>}
|
|
83
83
|
*/
|
|
@@ -281,7 +281,7 @@ export function renderFileCard(lf, entry, layer, ctx) {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
/**
|
|
284
|
-
* "2 more
|
|
284
|
+
* "2 more chunks in layer 4 · title" when a file's other hunks live in other layers.
|
|
285
285
|
* @param {LayerFile} lf
|
|
286
286
|
* @param {FileEntry | undefined} entry
|
|
287
287
|
* @param {Layer} layer
|
|
@@ -310,7 +310,7 @@ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
|
|
|
310
310
|
}
|
|
311
311
|
const parts = [...others.values()].map(
|
|
312
312
|
o =>
|
|
313
|
-
`${o.n} more ${o.n === 1 ? '
|
|
313
|
+
`${o.n} more ${o.n === 1 ? 'chunk' : 'chunks'} in <a href="#${esc(layerAnchorId(o.layer.key))}">${o.layer.kind === 'other' ? esc(o.layer.title) : `layer ${o.index + 1} · ${esc(o.layer.title)}`}</a>`
|
|
314
314
|
)
|
|
315
315
|
return `<div class="more-hunks">${parts.join(' · ')}</div>`
|
|
316
316
|
}
|
package/static/js/links.js
CHANGED
|
@@ -101,7 +101,7 @@ export function resolveLink(link, targets) {
|
|
|
101
101
|
if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
|
|
102
102
|
return {
|
|
103
103
|
ok: false,
|
|
104
|
-
message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length}
|
|
104
|
+
message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} chunks)`,
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
if (link.kind === 'line') {
|
|
@@ -111,7 +111,7 @@ export function resolveLink(link, targets) {
|
|
|
111
111
|
const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
|
|
112
112
|
return {
|
|
113
113
|
ok: false,
|
|
114
|
-
message: `${link.path}:${range} (${link.side}) is not inside one
|
|
114
|
+
message: `${link.path}:${range} (${link.side}) is not inside one chunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
@@ -149,7 +149,7 @@ export function linkLabel(link) {
|
|
|
149
149
|
case 'file':
|
|
150
150
|
return link.path
|
|
151
151
|
case 'hunk':
|
|
152
|
-
return `${link.path}
|
|
152
|
+
return `${link.path} chunk ${link.n}`
|
|
153
153
|
case 'line':
|
|
154
154
|
return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
|
|
155
155
|
}
|
package/static/js/markdown.js
CHANGED
|
@@ -147,6 +147,28 @@ function decorateLinks(root) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* GitHub images can require a signed-in browser session. Link to them so the browser
|
|
152
|
+
* can open them on their own origin, where that session is available.
|
|
153
|
+
* @param {HTMLImageElement} img
|
|
154
|
+
* @param {string} src
|
|
155
|
+
*/
|
|
156
|
+
function linkGitHubImage(img, src) {
|
|
157
|
+
const link = document.createElement('a')
|
|
158
|
+
link.href = src
|
|
159
|
+
const alt = img.alt.trim()
|
|
160
|
+
link.textContent = alt ? `View image on GitHub: ${alt}` : 'View image on GitHub'
|
|
161
|
+
if (img.title) link.title = img.title
|
|
162
|
+
const enclosingLink = img.closest('a')
|
|
163
|
+
if (enclosingLink) {
|
|
164
|
+
// Keep the original destination without creating nested links.
|
|
165
|
+
img.replaceWith(document.createTextNode(alt || 'Image'))
|
|
166
|
+
enclosingLink.after(document.createTextNode(' '), link)
|
|
167
|
+
} else {
|
|
168
|
+
img.replaceWith(link)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
/**
|
|
151
173
|
* One markdown text, without mermaid blocks.
|
|
152
174
|
* @param {string} src markdown
|
|
@@ -172,8 +194,13 @@ function renderProse(src, opts) {
|
|
|
172
194
|
if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
|
|
173
195
|
}
|
|
174
196
|
for (const img of root.querySelectorAll('img')) {
|
|
175
|
-
|
|
197
|
+
const imageSrc = img.getAttribute('src') ?? ''
|
|
198
|
+
if (!/^https:\/\//i.test(imageSrc)) {
|
|
176
199
|
img.remove()
|
|
200
|
+
} else if (
|
|
201
|
+
/^https:\/\/(?:github\.com|(?:[a-z0-9-]+\.)*githubusercontent\.com)(?::443)?\//i.test(imageSrc)
|
|
202
|
+
) {
|
|
203
|
+
linkGitHubImage(img, imageSrc)
|
|
177
204
|
} else {
|
|
178
205
|
img.loading = 'lazy'
|
|
179
206
|
img.referrerPolicy = 'no-referrer'
|
package/static/js/points.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { askButtonHtml } from './ask.js'
|
|
6
6
|
import { viewCommentHtml } from './comment-link.js'
|
|
7
7
|
import { esc } from './dom.js'
|
|
8
|
+
import { postToLabel } from './host.js'
|
|
8
9
|
import { layerAnchorId, pointAnchorId } from './keys.js'
|
|
9
10
|
import { renderMarkdown } from './markdown.js'
|
|
10
11
|
|
|
@@ -80,7 +81,7 @@ export function pointCommandsHtml(p, opts = {}) {
|
|
|
80
81
|
'<span class="tbtns">' +
|
|
81
82
|
`<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
|
|
82
83
|
(opts.postedUrl === undefined
|
|
83
|
-
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post
|
|
84
|
+
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>`
|
|
84
85
|
: viewCommentHtml(opts.postedUrl)) +
|
|
85
86
|
askButtonHtml(pointContext(p)) +
|
|
86
87
|
toggle +
|
|
@@ -24,12 +24,13 @@ import { sanitizeKey } from './keys.js'
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @typedef {{
|
|
27
|
-
* prNumber:
|
|
27
|
+
* prNumber: import('./contract-types.js').ReviewKey,
|
|
28
28
|
* artifact: ReviewArtifact,
|
|
29
29
|
* files: ReadonlyArray<FileEntry>,
|
|
30
30
|
* state: PrState,
|
|
31
31
|
* capabilities: Capabilities,
|
|
32
32
|
* headSha: string,
|
|
33
|
+
* canvasSha?: string,
|
|
33
34
|
* api?: Partial<SessionApi>,
|
|
34
35
|
* onState?: (state: PrState) => void,
|
|
35
36
|
* }} SessionOptions
|
|
@@ -193,7 +194,11 @@ export function createReviewSession(options) {
|
|
|
193
194
|
return change(
|
|
194
195
|
current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
|
|
195
196
|
current => withEntry(current, 'reviewed', id, before),
|
|
196
|
-
() =>
|
|
197
|
+
() =>
|
|
198
|
+
api.putReviewed(options.prNumber, id, reviewed, {
|
|
199
|
+
headSha: options.headSha,
|
|
200
|
+
...(options.canvasSha === undefined ? {} : { canvasSha: options.canvasSha }),
|
|
201
|
+
})
|
|
197
202
|
)
|
|
198
203
|
},
|
|
199
204
|
/**
|
package/static/js/settings.js
CHANGED
|
@@ -65,12 +65,13 @@ export function settingsDialogHtml(data, agents) {
|
|
|
65
65
|
`<p class="muted small mono">${esc(data.file)}</p>` +
|
|
66
66
|
'<div class="panel-ro"><h3>Project config (read-only)</h3>' +
|
|
67
67
|
`<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
|
|
68
|
+
`<li>canvas kept for an identical diff: ${project.keepForIdenticalDiff ? 'yes' : 'no'}</li>` +
|
|
68
69
|
`<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
|
|
69
70
|
`<li>configured layer suggestions: ${project.layers}</li>` +
|
|
70
71
|
`<li>high-risk patterns: ${project.highRisk}</li>` +
|
|
71
72
|
`<li>max repair rounds: ${project.maxRepairRounds}</li>` +
|
|
72
73
|
`<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
|
|
73
|
-
`<li>small change set: ${project.smallPrHunks}
|
|
74
|
+
`<li>small change set: ${project.smallPrHunks} chunks</li></ul>` +
|
|
74
75
|
`<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
|
|
75
76
|
'<p class="probe-result" role="status"></p>' +
|
|
76
77
|
'<div class="dialog-actions">' +
|
package/static/js/signoff.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
5
5
|
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
6
6
|
/** @typedef {import('./contract-types.js').ReviewBodyResponse} ReviewBodyResponse */
|
|
7
|
-
import { previewControlsHtml, setDisabledReason } from './composer.js'
|
|
7
|
+
import { previewControlsHtml, setDisabledReason, setMarkdownPreview } from './composer.js'
|
|
8
8
|
import { esc } from './dom.js'
|
|
9
|
+
import { hostLabel } from './host.js'
|
|
9
10
|
import { layerProgress } from './progress.js'
|
|
10
11
|
|
|
11
12
|
export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
|
|
@@ -35,7 +36,7 @@ export function approveBlockedReason(artifact, state) {
|
|
|
35
36
|
* @param {'APPROVE' | 'REQUEST_CHANGES'} event
|
|
36
37
|
*/
|
|
37
38
|
export function signoffTitle(event) {
|
|
38
|
-
return event === 'APPROVE' ?
|
|
39
|
+
return event === 'APPROVE' ? `Approve on ${hostLabel()}` : `Request changes on ${hostLabel()}`
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -46,7 +47,7 @@ export function signoffDialogHtml(opts) {
|
|
|
46
47
|
return (
|
|
47
48
|
`<dialog id="${SIGNOFF_DIALOG_ID}" class="signoff-dialog" aria-labelledby="signoff-h">` +
|
|
48
49
|
`<h2 id="signoff-h">${esc(signoffTitle(opts.event))}</h2>` +
|
|
49
|
-
|
|
50
|
+
`<p class="hint">This posts a review on ${esc(hostLabel())} as you. Edit the body first if you want to.</p>` +
|
|
50
51
|
'<p class="signoff-target muted mono"></p>' +
|
|
51
52
|
'<label class="sr" for="signoff-body">Review body</label>' +
|
|
52
53
|
previewControlsHtml() +
|
|
@@ -77,9 +78,7 @@ export function openSignoffDialog(root, opts) {
|
|
|
77
78
|
if (!(dialog instanceof HTMLDialogElement)) {
|
|
78
79
|
throw new Error('sign-off dialog did not render')
|
|
79
80
|
}
|
|
80
|
-
dialog
|
|
81
|
-
.querySelector('[data-act="markdown-write"]')
|
|
82
|
-
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
81
|
+
setMarkdownPreview(dialog, false)
|
|
83
82
|
dialog.setAttribute('data-event', opts.event)
|
|
84
83
|
const heading = dialog.querySelector('#signoff-h')
|
|
85
84
|
if (heading !== null) {
|
|
@@ -133,7 +132,7 @@ export function showSignoffResult(dialog, review) {
|
|
|
133
132
|
const result = dialog.querySelector('.signoff-result')
|
|
134
133
|
if (result !== null) {
|
|
135
134
|
result.textContent = 'Posted · '
|
|
136
|
-
result.append(externalLink(review.url,
|
|
135
|
+
result.append(externalLink(review.url, `see it on ${hostLabel()}`))
|
|
137
136
|
}
|
|
138
137
|
return result
|
|
139
138
|
}
|
|
@@ -116,6 +116,12 @@
|
|
|
116
116
|
color: var(--purple);
|
|
117
117
|
border-color: currentColor;
|
|
118
118
|
}
|
|
119
|
+
/* The two local reviews: work with no pull request, so neither open nor closed. */
|
|
120
|
+
.pill.branch,
|
|
121
|
+
.pill.uncommitted {
|
|
122
|
+
color: var(--accent);
|
|
123
|
+
border-color: currentColor;
|
|
124
|
+
}
|
|
119
125
|
.pill.agent {
|
|
120
126
|
color: var(--fg);
|
|
121
127
|
border-color: var(--ink);
|
package/static/styles/panels.css
CHANGED
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
box-shadow: 2px 2px 0 var(--shadow);
|
|
89
89
|
font-size: 13px;
|
|
90
90
|
}
|
|
91
|
+
/* The same bar for a canvas that still applies: a note, not a warning. */
|
|
92
|
+
.stale-bar.carried-over-bar {
|
|
93
|
+
background: var(--bg);
|
|
94
|
+
}
|
|
91
95
|
.notice {
|
|
92
96
|
margin: 10px 0 0;
|
|
93
97
|
padding: 6px 10px;
|