@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,140 @@
1
+ import { html, raw } from 'hono/html'
2
+ import type { HtmlEscapedString } from 'hono/utils/html'
3
+ import type { ErrorEnvelope, HomeData } from '../contract/api.js'
4
+ import type { Appearance } from '../contract/settings.js'
5
+
6
+ type Html = HtmlEscapedString | Promise<HtmlEscapedString>
7
+
8
+ /** Bare names the browser modules import; the server maps them to `/vendor/*` files. */
9
+ export const IMPORT_MAP = {
10
+ imports: {
11
+ diff: '/vendor/diff/index.js',
12
+ marked: '/vendor/marked.js',
13
+ dompurify: '/vendor/purify.js',
14
+ hljs: '/vendor/highlight.js',
15
+ mermaid: '/vendor/mermaid/mermaid.esm.min.mjs',
16
+ },
17
+ } as const
18
+
19
+ /**
20
+ * Names the page must not preload: mermaid is imported by `diagram.js` only when a screen holds a
21
+ * diagram, and most screens hold none.
22
+ */
23
+ export const LAZY_IMPORTS: readonly string[] = ['mermaid']
24
+
25
+ /** JSON that is safe inside a <script> element: no `<`, `>`, `&`, or line separators. */
26
+ export function jsonForScript(value: unknown): string {
27
+ return JSON.stringify(value)
28
+ .replace(/</g, '\\u003c')
29
+ .replace(/>/g, '\\u003e')
30
+ .replace(/&/g, '\\u0026')
31
+ .replace(/\u2028/g, '\\u2028')
32
+ .replace(/\u2029/g, '\\u2029')
33
+ }
34
+
35
+ export interface PageOptions {
36
+ title: string
37
+ bootstrap: unknown
38
+ body: Html
39
+ /** Load the app module. Off for the plain pages (home, error). */
40
+ app: boolean
41
+ /** The nonce of this response's Content-Security-Policy; the inline scripts carry it. */
42
+ nonce: string
43
+ /** How this page is painted, from the settings file or this request's `?skin` and `?theme`. */
44
+ appearance: Appearance
45
+ }
46
+
47
+ export function pageShell(opts: PageOptions): Html {
48
+ const preload = Object.entries(IMPORT_MAP.imports)
49
+ .filter(([name]) => !LAZY_IMPORTS.includes(name))
50
+ .map(([, href]) => href)
51
+ return html`<!doctype html>
52
+ <html lang="en" data-skin="${opts.appearance.skin}" data-theme="${opts.appearance.theme}">
53
+ <head>
54
+ <meta charset="utf-8">
55
+ <meta name="viewport" content="width=device-width, initial-scale=1">
56
+ <title>${opts.title}</title>
57
+ <link rel="stylesheet" href="/static/styles.css">
58
+ <script type="importmap" nonce="${opts.nonce}">${raw(jsonForScript(IMPORT_MAP))}</script>
59
+ ${opts.app ? preload.map(href => html`<link rel="modulepreload" href="${href}">`) : ''}
60
+ ${opts.app ? html`<link rel="modulepreload" href="/static/js/app.js">` : ''}
61
+ <script id="bootstrap" type="application/json" nonce="${opts.nonce}">${raw(jsonForScript(opts.bootstrap))}</script>
62
+ </head>
63
+ <body>
64
+ ${opts.body}
65
+ ${opts.app ? html`<script type="module" src="/static/js/app.js"></script>` : ''}
66
+ </body>
67
+ </html>`
68
+ }
69
+
70
+ export function reviewPage(
71
+ bootstrap: { prNumber: number; owner: string; repo: string; version: string },
72
+ nonce: string,
73
+ appearance: Appearance
74
+ ): Html {
75
+ return pageShell({
76
+ title: `PR #${bootstrap.prNumber} · ${bootstrap.owner}/${bootstrap.repo} · review canvas`,
77
+ bootstrap,
78
+ nonce,
79
+ appearance,
80
+ app: true,
81
+ 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>`,
83
+ })
84
+ }
85
+
86
+ export function homePage(
87
+ data: HomeData & { owner: string; repo: string; version: string; port: number },
88
+ nonce: string,
89
+ appearance: Appearance
90
+ ): Html {
91
+ return pageShell({
92
+ title: 'PR review canvas',
93
+ bootstrap: { owner: data.owner, repo: data.repo, version: data.version },
94
+ nonce,
95
+ appearance,
96
+ app: false,
97
+ body: html`<div class="page">
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>
100
+ <div class="hdr-actions"><a class="cmd" href="/api/health">health</a></div></div>
101
+ <div class="stripe" aria-hidden="true"></div>
102
+ <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>
104
+ </header>
105
+ <main id="main" class="home">
106
+ <section class="panel"><div class="panel-h"><h2>Open a pull request</h2></div>
107
+ <form class="body home-form" method="get" action="/review">
108
+ <label>PR number <input name="n" type="number" min="1" required inputmode="numeric"></label>
109
+ <button class="cmd fill" type="submit">open</button>
110
+ </form></section>
111
+ <section class="panel"><div class="panel-h"><h2>Recent</h2></div>
112
+ ${
113
+ data.recentPrs.length === 0
114
+ ? html`<div class="body muted">No pull requests opened yet.</div>`
115
+ : html`<ul class="plain body">${data.recentPrs.map(
116
+ p =>
117
+ html`<li><a href="/review/${String(p.number)}"><span class="mono num">#${String(p.number)}</span> ${p.title}</a></li>`
118
+ )}</ul>`
119
+ }
120
+ </section>
121
+ </main>
122
+ <footer><span>pr-review ${data.version}</span><span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>
123
+ </div>`,
124
+ })
125
+ }
126
+
127
+ export function errorPage(error: ErrorEnvelope['error'], nonce: string, appearance: Appearance): Html {
128
+ return pageShell({
129
+ title: `Error · ${error.code}`,
130
+ bootstrap: { error },
131
+ nonce,
132
+ appearance,
133
+ app: false,
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>
136
+ <div class="stripe" aria-hidden="true"></div></header>
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
+ <div class="body"><p>${error.message}</p>${error.hint ? html`<p class="muted">${error.hint}</p>` : ''}</div></section></main></div>`,
139
+ })
140
+ }
@@ -0,0 +1,42 @@
1
+ import { serve } from '@hono/node-server'
2
+ import { createApp } from './app.js'
3
+ import type { AppContext } from './context.js'
4
+
5
+ /** Binds 127.0.0.1 only. The Host allowlist in security.ts covers the rest. */
6
+ export function startServer(ctx: AppContext, log: (line: string) => void): { close: () => void } {
7
+ const app = createApp(ctx)
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}`)
10
+ log(`data dir ${ctx.config.dataDir}`)
11
+ if (ctx.fixtureArtifact !== null) {
12
+ log(`fixture canvas ${ctx.config.fixtureCanvasPath ?? ''} (dev only): every PR reports ready`)
13
+ }
14
+ for (const w of ctx.projectConfig.warnings) {
15
+ log(`warning: ${w}`)
16
+ }
17
+ })
18
+ let closed = false
19
+ const close = (): void => {
20
+ if (closed) {
21
+ return
22
+ }
23
+ closed = true
24
+ // A browser holds its keep-alive socket open, and `close()` alone waits for it, so Ctrl-C
25
+ // would look like a hung process. Dropping the sockets ends the wait. HTTP/2 servers have no
26
+ // such method, and this one never is one.
27
+ if ('closeAllConnections' in server && typeof server.closeAllConnections === 'function') {
28
+ server.closeAllConnections()
29
+ }
30
+ server.close()
31
+ process.off('SIGINT', onSignal)
32
+ process.off('SIGTERM', onSignal)
33
+ }
34
+ const onSignal = (): void => {
35
+ close()
36
+ log('stopped')
37
+ process.exit(0)
38
+ }
39
+ process.once('SIGINT', onSignal)
40
+ process.once('SIGTERM', onSignal)
41
+ return { close }
42
+ }
@@ -0,0 +1,256 @@
1
+ import { Hono } from 'hono'
2
+ import { z } from 'zod'
3
+ import { buildCanvasZipFor } from '../../canvas/export.js'
4
+ import { importCanvas } from '../../canvas/import.js'
5
+ import { CANVAS_ZIP_MAX_BYTES } from '../../canvas/zip.js'
6
+ import type { ContextResponse, HealthResponse, PatchesResponse, SharedCanvasFetchResponse } from '../../contract/api.js'
7
+ import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
8
+ import { createPrLoader, resolveBundle, runDiscovery } from '../bundle.js'
9
+ import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
10
+ import type { AppContext } from '../context.js'
11
+ import { AppError } from '../errors.js'
12
+ import { chatRoutes } from './chat-routes.js'
13
+ import { reviewRoutes } from './review-routes.js'
14
+
15
+ export const PrNumberSchema = z.coerce.number().int().positive()
16
+
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)
21
+ }
22
+ return parsed.data
23
+ }
24
+
25
+ const ContextQuerySchema = z.object({
26
+ path: z.string().min(1),
27
+ side: z.enum(['new', 'old']),
28
+ from: z.coerce.number().int().positive(),
29
+ to: z.coerce.number().int().positive(),
30
+ })
31
+
32
+ export const CONTEXT_MAX_LINES = 500
33
+
34
+ const SHA_RE = /^[0-9a-f]{40}$/
35
+
36
+ /** The multipart envelope of a 20 MB zip, with room for the field headers. */
37
+ export const MAX_UPLOAD_BYTES = CANVAS_ZIP_MAX_BYTES + 64 * 1024
38
+
39
+ /**
40
+ * The multipart form of an upload, read under the cap first. Parsing is done on the bytes we
41
+ * already hold, so a request that never declares its length cannot buffer without limit.
42
+ */
43
+ async function readUpload(request: Request): Promise<{ file: unknown; force: unknown }> {
44
+ let body: Uint8Array<ArrayBuffer>
45
+ try {
46
+ body = await readCappedBody(request, MAX_UPLOAD_BYTES)
47
+ } catch (err) {
48
+ if (err instanceof BodyTooLargeError) {
49
+ throw new AppError('CANVAS_TOO_LARGE', 'the upload is larger than the canvas size limit', 413)
50
+ }
51
+ throw err
52
+ }
53
+ const contentType = request.headers.get('content-type')
54
+ if (contentType === null || !contentType.startsWith('multipart/form-data')) {
55
+ throw new AppError('BAD_REQUEST', 'send the zip as multipart/form-data', 400)
56
+ }
57
+ const form = await new Response(new Blob([body]), { headers: { 'content-type': contentType } }).formData()
58
+ return { file: form.get('file'), force: form.get('force') }
59
+ }
60
+
61
+ function parseHeadShaQuery(raw: string | undefined): string | undefined {
62
+ if (raw !== undefined && !SHA_RE.test(raw)) {
63
+ throw new AppError('BAD_REQUEST', 'headSha must be a 40-character lowercase hex sha', 400)
64
+ }
65
+ return raw
66
+ }
67
+
68
+ /** The canvas the page is showing: the one for the head, else the stale one it fell back to. */
69
+ async function currentCanvasSha(
70
+ ctx: AppContext,
71
+ loader: ReturnType<typeof createPrLoader>,
72
+ number: number
73
+ ): Promise<string> {
74
+ const pr = await loader.currentPr(number)
75
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
76
+ if (found.status === 'missing') {
77
+ throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
78
+ }
79
+ return found.headSha
80
+ }
81
+
82
+ export function apiRoutes(ctx: AppContext): Hono {
83
+ const api = new Hono()
84
+ const loader = createPrLoader(ctx)
85
+ api.route('/', reviewRoutes(ctx, loader))
86
+ api.route('/', chatRoutes(ctx, loader))
87
+
88
+ // How the page is painted. It lives in the same settings file the chat settings do, but on its
89
+ // own route, because a repository with chat off still has a page to paint.
90
+ const appearanceOf = (settings: { skin: AppearanceResponse['skin']; theme: AppearanceResponse['theme'] }) => ({
91
+ skin: settings.skin,
92
+ theme: settings.theme,
93
+ })
94
+
95
+ api.get('/appearance', async c => {
96
+ const body: AppearanceResponse = appearanceOf(await ctx.settings.read())
97
+ return c.json(body)
98
+ })
99
+
100
+ api.put('/appearance', async c => {
101
+ const expected = 'send a JSON body: { "skin": "github", "theme": "dark" }'
102
+ let raw: unknown
103
+ try {
104
+ raw = await c.req.raw.json()
105
+ } catch {
106
+ throw new AppError('BAD_REQUEST', expected, 400)
107
+ }
108
+ const parsed = AppearanceInputSchema.safeParse(raw)
109
+ if (!parsed.success) {
110
+ throw new AppError('BAD_REQUEST', expected, 400, parsed.error.issues[0]?.message)
111
+ }
112
+ const body: AppearanceResponse = appearanceOf(await ctx.settings.write(parsed.data))
113
+ return c.json(body)
114
+ })
115
+
116
+ api.get('/health', async c => {
117
+ const chatEnabled = ctx.projectConfig.config.chat.enabled
118
+ const [gitCheck, ghStatus, acpx, agents] = await Promise.all([
119
+ ctx.git
120
+ .topLevel()
121
+ .then(() => ({ ok: true }))
122
+ .catch((err: unknown) => ({ ok: false, detail: err instanceof Error ? err.message : String(err) })),
123
+ ctx.gh.authStatus(),
124
+ chatEnabled ? ctx.preflight.get() : Promise.resolve({ installed: false, version: null }),
125
+ chatEnabled ? ctx.agents.list() : Promise.resolve(null),
126
+ ])
127
+ const settings = chatEnabled ? await ctx.chat.effectiveSettings() : null
128
+ const active = settings === null ? null : (agents?.agents.find(a => a.id === settings.agent) ?? null)
129
+ const body: HealthResponse = {
130
+ ok: gitCheck.ok && ghStatus.installed && ghStatus.authenticated,
131
+ version: ctx.version,
132
+ checks: {
133
+ git: gitCheck,
134
+ origin: { ok: true, detail: `${ctx.config.repo.owner}/${ctx.config.repo.name}` },
135
+ gh: ghStatus.installed ? { ok: true } : { ok: false, detail: ghStatus.detail },
136
+ ghAuth: ghStatus.authenticated ? { ok: true } : { ok: false, detail: ghStatus.detail },
137
+ ...(chatEnabled
138
+ ? {
139
+ acpx: acpx.installed
140
+ ? { ok: true, detail: acpx.version ?? '' }
141
+ : { ok: false, detail: 'acpx is not on PATH' },
142
+ agentInstalled: {
143
+ ok: active?.installed === true,
144
+ ...(active?.reason === undefined ? {} : { detail: active.reason }),
145
+ },
146
+ agentAuth: { ok: active?.authenticated === true },
147
+ }
148
+ : {}),
149
+ },
150
+ repo: ctx.config.repo,
151
+ dataDir: ctx.config.dataDir,
152
+ chat: {
153
+ enabled: chatEnabled,
154
+ acpx: acpx.installed,
155
+ ...(settings === null ? {} : { agent: settings.agent, model: settings.model }),
156
+ },
157
+ }
158
+ return c.json(body)
159
+ })
160
+
161
+ 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' })
164
+ return c.json(bundle)
165
+ })
166
+
167
+ 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)
171
+ if (derived === null) {
172
+ throw new AppError(
173
+ 'NOT_FOUND',
174
+ 'diffs for this head are not available locally',
175
+ 404,
176
+ 'fetch the PR head and reload'
177
+ )
178
+ }
179
+ const body: PatchesResponse = { headSha, patches: derived.patches }
180
+ return c.json(body)
181
+ })
182
+
183
+ api.get('/prs/:n/comments', async c => {
184
+ const number = parsePrNumber(c.req.param('n'))
185
+ const { comments } = await loader.refreshComments(number)
186
+ return c.json(comments)
187
+ })
188
+
189
+ api.get('/prs/:n/context', async c => {
190
+ const number = parsePrNumber(c.req.param('n'))
191
+ const parsed = ContextQuerySchema.safeParse(c.req.query())
192
+ if (!parsed.success) {
193
+ throw new AppError('BAD_REQUEST', 'context needs path, side (new|old), from, to', 400)
194
+ }
195
+ const q = parsed.data
196
+ if (q.to < q.from || q.to - q.from + 1 > CONTEXT_MAX_LINES) {
197
+ throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
198
+ }
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)
201
+ if (lines === null) {
202
+ throw new AppError('NOT_FOUND', `${q.path} is not materialized for this head`, 404)
203
+ }
204
+ const body: ContextResponse = { path: q.path, side: q.side, from: q.from, to: q.from + lines.length - 1, lines }
205
+ return c.json(body)
206
+ })
207
+
208
+ api.get('/prs/:n/export', async c => {
209
+ const number = parsePrNumber(c.req.param('n'))
210
+ const requested = parseHeadShaQuery(c.req.query('headSha'))
211
+ const headSha = requested ?? (await currentCanvasSha(ctx, loader, number))
212
+ const zip = await buildCanvasZipFor(ctx, headSha, number)
213
+ // The name is built from the repo slug, the PR number, and the sha, so it holds no user text.
214
+ return new Response(new Blob([zip.bytes]), {
215
+ headers: {
216
+ 'content-type': 'application/zip',
217
+ 'content-disposition': `attachment; filename="${zip.name}"`,
218
+ },
219
+ })
220
+ })
221
+
222
+ api.post('/prs/:n/import', async c => {
223
+ const number = parsePrNumber(c.req.param('n'))
224
+ const { file, force } = await readUpload(c.req.raw)
225
+ if (!(file instanceof File)) {
226
+ throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
227
+ }
228
+ 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)
230
+ }
231
+ const result = await importCanvas(ctx, {
232
+ bytes: new Uint8Array(await file.arrayBuffer()),
233
+ prNumber: number,
234
+ currentHeadSha: (await loader.currentPr(number)).headSha,
235
+ force: force === '1',
236
+ })
237
+ return c.json(result)
238
+ })
239
+
240
+ api.post('/prs/:n/shared-canvas/fetch', async c => {
241
+ const number = parsePrNumber(c.req.param('n'))
242
+ // Looking again means looking at the pull request as it is now, not at the cached copy.
243
+ const { pr, comments } = await loader.load(number, { refresh: true })
244
+ const discovery = await runDiscovery(ctx, pr, comments, { refresh: true })
245
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
246
+ const body: SharedCanvasFetchResponse = {
247
+ imported: discovery.imported,
248
+ status: found.status,
249
+ sharedCanvas: discovery.sharedCanvas,
250
+ warnings: discovery.warnings,
251
+ }
252
+ return c.json(body)
253
+ })
254
+
255
+ return api
256
+ }
@@ -0,0 +1,221 @@
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.
4
+ import { Hono, type MiddlewareHandler } from 'hono'
5
+ import { ChatBusyError } from '../../chat/chat-manager.js'
6
+ import { ChatContextError } from '../../chat/context.js'
7
+ import { isThreadNameFor } from '../../chat/threads.js'
8
+ import type { ChatEvent, ChatHistoryResponse } from '../../contract/chat.js'
9
+ import { ChatSendSchema } from '../../contract/chat.js'
10
+ import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
11
+ import type { SettingsResponse } from '../../contract/settings.js'
12
+ import { isChatAgent, SettingsInputSchema } from '../../contract/settings.js'
13
+ import type { PrLoader } from '../bundle.js'
14
+ import type { AppContext } from '../context.js'
15
+ import { AppError } from '../errors.js'
16
+ import { SSE_HEADERS, sseStream } from '../sse.js'
17
+ import { parsePrNumber } from './api.js'
18
+
19
+ async function readJsonBody<T>(
20
+ request: Request,
21
+ schema: {
22
+ safeParse: (raw: unknown) => { success: boolean; data?: T; error?: { issues: Array<{ message: string }> } }
23
+ },
24
+ expected: string
25
+ ): Promise<T> {
26
+ let raw: unknown
27
+ try {
28
+ raw = await request.json()
29
+ } catch {
30
+ throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400)
31
+ }
32
+ const parsed = schema.safeParse(raw)
33
+ if (!parsed.success || parsed.data === undefined) {
34
+ throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400, parsed.error?.issues[0]?.message)
35
+ }
36
+ return parsed.data
37
+ }
38
+
39
+ function settingsResponse(ctx: AppContext, settings: SettingsResponse['settings']): SettingsResponse {
40
+ const project = ctx.projectConfig.config
41
+ return {
42
+ settings,
43
+ overrides: ctx.config.chatOverrides,
44
+ file: ctx.settings.file,
45
+ project: {
46
+ file: ctx.projectConfig.source,
47
+ chatEnabled: project.chat.enabled,
48
+ rulebook: project.rulebook ?? null,
49
+ maxRepairRounds: project.generation.maxRepairRounds,
50
+ inlineDiffMaxLines: project.generation.inlineDiffMaxLines,
51
+ smallPrHunks: project.generation.smallPrHunks,
52
+ layers: project.layers.length,
53
+ highRisk: project.highRisk.length,
54
+ },
55
+ }
56
+ }
57
+
58
+ /** The canvas the chat talks about: the one written for the pull request's current head. */
59
+ async function artifactForChat(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
60
+ if (ctx.fixtureArtifact !== null) {
61
+ return { ...ctx.fixtureArtifact, pr }
62
+ }
63
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
64
+ const artifact = found.status === 'ready' ? await ctx.canvases.readArtifact(found.headSha) : null
65
+ if (artifact === null) {
66
+ throw new AppError(
67
+ 'CANVAS_NOT_FOUND',
68
+ 'there is no canvas for this commit, so the chat has nothing to talk about',
69
+ 404,
70
+ 'generate a canvas for the current head first'
71
+ )
72
+ }
73
+ return artifact
74
+ }
75
+
76
+ /** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
77
+ export const CHAT_ROUTE_PATTERNS = ['/settings', '/settings/*', '/prs/:n/chat', '/prs/:n/chat/*'] as const
78
+
79
+ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
80
+ const api = new Hono()
81
+
82
+ // With chat off, none of these routes exist. The patterns are listed rather than `*`: this
83
+ // app is mounted at the API root, so a blanket middleware would answer for every route.
84
+ const requireChat: MiddlewareHandler = async (_c, next) => {
85
+ if (!ctx.projectConfig.config.chat.enabled) {
86
+ throw new AppError(
87
+ 'NOT_FOUND',
88
+ 'chat is turned off for this repository',
89
+ 404,
90
+ 'set chat.enabled in pr-review.config.yml'
91
+ )
92
+ }
93
+ await next()
94
+ }
95
+ for (const pattern of CHAT_ROUTE_PATTERNS) {
96
+ api.use(pattern, requireChat)
97
+ }
98
+
99
+ api.get('/settings', async c => {
100
+ await ctx.settings.ensureFile()
101
+ return c.json(settingsResponse(ctx, await ctx.settings.read()))
102
+ })
103
+
104
+ api.put('/settings', async c => {
105
+ const input = await readJsonBody(c.req.raw, SettingsInputSchema, '{ "agent": "claude", "model": null }')
106
+ return c.json(settingsResponse(ctx, await ctx.settings.write(input)))
107
+ })
108
+
109
+ api.get('/settings/agents', async c => c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' })))
110
+
111
+ api.post('/settings/agents/:id/probe', async c => {
112
+ const id = c.req.param('id')
113
+ if (!isChatAgent(id)) {
114
+ throw new AppError('BAD_REQUEST', `not an agent this tool knows: ${id}`, 400)
115
+ }
116
+ return c.json(await ctx.agents.probe(id, { refresh: c.req.query('refresh') === '1' }))
117
+ })
118
+
119
+ api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parsePrNumber(c.req.param('n')))))
120
+
121
+ api.post('/prs/:n/chat/threads', async c => {
122
+ const number = parsePrNumber(c.req.param('n'))
123
+ const thread = await ctx.chat.createThread(number)
124
+ return c.json({ thread, ...(await ctx.chat.threads(number)) }, 201)
125
+ })
126
+
127
+ api.get('/prs/:n/chat/threads/:name/history', async c => {
128
+ const number = parsePrNumber(c.req.param('n'))
129
+ const name = c.req.param('name')
130
+ if (!isThreadNameFor(name, number)) {
131
+ throw new AppError('NOT_FOUND', `no chat thread named ${name} on this pull request`, 404)
132
+ }
133
+ const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(number, name) }
134
+ return c.json(body)
135
+ })
136
+
137
+ api.post('/prs/:n/chat/cancel', async c => {
138
+ const number = parsePrNumber(c.req.param('n'))
139
+ return c.json({ cancelled: await ctx.chat.cancel(number) })
140
+ })
141
+
142
+ api.post('/prs/:n/chat', async c => {
143
+ const number = parsePrNumber(c.req.param('n'))
144
+ const input = await readJsonBody(c.req.raw, ChatSendSchema, '{ "message": "…", "context": { "kind": "pr" } }')
145
+ const pr = await loader.currentPr(number)
146
+ const artifact = await artifactForChat(ctx, number, pr)
147
+ const derived = await ctx.derived.read(pr.headSha)
148
+ if (derived === null) {
149
+ throw new AppError(
150
+ 'NOT_FOUND',
151
+ 'the diff of this head is not available locally, so the chat cannot quote it',
152
+ 404,
153
+ 'fetch the PR head and reload'
154
+ )
155
+ }
156
+ const events = ctx.chat.send(
157
+ {
158
+ prNumber: number,
159
+ headSha: pr.headSha,
160
+ artifact,
161
+ files: derived.files,
162
+ patches: derived.patches,
163
+ derivedDir: ctx.derived.derivedDir(pr.headSha),
164
+ readLines: (side, filePath, from, to) => ctx.derived.readLines(pr.headSha, side, filePath, from, to),
165
+ },
166
+ { message: input.message, context: input.context, thread: input.thread }
167
+ )
168
+ // The first event comes back before the response starts, so a refusal is a JSON envelope
169
+ // with a status rather than an error frame inside a 200.
170
+ const iterator = events[Symbol.asyncIterator]()
171
+ let first: IteratorResult<ChatEvent>
172
+ try {
173
+ first = await iterator.next()
174
+ } catch (err) {
175
+ throw toChatError(err)
176
+ }
177
+ // A browser that goes away stops the agent; the turn is nobody's answer any more.
178
+ return new Response(
179
+ sseStream(replayFrom(first, iterator), undefined, () => {
180
+ void ctx.chat.cancel(number)
181
+ }),
182
+ { headers: SSE_HEADERS }
183
+ )
184
+ })
185
+
186
+ return api
187
+ }
188
+
189
+ /**
190
+ * The turn as a stream again, after its first event was read to see whether the route can answer
191
+ * with a stream at all. A turn that ended on that first read is an empty stream.
192
+ */
193
+ export function replayFrom(first: IteratorResult<ChatEvent>, rest: AsyncIterator<ChatEvent>): AsyncIterable<ChatEvent> {
194
+ let sent = first.done === true
195
+ return {
196
+ [Symbol.asyncIterator]: () => ({
197
+ next: () => {
198
+ if (sent) {
199
+ return rest.next()
200
+ }
201
+ sent = true
202
+ return Promise.resolve({ done: false, value: first.value })
203
+ },
204
+ return: async () => {
205
+ await rest.return?.(undefined)
206
+ return { done: true, value: undefined }
207
+ },
208
+ }),
209
+ }
210
+ }
211
+
212
+ /** The status a refused turn answers with: busy, a context that does not resolve, or a failure. */
213
+ export function toChatError(err: unknown): AppError {
214
+ if (err instanceof ChatBusyError) {
215
+ return new AppError('CHAT_BUSY', err.message, 409, 'stop the running answer, or wait for it to finish')
216
+ }
217
+ if (err instanceof ChatContextError) {
218
+ return new AppError('BAD_REQUEST', err.message, 400)
219
+ }
220
+ return err instanceof AppError ? err : new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
221
+ }