@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
@@ -10,16 +10,23 @@ import { ChatSendSchema } from '../../contract/chat.js'
10
10
  import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
11
11
  import type { SettingsResponse } from '../../contract/settings.js'
12
12
  import { isChatAgent, SettingsInputSchema } from '../../contract/settings.js'
13
+ import { lookupCanvas } from '../../review/carry-over.js'
14
+ import type { Derived } from '../../store/derived-store.js'
13
15
  import type { PrLoader } from '../bundle.js'
14
16
  import type { AppContext } from '../context.js'
15
- import { AppError } from '../errors.js'
17
+ import { AppError, logRequestError } from '../errors.js'
16
18
  import { SSE_HEADERS, sseStream } from '../sse.js'
17
- import { parsePrNumber } from './api.js'
19
+ import { isLocalKey, type ReviewKey } from '../../contract/review-key.js'
20
+ import { parseTargetKey } from './api.js'
18
21
 
19
22
  async function readJsonBody<T>(
20
23
  request: Request,
21
24
  schema: {
22
- safeParse: (raw: unknown) => { success: boolean; data?: T; error?: { issues: Array<{ message: string }> } }
25
+ safeParse: (raw: unknown) => {
26
+ success: boolean
27
+ data?: T
28
+ error?: { issues: Array<{ message: string }> }
29
+ }
23
30
  },
24
31
  expected: string
25
32
  ): Promise<T> {
@@ -49,28 +56,72 @@ function settingsResponse(ctx: AppContext, settings: SettingsResponse['settings'
49
56
  maxRepairRounds: project.generation.maxRepairRounds,
50
57
  inlineDiffMaxLines: project.generation.inlineDiffMaxLines,
51
58
  smallPrHunks: project.generation.smallPrHunks,
59
+ keepForIdenticalDiff: project.canvas.keepForIdenticalDiff,
52
60
  layers: project.layers.length,
53
61
  highRisk: project.highRisk.length,
54
62
  },
55
63
  }
56
64
  }
57
65
 
