@vintasoftware/pr-review-canvas 0.1.0 → 0.3.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 (98) hide show
  1. package/README.md +30 -28
  2. package/docs/reference.md +142 -98
  3. package/package.json +12 -3
  4. package/pr-review.config.example.yml +12 -31
  5. package/prompts/generation-format.md +4 -4
  6. package/prompts/layering-guidance.md +18 -0
  7. package/skills/pr-review-canvas/SKILL.md +8 -1
  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/export.ts +11 -2
  13. package/src/canvas/name.ts +13 -13
  14. package/src/canvas/zip.ts +4 -1
  15. package/src/chat/chat-manager.ts +16 -3
  16. package/src/chat/context.ts +10 -3
  17. package/src/chat/seed.ts +3 -1
  18. package/src/cli.ts +16 -13
  19. package/src/commands.ts +41 -11
  20. package/src/config.ts +15 -3
  21. package/src/contract/comments.ts +3 -1
  22. package/src/contract/generation-context.ts +3 -1
  23. package/src/contract/review-artifact.ts +14 -8
  24. package/src/contract/state.ts +3 -1
  25. package/src/git/diff-collector.ts +2 -1
  26. package/src/git/git.ts +9 -4
  27. package/src/github/attachments.ts +3 -3
  28. package/src/github/capabilities.ts +5 -1
  29. package/src/github/comments.ts +6 -3
  30. package/src/github/gh.ts +18 -3
  31. package/src/github/post-comment.ts +4 -1
  32. package/src/github/pr.ts +4 -1
  33. package/src/github/threads.ts +5 -1
  34. package/src/project-config.ts +14 -54
  35. package/src/prompt-files.ts +4 -3
  36. package/src/review/doctor.ts +38 -10
  37. package/src/review/install-skill.ts +24 -28
  38. package/src/review/normalize.ts +3 -1
  39. package/src/review/prepare.ts +24 -8
  40. package/src/review/prompt.ts +17 -11
  41. package/src/review/publish.ts +15 -3
  42. package/src/review/skill-content.ts +20 -0
  43. package/src/review/trim-caps.ts +10 -6
  44. package/src/review/validate.ts +65 -13
  45. package/src/server/app.ts +12 -4
  46. package/src/server/bundle.ts +17 -4
  47. package/src/server/context.ts +14 -2
  48. package/src/server/errors.ts +26 -2
  49. package/src/server/html.ts +2 -2
  50. package/src/server/node-server.ts +4 -2
  51. package/src/server/routes/api.ts +29 -5
  52. package/src/server/routes/chat-routes.ts +30 -9
  53. package/src/server/routes/pages.ts +3 -1
  54. package/src/server/routes/review-routes.ts +19 -4
  55. package/src/server/sse.ts +3 -1
  56. package/src/store/atomic-json.ts +5 -1
  57. package/src/store/canvas-store.ts +20 -6
  58. package/src/store/derived-store.ts +5 -1
  59. package/src/store/pr-store.ts +2 -1
  60. package/src/store/state-store.ts +2 -1
  61. package/static/brand.svg +19 -0
  62. package/static/js/api.js +15 -3
  63. package/static/js/app.js +8 -2
  64. package/static/js/chat-panel.js +93 -0
  65. package/static/js/chat.js +38 -15
  66. package/static/js/composer.js +6 -3
  67. package/static/js/diagram.js +2 -1
  68. package/static/js/diff-decorations.js +5 -3
  69. package/static/js/diff-renderer.js +6 -1
  70. package/static/js/dom.js +5 -7
  71. package/static/js/empty-state.js +3 -1
  72. package/static/js/header.js +1 -1
  73. package/static/js/interactions.js +55 -15
  74. package/static/js/keyboard.js +7 -2
  75. package/static/js/layers.js +23 -11
  76. package/static/js/links.js +8 -2
  77. package/static/js/nav.js +5 -2
  78. package/static/js/overview.js +32 -4
  79. package/static/js/points.js +3 -2
  80. package/static/js/progress.js +2 -1
  81. package/static/js/proposed-comment.js +4 -1
  82. package/static/js/quick-questions.js +2 -1
  83. package/static/js/regenerate.js +4 -1
  84. package/static/js/settings.js +1 -1
  85. package/static/js/signoff.js +6 -2
  86. package/static/styles/base.css +16 -6
  87. package/static/styles/chat-panel.css +81 -0
  88. package/static/styles/chat-tools.css +28 -0
  89. package/static/styles/chat.css +1 -1
  90. package/static/styles/commands.css +4 -4
  91. package/static/styles/diff.css +1 -1
  92. package/static/styles/header.css +18 -4
  93. package/static/styles/layout.css +4 -4
  94. package/static/styles/responsive.css +1 -15
  95. package/static/styles/review.css +24 -3
  96. package/static/styles/skin-github.css +93 -100
  97. package/static/styles.css +13 -12
  98. package/prompts/layers-default.md +0 -13
