@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
package/src/chat/chat-manager.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
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'
|
|
9
|
+
import type { ReviewKey } from '../contract/review-key.js'
|
|
8
10
|
import type { ChatThread } from '../contract/state.js'
|
|
9
11
|
import type { SettingsStore } from '../store/settings-store.js'
|
|
10
12
|
import type { StateStore } from '../store/state-store.js'
|
|
@@ -38,9 +40,9 @@ export interface ChatManagerDeps {
|
|
|
38
40
|
now: () => Date
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
/** Everything about the
|
|
43
|
+
/** Everything about the review target one turn needs, resolved by the route. */
|
|
42
44
|
export interface ChatTarget {
|
|
43
|
-
|
|
45
|
+
key: ReviewKey
|
|
44
46
|
headSha: string
|
|
45
47
|
artifact: ReviewArtifact
|
|
46
48
|
files: FileEntry[]
|
|
@@ -58,13 +60,13 @@ export interface ChatSendInput {
|
|
|
58
60
|
|
|
59
61
|
export interface ChatManager {
|
|
60
62
|
effectiveSettings(): Promise<Settings>
|
|
61
|
-
threads(
|
|
62
|
-
createThread(
|
|
63
|
-
selectThread(
|
|
63
|
+
threads(key: ReviewKey): Promise<ChatThreadsResponse>
|
|
64
|
+
createThread(key: ReviewKey): Promise<ChatThread>
|
|
65
|
+
selectThread(key: ReviewKey, name: string): Promise<ChatThread | null>
|
|
64
66
|
send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent>
|
|
65
67
|
/** True when a turn was running and has been asked to stop. */
|
|
66
|
-
cancel(
|
|
67
|
-
busy(
|
|
68
|
+
cancel(key: ReviewKey): Promise<boolean>
|
|
69
|
+
busy(key: ReviewKey): boolean
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** The slot a turn holds while it runs. The handle is filled in once the agent has started. */
|
|
@@ -74,8 +76,31 @@ interface RunningTurn {
|
|
|
74
76
|
stopped: boolean
|
|
75
77
|
}
|
|
76
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
|
+
|
|
77
102
|
export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
78
|
-
const running = new Map<
|
|
103
|
+
const running = new Map<ReviewKey, RunningTurn>()
|
|
79
104
|
|
|
80
105
|
const effectiveSettings = async (): Promise<Settings> => {
|
|
81
106
|
const saved = await deps.settings.read()
|
|
@@ -86,12 +111,12 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
86
111
|
}
|
|
87
112
|
}
|
|
88
113
|
|
|
89
|
-
const newThread = async (
|
|
114
|
+
const newThread = async (key: ReviewKey, agent: string): Promise<ChatThread> => {
|
|
90
115
|
const at = deps.now().toISOString()
|
|
91
116
|
let created: ChatThread | null = null
|
|
92
|
-
await deps.state.update(
|
|
117
|
+
await deps.state.update(key, state => {
|
|
93
118
|
const thread: ChatThread = {
|
|
94
|
-
name: threadName(deps.repo,
|
|
119
|
+
name: threadName(deps.repo, key, agent, nextThreadIndex(state.chat.threads)),
|
|
95
120
|
agent,
|
|
96
121
|
rev: 0,
|
|
97
122
|
title: NEW_THREAD_TITLE,
|
|
@@ -108,8 +133,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
/** 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(
|
|
136
|
+
const resolveThread = async (key: ReviewKey, agent: string, wanted?: string): Promise<ChatThread> => {
|
|
137
|
+
const state = await deps.state.read(key)
|
|
113
138
|
const byName = wanted === undefined ? undefined : state.chat.threads.find(t => t.name === wanted)
|
|
114
139
|
const active =
|
|
115
140
|
byName ??
|
|
@@ -118,10 +143,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
118
143
|
: state.chat.threads.find(t => t.name === state.chat.activeThread))
|
|
119
144
|
// Changing the agent starts a new thread: the old session belongs to the old agent.
|
|
120
145
|
if (active === undefined || active.agent !== agent) {
|
|
121
|
-
return newThread(
|
|
146
|
+
return newThread(key, agent)
|
|
122
147
|
}
|
|
123
148
|
if (state.chat.activeThread !== active.name) {
|
|
124
|
-
await deps.state.update(
|
|
149
|
+
await deps.state.update(key, current => ({
|
|
125
150
|
...current,
|
|
126
151
|
chat: { ...current.chat, activeThread: active.name },
|
|
127
152
|
}))
|
|
@@ -134,18 +159,18 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
134
159
|
* try creates the session again and sends the seed again.
|
|
135
160
|
*/
|
|
136
161
|
const saveTurn = async (
|
|
137
|
-
|
|
162
|
+
key: ReviewKey,
|
|
138
163
|
thread: ChatThread,
|
|
139
164
|
userText: string,
|
|
140
165
|
assistant: ChatTurn,
|
|
141
166
|
seededHeadSha: string,
|
|
142
167
|
reached: boolean
|
|
143
168
|
): Promise<void> => {
|
|
144
|
-
await deps.transcripts.append(
|
|
169
|
+
await deps.transcripts.append(key, thread.name, assistant)
|
|
145
170
|
if (!reached) {
|
|
146
171
|
return
|
|
147
172
|
}
|
|
148
|
-
await deps.state.update(
|
|
173
|
+
await deps.state.update(key, state => ({
|
|
149
174
|
...state,
|
|
150
175
|
chat: {
|
|
151
176
|
...state.chat,
|
|
@@ -169,8 +194,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
169
194
|
* it, so the next turn creates the session again and sends the seed with it. The revision keeps
|
|
170
195
|
* counting the turns the thread has taken, which is what its title is keyed on.
|
|
171
196
|
*/
|
|
172
|
-
const forgetSession = async (
|
|
173
|
-
await deps.state.update(
|
|
197
|
+
const forgetSession = async (key: ReviewKey, name: string): Promise<void> => {
|
|
198
|
+
await deps.state.update(key, state => ({
|
|
174
199
|
...state,
|
|
175
200
|
chat: {
|
|
176
201
|
...state.chat,
|
|
@@ -180,17 +205,17 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
async function* send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent> {
|
|
183
|
-
if (running.has(target.
|
|
208
|
+
if (running.has(target.key)) {
|
|
184
209
|
throw new ChatBusyError()
|
|
185
210
|
}
|
|
186
211
|
// The slot is taken before the first await, so a second request that arrives while this one
|
|
187
212
|
// is still reading the settings sees a busy chat rather than starting a second agent.
|
|
188
213
|
const slot: RunningTurn = { run: null, stopped: false }
|
|
189
|
-
running.set(target.
|
|
214
|
+
running.set(target.key, slot)
|
|
190
215
|
try {
|
|
191
216
|
yield* runTurn(target, input, slot)
|
|
192
217
|
} finally {
|
|
193
|
-
running.delete(target.
|
|
218
|
+
running.delete(target.key)
|
|
194
219
|
}
|
|
195
220
|
}
|
|
196
221
|
|
|
@@ -200,7 +225,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
200
225
|
slot: RunningTurn
|
|
201
226
|
): AsyncIterable<ChatEvent> {
|
|
202
227
|
const settings = await effectiveSettings()
|
|
203
|
-
const thread = await resolveThread(target.
|
|
228
|
+
const thread = await resolveThread(target.key, settings.agent, input.thread)
|
|
204
229
|
const seeded = thread.seededHeadSha !== target.headSha
|
|
205
230
|
const at = deps.now().toISOString()
|
|
206
231
|
const contextBlock = await renderChatContext(input.context, {
|
|
@@ -224,18 +249,20 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
224
249
|
})
|
|
225
250
|
}
|
|
226
251
|
|
|
227
|
-
await deps.transcripts.append(target.
|
|
252
|
+
await deps.transcripts.append(target.key, thread.name, {
|
|
228
253
|
role: 'user',
|
|
229
254
|
text: input.message,
|
|
230
255
|
at,
|
|
231
256
|
context: input.context,
|
|
232
257
|
})
|
|
233
258
|
|
|
259
|
+
const model = await modelForTurn(deps.runner, settings, thread.name, deps.repoRoot)
|
|
260
|
+
|
|
234
261
|
// A stop that arrived while the turn was setting up means no agent is started at all. The
|
|
235
262
|
// transcript is written before the events, so a reader who leaves now still finds it there.
|
|
236
263
|
if (slot.stopped) {
|
|
237
264
|
await saveTurn(
|
|
238
|
-
target.
|
|
265
|
+
target.key,
|
|
239
266
|
thread,
|
|
240
267
|
input.message,
|
|
241
268
|
{ role: 'assistant', text: '', at: deps.now().toISOString(), incomplete: 'cancelled' },
|
|
@@ -254,11 +281,11 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
254
281
|
prompt,
|
|
255
282
|
cwd: deps.repoRoot,
|
|
256
283
|
timeoutSec: settings.chatTimeoutSec,
|
|
257
|
-
model
|
|
284
|
+
model,
|
|
258
285
|
maxTurns: settings.maxTurns ?? undefined,
|
|
259
286
|
// The runner scrubs the line before it gets here; this only writes it down.
|
|
260
287
|
onRawLine: line => {
|
|
261
|
-
void deps.transcripts.appendEvent(target.
|
|
288
|
+
void deps.transcripts.appendEvent(target.key, thread.name, line).catch(() => undefined)
|
|
262
289
|
},
|
|
263
290
|
})
|
|
264
291
|
slot.run = run
|
|
@@ -316,7 +343,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
316
343
|
await run.cancel().catch(() => undefined)
|
|
317
344
|
}
|
|
318
345
|
await saveTurn(
|
|
319
|
-
target.
|
|
346
|
+
target.key,
|
|
320
347
|
thread,
|
|
321
348
|
input.message,
|
|
322
349
|
{
|
|
@@ -330,15 +357,15 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
330
357
|
answer !== '' || incomplete === undefined || incomplete === 'cancelled'
|
|
331
358
|
)
|
|
332
359
|
if (incomplete === 'AGENT_NO_SESSION') {
|
|
333
|
-
await forgetSession(target.
|
|
360
|
+
await forgetSession(target.key, thread.name)
|
|
334
361
|
}
|
|
335
362
|
}
|
|
336
363
|
}
|
|
337
364
|
|
|
338
365
|
return {
|
|
339
366
|
effectiveSettings,
|
|
340
|
-
async threads(
|
|
341
|
-
const [state, settings] = await Promise.all([deps.state.read(
|
|
367
|
+
async threads(key) {
|
|
368
|
+
const [state, settings] = await Promise.all([deps.state.read(key), effectiveSettings()])
|
|
342
369
|
return {
|
|
343
370
|
threads: state.chat.threads.map(t => ({
|
|
344
371
|
name: t.name,
|
|
@@ -350,25 +377,25 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
350
377
|
agent: settings.agent,
|
|
351
378
|
}
|
|
352
379
|
},
|
|
353
|
-
async createThread(
|
|
380
|
+
async createThread(key) {
|
|
354
381
|
const settings = await effectiveSettings()
|
|
355
|
-
return newThread(
|
|
382
|
+
return newThread(key, settings.agent)
|
|
356
383
|
},
|
|
357
|
-
async selectThread(
|
|
358
|
-
const state = await deps.state.read(
|
|
384
|
+
async selectThread(key, name) {
|
|
385
|
+
const state = await deps.state.read(key)
|
|
359
386
|
const thread = state.chat.threads.find(t => t.name === name)
|
|
360
387
|
if (thread === undefined) {
|
|
361
388
|
return null
|
|
362
389
|
}
|
|
363
|
-
await deps.state.update(
|
|
390
|
+
await deps.state.update(key, current => ({
|
|
364
391
|
...current,
|
|
365
392
|
chat: { ...current.chat, activeThread: name },
|
|
366
393
|
}))
|
|
367
394
|
return thread
|
|
368
395
|
},
|
|
369
396
|
send,
|
|
370
|
-
async cancel(
|
|
371
|
-
const slot = running.get(
|
|
397
|
+
async cancel(key) {
|
|
398
|
+
const slot = running.get(key)
|
|
372
399
|
if (slot === undefined) {
|
|
373
400
|
return false
|
|
374
401
|
}
|
|
@@ -378,7 +405,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
378
405
|
await slot.run?.cancel()
|
|
379
406
|
return true
|
|
380
407
|
},
|
|
381
|
-
busy:
|
|
408
|
+
busy: key => running.has(key),
|
|
382
409
|
}
|
|
383
410
|
}
|
|
384
411
|
|
package/src/chat/threads.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A chat thread is one acpx session plus the transcript of what was said in it. Both are named
|
|
2
|
-
// after the
|
|
2
|
+
// after the review target, so two repositories never share a session and a name can never point
|
|
3
3
|
// outside the chat directory.
|
|
4
4
|
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { type ChatTurn, ChatTurnSchema } from '../contract/chat.js'
|
|
7
|
+
import { keyToString, type ReviewKey } from '../contract/review-key.js'
|
|
7
8
|
import type { Repo } from '../contract/review-artifact.js'
|
|
8
9
|
import type { ChatThread } from '../contract/state.js'
|
|
9
10
|
import { isNotFound } from '../store/atomic-json.js'
|
|
@@ -17,19 +18,19 @@ export function slug(value: string): string {
|
|
|
17
18
|
.slice(0, 40)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function threadName(repo: Repo,
|
|
21
|
-
return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${
|
|
21
|
+
export function threadName(repo: Repo, key: ReviewKey, agent: string, index: number): string {
|
|
22
|
+
return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${keyToString(key)}-${slug(agent)}-t${index}`
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const NAME_RE = /^pr-review-[a-z0-9-]+-(\d
|
|
25
|
+
const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+|branch|uncommitted)-[a-z0-9-]+-t(\d+)$/
|
|
25
26
|
|
|
26
|
-
/** True when the name is one this tool could have made for this
|
|
27
|
-
export function isThreadNameFor(name: string,
|
|
27
|
+
/** True when the name is one this tool could have made for this target. */
|
|
28
|
+
export function isThreadNameFor(name: string, key: ReviewKey): boolean {
|
|
28
29
|
const m = NAME_RE.exec(name)
|
|
29
|
-
return m !== null && m[1] ===
|
|
30
|
+
return m !== null && m[1] === keyToString(key)
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/** The next `t<k>`: one past the highest index already used on this
|
|
33
|
+
/** The next `t<k>`: one past the highest index already used on this target. */
|
|
33
34
|
export function nextThreadIndex(threads: readonly ChatThread[]): number {
|
|
34
35
|
let highest = 0
|
|
35
36
|
for (const thread of threads) {
|
|
@@ -55,22 +56,22 @@ export function threadTitle(firstMessage: string): string {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export interface TranscriptStore {
|
|
58
|
-
dir(
|
|
59
|
-
file(
|
|
60
|
-
eventsFile(
|
|
61
|
-
read(
|
|
62
|
-
append(
|
|
59
|
+
dir(key: ReviewKey): string
|
|
60
|
+
file(key: ReviewKey, name: string): string
|
|
61
|
+
eventsFile(key: ReviewKey, name: string): string
|
|
62
|
+
read(key: ReviewKey, name: string): Promise<ChatTurn[]>
|
|
63
|
+
append(key: ReviewKey, name: string, turn: ChatTurn): Promise<void>
|
|
63
64
|
/** Appends one already-scrubbed acpx line to the raw event log. */
|
|
64
|
-
appendEvent(
|
|
65
|
+
appendEvent(key: ReviewKey, name: string, line: string): Promise<void>
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
export function createTranscriptStore(prDir: (
|
|
68
|
-
const dir = (
|
|
69
|
-
const named = (
|
|
70
|
-
if (!isThreadNameFor(name,
|
|
71
|
-
throw new Error(`not a chat thread of
|
|
68
|
+
export function createTranscriptStore(prDir: (key: ReviewKey) => string): TranscriptStore {
|
|
69
|
+
const dir = (key: ReviewKey): string => path.join(prDir(key), 'chat')
|
|
70
|
+
const named = (key: ReviewKey, name: string, suffix: string): string => {
|
|
71
|
+
if (!isThreadNameFor(name, key)) {
|
|
72
|
+
throw new Error(`not a chat thread of ${keyToString(key)}: ${name}`)
|
|
72
73
|
}
|
|
73
|
-
return path.join(dir(
|
|
74
|
+
return path.join(dir(key), `${name}${suffix}`)
|
|
74
75
|
}
|
|
75
76
|
const appendLine = async (file: string, line: string): Promise<void> => {
|
|
76
77
|
await mkdir(path.dirname(file), { recursive: true })
|
|
@@ -78,12 +79,12 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
|
|
|
78
79
|
}
|
|
79
80
|
return {
|
|
80
81
|
dir,
|
|
81
|
-
file: (
|
|
82
|
-
eventsFile: (
|
|
83
|
-
async read(
|
|
82
|
+
file: (key, name) => named(key, name, '.jsonl'),
|
|
83
|
+
eventsFile: (key, name) => named(key, name, '.events.ndjson'),
|
|
84
|
+
async read(key, name) {
|
|
84
85
|
let text: string
|
|
85
86
|
try {
|
|
86
|
-
text = await readFile(named(
|
|
87
|
+
text = await readFile(named(key, name, '.jsonl'), 'utf8')
|
|
87
88
|
} catch (err) {
|
|
88
89
|
if (isNotFound(err)) {
|
|
89
90
|
return []
|
|
@@ -108,7 +109,7 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
|
|
|
108
109
|
}
|
|
109
110
|
return turns
|
|
110
111
|
},
|
|
111
|
-
append: (
|
|
112
|
-
appendEvent: (
|
|
112
|
+
append: (key, name, turn) => appendLine(named(key, name, '.jsonl'), JSON.stringify(turn)),
|
|
113
|
+
appendEvent: (key, name, line) => appendLine(named(key, name, '.events.ndjson'), line),
|
|
113
114
|
}
|
|
114
115
|
}
|
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,
|
|
@@ -18,13 +21,15 @@ import {
|
|
|
18
21
|
import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
|
|
19
22
|
import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
20
23
|
import { createGit } from './git/git.js'
|
|
21
|
-
import {
|
|
24
|
+
import { createHostClient } from './host/client.js'
|
|
22
25
|
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,13 +40,19 @@ 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]
|
|
41
47
|
|
|
42
48
|
serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
|
|
43
49
|
[--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
|
|
44
|
-
prepare (--pr <n> | --base <ref> --head <ref>) [--force]
|
|
50
|
+
prepare (--pr <n> | --branch | --uncommitted | --base <ref> --head <ref>) [--force]
|
|
51
|
+
[--base <ref>] [--repo <dir>] [--data-dir <dir>]
|
|
52
|
+
(--branch reviews the current branch against the default branch, and
|
|
53
|
+
--uncommitted reviews it with the working tree's edits and new files on top.
|
|
54
|
+
Both are for work with no PR yet, take --base <ref> to compare against
|
|
55
|
+
another branch, and show at /review/branch and /review/uncommitted.)
|
|
45
56
|
validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
|
|
46
57
|
(--fix trims over-cap titles in place and reports each one)
|
|
47
58
|
publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
|
|
@@ -50,9 +61,12 @@ const USAGE = `usage: pr-review <command> [flags]
|
|
|
50
61
|
(both flags: the named commit is exported and the number stamps the zip)
|
|
51
62
|
import <zip> [--pr <n>] [--force] [--repo <dir>] [--data-dir <dir>]
|
|
52
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)
|
|
53
67
|
|
|
54
68
|
Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
|
|
55
|
-
Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
|
|
69
|
+
Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
|
|
56
70
|
`
|
|
57
71
|
|
|
58
72
|
const io: CliIo = {
|
|
@@ -125,15 +139,15 @@ async function serve(argv: string[]): Promise<number> {
|
|
|
125
139
|
return EXIT.ok
|
|
126
140
|
}
|
|
127
141
|
|
|
128
|
-
/** doctor builds no AppContext: it has to answer even when the repo or
|
|
142
|
+
/** doctor builds no AppContext: it has to answer even when the repo or host CLI is the problem. */
|
|
129
143
|
async function doctorCommand(argv: string[]): Promise<number> {
|
|
130
144
|
const { repo, dataDir, rest } = splitCommonFlags(argv)
|
|
131
145
|
const cwd = process.cwd()
|
|
132
|
-
const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
|
|
133
146
|
return runDoctor(
|
|
134
147
|
{
|
|
135
|
-
git,
|
|
136
|
-
|
|
148
|
+
git: createGit(repo === undefined ? cwd : path.resolve(cwd, repo)),
|
|
149
|
+
env: process.env,
|
|
150
|
+
client: host => createHostClient(host.cli),
|
|
137
151
|
version: readPackageVersion(),
|
|
138
152
|
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
139
153
|
dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
|
|
@@ -150,6 +164,52 @@ async function installSkillCommand(argv: string[]): Promise<number> {
|
|
|
150
164
|
return runInstallSkill({ repoRoot, cwd }, rest, io)
|
|
151
165
|
}
|
|
152
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
|
+
|
|
153
213
|
export async function main(argv: string[]): Promise<number> {
|
|
154
214
|
// `pnpm review -- --port 3011` forwards the `--` itself; drop it so parseArgs sees the flags.
|
|
155
215
|
const [command, ...rest] = argv.filter(a => a !== '--')
|
|
@@ -169,6 +229,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
169
229
|
return await installSkillCommand(rest)
|
|
170
230
|
case 'doctor':
|
|
171
231
|
return await doctorCommand(rest)
|
|
232
|
+
case 'upgrade':
|
|
233
|
+
return await upgradeCommand(rest)
|
|
172
234
|
default: {
|
|
173
235
|
const { repo, dataDir, rest: own } = splitCommonFlags(rest)
|
|
174
236
|
const ctx = await buildContext(repo, dataDir)
|