@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -1,7 +1,9 @@
1
1
  import { html, raw } from 'hono/html'
2
2
  import type { HtmlEscapedString } from 'hono/utils/html'
3
- import type { ErrorEnvelope, HomeData } from '../contract/api.js'
3
+ import type { ErrorEnvelope, HomeData, ReviewBootstrap } from '../contract/api.js'
4
+ import { keyLabel, keyToString, type LocalKey } from '../contract/review-key.js'
4
5
  import type { Appearance } from '../contract/settings.js'
6
+ import { type Host, publicHost } from '../host/host.js'
5
7
 
6
8
  type Html = HtmlEscapedString | Promise<HtmlEscapedString>
7
9
 
@@ -68,29 +70,42 @@ ${opts.app ? html`<script type="module" src="/static/js/app.js"></script>` : ''}
68
70
  }
69
71
 
70
72
  export function reviewPage(
71
- bootstrap: { prNumber: number; owner: string; repo: string; version: string },
73
+ page: Omit<ReviewBootstrap, 'host'> & { host: Host },
72
74
  nonce: string,
73
75
  appearance: Appearance
74
76
  ): Html {
77
+ const bootstrap: ReviewBootstrap = { ...page, host: publicHost(page.host) }
78
+ const key = keyToString(bootstrap.prNumber)
79
+ const what =
80
+ typeof bootstrap.prNumber === 'number' ? `${page.host.nounShort} #${key}` : keyLabel(bootstrap.prNumber)
75
81
  return pageShell({
76
- title: `PR #${bootstrap.prNumber} · ${bootstrap.owner}/${bootstrap.repo} · review canvas`,
82
+ title: `${what} · ${bootstrap.owner}/${bootstrap.repo} · review canvas`,
77
83
  bootstrap,
78
84
  nonce,
79
85
  appearance,
80
86
  app: true,
81
87
  body: html`<a class="skip" href="#main">Skip to content</a>
82
- <pr-app class="page" data-pr="${String(bootstrap.prNumber)}"><div class="loading">Loading PR #${String(bootstrap.prNumber)}…</div></pr-app>`,
88
+ <pr-app class="page" data-pr="${key}"><div class="loading">Loading ${what}…</div></pr-app>`,
83
89
  })
84
90
  }
85
91
 
86
92
  export function homePage(
87
- data: HomeData & { owner: string; repo: string; version: string; port: number },
93
+ data: HomeData & {
94
+ owner: string
95
+ repo: string
96
+ version: string
97
+ port: number
98
+ host: Host
99
+ /** The local reviews prepared here, so the home page can link straight to them. */
100
+ localReviews: readonly LocalKey[]
101
+ },
88
102
  nonce: string,
89
103
  appearance: Appearance
90
104
  ): Html {
105
+ const { noun, nounShort, label } = data.host
91
106
  return pageShell({
92
107
  title: 'PR review canvas',
93
- bootstrap: { owner: data.owner, repo: data.repo, version: data.version },
108
+ bootstrap: { owner: data.owner, repo: data.repo, version: data.version, host: publicHost(data.host) },
94
109
  nonce,
95
110
  appearance,
96
111
  app: false,
@@ -100,18 +115,27 @@ export function homePage(
100
115
  <div class="hdr-actions"><a class="cmd" href="/api/health">health</a></div></div>
101
116
  <div class="stripe" aria-hidden="true"></div>
102
117
  <div class="hdr-title"><div class="title"><h1>${data.owner}/${data.repo}</h1></div>
103
- <p class="meta"><span>Open a pull request by number. Diffs come from your local clone; the canvas from a published review.</span></p></div>
118
+ <p class="meta"><span>Open a ${noun} by number. Diffs come from your local clone; the canvas from a published review.</span></p></div>
104
119
  </header>
105
120
  <main id="main" class="home">
106
- <section class="panel"><div class="panel-h"><h2>Open a pull request</h2></div>
121
+ <section class="panel"><div class="panel-h"><h2>Open a ${noun}</h2></div>
107
122
  <form class="body home-form" method="get" action="/review">
108
- <label>PR number <input name="n" type="number" min="1" required inputmode="numeric"></label>
123
+ <label>${nounShort} number <input name="n" type="number" min="1" required inputmode="numeric"></label>
109
124
  <button class="cmd fill" type="submit">open</button>
110
125
  </form></section>
126
+ <section class="panel"><div class="panel-h"><h2>Before the ${noun}</h2></div>
127
+ <div class="body">${
128
+ data.localReviews.length === 0
129
+ ? html`<p class="muted">Nothing reviewed here yet. Run <code>/pr-review-canvas branch</code> to read the current branch against the default one, or <code>/pr-review-canvas uncommitted</code> to read it with your working-tree edits on top, before opening a ${noun}.</p>`
130
+ : html`<ul class="plain">${data.localReviews.map(
131
+ key =>
132
+ html`<li><a href="/review/${key}">${keyLabel(key)}</a> <span class="muted">/review/${key}</span></li>`
133
+ )}</ul>`
134
+ }</div></section>
111
135
  <section class="panel"><div class="panel-h"><h2>Recent</h2></div>
112
136
  ${
113
137
  data.recentPrs.length === 0
114
- ? html`<div class="body muted">No pull requests opened yet.</div>`
138
+ ? html`<div class="body muted">No ${noun}s opened yet.</div>`
115
139
  : html`<ul class="plain body">${data.recentPrs.map(
116
140
  p =>
117
141
  html`<li><a href="/review/${String(p.number)}"><span class="mono num">#${String(p.number)}</span> ${p.title}</a></li>`
@@ -119,7 +143,7 @@ ${
119
143
  }
120
144
  </section>
121
145
  </main>
122
- <footer><span>pr-review ${data.version}</span><span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>
146
+ <footer><span>pr-review ${data.version}</span><span>localhost only · nothing leaves this machine except ${label} posts you confirm</span></footer>
123
147
  </div>`,
124
148
  })
125
149
  }
@@ -9,22 +9,45 @@ import type {
9
9
  PatchesResponse,
10
10
  SharedCanvasFetchResponse,
11
11
  } from '../../contract/api.js'
12
+ import { isLocalKey, parseReviewKey, type ReviewKey } from '../../contract/review-key.js'
12
13
  import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
13
- import { createPrLoader, resolveBundle, runDiscovery } from '../bundle.js'
14
+ import { publicHost } from '../../host/host.js'
15
+ import { lookupCanvas } from '../../review/carry-over.js'
16
+ import { createPrLoader, resolveBundle, resolveLocalBundle, runDiscovery } from '../bundle.js'
14
17
  import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
15
18
  import type { AppContext } from '../context.js'
16
19
  import { AppError } from '../errors.js'
17
20
  import { chatRoutes } from './chat-routes.js'
18
21
  import { reviewRoutes } from './review-routes.js'
19
22
 
20
- export const PrNumberSchema = z.coerce.number().int().positive()
23
+ /** The target a route's `:n` names: a pull request number, or `local` for work with no PR yet. */
24
+ export function parseTargetKey(raw: string): ReviewKey {
25
+ const key = parseReviewKey(raw)
26
+ if (key === null) {
27
+ throw new AppError(
28
+ 'BAD_REQUEST',
29
+ `not a review target: ${raw}`,
30
+ 400,
31
+ 'use a PR number, `branch`, or `uncommitted`'
32
+ )
33
+ }
34
+ return key
35
+ }
21
36
 
22
- export function parsePrNumber(raw: string): number {
23
- const parsed = PrNumberSchema.safeParse(raw)
24
- if (!parsed.success) {
25
- throw new AppError('BAD_REQUEST', `not a pull request number: ${raw}`, 400)
37
+ /**
38
+ * The pull request behind a key, for the routes that talk to the forge. Local work has none, and
39
+ * saying so is better than letting the call fail somewhere inside the host client.
40
+ */
41
+ export function requirePrNumber(key: ReviewKey, what: string): number {
42
+ if (isLocalKey(key)) {
43
+ throw new AppError(
44
+ 'BAD_REQUEST',
45
+ `${what} needs a pull request, and this canvas describes work that has none yet`,
46
+ 400,
47
+ 'open the pull request, then review it at /review/<number>'
48
+ )
26
49
  }
27
- return parsed.data
50
+ return key
28
51
  }
29
52
 
30
53
  const ContextQuerySchema = z.object({
@@ -74,12 +97,14 @@ function parseHeadShaQuery(raw: string | undefined): string | undefined {
74
97
  async function currentCanvasSha(
75
98
  ctx: AppContext,
76
99
  loader: ReturnType<typeof createPrLoader>,
77
- number: number
100
+ key: ReviewKey
78
101
  ): Promise<string> {
79
- const pr = await loader.currentPr(number)
80
- const found = await ctx.canvases.findForPr(number, pr.headSha)
102
+ const pr = await loader.currentTarget(key)
103
+ const found = isLocalKey(key)
104
+ ? await ctx.canvases.findForLocal(key, pr.headSha)
105
+ : await lookupCanvas(ctx, key, pr)
81
106
  if (found.status === 'missing') {
82
- throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
107
+ throw new AppError('CANVAS_NOT_FOUND', `no canvas for ${String(key)}`, 404, 'generate one first')
83
108
  }
84
109
  return found.headSha
85
110
  }
@@ -156,6 +181,7 @@ export function apiRoutes(ctx: AppContext): Hono {
156
181
  : {}),
157
182
  },
158
183
  repo: ctx.config.repo,
184
+ host: publicHost(ctx.config.host),
159
185
  dataDir: ctx.config.dataDir,
160
186
  chat: {
161
187
  enabled: chatEnabled,
@@ -167,15 +193,24 @@ export function apiRoutes(ctx: AppContext): Hono {
167
193
  })
168
194
 
169
195
  api.get('/prs/:n', async c => {
170
- const number = parsePrNumber(c.req.param('n'))
171
- const bundle = await resolveBundle(ctx, loader, number, { refresh: c.req.query('refresh') === '1' })
196
+ const key = parseTargetKey(c.req.param('n'))
197
+ const opts = { refresh: c.req.query('refresh') === '1', poll: c.req.query('poll') === '1' }
198
+ const bundle = isLocalKey(key)
199
+ ? await resolveLocalBundle(ctx, loader, key, opts)
200
+ : await resolveBundle(ctx, loader, key, opts)
172
201
  return c.json(bundle)
173
202
  })
174
203
 
175
204
  api.get('/prs/:n/patches', async c => {
176
- const number = parsePrNumber(c.req.param('n'))
177
- const headSha = parseHeadShaQuery(c.req.query('headSha')) ?? (await loader.currentPr(number)).headSha
178
- const derived = await ctx.derived.read(headSha)
205
+ const key = parseTargetKey(c.req.param('n'))
206
+ const pr = await loader.currentTarget(key)
207
+ const headSha = parseHeadShaQuery(c.req.query('headSha')) ?? pr.headSha
208
+ // Only the target's own head has a merge base to build from; any other sha the page names is
209
+ // an older canvas, whose diffs were written when that canvas was drawn.
210
+ const derived = await ctx.derived.readOrBuild(
211
+ headSha,
212
+ headSha === pr.headSha ? pr.mergeBaseSha : undefined
213
+ )
179
214
  if (derived === null) {
180
215
  throw new AppError(
181
216
  'NOT_FOUND',
@@ -189,13 +224,13 @@ export function apiRoutes(ctx: AppContext): Hono {
189
224
  })
190
225
 
191
226
  api.get('/prs/:n/comments', async c => {
192
- const number = parsePrNumber(c.req.param('n'))
227
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'reading comments')
193
228
  const { comments } = await loader.refreshComments(number)
194
229
  return c.json(comments)
195
230
  })
196
231
 
197
232
  api.get('/prs/:n/context', async c => {
198
- const number = parsePrNumber(c.req.param('n'))
233
+ const key = parseTargetKey(c.req.param('n'))
199
234
  const parsed = ContextQuerySchema.safeParse(c.req.query())
200
235
  if (!parsed.success) {
201
236
  throw new AppError('BAD_REQUEST', 'context needs path, side (new|old), from, to', 400)
@@ -204,7 +239,7 @@ export function apiRoutes(ctx: AppContext): Hono {
204
239
  if (q.to < q.from || q.to - q.from + 1 > CONTEXT_MAX_LINES) {
205
240
  throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
206
241
  }
207
- const pr = await loader.currentPr(number)
242
+ const pr = await loader.currentTarget(key)
208
243
  const lines = await ctx.derived.readLines(
209
244
  pr.headSha,
210
245
  q.side === 'new' ? 'head' : 'base',
@@ -226,10 +261,12 @@ export function apiRoutes(ctx: AppContext): Hono {
226
261
  })
227
262
 
228
263
  api.get('/prs/:n/export', async c => {
229
- const number = parsePrNumber(c.req.param('n'))
264
+ const key = parseTargetKey(c.req.param('n'))
230
265
  const requested = parseHeadShaQuery(c.req.query('headSha'))
231
- const headSha = requested ?? (await currentCanvasSha(ctx, loader, number))
232
- const zip = await buildCanvasZipFor(ctx, headSha, number)
266
+ const headSha = requested ?? (await currentCanvasSha(ctx, loader, key))
267
+ // A local canvas is exported without a number, which is what makes it importable on the
268
+ // pull request once that exists.
269
+ const zip = await buildCanvasZipFor(ctx, headSha, isLocalKey(key) ? undefined : key)
233
270
  // The name is built from the repo slug, the PR number, and the sha, so it holds no user text.
234
271
  return new Response(new Blob([zip.bytes]), {
235
272
  headers: {
@@ -240,7 +277,7 @@ export function apiRoutes(ctx: AppContext): Hono {
240
277
  })
241
278
 
242
279
  api.post('/prs/:n/import', async c => {
243
- const number = parsePrNumber(c.req.param('n'))
280
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'importing a shared canvas')
244
281
  const { file, force } = await readUpload(c.req.raw)
245
282
  if (!(file instanceof File)) {
246
283
  throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
@@ -255,18 +292,18 @@ export function apiRoutes(ctx: AppContext): Hono {
255
292
  const result = await importCanvas(ctx, {
256
293
  bytes: new Uint8Array(await file.arrayBuffer()),
257
294
  prNumber: number,
258
- currentHeadSha: (await loader.currentPr(number)).headSha,
295
+ currentHead: await loader.currentPr(number),
259
296
  force: force === '1',
260
297
  })
261
298
  return c.json(result)
262
299
  })
263
300
 
264
301
  api.post('/prs/:n/shared-canvas/fetch', async c => {
265
- const number = parsePrNumber(c.req.param('n'))
302
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'looking for a shared canvas')
266
303
  // Looking again means looking at the pull request as it is now, not at the cached copy.
267
304
  const { pr, comments } = await loader.load(number, { refresh: true })
268
305
  const discovery = await runDiscovery(ctx, pr, comments, { refresh: true })
269
- const found = await ctx.canvases.findForPr(number, pr.headSha)
306
+ const found = await lookupCanvas(ctx, number, pr)
270
307
  const body: SharedCanvasFetchResponse = {
271
308
  imported: discovery.imported,
272
309
  status: found.status,
@@ -1,6 +1,6 @@
1
- // The AI Chat pane's routes and the personal settings behind it. Every route here answers 404
2
- // when the project config turns chat off, so a repository that does not want an agent in the
3
- // loop has no agent surface at all.
1
+ // The AI Chat pane's routes and the personal settings behind it. Every chat and agent route here
2
+ // answers 404 when the project config turns chat off, so a repository that does not want an agent
3
+ // in the loop has no agent surface at all; the settings file stays reachable for the reading level.
4
4
  import { Hono, type MiddlewareHandler } from 'hono'
5
5
  import { ChatBusyError } from '../../chat/chat-manager.js'
6
6
  import { ChatContextError } from '../../chat/context.js'
@@ -10,11 +10,14 @@ 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
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,
@@ -53,37 +56,90 @@ function settingsResponse(ctx: AppContext, settings: SettingsResponse['settings'
53
56
  maxRepairRounds: project.generation.maxRepairRounds,
54
57
  inlineDiffMaxLines: project.generation.inlineDiffMaxLines,
55
58
  smallPrHunks: project.generation.smallPrHunks,
59
+ keepForIdenticalDiff: project.canvas.keepForIdenticalDiff,
56
60
  layers: project.layers.length,
57
61
  highRisk: project.highRisk.length,
58
62
  },
59
63
  }
60
64
  }
61
65
 
62
- /** The canvas the chat talks about: the one written for the pull request's current head. */
63
- 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> {
64
80
  if (ctx.fixtureArtifact !== null) {
65
- return { ...ctx.fixtureArtifact, pr }
81
+ return withDiff({ ...ctx.fixtureArtifact, pr }, pr.headSha, await ctx.derived.read(pr.headSha))
66
82
  }
67
- const found = await ctx.canvases.findForPr(number, pr.headSha)
68
- const artifact = found.status === 'ready' ? await ctx.canvases.readArtifact(found.headSha) : null
69
- 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) {
70
88
  throw new AppError(
71
89
  'CANVAS_NOT_FOUND',
72
- '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',
73
91
  404,
74
92
  'generate a canvas for the current head first'
75
93
  )
76
94
  }
77
- 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))
78
109
  }
79
110
 
80
- /** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
81
- export const CHAT_ROUTE_PATTERNS = ['/settings', '/settings/*', '/prs/:n/chat', '/prs/:n/chat/*'] as const
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 }
125
+ }
126
+
127
+ /**
128
+ * Every chat path this file serves, so the chat-disabled check covers all of them and nothing
129
+ * else. `/settings` itself stays open: the settings file also holds the reading level a review
130
+ * opens at, which the page reads whether or not chat is on.
131
+ */
132
+ export const CHAT_ROUTE_PATTERNS = [
133
+ '/settings/agents',
134
+ '/settings/agents/*',
135
+ '/prs/:n/chat',
136
+ '/prs/:n/chat/*',
137
+ ] as const
82
138
 
83
139
  export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
84
140
  const api = new Hono()
85
141
 
86
- // With chat off, none of these routes exist. The patterns are listed rather than `*`: this
142
+ // With chat off, none of the chat routes exist. The patterns are listed rather than `*`: this
87
143
  // app is mounted at the API root, so a blanket middleware would answer for every route.
88
144
  const requireChat: MiddlewareHandler = async (_c, next) => {
89
145
  if (!ctx.projectConfig.config.chat.enabled) {
@@ -122,56 +178,47 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
122
178
  return c.json(await ctx.agents.probe(id, { refresh: c.req.query('refresh') === '1' }))
123
179
  })
124
180
 
125
- api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parsePrNumber(c.req.param('n')))))
181
+ api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parseTargetKey(c.req.param('n')))))
126
182
 
127
183
  api.post('/prs/:n/chat/threads', async c => {
128
- const number = parsePrNumber(c.req.param('n'))
129
- const thread = await ctx.chat.createThread(number)
130
- return c.json({ thread, ...(await ctx.chat.threads(number)) }, 201)
184
+ const key = parseTargetKey(c.req.param('n'))
185
+ const thread = await ctx.chat.createThread(key)
186
+ return c.json({ thread, ...(await ctx.chat.threads(key)) }, 201)
131
187
  })
132
188
 
133
189
  api.get('/prs/:n/chat/threads/:name/history', async c => {
134
- const number = parsePrNumber(c.req.param('n'))
190
+ const key = parseTargetKey(c.req.param('n'))
135
191
  const name = c.req.param('name')
136
- if (!isThreadNameFor(name, number)) {
137
- throw new AppError('NOT_FOUND', `no chat thread named ${name} on this pull request`, 404)
192
+ if (!isThreadNameFor(name, key)) {
193
+ throw new AppError('NOT_FOUND', `no chat thread named ${name} on this review`, 404)
138
194
  }
139
- const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(number, name) }
195
+ const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(key, name) }
140
196
  return c.json(body)
141
197
  })
142
198
 
143
199
  api.post('/prs/:n/chat/cancel', async c => {
144
- const number = parsePrNumber(c.req.param('n'))
145
- return c.json({ cancelled: await ctx.chat.cancel(number) })
200
+ const key = parseTargetKey(c.req.param('n'))
201
+ return c.json({ cancelled: await ctx.chat.cancel(key) })
146
202
  })
147
203
 
148
204
  api.post('/prs/:n/chat', async c => {
149
- const number = parsePrNumber(c.req.param('n'))
205
+ const key = parseTargetKey(c.req.param('n'))
150
206
  const input = await readJsonBody(
151
207
  c.req.raw,
152
208
  ChatSendSchema,
153
209
  '{ "message": "…", "context": { "kind": "pr" } }'
154
210
  )
155
- const pr = await loader.currentPr(number)
156
- const artifact = await artifactForChat(ctx, number, pr)
157
- const derived = await ctx.derived.read(pr.headSha)
158
- if (derived === null) {
159
- throw new AppError(
160
- 'NOT_FOUND',
161
- 'the diff of this head is not available locally, so the chat cannot quote it',
162
- 404,
163
- 'fetch the PR head and reload'
164
- )
165
- }
211
+ const pr = await loader.currentTarget(key)
212
+ const { artifact, headSha, files, patches } = await subjectForChat(ctx, key, pr)
166
213
  const events = ctx.chat.send(
167
214
  {
168
- prNumber: number,
169
- headSha: pr.headSha,
215
+ key,
216
+ headSha,
170
217
  artifact,
171
- files: derived.files,
172
- patches: derived.patches,
173
- derivedDir: ctx.derived.derivedDir(pr.headSha),
174
- readLines: (side, filePath, from, to) => ctx.derived.readLines(pr.headSha, side, filePath, from, to),
218
+ files,
219
+ patches,
220
+ derivedDir: ctx.derived.derivedDir(headSha),
221
+ readLines: (side, filePath, from, to) => ctx.derived.readLines(headSha, side, filePath, from, to),
175
222
  },
176
223
  { message: input.message, context: input.context, thread: input.thread }
177
224
  )
@@ -190,7 +237,7 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
190
237
  replayFrom(first, iterator),
191
238
  undefined,
192
239
  () => {
193
- void ctx.chat.cancel(number)
240
+ void ctx.chat.cancel(key)
194
241
  },
195
242
  err => logRequestError(ctx.log, c.req, err)
196
243
  ),
@@ -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> {
@@ -22,15 +22,20 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
22
22
  const app = new Hono<AppEnv>()
23
23
 
24
24
  app.get('/', async c => {
25
- 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
+ ])
26
29
  return c.html(
27
30
  homePage(
28
31
  {
29
32
  recentPrs,
33
+ localReviews: LOCAL_KEYS.filter((_, i) => localPrs[i] !== null && localPrs[i] !== undefined),
30
34
  owner: ctx.config.repo.owner,
31
35
  repo: ctx.config.repo.name,
32
36
  version: ctx.version,
33
37
  port: ctx.config.port,
38
+ host: ctx.config.host,
34
39
  },
35
40
  c.get('cspNonce'),
36
41
  await appearanceFor(ctx, appearanceQuery(c))
@@ -47,17 +52,29 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
47
52
 
48
53
  app.get('/review/:n', async c => {
49
54
  const raw = c.req.param('n')
50
- let prNumber: number
51
- try {
52
- prNumber = parsePrNumber(raw)
53
- } catch {
54
- 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
+ )
55
63
  }
64
+ // One read serves both the appearance and the reading level the canvas opens at.
65
+ const settings = await ctx.settings.read()
56
66
  return c.html(
57
67
  reviewPage(
58
- { prNumber, owner: ctx.config.repo.owner, repo: ctx.config.repo.name, version: ctx.version },
68
+ {
69
+ prNumber,
70
+ owner: ctx.config.repo.owner,
71
+ repo: ctx.config.repo.name,
72
+ version: ctx.version,
73
+ host: ctx.config.host,
74
+ foldLevel: settings.foldLevel,
75
+ },
59
76
  c.get('cspNonce'),
60
- await appearanceFor(ctx, appearanceQuery(c))
77
+ appearanceForRequest(settings, appearanceQuery(c))
61
78
  )
62
79
  )
63
80
  })