@@ -77,7 +77,10 @@ interface TitledOutput {
77
77
  * Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
78
78
  * call, not the tool's.
79
79
  */
80
- export function applyTitleTrims(output: unknown, caps: { layerTitle: number; pointTitle: number }): TitleTrim[] {
80
+ export function applyTitleTrims(
81
+ output: unknown,
82
+ caps: { layerTitle: number; pointTitle: number }
83
+ ): TitleTrim[] {
81
84
  const trims: TitleTrim[] = []
82
85
  const trim = (where: string, holder: { title?: string }, cap: number): void => {
83
86
  if (typeof holder.title !== 'string') {
@@ -89,11 +92,12 @@ export function applyTitleTrims(output: unknown, caps: { layerTitle: number; poi
89
92
  holder.title = trimmed
90
93
  } else if (visibleLength(holder.title) > cap) {
91
94
  const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
92
- const reason = match === null
93
- ? 'no explainer separator (:, —, – or -) to drop'
94
- : match.index === 0
95
- ? 'dropping the explainer would leave an empty title'
96
- : 'the title before the explainer still exceeds the cap'
95
+ const reason =
96
+ match === null
97
+ ? 'no explainer separator (:, —, – or -) to drop'
98
+ : match.index === 0
99
+ ? 'dropping the explainer would leave an empty title'
100
+ : 'the title before the explainer still exceeds the cap'
97
101
  trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
98
102
  }
99
103
  }
@@ -152,7 +152,11 @@ function checkLengths(output: ModelOutput, caps: TextCaps, report: Report): void
152
152
  f.folds?.forEach((fold, k) => {
153
153
  const where = `${at}.files.${j}.folds.${k}.title`
154
154
  if (fold.title.length > caps.pointTitle) {
155
- report.add('TEXT_TOO_LONG', where, `${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`)
155
+ report.add(
156
+ 'TEXT_TOO_LONG',
157
+ where,
158
+ `${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`
159
+ )
156
160
  }
157
161
  })
158
162
  f.annotations.forEach((a, k) => {
@@ -218,7 +222,11 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
218
222
  continue
219
223
  }
220
224
  if (hit.file.path !== file.path) {
221
- report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`)
225
+ report.add(
226
+ 'HUNK_UNKNOWN',
227
+ where,
228
+ `${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`
229
+ )
222
230
  continue
223
231
  }
224
232
  const first = owner.get(id)
@@ -236,7 +244,11 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
236
244
  }
237
245
  for (const { file, hunk } of index.byHunkId.values()) {
238
246
  if (!owner.has(hunk.id)) {
239
- report.add('HUNK_UNASSIGNED', `hunk:${hunk.id}`, `${hunk.id} in ${file.path} (${hunk.header}) is in no layer`)
247
+ report.add(
248
+ 'HUNK_UNASSIGNED',
249
+ `hunk:${hunk.id}`,
250
+ `${hunk.id} in ${file.path} (${hunk.header}) is in no layer`
251
+ )
240
252
  }
241
253
  }
242
254
  return owner
@@ -248,7 +260,11 @@ function checkLayers(output: ModelOutput, report: Report): void {
248
260
  const where = `layer:${layer.key}`
249
261
  const seen = keys.get(layer.key)
250
262
  if (seen !== undefined) {
251
- report.add('LAYER_KEY_DUPLICATE', where, `layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`)
263
+ report.add(
264
+ 'LAYER_KEY_DUPLICATE',
265
+ where,
266
+ `layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`
267
+ )
252
268
  } else {
253
269
  keys.set(layer.key, i)
254
270
  }
@@ -268,7 +284,11 @@ function checkLayers(output: ModelOutput, report: Report): void {
268
284
  }
269
285
  const last = output.layers[output.layers.length - 1]
270
286
  if (first !== undefined && first !== last) {
271
- report.add('OTHER_NOT_LAST', `layer:${first.key}`, `layer ${first.key} (kind other) must be the last layer`)
287
+ report.add(
288
+ 'OTHER_NOT_LAST',
289
+ `layer:${first.key}`,
290
+ `layer ${first.key} (kind other) must be the last layer`
291
+ )
272
292
  }
273
293
  }
274
294
 
@@ -308,7 +328,11 @@ function checkTests(
308
328
  continue
309
329
  }
310
330
  if (t.testPath === undefined) {
311
- report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`)
331
+ report.add(
332
+ 'TEST_PATH_UNKNOWN',
333
+ where,
334
+ `${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`
335
+ )
312
336
  } else if (!(index.byPath.has(t.testPath) || headPaths.has(t.testPath))) {
313
337
  report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: ${t.testPath} is not in the PR head`)
314
338
  }
@@ -339,12 +363,20 @@ function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], repor
339
363
  }
340
364
  const where = `layer:${other.key}`
341
365
  for (const tag of other.risk ?? []) {
342
- report.add('RISK_IN_OTHER', where, `other: carries the risk tag "${tag.label}"; move the hunks to a real layer`)
366
+ report.add(
367
+ 'RISK_IN_OTHER',
368
+ where,
369
+ `other: carries the risk tag "${tag.label}"; move the hunks to a real layer`
370
+ )
343
371
  }
344
372
  for (const file of other.files) {
345
373
  const rule = highRisk.find(r => matchesGlob(r.pattern, file.path))
346
374
  if (rule !== undefined) {
347
- report.add('RISK_IN_OTHER', where, `other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`)
375
+ report.add(
376
+ 'RISK_IN_OTHER',
377
+ where,
378
+ `other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`
379
+ )
348
380
  }
349
381
  }
350
382
  }
@@ -368,9 +400,17 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
368
400
  const start = hunkAt(entry, a.side, a.startLine)
369
401
  const end = hunkAt(entry, a.side, a.endLine)
370
402
  if (start === null || end === null || start.id !== end.id) {
371
- report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`)
403
+ report.add(
404
+ 'ANNOTATION_OUTSIDE_HUNK',
405
+ where,
406
+ `${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
407
+ )
372
408
  } else if (!file.hunks.includes(start.id)) {
373
- report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is in ${start.id}, which this layer does not list`)
409
+ report.add(
410
+ 'ANNOTATION_OUTSIDE_HUNK',
411
+ where,
412
+ `${label} is in ${start.id}, which this layer does not list`
413
+ )
374
414
  }
375
415
  })
376
416
  }
@@ -378,7 +418,10 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
378
418
  }
379
419
 
380
420
  function checkPoints(output: ModelOutput, index: Index, limits: Limits, report: Report): void {
381
- const missingTests = output.layers.reduce((n, l) => n + l.tests.filter(t => t.status === 'missing').length, 0)
421
+ const missingTests = output.layers.reduce(
422
+ (n, l) => n + l.tests.filter(t => t.status === 'missing').length,
423
+ 0
424
+ )
382
425
  const total = output.points.length + missingTests
383
426
  if (total > limits.maxPoints) {
384
427
  report.add(
@@ -464,7 +507,12 @@ function checkLinks(output: ModelOutput, files: readonly FileEntry[], report: Re
464
507
  * The sidecar map of a layer's diagram: each key names a node of the mermaid source, each value
465
508
  * is a canvas link, and a diagram carries at most `maxDiagramLinks` of them.
466
509
  */
467
- function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], limits: Limits, report: Report): void {
510
+ function checkDiagramLinks(
511
+ output: ModelOutput,
512
+ files: readonly FileEntry[],
513
+ limits: Limits,
514
+ report: Report
515
+ ): void {
468
516
  const targets: LinkTargets = { layers: output.layers, files }
469
517
  for (const layer of output.layers) {
470
518
  if (layer.diagram === undefined) {
@@ -474,7 +522,11 @@ function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], lim
474
522
  const label = `${layerLabel(layer)} diagram`
475
523
  const entries = Object.entries(layer.diagram.links)
476
524
  if (entries.length > limits.maxDiagramLinks) {
477
- report.add('DIAGRAM_LIMIT', where, `${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`)
525
+ report.add(
526
+ 'DIAGRAM_LIMIT',
527
+ where,
528
+ `${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`
529
+ )
478
530
  }
479
531
  const kind = diagramKind(layer.diagram.mermaid)
480
532
  const nodes = diagramNodeIds(layer.diagram.mermaid)
package/src/server/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Context, Hono } from 'hono'
2
2
  import type { AppContext } from './context.js'
3
3
  import type { AppEnv } from './env.js'
4
- import { AppError, toAppError } from './errors.js'
4
+ import { AppError, logRequestError, toAppError } from './errors.js'
5
5
  import { errorPage } from './html.js'
6
6
  import { apiRoutes } from './routes/api.js'
7
7
  import { appearanceFor, appearanceQuery, pageRoutes } from './routes/pages.js'
@@ -29,14 +29,22 @@ export function createApp(ctx: AppContext): Hono<AppEnv> {
29
29
  const nonce = c.get('cspNonce') ?? createNonce()
30
30
  const res = wantsJson(c.req.path)
31
31
  ? c.json(envelope, err.status)
32
- : await c.html(errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))), err.status)
32
+ : await c.html(
33
+ errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))),
34
+ err.status
35
+ )
33
36
  applyResponseHeaders(res, c.req.path, nonce)
