@vintasoftware/pr-review-canvas 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
|
@@ -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,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]`
|
|
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
|
|
@@ -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.
|
|
155
|
+
### 6. Report the sharing result
|
|
124
156
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
183
|
-
|
|
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
|
+
}
|
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. */
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/chat/chat-manager.ts
CHANGED
|
@@ -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
|
|
42
|
+
/** Everything about the review target one turn needs, resolved by the route. */
|
|
42
43
|
export interface ChatTarget {
|
|
43
|
-
|
|
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(
|
|
62
|
-
createThread(
|
|
63
|
-
selectThread(
|
|
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(
|
|
67
|
-
busy(
|
|
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<
|
|
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 (
|
|
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(
|
|
93
|
+
await deps.state.update(key, state => {
|
|
93
94
|
const thread: ChatThread = {
|
|
94
|
-
name: threadName(deps.repo,
|
|
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 (
|
|
112
|
-
const state = await deps.state.read(
|
|
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(
|
|
122
|
+
return newThread(key, agent)
|
|
122
123
|
}
|
|
123
124
|
if (state.chat.activeThread !== active.name) {
|
|
124
|
-
await deps.state.update(
|
|
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
|
-
|
|
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(
|
|
145
|
+
await deps.transcripts.append(key, thread.name, assistant)
|
|
145
146
|
if (!reached) {
|
|
146
147
|
return
|
|
147
148
|
}
|
|
148
|
-
await deps.state.update(
|
|
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 (
|
|
173
|
-
await deps.state.update(
|
|
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.
|
|
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.
|
|
190
|
+
running.set(target.key, slot)
|
|
190
191
|
try {
|
|
191
192
|
yield* runTurn(target, input, slot)
|
|
192
193
|
} finally {
|
|
193
|
-
running.delete(target.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
334
|
+
await forgetSession(target.key, thread.name)
|
|
334
335
|
}
|
|
335
336
|
}
|
|
336
337
|
}
|
|
337
338
|
|
|
338
339
|
return {
|
|
339
340
|
effectiveSettings,
|
|
340
|
-
async threads(
|
|
341
|
-
const [state, settings] = await Promise.all([deps.state.read(
|
|
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(
|
|
354
|
+
async createThread(key) {
|
|
354
355
|
const settings = await effectiveSettings()
|
|
355
|
-
return newThread(
|
|
356
|
+
return newThread(key, settings.agent)
|
|
356
357
|
},
|
|
357
|
-
async selectThread(
|
|
358
|
-
const state = await deps.state.read(
|
|
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(
|
|
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(
|
|
371
|
-
const slot = running.get(
|
|
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:
|
|
382
|
+
busy: key => running.has(key),
|
|
382
383
|
}
|
|
383
384
|
}
|
|
384
385
|
|