@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.
Files changed (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -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,31 +11,55 @@ 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
@@ -53,7 +77,7 @@ Progress goes to stderr. The last stdout line is JSON:
53
77
  "canvas already exists for <headSha>; run with --force to regenerate".
54
78
  - A line of the form `{ "error": { "code", "message", "hint" } }` means prepare failed. Report the
55
79
  code, message, and hint verbatim and stop. `pr-review doctor` names which of git, origin,
56
- `gh`, the data dir, and the skill install is missing.
80
+ `gh` or `glab`, the data dir, and the skill install is missing.
57
81
 
58
82
  ### 2. Read the task
59
83
 
@@ -103,7 +127,15 @@ pr-review publish <canvasDir> --agent <your agent id> --model <model id if you k
103
127
  anywhere else.
104
128
 
105
129
  On success the last line is `{ "status": "published", "headSha", "reviewJsonPath", "attempts",
106
- "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.
107
139
 
108
140
  On failure the command prints one line per problem, then an error line, and exits 5:
109
141
 
@@ -120,35 +152,33 @@ verbatim. Do not weaken the content to pass: shorten text, move hunks, fix links
120
152
  If publish prints `CANVAS_STALE`, the branch moved while you worked. Tell the user and offer to run
121
153
  prepare again; pass `--allow-stale` only when the user asks for the canvas of the old commit.
122
154
 
123
- ### 6. Export the zip
155
+ ### 6. Report the sharing result
124
156
 
125
- ```bash
126
- pr-review export --head <headSha> [--pr <n>]
127
- ```
128
-
129
- Pass `--pr <n>` when the run had a PR number, so the file name and the manifest carry it. The
130
- 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`:
131
158
 
132
- ### 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.
133
165
 
134
- For a PR run, report the `reviewUrl` from publish, the absolute zip path from export, and a link
135
- 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.
136
170
 
137
- > The canvas is ready at <reviewUrl> (start the server with `pr-review serve` if it is not running).
138
- > ZIP: <path>
139
- > If you're happy with the produced canvas, open <PR URL>, edit the PR description, drag the ZIP
140
- > 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:
141
173
 
142
- For an update, tell the user to replace the old canvas attachment link with the new one.
143
- Include these instructions in the final response without asking a question or waiting for a reply.
144
-
145
- Uploading and saving the description are manual browser steps. Do not create a release or claim
146
- the ZIP was uploaded. GitHub's `gh --attach` supports images and video, but not ZIP files
147
- ([supported types](https://github.com/cli/cli/blob/trunk/internal/attachments/userasset.go)).
174
+ ```bash
175
+ pr-review export --head <headSha>
176
+ ```
148
177
 
149
- For a `--base/--head` run, say the canvas is stored for `<headSha>`, that the zip has no PR number
150
- yet, and that `pr-review export --pr <n>` re-exports it once the pull request exists. Include the
151
- 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.
152
182
 
153
183
  ## Rules the validator enforces (and models tend to break)
154
184
 
@@ -179,6 +209,6 @@ manual upload instructions for when the PR is ready.
179
209
  ## Updating a shared canvas
180
210
 
181
211
  After new commits, run this skill again for the PR number. Add `--force` to regenerate a canvas
182
- for the same commit. Export the new zip and ask the user to replace the attachment in their PR
183
- 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
184
214
  PR head shows **Canvas is outdated**; an older canvas remains readable with posting disabled.
@@ -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
+ }
@@ -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. */
@@ -122,5 +128,19 @@ export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
122
128
  `${REVIEW_ENTRY} is for ${artifact.data.pr.headSha.slice(0, 7)} while ${MANIFEST_ENTRY} says ${manifest.data.headSha.slice(0, 7)}`,
123
129
  ])
124
130
  }
125
- 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
126
146
  }
@@ -5,6 +5,7 @@ import type { AgentRunner } from '../acpx/acpx.js'
5
5
  import type { ChatContext, ChatEvent, ChatThreadsResponse, ChatTurn } from '../contract/chat.js'
6
6
  import type { FileEntry, Repo, ReviewArtifact } from '../contract/review-artifact.js'
7
7
  import type { Settings, SettingsOverrides } from '../contract/settings.js'
8
+ import type { ReviewKey } from '../contract/review-key.js'
8
9
  import type { ChatThread } from '../contract/state.js'
9
10
  import type { SettingsStore } from '../store/settings-store.js'
10
11
  import type { StateStore } from '../store/state-store.js'
@@ -38,9 +39,9 @@ export interface ChatManagerDeps {
38
39
  now: () => Date
39
40
  }
40
41
 
