@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
@@ -1,9 +1,22 @@
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'
4
5
  import { GitError } from '../git/git.js'
5
- import { GitHubApiError } from '../github/gh.js'
6
- import { PrNotFoundError } from '../github/pr.js'
6
+ import { LocalTargetError } from '../git/local-target.js'
7
+ import { PrNotFoundError } from '../host/pr.js'
8
+ import { CLI_INFO, type HostCli, HostCliError } from '../host/client.js'
9
+
10
+ /** The codes each host CLI's failures map to. The front end and the docs name them one by one. */
11
+ const CLI_ERROR_CODES: Record<HostCli, { missing: ErrorCode; unauthenticated: ErrorCode; api: ErrorCode }> = {
12
+ gh: { missing: 'GH_MISSING', unauthenticated: 'GH_UNAUTHENTICATED', api: 'GITHUB_API_ERROR' },
13
+ glab: { missing: 'GLAB_MISSING', unauthenticated: 'GLAB_UNAUTHENTICATED', api: 'GITLAB_API_ERROR' },
14
+ }
15
+
16
+ /** The codes that mean the host CLI itself, not the request, is the problem. */
17
+ export const CLI_SETUP_CODES: ReadonlySet<ErrorCode> = new Set(
18
+ Object.values(CLI_ERROR_CODES).flatMap(c => [c.missing, c.unauthenticated])
19
+ )
7
20
 
