@vintasoftware/pr-review-canvas 0.3.0 → 0.5.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 +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- 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 +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- 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 +64 -23
- 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 +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -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/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- 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,60 @@ 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
When the target already has a canvas for a commit this head was built on, prepare renders a
|
|
15
|
+
different prompt: it names that canvas, lists which files the new commits left untouched, and tells
|
|
16
|
+
you what to carry from it word for word and what to decide again. Follow that prompt as written;
|
|
17
|
+
the two lists are computed, not suggestions.
|
|
18
|
+
|
|
19
|
+
Arguments: `<pr-number> [--force]`, `branch [--base <ref>] [--force]`,
|
|
20
|
+
`uncommitted [--base <ref>] [--force]`, or `--base <ref> --head <ref> [--force]`. `--force`
|
|
21
|
+
regenerates a canvas that already exists for the head commit: prepare removes the old `model.json` and any other leftovers from the canvas
|
|
22
|
+
directory, keeping `derived/`, `publish.log` (the attempts history), and the published
|
|
23
|
+
`review.json` + `manifest.json` (the page keeps showing the old canvas until your publish replaces
|
|
24
|
+
it), so you start a fresh `model.json`. Run every `pr-review` command from the repository root.
|
|
20
25
|
|
|
21
26
|
## Flow
|
|
22
27
|
|
|
23
28
|
### Model choice
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
policy, or protected health information (PHI) handling, use
|
|
27
|
-
validation steps when available.
|
|
28
|
-
|
|
30
|
+
Use a mid-tier model, such as Sonnet, by default. If the prepared diff changes authentication,
|
|
31
|
+
access policy, or protected health information (PHI) handling, use a more capable model, such as
|
|
32
|
+
Opus, for the generation and validation steps when available. When delegating to another agent,
|
|
33
|
+
pass it the prepared prompt and context paths; it writes the same model file. Honor an explicit
|
|
34
|
+
user model choice. If the host cannot select models, keep its selected model.
|
|
29
35
|
Record the model that actually generated the canvas when publishing.
|
|
30
36
|
|
|
31
37
|
### 1. Prepare
|
|
32
38
|
|
|
33
39
|
```bash
|
|
34
40
|
pr-review prepare --pr <n> [--force]
|
|
35
|
-
# or, before a
|
|
41
|
+
# or, before a pull request exists, one of the two reviews of this clone:
|
|
42
|
+
pr-review prepare --branch [--base <ref>] [--force]
|
|
43
|
+
pr-review prepare --uncommitted [--base <ref>] [--force]
|
|
44
|
+
# or, for any two refs:
|
|
36
45
|
pr-review prepare --base <ref> --head <ref> [--force]
|
|
37
46
|
```
|
|
38
47
|
|
|
48
|
+
The skill word maps to the flag: `/pr-review-canvas branch` runs `prepare --branch`, and
|
|
49
|
+
`/pr-review-canvas uncommitted` runs `prepare --uncommitted`.
|
|
50
|
+
|
|
51
|
+
Both compare against the default branch, which prepare reads from `origin/HEAD` unless `--base`
|
|
52
|
+
names another one. They differ in what the head holds:
|
|
53
|
+
|
|
54
|
+
- `--branch` is the tip of the current branch. Whatever is in the working tree is left out.
|
|
55
|
+
- `--uncommitted` is the working tree itself: prepare snapshots the edits and the untracked files
|
|
56
|
+
into a commit of its own, without touching anything the user has staged. With a clean tree it is
|
|
57
|
+
the branch tip, and the canvas is the same one `--branch` would build.
|
|
58
|
+
|
|
59
|
+
They are separate reviews with separate pages, progress and chat threads, so preparing one leaves
|
|
60
|
+
the other alone. Pick the one the user asked for; when they only say "review my work", ask which,
|
|
61
|
+
unless the words already decide it ("before I commit" is `uncommitted`, "before I open the PR" with
|
|
62
|
+
everything committed is `branch`).
|
|
63
|
+
|
|
64
|
+
The JSON carries an extra `local` object with the review's name, the base that was resolved, the
|
|
65
|
+
branch the head is on, and whether uncommitted work is in it. Tell the user all four: a review of
|
|
66
|
+
the wrong base, or of a tree that has moved, is worth catching early.
|
|
67
|
+
|
|
39
68
|
Progress goes to stderr. The last stdout line is JSON:
|
|
40
69
|
|
|
41
70
|
```json
|
|
@@ -53,12 +82,14 @@ Progress goes to stderr. The last stdout line is JSON:
|
|
|
53
82
|
"canvas already exists for <headSha>; run with --force to regenerate".
|
|
54
83
|
- A line of the form `{ "error": { "code", "message", "hint" } }` means prepare failed. Report the
|
|
55
84
|
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.
|
|
85
|
+
`gh` or `glab`, the data dir, and the skill install is missing.
|
|
57
86
|
|
|
58
87
|
### 2. Read the task
|
|
59
88
|
|
|
60
89
|
Read `promptPath` in full: it holds the pull request, the manifest with every hunk id, the diffs
|
|
61
|
-
(inline or by file path), the layering and length rules, the rulebook, and the JSON schema.
|
|
90
|
+
(inline or by file path), the layering and length rules, the rulebook, and the JSON schema. On an
|
|
91
|
+
incremental run it also names the basis canvas and its `review.json`: read that file for the exact
|
|
92
|
+
wording of everything it tells you to carry. Read
|
|
62
93
|
`contextPath` when you need the paths of the head files, the base files, or the patches. Read any
|
|
63
94
|
untouched file with `git show <headSha>:<path>` from the repository root, using the SHA returned
|
|
64
95
|
by prepare. The working tree may be on another branch. Do not check anything out.
|
|
@@ -98,12 +129,20 @@ pr-review publish <canvasDir> --agent <your agent id> --model <model id if you k
|
|
|
98
129
|
```
|
|
99
130
|
|
|
100
131
|
- `--agent`: a free-text id of the agent product you are: `claude`, `codex`, `gemini`, ...
|
|
101
|
-
- `--model`: the model id when you know it (`claude-opus-
|
|
132
|
+
- `--model`: the model id when you know it (`claude-opus-5-5`, `gpt-6-astra`, ...); omit it otherwise.
|
|
102
133
|
- `--harness`: `claude-code` when you run inside Claude Code, `codex` inside Codex, `other`
|
|
103
134
|
anywhere else.
|
|
104
135
|
|
|
105
136
|
On success the last line is `{ "status": "published", "headSha", "reviewJsonPath", "attempts",
|
|
106
|
-
"reviewUrl" }` (`reviewUrl` is absent for a `--base/--head` run
|
|
137
|
+
"reviewUrl", "sharing" }` (`reviewUrl` is absent only for a `--base/--head` run; a local run
|
|
138
|
+
points at `/review/branch` or `/review/uncommitted`).
|
|
139
|
+
For PR/MR runs, publish automatically creates or updates your canvas comment using the host CLI login.
|
|
140
|
+
Always inspect `sharing.status`: local validation success does not mean remote sharing succeeded.
|
|
141
|
+
|
|
142
|
+
For a local run there is nothing to share: `sharing.status` is `"local"`. Report `reviewUrl` and
|
|
143
|
+
tell the user to start `pr-review serve` to read the canvas. If publish prints `CANVAS_STALE`, the
|
|
144
|
+
branch or the working tree changed while you worked; offer to prepare again rather than passing
|
|
145
|
+
`--allow-stale`, because the canvas would then describe code the user has already changed.
|
|
107
146
|
|
|
108
147
|
On failure the command prints one line per problem, then an error line, and exits 5:
|
|
109
148
|
|
|
@@ -120,35 +159,33 @@ verbatim. Do not weaken the content to pass: shorten text, move hunks, fix links
|
|
|
120
159
|
If publish prints `CANVAS_STALE`, the branch moved while you worked. Tell the user and offer to run
|
|
121
160
|
prepare again; pass `--allow-stale` only when the user asks for the canvas of the old commit.
|
|
122
161
|
|
|
123
|
-
### 6.
|
|
162
|
+
### 6. Report the sharing result
|
|
124
163
|
|
|
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.
|
|
164
|
+
For a PR/MR run, report the local `reviewUrl` (start it with `pr-review serve`) and inspect `sharing`:
|
|
131
165
|
|
|
132
|
-
|
|
166
|
+
- `status: "shared"`: link to `sharing.url` and say the canvas was shared automatically.
|
|
167
|
+
- `status: "failed"`: clearly warn that automatic sharing failed, quote `sharing.warning`, and
|
|
168
|
+
give the absolute `sharing.zipPath`. Tell the user to open the PR/MR, edit its description,
|
|
169
|
+
drag the ZIP into the editor, wait for upload to finish, and save. Replace any older canvas
|
|
170
|
+
attachment link. Include these instructions in your final response; the local canvas is ready,
|
|
171
|
+
but reviewers still need the upload. Do not regenerate the model to repair a sharing failure.
|
|
133
172
|
|
|
134
|
-
For a
|
|
135
|
-
|
|
173
|
+
For a local run, `sharing.status` is `"local"` and there is nothing to share. Give the user
|
|
174
|
+
`reviewUrl` (`http://localhost:<port>/review/branch` or `.../review/uncommitted`) and tell them to
|
|
175
|
+
start `pr-review serve` if it is not running. Say which base was compared and whether uncommitted
|
|
176
|
+
work was included, both from the `local` object prepare printed.
|
|
136
177
|
|
|
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.
|
|
178
|
+
For a `--base/--head` run, `sharing.status` is `"local"` too, but the canvas has no page of its
|
|
179
|
+
own. Report the stored commit and export it:
|
|
141
180
|
|
|
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)).
|
|
181
|
+
```bash
|
|
182
|
+
pr-review export --head <headSha>
|
|
183
|
+
```
|
|
148
184
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
185
|
+
Give the returned absolute ZIP path. Once a PR exists, `pr-review export --head <headSha> --pr <n>`
|
|
186
|
+
stamps its number for manual upload, or rerun this skill for the PR number with `--force` to share
|
|
187
|
+
automatically. A canvas of a working-tree snapshot cannot be carried to a pull request this way:
|
|
188
|
+
its commit is on no branch, so generate a fresh one for the PR.
|
|
152
189
|
|
|
153
190
|
## Rules the validator enforces (and models tend to break)
|
|
154
191
|
|
|
@@ -178,7 +215,9 @@ manual upload instructions for when the PR is ready.
|
|
|
178
215
|
|
|
179
216
|
## Updating a shared canvas
|
|
180
217
|
|
|
181
|
-
After new commits, run this skill again for the PR number.
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
After new commits, run this skill again for the PR number. The run updates the canvas of the
|
|
219
|
+
nearest earlier commit instead of writing one from nothing, and the reviewer's progress on the
|
|
220
|
+
untouched files follows it. Add `--force` to regenerate a canvas for the same commit, or to start
|
|
221
|
+
over from a blank page. Publish updates your canvas comment; follow the sharing-result instructions
|
|
222
|
+
above if it fails. Reviewers click **refresh** to load it. A canvas for a different
|
|
184
223
|
PR head shows **Canvas is outdated**; an older canvas remains readable with posting disabled.
|
package/src/acpx/acpx.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* `src/testing/fake-runner.ts` or spawn `src/testing/fake-acpx.mjs` through this adapter.
|
|
5
5
|
*/
|
|
6
6
|
import { type ChildProcess, execFile, type SpawnOptions, spawn } from 'node:child_process'
|
|
7
|
+
import { accessSync, constants } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
7
9
|
import { promisify } from 'node:util'
|
|
10
|
+
import { z } from 'zod'
|
|
8
11
|
import {
|
|
9
12
|
type AgentErrorCode,
|
|
10
13
|
type AgentEvent,
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
mapAcpxMessage,
|
|
14
17
|
scrubForLog,
|
|
15
18
|
} from './events.js'
|
|
19
|
+
import type { ModelUpgrades } from './models.js'
|
|
16
20
|
import { createNdjsonSplitter, NdjsonError } from './ndjson.js'
|
|
17
21
|
|
|
18
22
|
const execFileAsync = promisify(execFile)
|
|
@@ -23,6 +27,8 @@ export const ACPX_BIN = 'acpx'
|
|
|
23
27
|
export const KILL_GRACE_MS = 3000
|
|
24
28
|
/** A cancel that has not answered by then is given up on, and the child is killed instead. */
|
|
25
29
|
export const CANCEL_TIMEOUT_SEC = 30
|
|
30
|
+
/** `sessions show` reads a local record, so it answers fast or not at all. */
|
|
31
|
+
export const SHOW_TIMEOUT_SEC = 20
|
|
26
32
|
/** The runner's deadline sits this far past acpx's, so acpx reports its own timeout first. */
|
|
27
33
|
export const DEADLINE_SLACK_MS = 15_000
|
|
28
34
|
|
|
@@ -68,6 +74,10 @@ export interface AgentRunner {
|
|
|
68
74
|
acpxVersion(): Promise<string | null>
|
|
69
75
|
/** Whether one agent's own CLI is installed and logged in. */
|
|
70
76
|
availability(agent: string): Promise<{ installed: boolean; authenticated: boolean; reason?: string }>
|
|
77
|
+
/** Which models the agent's catalog says were replaced, and by what. Empty when it says nothing. */
|
|
78
|
+
modelUpgrades(agent: string): Promise<ModelUpgrades>
|
|
79
|
+
/** The model the named session last ran, or null when acpx does not say. */
|
|
80
|
+
sessionModel(options: { agent: string; session: string; cwd: string }): Promise<string | null>
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
/**
|
|
@@ -115,6 +125,10 @@ export function buildEnsureArgs(agent: string, session: string, cwd: string, tim
|
|
|
115
125
|
return [...commonAcpxArgs(cwd, timeoutSec), agent, 'sessions', 'ensure', '-s', session]
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
export function buildShowArgs(agent: string, session: string, cwd: string): string[] {
|
|
129
|
+
return [...commonAcpxArgs(cwd, SHOW_TIMEOUT_SEC), agent, 'sessions', 'show', session]
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
export function buildExecArgs(agent: string, cwd: string, timeoutSec: number, prompt: string): string[] {
|
|
119
133
|
return [...commonAcpxArgs(cwd, timeoutSec), agent, 'exec', prompt]
|
|
120
134
|
}
|
|
@@ -125,16 +139,62 @@ const AUTH_CHECKS: Readonly<Record<string, { bin: string; args: string[] }>> = {
|
|
|
125
139
|
codex: { bin: 'codex', args: ['login', 'status'] },
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
/** The part of `codex debug models` read here: each model and the one that replaced it. */
|
|
143
|
+
const CodexCatalogSchema = z.object({
|
|
144
|
+
models: z.array(z.object({ slug: z.string(), upgrade: z.object({ model: z.string() }).nullish() })),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/** The part of `sessions show` read here: the model the session last ran. */
|
|
148
|
+
const SessionRecordSchema = z.object({ acpx: z.object({ current_model_id: z.string().min(1) }) })
|
|
149
|
+
|
|
150
|
+
/** The path `name` runs from on this PATH, or null when it is not there. */
|
|
151
|
+
export function findOnPath(name: string, env: NodeJS.ProcessEnv): string | null {
|
|
152
|
+
for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
|
|
153
|
+
if (dir === '') {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const candidate = path.join(dir, name)
|
|
157
|
+
try {
|
|
158
|
+
accessSync(candidate, constants.X_OK)
|
|
159
|
+
return candidate
|
|
160
|
+
} catch {
|
|
161
|
+
// Not in this directory.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The environment acpx runs under. Without `CLAUDE_CODE_EXECUTABLE`, the Claude adapter runs the
|
|
169
|
+
* Claude Code build bundled with it, which can be months behind the installed one and so resolves
|
|
170
|
+
* `opus` to an older model. A value the user set already is kept.
|
|
171
|
+
*/
|
|
172
|
+
export function acpxEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
173
|
+
if (env['CLAUDE_CODE_EXECUTABLE'] !== undefined) {
|
|
174
|
+
return env
|
|
175
|
+
}
|
|
176
|
+
const claude = findOnPath('claude', env)
|
|
177
|
+
return claude === null ? env : { ...env, CLAUDE_CODE_EXECUTABLE: claude }
|
|
178
|
+
}
|
|
179
|
+
|
|
128
180
|
/** Only the one call shape the runner makes, so a test double is a plain function. */
|
|
129
181
|
export type SpawnImpl = (file: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
130
182
|
export type ExecFileImpl = (
|
|
131
183
|
file: string,
|
|
132
184
|
args: string[],
|
|
133
|
-
options: {
|
|
185
|
+
options: {
|
|
186
|
+
cwd?: string
|
|
187
|
+
timeout?: number
|
|
188
|
+
maxBuffer?: number
|
|
189
|
+
killSignal?: NodeJS.Signals
|
|
190
|
+
env?: NodeJS.ProcessEnv
|
|
191
|
+
}
|
|
134
192
|
) => Promise<{ stdout: string; stderr: string }>
|
|
135
193
|
|
|
136
194
|
export interface CreateAgentRunnerOptions {
|
|
137
195
|
bin?: string
|
|
196
|
+
/** The environment acpx gets, before `acpxEnv` fills it in. Defaults to this process's. */
|
|
197
|
+
env?: NodeJS.ProcessEnv
|
|
138
198
|
spawnImpl?: SpawnImpl
|
|
139
199
|
execFileImpl?: ExecFileImpl
|
|
140
200
|
/** How far the runner's own deadline sits past acpx's. Tests shorten it. */
|
|
@@ -189,6 +249,7 @@ function createEventQueue(): {
|
|
|
189
249
|
|
|
190
250
|
export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRunner {
|
|
191
251
|
const bin = opts.bin ?? ACPX_BIN
|
|
252
|
+
const env = acpxEnv(opts.env ?? process.env)
|
|
192
253
|
const slackMs = opts.deadlineSlackMs ?? DEADLINE_SLACK_MS
|
|
193
254
|
const cancelGraceMs = opts.cancelGraceMs ?? CANCEL_TIMEOUT_SEC * 1000
|
|
194
255
|
const spawnImpl = opts.spawnImpl ?? spawn
|
|
@@ -201,9 +262,7 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
201
262
|
args: string[],
|
|
202
263
|
options: { cwd?: string; timeoutSec?: number; killSignal?: NodeJS.Signals } = {}
|
|
203
264
|
): Promise<{ ok: boolean; stdout: string; stderr: string; error?: unknown }> => {
|
|
204
|
-
const call: {
|
|
205
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
206
|
-
}
|
|
265
|
+
const call: Parameters<ExecFileImpl>[2] = { maxBuffer: 4 * 1024 * 1024, env }
|
|
207
266
|
if (options.cwd !== undefined) {
|
|
208
267
|
call.cwd = options.cwd
|
|
209
268
|
}
|
|
@@ -226,6 +285,7 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
226
285
|
run(options) {
|
|
227
286
|
return startRun(
|
|
228
287
|
bin,
|
|
288
|
+
env,
|
|
229
289
|
spawnImpl,
|
|
230
290
|
options,
|
|
231
291
|
// The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
|
|
@@ -307,6 +367,34 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
307
367
|
reason: `\`${check.bin} ${check.args.join(' ')}\` failed; log in and try again`,
|
|
308
368
|
}
|
|
309
369
|
},
|
|
370
|
+
|
|
371
|
+
async modelUpgrades(agent) {
|
|
372
|
+
if (agent !== 'codex') {
|
|
373
|
+
return new Map()
|
|
374
|
+
}
|
|
375
|
+
// Codex keeps the catalog it last fetched on disk, so this answers in milliseconds.
|
|
376
|
+
const result = await execQuiet('codex', ['debug', 'models'], { timeoutSec: 20 })
|
|
377
|
+
// A failed call, output that is not JSON, and an unknown shape all mean no known upgrades.
|
|
378
|
+
try {
|
|
379
|
+
const catalog = CodexCatalogSchema.parse(JSON.parse(result.stdout))
|
|
380
|
+
return new Map(catalog.models.flatMap(m => (m.upgrade ? [[m.slug, m.upgrade.model] as const] : [])))
|
|
381
|
+
} catch {
|
|
382
|
+
return new Map()
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async sessionModel(options) {
|
|
387
|
+
const result = await execQuiet(bin, buildShowArgs(options.agent, options.session, options.cwd), {
|
|
388
|
+
cwd: options.cwd,
|
|
389
|
+
timeoutSec: SHOW_TIMEOUT_SEC,
|
|
390
|
+
})
|
|
391
|
+
// A missing session prints an error line instead, which fails the schema like any other.
|
|
392
|
+
try {
|
|
393
|
+
return SessionRecordSchema.parse(JSON.parse(result.stdout)).acpx.current_model_id
|
|
394
|
+
} catch {
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
},
|
|
310
398
|
}
|
|
311
399
|
}
|
|
312
400
|
|
|
@@ -367,6 +455,7 @@ export function readExecStream(stdout: string): {
|
|
|
367
455
|
/** Spawns one prompt turn and turns its output into events. */
|
|
368
456
|
function startRun(
|
|
369
457
|
bin: string,
|
|
458
|
+
env: NodeJS.ProcessEnv,
|
|
370
459
|
spawnImpl: SpawnImpl,
|
|
371
460
|
options: AgentRunOptions,
|
|
372
461
|
cancelCall: (args: string[]) => Promise<unknown>,
|
|
@@ -385,7 +474,11 @@ function startRun(
|
|
|
385
474
|
|
|
386
475
|
let child: ChildProcess
|
|
387
476
|
try {
|
|
388
|
-
child = spawnImpl(bin, buildPromptArgs(options), {
|
|
477
|
+
child = spawnImpl(bin, buildPromptArgs(options), {
|
|
478
|
+
cwd: options.cwd,
|
|
479
|
+
env,
|
|
480
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
481
|
+
})
|
|
389
482
|
} catch {
|
|
390
483
|
queue.push({ type: 'error', code: 'AGENT_MISSING', message: `${bin} could not be started` })
|
|
391
484
|
queue.end()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A saved model id names a family, not a frozen version: every turn runs the newest model of it.
|
|
3
|
+
*
|
|
4
|
+
* Claude has family aliases (`opus`, `sonnet[1m]`, ...) that the Claude CLI resolves to its newest
|
|
5
|
+
* model, so a versioned id is rewritten to its alias. GPT has no aliases, and a family's next model can
|
|
6
|
+
* carry a new name (`gpt-5.6-terra` became `gpt-6-sol`), so the Codex catalog's own `upgrade` links
|
|
7
|
+
* are followed instead. `pin:<id>` opts out: the id after it is sent as written.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const PIN_PREFIX = 'pin:'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `claude-opus-4-8`, `claude-sonnet-5[1m]`, `claude-haiku-4-5-20251001`. Bedrock and Vertex ids
|
|
14
|
+
* (`us.anthropic.claude-...`, `claude-...@date`) do not match, so they run as written too.
|
|
15
|
+
*/
|
|
16
|
+
const CLAUDE_VERSIONED = /^claude-(opus|sonnet|haiku|fable)-\d[\w.-]*?(\[[^\]]+\])?$/i
|
|
17
|
+
|
|
18
|
+
/** A trailing `[...]` is a setting on the model (`[1m]`, `[high]`), kept across the upgrade. */
|
|
19
|
+
const SUFFIX = /^([^[]+)(\[[^\]]+\])?$/
|
|
20
|
+
|
|
21
|
+
/** Old model slug to the slug that replaced it, as the agent's catalog reports it. */
|
|
22
|
+
export type ModelUpgrades = ReadonlyMap<string, string>
|
|
23
|
+
|
|
24
|
+
export function latestModel(agent: string, model: string, upgrades: ModelUpgrades): string {
|
|
25
|
+
if (model.startsWith(PIN_PREFIX)) {
|
|
26
|
+
return model.slice(PIN_PREFIX.length).trim()
|
|
27
|
+
}
|
|
28
|
+
if (agent === 'claude') {
|
|
29
|
+
const versioned = CLAUDE_VERSIONED.exec(model)
|
|
30
|
+
return versioned === null ? model : `${versioned[1]?.toLowerCase()}${versioned[2] ?? ''}`
|
|
31
|
+
}
|
|
32
|
+
const parts = SUFFIX.exec(model)
|
|
33
|
+
if (parts?.[1] === undefined) {
|
|
34
|
+
return model
|
|
35
|
+
}
|
|
36
|
+
let slug = parts[1]
|
|
37
|
+
const seen = new Set([slug])
|
|
38
|
+
for (let next = upgrades.get(slug); next !== undefined && !seen.has(next); next = upgrades.get(slug)) {
|
|
39
|
+
seen.add(next)
|
|
40
|
+
slug = next
|
|
41
|
+
}
|
|
42
|
+
return `${slug}${parts[2] ?? ''}`
|
|
43
|
+
}
|
|
@@ -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
|
}
|