41
- /** Everything about the pull request one turn needs, resolved by the route. */
42
+ /** Everything about the review target one turn needs, resolved by the route. */
42
43
  export interface ChatTarget {
43
- prNumber: number
44
+ key: ReviewKey
44
45
  headSha: string
45
46
  artifact: ReviewArtifact
46
47
  files: FileEntry[]
@@ -58,13 +59,13 @@ export interface ChatSendInput {
58
59
 
59
60
  export interface ChatManager {
60
61
  effectiveSettings(): Promise<Settings>
61
- threads(prNumber: number): Promise<ChatThreadsResponse>
62
- createThread(prNumber: number): Promise<ChatThread>
63
- selectThread(prNumber: number, name: string): Promise<ChatThread | null>
62
+ threads(key: ReviewKey): Promise<ChatThreadsResponse>
63
+ createThread(key: ReviewKey): Promise<ChatThread>
64
+ selectThread(key: ReviewKey, name: string): Promise<ChatThread | null>
64
65
  send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent>
65
66
  /** True when a turn was running and has been asked to stop. */
66
- cancel(prNumber: number): Promise<boolean>
67
- busy(prNumber: number): boolean
67
+ cancel(key: ReviewKey): Promise<boolean>
68
+ busy(key: ReviewKey): boolean
68
69
  }
69
70
 
70
71
  /** The slot a turn holds while it runs. The handle is filled in once the agent has started. */
@@ -75,7 +76,7 @@ interface RunningTurn {
75
76
  }
76
77
 
77
78
  export function createChatManager(deps: ChatManagerDeps): ChatManager {
78
- const running = new Map<number, RunningTurn>()
79
+ const running = new Map<ReviewKey, RunningTurn>()
79
80
 
80
81
  const effectiveSettings = async (): Promise<Settings> => {
81
82
  const saved = await deps.settings.read()
@@ -86,12 +87,12 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
86
87
  }
87
88
  }
88
89
 
89
- const newThread = async (prNumber: number, agent: string): Promise<ChatThread> => {
90
+ const newThread = async (key: ReviewKey, agent: string): Promise<ChatThread> => {
90
91
  const at = deps.now().toISOString()
91
92
  let created: ChatThread | null = null
92
- await deps.state.update(prNumber, state => {
93
+ await deps.state.update(key, state => {
93
94
  const thread: ChatThread = {
94
- name: threadName(deps.repo, prNumber, agent, nextThreadIndex(state.chat.threads)),
95
+ name: threadName(deps.repo, key, agent, nextThreadIndex(state.chat.threads)),
95
96
  agent,
96
97
  rev: 0,
97
98
  title: NEW_THREAD_TITLE,
@@ -108,8 +109,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
108
109
  }
109
110
 
110
111
  /** The thread a message goes to: the one asked for, the active one, or a fresh one. */
111
- const resolveThread = async (prNumber: number, agent: string, wanted?: string): Promise<ChatThread> => {
112
- const state = await deps.state.read(prNumber)
112
+ const resolveThread = async (key: ReviewKey, agent: string, wanted?: string): Promise<ChatThread> => {
113
+ const state = await deps.state.read(key)
113
114
  const byName = wanted === undefined ? undefined : state.chat.threads.find(t => t.name === wanted)
114
115
  const active =
115
116
  byName ??
@@ -118,10 +119,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
118
119
  : state.chat.threads.find(t => t.name === state.chat.activeThread))
119
120
  // Changing the agent starts a new thread: the old session belongs to the old agent.
120
121
  if (active === undefined || active.agent !== agent) {
121
- return newThread(prNumber, agent)
122
+ return newThread(key, agent)
122
123
  }
123
124
  if (state.chat.activeThread !== active.name) {
124
- await deps.state.update(prNumber, current => ({
125
+ await deps.state.update(key, current => ({
125
126
  ...current,
126
127
  chat: { ...current.chat, activeThread: active.name },
127
128
  }))
@@ -134,18 +135,18 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
134
135
  * try creates the session again and sends the seed again.
135
136
  */
136
137
  const saveTurn = async (
137
- prNumber: number,
138
+ key: ReviewKey,
138
139
  thread: ChatThread,
139
140
  userText: string,
140
141
  assistant: ChatTurn,
141
142
  seededHeadSha: string,
142
143
  reached: boolean
143
144
  ): Promise<void> => {
144
- await deps.transcripts.append(prNumber, thread.name, assistant)
145
+ await deps.transcripts.append(key, thread.name, assistant)
145
146
  if (!reached) {
146
147
  return
147
148
  }
148
- await deps.state.update(prNumber, state => ({
149
+ await deps.state.update(key, state => ({
149
150
  ...state,
150
151
  chat: {
151
152
  ...state.chat,
@@ -169,8 +170,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
169
170
  * it, so the next turn creates the session again and sends the seed with it. The revision keeps
170
171
  * counting the turns the thread has taken, which is what its title is keyed on.
171
172
  */
172
- const forgetSession = async (prNumber: number, name: string): Promise<void> => {
173
- await deps.state.update(prNumber, state => ({
173
+ const forgetSession = async (key: ReviewKey, name: string): Promise<void> => {
174
+ await deps.state.update(key, state => ({
174
175
  ...state,
175
176
  chat: {
176
177
  ...state.chat,
@@ -180,17 +181,17 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
180
181
  }
181
182
 
182
183
  async function* send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent> {
183
- if (running.has(target.prNumber)) {
184
+ if (running.has(target.key)) {
184
185
  throw new ChatBusyError()
185
186
  }
186
187
  // The slot is taken before the first await, so a second request that arrives while this one
187
188
  // is still reading the settings sees a busy chat rather than starting a second agent.
188
189
  const slot: RunningTurn = { run: null, stopped: false }
189
- running.set(target.prNumber, slot)
190
+ running.set(target.key, slot)
190
191
  try {
191
192
  yield* runTurn(target, input, slot)
192
193
  } finally {
193
- running.delete(target.prNumber)
194
+ running.delete(target.key)
194
195
  }
195
196
  }
196
197
 
@@ -200,7 +201,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
200
201
  slot: RunningTurn
201
202
  ): AsyncIterable<ChatEvent> {
202
203
  const settings = await effectiveSettings()
203
- const thread = await resolveThread(target.prNumber, settings.agent, input.thread)
204
+ const thread = await resolveThread(target.key, settings.agent, input.thread)
204
205
  const seeded = thread.seededHeadSha !== target.headSha
205
206
  const at = deps.now().toISOString()
206
207
  const contextBlock = await renderChatContext(input.context, {
@@ -224,7 +225,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
224
225
  })
225
226
  }
226
227
 
227
- await deps.transcripts.append(target.prNumber, thread.name, {
228
+ await deps.transcripts.append(target.key, thread.name, {
228
229
  role: 'user',
229
230
  text: input.message,
230
231
  at,
@@ -235,7 +236,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
235
236
  // transcript is written before the events, so a reader who leaves now still finds it there.
236
237
  if (slot.stopped) {
237
238
  await saveTurn(
238
- target.prNumber,
239
+ target.key,
239
240
  thread,
240
241
  input.message,
241
242
  { role: 'assistant', text: '', at: deps.now().toISOString(), incomplete: 'cancelled' },
@@ -258,7 +259,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
258
259
  maxTurns: settings.maxTurns ?? undefined,
259
260
  // The runner scrubs the line before it gets here; this only writes it down.
260
261
  onRawLine: line => {
261
- void deps.transcripts.appendEvent(target.prNumber, thread.name, line).catch(() => undefined)
262
+ void deps.transcripts.appendEvent(target.key, thread.name, line).catch(() => undefined)
262
263
  },
263
264
  })
264
265
  slot.run = run
@@ -316,7 +317,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
316
317
  await run.cancel().catch(() => undefined)
317
318
  }
318
319
  await saveTurn(
319
- target.prNumber,
320
+ target.key,
320
321
  thread,
321
322
  input.message,
322
323
  {
@@ -330,15 +331,15 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
330
331
  answer !== '' || incomplete === undefined || incomplete === 'cancelled'
331
332
  )
332
333
  if (incomplete === 'AGENT_NO_SESSION') {
333
- await forgetSession(target.prNumber, thread.name)
334
+ await forgetSession(target.key, thread.name)
334
335
  }
335
336
  }
336
337
  }
337
338
 
338
339
  return {
339
340
  effectiveSettings,
340
- async threads(prNumber) {
341
- const [state, settings] = await Promise.all([deps.state.read(prNumber), effectiveSettings()])
341
+ async threads(key) {
342
+ const [state, settings] = await Promise.all([deps.state.read(key), effectiveSettings()])
342
343
  return {
343
344
  threads: state.chat.threads.map(t => ({
344
345
  name: t.name,
@@ -350,25 +351,25 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
350
351
  agent: settings.agent,
351
352
  }
352
353
  },
353
- async createThread(prNumber) {
354
+ async createThread(key) {
354
355
  const settings = await effectiveSettings()
355
- return newThread(prNumber, settings.agent)
356
+ return newThread(key, settings.agent)
356
357
  },
357
- async selectThread(prNumber, name) {
358
- const state = await deps.state.read(prNumber)
358
+ async selectThread(key, name) {
359
+ const state = await deps.state.read(key)
359
360
  const thread = state.chat.threads.find(t => t.name === name)
360
361
  if (thread === undefined) {
361
362
  return null
362
363
  }
363
- await deps.state.update(prNumber, current => ({
364
+ await deps.state.update(key, current => ({
364
365
  ...current,
365
366
  chat: { ...current.chat, activeThread: name },
366
367
  }))
367
368
  return thread
368
369
  },
369
370
  send,
370
- async cancel(prNumber) {
371
- const slot = running.get(prNumber)
371
+ async cancel(key) {
372
+ const slot = running.get(key)
372
373
  if (slot === undefined) {
373
374
  return false
374
375
  }
@@ -378,7 +379,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
378
379
  await slot.run?.cancel()
379
380
  return true
380
381
  },
381
- busy: prNumber => running.has(prNumber),
382
+ busy: key => running.has(key),
382
383
  }
383
384
  }
384
385