8
21
  export class AppError extends Error {
9
22
  readonly code: ErrorCode
@@ -12,7 +25,13 @@ export class AppError extends Error {
12
25
  /** What a rejected file got wrong, one line each. Never the file's content. */
13
26
  readonly issues: string[] | undefined
14
27
 
15
- constructor(code: ErrorCode, message: string, status: ContentfulStatusCode, hint?: string, issues?: string[]) {
28
+ constructor(
29
+ code: ErrorCode,
30
+ message: string,
31
+ status: ContentfulStatusCode,
32
+ hint?: string,
33
+ issues?: string[]
34
+ ) {
16
35
  super(message)
17
36
  this.name = 'AppError'
18
37
  this.code = code
@@ -39,21 +58,36 @@ export function toAppError(err: unknown): AppError {
39
58
  return err
40
59
  }
41
60
  if (err instanceof PrNotFoundError) {
42
- return new AppError('PR_NOT_FOUND', err.message, 404, 'check the number and that origin is the right repository')
61
+ return new AppError(
62
+ 'PR_NOT_FOUND',
63
+ err.message,
64
+ 404,
65
+ 'check the number and that origin is the right repository'
66
+ )
43
67
  }
44
- if (err instanceof GitHubApiError) {
68
+ if (err instanceof HostCliError) {
69
+ const codes = CLI_ERROR_CODES[err.cli]
70
+ const info = CLI_INFO[err.cli]
45
71
  if (err.missingBinary) {
46
72
  return new AppError(
47
- 'GH_MISSING',
48
- 'the GitHub CLI (gh) is not installed',
73
+ codes.missing,
74
+ `the ${info.label} is not installed`,
49
75
  500,
50
- 'install it from https://cli.github.com'
76
+ `install it from ${info.installUrl}`
51
77
  )
52
78
  }
53
79
  if (err.unauthenticated) {
54
- return new AppError('GH_UNAUTHENTICATED', 'gh is not logged in', 401, 'run `gh auth login`')
80
+ return new AppError(
81
+ codes.unauthenticated,
82
+ `${err.cli} is not logged in`,
83
+ 401,
84
+ `run \`${info.loginCommand}\``
85
+ )
55
86
  }
56
- return new AppError('GITHUB_API_ERROR', err.message, 502)
87
+ return new AppError(codes.api, err.message, 502)
88
+ }
89
+ if (err instanceof LocalTargetError) {
90
+ return new AppError('BAD_REQUEST', err.message, 400, err.hint)
57
91
  }
58
92
  if (err instanceof GitError) {
59
93
  return new AppError('GIT_ERROR', err.message, 500)
@@ -63,3 +97,15 @@ export function toAppError(err: unknown): AppError {
63
97
  }
64
98
  return new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
65
99
  }
100
+
101
+ /** Keep the original error and its causes in the server log, before response mapping loses the stack. */
102
+ export function logRequestError(
103
+ log: (line: string) => void,
104
+ request: { method: string; path: string },
105
+ err: unknown
106
+ ): void {
107
+ const mapped = toAppError(err)
108
+ if (mapped.status >= 500) {
109
+ log(`[serve] ${request.method} ${request.path} ${mapped.status} ${mapped.code}\n${inspect(err)}`)
110
+ }
111
+ }
@@ -1,7 +1,9 @@
1
1
  import { html, raw } from 'hono/html'
2
2
  import type { HtmlEscapedString } from 'hono/utils/html'
3
3
  import type { ErrorEnvelope, HomeData } from '../contract/api.js'
4
+ import { keyLabel, keyToString, type LocalKey, type ReviewKey } 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,50 +70,72 @@ ${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: { prNumber: ReviewKey; owner: string; repo: string; version: string; host: Host },
72
74
  nonce: string,
73
75
  appearance: Appearance
74
76
  ): Html {
77
+ const bootstrap = { ...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,
97
112
  body: html`<div class="page">
98
113
  <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>
114
+ <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
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
  }
@@ -132,7 +156,7 @@ export function errorPage(error: ErrorEnvelope['error'], nonce: string, appearan
132
156
  appearance,
133
157
  app: false,
134
158
  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>
159
+ <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
160
  <div class="stripe" aria-hidden="true"></div></header>
137
161
  <main id="main" class="home"><section class="panel error-card"><div class="panel-h"><h2><span class="mono">${error.code}</span></h2></div>
138
162
  <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,23 +3,51 @@ 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'
12
+ import { isLocalKey, parseReviewKey, type ReviewKey } from '../../contract/review-key.js'
7
13
  import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
8
- 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'
9
17
  import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
10
18
  import type { AppContext } from '../context.js'
11
19
  import { AppError } from '../errors.js'
12
20
  import { chatRoutes } from './chat-routes.js'
13
21
  import { reviewRoutes } from './review-routes.js'
14
22
 
15
- 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
+ }
16
36
 
17
- export function parsePrNumber(raw: string): number {
18
- const parsed = PrNumberSchema.safeParse(raw)
19
- if (!parsed.success) {
20
- 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
+ )
21
49
  }
22
- return parsed.data
50
+ return key
23
51
  }
24
52
 
25
53
  const ContextQuerySchema = z.object({
@@ -69,12 +97,14 @@ function parseHeadShaQuery(raw: string | undefined): string | undefined {
69
97
  async function currentCanvasSha(
70
98
  ctx: AppContext,
71
99
  loader: ReturnType<typeof createPrLoader>,
72
- number: number
100
+ key: ReviewKey
73
101
  ): Promise<string> {
74
- const pr = await loader.currentPr(number)
75
- 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)
76
106
  if (found.status === 'missing') {
77
- 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')
78
108
  }
79
109
  return found.headSha
80
110
  }
@@ -87,7 +117,10 @@ export function apiRoutes(ctx: AppContext): Hono {
87
117
 
88
118
  // How the page is painted. It lives in the same settings file the chat settings do, but on its
89
119
  // own route, because a repository with chat off still has a page to paint.
90
- const appearanceOf = (settings: { skin: AppearanceResponse['skin']; theme: AppearanceResponse['theme'] }) => ({
120
+ const appearanceOf = (settings: {
121
+ skin: AppearanceResponse['skin']
122
+ theme: AppearanceResponse['theme']
123
+ }) => ({
91
124
  skin: settings.skin,
92
125
  theme: settings.theme,
93
126
  })
@@ -148,6 +181,7 @@ export function apiRoutes(ctx: AppContext): Hono {
148
181
  : {}),
149
182
  },
150
183
  repo: ctx.config.repo,
184
+ host: publicHost(ctx.config.host),
151
185
  dataDir: ctx.config.dataDir,
152
186
  chat: {
153
187
  enabled: chatEnabled,
@@ -159,15 +193,24 @@ export function apiRoutes(ctx: AppContext): Hono {
159
193
  })
160
194
 
161
195
  api.get('/prs/:n', async c => {
162
- const number = parsePrNumber(c.req.param('n'))
163
- 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)
164
201
  return c.json(bundle)
165
202
  })
166
203
 
167
204
  api.get('/prs/:n/patches', async c => {
168
- const number = parsePrNumber(c.req.param('n'))
169
- const headSha = parseHeadShaQuery(c.req.query('headSha')) ?? (await loader.currentPr(number)).headSha
170
- 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
+ )
171
214
  if (derived === null) {
172
215
  throw new AppError(
173
216
  'NOT_FOUND',
@@ -181,13 +224,13 @@ export function apiRoutes(ctx: AppContext): Hono {
181
224
  })
182
225
 
183
226
  api.get('/prs/:n/comments', async c => {
184
- const number = parsePrNumber(c.req.param('n'))
227
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'reading comments')
185
228
  const { comments } = await loader.refreshComments(number)
186
229
  return c.json(comments)
187
230
  })
188
231
 
189
232
  api.get('/prs/:n/context', async c => {
190
- const number = parsePrNumber(c.req.param('n'))
233
+ const key = parseTargetKey(c.req.param('n'))
191
234
  const parsed = ContextQuerySchema.safeParse(c.req.query())
192
235
  if (!parsed.success) {
193
236
  throw new AppError('BAD_REQUEST', 'context needs path, side (new|old), from, to', 400)
@@ -196,20 +239,34 @@ export function apiRoutes(ctx: AppContext): Hono {
196
239
  if (q.to < q.from || q.to - q.from + 1 > CONTEXT_MAX_LINES) {
197
240
  throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
198
241
  }
199
- 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)
242
+ const pr = await loader.currentTarget(key)
243
+ const lines = await ctx.derived.readLines(
244
+ pr.headSha,
245
+ q.side === 'new' ? 'head' : 'base',
246
+ q.path,
247
+ q.from,
248
+ q.to
249
+ )
201
250
  if (lines === null) {
202
251
  throw new AppError('NOT_FOUND', `${q.path} is not materialized for this head`, 404)
203
252
  }
204
- const body: ContextResponse = { path: q.path, side: q.side, from: q.from, to: q.from + lines.length - 1, lines }
253
+ const body: ContextResponse = {
254
+ path: q.path,
255
+ side: q.side,
256
+ from: q.from,
257
+ to: q.from + lines.length - 1,
258
+ lines,
259
+ }
205
260
  return c.json(body)
206
261
  })
207
262
 
208
263
  api.get('/prs/:n/export', async c => {
209
- const number = parsePrNumber(c.req.param('n'))
264
+ const key = parseTargetKey(c.req.param('n'))
210
265
  const requested = parseHeadShaQuery(c.req.query('headSha'))
211
- const headSha = requested ?? (await currentCanvasSha(ctx, loader, number))
212
- 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)
213
270
  // The name is built from the repo slug, the PR number, and the sha, so it holds no user text.
214
271
  return new Response(new Blob([zip.bytes]), {
215
272
  headers: {
@@ -220,29 +277,33 @@ export function apiRoutes(ctx: AppContext): Hono {
220
277
  })
221
278
 
222
279
  api.post('/prs/:n/import', async c => {
223
- const number = parsePrNumber(c.req.param('n'))
280
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'importing a shared canvas')
224
281
  const { file, force } = await readUpload(c.req.raw)
225
282
  if (!(file instanceof File)) {
226
283
  throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
227
284
  }
228
285
  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)
286
+ throw new AppError(
287
+ 'CANVAS_TOO_LARGE',
288
+ `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
289
+ 413
290
+ )
230
291
  }
231
292
  const result = await importCanvas(ctx, {
232
293
  bytes: new Uint8Array(await file.arrayBuffer()),
233
294
  prNumber: number,
234
- currentHeadSha: (await loader.currentPr(number)).headSha,
295
+ currentHead: await loader.currentPr(number),
235
296
  force: force === '1',
236
297
  })
237
298
  return c.json(result)
238
299
  })
239
300
 
240
301
  api.post('/prs/:n/shared-canvas/fetch', async c => {
241
- const number = parsePrNumber(c.req.param('n'))
302
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'looking for a shared canvas')
242
303
  // Looking again means looking at the pull request as it is now, not at the cached copy.
243
304
  const { pr, comments } = await loader.load(number, { refresh: true })
244
305
  const discovery = await runDiscovery(ctx, pr, comments, { refresh: true })
245
- const found = await ctx.canvases.findForPr(number, pr.headSha)
306
+ const found = await lookupCanvas(ctx, number, pr)
246
307
  const body: SharedCanvasFetchResponse = {
247
308
  imported: discovery.imported,
248
309
  status: found.status,