34
37
  return res
35
38
  }
36
39
 
37
- app.onError((err, c) => errorResponse(c, toAppError(err)))
40
+ app.onError((err, c) => {
41
+ logRequestError(ctx.log, c.req, err)
42
+ return errorResponse(c, toAppError(err))
43
+ })
38
44
 
39
- app.notFound(c => errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404)))
45
+ app.notFound(c =>
46
+ errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404))
47
+ )
40
48
 
41
49
  app.route('/api', apiRoutes(ctx))
42
50
  app.route('/', staticRoutes(ctx))
@@ -45,7 +45,11 @@ export async function runDiscovery(
45
45
  checkedAt: ctx.now().toISOString(),
46
46
  sharedCanvas: outcome.sharedCanvas,
47
47
  })
48
- return { sharedCanvas: outcome.sharedCanvas, imported: outcome.imported !== null, warnings: outcome.warnings }
48
+ return {
49
+ sharedCanvas: outcome.sharedCanvas,
50
+ imported: outcome.imported !== null,
51
+ warnings: outcome.warnings,
52
+ }
49
53
  }
50
54
 
51
55
  /**
@@ -55,7 +59,9 @@ export async function runDiscovery(
55
59
  export function createPrLoader(ctx: AppContext) {
56
60
  const refreshed = new Set<number>()
57
61
 
58
- async function refreshPr(number: number): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
62
+ async function refreshPr(
63
+ number: number
64
+ ): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
59
65
  const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
60
66
  const shas = await fetchPrRefs(ctx.git, meta)
61
67
  const pr = toPr(meta, ctx.config.repo, shas)
@@ -225,11 +231,18 @@ export async function resolveBundle(
225
231
  const shared = sharedCanvas === null ? {} : { sharedCanvas }
226
232
 
227
233
  if (found.status === 'missing') {
228
- return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: false }) }
234
+ return {
235
+ ...base,
236
+ ...shared,
237
+ status: 'missing',
238
+ skillCommand: buildSkillCommand(number, { force: false }),
239
+ }
229
240
  }
230
241
  const loaded = await loadCanvas(ctx, found)
231
242
  if (loaded === null) {
232
- allWarnings.push(`the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`)
243
+ allWarnings.push(
244
+ `the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`
245
+ )
233
246
  return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: true }) }
234
247
  }
235
248
  // A canvas exists for this PR, so regenerating always needs --force.
@@ -36,6 +36,7 @@ export interface VendorRoots {
36
36
  * `createAppContext`; tests assemble their own with fakes and a temp data dir.
37
37
  */
