@vintasoftware/pr-review-canvas 0.4.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 +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -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 +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- 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/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -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 +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- 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/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
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
|
+
}
|
package/src/chat/chat-manager.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// and keep the transcript. The lock is what makes `CHAT_BUSY` a real answer rather than two
|
|
3
3
|
// agents writing into one thread.
|
|
4
4
|
import type { AgentRunner } from '../acpx/acpx.js'
|
|
5
|
+
import { latestModel } from '../acpx/models.js'
|
|
5
6
|
import type { ChatContext, ChatEvent, ChatThreadsResponse, ChatTurn } from '../contract/chat.js'
|
|
6
7
|
import type { FileEntry, Repo, ReviewArtifact } from '../contract/review-artifact.js'
|
|
7
8
|
import type { Settings, SettingsOverrides } from '../contract/settings.js'
|
|
@@ -75,6 +76,29 @@ interface RunningTurn {
|
|
|
75
76
|
stopped: boolean
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The `--model` of one turn: the newest model of the saved family. With no saved model the agent's
|
|
81
|
+
* own default applies, unless the session is on a model that has since been replaced: a thread
|
|
82
|
+
* started months ago, or a default set to an old id.
|
|
83
|
+
*/
|
|
84
|
+
async function modelForTurn(
|
|
85
|
+
runner: AgentRunner,
|
|
86
|
+
settings: Settings,
|
|
87
|
+
session: string,
|
|
88
|
+
cwd: string
|
|
89
|
+
): Promise<string | undefined> {
|
|
90
|
+
const upgrades = await runner.modelUpgrades(settings.agent)
|
|
91
|
+
if (settings.model !== null) {
|
|
92
|
+
return latestModel(settings.agent, settings.model, upgrades)
|
|
93
|
+
}
|
|
94
|
+
const current = await runner.sessionModel({ agent: settings.agent, session, cwd })
|
|
95
|
+
if (current === null) {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
const latest = latestModel(settings.agent, current, upgrades)
|
|
99
|
+
return latest === current ? undefined : latest
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
79
103
|
const running = new Map<ReviewKey, RunningTurn>()
|
|
80
104
|
|
|
@@ -232,6 +256,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
232
256
|
context: input.context,
|
|
233
257
|
})
|
|
234
258
|
|
|
259
|
+
const model = await modelForTurn(deps.runner, settings, thread.name, deps.repoRoot)
|
|
260
|
+
|
|
235
261
|
// A stop that arrived while the turn was setting up means no agent is started at all. The
|
|
236
262
|
// transcript is written before the events, so a reader who leaves now still finds it there.
|
|
237
263
|
if (slot.stopped) {
|
|
@@ -255,7 +281,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
255
281
|
prompt,
|
|
256
282
|
cwd: deps.repoRoot,
|
|
257
283
|
timeoutSec: settings.chatTimeoutSec,
|
|
258
|
-
model
|
|
284
|
+
model,
|
|
259
285
|
maxTurns: settings.maxTurns ?? undefined,
|
|
260
286
|
// The runner scrubs the line before it gets here; this only writes it down.
|
|
261
287
|
onRawLine: line => {
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
1
3
|
import path from 'node:path'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
5
|
import { parseArgs } from 'node:util'
|
|
3
|
-
import { createAgentRunner } from './acpx/acpx.js'
|
|
6
|
+
import { ACPX_BIN, createAgentRunner, findOnPath } from './acpx/acpx.js'
|
|
4
7
|
import {
|
|
5
8
|
type CliIo,
|
|
6
9
|
EXIT,
|
|
@@ -23,8 +26,10 @@ import { loadProjectConfig } from './project-config.js'
|
|
|
23
26
|
import { checkSkill } from './review/doctor.js'
|
|
24
27
|
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
25
28
|
import { startServer } from './server/node-server.js'
|
|
29
|
+
import { PACKAGE_ROOT } from './paths.js'
|
|
26
30
|
import { readJson } from './store/atomic-json.js'
|
|
27
31
|
import { ensureDataDir } from './store/data-dir.js'
|
|
32
|
+
import { type CommandResult, runUpgrade } from './upgrade.js'
|
|
28
33
|
|
|
29
34
|
const SUBCOMMANDS = [
|
|
30
35
|
'serve',
|
|
@@ -35,6 +40,7 @@ const SUBCOMMANDS = [
|
|
|
35
40
|
'import',
|
|
36
41
|
'install-skill',
|
|
37
42
|
'doctor',
|
|
43
|
+
'upgrade',
|
|
38
44
|
] as const
|
|
39
45
|
|
|
40
46
|
const USAGE = `usage: pr-review <command> [flags]
|
|
@@ -55,6 +61,9 @@ const USAGE = `usage: pr-review <command> [flags]
|
|
|
55
61
|
(both flags: the named commit is exported and the number stamps the zip)
|
|
56
62
|
import <zip> [--pr <n>] [--force] [--repo <dir>] [--data-dir <dir>]
|
|
57
63
|
doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
|
|
64
|
+
upgrade [--yes] [--only package,acpx,skill] [--repo <dir>]
|
|
65
|
+
(updates pr-review and acpx with npm, and refreshes the project's skill copies;
|
|
66
|
+
lists the changes and asks first unless --yes)
|
|
58
67
|
|
|
59
68
|
Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
|
|
60
69
|
Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
|
|
@@ -155,6 +164,52 @@ async function installSkillCommand(argv: string[]): Promise<number> {
|
|
|
155
164
|
return runInstallSkill({ repoRoot, cwd }, rest, io)
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
function runCommand(file: string, args: string[]): Promise<CommandResult> {
|
|
168
|
+
return new Promise(resolve => {
|
|
169
|
+
execFile(file, args, { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) =>
|
|
170
|
+
resolve({ ok: err === null, stdout, stderr })
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function confirmOnTerminal(question: string): Promise<boolean | null> {
|
|
176
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return null
|
|
177
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
178
|
+
try {
|
|
179
|
+
return /^y(es)?$/i.test((await rl.question(question)).trim())
|
|
180
|
+
} finally {
|
|
181
|
+
rl.close()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function upgradeCommand(argv: string[]): Promise<number> {
|
|
186
|
+
const { repo, rest } = splitCommonFlags(argv)
|
|
187
|
+
const cwd = process.cwd()
|
|
188
|
+
let repoRoot: string | null
|
|
189
|
+
try {
|
|
190
|
+
repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
|
|
191
|
+
} catch {
|
|
192
|
+
repoRoot = null
|
|
193
|
+
}
|
|
194
|
+
const pkg = JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { name: string }
|
|
195
|
+
return runUpgrade(
|
|
196
|
+
{
|
|
197
|
+
packageName: pkg.name,
|
|
198
|
+
version: readPackageVersion(),
|
|
199
|
+
packageRoot: PACKAGE_ROOT,
|
|
200
|
+
repoRoot,
|
|
201
|
+
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
202
|
+
acpxPath: findOnPath(ACPX_BIN, process.env),
|
|
203
|
+
run: runCommand,
|
|
204
|
+
runInstalled: args =>
|
|
205
|
+
runCommand(process.execPath, [path.join(PACKAGE_ROOT, 'bin', 'pr-review.mjs'), ...args]),
|
|
206
|
+
confirm: confirmOnTerminal,
|
|
207
|
+
},
|
|
208
|
+
rest,
|
|
209
|
+
io
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
158
213
|
export async function main(argv: string[]): Promise<number> {
|
|
159
214
|
// `pnpm review -- --port 3011` forwards the `--` itself; drop it so parseArgs sees the flags.
|
|
160
215
|
const [command, ...rest] = argv.filter(a => a !== '--')
|
|
@@ -174,6 +229,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
174
229
|
return await installSkillCommand(rest)
|
|
175
230
|
case 'doctor':
|
|
176
231
|
return await doctorCommand(rest)
|
|
232
|
+
case 'upgrade':
|
|
233
|
+
return await upgradeCommand(rest)
|
|
177
234
|
default: {
|
|
178
235
|
const { repo, dataDir, rest: own } = splitCommonFlags(rest)
|
|
179
236
|
const ctx = await buildContext(repo, dataDir)
|
package/src/commands.ts
CHANGED
|
@@ -213,7 +213,11 @@ async function validateFile(
|
|
|
213
213
|
}
|
|
214
214
|
const artifact = ReviewArtifactSchema.safeParse(parsed.raw)
|
|
215
215
|
const input = artifact.success ? artifactToModelOutput(artifact.data satisfies ReviewArtifact) : parsed.raw
|
|
216
|
-
|
|
216
|
+
// A stored canvas may predate the rules about what a generation must hide; only its correctness is checked.
|
|
217
|
+
const result = validateModelOutput(input, {
|
|
218
|
+
...(await validationInput(ctx, context, input)),
|
|
219
|
+
storedArtifact: artifact.success,
|
|
220
|
+
})
|
|
217
221
|
return { ok: result.ok, errors: result.errors }
|
|
218
222
|
}
|
|
219
223
|
|
package/src/contract/api.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { z } from 'zod'
|
|
|
2
2
|
import type { CanvasManifest } from './canvas-manifest.js'
|
|
3
3
|
import type { CommentsPayload, IssueComment, ReviewComment } from './comments.js'
|
|
4
4
|
import type { SharedCanvasInfoSchema } from './discovery.js'
|
|
5
|
-
import type { FileEntry, Pr, ReviewArtifact } from './review-artifact.js'
|
|
5
|
+
import type { FileEntry, FoldLevel, Pr, ReviewArtifact } from './review-artifact.js'
|
|
6
6
|
import type { LocalKey, ReviewKey } from './review-key.js'
|
|
7
7
|
import type { PrState } from './state.js'
|
|
8
8
|
|
|
@@ -123,6 +123,12 @@ export interface PrBundle {
|
|
|
123
123
|
stale?: StaleInfo
|
|
124
124
|
/** Set on a ready bundle whose canvas was generated for another commit with an identical diff. */
|
|
125
125
|
carriedOver?: CarriedOverInfo
|
|
126
|
+
/**
|
|
127
|
+
* The canvas the reviewed marks on this page were made on, when they followed a line of descent
|
|
128
|
+
* onto this one; absent when none did. Not necessarily this canvas's own basis: the reviewer may
|
|
129
|
+
* have marked nothing on the canvases in between.
|
|
130
|
+
*/
|
|
131
|
+
marksCarriedFrom?: string
|
|
126
132
|
sharedCanvas?: SharedCanvasInfo
|
|
127
133
|
skillCommand: string
|
|
128
134
|
/** Set on a local review: work that has no pull request, so the forge side of the page is off. */
|
|
@@ -162,12 +168,20 @@ export interface ReviewSummary {
|
|
|
162
168
|
export interface ReviewBodyResponse {
|
|
163
169
|
headSha: string
|
|
164
170
|
body: string
|
|
171
|
+
/** How many pending comments would go out with the review. */
|
|
172
|
+
pending: number
|
|
165
173
|
/** The titles of the layers that still need a look; approve is refused while this is not empty. */
|
|
166
174
|
unreviewed: string[]
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
export interface PostReviewResponse {
|
|
170
178
|
review: ReviewSummary
|
|
179
|
+
comments: ReviewComment[]
|
|
180
|
+
warnings: string[]
|
|
181
|
+
/** How many pending comments went out with the review. */
|
|
182
|
+
submitted: number
|
|
183
|
+
/** The state after the pending review was cleared, so the page drops its drafts in one step. */
|
|
184
|
+
state: PrState
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
export interface PatchesResponse {
|
|
@@ -219,3 +233,14 @@ export interface ChatStatus {
|
|
|
219
233
|
export interface HomeData {
|
|
220
234
|
recentPrs: Array<{ number: number; title: string; updatedAt: string }>
|
|
221
235
|
}
|
|
236
|
+
|
|
237
|
+
/** The JSON the review page carries in its bootstrap script; the app reads it before any request. */
|
|
238
|
+
export interface ReviewBootstrap {
|
|
239
|
+
prNumber: ReviewKey
|
|
240
|
+
owner: string
|
|
241
|
+
repo: string
|
|
242
|
+
version: string
|
|
243
|
+
host: PublicHost
|
|
244
|
+
/** The reading level the canvas opens at, from the settings file. */
|
|
245
|
+
foldLevel: FoldLevel
|
|
246
|
+
}
|
|
@@ -23,6 +23,11 @@ export const CanvasIndexSchema = z.object({
|
|
|
23
23
|
generatedAt: z.string(),
|
|
24
24
|
source: z.enum(['local', 'import']),
|
|
25
25
|
importedAt: z.string().optional(),
|
|
26
|
+
/**
|
|
27
|
+
* The canvas this one was generated from. Kept here as well as on the artifact so the line
|
|
28
|
+
* of descent can be walked from the index alone, without opening every canvas on the way.
|
|
29
|
+
*/
|
|
30
|
+
basisCanvasSha: z.string().optional(),
|
|
26
31
|
/** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
|
|
27
32
|
worktree: z.boolean().optional(),
|
|
28
33
|
})
|
|
@@ -2,7 +2,14 @@ import { z } from 'zod'
|
|
|
2
2
|
import { DefaultLayerSchema, GenerationModeSchema, HighRiskRuleSchema } from '../project-config.js'
|
|
3
3
|
import { DEFAULT_TEST_PATTERNS } from '../review/test-paths.js'
|
|
4
4
|
import { type LocalKey, LocalKeySchema } from './review-key.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
FileEntrySchema,
|
|
7
|
+
LIMITS,
|
|
8
|
+
POINT_KINDS,
|
|
9
|
+
PrSchema,
|
|
10
|
+
RepoSchema,
|
|
11
|
+
type TextCaps,
|
|
12
|
+
} from './review-artifact.js'
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
|
|
@@ -46,6 +53,48 @@ const capsShape = {
|
|
|
46
53
|
diagram: z.number().int().positive(),
|
|
47
54
|
} satisfies Record<keyof TextCaps, z.ZodNumber>
|
|
48
55
|
|
|
56
|
+
/** Which files the head changes relative to the basis canvas's diff, by path. */
|
|
57
|
+
export const FileDeltaSchema = z.object({
|
|
58
|
+
unchanged: z.array(z.string()),
|
|
59
|
+
changed: z.array(z.string()),
|
|
60
|
+
added: z.array(z.string()),
|
|
61
|
+
removed: z.array(z.string()),
|
|
62
|
+
})
|
|
63
|
+
export type FileDelta = z.infer<typeof FileDeltaSchema>
|
|
64
|
+
|
|
65
|
+
export const BasisSplitLayerSchema = z.object({
|
|
66
|
+
key: z.string().min(1),
|
|
67
|
+
title: z.string(),
|
|
68
|
+
/** `carried` when the head touches none of the layer's files. */
|
|
69
|
+
status: z.enum(['carried', 're-judged']),
|
|
70
|
+
carriedFiles: z.array(z.string()),
|
|
71
|
+
reJudgedFiles: z.array(z.string()),
|
|
72
|
+
})
|
|
73
|
+
export type BasisSplitLayer = z.infer<typeof BasisSplitLayerSchema>
|
|
74
|
+
|
|
75
|
+
export const BasisSplitPointSchema = z.object({
|
|
76
|
+
kind: z.enum(POINT_KINDS),
|
|
77
|
+
path: z.string().min(1),
|
|
78
|
+
title: z.string(),
|
|
79
|
+
status: z.enum(['carried', 're-judged']),
|
|
80
|
+
})
|
|
81
|
+
export type BasisSplitPoint = z.infer<typeof BasisSplitPointSchema>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The basis canvas of an incremental run, already divided into what the head leaves untouched and
|
|
85
|
+
* what has to be decided anew. `prepare` computes it, so the generator reads two lists instead of
|
|
86
|
+
* comparing diffs itself, and `publish` records `canvasSha` on the canvas it stores.
|
|
87
|
+
*/
|
|
88
|
+
export const BasisSplitSchema = z.object({
|
|
89
|
+
canvasSha: z.string().regex(/^[0-9a-f]{40}$/),
|
|
90
|
+
/** Absolute path of the basis canvas's `review.json`, in the canvas store. */
|
|
91
|
+
reviewJsonPath: z.string().min(1),
|
|
92
|
+
files: FileDeltaSchema,
|
|
93
|
+
layers: z.array(BasisSplitLayerSchema),
|
|
94
|
+
points: z.array(BasisSplitPointSchema),
|
|
95
|
+
})
|
|
96
|
+
export type BasisSplit = z.infer<typeof BasisSplitSchema>
|
|
97
|
+
|
|
49
98
|
/**
|
|
50
99
|
* `context.json`: everything the agent and `publish` need about one prepared canvas. Written by
|
|
51
100
|
* `prepare` next to `prompt.md`; `publish` validates `model.json` against the hunk index and the
|
|
@@ -87,6 +136,8 @@ export const GenerationContextSchema = z.object({
|
|
|
87
136
|
smallPr: z.boolean(),
|
|
88
137
|
/** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
|
|
89
138
|
largePr: z.boolean(),
|
|
139
|
+
/** Absent when this canvas is generated from a blank page: no basis, `--force`, or turned off. */
|
|
140
|
+
basis: BasisSplitSchema.optional(),
|
|
90
141
|
preparedAt: z.string(),
|
|
91
142
|
})
|
|
92
143
|
export type GenerationContext = z.infer<typeof GenerationContextSchema>
|
package/src/contract/keys.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A review the reviewer is still writing: comments kept on this machine until the review is
|
|
2
|
+
// submitted, the way a pending review works on the forge's own page.
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { SideSchema } from './review-artifact.js'
|
|
5
|
+
import { COMMENT_BODY_MAX } from './comments.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One comment waiting in the pending review. It names a line the way a posted inline comment
|
|
9
|
+
* does, so submitting it needs nothing the page has to look up again.
|
|
10
|
+
*/
|
|
11
|
+
export const PendingCommentSchema = z.object({
|
|
12
|
+
/** Local id, unique inside one target's state. Never a forge id: nothing was posted yet. */
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
path: z.string().min(1),
|
|
15
|
+
line: z.number().int().positive(),
|
|
16
|
+
side: SideSchema,
|
|
17
|
+
/** The first line of a multi-line comment, absent when it covers one line. */
|
|
18
|
+
startLine: z.number().int().positive().optional(),
|
|
19
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
20
|
+
/** Set when the comment came from an attention point, so the point can be marked posted. */
|
|
21
|
+
pointFingerprint: z.string().min(1).optional(),
|
|
22
|
+
/** The commit the reviewer was reading when they wrote it. */
|
|
23
|
+
headSha: z.string(),
|
|
24
|
+
createdAt: z.string(),
|
|
25
|
+
/** Creation time until the reviewer edits the draft. */
|
|
26
|
+
updatedAt: z.string(),
|
|
27
|
+
})
|
|
28
|
+
export type PendingComment = z.infer<typeof PendingCommentSchema>
|
|
29
|
+
|
|
30
|
+
/** What the page sends to `POST /api/prs/:n/pending`. */
|
|
31
|
+
export const AddPendingInputSchema = z.object({
|
|
32
|
+
path: z.string().min(1),
|
|
33
|
+
line: z.number().int().positive(),
|
|
34
|
+
side: SideSchema,
|
|
35
|
+
startLine: z.number().int().positive().optional(),
|
|
36
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
37
|
+
pointFingerprint: z.string().min(1).optional(),
|
|
38
|
+
headSha: z
|
|
39
|
+
.string()
|
|
40
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
41
|
+
.optional(),
|
|
42
|
+
})
|
|
43
|
+
export type AddPendingInput = z.infer<typeof AddPendingInputSchema>
|
|
44
|
+
|
|
45
|
+
/** What the page sends to `PATCH /api/prs/:n/pending/:id`. */
|
|
46
|
+
export const EditPendingInputSchema = z.object({
|
|
47
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
48
|
+
})
|
|
49
|
+
export type EditPendingInput = z.infer<typeof EditPendingInputSchema>
|