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