@vintasoftware/pr-review-canvas 0.2.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 +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- 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/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- 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 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- 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 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -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 +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- 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/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- 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 +10 -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/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
|
@@ -10,16 +10,23 @@ 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
|
-
import { AppError } from '../errors.js'
|
|
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,
|
|
21
24
|
schema: {
|
|
22
|
-
safeParse: (raw: unknown) => {
|
|
25
|
+
safeParse: (raw: unknown) => {
|
|
26
|
+
success: boolean
|
|
27
|
+
data?: T
|
|
28
|
+
error?: { issues: Array<{ message: string }> }
|
|
29
|
+
}
|
|
23
30
|
},
|
|
24
31
|
expected: string
|
|
25
32
|
): Promise<T> {
|
|
@@ -49,28 +56,72 @@ function settingsResponse(ctx: AppContext, settings: SettingsResponse['settings'
|
|
|
49
56
|
maxRepairRounds: project.generation.maxRepairRounds,
|
|
50
57
|
inlineDiffMaxLines: project.generation.inlineDiffMaxLines,
|
|
51
58
|
smallPrHunks: project.generation.smallPrHunks,
|
|
59
|
+
keepForIdenticalDiff: project.canvas.keepForIdenticalDiff,
|
|
52
60
|
layers: project.layers.length,
|
|
53
61
|
highRisk: project.highRisk.length,
|
|
54
62
|
},
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
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> {
|
|
60
80
|
if (ctx.fixtureArtifact !== null) {
|
|
61
|
-
return { ...ctx.fixtureArtifact, pr }
|
|
81
|
+
return withDiff({ ...ctx.fixtureArtifact, pr }, pr.headSha, await ctx.derived.read(pr.headSha))
|
|
62
82
|
}
|
|
63
|
-
const found =
|
|
64
|
-
|
|
65
|
-
|
|
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) {
|
|
66
88
|
throw new AppError(
|
|
67
89
|
'CANVAS_NOT_FOUND',
|
|
68
|
-
'there is no canvas for this
|
|
90
|
+
'there is no canvas for this pull request, so the chat has nothing to talk about',
|
|
69
91
|
404,
|
|
70
92
|
'generate a canvas for the current head first'
|
|
71
93
|
)
|
|
72
94
|
}
|
|
73
|
-
|
|
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 }
|
|
74
125
|
}
|
|
75
126
|
|
|
76
127
|
/** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
|
|
@@ -106,7 +157,9 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
106
157
|
return c.json(settingsResponse(ctx, await ctx.settings.write(input)))
|
|
107
158
|
})
|
|
108
159
|
|
|
109
|
-
api.get('/settings/agents', async c =>
|
|
160
|
+
api.get('/settings/agents', async c =>
|
|
161
|
+
c.json(await ctx.agents.list({ refresh: c.req.query('refresh') === '1' }))
|
|
162
|
+
)
|
|
110
163
|
|
|
111
164
|
api.post('/settings/agents/:id/probe', async c => {
|
|
112
165
|
const id = c.req.param('id')
|
|
@@ -116,52 +169,47 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
116
169
|
return c.json(await ctx.agents.probe(id, { refresh: c.req.query('refresh') === '1' }))
|
|
117
170
|
})
|
|
118
171
|
|
|
119
|
-
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')))))
|
|
120
173
|
|
|
121
174
|
api.post('/prs/:n/chat/threads', async c => {
|
|
122
|
-
const
|
|
123
|
-
const thread = await ctx.chat.createThread(
|
|
124
|
-
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)
|
|
125
178
|
})
|
|
126
179
|
|
|
127
180
|
api.get('/prs/:n/chat/threads/:name/history', async c => {
|
|
128
|
-
const
|
|
181
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
129
182
|
const name = c.req.param('name')
|
|
130
|
-
if (!isThreadNameFor(name,
|
|
131
|
-
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)
|
|
132
185
|
}
|
|
133
|
-
const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(
|
|
186
|
+
const body: ChatHistoryResponse = { name, turns: await ctx.transcripts.read(key, name) }
|
|
134
187
|
return c.json(body)
|
|
135
188
|
})
|
|
136
189
|
|
|
137
190
|
api.post('/prs/:n/chat/cancel', async c => {
|
|
138
|
-
const
|
|
139
|
-
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) })
|
|
140
193
|
})
|
|
141
194
|
|
|
142
195
|
api.post('/prs/:n/chat', async c => {
|
|
143
|
-
const
|
|
144
|
-
const input = await readJsonBody(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'the diff of this head is not available locally, so the chat cannot quote it',
|
|
152
|
-
404,
|
|
153
|
-
'fetch the PR head and reload'
|
|
154
|
-
)
|
|
155
|
-
}
|
|
196
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
197
|
+
const input = await readJsonBody(
|
|
198
|
+
c.req.raw,
|
|
199
|
+
ChatSendSchema,
|
|
200
|
+
'{ "message": "…", "context": { "kind": "pr" } }'
|
|
201
|
+
)
|
|
202
|
+
const pr = await loader.currentTarget(key)
|
|
203
|
+
const { artifact, headSha, files, patches } = await subjectForChat(ctx, key, pr)
|
|
156
204
|
const events = ctx.chat.send(
|
|
157
205
|
{
|
|
158
|
-
|
|
159
|
-
headSha
|
|
206
|
+
key,
|
|
207
|
+
headSha,
|
|
160
208
|
artifact,
|
|
161
|
-
files
|
|
162
|
-
patches
|
|
163
|
-
derivedDir: ctx.derived.derivedDir(
|
|
164
|
-
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),
|
|
165
213
|
},
|
|
166
214
|
{ message: input.message, context: input.context, thread: input.thread }
|
|
167
215
|
)
|
|
@@ -176,9 +224,14 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
176
224
|
}
|
|
177
225
|
// A browser that goes away stops the agent; the turn is nobody's answer any more.
|
|
178
226
|
return new Response(
|
|
179
|
-
sseStream(
|
|
180
|
-
|
|
181
|
-
|
|
227
|
+
sseStream(
|
|
228
|
+
replayFrom(first, iterator),
|
|
229
|
+
undefined,
|
|
230
|
+
() => {
|
|
231
|
+
void ctx.chat.cancel(key)
|
|
232
|
+
},
|
|
233
|
+
err => logRequestError(ctx.log, c.req, err)
|
|
234
|
+
),
|
|
182
235
|
{ headers: SSE_HEADERS }
|
|
183
236
|
)
|
|
184
237
|
})
|
|
@@ -190,7 +243,10 @@ export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
190
243
|
* The turn as a stream again, after its first event was read to see whether the route can answer
|
|
191
244
|
* with a stream at all. A turn that ended on that first read is an empty stream.
|
|
192
245
|
*/
|
|
193
|
-
export function replayFrom(
|
|
246
|
+
export function replayFrom(
|
|
247
|
+
first: IteratorResult<ChatEvent>,
|
|
248
|
+
rest: AsyncIterator<ChatEvent>
|
|
249
|
+
): AsyncIterable<ChatEvent> {
|
|
194
250
|
let sent = first.done === true
|
|
195
251
|
return {
|
|
196
252
|
[Symbol.asyncIterator]: () => ({
|
|
@@ -217,5 +273,8 @@ export function toChatError(err: unknown): AppError {
|
|
|
217
273
|
if (err instanceof ChatContextError) {
|
|
218
274
|
return new AppError('BAD_REQUEST', err.message, 400)
|
|
219
275
|
}
|
|
220
|
-
|
|
276
|
+
if (err instanceof AppError) return err
|
|
277
|
+
const error = new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
|
|
278
|
+
error.cause = err
|
|
279
|
+
return error
|
|
221
280
|
}
|
|
@@ -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> {
|
|
@@ -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
|
|
|
@@ -20,15 +22,20 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
|
|
|
20
22
|
const app = new Hono<AppEnv>()
|
|
21
23
|
|
|
22
24
|
app.get('/', async c => {
|
|
23
|
-
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
|
+
])
|
|
24
29
|
return c.html(
|
|
25
30
|
homePage(
|
|
26
31
|
{
|
|
27
32
|
recentPrs,
|
|
33
|
+
localReviews: LOCAL_KEYS.filter((_, i) => localPrs[i] !== null && localPrs[i] !== undefined),
|
|
28
34
|
owner: ctx.config.repo.owner,
|
|
29
35
|
repo: ctx.config.repo.name,
|
|
30
36
|
version: ctx.version,
|
|
31
37
|
port: ctx.config.port,
|
|
38
|
+
host: ctx.config.host,
|
|
32
39
|
},
|
|
33
40
|
c.get('cspNonce'),
|
|
34
41
|
await appearanceFor(ctx, appearanceQuery(c))
|
|
@@ -45,15 +52,24 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
|
|
|
45
52
|
|
|
46
53
|
app.get('/review/:n', async c => {
|
|
47
54
|
const raw = c.req.param('n')
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
)
|
|
53
63
|
}
|
|
54
64
|
return c.html(
|
|
55
65
|
reviewPage(
|
|
56
|
-
{
|
|
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
|
+
},
|
|
57
73
|
c.get('cspNonce'),
|
|
58
74
|
await appearanceFor(ctx, appearanceQuery(c))
|
|
59
75
|
)
|
|
@@ -4,14 +4,19 @@ import { z } from 'zod'
|
|
|
4
4
|
import type { ReviewBodyResponse, StateResponse } from '../../contract/api.js'
|
|
5
5
|
import { PostCommentInputSchema, type PostCommentResult } from '../../contract/comments.js'
|
|
6
6
|
import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import type { PrState } from '../../contract/state.js'
|
|
8
|
+
import { checkInlineTarget } from '../../git/patch-lines.js'
|
|
9
|
+
import { PostReviewInputSchema } from '../../contract/reviews.js'
|
|
10
|
+
import { lookupCanvas } from '../../review/carry-over.js'
|
|
11
|
+
import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
|
|
12
|
+
import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
|
|
13
|
+
import { canvasBelongsTo } from '../../store/canvas-store.js'
|
|
10
14
|
import { isReviewedId } from '../../store/state-store.js'
|
|
11
|
-
import type {
|
|
15
|
+
import type { Derived } from '../../store/derived-store.js'
|
|
16
|
+
import { LOCAL_CAPABILITIES, type PrLoader } from '../bundle.js'
|
|
12
17
|
import type { AppContext } from '../context.js'
|
|
13
18
|
import { AppError } from '../errors.js'
|
|
14
|
-
import {
|
|
19
|
+
import { parseTargetKey, requirePrNumber } from './api.js'
|
|
15
20
|
|
|
16
21
|
const ReviewedBodySchema = z.object({
|
|
17
22
|
reviewed: z.boolean(),
|
|
@@ -20,6 +25,14 @@ const ReviewedBodySchema = z.object({
|
|
|
20
25
|
.string()
|
|
21
26
|
.regex(/^[0-9a-f]{40}$/)
|
|
22
27
|
.optional(),
|
|
28
|
+
/**
|
|
29
|
+
* The commit of the canvas on the page, which the marks are keyed to. The head when absent. Any
|
|
30
|
+
* other value must be a canvas indexed for this pull request.
|
|
31
|
+
*/
|
|
32
|
+
canvasSha: z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
35
|
+
.optional(),
|
|
23
36
|
})
|
|
24
37
|
const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
|
|
25
38
|
const HiddenBodySchema = z.object({ hidden: z.boolean() })
|
|
@@ -54,12 +67,34 @@ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: str
|
|
|
54
67
|
return parsed.data
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
/**
|
|
58
|
-
async function
|
|
70
|
+
/** Refuses a commit that is not an indexed canvas of this target. */
|
|
71
|
+
async function requireCanvasOf(ctx: AppContext, key: ReviewKey, canvasSha: string): Promise<void> {
|
|
72
|
+
const entry = (await ctx.canvases.readIndex()).canvases[canvasSha]
|
|
73
|
+
// The same rule the lookups use, so a mark can only be keyed to a canvas this target shows.
|
|
74
|
+
if (entry === undefined || !canvasBelongsTo(entry, key)) {
|
|
75
|
+
throw new AppError(
|
|
76
|
+
'CANVAS_NOT_FOUND',
|
|
77
|
+
`${canvasSha.slice(0, 7)} is not a canvas of ${keyLabel(key)}`,
|
|
78
|
+
404,
|
|
79
|
+
'reload the page'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The canvas the reviewer is signing off on, with the marks made on it: the canvas written for the
|
|
86
|
+
* pull request's head, or for another commit with an identical diff.
|
|
87
|
+
*/
|
|
88
|
+
async function canvasForSignOff(
|
|
89
|
+
ctx: AppContext,
|
|
90
|
+
number: number,
|
|
91
|
+
pr: Pr
|
|
92
|
+
): Promise<{ artifact: ReviewArtifact; state: PrState }> {
|
|
93
|
+
const stored = await ctx.state.read(number)
|
|
59
94
|
if (ctx.fixtureArtifact !== null) {
|
|
60
|
-
return { ...ctx.fixtureArtifact, pr }
|
|
95
|
+
return { artifact: { ...ctx.fixtureArtifact, pr }, state: stateForCanvas(stored, pr.headSha) }
|
|
61
96
|
}
|
|
62
|
-
const found = await ctx
|
|
97
|
+
const found = await lookupCanvas(ctx, number, pr)
|
|
63
98
|
if (found.status !== 'ready') {
|
|
64
99
|
throw new AppError(
|
|
65
100
|
'SIGNOFF_INCOMPLETE',
|
|
@@ -72,7 +107,7 @@ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise
|
|
|
72
107
|
if (artifact === null) {
|
|
73
108
|
throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
|
|
74
109
|
}
|
|
75
|
-
return artifact
|
|
110
|
+
return { artifact, state: stateForCanvas(stored, found.headSha) }
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
@@ -84,42 +119,60 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
84
119
|
if (caps.canComment === false) {
|
|
85
120
|
throw new AppError(
|
|
86
121
|
'COMMENT_FORBIDDEN',
|
|
87
|
-
caps.reason ??
|
|
122
|
+
caps.reason ?? `this ${ctx.config.host.label} login cannot post on this repository`,
|
|
88
123
|
403,
|
|
89
124
|
caps.hint
|
|
90
125
|
)
|
|
91
126
|
}
|
|
92
127
|
}
|
|
93
128
|
|
|
94
|
-
const stateBody = (
|
|
129
|
+
const stateBody = (key: ReviewKey, state: StateResponse['state']): StateResponse => ({
|
|
130
|
+
prNumber: key,
|
|
131
|
+
state,
|
|
132
|
+
})
|
|
95
133
|
|
|
96
134
|
api.get('/prs/:n/state', async c => {
|
|
97
|
-
const
|
|
98
|
-
return c.json(stateBody(
|
|
135
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
136
|
+
return c.json(stateBody(key, await ctx.state.read(key)))
|
|
99
137
|
})
|
|
100
138
|
|
|
101
139
|
api.put('/prs/:n/reviewed/:id{.+}', async c => {
|
|
102
|
-
const
|
|
140
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
103
141
|
const id = c.req.param('id')
|
|
104
142
|
if (!isReviewedId(id)) {
|
|
105
|
-
throw new AppError(
|
|
143
|
+
throw new AppError(
|
|
144
|
+
'BAD_REQUEST',
|
|
145
|
+
`not a reviewed id: ${id}`,
|
|
146
|
+
400,
|
|
147
|
+
'use layer:<id> or layer:<id>/file:<key>'
|
|
148
|
+
)
|
|
106
149
|
}
|
|
107
150
|
const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
// A mark is made against the canvas on screen, which for a local review is the snapshot the
|
|
152
|
+
// page was drawn from, not whatever the working tree holds a keystroke later.
|
|
153
|
+
const pr = await loader.currentTarget(key)
|
|
154
|
+
if (!isLocalKey(key)) {
|
|
155
|
+
requireSameHead(body.headSha, pr.headSha)
|
|
156
|
+
}
|
|
157
|
+
// The page sends back the canvas commit the bundle keyed its marks to, so a toggle costs no
|
|
158
|
+
// git work; the index, not git, says the commit is a canvas of this target.
|
|
159
|
+
const canvasSha = body.canvasSha ?? pr.headSha
|
|
160
|
+
if (canvasSha !== pr.headSha) {
|
|
161
|
+
await requireCanvasOf(ctx, key, canvasSha)
|
|
162
|
+
}
|
|
163
|
+
return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, canvasSha)))
|
|
111
164
|
})
|
|
112
165
|
|
|
113
166
|
api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
|
|
114
|
-
const
|
|
167
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
115
168
|
const fingerprint = c.req.param('fingerprint')
|
|
116
169
|
const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
|
|
117
|
-
const next = await ctx.state.setDismissed(
|
|
118
|
-
return c.json(stateBody(
|
|
170
|
+
const next = await ctx.state.setDismissed(key, fingerprint, body.dismissed, body.reason)
|
|
171
|
+
return c.json(stateBody(key, next))
|
|
119
172
|
})
|
|
120
173
|
|
|
121
174
|
api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
|
|
122
|
-
const number =
|
|
175
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'hiding a comment thread')
|
|
123
176
|
const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
|
|
124
177
|
if (!rootId.success) {
|
|
125
178
|
throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
|
|
@@ -129,16 +182,20 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
129
182
|
})
|
|
130
183
|
|
|
131
184
|
api.get('/prs/:n/capabilities', async c => {
|
|
132
|
-
|
|
185
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
186
|
+
if (isLocalKey(key)) {
|
|
187
|
+
return c.json(LOCAL_CAPABILITIES)
|
|
188
|
+
}
|
|
133
189
|
return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
|
|
134
190
|
})
|
|
135
191
|
|
|
136
192
|
api.post('/prs/:n/comments', async c => {
|
|
137
|
-
const number =
|
|
193
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'posting a comment')
|
|
138
194
|
const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
|
|
139
195
|
await requirePosting()
|
|
140
196
|
const pr = await loader.currentPr(number)
|
|
141
197
|
requireSameHead(input.headSha, pr.headSha)
|
|
198
|
+
let diff: Derived = { files: [], patches: {} }
|
|
142
199
|
if (input.kind === 'inline') {
|
|
143
200
|
const derived = await ctx.derived.read(pr.headSha)
|
|
144
201
|
if (derived === null) {
|
|
@@ -153,8 +210,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
153
210
|
if (problem !== null) {
|
|
154
211
|
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
155
212
|
}
|
|
213
|
+
diff = derived
|
|
156
214
|
}
|
|
157
|
-
const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
|
|
215
|
+
const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
|
|
158
216
|
await appendComment(ctx, number, posted)
|
|
159
217
|
const entry =
|
|
160
218
|
input.kind === 'inline' && input.pointFingerprint !== undefined
|
|
@@ -165,10 +223,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
165
223
|
})
|
|
166
224
|
|
|
167
225
|
api.get('/prs/:n/review/body', async c => {
|
|
168
|
-
const number =
|
|
226
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
|
|
169
227
|
const pr = await loader.currentPr(number)
|
|
170
|
-
const artifact = await
|
|
171
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
228
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
172
229
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
173
230
|
const body: ReviewBodyResponse = {
|
|
174
231
|
headSha: pr.headSha,
|
|
@@ -179,13 +236,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
179
236
|
})
|
|
180
237
|
|
|
181
238
|
api.post('/prs/:n/review', async c => {
|
|
182
|
-
const number =
|
|
239
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'submitting a review')
|
|
183
240
|
const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
|
|
184
241
|
await requirePosting()
|
|
185
242
|
const pr = await loader.currentPr(number)
|
|
186
243
|
requireSameHead(input.headSha, pr.headSha)
|
|
187
|
-
const artifact = await
|
|
188
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
244
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
189
245
|
if (input.event === 'APPROVE') {
|
|
190
246
|
const missing = unreviewedLayers(artifact, state)
|
|
191
247
|
if (missing.length > 0) {
|
|
@@ -199,7 +255,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
199
255
|
}
|
|
200
256
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
201
257
|
const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
|
|
202
|
-
const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
|
|
258
|
+
const review = await ctx.config.host.postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
|
|
259
|
+
event: input.event,
|
|
260
|
+
body,
|
|
261
|
+
})
|
|
203
262
|
return c.json({ review }, 201)
|
|
204
263
|
})
|
|
205
264
|
|
|
@@ -223,7 +282,11 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
|
|
|
223
282
|
* Keeps the cached comments in step with what was just posted, so a reload shows it once. With
|
|
224
283
|
* nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
|
|
225
284
|
*/
|
|
226
|
-
async function writeAppendedComment(
|
|
285
|
+
async function writeAppendedComment(
|
|
286
|
+
ctx: AppContext,
|
|
287
|
+
number: number,
|
|
288
|
+
posted: PostCommentResult
|
|
289
|
+
): Promise<void> {
|
|
227
290
|
const comments = await ctx.prs.readComments(number)
|
|
228
291
|
if (comments === null) {
|
|
229
292
|
return
|
|
@@ -241,5 +304,8 @@ async function writeAppendedComment(ctx: AppContext, number: number, posted: Pos
|
|
|
241
304
|
if (comments.issueComments.some(c => c.id === posted.comment.id)) {
|
|
242
305
|
return
|
|
243
306
|
}
|
|
244
|
-
await ctx.prs.writeComments(number, {
|
|
307
|
+
await ctx.prs.writeComments(number, {
|
|
308
|
+
...comments,
|
|
309
|
+
issueComments: [...comments.issueComments, posted.comment],
|
|
310
|
+
})
|
|
245
311
|
}
|
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()
|