@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|