38
38
  export interface AppContext {
39
+ log: (line: string) => void
39
40
  config: RuntimeConfig
40
41
  projectConfig: LoadedProjectConfig
41
42
  git: Git
@@ -72,7 +73,11 @@ export function resolveVendorRoots(): VendorRoots {
72
73
  diff: path.dirname(fileURLToPath(import.meta.resolve('diff'))),
73
74
  marked: fileURLToPath(import.meta.resolve('marked')),
74
75
  dompurify: fileURLToPath(import.meta.resolve('dompurify')),
75
- hljs: path.join(path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')), 'es', 'highlight.min.js'),
76
+ hljs: path.join(
77
+ path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')),
78
+ 'es',
79
+ 'highlight.min.js'
80
+ ),
76
81
  mermaid: path.dirname(fileURLToPath(import.meta.resolve('mermaid'))),
77
82
  }
78
83
  }
@@ -158,13 +163,20 @@ export function createAppContext(opts: CreateAppContextOptions): AppContext {
158
163
  const stores = createStores(opts.config.dataDir, opts.config, git, now)
159
164
  return {
160
165
  config: opts.config,
166
+ log: line => process.stderr.write(`${line}\n`),
161
167
  projectConfig: opts.projectConfig,
162
168
  git,
163
169
  gh,
164
170
  capabilities: createCapabilityProbe(gh, opts.config.repo, now),
165
171
  fetch: opts.fetch ?? ((input, init) => globalThis.fetch(input, init)),
166
172
  ...stores,
167
- ...createChatSet(opts.config, opts.runner ?? createAgentRunner(), stores, now, opts.projectConfig.config.prompts),
173
+ ...createChatSet(
174
+ opts.config,
175
+ opts.runner ?? createAgentRunner(),
176
+ stores,
177
+ now,
178
+ opts.projectConfig.config.prompts
179
+ ),
168
180
  now,
169
181
  version: readPackageVersion(),
170
182
  staticDir: STATIC_DIR,
@@ -1,3 +1,4 @@
1
+ import { inspect } from 'node:util'
1
2
  import type { ContentfulStatusCode } from 'hono/utils/http-status'
2
3
  import { ConfigError } from '../config.js'
3
4
  import type { ErrorCode, ErrorEnvelope } from '../contract/api.js'
@@ -12,7 +13,13 @@ export class AppError extends Error {
12
13
  /** What a rejected file got wrong, one line each. Never the file's content. */
13
14
  readonly issues: string[] | undefined
14
15
 
15
- constructor(code: ErrorCode, message: string, status: ContentfulStatusCode, hint?: string, issues?: string[]) {
16
+ constructor(
17
+ code: ErrorCode,
18
+ message: string,
19
+ status: ContentfulStatusCode,
20
+ hint?: string,
21
+ issues?: string[]
22
+ ) {
16
23
  super(message)
17
24
  this.name = 'AppError'
18
25
  this.code = code
@@ -39,7 +46,12 @@ export function toAppError(err: unknown): AppError {
39
46
  return err
40
47
  }
41
48
  if (err instanceof PrNotFoundError) {
42
- return new AppError('PR_NOT_FOUND', err.message, 404, 'check the number and that origin is the right repository')
49
+ return new AppError(
50
+ 'PR_NOT_FOUND',
51
+ err.message,
52
+ 404,
53
+ 'check the number and that origin is the right repository'
54
+ )
43
55
  }
44
56
  if (err instanceof GitHubApiError) {
45
57
  if (err.missingBinary) {
@@ -63,3 +75,15 @@ export function toAppError(err: unknown): AppError {
63
75
  }
64
76
  return new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
65
77
  }
78
+
79
+ /** Keep the original error and its causes in the server log, before response mapping loses the stack. */
80
+ export function logRequestError(
81
+ log: (line: string) => void,
82
+ request: { method: string; path: string },
83
+ err: unknown
84
+ ): void {
85
+ const mapped = toAppError(err)
86
+ if (mapped.status >= 500) {
87
+ log(`[serve] ${request.method} ${request.path} ${mapped.status} ${mapped.code}\n${inspect(err)}`)
88
+ }
89
+ }
@@ -96,7 +96,7 @@ export function homePage(
96
96
  app: false,
97
97
  body: html`<div class="page">
98
98
  <header class="hdr">
99
- <div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span><span class="mono muted">localhost:${String(data.port)}</span></div>
99
+ <div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">localhost:${String(data.port)}</span></div>
100
100
  <div class="hdr-actions"><a class="cmd" href="/api/health">health</a></div></div>
101
101
  <div class="stripe" aria-hidden="true"></div>
102
102
  <div class="hdr-title"><div class="title"><h1>${data.owner}/${data.repo}</h1></div>
@@ -132,7 +132,7 @@ export function errorPage(error: ErrorEnvelope['error'], nonce: string, appearan
132
132
  appearance,
133
133
  app: false,
134
134
  body: html`<div class="page"><header class="hdr">
135
- <div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span></div><div class="hdr-actions"><a class="cmd" href="/">home</a></div></div>
135
+ <div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span></div><div class="hdr-actions"><a class="cmd" href="/">home</a></div></div>
136
136
  <div class="stripe" aria-hidden="true"></div></header>
137
137
  <main id="main" class="home"><section class="panel error-card"><div class="panel-h"><h2><span class="mono">${error.code}</span></h2></div>
138
138
  <div class="body"><p>${error.message}</p>${error.hint ? html`<p class="muted">${error.hint}</p>` : ''}</div></section></main></div>`,
@@ -4,9 +4,11 @@ import type { AppContext } from './context.js'
4
4
 
5
5
  /** Binds 127.0.0.1 only. The Host allowlist in security.ts covers the rest. */
6
6
  export function startServer(ctx: AppContext, log: (line: string) => void): { close: () => void } {
7
- const app = createApp(ctx)
7
+ const app = createApp({ ...ctx, log })
8
8
  const server = serve({ fetch: app.fetch, port: ctx.config.port, hostname: '127.0.0.1' }, info => {
9
- log(`pr-review ${ctx.version} · http://localhost:${info.port}/ · ${ctx.config.repo.owner}/${ctx.config.repo.name}`)
9
+ log(
10
+ `pr-review ${ctx.version} · http://localhost:${info.port}/ · ${ctx.config.repo.owner}/${ctx.config.repo.name}`
11
+ )
10
12
  log(`data dir ${ctx.config.dataDir}`)
11
13
  if (ctx.fixtureArtifact !== null) {
12
14
  log(`fixture canvas ${ctx.config.fixtureCanvasPath ?? ''} (dev only): every PR reports ready`)
@@ -3,7 +3,12 @@ import { z } from 'zod'
3
3
  import { buildCanvasZipFor } from '../../canvas/export.js'
4
4
  import { importCanvas } from '../../canvas/import.js'
5
5
  import { CANVAS_ZIP_MAX_BYTES } from '../../canvas/zip.js'
6
- import type { ContextResponse, HealthResponse, PatchesResponse, SharedCanvasFetchResponse } from '../../contract/api.js'
6
+ import type {
7
+ ContextResponse,
8
+ HealthResponse,
9
+ PatchesResponse,
10
+ SharedCanvasFetchResponse,
11
+ } from '../../contract/api.js'
7
12
  import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
8
13
  import { createPrLoader, resolveBundle, runDiscovery } from '../bundle.js'
9
14
  import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
@@ -87,7 +92,10 @@ export function apiRoutes(ctx: AppContext): Hono {
87
92
 
88
93
  // How the page is painted. It lives in the same settings file the chat settings do, but on its
89
94
  // own route, because a repository with chat off still has a page to paint.
90
- const appearanceOf = (settings: { skin: AppearanceResponse['skin']; theme: AppearanceResponse['theme'] }) => ({
95
+ const appearanceOf = (settings: {
96
+ skin: AppearanceResponse['skin']
97
+ theme: AppearanceResponse['theme']
98
+ }) => ({
91
99
  skin: settings.skin,
92
100
  theme: settings.theme,
93
101
  })
@@ -197,11 +205,23 @@ export function apiRoutes(ctx: AppContext): Hono {
197
205
  throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
198
206
  }
199
207
  const pr = await loader.currentPr(number)
200
- const lines = await ctx.derived.readLines(pr.headSha, q.side === 'new' ? 'head' : 'base', q.path, q.from, q.to)
208
+ const lines = await ctx.derived.readLines(
209
+ pr.headSha,
210
+ q.side === 'new' ? 'head' : 'base',
211
+ q.path,
212
+ q.from,
213
+ q.to
214
+ )
201
215
  if (lines === null) {
202
216
  throw new AppError('NOT_FOUND', `${q.path} is not materialized for this head`, 404)
203
217
  }
204
- const body: ContextResponse = { path: q.path, side: q.side, from: q.from, to: q.from + lines.length - 1, lines }
218
+ const body: ContextResponse = {
219
+ path: q.path,
220
+ side: q.side,
221
+ from: q.from,
222
+ to: q.from + lines.length - 1,
223
+ lines,
224
+ }
205
225
  return c.json(body)
206
226
  })
207
227
 
@@ -226,7 +246,11 @@ export function apiRoutes(ctx: AppContext): Hono {
226
246
  throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
227
247
  }
228
248
  if (file.size > CANVAS_ZIP_MAX_BYTES) {
229
- throw new AppError('CANVAS_TOO_LARGE', `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`, 413)
249
+ throw new AppError(
250
+ 'CANVAS_TOO_LARGE',
251
+ `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
252
+ 413
253
+ )
230
254
  }
231
255
  const result = await importCanvas(ctx, {
232
256
  bytes: new Uint8Array(await file.arrayBuffer()),
@@ -12,14 +12,18 @@ import type { SettingsResponse } from '../../contract/settings.js'
12
12
  import { isChatAgent, SettingsInputSchema } from '../../contract/settings.js'
13
13
  import type { PrLoader } from '../bundle.js'
14
14
  import type { AppContext } from '../context.js'
15
- import { AppError } from '../errors.js'
15
+ import { AppError, logRequestError } from '../errors.js'
16
16
  import { SSE_HEADERS, sseStream } from '../sse.js'
17
17
  import { parsePrNumber } from './api.js'
18
18
 
19
19
  async function readJsonBody<T>(
20
20
  request: Request,
21
21
  schema: {
22
- safeParse: (raw: unknown) => { success: boolean; data?: T; error?: { issues: Array<{ message: string }> } }
22
+ safeParse: (raw: unknown) => {
23
+ success: boolean
24
+ data?: T
25
+ error?: { issues: Array<{ message: string }> }
26
+ }
23
27
  },
24
28
  expected: string
25
29
  ): Promise<T> {
@@ -106,7 +110,9 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
106
110
  return c.json(settingsResponse(ctx, await ctx.settings.write(input)))
107
111
  })
108
112
 
109
- api.get('/settings/agents', async c => c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' })))
113
+ api.get('/settings/agents', async c =>
114
+ c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' }))
115
+ )
110
116
 
111
117
  api.post('/settings/agents/:id/probe', async c => {
112
118
  const id = c.req.param('id')
@@ -141,7 +147,11 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
141
147
 
142
148
  api.post('/prs/:n/chat', async c => {
143
149
  const number = parsePrNumber(c.req.param('n'))
144
- const input = await readJsonBody(c.req.raw, ChatSendSchema, '{ "message": "…", "context": { "kind": "pr" } }')
150
+ const input = await readJsonBody(
151
+ c.req.raw,
152
+ ChatSendSchema,
153
+ '{ "message": "…", "context": { "kind": "pr" } }'
154
+ )
145
155
  const pr = await loader.currentPr(number)
146
156
  const artifact = await artifactForChat(ctx, number, pr)
147
157
  const derived = await ctx.derived.read(pr.headSha)
@@ -176,9 +186,14 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
176
186
  }
177
187
  // A browser that goes away stops the agent; the turn is nobody's answer any more.
178
188
  return new Response(
179
- sseStream(replayFrom(first, iterator), undefined, () => {
180
- void ctx.chat.cancel(number)
181
- }),
189
+ sseStream(
190
+ replayFrom(first, iterator),
191
+ undefined,
192
+ () => {
193
+ void ctx.chat.cancel(number)
194
+ },
195
+ err => logRequestError(ctx.log, c.req, err)
196
+ ),
182
197
  { headers: SSE_HEADERS }
183
198
  )
184
199
  })
@@ -190,7 +205,10 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
190
205
  * The turn as a stream again, after its first event was read to see whether the route can answer
191
206
  * with a stream at all. A turn that ended on that first read is an empty stream.
192
207
  */
193
- export function replayFrom(first: IteratorResult<ChatEvent>, rest: AsyncIterator<ChatEvent>): AsyncIterable<ChatEvent> {
208
+ export function replayFrom(
209
+ first: IteratorResult<ChatEvent>,
210
+ rest: AsyncIterator<ChatEvent>
211
+ ): AsyncIterable<ChatEvent> {
194
212
  let sent = first.done === true
195
213
  return {
196
214
  [Symbol.asyncIterator]: () => ({
@@ -217,5 +235,8 @@ export function toChatError(err: unknown): AppError {
217
235
  if (err instanceof ChatContextError) {
218
236
  return new AppError('BAD_REQUEST', err.message, 400)
219
237
  }
220
- return err instanceof AppError ? err : new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
238
+ if (err instanceof AppError) return err
239
+ const error = new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
240
+ error.cause = err
241
+ return error
221
242
  }
@@ -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
 
@@ -91,7 +91,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
91
91
  }
92
92
  }
93
93
 
94
- const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({ prNumber: number, state })
94
+ const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({
95
+ prNumber: number,
96
+ state,
97
+ })
95
98
 
96
99
  api.get('/prs/:n/state', async c => {
97
100
  const number = parsePrNumber(c.req.param('n'))
@@ -102,7 +105,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
102
105
  const number = parsePrNumber(c.req.param('n'))
103
106
  const id = c.req.param('id')
104
107
  if (!isReviewedId(id)) {
105
- throw new AppError('BAD_REQUEST', `not a reviewed id: ${id}`, 400, 'use layer:<id> or layer:<id>/file:<key>')
108
+ throw new AppError(
109
+ 'BAD_REQUEST',
110
+ `not a reviewed id: ${id}`,
111
+ 400,
112
+ 'use layer:<id> or layer:<id>/file:<key>'
113
+ )
106
114
  }
107
115
  const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
108
116
  const pr = await loader.currentPr(number)
@@ -223,7 +231,11 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
223
231
  * Keeps the cached comments in step with what was just posted, so a reload shows it once. With
224
232
  * nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
225
233
  */
226
- async function writeAppendedComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
234
+ async function writeAppendedComment(
235
+ ctx: AppContext,
236
+ number: number,
237
+ posted: PostCommentResult
238
+ ): Promise<void> {
227
239
  const comments = await ctx.prs.readComments(number)
228
240
  if (comments === null) {
229
241
  return
@@ -241,5 +253,8 @@ async function writeAppendedComment(ctx: AppContext, number: number, posted: Pos
241
253
  if (comments.issueComments.some(c => c.id === posted.comment.id)) {
242
254
  return
243
255
  }
244
- await ctx.prs.writeComments(number, { ...comments, issueComments: [...comments.issueComments, posted.comment] })
256
+ await ctx.prs.writeComments(number, {
257
+ ...comments,
258
+ issueComments: [...comments.issueComments, posted.comment],
259
+ })
245
260
  }
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()