@vintasoftware/pr-review-canvas 0.2.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 +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- 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 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- 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 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -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 +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pr-review-canvas
|
|
3
3
|
model: sonnet
|
|
4
|
-
description: Generate a review canvas for a GitHub pull request
|
|
4
|
+
description: Generate a review canvas for a GitHub pull request or GitLab merge request, for the work in this clone before a pull request exists, or for two refs, with the pr-review tool. Runs `pr-review prepare`, writes the layered model.json the prompt asks for, and runs `pr-review publish` to validate and automatically share it as a compressed PR/MR comment. Use when the user runs `/pr-review-canvas <pr-number>`, `/pr-review-canvas branch`, `/pr-review-canvas uncommitted`, `/pr-review-canvas --base <ref> --head <ref>`, or asks for a review canvas for a PR or MR, for their branch, or for what they have not committed.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# pr-review-canvas
|
|
@@ -11,42 +11,73 @@ points, and hand it to the `pr-review` CLI. The CLI does the deterministic work
|
|
|
11
11
|
validation, storage); you do the reading and the writing of `model.json`. Nothing here checks out
|
|
12
12
|
a branch or writes outside the canvas directory.
|
|
13
13
|
|
|
14
|
-
Arguments: `<pr-number> [--force]`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
repository root.
|
|
14
|
+
Arguments: `<pr-number> [--force]`, `branch [--base <ref>] [--force]`,
|
|
15
|
+
`uncommitted [--base <ref>] [--force]`, or `--base <ref> --head <ref> [--force]`. `--force`
|
|
16
|
+
regenerates a canvas that already exists for the head commit: prepare removes the old `model.json` and any other leftovers from the canvas
|
|
17
|
+
directory, keeping `derived/`, `publish.log` (the attempts history), and the published
|
|
18
|
+
`review.json` + `manifest.json` (the page keeps showing the old canvas until your publish replaces
|
|
19
|
+
it), so you start a fresh `model.json`. Run every `pr-review` command from the repository root.
|
|
20
20
|
|
|
21
21
|
## Flow
|
|
22
22
|
|
|
23
23
|
### Model choice
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
policy, or protected health information (PHI) handling, use
|
|
27
|
-
validation steps when available.
|
|
28
|
-
|
|
25
|
+
Use a mid-tier model, such as Sonnet, by default. If the prepared diff changes authentication,
|
|
26
|
+
access policy, or protected health information (PHI) handling, use a more capable model, such as
|
|
27
|
+
Opus, for the generation and validation steps when available. When delegating to another agent,
|
|
28
|
+
pass it the prepared prompt and context paths; it writes the same model file. Honor an explicit
|
|
29
|
+
user model choice. If the host cannot select models, keep its selected model.
|
|
29
30
|
Record the model that actually generated the canvas when publishing.
|
|
30
31
|
|
|
31
32
|
### 1. Prepare
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
35
|
pr-review prepare --pr <n> [--force]
|
|
35
|
-
# or, before a
|
|
36
|
+
# or, before a pull request exists, one of the two reviews of this clone:
|
|
37
|
+
pr-review prepare --branch [--base <ref>] [--force]
|
|
38
|
+
pr-review prepare --uncommitted [--base <ref>] [--force]
|
|
39
|
+
# or, for any two refs:
|
|
36
40
|
pr-review prepare --base <ref> --head <ref> [--force]
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
The skill word maps to the flag: `/pr-review-canvas branch` runs `prepare --branch`, and
|
|
44
|
+
`/pr-review-canvas uncommitted` runs `prepare --uncommitted`.
|
|
45
|
+
|
|
46
|
+
Both compare against the default branch, which prepare reads from `origin/HEAD` unless `--base`
|
|
47
|
+
names another one. They differ in what the head holds:
|
|
48
|
+
|
|
49
|
+
- `--branch` is the tip of the current branch. Whatever is in the working tree is left out.
|
|
50
|
+
- `--uncommitted` is the working tree itself: prepare snapshots the edits and the untracked files
|
|
51
|
+
into a commit of its own, without touching anything the user has staged. With a clean tree it is
|
|
52
|
+
the branch tip, and the canvas is the same one `--branch` would build.
|
|
53
|
+
|
|
54
|
+
They are separate reviews with separate pages, progress and chat threads, so preparing one leaves
|
|
55
|
+
the other alone. Pick the one the user asked for; when they only say "review my work", ask which,
|
|
56
|
+
unless the words already decide it ("before I commit" is `uncommitted`, "before I open the PR" with
|
|
57
|
+
everything committed is `branch`).
|
|
58
|
+
|
|
59
|
+
The JSON carries an extra `local` object with the review's name, the base that was resolved, the
|
|
60
|
+
branch the head is on, and whether uncommitted work is in it. Tell the user all four: a review of
|
|
61
|
+
the wrong base, or of a tree that has moved, is worth catching early.
|
|
62
|
+
|
|
39
63
|
Progress goes to stderr. The last stdout line is JSON:
|
|
40
64
|
|
|
41
65
|
```json
|
|
42
|
-
{
|
|
66
|
+
{
|
|
67
|
+
"canvasDir": "...",
|
|
68
|
+
"headSha": "...",
|
|
69
|
+
"mergeBaseSha": "...",
|
|
70
|
+
"promptPath": "...",
|
|
71
|
+
"contextPath": "...",
|
|
72
|
+
"status": "prepared"
|
|
73
|
+
}
|
|
43
74
|
```
|
|
44
75
|
|
|
45
76
|
- `status: "exists"` means a canvas already exists for this head. Stop and tell the user:
|
|
46
77
|
"canvas already exists for <headSha>; run with --force to regenerate".
|
|
47
78
|
- A line of the form `{ "error": { "code", "message", "hint" } }` means prepare failed. Report the
|
|
48
79
|
code, message, and hint verbatim and stop. `pr-review doctor` names which of git, origin,
|
|
49
|
-
`gh`, the data dir, and the skill install is missing.
|
|
80
|
+
`gh` or `glab`, the data dir, and the skill install is missing.
|
|
50
81
|
|
|
51
82
|
### 2. Read the task
|
|
52
83
|
|
|
@@ -96,7 +127,15 @@ pr-review publish <canvasDir> --agent <your agent id> --model <model id if you k
|
|
|
96
127
|
anywhere else.
|
|
97
128
|
|
|
98
129
|
On success the last line is `{ "status": "published", "headSha", "reviewJsonPath", "attempts",
|
|
99
|
-
"reviewUrl" }` (`reviewUrl` is absent for a `--base/--head` run
|
|
130
|
+
"reviewUrl", "sharing" }` (`reviewUrl` is absent only for a `--base/--head` run; a local run
|
|
131
|
+
points at `/review/branch` or `/review/uncommitted`).
|
|
132
|
+
For PR/MR runs, publish automatically creates or updates your canvas comment using the host CLI login.
|
|
133
|
+
Always inspect `sharing.status`: local validation success does not mean remote sharing succeeded.
|
|
134
|
+
|
|
135
|
+
For a local run there is nothing to share: `sharing.status` is `"local"`. Report `reviewUrl` and
|
|
136
|
+
tell the user to start `pr-review serve` to read the canvas. If publish prints `CANVAS_STALE`, the
|
|
137
|
+
branch or the working tree changed while you worked; offer to prepare again rather than passing
|
|
138
|
+
`--allow-stale`, because the canvas would then describe code the user has already changed.
|
|
100
139
|
|
|
101
140
|
On failure the command prints one line per problem, then an error line, and exits 5:
|
|
102
141
|
|
|
@@ -113,35 +152,33 @@ verbatim. Do not weaken the content to pass: shorten text, move hunks, fix links
|
|
|
113
152
|
If publish prints `CANVAS_STALE`, the branch moved while you worked. Tell the user and offer to run
|
|
114
153
|
prepare again; pass `--allow-stale` only when the user asks for the canvas of the old commit.
|
|
115
154
|
|
|
116
|
-
### 6.
|
|
155
|
+
### 6. Report the sharing result
|
|
117
156
|
|
|
118
|
-
|
|
119
|
-
pr-review export --head <headSha> [--pr <n>]
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Pass `--pr <n>` when the run had a PR number, so the file name and the manifest carry it. The
|
|
123
|
-
command prints one JSON line with the absolute `path` of the zip.
|
|
157
|
+
For a PR/MR run, report the local `reviewUrl` (start it with `pr-review serve`) and inspect `sharing`:
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
- `status: "shared"`: link to `sharing.url` and say the canvas was shared automatically.
|
|
160
|
+
- `status: "failed"`: clearly warn that automatic sharing failed, quote `sharing.warning`, and
|
|
161
|
+
give the absolute `sharing.zipPath`. Tell the user to open the PR/MR, edit its description,
|
|
162
|
+
drag the ZIP into the editor, wait for upload to finish, and save. Replace any older canvas
|
|
163
|
+
attachment link. Include these instructions in your final response; the local canvas is ready,
|
|
164
|
+
but reviewers still need the upload. Do not regenerate the model to repair a sharing failure.
|
|
126
165
|
|
|
127
|
-
For a
|
|
128
|
-
|
|
166
|
+
For a local run, `sharing.status` is `"local"` and there is nothing to share. Give the user
|
|
167
|
+
`reviewUrl` (`http://localhost:<port>/review/branch` or `.../review/uncommitted`) and tell them to
|
|
168
|
+
start `pr-review serve` if it is not running. Say which base was compared and whether uncommitted
|
|
169
|
+
work was included, both from the `local` object prepare printed.
|
|
129
170
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
> If you're happy with the produced canvas, open <PR URL>, edit the PR description, drag the ZIP
|
|
133
|
-
> into the editor, wait for the upload to finish, and save.
|
|
171
|
+
For a `--base/--head` run, `sharing.status` is `"local"` too, but the canvas has no page of its
|
|
172
|
+
own. Report the stored commit and export it:
|
|
134
173
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
Uploading and saving the description are manual browser steps. Do not create a release or claim
|
|
139
|
-
the ZIP was uploaded. GitHub's `gh --attach` supports images and video, but not ZIP files
|
|
140
|
-
([supported types](https://github.com/cli/cli/blob/trunk/internal/attachments/userasset.go)).
|
|
174
|
+
```bash
|
|
175
|
+
pr-review export --head <headSha>
|
|
176
|
+
```
|
|
141
177
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
178
|
+
Give the returned absolute ZIP path. Once a PR exists, `pr-review export --head <headSha> --pr <n>`
|
|
179
|
+
stamps its number for manual upload, or rerun this skill for the PR number with `--force` to share
|
|
180
|
+
automatically. A canvas of a working-tree snapshot cannot be carried to a pull request this way:
|
|
181
|
+
its commit is on no branch, so generate a fresh one for the PR.
|
|
145
182
|
|
|
146
183
|
## Rules the validator enforces (and models tend to break)
|
|
147
184
|
|
|
@@ -172,6 +209,6 @@ manual upload instructions for when the PR is ready.
|
|
|
172
209
|
## Updating a shared canvas
|
|
173
210
|
|
|
174
211
|
After new commits, run this skill again for the PR number. Add `--force` to regenerate a canvas
|
|
175
|
-
for the same commit.
|
|
176
|
-
|
|
212
|
+
for the same commit. Publish updates your canvas comment; follow the sharing-result instructions
|
|
213
|
+
above if it fails. Reviewers click **refresh** to load it. A canvas for a different
|
|
177
214
|
PR head shows **Canvas is outdated**; an older canvas remains readable with posting disabled.
|
package/src/acpx/acpx.ts
CHANGED
|
@@ -231,7 +231,11 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
231
231
|
// The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
|
|
232
232
|
// must neither hold the kill back nor stay behind as a process of its own.
|
|
233
233
|
agentArgs =>
|
|
234
|
-
execQuiet(bin, agentArgs, {
|
|
234
|
+
execQuiet(bin, agentArgs, {
|
|
235
|
+
cwd: options.cwd,
|
|
236
|
+
timeoutSec: CANCEL_TIMEOUT_SEC,
|
|
237
|
+
killSignal: 'SIGKILL',
|
|
238
|
+
}),
|
|
235
239
|
slackMs,
|
|
236
240
|
cancelGraceMs
|
|
237
241
|
)
|
|
@@ -268,7 +272,12 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
268
272
|
}
|
|
269
273
|
}
|
|
270
274
|
if (!ended) {
|
|
271
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
text: '',
|
|
278
|
+
code: 'AGENT_INCOMPLETE',
|
|
279
|
+
message: 'the agent stopped before finishing',
|
|
280
|
+
}
|
|
272
281
|
}
|
|
273
282
|
return text === ''
|
|
274
283
|
? { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent replied nothing' }
|
|
@@ -516,11 +525,19 @@ function startRun(
|
|
|
516
525
|
const exit = code ?? 1
|
|
517
526
|
const mapped = exitCodeToAgentCode(exit)
|
|
518
527
|
if (mapped !== null) {
|
|
519
|
-
finish({
|
|
528
|
+
finish({
|
|
529
|
+
type: 'error',
|
|
530
|
+
code: mapped,
|
|
531
|
+
message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim(),
|
|
532
|
+
})
|
|
520
533
|
return
|
|
521
534
|
}
|
|
522
535
|
// acpx 0.13.2 exits 0 when its own --timeout elapses, without a terminal event.
|
|
523
|
-
finish({
|
|
536
|
+
finish({
|
|
537
|
+
type: 'error',
|
|
538
|
+
code: 'AGENT_INCOMPLETE',
|
|
539
|
+
message: 'the agent stopped before finishing its answer',
|
|
540
|
+
})
|
|
524
541
|
})
|
|
525
542
|
|
|
526
543
|
child.stdin?.on('error', () => undefined)
|
package/src/acpx/events.ts
CHANGED
|
@@ -64,7 +64,11 @@ function contentText(value: unknown): string {
|
|
|
64
64
|
*/
|
|
65
65
|
export function mapAcpxMessage(message: unknown): AgentEvent | null {
|
|
66
66
|
if (!isRecord(message)) {
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
type: 'error',
|
|
69
|
+
code: 'AGENT_PROTOCOL_INVALID',
|
|
70
|
+
message: 'the agent wrote a line that is not a message',
|
|
71
|
+
}
|
|
68
72
|
}
|
|
69
73
|
const error = read(message, 'error')
|
|
70
74
|
if (isRecord(error)) {
|
package/src/acpx/ndjson.ts
CHANGED
|
@@ -50,7 +50,9 @@ export function createNdjsonSplitter(lineMax: number = NDJSON_LINE_MAX): NdjsonS
|
|
|
50
50
|
const raw = buffer.slice(0, at)
|
|
51
51
|
buffer = buffer.slice(at + 1)
|
|
52
52
|
if (raw.length > lineMax) {
|
|
53
|
-
throw new NdjsonError(
|
|
53
|
+
throw new NdjsonError(
|
|
54
|
+
`the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`
|
|
55
|
+
)
|
|
54
56
|
}
|
|
55
57
|
out.push(...take(raw))
|
|
56
58
|
at = buffer.indexOf('\n')
|
package/src/acpx/preflight.ts
CHANGED
|
@@ -16,7 +16,11 @@ export interface PreflightProbe {
|
|
|
16
16
|
* Is acpx on PATH? The answer barely changes while the server runs, so it is asked once and
|
|
17
17
|
* reused; the page shows a banner and hides the chat when it says no.
|
|
18
18
|
*/
|
|
19
|
-
export function createPreflightProbe(
|
|
19
|
+
export function createPreflightProbe(
|
|
20
|
+
runner: AgentRunner,
|
|
21
|
+
now: () => Date,
|
|
22
|
+
ttlMs = PREFLIGHT_TTL_MS
|
|
23
|
+
): PreflightProbe {
|
|
20
24
|
let cached: { at: number; value: AcpxPreflight } | null = null
|
|
21
25
|
let inFlight: Promise<AcpxPreflight> | null = null
|
|
22
26
|
return {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CanvasZip } from './export.js'
|
|
2
|
+
|
|
3
|
+
export const CANVAS_COMMENT_MARKER = '<!-- pr-review-canvas:v1\n'
|
|
4
|
+
|
|
5
|
+
/** The ZIP stays in the raw comment body; the rendered comment gives reviewer instructions. */
|
|
6
|
+
export function buildCanvasComment(zip: CanvasZip, limit: number): string {
|
|
7
|
+
const body = `PR Review Canvas for commit ${zip.headSha}.\n\nRun \`pr-review serve\` and open #${zip.prNumber}. If already reviewing, click **refresh**.\n\n${CANVAS_COMMENT_MARKER}${zip.name}\n${Buffer.from(zip.bytes).toString('base64')}\n-->`
|
|
8
|
+
if (body.length > limit) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`the compressed canvas comment needs ${body.length} characters; the host limit is ${limit}`
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
return body
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only the versioned envelope is decoded. ZIP contents are checked by the normal importer. */
|
|
17
|
+
export function readCanvasComment(body: string): { name: string; bytes: Uint8Array } | null {
|
|
18
|
+
const match = /<!-- pr-review-canvas:v1\n([A-Za-z0-9._-]+\.zip)\n([A-Za-z0-9+/]+={0,2})\n-->/.exec(body)
|
|
19
|
+
if (match === null) return null
|
|
20
|
+
const name = match[1]!
|
|
21
|
+
const encoded = match[2]!
|
|
22
|
+
if (encoded.length > 1_000_000 || encoded.length % 4 !== 0) return null
|
|
23
|
+
return { name, bytes: Buffer.from(encoded, 'base64') }
|
|
24
|
+
}
|
package/src/canvas/export.ts
CHANGED
|
@@ -37,7 +37,12 @@ export async function buildCanvasZipFor(
|
|
|
37
37
|
const number = prNumber ?? stored.prNumber
|
|
38
38
|
const manifest: CanvasManifest = number === undefined ? stored : { ...stored, prNumber: number }
|
|
39
39
|
const zip: CanvasZip = {
|
|
40
|
-
name: buildCanvasZipName({
|
|
40
|
+
name: buildCanvasZipName({
|
|
41
|
+
repo: manifest.repo,
|
|
42
|
+
headSha,
|
|
43
|
+
prNumber: number,
|
|
44
|
+
generatedAt: manifest.generatedAt,
|
|
45
|
+
}),
|
|
41
46
|
bytes: buildCanvasZip(manifest, artifact),
|
|
42
47
|
headSha,
|
|
43
48
|
}
|
|
@@ -64,7 +69,11 @@ async function isDirectory(target: string): Promise<boolean> {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/** `--out` names a file when it ends in `.zip` or is not an existing directory. */
|
|
67
|
-
export async function resolveOutPath(
|
|
72
|
+
export async function resolveOutPath(
|
|
73
|
+
out: string | undefined,
|
|
74
|
+
defaultDir: string,
|
|
75
|
+
name: string
|
|
76
|
+
): Promise<string> {
|
|
68
77
|
if (out === undefined) {
|
|
69
78
|
return path.join(defaultDir, name)
|
|
70
79
|
}
|
package/src/canvas/import.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// One import path for the three ways a canvas arrives: the drop zone, `pr-review import`, and a
|
|
2
2
|
// zip discovered on the pull request. Everything is checked here, so no caller can skip a step.
|
|
3
3
|
import type { CanvasRelation, ImportResult } from '../contract/api.js'
|
|
4
|
-
import type
|
|
5
|
-
import type { ReviewArtifact } from '../contract/review-artifact.js'
|
|
4
|
+
import { type DiffedCommit, standsForHead } from '../review/carry-over.js'
|
|
6
5
|
import type { AppContext } from '../server/context.js'
|
|
7
6
|
import { AppError } from '../server/errors.js'
|
|
8
|
-
import { CanvasZipError, readCanvasZip } from './zip.js'
|
|
7
|
+
import { type CanvasZipContents, CanvasZipError, readCanvasZip } from './zip.js'
|
|
9
8
|
|
|
10
9
|
export interface ImportOptions {
|
|
11
10
|
bytes: Uint8Array
|
|
12
11
|
/** The pull request the canvas is imported for; recorded on the canvas. */
|
|
13
12
|
prNumber?: number | undefined
|
|
14
|
-
/**
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The head the page is looking at, with the merge base its diff runs from. Absent means the
|
|
15
|
+
* canvas is taken as the current one.
|
|
16
|
+
*/
|
|
17
|
+
currentHead?: DiffedCommit | undefined
|
|
16
18
|
/** Accepts a canvas exported from another repository. */
|
|
17
19
|
force?: boolean | undefined
|
|
18
20
|
}
|
|
@@ -79,13 +81,13 @@ async function relateToHead(
|
|
|
79
81
|
* canvas already on disk is kept unless the incoming one was generated later.
|
|
80
82
|
*/
|
|
81
83
|
export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promise<ImportResult> {
|
|
82
|
-
let contents:
|
|
84
|
+
let contents: CanvasZipContents
|
|
83
85
|
try {
|
|
84
86
|
contents = readCanvasZip(opts.bytes)
|
|
85
87
|
} catch (err) {
|
|
86
88
|
throw err instanceof CanvasZipError ? toAppErrorFromZip(err) : err
|
|
87
89
|
}
|
|
88
|
-
const { manifest, artifact } = contents
|
|
90
|
+
const { manifest, artifact, prNumber: canvasPr } = contents
|
|
89
91
|
const warnings: string[] = []
|
|
90
92
|
if (!sameRepo(manifest.repo, ctx.config.repo)) {
|
|
91
93
|
const from = `${manifest.repo.owner}/${manifest.repo.name}`
|
|
@@ -100,8 +102,21 @@ export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promis
|
|
|
100
102
|
warnings.push(`imported a canvas exported from ${from}`)
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// The wrong zip attached to a pull request is the common mistake, and the head check below does
|
|
106
|
+
// not catch it: two open pull requests have unrelated heads either way. This is the one check
|
|
107
|
+
// --force cannot lift: the canvas in this zip may only be stored under the pull request it
|
|
108
|
+
// names, so forcing could only write an index entry that contradicts the zip it came from.
|
|
109
|
+
if (opts.prNumber !== undefined && canvasPr !== undefined && canvasPr !== opts.prNumber) {
|
|
110
|
+
throw new AppError(
|
|
111
|
+
'CANVAS_PR_MISMATCH',
|
|
112
|
+
`this canvas was exported for #${canvasPr}, and it is being imported for #${opts.prNumber}`,
|
|
113
|
+
400,
|
|
114
|
+
`import it without --pr to store it under #${canvasPr}, or generate a canvas for #${opts.prNumber}`
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
const headSha = manifest.headSha
|
|
104
|
-
const currentHeadSha = opts.
|
|
119
|
+
const currentHeadSha = opts.currentHead?.headSha ?? headSha
|
|
105
120
|
const index = await ctx.canvases.readIndex()
|
|
106
121
|
const stored = index.canvases[headSha]
|
|
107
122
|
const keepStored = stored !== undefined && stored.generatedAt >= artifact.generatedAt
|
|
@@ -119,7 +134,13 @@ export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promis
|
|
|
119
134
|
if (keepStored) {
|
|
120
135
|
return { status: 'exists', headSha, currentHeadSha, derivable, warnings }
|
|
121
136
|
}
|
|
122
|
-
|
|
137
|
+
// The same rule the page reads: a canvas of another commit is current when the head's diff is
|
|
138
|
+
// identical to the one it was generated from, so the CLI never calls stale what the page shows
|
|
139
|
+
// as carried over.
|
|
140
|
+
if (
|
|
141
|
+
opts.currentHead === undefined ||
|
|
142
|
+
(await standsForHead(ctx, opts.currentHead, { headSha, mergeBaseSha }))
|
|
143
|
+
) {
|
|
123
144
|
return { status: 'ready', headSha, currentHeadSha, derivable, warnings }
|
|
124
145
|
}
|
|
125
146
|
const related = await relateToHead(ctx, headSha, currentHeadSha)
|
package/src/canvas/name.ts
CHANGED
|
@@ -33,6 +33,7 @@ export function buildCanvasZipName(opts: BuildNameOptions): string {
|
|
|
33
33
|
return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The strict form of the grammar the drop zone applies to a picked file (`validateCanvasFilename`). */
|
|
36
37
|
const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
|
|
37
38
|
|
|
38
39
|
/**
|
package/src/canvas/zip.ts
CHANGED
|
@@ -14,6 +14,12 @@ export const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04] as const
|
|
|
14
14
|
export interface CanvasZipContents {
|
|
15
15
|
manifest: CanvasManifest
|
|
16
16
|
artifact: ReviewArtifact
|
|
17
|
+
/**
|
|
18
|
+
* The pull request the canvas was made for, once for every reader: the two entries are proved to
|
|
19
|
+
* agree below, and the two ways of holding "no pull request" are settled into one. A canvas
|
|
20
|
+
* generated before the pull request existed names none, and joins the PR that imports it.
|
|
21
|
+
*/
|
|
22
|
+
prNumber?: number
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** A zip that cannot be read as a canvas. `issues` names the problems, never the file's content. */
|
|
@@ -66,7 +72,10 @@ function decode(entry: Uint8Array | undefined, name: string, issues: string[]):
|
|
|
66
72
|
*/
|
|
67
73
|
export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
|
|
68
74
|
if (bytes.length > CANVAS_ZIP_MAX_BYTES) {
|
|
69
|
-
throw new CanvasZipError(
|
|
75
|
+
throw new CanvasZipError(
|
|
76
|
+
'CANVAS_TOO_LARGE',
|
|
77
|
+
`the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`
|
|
78
|
+
)
|
|
70
79
|
}
|
|
71
80
|
if (!hasZipMagic(bytes)) {
|
|
72
81
|
throw new CanvasZipError('CANVAS_INVALID', 'this file is not a zip', [
|
|
@@ -119,5 +128,19 @@ export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
|
|
|
119
128
|
`${REVIEW_ENTRY} is for ${artifact.data.pr.headSha.slice(0, 7)} while ${MANIFEST_ENTRY} says ${manifest.data.headSha.slice(0, 7)}`,
|
|
120
129
|
])
|
|
121
130
|
}
|
|
122
|
-
|
|
131
|
+
// A canvas generated before the pull request existed carries no number in review.json, and the
|
|
132
|
+
// export stamps one on the manifest; only two numbers that are both there must agree.
|
|
133
|
+
const artifactPr = artifact.data.pr.number
|
|
134
|
+
const manifestPr = manifest.data.prNumber
|
|
135
|
+
if (artifactPr !== null && manifestPr !== undefined && artifactPr !== manifestPr) {
|
|
136
|
+
throw new CanvasZipError('CANVAS_INVALID', 'the zip is not a review canvas', [
|
|
137
|
+
`${REVIEW_ENTRY} is for #${artifactPr} while ${MANIFEST_ENTRY} says #${manifestPr}`,
|
|
138
|
+
])
|
|
139
|
+
}
|
|
140
|
+
const contents: CanvasZipContents = { manifest: manifest.data, artifact: artifact.data }
|
|
141
|
+
const prNumber = manifestPr ?? artifactPr
|
|
142
|
+
if (prNumber !== null) {
|
|
143
|
+
contents.prNumber = prNumber
|
|
144
|
+
}
|
|
145
|
+
return contents
|
|
123
146
|
}
|