@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.
Files changed (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. 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 (or 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` until the validator passes. Use when the user runs `/pr-review-canvas <pr-number>`, `/pr-review-canvas --base <ref> --head <ref>`, or asks for a review canvas for a PR.
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]` or `--base <ref> --head <ref> [--force]`. `--force` regenerates
15
- a canvas that already exists for the head commit: prepare removes the old `model.json` and any
16
- other leftovers from the canvas directory, keeping `derived/`, `publish.log` (the attempts history),
17
- and the published `review.json` + `manifest.json` (the page keeps showing the old canvas until your
18
- publish replaces it), so you start a fresh `model.json`. Run every `pr-review` command from the
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
- Claude Code defaults this skill to Sonnet. If the prepared diff changes authentication, access
26
- policy, or protected health information (PHI) handling, use an Opus agent for the generation and
27
- validation steps when available. Pass it the prepared prompt and context paths; it writes the
28
- same model file. Honor an explicit user model choice. Other hosts keep their selected model.
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 PR exists:
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
- { "canvasDir": "...", "headSha": "...", "mergeBaseSha": "...", "promptPath": "...", "contextPath": "...", "status": "prepared" }
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. Export the zip
155
+ ### 6. Report the sharing result
117
156
 
118
- ```bash
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
- ### 7. Finish
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 PR run, report the `reviewUrl` from publish, the absolute zip path from export, and a link
128
- to the GitHub PR from the prepared context. End with upload instructions:
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
- > The canvas is ready at <reviewUrl> (start the server with `pr-review serve` if it is not running).
131
- > ZIP: <path>
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
- For an update, tell the user to replace the old canvas attachment link with the new one.
136
- Include these instructions in the final response without asking a question or waiting for a reply.
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
- For a `--base/--head` run, say the canvas is stored for `<headSha>`, that the zip has no PR number
143
- yet, and that `pr-review export --pr <n>` re-exports it once the pull request exists. Include the
144
- manual upload instructions for when the PR is ready.
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. Export the new zip and ask the user to replace the attachment in their PR
176
- description. Reviewers click **refresh** to load it. A canvas for a different
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, { cwd: options.cwd, timeoutSec: CANCEL_TIMEOUT_SEC, killSignal: 'SIGKILL' }),
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 { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing' }
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({ type: 'error', code: mapped, message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim() })
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({ type: 'error', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing its answer' })
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)
@@ -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 { type: 'error', code: 'AGENT_PROTOCOL_INVALID', message: 'the agent wrote a line that is not a message' }
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)) {
@@ -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(`the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`)
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')
@@ -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(runner: AgentRunner, now: () => Date, ttlMs = PREFLIGHT_TTL_MS): PreflightProbe {
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
+ }
@@ -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({ repo: manifest.repo, headSha, prNumber: number, generatedAt: manifest.generatedAt }),
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(out: string | undefined, defaultDir: string, name: string): Promise<string> {
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
  }
@@ -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 { CanvasManifest } from '../contract/canvas-manifest.js'
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
- /** The head the page is looking at. Absent means the canvas is taken as the current one. */
15
- currentHeadSha?: string | undefined
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: { manifest: CanvasManifest; artifact: ReviewArtifact }
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.currentHeadSha ?? headSha
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
- if (headSha === currentHeadSha) {
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)
@@ -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('CANVAS_TOO_LARGE', `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`)
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
- return { manifest: manifest.data, artifact: artifact.data }
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
  }