@vintasoftware/pr-review-canvas 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- 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 +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- 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 +4 -19
- 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 +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -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/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- 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
3
|
import type { ErrorEnvelope, HomeData } from '../contract/api.js'
|
|
4
|
+
import { keyLabel, keyToString, type LocalKey, type ReviewKey } from '../contract/review-key.js'
|
|
4
5
|
import type { Appearance } from '../contract/settings.js'
|
|
6
|
+
import { type Host, publicHost } from '../host/host.js'
|
|
5
7
|
|
|
6
8
|
type Html = HtmlEscapedString | Promise<HtmlEscapedString>
|
|
7
9
|
|
|
@@ -68,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: { prNumber: ReviewKey; owner: string; repo: string; version: string; host: Host },
|
|
72
74
|
nonce: string,
|
|
73
75
|
appearance: Appearance
|
|
74
76
|
): Html {
|
|
77
|
+
const bootstrap = { ...page, host: publicHost(page.host) }
|
|
78
|
+
const key = keyToString(bootstrap.prNumber)
|
|
79
|
+
const what =
|
|
80
|
+
typeof bootstrap.prNumber === 'number' ? `${page.host.nounShort} #${key}` : keyLabel(bootstrap.prNumber)
|
|
75
81
|
return pageShell({
|
|
76
|
-
title:
|
|
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,
|
|
@@ -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,28 +56,72 @@ 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))
|
|
109
|
+
}
|
|
110
|
+
|
|
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 }
|
|
78
125
|
}
|
|
79
126
|
|
|
80
127
|
/** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
|
|
@@ -122,56 +169,47 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
122
169
|
return c.json(await ctx.agents.probe(id, { refresh: c.req.query('refresh') === '1' }))
|
|
123
170
|
})
|
|
124
171
|
|
|
125
|
-
api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(
|
|
172
|
+
api.get('/prs/:n/chat/threads', async c => c.json(await ctx.chat.threads(parseTargetKey(c.req.param('n')))))
|
|
126
173
|
|
|
127
174
|
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(
|
|
175
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
176
|
+
const thread = await ctx.chat.createThread(key)
|
|
177
|
+
return c.json({ thread, ...(await ctx.chat.threads(key)) }, 201)
|
|
131
178
|
})
|
|
132
179
|
|
|
133
180
|
api.get('/prs/:n/chat/threads/:name/history', async c => {
|
|
134
|
-
const
|
|
181
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
135
182
|
const name = c.req.param('name')
|
|
136
|
-
if (!isThreadNameFor(name,
|
|
137
|
-
throw new AppError('NOT_FOUND', `no chat thread named ${name} on this
|
|
183
|
+
if (!isThreadNameFor(name, key)) {
|
|
184
|
+
throw new AppError('NOT_FOUND', `no chat thread named ${name} on this review`, 404)
|
|
138
185
|
}
|
|
139
|
-
const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(
|
|
186
|
+
const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(key, name) }
|
|
140
187
|
return c.json(body)
|
|
141
188
|
})
|
|
142
189
|
|
|
143
190
|
api.post('/prs/:n/chat/cancel', async c => {
|
|
144
|
-
const
|
|
145
|
-
return c.json({ cancelled: await ctx.chat.cancel(
|
|
191
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
192
|
+
return c.json({ cancelled: await ctx.chat.cancel(key) })
|
|
146
193
|
})
|
|
147
194
|
|
|
148
195
|
api.post('/prs/:n/chat', async c => {
|
|
149
|
-
const
|
|
196
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
150
197
|
const input = await readJsonBody(
|
|
151
198
|
c.req.raw,
|
|
152
199
|
ChatSendSchema,
|
|
153
200
|
'{ "message": "…", "context": { "kind": "pr" } }'
|
|
154
201
|
)
|
|
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
|
-
}
|
|
202
|
+
const pr = await loader.currentTarget(key)
|
|
203
|
+
const { artifact, headSha, files, patches } = await subjectForChat(ctx, key, pr)
|
|
166
204
|
const events = ctx.chat.send(
|
|
167
205
|
{
|
|
168
|
-
|
|
169
|
-
headSha
|
|
206
|
+
key,
|
|
207
|
+
headSha,
|
|
170
208
|
artifact,
|
|
171
|
-
files
|
|
172
|
-
patches
|
|
173
|
-
derivedDir: ctx.derived.derivedDir(
|
|
174
|
-
readLines: (side, filePath, from, to) => ctx.derived.readLines(
|
|
209
|
+
files,
|
|
210
|
+
patches,
|
|
211
|
+
derivedDir: ctx.derived.derivedDir(headSha),
|
|
212
|
+
readLines: (side, filePath, from, to) => ctx.derived.readLines(headSha, side, filePath, from, to),
|
|
175
213
|
},
|
|
176
214
|
{ message: input.message, context: input.context, thread: input.thread }
|
|
177
215
|
)
|
|
@@ -190,7 +228,7 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
190
228
|
replayFrom(first, iterator),
|
|
191
229
|
undefined,
|
|
192
230
|
() => {
|
|
193
|
-
void ctx.chat.cancel(
|
|
231
|
+
void ctx.chat.cancel(key)
|
|
194
232
|
},
|
|
195
233
|
err => logRequestError(ctx.log, c.req, err)
|
|
196
234
|
),
|
|
@@ -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,15 +52,24 @@ 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
|
}
|
|
56
64
|
return c.html(
|
|
57
65
|
reviewPage(
|
|
58
|
-
{
|
|
66
|
+
{
|
|
67
|
+
prNumber,
|
|
68
|
+
owner: ctx.config.repo.owner,
|
|
69
|
+
repo: ctx.config.repo.name,
|
|
70
|
+
version: ctx.version,
|
|
71
|
+
host: ctx.config.host,
|
|
72
|
+
},
|
|
59
73
|
c.get('cspNonce'),
|
|
60
74
|
await appearanceFor(ctx, appearanceQuery(c))
|
|
61
75
|
)
|