@vintasoftware/pr-review-canvas 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- 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 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
package/src/chat/chat-manager.ts
CHANGED
|
@@ -5,12 +5,19 @@ import type { AgentRunner } from '../acpx/acpx.js'
|
|
|
5
5
|
import type { ChatContext, ChatEvent, ChatThreadsResponse, ChatTurn } from '../contract/chat.js'
|
|
6
6
|
import type { FileEntry, Repo, ReviewArtifact } from '../contract/review-artifact.js'
|
|
7
7
|
import type { Settings, SettingsOverrides } from '../contract/settings.js'
|
|
8
|
+
import type { ReviewKey } from '../contract/review-key.js'
|
|
8
9
|
import type { ChatThread } from '../contract/state.js'
|
|
9
10
|
import type { SettingsStore } from '../store/settings-store.js'
|
|
10
11
|
import type { StateStore } from '../store/state-store.js'
|
|
11
12
|
import { type ContextSources, renderChatContext } from './context.js'
|
|
12
13
|
import { renderSeed, type SeedPaths } from './seed.js'
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
NEW_THREAD_TITLE,
|
|
16
|
+
nextThreadIndex,
|
|
17
|
+
type TranscriptStore,
|
|
18
|
+
threadName,
|
|
19
|
+
threadTitle,
|
|
20
|
+
} from './threads.js'
|
|
14
21
|
|
|
15
22
|
export class ChatBusyError extends Error {
|
|
16
23
|
constructor() {
|
|
@@ -32,9 +39,9 @@ export interface ChatManagerDeps {
|
|
|
32
39
|
now: () => Date
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/** Everything about the
|
|
42
|
+
/** Everything about the review target one turn needs, resolved by the route. */
|
|
36
43
|
export interface ChatTarget {
|
|
37
|
-
|
|
44
|
+
key: ReviewKey
|
|
38
45
|
headSha: string
|
|
39
46
|
artifact: ReviewArtifact
|
|
40
47
|
files: FileEntry[]
|
|
@@ -52,13 +59,13 @@ export interface ChatSendInput {
|
|
|
52
59
|
|
|
53
60
|
export interface ChatManager {
|
|
54
61
|
effectiveSettings(): Promise<Settings>
|
|
55
|
-
threads(
|
|
56
|
-
createThread(
|
|
57
|
-
selectThread(
|
|
62
|
+
threads(key: ReviewKey): Promise<ChatThreadsResponse>
|
|
63
|
+
createThread(key: ReviewKey): Promise<ChatThread>
|
|
64
|
+
selectThread(key: ReviewKey, name: string): Promise<ChatThread | null>
|
|
58
65
|
send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent>
|
|
59
66
|
/** True when a turn was running and has been asked to stop. */
|
|
60
|
-
cancel(
|
|
61
|
-
busy(
|
|
67
|
+
cancel(key: ReviewKey): Promise<boolean>
|
|
68
|
+
busy(key: ReviewKey): boolean
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/** The slot a turn holds while it runs. The handle is filled in once the agent has started. */
|
|
@@ -69,7 +76,7 @@ interface RunningTurn {
|
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
72
|
-
const running = new Map<
|
|
79
|
+
const running = new Map<ReviewKey, RunningTurn>()
|
|
73
80
|
|
|
74
81
|
const effectiveSettings = async (): Promise<Settings> => {
|
|
75
82
|
const saved = await deps.settings.read()
|
|
@@ -80,12 +87,12 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
const newThread = async (
|
|
90
|
+
const newThread = async (key: ReviewKey, agent: string): Promise<ChatThread> => {
|
|
84
91
|
const at = deps.now().toISOString()
|
|
85
92
|
let created: ChatThread | null = null
|
|
86
|
-
await deps.state.update(
|
|
93
|
+
await deps.state.update(key, state => {
|
|
87
94
|
const thread: ChatThread = {
|
|
88
|
-
name: threadName(deps.repo,
|
|
95
|
+
name: threadName(deps.repo, key, agent, nextThreadIndex(state.chat.threads)),
|
|
89
96
|
agent,
|
|
90
97
|
rev: 0,
|
|
91
98
|
title: NEW_THREAD_TITLE,
|
|
@@ -102,8 +109,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/** The thread a message goes to: the one asked for, the active one, or a fresh one. */
|
|
105
|
-
const resolveThread = async (
|
|
106
|
-
const state = await deps.state.read(
|
|
112
|
+
const resolveThread = async (key: ReviewKey, agent: string, wanted?: string): Promise<ChatThread> => {
|
|
113
|
+
const state = await deps.state.read(key)
|
|
107
114
|
const byName = wanted === undefined ? undefined : state.chat.threads.find(t => t.name === wanted)
|
|
108
115
|
const active =
|
|
109
116
|
byName ??
|
|
@@ -112,10 +119,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
112
119
|
: state.chat.threads.find(t => t.name === state.chat.activeThread))
|
|
113
120
|
// Changing the agent starts a new thread: the old session belongs to the old agent.
|
|
114
121
|
if (active === undefined || active.agent !== agent) {
|
|
115
|
-
return newThread(
|
|
122
|
+
return newThread(key, agent)
|
|
116
123
|
}
|
|
117
124
|
if (state.chat.activeThread !== active.name) {
|
|
118
|
-
await deps.state.update(
|
|
125
|
+
await deps.state.update(key, current => ({
|
|
119
126
|
...current,
|
|
120
127
|
chat: { ...current.chat, activeThread: active.name },
|
|
121
128
|
}))
|
|
@@ -128,18 +135,18 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
128
135
|
* try creates the session again and sends the seed again.
|
|
129
136
|
*/
|
|
130
137
|
const saveTurn = async (
|
|
131
|
-
|
|
138
|
+
key: ReviewKey,
|
|
132
139
|
thread: ChatThread,
|
|
133
140
|
userText: string,
|
|
134
141
|
assistant: ChatTurn,
|
|
135
142
|
seededHeadSha: string,
|
|
136
143
|
reached: boolean
|
|
137
144
|
): Promise<void> => {
|
|
138
|
-
await deps.transcripts.append(
|
|
145
|
+
await deps.transcripts.append(key, thread.name, assistant)
|
|
139
146
|
if (!reached) {
|
|
140
147
|
return
|
|
141
148
|
}
|
|
142
|
-
await deps.state.update(
|
|
149
|
+
await deps.state.update(key, state => ({
|
|
143
150
|
...state,
|
|
144
151
|
chat: {
|
|
145
152
|
...state.chat,
|
|
@@ -163,8 +170,8 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
163
170
|
* it, so the next turn creates the session again and sends the seed with it. The revision keeps
|
|
164
171
|
* counting the turns the thread has taken, which is what its title is keyed on.
|
|
165
172
|
*/
|
|
166
|
-
const forgetSession = async (
|
|
167
|
-
await deps.state.update(
|
|
173
|
+
const forgetSession = async (key: ReviewKey, name: string): Promise<void> => {
|
|
174
|
+
await deps.state.update(key, state => ({
|
|
168
175
|
...state,
|
|
169
176
|
chat: {
|
|
170
177
|
...state.chat,
|
|
@@ -174,23 +181,27 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
async function* send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent> {
|
|
177
|
-
if (running.has(target.
|
|
184
|
+
if (running.has(target.key)) {
|
|
178
185
|
throw new ChatBusyError()
|
|
179
186
|
}
|
|
180
187
|
// The slot is taken before the first await, so a second request that arrives while this one
|
|
181
188
|
// is still reading the settings sees a busy chat rather than starting a second agent.
|
|
182
189
|
const slot: RunningTurn = { run: null, stopped: false }
|
|
183
|
-
running.set(target.
|
|
190
|
+
running.set(target.key, slot)
|
|
184
191
|
try {
|
|
185
192
|
yield* runTurn(target, input, slot)
|
|
186
193
|
} finally {
|
|
187
|
-
running.delete(target.
|
|
194
|
+
running.delete(target.key)
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
|
|
191
|
-
async function* runTurn(
|
|
198
|
+
async function* runTurn(
|
|
199
|
+
target: ChatTarget,
|
|
200
|
+
input: ChatSendInput,
|
|
201
|
+
slot: RunningTurn
|
|
202
|
+
): AsyncIterable<ChatEvent> {
|
|
192
203
|
const settings = await effectiveSettings()
|
|
193
|
-
const thread = await resolveThread(target.
|
|
204
|
+
const thread = await resolveThread(target.key, settings.agent, input.thread)
|
|
194
205
|
const seeded = thread.seededHeadSha !== target.headSha
|
|
195
206
|
const at = deps.now().toISOString()
|
|
196
207
|
const contextBlock = await renderChatContext(input.context, {
|
|
@@ -214,7 +225,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
214
225
|
})
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
await deps.transcripts.append(target.
|
|
228
|
+
await deps.transcripts.append(target.key, thread.name, {
|
|
218
229
|
role: 'user',
|
|
219
230
|
text: input.message,
|
|
220
231
|
at,
|
|
@@ -225,7 +236,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
225
236
|
// transcript is written before the events, so a reader who leaves now still finds it there.
|
|
226
237
|
if (slot.stopped) {
|
|
227
238
|
await saveTurn(
|
|
228
|
-
target.
|
|
239
|
+
target.key,
|
|
229
240
|
thread,
|
|
230
241
|
input.message,
|
|
231
242
|
{ role: 'assistant', text: '', at: deps.now().toISOString(), incomplete: 'cancelled' },
|
|
@@ -248,7 +259,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
248
259
|
maxTurns: settings.maxTurns ?? undefined,
|
|
249
260
|
// The runner scrubs the line before it gets here; this only writes it down.
|
|
250
261
|
onRawLine: line => {
|
|
251
|
-
void deps.transcripts.appendEvent(target.
|
|
262
|
+
void deps.transcripts.appendEvent(target.key, thread.name, line).catch(() => undefined)
|
|
252
263
|
},
|
|
253
264
|
})
|
|
254
265
|
slot.run = run
|
|
@@ -306,7 +317,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
306
317
|
await run.cancel().catch(() => undefined)
|
|
307
318
|
}
|
|
308
319
|
await saveTurn(
|
|
309
|
-
target.
|
|
320
|
+
target.key,
|
|
310
321
|
thread,
|
|
311
322
|
input.message,
|
|
312
323
|
{
|
|
@@ -320,15 +331,15 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
320
331
|
answer !== '' || incomplete === undefined || incomplete === 'cancelled'
|
|
321
332
|
)
|
|
322
333
|
if (incomplete === 'AGENT_NO_SESSION') {
|
|
323
|
-
await forgetSession(target.
|
|
334
|
+
await forgetSession(target.key, thread.name)
|
|
324
335
|
}
|
|
325
336
|
}
|
|
326
337
|
}
|
|
327
338
|
|
|
328
339
|
return {
|
|
329
340
|
effectiveSettings,
|
|
330
|
-
async threads(
|
|
331
|
-
const [state, settings] = await Promise.all([deps.state.read(
|
|
341
|
+
async threads(key) {
|
|
342
|
+
const [state, settings] = await Promise.all([deps.state.read(key), effectiveSettings()])
|
|
332
343
|
return {
|
|
333
344
|
threads: state.chat.threads.map(t => ({
|
|
334
345
|
name: t.name,
|
|
@@ -340,22 +351,25 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
340
351
|
agent: settings.agent,
|
|
341
352
|
}
|
|
342
353
|
},
|
|
343
|
-
async createThread(
|
|
354
|
+
async createThread(key) {
|
|
344
355
|
const settings = await effectiveSettings()
|
|
345
|
-
return newThread(
|
|
356
|
+
return newThread(key, settings.agent)
|
|
346
357
|
},
|
|
347
|
-
async selectThread(
|
|
348
|
-
const state = await deps.state.read(
|
|
358
|
+
async selectThread(key, name) {
|
|
359
|
+
const state = await deps.state.read(key)
|
|
349
360
|
const thread = state.chat.threads.find(t => t.name === name)
|
|
350
361
|
if (thread === undefined) {
|
|
351
362
|
return null
|
|
352
363
|
}
|
|
353
|
-
await deps.state.update(
|
|
364
|
+
await deps.state.update(key, current => ({
|
|
365
|
+
...current,
|
|
366
|
+
chat: { ...current.chat, activeThread: name },
|
|
367
|
+
}))
|
|
354
368
|
return thread
|
|
355
369
|
},
|
|
356
370
|
send,
|
|
357
|
-
async cancel(
|
|
358
|
-
const slot = running.get(
|
|
371
|
+
async cancel(key) {
|
|
372
|
+
const slot = running.get(key)
|
|
359
373
|
if (slot === undefined) {
|
|
360
374
|
return false
|
|
361
375
|
}
|
|
@@ -365,7 +379,7 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
365
379
|
await slot.run?.cancel()
|
|
366
380
|
return true
|
|
367
381
|
},
|
|
368
|
-
busy:
|
|
382
|
+
busy: key => running.has(key),
|
|
369
383
|
}
|
|
370
384
|
}
|
|
371
385
|
|
package/src/chat/context.ts
CHANGED
|
@@ -106,7 +106,10 @@ export async function renderChatContext(context: ChatContext, sources: ContextSo
|
|
|
106
106
|
* An attention point: what it says, plus the lines it sits on. The agent needs the text to know
|
|
107
107
|
* what the reader is reacting to; the lines alone leave it guessing. One context, one heading.
|
|
108
108
|
*/
|
|
109
|
-
async function renderPoint(
|
|
109
|
+
async function renderPoint(
|
|
110
|
+
context: Extract<ChatContext, { kind: 'point' }>,
|
|
111
|
+
sources: ContextSources
|
|
112
|
+
): Promise<string> {
|
|
110
113
|
const point = sources.artifact.points.find(p => p.fingerprint === context.fingerprint)
|
|
111
114
|
if (point === undefined) {
|
|
112
115
|
throw new ChatContextError('this canvas has no such attention point')
|
|
@@ -140,7 +143,10 @@ async function renderLines(
|
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** The quoted lines and the hunk around them, with no heading of their own. */
|
|
143
|
-
async function linesBody(
|
|
146
|
+
async function linesBody(
|
|
147
|
+
context: Extract<ChatContext, { kind: 'lines' }>,
|
|
148
|
+
sources: ContextSources
|
|
149
|
+
): Promise<string> {
|
|
144
150
|
if (context.end < context.start) {
|
|
145
151
|
throw new ChatContextError('the last line of a selection comes after the first')
|
|
146
152
|
}
|
|
@@ -155,6 +161,7 @@ async function linesBody(context: Extract<ChatContext, { kind: 'lines' }>, sourc
|
|
|
155
161
|
lines === null || lines.length === 0
|
|
156
162
|
? '_these lines are not available locally_'
|
|
157
163
|
: fence(lines.map((text, i) => `${context.start + i}: ${text}`).join('\n'))
|
|
158
|
-
const hunkBlock =
|
|
164
|
+
const hunkBlock =
|
|
165
|
+
hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
|
|
159
166
|
return `${numbered}${hunkBlock}`
|
|
160
167
|
}
|
package/src/chat/seed.ts
CHANGED
|
@@ -42,7 +42,9 @@ export function pointsMarkdown(artifact: ReviewArtifact): string {
|
|
|
42
42
|
if (artifact.points.length === 0) {
|
|
43
43
|
return '_none_'
|
|
44
44
|
}
|
|
45
|
-
return artifact.points
|
|
45
|
+
return artifact.points
|
|
46
|
+
.map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`)
|
|
47
|
+
.join('\n')
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export function testsMarkdown(artifact: ReviewArtifact): string {
|
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
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
|
|
19
19
|
import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
20
20
|
import { createGit } from './git/git.js'
|
|
21
|
-
import {
|
|
21
|
+
import { createHostClient } from './host/client.js'
|
|
22
22
|
import { loadProjectConfig } from './project-config.js'
|
|
23
23
|
import { checkSkill } from './review/doctor.js'
|
|
24
24
|
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
@@ -26,13 +26,27 @@ import { startServer } from './server/node-server.js'
|
|
|
26
26
|
import { readJson } from './store/atomic-json.js'
|
|
27
27
|
import { ensureDataDir } from './store/data-dir.js'
|
|
28
28
|
|
|
29
|
-
const SUBCOMMANDS = [
|
|
29
|
+
const SUBCOMMANDS = [
|
|
30
|
+
'serve',
|
|
31
|
+
'prepare',
|
|
32
|
+
'validate',
|
|
33
|
+
'publish',
|
|
34
|
+
'export',
|
|
35
|
+
'import',
|
|
36
|
+
'install-skill',
|
|
37
|
+
'doctor',
|
|
38
|
+
] as const
|
|
30
39
|
|
|
31
40
|
const USAGE = `usage: pr-review <command> [flags]
|
|
32
41
|
|
|
33
42
|
serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
|
|
34
43
|
[--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
|
|
35
|
-
prepare (--pr <n> | --base <ref> --head <ref>) [--force]
|
|
44
|
+
prepare (--pr <n> | --branch | --uncommitted | --base <ref> --head <ref>) [--force]
|
|
45
|
+
[--base <ref>] [--repo <dir>] [--data-dir <dir>]
|
|
46
|
+
(--branch reviews the current branch against the default branch, and
|
|
47
|
+
--uncommitted reviews it with the working tree's edits and new files on top.
|
|
48
|
+
Both are for work with no PR yet, take --base <ref> to compare against
|
|
49
|
+
another branch, and show at /review/branch and /review/uncommitted.)
|
|
36
50
|
validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
|
|
37
51
|
(--fix trims over-cap titles in place and reports each one)
|
|
38
52
|
publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
|
|
@@ -43,7 +57,7 @@ const USAGE = `usage: pr-review <command> [flags]
|
|
|
43
57
|
doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
|
|
44
58
|
|
|
45
59
|
Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
|
|
46
|
-
Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
|
|
60
|
+
Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
|
|
47
61
|
`
|
|
48
62
|
|
|
49
63
|
const io: CliIo = {
|
|
@@ -78,7 +92,8 @@ async function buildContext(
|
|
|
78
92
|
)
|
|
79
93
|
const projectConfig = await loadProjectConfig(config.repoRoot)
|
|
80
94
|
await ensureDataDir(config.dataDir)
|
|
81
|
-
const fixtureArtifact =
|
|
95
|
+
const fixtureArtifact =
|
|
96
|
+
config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
|
|
82
97
|
return createAppContext({ config, projectConfig, fixtureArtifact })
|
|
83
98
|
}
|
|
84
99
|
|
|
@@ -115,15 +130,15 @@ async function serve(argv: string[]): Promise<number> {
|
|
|
115
130
|
return EXIT.ok
|
|
116
131
|
}
|
|
117
132
|
|
|
118
|
-
/** doctor builds no AppContext: it has to answer even when the repo or
|
|
133
|
+
/** doctor builds no AppContext: it has to answer even when the repo or host CLI is the problem. */
|
|
119
134
|
async function doctorCommand(argv: string[]): Promise<number> {
|
|
120
135
|
const { repo, dataDir, rest } = splitCommonFlags(argv)
|
|
121
136
|
const cwd = process.cwd()
|
|
122
|
-
const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
|
|
123
137
|
return runDoctor(
|
|
124
138
|
{
|
|
125
|
-
git,
|
|
126
|
-
|
|
139
|
+
git: createGit(repo === undefined ? cwd : path.resolve(cwd, repo)),
|
|
140
|
+
env: process.env,
|
|
141
|
+
client: host => createHostClient(host.cli),
|
|
127
142
|
version: readPackageVersion(),
|
|
128
143
|
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
129
144
|
dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
|