58
- /** The canvas the chat talks about: the one written for the pull request's current head. */
59
- async function artifactForChat(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
66
+ /** What the chat talks about: the canvas on screen, and the diff that canvas is shown with. */
67
+ interface ChatSubject {
68
+ artifact: ReviewArtifact
69
+ /** The head for a current canvas; the canvas's own commit for an outdated one, as the page shows it. */
70
+ headSha: string
71
+ files: Derived['files']
72
+ patches: Derived['patches']
73
+ }
74
+
75
+ /**
76
+ * The canvas the chat talks about. A current canvas is read with the head's diff. An outdated one
77
+ * is still a canvas: the page shows it with the diff of its own commit, so the chat quotes that.
78
+ */
79
+ async function subjectForChat(ctx: AppContext, key: ReviewKey, pr: Pr): Promise<ChatSubject> {
60
80
  if (ctx.fixtureArtifact !== null) {
61
- return { ...ctx.fixtureArtifact, pr }
81
+ return withDiff({ ...ctx.fixtureArtifact, pr }, pr.headSha, await ctx.derived.read(pr.headSha))
62
82
  }
63
- const found = await ctx.canvases.findForPr(number, pr.headSha)
64
- const artifact = found.status === 'ready' ? await ctx.canvases.readArtifact(found.headSha) : null
65
- if (artifact === null) {
83
+ const found = isLocalKey(key)
84
+ ? await ctx.canvases.findForLocal(key, pr.headSha)
85
+ : await lookupCanvas(ctx, key, pr)
86
+ const artifact = found.status === 'missing' ? null : await ctx.canvases.readArtifact(found.headSha)
87
+ if (found.status === 'missing' || artifact === null) {
66
88
  throw new AppError(
67
89
  'CANVAS_NOT_FOUND',
68
- 'there is no canvas for this commit, so the chat has nothing to talk about',
90
+ 'there is no canvas for this pull request, so the chat has nothing to talk about',
69
91
  404,
70
92
  'generate a canvas for the current head first'
71
93
  )
72
94
  }
73
- return artifact
95
+ if (found.status === 'stale') {
96
+ // The diff is the canvas's own commit's, so the stored artifact, which names that commit,
97
+ // is already the one that describes it.
98
+ const manifest = await ctx.canvases.readManifest(found.headSha)
99
+ return withDiff(
100
+ artifact,
101
+ found.headSha,
102
+ await ctx.derived.readOrBuild(found.headSha, manifest?.mergeBaseSha)
103
+ )
104
+ }
105
+ // A carried-over canvas was generated for an earlier commit but is read with the head's diff,
106
+ // so the live pull request is stamped on it: the seed's header names the commit whose diff the
107
+ // agent is given, with that commit's file and line counts.
108
+ return withDiff({ ...artifact, pr }, pr.headSha, await ctx.derived.read(pr.headSha))
109
+ }
110
+
111
+ /**
112
+ * The subject with its diff, or the 404 that says the diff is not on this machine. The artifact
113
+ * passed in names `headSha` as its own commit, so the seed header and the diff agree.
114
+ */
115
+ function withDiff(artifact: ReviewArtifact, headSha: string, derived: Derived | null): ChatSubject {
116
+ if (derived === null) {
117
+ throw new AppError(
118
+ 'NOT_FOUND',
119
+ 'the diff of this head is not available locally, so the chat cannot quote it',
120
+ 404,
121
+ 'fetch the PR head and reload'
122
+ )
123
+ }
124
+ return { artifact, headSha, files: derived.files, patches: derived.patches }
74
125
  }
75
126
 
76
127
  /** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
@@ -106,7 +157,9 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
106
157
  return c.json(settingsResponse(ctx, await ctx.settings.write(input)))
107
158
  })
108
159
 
109
- api.get('/settings/agents', async c => c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' })))
160
+ api.get('/settings/agents', async c =>
161
+ c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' }))
162
+ )
110
163
 
111
164
  api.post('/settings/agents/:id/probe', async c => {
112
165
  const id = c.req.param('id')
@@ -116,52 +169,47 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
116
169
  return c.json(await ctx.agents.probe(id, { refresh: c.req.query('refresh') === '1' }))
117
170
  })
118
171
 
119
- api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parsePrNumber(c.req.param('n')))))
172
+ api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parseTargetKey(c.req.param('n')))))
120
173
 
121
174
  api.post('/prs/:n/chat/threads', async c => {
122
- const number = parsePrNumber(c.req.param('n'))
123
- const thread = await ctx.chat.createThread(number)
124
- return c.json({ thread, ...(await ctx.chat.threads(number)) }, 201)
175
+ const key = parseTargetKey(c.req.param('n'))
176
+ const thread = await ctx.chat.createThread(key)
177
+ return c.json({ thread, ...(await ctx.chat.threads(key)) }, 201)
125
178
  })
126
179
 
127
180
  api.get('/prs/:n/chat/threads/:name/history', async c => {
128
- const number = parsePrNumber(c.req.param('n'))
181
+ const key = parseTargetKey(c.req.param('n'))
129
182
  const name = c.req.param('name')
130
- if (!isThreadNameFor(name, number)) {
131
- throw new AppError('NOT_FOUND', `no chat thread named ${name} on this pull request`, 404)
183
+ if (!isThreadNameFor(name, key)) {
184
+ throw new AppError('NOT_FOUND', `no chat thread named ${name} on this review`, 404)
132
185
  }
133
- const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(number, name) }
186
+ const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(key, name) }
134
187
  return c.json(body)
135
188
  })
136
189
 
137
190
  api.post('/prs/:n/chat/cancel', async c => {
138
- const number = parsePrNumber(c.req.param('n'))
139
- return c.json({ cancelled: await ctx.chat.cancel(number) })
191
+ const key = parseTargetKey(c.req.param('n'))
192
+ return c.json({ cancelled: await ctx.chat.cancel(key) })
140
193
  })
141
194
 
142
195
  api.post('/prs/:n/chat', async c => {
143
- const number = parsePrNumber(c.req.param('n'))
144
- const input = await readJsonBody(c.req.raw, ChatSendSchema, '{ "message": "…", "context": { "kind": "pr" } }')
145
- const pr = await loader.currentPr(number)
146
- const artifact = await artifactForChat(ctx, number, pr)
147
- const derived = await ctx.derived.read(pr.headSha)
148
- if (derived === null) {
149
- throw new AppError(
150
- 'NOT_FOUND',
151
- 'the diff of this head is not available locally, so the chat cannot quote it',
152
- 404,
153
- 'fetch the PR head and reload'
154
- )
155
- }
196
+ const key = parseTargetKey(c.req.param('n'))
197
+ const input = await readJsonBody(
198
+ c.req.raw,
199
+ ChatSendSchema,
200
+ '{ "message": "…", "context": { "kind": "pr" } }'
201
+ )
202
+ const pr = await loader.currentTarget(key)
203
+ const { artifact, headSha, files, patches } = await subjectForChat(ctx, key, pr)
156
204
  const events = ctx.chat.send(
157
205
  {
158
- prNumber: number,
159
- headSha: pr.headSha,
206
+ key,
207
+ headSha,
160
208
  artifact,
161
- files: derived.files,
162
- patches: derived.patches,
163
- derivedDir: ctx.derived.derivedDir(pr.headSha),
164
- readLines: (side, filePath, from, to) => ctx.derived.readLines(pr.headSha, side, filePath, from, to),
209
+ files,
210
+ patches,
211
+ derivedDir: ctx.derived.derivedDir(headSha),
212
+ readLines: (side, filePath, from, to) => ctx.derived.readLines(headSha, side, filePath, from, to),
165
213
  },
166
214
  { message: input.message, context: input.context, thread: input.thread }
167
215
  )
@@ -176,9 +224,14 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
176
224
  }
177
225
  // A browser that goes away stops the agent; the turn is nobody's answer any more.
178
226
  return new Response(
179
- sseStream(replayFrom(first, iterator), undefined, () => {
180
- void ctx.chat.cancel(number)
181
- }),
227
+ sseStream(
228
+ replayFrom(first, iterator),
229
+ undefined,
230
+ () => {
231
+ void ctx.chat.cancel(key)
232
+ },
233
+ err => logRequestError(ctx.log, c.req, err)
234
+ ),
182
235
  { headers: SSE_HEADERS }
183
236
  )
184
237
  })
@@ -190,7 +243,10 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
190
243
  * The turn as a stream again, after its first event was read to see whether the route can answer
191
244
  * with a stream at all. A turn that ended on that first read is an empty stream.
192
245
  */
193
- export function replayFrom(first: IteratorResult<ChatEvent>, rest: AsyncIterator<ChatEvent>): AsyncIterable<ChatEvent> {
246
+ export function replayFrom(
247
+ first: IteratorResult<ChatEvent>,
248
+ rest: AsyncIterator<ChatEvent>
249
+ ): AsyncIterable<ChatEvent> {
194
250
  let sent = first.done === true
195
251
  return {
196
252
  [Symbol.asyncIterator]: () => ({
@@ -217,5 +273,8 @@ export function toChatError(err: unknown): AppError {
217
273
  if (err instanceof ChatContextError) {
218
274
  return new AppError('BAD_REQUEST', err.message, 400)
219
275
  }
220
- return err instanceof AppError ? err : new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
276
+ if (err instanceof AppError) return err
277
+ const error = new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
278
+ error.cause = err
279
+ return error
221
280
  }
@@ -3,8 +3,8 @@ import { type Appearance, type AppearanceQuery, appearanceForRequest } from '../
3
3
  import type { AppContext } from '../context.js'
4
4
  import type { AppEnv } from '../env.js'
5
5
  import { AppError } from '../errors.js'
6
+ import { LOCAL_KEYS, parseReviewKey } from '../../contract/review-key.js'
6
7
  import { homePage, reviewPage } from '../html.js'
7
- import { parsePrNumber } from './api.js'
8
8
 
9
9
  /** How the page is painted, rendered onto the tag so nothing flashes before the app module runs. */
10
10
  export async function appearanceFor(ctx: AppContext, query: AppearanceQuery): Promise<Appearance> {
@@ -12,7 +12,9 @@ export async function appearanceFor(ctx: AppContext, query: AppearanceQuery): Pr
12
12
  }
13
13
 
14
14
  /** The `?skin` and `?theme` of one request, which pick an appearance for that load alone. */
15
- export function appearanceQuery(c: { req: { query: (name: string) => string | undefined } }): AppearanceQuery {
15
+ export function appearanceQuery(c: {
16
+ req: { query: (name: string) => string | undefined }
17
+ }): AppearanceQuery {
16
18
  return { skin: c.req.query('skin'), theme: c.req.query('theme') }
17
19
  }
18
20
 
@@ -20,15 +22,20 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
20
22
  const app = new Hono<AppEnv>()
21
23
 
22
24
  app.get('/', async c => {
23
- const recentPrs = await ctx.prs.listRecent(10)
25
+ const [recentPrs, ...localPrs] = await Promise.all([
26
+ ctx.prs.listRecent(10),
27
+ ...LOCAL_KEYS.map(key => ctx.prs.readPr(key)),
28
+ ])
24
29
  return c.html(
25
30
  homePage(
26
31
  {
27
32
  recentPrs,
33
+ localReviews: LOCAL_KEYS.filter((_, i) => localPrs[i] !== null && localPrs[i] !== undefined),
28
34
  owner: ctx.config.repo.owner,
29
35
  repo: ctx.config.repo.name,
30
36
  version: ctx.version,
31
37
  port: ctx.config.port,
38
+ host: ctx.config.host,
32
39
  },
33
40
  c.get('cspNonce'),
34
41
  await appearanceFor(ctx, appearanceQuery(c))
@@ -45,15 +52,24 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
45
52
 
46
53
  app.get('/review/:n', async c => {
47
54
  const raw = c.req.param('n')
48
- let prNumber: number
49
- try {
50
- prNumber = parsePrNumber(raw)
51
- } catch {
52
- throw new AppError('BAD_REQUEST', `"${raw}" is not a pull request number`, 400, 'use /review/<number>')
55
+ const prNumber = parseReviewKey(raw)
56
+ if (prNumber === null) {
57
+ throw new AppError(
58
+ 'BAD_REQUEST',
59
+ `"${raw}" is not a review target`,
60
+ 400,
61
+ 'use /review/<number>, /review/branch, or /review/uncommitted'
62
+ )
53
63
  }
54
64
  return c.html(
55
65
  reviewPage(
56
- { prNumber, owner: ctx.config.repo.owner, repo: ctx.config.repo.name, version: ctx.version },
66
+ {
67
+ prNumber,
68
+ owner: ctx.config.repo.owner,
69
+ repo: ctx.config.repo.name,
70
+ version: ctx.version,
71
+ host: ctx.config.host,
72
+ },
57
73
  c.get('cspNonce'),
58
74
  await appearanceFor(ctx, appearanceQuery(c))
59
75
  )
@@ -4,14 +4,19 @@ import { z } from 'zod'
4
4
  import type { ReviewBodyResponse, StateResponse } from '../../contract/api.js'
5
5
  import { PostCommentInputSchema, type PostCommentResult } from '../../contract/comments.js'
6
6
  import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
7
- import { checkInlineTarget, postComment } from '../../github/post-comment.js'
8
- import { PostReviewInputSchema, postReview } from '../../github/post-review.js'
9
- import { buildReviewBody, stateForHead, unreviewedLayers } from '../../github/review-body.js'
7
+ import type { PrState } from '../../contract/state.js'
8
+ import { checkInlineTarget } from '../../git/patch-lines.js'
9
+ import { PostReviewInputSchema } from '../../contract/reviews.js'
10
+ import { lookupCanvas } from '../../review/carry-over.js'
11
+ import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
12
+ import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
13
+ import { canvasBelongsTo } from '../../store/canvas-store.js'
10
14
  import { isReviewedId } from '../../store/state-store.js'
11
- import type { PrLoader } from '../bundle.js'
15
+ import type { Derived } from '../../store/derived-store.js'
16
+ import { LOCAL_CAPABILITIES, type PrLoader } from '../bundle.js'
12
17
  import type { AppContext } from '../context.js'
13
18
  import { AppError } from '../errors.js'
14
- import { parsePrNumber } from './api.js'
19
+ import { parseTargetKey, requirePrNumber } from './api.js'
15
20
 
16
21
  const ReviewedBodySchema = z.object({
17
22
  reviewed: z.boolean(),
@@ -20,6 +25,14 @@ const ReviewedBodySchema = z.object({
20
25
  .string()
21
26
  .regex(/^[0-9a-f]{40}$/)
22
27
  .optional(),
28
+ /**
29
+ * The commit of the canvas on the page, which the marks are keyed to. The head when absent. Any
30
+ * other value must be a canvas indexed for this pull request.
31
+ */
32
+ canvasSha: z
33
+ .string()
34
+ .regex(/^[0-9a-f]{40}$/)
35
+ .optional(),
23
36
  })
24
37
  const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
25
38
  const HiddenBodySchema = z.object({ hidden: z.boolean() })
@@ -54,12 +67,34 @@ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: str
54
67
  return parsed.data
55
68
  }
56
69
 
57
- /** The canvas the reviewer is signing off on: the one written for the pull request's head. */
58
- async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
70
+ /** Refuses a commit that is not an indexed canvas of this target. */
71
+ async function requireCanvasOf(ctx: AppContext, key: ReviewKey, canvasSha: string): Promise<void> {
72
+ const entry = (await ctx.canvases.readIndex()).canvases[canvasSha]
73
+ // The same rule the lookups use, so a mark can only be keyed to a canvas this target shows.
74
+ if (entry === undefined || !canvasBelongsTo(entry, key)) {
75
+ throw new AppError(
76
+ 'CANVAS_NOT_FOUND',
77
+ `${canvasSha.slice(0, 7)} is not a canvas of ${keyLabel(key)}`,
78
+ 404,
79
+ 'reload the page'
80
+ )
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The canvas the reviewer is signing off on, with the marks made on it: the canvas written for the
86
+ * pull request's head, or for another commit with an identical diff.
87
+ */
88
+ async function canvasForSignOff(
89
+ ctx: AppContext,
90
+ number: number,
91
+ pr: Pr
92
+ ): Promise<{ artifact: ReviewArtifact; state: PrState }> {
93
+ const stored = await ctx.state.read(number)
59
94
  if (ctx.fixtureArtifact !== null) {
60
- return { ...ctx.fixtureArtifact, pr }
95
+ return { artifact: { ...ctx.fixtureArtifact, pr }, state: stateForCanvas(stored, pr.headSha) }
61
96
  }
62
- const found = await ctx.canvases.findForPr(number, pr.headSha)
97
+ const found = await lookupCanvas(ctx, number, pr)
63
98
  if (found.status !== 'ready') {
64
99
  throw new AppError(
65
100
  'SIGNOFF_INCOMPLETE',
@@ -72,7 +107,7 @@ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise
72
107
  if (artifact === null) {
73
108
  throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
74
109
  }
75
- return artifact
110
+ return { artifact, state: stateForCanvas(stored, found.headSha) }
76
111
  }
77
112
 
78
113
  export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
@@ -84,42 +119,60 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
84
119
  if (caps.canComment === false) {
85
120
  throw new AppError(
86
121
  'COMMENT_FORBIDDEN',
87
- caps.reason ?? 'this GitHub login cannot post on this repository',
122
+ caps.reason ?? `this ${ctx.config.host.label} login cannot post on this repository`,
88
123
  403,
89
124
  caps.hint
90
125
  )
91
126
  }
92
127
  }
93
128
 
94
- const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({ prNumber: number, state })
129
+ const stateBody = (key: ReviewKey, state: StateResponse['state']): StateResponse => ({
130
+ prNumber: key,
131
+ state,
132
+ })
95
133
 
96
134
  api.get('/prs/:n/state', async c => {
97
- const number = parsePrNumber(c.req.param('n'))
98
- return c.json(stateBody(number, await ctx.state.read(number)))
135
+ const key = parseTargetKey(c.req.param('n'))
136
+ return c.json(stateBody(key, await ctx.state.read(key)))
99
137
  })
100
138
 
101
139
  api.put('/prs/:n/reviewed/:id{.+}', async c => {
102
- const number = parsePrNumber(c.req.param('n'))
140
+ const key = parseTargetKey(c.req.param('n'))
103
141
  const id = c.req.param('id')
104
142
  if (!isReviewedId(id)) {
105
- throw new AppError('BAD_REQUEST', `not a reviewed id: ${id}`, 400, 'use layer:<id> or layer:<id>/file:<key>')
143
+ throw new AppError(
144
+ 'BAD_REQUEST',
145
+ `not a reviewed id: ${id}`,
146
+ 400,
147
+ 'use layer:<id> or layer:<id>/file:<key>'
148
+ )
106
149
  }
107
150
  const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
108
- const pr = await loader.currentPr(number)
109
- requireSameHead(body.headSha, pr.headSha)
110
- return c.json(stateBody(number, await ctx.state.setReviewed(number, id, body.reviewed, pr.headSha)))
151
+ // A mark is made against the canvas on screen, which for a local review is the snapshot the
152
+ // page was drawn from, not whatever the working tree holds a keystroke later.
153
+ const pr = await loader.currentTarget(key)
154
+ if (!isLocalKey(key)) {
155
+ requireSameHead(body.headSha, pr.headSha)
156
+ }
157
+ // The page sends back the canvas commit the bundle keyed its marks to, so a toggle costs no
158
+ // git work; the index, not git, says the commit is a canvas of this target.
159
+ const canvasSha = body.canvasSha ?? pr.headSha
160
+ if (canvasSha !== pr.headSha) {
161
+ await requireCanvasOf(ctx, key, canvasSha)
162
+ }
163
+ return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, canvasSha)))
111
164
  })
112
165
 
113
166
  api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
114
- const number = parsePrNumber(c.req.param('n'))
167
+ const key = parseTargetKey(c.req.param('n'))
115
168
  const fingerprint = c.req.param('fingerprint')
116
169
  const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
117
- const next = await ctx.state.setDismissed(number, fingerprint, body.dismissed, body.reason)
118
- return c.json(stateBody(number, next))
170
+ const next = await ctx.state.setDismissed(key, fingerprint, body.dismissed, body.reason)
171
+ return c.json(stateBody(key, next))
119
172
  })
120
173
 
121
174
  api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
122
- const number = parsePrNumber(c.req.param('n'))
175
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'hiding a comment thread')
123
176
  const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
124
177
  if (!rootId.success) {
125
178
  throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
@@ -129,16 +182,20 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
129
182
  })
130
183
 
131
184
  api.get('/prs/:n/capabilities', async c => {
132
- parsePrNumber(c.req.param('n'))
185
+ const key = parseTargetKey(c.req.param('n'))
186
+ if (isLocalKey(key)) {
187
+ return c.json(LOCAL_CAPABILITIES)
188
+ }
133
189
  return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
134
190
  })
135
191
 
136
192
  api.post('/prs/:n/comments', async c => {
137
- const number = parsePrNumber(c.req.param('n'))
193
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'posting a comment')
138
194
  const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
139
195
  await requirePosting()
140
196
  const pr = await loader.currentPr(number)
141
197
  requireSameHead(input.headSha, pr.headSha)
198
+ let diff: Derived = { files: [], patches: {} }
142
199
  if (input.kind === 'inline') {
143
200
  const derived = await ctx.derived.read(pr.headSha)
144
201
  if (derived === null) {
@@ -153,8 +210,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
153
210
  if (problem !== null) {
154
211
  throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
155
212
  }
213
+ diff = derived
156
214
  }
157
- const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
215
+ const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
158
216
  await appendComment(ctx, number, posted)
159
217
  const entry =
160
218
  input.kind === 'inline' && input.pointFingerprint !== undefined
@@ -165,10 +223,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
165
223
  })
166
224
 
167
225
  api.get('/prs/:n/review/body', async c => {
168
- const number = parsePrNumber(c.req.param('n'))
226
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
169
227
  const pr = await loader.currentPr(number)
170
- const artifact = await artifactForHead(ctx, number, pr)
171
- const state = stateForHead(await ctx.state.read(number), pr.headSha)
228
+ const { artifact, state } = await canvasForSignOff(ctx, number, pr)
172
229
  const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
173
230
  const body: ReviewBodyResponse = {
174
231
  headSha: pr.headSha,
@@ -179,13 +236,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
179
236
  })
180
237
 
181
238
  api.post('/prs/:n/review', async c => {
182
- const number = parsePrNumber(c.req.param('n'))
239
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'submitting a review')
183
240
  const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
184
241
  await requirePosting()
185
242
  const pr = await loader.currentPr(number)
186
243
  requireSameHead(input.headSha, pr.headSha)
187
- const artifact = await artifactForHead(ctx, number, pr)
188
- const state = stateForHead(await ctx.state.read(number), pr.headSha)
244
+ const { artifact, state } = await canvasForSignOff(ctx, number, pr)
189
245
  if (input.event === 'APPROVE') {
190
246
  const missing = unreviewedLayers(artifact, state)
191
247
  if (missing.length > 0) {
@@ -199,7 +255,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
199
255
  }
200
256
  const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
201
257
  const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
202
- const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, { event: input.event, body })
258
+ const review = await ctx.config.host.postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
259
+ event: input.event,
260
+ body,
261
+ })
203
262
  return c.json({ review }, 201)
204
263
  })
205
264
 
@@ -223,7 +282,11 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
223
282
  * Keeps the cached comments in step with what was just posted, so a reload shows it once. With
224
283
  * nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
225
284
  */
226
- async function writeAppendedComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
285
+ async function writeAppendedComment(
286
+ ctx: AppContext,
287
+ number: number,
288
+ posted: PostCommentResult
289
+ ): Promise<void> {
227
290
  const comments = await ctx.prs.readComments(number)
228
291
  if (comments === null) {
229
292
  return
@@ -241,5 +304,8 @@ async function writeAppendedComment(ctx: AppContext, number: number, posted: Pos
241
304
  if (comments.issueComments.some(c => c.id === posted.comment.id)) {
242
305
  return
243
306
  }
244
- await ctx.prs.writeComments(number, { ...comments, issueComments: [...comments.issueComments, posted.comment] })
307
+ await ctx.prs.writeComments(number, {
308
+ ...comments,
309
+ issueComments: [...comments.issueComments, posted.comment],
310
+ })
245
311
  }
package/src/server/sse.ts CHANGED
@@ -28,7 +28,8 @@ export function sseFrame(event: ChatEvent): string {
28
28
  export function sseStream(
29
29
  events: AsyncIterable<ChatEvent>,
30
30
  onClose?: () => void,
31
- onCancel?: () => void
31
+ onCancel?: () => void,
32
+ onError?: (err: unknown) => void
32
33
  ): ReadableStream<Uint8Array> {
33
34
  const encoder = new TextEncoder()
34
35
  const iterator = events[Symbol.asyncIterator]()
@@ -38,6 +39,7 @@ export function sseStream(
38
39
  try {
39
40
  next = await iterator.next()
40
41
  } catch (err) {
42
+ onError?.(err)
41
43
  controller.enqueue(
42
44
  encoder.encode(
43
45
  sseFrame({
@@ -37,7 +37,11 @@ export async function readJson<T>(file: string, schema: z.ZodType<T>): Promise<T
37
37
  * Like readJson, but a missing file, invalid JSON, or a shape the schema rejects all return
38
38
  * `fallback()`. Other read errors still throw.
39
39
  */
40
- export async function readJsonOrDefault<T, D>(file: string, schema: z.ZodType<T>, fallback: () => D): Promise<T | D> {
40
+ export async function readJsonOrDefault<T, D>(
41
+ file: string,
42
+ schema: z.ZodType<T>,
43
+ fallback: () => D
44
+ ): Promise<T | D> {
41
45
  const text = await readText(file)
42
46
  if (text === null) {
43
47
  return fallback()