@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.
Files changed (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. 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: stateForCanvas(stored, found.headSha) }
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
- return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, canvasSha)))
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
- const derived = await ctx.derived.read(pr.headSha)
201
- if (derived === null) {
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 appendComment(ctx, number, posted)
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
- const review = await ctx.config.host.postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
259
- event: input.event,
260
- body,
261
- })
262
- return c.json({ review }, 201)
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 appendComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
272
- const run = () => writeAppendedComment(ctx, number, posted)
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 writeAppendedComment(
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
- if (posted.kind === 'review') {
295
- if (comments.reviewComments.some(c => c.id === posted.comment.id)) {
296
- return
297
- }
298
- await ctx.prs.writeComments(number, {
299
- ...comments,
300
- reviewComments: [...comments.reviewComments, posted.comment],
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 (comments.issueComments.some(c => c.id === posted.comment.id)) {
305
- return
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: terminal
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
  }
@@ -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, canvasSha?: string): Promise<PrState>
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, canvasSha) =>
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
  }