@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.
Files changed (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -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 { NEW_THREAD_TITLE, nextThreadIndex, type TranscriptStore, threadName, threadTitle } from './threads.js'
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 pull request one turn needs, resolved by the route. */
42
+ /** Everything about the review target one turn needs, resolved by the route. */
36
43
  export interface ChatTarget {
37
- prNumber: number
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(prNumber: number): Promise<ChatThreadsResponse>
56
- createThread(prNumber: number): Promise<ChatThread>
57
- selectThread(prNumber: number, name: string): Promise<ChatThread | null>
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(prNumber: number): Promise<boolean>
61
- busy(prNumber: number): boolean
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<number, RunningTurn>()
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 (prNumber: number, agent: string): Promise<ChatThread> => {
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(prNumber, state => {
93
+ await deps.state.update(key, state => {
87
94
  const thread: ChatThread = {
88
- name: threadName(deps.repo, prNumber, agent, nextThreadIndex(state.chat.threads)),
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 (prNumber: number, agent: string, wanted?: string): Promise<ChatThread> => {
106
- const state = await deps.state.read(prNumber)
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(prNumber, agent)
122
+ return newThread(key, agent)
116
123
  }
117
124
  if (state.chat.activeThread !== active.name) {
118
- await deps.state.update(prNumber, current => ({
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
- prNumber: number,
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(prNumber, thread.name, assistant)
145
+ await deps.transcripts.append(key, thread.name, assistant)
139
146
  if (!reached) {
140
147
  return
141
148
  }
142
- await deps.state.update(prNumber, state => ({
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 (prNumber: number, name: string): Promise<void> => {
167
- await deps.state.update(prNumber, state => ({
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.prNumber)) {
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.prNumber, slot)
190
+ running.set(target.key, slot)
184
191
  try {
185
192
  yield* runTurn(target, input, slot)
186
193
  } finally {
187
- running.delete(target.prNumber)
194
+ running.delete(target.key)
188
195
  }
189
196
  }
190
197
 
191
- async function* runTurn(target: ChatTarget, input: ChatSendInput, slot: RunningTurn): AsyncIterable<ChatEvent> {
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.prNumber, settings.agent, input.thread)
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.prNumber, thread.name, {
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.prNumber,
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.prNumber, thread.name, line).catch(() => undefined)
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.prNumber,
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.prNumber, thread.name)
334
+ await forgetSession(target.key, thread.name)
324
335
  }
325
336
  }
326
337
  }
327
338
 
328
339
  return {
329
340
  effectiveSettings,
330
- async threads(prNumber) {
331
- const [state, settings] = await Promise.all([deps.state.read(prNumber), effectiveSettings()])
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(prNumber) {
354
+ async createThread(key) {
344
355
  const settings = await effectiveSettings()
345
- return newThread(prNumber, settings.agent)
356
+ return newThread(key, settings.agent)
346
357
  },
347
- async selectThread(prNumber, name) {
348
- const state = await deps.state.read(prNumber)
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(prNumber, current => ({ ...current, chat: { ...current.chat, activeThread: name } }))
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(prNumber) {
358
- const slot = running.get(prNumber)
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: prNumber => running.has(prNumber),
382
+ busy: key => running.has(key),
369
383
  }
370
384
  }
371
385
 
@@ -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(context: Extract<ChatContext, { kind: 'point' }>, sources: ContextSources): Promise<string> {
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(context: Extract<ChatContext, { kind: 'lines' }>, sources: ContextSources): Promise<string> {
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 = hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
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.map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`).join('\n')
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 {
@@ -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 pull request, so two repositories never share a session and a name can never point
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, prNumber: number, agent: string, index: number): string {
21
- return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${prNumber}-${slug(agent)}-t${index}`
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+)-[a-z0-9-]+-t(\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 pull request. */
27
- export function isThreadNameFor(name: string, prNumber: number): boolean {
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] === String(prNumber)
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 pull request. */
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(prNumber: number): string
59
- file(prNumber: number, name: string): string
60
- eventsFile(prNumber: number, name: string): string
61
- read(prNumber: number, name: string): Promise<ChatTurn[]>
62
- append(prNumber: number, name: string, turn: ChatTurn): Promise<void>
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(prNumber: number, name: string, line: string): Promise<void>
65
+ appendEvent(key: ReviewKey, name: string, line: string): Promise<void>
65
66
  }
66
67
 
67
- export function createTranscriptStore(prDir: (prNumber: number) => string): TranscriptStore {
68
- const dir = (prNumber: number): string => path.join(prDir(prNumber), 'chat')
69
- const named = (prNumber: number, name: string, suffix: string): string => {
70
- if (!isThreadNameFor(name, prNumber)) {
71
- throw new Error(`not a chat thread of pull request ${prNumber}: ${name}`)
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(prNumber), `${name}${suffix}`)
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: (prNumber, name) => named(prNumber, name, '.jsonl'),
82
- eventsFile: (prNumber, name) => named(prNumber, name, '.events.ndjson'),
83
- async read(prNumber, name) {
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(prNumber, name, '.jsonl'), 'utf8')
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: (prNumber, name, turn) => appendLine(named(prNumber, name, '.jsonl'), JSON.stringify(turn)),
112
- appendEvent: (prNumber, name, line) => appendLine(named(prNumber, name, '.events.ndjson'), line),
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 { createGitHubClient } from './github/gh.js'
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 = ['serve', 'prepare', 'validate', 'publish', 'export', 'import', 'install-skill', 'doctor'] as const
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] [--repo <dir>] [--data-dir <dir>]
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 = config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
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 `gh` is the problem. */
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
- gh: createGitHubClient(),
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'),