@vintasoftware/pr-review-canvas 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
|
@@ -3,11 +3,13 @@ import { Hono } from 'hono'
|
|
|
3
3
|
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
|
+
import { AddPendingInputSchema, EditPendingInputSchema, type PendingComment } from '../../contract/pending.js'
|
|
6
7
|
import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
|
|
7
8
|
import type { PrState } from '../../contract/state.js'
|
|
8
9
|
import { checkInlineTarget } from '../../git/patch-lines.js'
|
|
9
10
|
import { PostReviewInputSchema } from '../../contract/reviews.js'
|
|
10
|
-
import { lookupCanvas } from '../../review/carry-over.js'
|
|
11
|
+
import { lookupCanvas, samePatches } from '../../review/carry-over.js'
|
|
12
|
+
import { marksForCanvas } from '../../review/carry-marks.js'
|
|
11
13
|
import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
|
|
12
14
|
import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
|
|
13
15
|
import { canvasBelongsTo } from '../../store/canvas-store.js'
|
|
@@ -107,7 +109,93 @@ async function canvasForSignOff(
|
|
|
107
109
|
if (artifact === null) {
|
|
108
110
|
throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
|
|
109
111
|
}
|
|
110
|
-
return { artifact, state:
|
|
112
|
+
return { artifact, state: (await marksForCanvas(ctx, artifact, found.headSha, stored)).state }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The marks the state should start from when the next write keys it to `canvasSha`: none when the
|
|
117
|
+
* marks already describe that canvas, otherwise the ones that may follow it from its basis. The
|
|
118
|
+
* same rule the bundle showed the reviewer, recomputed here rather than taken from the page.
|
|
119
|
+
*/
|
|
120
|
+
async function adoptedMarks(
|
|
121
|
+
ctx: AppContext,
|
|
122
|
+
key: ReviewKey,
|
|
123
|
+
canvasSha: string
|
|
124
|
+
): Promise<Record<string, true> | undefined> {
|
|
125
|
+
const stored = await ctx.state.read(key)
|
|
126
|
+
if (stored.reviewedCanvasSha === canvasSha) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const artifact = await ctx.canvases.readArtifact(canvasSha)
|
|
130
|
+
if (artifact === null) {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
return (await marksForCanvas(ctx, artifact, canvasSha, stored)).state.reviewed
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The diff of this head as it is stored locally, which every inline target is checked against.
|
|
138
|
+
* Without it the line a comment names cannot be checked, so the comment is refused rather than
|
|
139
|
+
* sent to a coordinate nobody verified.
|
|
140
|
+
*/
|
|
141
|
+
async function requireDerived(ctx: AppContext, headSha: string): Promise<Derived> {
|
|
142
|
+
const derived = await ctx.derived.read(headSha)
|
|
143
|
+
if (derived === null) {
|
|
144
|
+
throw new AppError(
|
|
145
|
+
'NOT_FOUND',
|
|
146
|
+
'the diff of this head is not available locally, so the line cannot be checked',
|
|
147
|
+
404,
|
|
148
|
+
'fetch the PR head and reload'
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return derived
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Refuses a line the diff on screen does not show. */
|
|
155
|
+
function requireInlineTarget(diff: Derived, target: Parameters<typeof checkInlineTarget>[1]): void {
|
|
156
|
+
const problem = checkInlineTarget(diff.files, target)
|
|
157
|
+
if (problem !== null) {
|
|
158
|
+
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The `posted` entries for drafts that came from attention points, found in the comment list the
|
|
164
|
+
* forge created in this submission. Match the full range and body inside that receipt,
|
|
165
|
+
* never against historical comments. A failed comment read is reported by the host adapter.
|
|
166
|
+
*/
|
|
167
|
+
export function postedFromPending(
|
|
168
|
+
pending: ReadonlyArray<PendingComment>,
|
|
169
|
+
comments: ReadonlyArray<{
|
|
170
|
+
id: number
|
|
171
|
+
path: string
|
|
172
|
+
line: number | null
|
|
173
|
+
startLine?: number | undefined
|
|
174
|
+
side: string
|
|
175
|
+
body: string
|
|
176
|
+
}>
|
|
177
|
+
): Array<{ commentId: number; pointFingerprint: string }> {
|
|
178
|
+
const entries: Array<{ commentId: number; pointFingerprint: string }> = []
|
|
179
|
+
const used = new Set<number>()
|
|
180
|
+
for (const draft of pending) {
|
|
181
|
+
if (draft.pointFingerprint === undefined) {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const match = comments.find(
|
|
185
|
+
c =>
|
|
186
|
+
!used.has(c.id) &&
|
|
187
|
+
c.path === draft.path &&
|
|
188
|
+
c.line === draft.line &&
|
|
189
|
+
(c.startLine ?? c.line) === (draft.startLine ?? draft.line) &&
|
|
190
|
+
c.side === draft.side &&
|
|
191
|
+
c.body === draft.body
|
|
192
|
+
)
|
|
193
|
+
if (match !== undefined) {
|
|
194
|
+
used.add(match.id)
|
|
195
|
+
entries.push({ commentId: match.id, pointFingerprint: draft.pointFingerprint })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return entries
|
|
111
199
|
}
|
|
112
200
|
|
|
113
201
|
export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
@@ -160,7 +248,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
160
248
|
if (canvasSha !== pr.headSha) {
|
|
161
249
|
await requireCanvasOf(ctx, key, canvasSha)
|
|
162
250
|
}
|
|
163
|
-
|
|
251
|
+
// The first mark on a canvas adopts the marks that followed it from the canvas it descends
|
|
252
|
+
// from, so the page's carried ticks survive the write instead of being replaced.
|
|
253
|
+
const carried = await adoptedMarks(ctx, key, canvasSha)
|
|
254
|
+
return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, { canvasSha, carried })))
|
|
164
255
|
})
|
|
165
256
|
|
|
166
257
|
api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
|
|
@@ -197,23 +288,11 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
197
288
|
requireSameHead(input.headSha, pr.headSha)
|
|
198
289
|
let diff: Derived = { files: [], patches: {} }
|
|
199
290
|
if (input.kind === 'inline') {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
throw new AppError(
|
|
203
|
-
'NOT_FOUND',
|
|
204
|
-
'the diff of this head is not available locally, so the line cannot be checked',
|
|
205
|
-
404,
|
|
206
|
-
'fetch the PR head and reload'
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
const problem = checkInlineTarget(derived.files, input)
|
|
210
|
-
if (problem !== null) {
|
|
211
|
-
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
212
|
-
}
|
|
213
|
-
diff = derived
|
|
291
|
+
diff = await requireDerived(ctx, pr.headSha)
|
|
292
|
+
requireInlineTarget(diff, input)
|
|
214
293
|
}
|
|
215
294
|
const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
|
|
216
|
-
await
|
|
295
|
+
await appendComments(ctx, number, [posted])
|
|
217
296
|
const entry =
|
|
218
297
|
input.kind === 'inline' && input.pointFingerprint !== undefined
|
|
219
298
|
? { commentId: posted.comment.id, pointFingerprint: input.pointFingerprint }
|
|
@@ -222,6 +301,42 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
222
301
|
return c.json({ ...posted, state }, 201)
|
|
223
302
|
})
|
|
224
303
|
|
|
304
|
+
// The pending review: comments the reviewer wrote and has not submitted. They are kept here,
|
|
305
|
+
// never on the forge, until a review carries them out in one go.
|
|
306
|
+
api.post('/prs/:n/pending', async c => {
|
|
307
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
308
|
+
const input = await readBody(c.req.raw, AddPendingInputSchema, 'a comment on a line of the diff')
|
|
309
|
+
const pr = await loader.currentPr(number)
|
|
310
|
+
requireSameHead(input.headSha, pr.headSha)
|
|
311
|
+
// A draft is checked against the diff when it is written, so a line that cannot take a comment
|
|
312
|
+
// is refused while the reviewer is still looking at it, not when the review is submitted.
|
|
313
|
+
requireInlineTarget(await requireDerived(ctx, pr.headSha), input)
|
|
314
|
+
const state = await ctx.state.addPending(number, input, pr.headSha)
|
|
315
|
+
return c.json(stateBody(number, state), 201)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
api.patch('/prs/:n/pending/:id', async c => {
|
|
319
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
320
|
+
const id = c.req.param('id')
|
|
321
|
+
const { body } = await readBody(c.req.raw, EditPendingInputSchema, '{ "body": "…" }')
|
|
322
|
+
const current = await ctx.state.read(number)
|
|
323
|
+
if (!current.pending.some(p => p.id === id)) {
|
|
324
|
+
throw new AppError('NOT_FOUND', `no pending comment ${id}`, 404, 'reload the page')
|
|
325
|
+
}
|
|
326
|
+
return c.json(stateBody(number, await ctx.state.editPending(number, id, body)))
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
api.delete('/prs/:n/pending/:id', async c => {
|
|
330
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
331
|
+
return c.json(stateBody(number, await ctx.state.removePending(number, c.req.param('id'))))
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/** Discards the whole pending review. Nothing was on the forge, so nothing is withdrawn. */
|
|
335
|
+
api.delete('/prs/:n/pending', async c => {
|
|
336
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review')
|
|
337
|
+
return c.json(stateBody(number, await ctx.state.clearPending(number)))
|
|
338
|
+
})
|
|
339
|
+
|
|
225
340
|
api.get('/prs/:n/review/body', async c => {
|
|
226
341
|
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
|
|
227
342
|
const pr = await loader.currentPr(number)
|
|
@@ -231,6 +346,7 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
231
346
|
headSha: pr.headSha,
|
|
232
347
|
body: buildReviewBody({ artifact, state, comments, headSha: pr.headSha }),
|
|
233
348
|
unreviewed: unreviewedLayers(artifact, state).map(l => l.title),
|
|
349
|
+
pending: state.pending.length,
|
|
234
350
|
}
|
|
235
351
|
return c.json(body)
|
|
236
352
|
})
|
|
@@ -255,11 +371,54 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
255
371
|
}
|
|
256
372
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
257
373
|
const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
374
|
+
// The drafts read here are the ones that go out. A draft written after this read stays in the
|
|
375
|
+
// pending review instead of being dropped by the clear below.
|
|
376
|
+
const pending = input.includePending ? (await ctx.state.read(number)).pending : []
|
|
377
|
+
// GitLab needs the diff to position its draft notes.
|
|
378
|
+
const diff = pending.length === 0 ? { files: [], patches: {} } : await requireDerived(ctx, pr.headSha)
|
|
379
|
+
for (const head of new Set(pending.map(p => p.headSha))) {
|
|
380
|
+
if (head === pr.headSha) continue
|
|
381
|
+
const original = await ctx.derived.read(head)
|
|
382
|
+
if (
|
|
383
|
+
!ctx.projectConfig.config.canvas.keepForIdenticalDiff ||
|
|
384
|
+
original === null ||
|
|
385
|
+
!samePatches(original, diff)
|
|
386
|
+
) {
|
|
387
|
+
throw new AppError(
|
|
388
|
+
'CANVAS_STALE',
|
|
389
|
+
'pending comments were written on a different diff',
|
|
390
|
+
409,
|
|
391
|
+
'copy or delete the earlier-commit drafts in the pending review, then comment on the current code'
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const {
|
|
396
|
+
comments: submittedComments,
|
|
397
|
+
warnings,
|
|
398
|
+
...review
|
|
399
|
+
} = await ctx.config.host.postReview(
|
|
400
|
+
ctx.gh,
|
|
401
|
+
ctx.config.repo,
|
|
402
|
+
number,
|
|
403
|
+
pr.headSha,
|
|
404
|
+
{ event: input.event, body, comments: pending },
|
|
405
|
+
diff
|
|
406
|
+
)
|
|
407
|
+
const next =
|
|
408
|
+
pending.length === 0
|
|
409
|
+
? await ctx.state.read(number)
|
|
410
|
+
: await ctx.state.completePending(number, pending, postedFromPending(pending, submittedComments))
|
|
411
|
+
if (submittedComments.length > 0) {
|
|
412
|
+
await appendComments(
|
|
413
|
+
ctx,
|
|
414
|
+
number,
|
|
415
|
+
submittedComments.map(comment => ({ kind: 'review', comment }))
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
return c.json(
|
|
419
|
+
{ review, submitted: pending.length, state: next, comments: submittedComments, warnings },
|
|
420
|
+
201
|
|
421
|
+
)
|
|
263
422
|
})
|
|
264
423
|
|
|
265
424
|
return api
|
|
@@ -268,8 +427,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
268
427
|
/** One append at a time per PR, so two posts that land together do not overwrite each other. */
|
|
269
428
|
const appendChains = new Map<number, Promise<unknown>>()
|
|
270
429
|
|
|
271
|
-
function
|
|
272
|
-
|
|
430
|
+
function appendComments(
|
|
431
|
+
ctx: AppContext,
|
|
432
|
+
number: number,
|
|
433
|
+
posted: ReadonlyArray<PostCommentResult>
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
const run = () => writeAppendedComments(ctx, number, posted)
|
|
273
436
|
const chained = (appendChains.get(number) ?? Promise.resolve()).then(run, run)
|
|
274
437
|
appendChains.set(
|
|
275
438
|
number,
|
|
@@ -282,30 +445,27 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
|
|
|
282
445
|
* Keeps the cached comments in step with what was just posted, so a reload shows it once. With
|
|
283
446
|
* nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
|
|
284
447
|
*/
|
|
285
|
-
async function
|
|
448
|
+
async function writeAppendedComments(
|
|
286
449
|
ctx: AppContext,
|
|
287
450
|
number: number,
|
|
288
|
-
posted: PostCommentResult
|
|
451
|
+
posted: ReadonlyArray<PostCommentResult>
|
|
289
452
|
): Promise<void> {
|
|
290
453
|
const comments = await ctx.prs.readComments(number)
|
|
291
454
|
if (comments === null) {
|
|
292
455
|
return
|
|
293
456
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
})
|
|
302
|
-
return
|
|
457
|
+
const reviewComments = [...comments.reviewComments]
|
|
458
|
+
const issueComments = [...comments.issueComments]
|
|
459
|
+
for (const entry of posted) {
|
|
460
|
+
const list = entry.kind === 'review' ? reviewComments : issueComments
|
|
461
|
+
if (list.some(c => c.id === entry.comment.id)) continue
|
|
462
|
+
if (entry.kind === 'review') reviewComments.push(entry.comment)
|
|
463
|
+
else issueComments.push(entry.comment)
|
|
303
464
|
}
|
|
304
|
-
if (
|
|
305
|
-
|
|
465
|
+
if (
|
|
466
|
+
reviewComments.length !== comments.reviewComments.length ||
|
|
467
|
+
issueComments.length !== comments.issueComments.length
|
|
468
|
+
) {
|
|
469
|
+
await ctx.prs.writeComments(number, { ...comments, reviewComments, issueComments })
|
|
306
470
|
}
|
|
307
|
-
await ctx.prs.writeComments(number, {
|
|
308
|
-
...comments,
|
|
309
|
-
issueComments: [...comments.issueComments, posted.comment],
|
|
310
|
-
})
|
|
311
471
|
}
|
|
@@ -148,6 +148,9 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
148
148
|
if (artifact.importedAt !== undefined) {
|
|
149
149
|
entry.importedAt = artifact.importedAt
|
|
150
150
|
}
|
|
151
|
+
if (artifact.basisCanvasSha !== undefined) {
|
|
152
|
+
entry.basisCanvasSha = artifact.basisCanvasSha
|
|
153
|
+
}
|
|
151
154
|
if (flags?.worktree === true) {
|
|
152
155
|
entry.worktree = true
|
|
153
156
|
}
|
|
@@ -11,11 +11,15 @@ export const SETTINGS_TEMPLATE = `# Personal pr-review settings. Gitignored: thi
|
|
|
11
11
|
version: 1
|
|
12
12
|
|
|
13
13
|
# The look of the page: terminal or github.
|
|
14
|
-
skin:
|
|
14
|
+
skin: ${DEFAULT_SETTINGS.skin}
|
|
15
15
|
|
|
16
16
|
# Light or dark: auto follows the operating system, light and dark pin one.
|
|
17
17
|
theme: auto
|
|
18
18
|
|
|
19
|
+
# How much code a review hides when it opens: light, moderate, or aggressive. The Hide code
|
|
20
|
+
# control on the page changes it for that page only.
|
|
21
|
+
foldLevel: light
|
|
22
|
+
|
|
19
23
|
# Which agent answers in the AI Chat pane: claude or codex.
|
|
20
24
|
agent: claude
|
|
21
25
|
|
|
@@ -121,6 +125,7 @@ export function applySettings(text: string, input: SettingsInput): { text: strin
|
|
|
121
125
|
doc.set('version', 1)
|
|
122
126
|
doc.set('skin', settings.skin)
|
|
123
127
|
doc.set('theme', settings.theme)
|
|
128
|
+
doc.set('foldLevel', settings.foldLevel)
|
|
124
129
|
doc.set('agent', settings.agent)
|
|
125
130
|
doc.set('model', settings.model)
|
|
126
131
|
doc.set('chatTimeoutSec', settings.chatTimeoutSec)
|
|
@@ -136,6 +141,9 @@ function stripUndefined(input: SettingsInput): Partial<Settings> {
|
|
|
136
141
|
if (input.theme !== undefined) {
|
|
137
142
|
out.theme = input.theme
|
|
138
143
|
}
|
|
144
|
+
if (input.foldLevel !== undefined) {
|
|
145
|
+
out.foldLevel = input.foldLevel
|
|
146
|
+
}
|
|
139
147
|
if (input.agent !== undefined) {
|
|
140
148
|
out.agent = input.agent
|
|
141
149
|
}
|
package/src/store/state-store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { ReviewKey } from '../contract/review-key.js'
|
|
3
3
|
import { emptyState, type PrState, PrStateSchema } from '../contract/state.js'
|
|
4
|
+
import type { AddPendingInput, PendingComment } from '../contract/pending.js'
|
|
4
5
|
import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
|
|
5
6
|
import type { PrStore } from './pr-store.js'
|
|
6
7
|
|
|
@@ -22,12 +23,33 @@ export interface StateStore {
|
|
|
22
23
|
/**
|
|
23
24
|
* Marks one layer or file. `canvasSha` is the commit of the canvas the page was showing: when it
|
|
24
25
|
* differs from the one the marks describe, the old marks are dropped, because they were about
|
|
25
|
-
* other code
|
|
26
|
+
* other code, and `carried` takes their place — the marks the caller worked out may follow that
|
|
27
|
+
* canvas from the one it was generated from.
|
|
26
28
|
*/
|
|
27
|
-
setReviewed(key: ReviewKey, id: string, reviewed: boolean,
|
|
29
|
+
setReviewed(key: ReviewKey, id: string, reviewed: boolean, opts?: SetReviewedOptions): Promise<PrState>
|
|
28
30
|
setDismissed(key: ReviewKey, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
|
|
29
31
|
setThreadHidden(key: ReviewKey, rootCommentId: number, hidden: boolean): Promise<PrState>
|
|
30
32
|
addPosted(key: ReviewKey, entry: PostedEntry): Promise<PrState>
|
|
33
|
+
/** Adds one comment to the pending review and returns the state that holds it. */
|
|
34
|
+
addPending(key: ReviewKey, input: AddPendingInput, headSha: string): Promise<PrState>
|
|
35
|
+
/** Rewrites one draft's body. A draft that is no longer there leaves the state alone. */
|
|
36
|
+
editPending(key: ReviewKey, id: string, body: string): Promise<PrState>
|
|
37
|
+
removePending(key: ReviewKey, id: string): Promise<PrState>
|
|
38
|
+
/** Discards every draft at the reviewer's request. */
|
|
39
|
+
clearPending(key: ReviewKey): Promise<PrState>
|
|
40
|
+
/** Acknowledge submitted versions, preserving drafts added or edited while posting. */
|
|
41
|
+
completePending(
|
|
42
|
+
key: ReviewKey,
|
|
43
|
+
submitted: ReadonlyArray<PendingComment>,
|
|
44
|
+
posted: PostedEntry[]
|
|
45
|
+
): Promise<PrState>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SetReviewedOptions {
|
|
49
|
+
/** The commit of the canvas the page was showing. */
|
|
50
|
+
canvasSha?: string | undefined
|
|
51
|
+
/** Marks that follow `canvasSha` from the canvas it was generated from; used on the first write. */
|
|
52
|
+
carried?: Readonly<Record<string, true>> | undefined
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
/** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
|
|
@@ -43,7 +65,13 @@ function layerOf(id: string): string {
|
|
|
43
65
|
return at === -1 ? id : id.slice(0, at)
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
/** Ids only have to be unique inside one state file; the clock and the counter make them so. */
|
|
69
|
+
function pendingId(now: () => Date, seq: number): string {
|
|
70
|
+
return `p${now().getTime().toString(36)}-${seq.toString(36)}`
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
74
|
+
let pendingSeq = 0
|
|
47
75
|
const file = (key: ReviewKey): string => path.join(prs.prDir(key), 'state.json')
|
|
48
76
|
const read = (key: ReviewKey): Promise<PrState> =>
|
|
49
77
|
readJsonOrDefault(file(key), PrStateSchema, () => emptyState(now().toISOString()))
|
|
@@ -71,13 +99,14 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
|
71
99
|
return {
|
|
72
100
|
read,
|
|
73
101
|
update,
|
|
74
|
-
setReviewed: (key, id, reviewed,
|
|
102
|
+
setReviewed: (key, id, reviewed, opts) =>
|
|
75
103
|
update(key, state => {
|
|
104
|
+
const canvasSha = opts?.canvasSha
|
|
76
105
|
const sameCanvas =
|
|
77
106
|
canvasSha === undefined ||
|
|
78
107
|
state.reviewedCanvasSha === undefined ||
|
|
79
108
|
state.reviewedCanvasSha === canvasSha
|
|
80
|
-
const next = sameCanvas ? { ...state.reviewed } : {}
|
|
109
|
+
const next = sameCanvas ? { ...state.reviewed } : { ...opts?.carried }
|
|
81
110
|
if (reviewed) {
|
|
82
111
|
next[id] = true
|
|
83
112
|
} else {
|
|
@@ -122,5 +151,41 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
|
122
151
|
const posted = { ...entry, at: now().toISOString() }
|
|
123
152
|
return { ...state, posted: [...state.posted, posted] }
|
|
124
153
|
}),
|
|
154
|
+
addPending: (key, input, headSha) => {
|
|
155
|
+
pendingSeq += 1
|
|
156
|
+
const at = now().toISOString()
|
|
157
|
+
const draft: PendingComment = {
|
|
158
|
+
id: pendingId(now, pendingSeq),
|
|
159
|
+
path: input.path,
|
|
160
|
+
line: input.line,
|
|
161
|
+
side: input.side,
|
|
162
|
+
...(input.startLine === undefined ? {} : { startLine: input.startLine }),
|
|
163
|
+
body: input.body,
|
|
164
|
+
...(input.pointFingerprint === undefined ? {} : { pointFingerprint: input.pointFingerprint }),
|
|
165
|
+
headSha,
|
|
166
|
+
createdAt: at,
|
|
167
|
+
updatedAt: at,
|
|
168
|
+
}
|
|
169
|
+
return update(key, state => ({ ...state, pending: [...state.pending, draft] }))
|
|
170
|
+
},
|
|
171
|
+
editPending: (key, id, body) =>
|
|
172
|
+
update(key, state => ({
|
|
173
|
+
...state,
|
|
174
|
+
pending: state.pending.map(p => (p.id === id ? { ...p, body, updatedAt: now().toISOString() } : p)),
|
|
175
|
+
})),
|
|
176
|
+
removePending: (key, id) =>
|
|
177
|
+
update(key, state => ({ ...state, pending: state.pending.filter(p => p.id !== id) })),
|
|
178
|
+
clearPending: key => update(key, state => ({ ...state, pending: [] })),
|
|
179
|
+
completePending: (key, submitted, posted) =>
|
|
180
|
+
update(key, state => ({
|
|
181
|
+
...state,
|
|
182
|
+
pending: state.pending.filter(p => !submitted.some(s => s.id === p.id && s.body === p.body)),
|
|
183
|
+
posted: [
|
|
184
|
+
...state.posted,
|
|
185
|
+
...posted
|
|
186
|
+
.filter(p => !state.posted.some(s => s.commentId === p.commentId))
|
|
187
|
+
.map(p => ({ ...p, at: now().toISOString() })),
|
|
188
|
+
],
|
|
189
|
+
})),
|
|
125
190
|
}
|
|
126
191
|
}
|