@vintasoftware/pr-review-canvas 0.1.0 → 0.3.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 +30 -28
- package/docs/reference.md +142 -98
- package/package.json +12 -3
- package/pr-review.config.example.yml +12 -31
- package/prompts/generation-format.md +4 -4
- package/prompts/layering-guidance.md +18 -0
- package/skills/pr-review-canvas/SKILL.md +8 -1
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/export.ts +11 -2
- package/src/canvas/name.ts +13 -13
- package/src/canvas/zip.ts +4 -1
- package/src/chat/chat-manager.ts +16 -3
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/cli.ts +16 -13
- package/src/commands.ts +41 -11
- package/src/config.ts +15 -3
- package/src/contract/comments.ts +3 -1
- package/src/contract/generation-context.ts +3 -1
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/state.ts +3 -1
- package/src/git/diff-collector.ts +2 -1
- package/src/git/git.ts +9 -4
- package/src/github/attachments.ts +3 -3
- package/src/github/capabilities.ts +5 -1
- package/src/github/comments.ts +6 -3
- package/src/github/gh.ts +18 -3
- package/src/github/post-comment.ts +4 -1
- package/src/github/pr.ts +4 -1
- package/src/github/threads.ts +5 -1
- package/src/project-config.ts +14 -54
- package/src/prompt-files.ts +4 -3
- package/src/review/doctor.ts +38 -10
- package/src/review/install-skill.ts +24 -28
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +24 -8
- package/src/review/prompt.ts +17 -11
- package/src/review/publish.ts +15 -3
- package/src/review/skill-content.ts +20 -0
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate.ts +65 -13
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +17 -4
- package/src/server/context.ts +14 -2
- package/src/server/errors.ts +26 -2
- package/src/server/html.ts +2 -2
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +29 -5
- package/src/server/routes/chat-routes.ts +30 -9
- package/src/server/routes/pages.ts +3 -1
- package/src/server/routes/review-routes.ts +19 -4
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +20 -6
- package/src/store/derived-store.ts +5 -1
- package/src/store/pr-store.ts +2 -1
- package/src/store/state-store.ts +2 -1
- package/static/brand.svg +19 -0
- package/static/js/api.js +15 -3
- package/static/js/app.js +8 -2
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +38 -15
- package/static/js/composer.js +6 -3
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +6 -1
- package/static/js/dom.js +5 -7
- package/static/js/empty-state.js +3 -1
- package/static/js/header.js +1 -1
- package/static/js/interactions.js +55 -15
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +23 -11
- package/static/js/links.js +8 -2
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +3 -2
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/settings.js +1 -1
- package/static/js/signoff.js +6 -2
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +4 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/responsive.css +1 -15
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +93 -100
- package/static/styles.css +13 -12
- package/prompts/layers-default.md +0 -13
package/src/review/trim-caps.ts
CHANGED
|
@@ -77,7 +77,10 @@ interface TitledOutput {
|
|
|
77
77
|
* Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
|
|
78
78
|
* call, not the tool's.
|
|
79
79
|
*/
|
|
80
|
-
export function applyTitleTrims(
|
|
80
|
+
export function applyTitleTrims(
|
|
81
|
+
output: unknown,
|
|
82
|
+
caps: { layerTitle: number; pointTitle: number }
|
|
83
|
+
): TitleTrim[] {
|
|
81
84
|
const trims: TitleTrim[] = []
|
|
82
85
|
const trim = (where: string, holder: { title?: string }, cap: number): void => {
|
|
83
86
|
if (typeof holder.title !== 'string') {
|
|
@@ -89,11 +92,12 @@ export function applyTitleTrims(output: unknown, caps: { layerTitle: number; poi
|
|
|
89
92
|
holder.title = trimmed
|
|
90
93
|
} else if (visibleLength(holder.title) > cap) {
|
|
91
94
|
const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
|
|
92
|
-
const reason =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
const reason =
|
|
96
|
+
match === null
|
|
97
|
+
? 'no explainer separator (:, —, – or -) to drop'
|
|
98
|
+
: match.index === 0
|
|
99
|
+
? 'dropping the explainer would leave an empty title'
|
|
100
|
+
: 'the title before the explainer still exceeds the cap'
|
|
97
101
|
trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
|
|
98
102
|
}
|
|
99
103
|
}
|
package/src/review/validate.ts
CHANGED
|
@@ -152,7 +152,11 @@ function checkLengths(output: ModelOutput, caps: TextCaps, report: Report): void
|
|
|
152
152
|
f.folds?.forEach((fold, k) => {
|
|
153
153
|
const where = `${at}.files.${j}.folds.${k}.title`
|
|
154
154
|
if (fold.title.length > caps.pointTitle) {
|
|
155
|
-
report.add(
|
|
155
|
+
report.add(
|
|
156
|
+
'TEXT_TOO_LONG',
|
|
157
|
+
where,
|
|
158
|
+
`${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`
|
|
159
|
+
)
|
|
156
160
|
}
|
|
157
161
|
})
|
|
158
162
|
f.annotations.forEach((a, k) => {
|
|
@@ -218,7 +222,11 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
|
|
|
218
222
|
continue
|
|
219
223
|
}
|
|
220
224
|
if (hit.file.path !== file.path) {
|
|
221
|
-
report.add(
|
|
225
|
+
report.add(
|
|
226
|
+
'HUNK_UNKNOWN',
|
|
227
|
+
where,
|
|
228
|
+
`${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`
|
|
229
|
+
)
|
|
222
230
|
continue
|
|
223
231
|
}
|
|
224
232
|
const first = owner.get(id)
|
|
@@ -236,7 +244,11 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
|
|
|
236
244
|
}
|
|
237
245
|
for (const { file, hunk } of index.byHunkId.values()) {
|
|
238
246
|
if (!owner.has(hunk.id)) {
|
|
239
|
-
report.add(
|
|
247
|
+
report.add(
|
|
248
|
+
'HUNK_UNASSIGNED',
|
|
249
|
+
`hunk:${hunk.id}`,
|
|
250
|
+
`${hunk.id} in ${file.path} (${hunk.header}) is in no layer`
|
|
251
|
+
)
|
|
240
252
|
}
|
|
241
253
|
}
|
|
242
254
|
return owner
|
|
@@ -248,7 +260,11 @@ function checkLayers(output: ModelOutput, report: Report): void {
|
|
|
248
260
|
const where = `layer:${layer.key}`
|
|
249
261
|
const seen = keys.get(layer.key)
|
|
250
262
|
if (seen !== undefined) {
|
|
251
|
-
report.add(
|
|
263
|
+
report.add(
|
|
264
|
+
'LAYER_KEY_DUPLICATE',
|
|
265
|
+
where,
|
|
266
|
+
`layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`
|
|
267
|
+
)
|
|
252
268
|
} else {
|
|
253
269
|
keys.set(layer.key, i)
|
|
254
270
|
}
|
|
@@ -268,7 +284,11 @@ function checkLayers(output: ModelOutput, report: Report): void {
|
|
|
268
284
|
}
|
|
269
285
|
const last = output.layers[output.layers.length - 1]
|
|
270
286
|
if (first !== undefined && first !== last) {
|
|
271
|
-
report.add(
|
|
287
|
+
report.add(
|
|
288
|
+
'OTHER_NOT_LAST',
|
|
289
|
+
`layer:${first.key}`,
|
|
290
|
+
`layer ${first.key} (kind other) must be the last layer`
|
|
291
|
+
)
|
|
272
292
|
}
|
|
273
293
|
}
|
|
274
294
|
|
|
@@ -308,7 +328,11 @@ function checkTests(
|
|
|
308
328
|
continue
|
|
309
329
|
}
|
|
310
330
|
if (t.testPath === undefined) {
|
|
311
|
-
report.add(
|
|
331
|
+
report.add(
|
|
332
|
+
'TEST_PATH_UNKNOWN',
|
|
333
|
+
where,
|
|
334
|
+
`${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`
|
|
335
|
+
)
|
|
312
336
|
} else if (!(index.byPath.has(t.testPath) || headPaths.has(t.testPath))) {
|
|
313
337
|
report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: ${t.testPath} is not in the PR head`)
|
|
314
338
|
}
|
|
@@ -339,12 +363,20 @@ function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], repor
|
|
|
339
363
|
}
|
|
340
364
|
const where = `layer:${other.key}`
|
|
341
365
|
for (const tag of other.risk ?? []) {
|
|
342
|
-
report.add(
|
|
366
|
+
report.add(
|
|
367
|
+
'RISK_IN_OTHER',
|
|
368
|
+
where,
|
|
369
|
+
`other: carries the risk tag "${tag.label}"; move the hunks to a real layer`
|
|
370
|
+
)
|
|
343
371
|
}
|
|
344
372
|
for (const file of other.files) {
|
|
345
373
|
const rule = highRisk.find(r => matchesGlob(r.pattern, file.path))
|
|
346
374
|
if (rule !== undefined) {
|
|
347
|
-
report.add(
|
|
375
|
+
report.add(
|
|
376
|
+
'RISK_IN_OTHER',
|
|
377
|
+
where,
|
|
378
|
+
`other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`
|
|
379
|
+
)
|
|
348
380
|
}
|
|
349
381
|
}
|
|
350
382
|
}
|
|
@@ -368,9 +400,17 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
|
|
|
368
400
|
const start = hunkAt(entry, a.side, a.startLine)
|
|
369
401
|
const end = hunkAt(entry, a.side, a.endLine)
|
|
370
402
|
if (start === null || end === null || start.id !== end.id) {
|
|
371
|
-
report.add(
|
|
403
|
+
report.add(
|
|
404
|
+
'ANNOTATION_OUTSIDE_HUNK',
|
|
405
|
+
where,
|
|
406
|
+
`${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
|
|
407
|
+
)
|
|
372
408
|
} else if (!file.hunks.includes(start.id)) {
|
|
373
|
-
report.add(
|
|
409
|
+
report.add(
|
|
410
|
+
'ANNOTATION_OUTSIDE_HUNK',
|
|
411
|
+
where,
|
|
412
|
+
`${label} is in ${start.id}, which this layer does not list`
|
|
413
|
+
)
|
|
374
414
|
}
|
|
375
415
|
})
|
|
376
416
|
}
|
|
@@ -378,7 +418,10 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
|
|
|
378
418
|
}
|
|
379
419
|
|
|
380
420
|
function checkPoints(output: ModelOutput, index: Index, limits: Limits, report: Report): void {
|
|
381
|
-
const missingTests = output.layers.reduce(
|
|
421
|
+
const missingTests = output.layers.reduce(
|
|
422
|
+
(n, l) => n + l.tests.filter(t => t.status === 'missing').length,
|
|
423
|
+
0
|
|
424
|
+
)
|
|
382
425
|
const total = output.points.length + missingTests
|
|
383
426
|
if (total > limits.maxPoints) {
|
|
384
427
|
report.add(
|
|
@@ -464,7 +507,12 @@ function checkLinks(output: ModelOutput, files: readonly FileEntry[], report: Re
|
|
|
464
507
|
* The sidecar map of a layer's diagram: each key names a node of the mermaid source, each value
|
|
465
508
|
* is a canvas link, and a diagram carries at most `maxDiagramLinks` of them.
|
|
466
509
|
*/
|
|
467
|
-
function checkDiagramLinks(
|
|
510
|
+
function checkDiagramLinks(
|
|
511
|
+
output: ModelOutput,
|
|
512
|
+
files: readonly FileEntry[],
|
|
513
|
+
limits: Limits,
|
|
514
|
+
report: Report
|
|
515
|
+
): void {
|
|
468
516
|
const targets: LinkTargets = { layers: output.layers, files }
|
|
469
517
|
for (const layer of output.layers) {
|
|
470
518
|
if (layer.diagram === undefined) {
|
|
@@ -474,7 +522,11 @@ function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], lim
|
|
|
474
522
|
const label = `${layerLabel(layer)} diagram`
|
|
475
523
|
const entries = Object.entries(layer.diagram.links)
|
|
476
524
|
if (entries.length > limits.maxDiagramLinks) {
|
|
477
|
-
report.add(
|
|
525
|
+
report.add(
|
|
526
|
+
'DIAGRAM_LIMIT',
|
|
527
|
+
where,
|
|
528
|
+
`${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`
|
|
529
|
+
)
|
|
478
530
|
}
|
|
479
531
|
const kind = diagramKind(layer.diagram.mermaid)
|
|
480
532
|
const nodes = diagramNodeIds(layer.diagram.mermaid)
|
package/src/server/app.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Context, Hono } from 'hono'
|
|
2
2
|
import type { AppContext } from './context.js'
|
|
3
3
|
import type { AppEnv } from './env.js'
|
|
4
|
-
import { AppError, toAppError } from './errors.js'
|
|
4
|
+
import { AppError, logRequestError, toAppError } from './errors.js'
|
|
5
5
|
import { errorPage } from './html.js'
|
|
6
6
|
import { apiRoutes } from './routes/api.js'
|
|
7
7
|
import { appearanceFor, appearanceQuery, pageRoutes } from './routes/pages.js'
|
|
@@ -29,14 +29,22 @@ export function createApp(ctx: AppContext): Hono<AppEnv> {
|
|
|
29
29
|
const nonce = c.get('cspNonce') ?? createNonce()
|
|
30
30
|
const res = wantsJson(c.req.path)
|
|
31
31
|
? c.json(envelope, err.status)
|
|
32
|
-
: await c.html(
|
|
32
|
+
: await c.html(
|
|
33
|
+
errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))),
|
|
34
|
+
err.status
|
|
35
|
+
)
|
|
33
36
|
applyResponseHeaders(res, c.req.path, nonce)
|
|
34
37
|
return res
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
app.onError((err, c) =>
|
|
40
|
+
app.onError((err, c) => {
|
|
41
|
+
logRequestError(ctx.log, c.req, err)
|
|
42
|
+
return errorResponse(c, toAppError(err))
|
|
43
|
+
})
|
|
38
44
|
|
|
39
|
-
app.notFound(c =>
|
|
45
|
+
app.notFound(c =>
|
|
46
|
+
errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404))
|
|
47
|
+
)
|
|
40
48
|
|
|
41
49
|
app.route('/api', apiRoutes(ctx))
|
|
42
50
|
app.route('/', staticRoutes(ctx))
|
package/src/server/bundle.ts
CHANGED
|
@@ -45,7 +45,11 @@ export async function runDiscovery(
|
|
|
45
45
|
checkedAt: ctx.now().toISOString(),
|
|
46
46
|
sharedCanvas: outcome.sharedCanvas,
|
|
47
47
|
})
|
|
48
|
-
return {
|
|
48
|
+
return {
|
|
49
|
+
sharedCanvas: outcome.sharedCanvas,
|
|
50
|
+
imported: outcome.imported !== null,
|
|
51
|
+
warnings: outcome.warnings,
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -55,7 +59,9 @@ export async function runDiscovery(
|
|
|
55
59
|
export function createPrLoader(ctx: AppContext) {
|
|
56
60
|
const refreshed = new Set<number>()
|
|
57
61
|
|
|
58
|
-
async function refreshPr(
|
|
62
|
+
async function refreshPr(
|
|
63
|
+
number: number
|
|
64
|
+
): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
|
|
59
65
|
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
|
|
60
66
|
const shas = await fetchPrRefs(ctx.git, meta)
|
|
61
67
|
const pr = toPr(meta, ctx.config.repo, shas)
|
|
@@ -225,11 +231,18 @@ export async function resolveBundle(
|
|
|
225
231
|
const shared = sharedCanvas === null ? {} : { sharedCanvas }
|
|
226
232
|
|
|
227
233
|
if (found.status === 'missing') {
|
|
228
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
...shared,
|
|
237
|
+
status: 'missing',
|
|
238
|
+
skillCommand: buildSkillCommand(number, { force: false }),
|
|
239
|
+
}
|
|
229
240
|
}
|
|
230
241
|
const loaded = await loadCanvas(ctx, found)
|
|
231
242
|
if (loaded === null) {
|
|
232
|
-
allWarnings.push(
|
|
243
|
+
allWarnings.push(
|
|
244
|
+
`the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`
|
|
245
|
+
)
|
|
233
246
|
return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: true }) }
|
|
234
247
|
}
|
|
235
248
|
// A canvas exists for this PR, so regenerating always needs --force.
|
package/src/server/context.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface VendorRoots {
|
|
|
36
36
|
* `createAppContext`; tests assemble their own with fakes and a temp data dir.
|
|
37
37
|
*/
|
|
38
38
|
export interface AppContext {
|
|
39
|
+
log: (line: string) => void
|
|
39
40
|
config: RuntimeConfig
|
|
40
41
|
projectConfig: LoadedProjectConfig
|
|
41
42
|
git: Git
|
|
@@ -72,7 +73,11 @@ export function resolveVendorRoots(): VendorRoots {
|
|
|
72
73
|
diff: path.dirname(fileURLToPath(import.meta.resolve('diff'))),
|
|
73
74
|
marked: fileURLToPath(import.meta.resolve('marked')),
|
|
74
75
|
dompurify: fileURLToPath(import.meta.resolve('dompurify')),
|
|
75
|
-
hljs: path.join(
|
|
76
|
+
hljs: path.join(
|
|
77
|
+
path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')),
|
|
78
|
+
'es',
|
|
79
|
+
'highlight.min.js'
|
|
80
|
+
),
|
|
76
81
|
mermaid: path.dirname(fileURLToPath(import.meta.resolve('mermaid'))),
|
|
77
82
|
}
|
|
78
83
|
}
|
|
@@ -158,13 +163,20 @@ export function createAppContext(opts: CreateAppContextOptions): AppContext {
|
|
|
158
163
|
const stores = createStores(opts.config.dataDir, opts.config, git, now)
|
|
159
164
|
return {
|
|
160
165
|
config: opts.config,
|
|
166
|
+
log: line => process.stderr.write(`${line}\n`),
|
|
161
167
|
projectConfig: opts.projectConfig,
|
|
162
168
|
git,
|
|
163
169
|
gh,
|
|
164
170
|
capabilities: createCapabilityProbe(gh, opts.config.repo, now),
|
|
165
171
|
fetch: opts.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
166
172
|
...stores,
|
|
167
|
-
...createChatSet(
|
|
173
|
+
...createChatSet(
|
|
174
|
+
opts.config,
|
|
175
|
+
opts.runner ?? createAgentRunner(),
|
|
176
|
+
stores,
|
|
177
|
+
now,
|
|
178
|
+
opts.projectConfig.config.prompts
|
|
179
|
+
),
|
|
168
180
|
now,
|
|
169
181
|
version: readPackageVersion(),
|
|
170
182
|
staticDir: STATIC_DIR,
|
package/src/server/errors.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspect } from 'node:util'
|
|
1
2
|
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
|
2
3
|
import { ConfigError } from '../config.js'
|
|
3
4
|
import type { ErrorCode, ErrorEnvelope } from '../contract/api.js'
|
|
@@ -12,7 +13,13 @@ export class AppError extends Error {
|
|
|
12
13
|
/** What a rejected file got wrong, one line each. Never the file's content. */
|
|
13
14
|
readonly issues: string[] | undefined
|
|
14
15
|
|
|
15
|
-
constructor(
|
|
16
|
+
constructor(
|
|
17
|
+
code: ErrorCode,
|
|
18
|
+
message: string,
|
|
19
|
+
status: ContentfulStatusCode,
|
|
20
|
+
hint?: string,
|
|
21
|
+
issues?: string[]
|
|
22
|
+
) {
|
|
16
23
|
super(message)
|
|
17
24
|
this.name = 'AppError'
|
|
18
25
|
this.code = code
|
|
@@ -39,7 +46,12 @@ export function toAppError(err: unknown): AppError {
|
|
|
39
46
|
return err
|
|
40
47
|
}
|
|
41
48
|
if (err instanceof PrNotFoundError) {
|
|
42
|
-
return new AppError(
|
|
49
|
+
return new AppError(
|
|
50
|
+
'PR_NOT_FOUND',
|
|
51
|
+
err.message,
|
|
52
|
+
404,
|
|
53
|
+
'check the number and that origin is the right repository'
|
|
54
|
+
)
|
|
43
55
|
}
|
|
44
56
|
if (err instanceof GitHubApiError) {
|
|
45
57
|
if (err.missingBinary) {
|
|
@@ -63,3 +75,15 @@ export function toAppError(err: unknown): AppError {
|
|
|
63
75
|
}
|
|
64
76
|
return new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
|
|
65
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Keep the original error and its causes in the server log, before response mapping loses the stack. */
|
|
80
|
+
export function logRequestError(
|
|
81
|
+
log: (line: string) => void,
|
|
82
|
+
request: { method: string; path: string },
|
|
83
|
+
err: unknown
|
|
84
|
+
): void {
|
|
85
|
+
const mapped = toAppError(err)
|
|
86
|
+
if (mapped.status >= 500) {
|
|
87
|
+
log(`[serve] ${request.method} ${request.path} ${mapped.status} ${mapped.code}\n${inspect(err)}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/server/html.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function homePage(
|
|
|
96
96
|
app: false,
|
|
97
97
|
body: html`<div class="page">
|
|
98
98
|
<header class="hdr">
|
|
99
|
-
<div class="hdr-bar"><div class="brand"><span class="
|
|
99
|
+
<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">localhost:${String(data.port)}</span></div>
|
|
100
100
|
<div class="hdr-actions"><a class="cmd" href="/api/health">health</a></div></div>
|
|
101
101
|
<div class="stripe" aria-hidden="true"></div>
|
|
102
102
|
<div class="hdr-title"><div class="title"><h1>${data.owner}/${data.repo}</h1></div>
|
|
@@ -132,7 +132,7 @@ export function errorPage(error: ErrorEnvelope['error'], nonce: string, appearan
|
|
|
132
132
|
appearance,
|
|
133
133
|
app: false,
|
|
134
134
|
body: html`<div class="page"><header class="hdr">
|
|
135
|
-
<div class="hdr-bar"><div class="brand"><span class="
|
|
135
|
+
<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span></div><div class="hdr-actions"><a class="cmd" href="/">home</a></div></div>
|
|
136
136
|
<div class="stripe" aria-hidden="true"></div></header>
|
|
137
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
138
|
<div class="body"><p>${error.message}</p>${error.hint ? html`<p class="muted">${error.hint}</p>` : ''}</div></section></main></div>`,
|
|
@@ -4,9 +4,11 @@ import type { AppContext } from './context.js'
|
|
|
4
4
|
|
|
5
5
|
/** Binds 127.0.0.1 only. The Host allowlist in security.ts covers the rest. */
|
|
6
6
|
export function startServer(ctx: AppContext, log: (line: string) => void): { close: () => void } {
|
|
7
|
-
const app = createApp(ctx)
|
|
7
|
+
const app = createApp({ ...ctx, log })
|
|
8
8
|
const server = serve({ fetch: app.fetch, port: ctx.config.port, hostname: '127.0.0.1' }, info => {
|
|
9
|
-
log(
|
|
9
|
+
log(
|
|
10
|
+
`pr-review ${ctx.version} · http://localhost:${info.port}/ · ${ctx.config.repo.owner}/${ctx.config.repo.name}`
|
|
11
|
+
)
|
|
10
12
|
log(`data dir ${ctx.config.dataDir}`)
|
|
11
13
|
if (ctx.fixtureArtifact !== null) {
|
|
12
14
|
log(`fixture canvas ${ctx.config.fixtureCanvasPath ?? ''} (dev only): every PR reports ready`)
|
package/src/server/routes/api.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { z } from 'zod'
|
|
|
3
3
|
import { buildCanvasZipFor } from '../../canvas/export.js'
|
|
4
4
|
import { importCanvas } from '../../canvas/import.js'
|
|
5
5
|
import { CANVAS_ZIP_MAX_BYTES } from '../../canvas/zip.js'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ContextResponse,
|
|
8
|
+
HealthResponse,
|
|
9
|
+
PatchesResponse,
|
|
10
|
+
SharedCanvasFetchResponse,
|
|
11
|
+
} from '../../contract/api.js'
|
|
7
12
|
import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
|
|
8
13
|
import { createPrLoader, resolveBundle, runDiscovery } from '../bundle.js'
|
|
9
14
|
import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
|
|
@@ -87,7 +92,10 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
87
92
|
|
|
88
93
|
// How the page is painted. It lives in the same settings file the chat settings do, but on its
|
|
89
94
|
// own route, because a repository with chat off still has a page to paint.
|
|
90
|
-
const appearanceOf = (settings: {
|
|
95
|
+
const appearanceOf = (settings: {
|
|
96
|
+
skin: AppearanceResponse['skin']
|
|
97
|
+
theme: AppearanceResponse['theme']
|
|
98
|
+
}) => ({
|
|
91
99
|
skin: settings.skin,
|
|
92
100
|
theme: settings.theme,
|
|
93
101
|
})
|
|
@@ -197,11 +205,23 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
197
205
|
throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
|
|
198
206
|
}
|
|
199
207
|
const pr = await loader.currentPr(number)
|
|
200
|
-
const lines = await ctx.derived.readLines(
|
|
208
|
+
const lines = await ctx.derived.readLines(
|
|
209
|
+
pr.headSha,
|
|
210
|
+
q.side === 'new' ? 'head' : 'base',
|
|
211
|
+
q.path,
|
|
212
|
+
q.from,
|
|
213
|
+
q.to
|
|
214
|
+
)
|
|
201
215
|
if (lines === null) {
|
|
202
216
|
throw new AppError('NOT_FOUND', `${q.path} is not materialized for this head`, 404)
|
|
203
217
|
}
|
|
204
|
-
const body: ContextResponse = {
|
|
218
|
+
const body: ContextResponse = {
|
|
219
|
+
path: q.path,
|
|
220
|
+
side: q.side,
|
|
221
|
+
from: q.from,
|
|
222
|
+
to: q.from + lines.length - 1,
|
|
223
|
+
lines,
|
|
224
|
+
}
|
|
205
225
|
return c.json(body)
|
|
206
226
|
})
|
|
207
227
|
|
|
@@ -226,7 +246,11 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
226
246
|
throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
|
|
227
247
|
}
|
|
228
248
|
if (file.size > CANVAS_ZIP_MAX_BYTES) {
|
|
229
|
-
throw new AppError(
|
|
249
|
+
throw new AppError(
|
|
250
|
+
'CANVAS_TOO_LARGE',
|
|
251
|
+
`the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
|
|
252
|
+
413
|
|
253
|
+
)
|
|
230
254
|
}
|
|
231
255
|
const result = await importCanvas(ctx, {
|
|
232
256
|
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
@@ -12,14 +12,18 @@ import type { SettingsResponse } from '../../contract/settings.js'
|
|
|
12
12
|
import { isChatAgent, SettingsInputSchema } from '../../contract/settings.js'
|
|
13
13
|
import type { PrLoader } from '../bundle.js'
|
|
14
14
|
import type { AppContext } from '../context.js'
|
|
15
|
-
import { AppError } from '../errors.js'
|
|
15
|
+
import { AppError, logRequestError } from '../errors.js'
|
|
16
16
|
import { SSE_HEADERS, sseStream } from '../sse.js'
|
|
17
17
|
import { parsePrNumber } from './api.js'
|
|
18
18
|
|
|
19
19
|
async function readJsonBody<T>(
|
|
20
20
|
request: Request,
|
|
21
21
|
schema: {
|
|
22
|
-
safeParse: (raw: unknown) => {
|
|
22
|
+
safeParse: (raw: unknown) => {
|
|
23
|
+
success: boolean
|
|
24
|
+
data?: T
|
|
25
|
+
error?: { issues: Array<{ message: string }> }
|
|
26
|
+
}
|
|
23
27
|
},
|
|
24
28
|
expected: string
|
|
25
29
|
): Promise<T> {
|
|
@@ -106,7 +110,9 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
106
110
|
return c.json(settingsResponse(ctx, await ctx.settings.write(input)))
|
|
107
111
|
})
|
|
108
112
|
|
|
109
|
-
api.get('/settings/agents', async c =>
|
|
113
|
+
api.get('/settings/agents', async c =>
|
|
114
|
+
c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' }))
|
|
115
|
+
)
|
|
110
116
|
|
|
111
117
|
api.post('/settings/agents/:id/probe', async c => {
|
|
112
118
|
const id = c.req.param('id')
|
|
@@ -141,7 +147,11 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
141
147
|
|
|
142
148
|
api.post('/prs/:n/chat', async c => {
|
|
143
149
|
const number = parsePrNumber(c.req.param('n'))
|
|
144
|
-
const input = await readJsonBody(
|
|
150
|
+
const input = await readJsonBody(
|
|
151
|
+
c.req.raw,
|
|
152
|
+
ChatSendSchema,
|
|
153
|
+
'{ "message": "…", "context": { "kind": "pr" } }'
|
|
154
|
+
)
|
|
145
155
|
const pr = await loader.currentPr(number)
|
|
146
156
|
const artifact = await artifactForChat(ctx, number, pr)
|
|
147
157
|
const derived = await ctx.derived.read(pr.headSha)
|
|
@@ -176,9 +186,14 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
176
186
|
}
|
|
177
187
|
// A browser that goes away stops the agent; the turn is nobody's answer any more.
|
|
178
188
|
return new Response(
|
|
179
|
-
sseStream(
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
sseStream(
|
|
190
|
+
replayFrom(first, iterator),
|
|
191
|
+
undefined,
|
|
192
|
+
() => {
|
|
193
|
+
void ctx.chat.cancel(number)
|
|
194
|
+
},
|
|
195
|
+
err => logRequestError(ctx.log, c.req, err)
|
|
196
|
+
),
|
|
182
197
|
{ headers: SSE_HEADERS }
|
|
183
198
|
)
|
|
184
199
|
})
|
|
@@ -190,7 +205,10 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
190
205
|
* The turn as a stream again, after its first event was read to see whether the route can answer
|
|
191
206
|
* with a stream at all. A turn that ended on that first read is an empty stream.
|
|
192
207
|
*/
|
|
193
|
-
export function replayFrom(
|
|
208
|
+
export function replayFrom(
|
|
209
|
+
first: IteratorResult<ChatEvent>,
|
|
210
|
+
rest: AsyncIterator<ChatEvent>
|
|
211
|
+
): AsyncIterable<ChatEvent> {
|
|
194
212
|
let sent = first.done === true
|
|
195
213
|
return {
|
|
196
214
|
[Symbol.asyncIterator]: () => ({
|
|
@@ -217,5 +235,8 @@ export function toChatError(err: unknown): AppError {
|
|
|
217
235
|
if (err instanceof ChatContextError) {
|
|
218
236
|
return new AppError('BAD_REQUEST', err.message, 400)
|
|
219
237
|
}
|
|
220
|
-
|
|
238
|
+
if (err instanceof AppError) return err
|
|
239
|
+
const error = new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
|
|
240
|
+
error.cause = err
|
|
241
|
+
return error
|
|
221
242
|
}
|
|
@@ -12,7 +12,9 @@ export async function appearanceFor(ctx: AppContext, query: AppearanceQuery): Pr
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** The `?skin` and `?theme` of one request, which pick an appearance for that load alone. */
|
|
15
|
-
export function appearanceQuery(c: {
|
|
15
|
+
export function appearanceQuery(c: {
|
|
16
|
+
req: { query: (name: string) => string | undefined }
|
|
17
|
+
}): AppearanceQuery {
|
|
16
18
|
return { skin: c.req.query('skin'), theme: c.req.query('theme') }
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -91,7 +91,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({
|
|
94
|
+
const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({
|
|
95
|
+
prNumber: number,
|
|
96
|
+
state,
|
|
97
|
+
})
|
|
95
98
|
|
|
96
99
|
api.get('/prs/:n/state', async c => {
|
|
97
100
|
const number = parsePrNumber(c.req.param('n'))
|
|
@@ -102,7 +105,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
102
105
|
const number = parsePrNumber(c.req.param('n'))
|
|
103
106
|
const id = c.req.param('id')
|
|
104
107
|
if (!isReviewedId(id)) {
|
|
105
|
-
throw new AppError(
|
|
108
|
+
throw new AppError(
|
|
109
|
+
'BAD_REQUEST',
|
|
110
|
+
`not a reviewed id: ${id}`,
|
|
111
|
+
400,
|
|
112
|
+
'use layer:<id> or layer:<id>/file:<key>'
|
|
113
|
+
)
|
|
106
114
|
}
|
|
107
115
|
const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
|
|
108
116
|
const pr = await loader.currentPr(number)
|
|
@@ -223,7 +231,11 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
|
|
|
223
231
|
* Keeps the cached comments in step with what was just posted, so a reload shows it once. With
|
|
224
232
|
* nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
|
|
225
233
|
*/
|
|
226
|
-
async function writeAppendedComment(
|
|
234
|
+
async function writeAppendedComment(
|
|
235
|
+
ctx: AppContext,
|
|
236
|
+
number: number,
|
|
237
|
+
posted: PostCommentResult
|
|
238
|
+
): Promise<void> {
|
|
227
239
|
const comments = await ctx.prs.readComments(number)
|
|
228
240
|
if (comments === null) {
|
|
229
241
|
return
|
|
@@ -241,5 +253,8 @@ async function writeAppendedComment(ctx: AppContext, number: number, posted: Pos
|
|
|
241
253
|
if (comments.issueComments.some(c => c.id === posted.comment.id)) {
|
|
242
254
|
return
|
|
243
255
|
}
|
|
244
|
-
await ctx.prs.writeComments(number, {
|
|
256
|
+
await ctx.prs.writeComments(number, {
|
|
257
|
+
...comments,
|
|
258
|
+
issueComments: [...comments.issueComments, posted.comment],
|
|
259
|
+
})
|
|
245
260
|
}
|
package/src/server/sse.ts
CHANGED
|
@@ -28,7 +28,8 @@ export function sseFrame(event: ChatEvent): string {
|
|
|
28
28
|
export function sseStream(
|
|
29
29
|
events: AsyncIterable<ChatEvent>,
|
|
30
30
|
onClose?: () => void,
|
|
31
|
-
onCancel?: () => void
|
|
31
|
+
onCancel?: () => void,
|
|
32
|
+
onError?: (err: unknown) => void
|
|
32
33
|
): ReadableStream<Uint8Array> {
|
|
33
34
|
const encoder = new TextEncoder()
|
|
34
35
|
const iterator = events[Symbol.asyncIterator]()
|
|
@@ -38,6 +39,7 @@ export function sseStream(
|
|
|
38
39
|
try {
|
|
39
40
|
next = await iterator.next()
|
|
40
41
|
} catch (err) {
|
|
42
|
+
onError?.(err)
|
|
41
43
|
controller.enqueue(
|
|
42
44
|
encoder.encode(
|
|
43
45
|
sseFrame({
|
package/src/store/atomic-json.ts
CHANGED
|
@@ -37,7 +37,11 @@ export async function readJson<T>(file: string, schema: z.ZodType<T>): Promise<T
|
|
|
37
37
|
* Like readJson, but a missing file, invalid JSON, or a shape the schema rejects all return
|
|
38
38
|
* `fallback()`. Other read errors still throw.
|
|
39
39
|
*/
|
|
40
|
-
export async function readJsonOrDefault<T, D>(
|
|
40
|
+
export async function readJsonOrDefault<T, D>(
|
|
41
|
+
file: string,
|
|
42
|
+
schema: z.ZodType<T>,
|
|
43
|
+
fallback: () => D
|
|
44
|
+
): Promise<T | D> {
|
|
41
45
|
const text = await readText(file)
|
|
42
46
|
if (text === null) {
|
|
43
47
|
return fallback()
|