@vintasoftware/pr-review-canvas 0.3.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 (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -3,15 +3,22 @@ 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
- import { checkInlineTarget, postComment } from '../../github/post-comment.js'
8
- import { PostReviewInputSchema, postReview } from '../../github/post-review.js'
9
- import { buildReviewBody, stateForHead, unreviewedLayers } from '../../github/review-body.js'
8
+ import type { PrState } from '../../contract/state.js'
9
+ import { checkInlineTarget } from '../../git/patch-lines.js'
10
+ import { PostReviewInputSchema } from '../../contract/reviews.js'
11
+ import { lookupCanvas, samePatches } from '../../review/carry-over.js'
12
+ import { marksForCanvas } from '../../review/carry-marks.js'
13
+ import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
14
+ import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
15
+ import { canvasBelongsTo } from '../../store/canvas-store.js'
10
16
  import { isReviewedId } from '../../store/state-store.js'
11
- import type { PrLoader } from '../bundle.js'
17
+ import type { Derived } from '../../store/derived-store.js'
18
+ import { LOCAL_CAPABILITIES, type PrLoader } from '../bundle.js'
12
19
  import type { AppContext } from '../context.js'
13
20
  import { AppError } from '../errors.js'
14
- import { parsePrNumber } from './api.js'
21
+ import { parseTargetKey, requirePrNumber } from './api.js'
15
22
 
16
23
  const ReviewedBodySchema = z.object({
17
24
  reviewed: z.boolean(),
@@ -20,6 +27,14 @@ const ReviewedBodySchema = z.object({
20
27
  .string()
21
28
  .regex(/^[0-9a-f]{40}$/)
22
29
  .optional(),
30
+ /**
31
+ * The commit of the canvas on the page, which the marks are keyed to. The head when absent. Any
32
+ * other value must be a canvas indexed for this pull request.
33
+ */
34
+ canvasSha: z
35
+ .string()
36
+ .regex(/^[0-9a-f]{40}$/)
37
+ .optional(),
23
38
  })
24
39
  const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
25
40
  const HiddenBodySchema = z.object({ hidden: z.boolean() })
@@ -54,12 +69,34 @@ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: str
54
69
  return parsed.data
55
70
  }
56
71
 
57
- /** The canvas the reviewer is signing off on: the one written for the pull request's head. */
58
- async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
72
+ /** Refuses a commit that is not an indexed canvas of this target. */
73
+ async function requireCanvasOf(ctx: AppContext, key: ReviewKey, canvasSha: string): Promise<void> {
74
+ const entry = (await ctx.canvases.readIndex()).canvases[canvasSha]
75
+ // The same rule the lookups use, so a mark can only be keyed to a canvas this target shows.
76
+ if (entry === undefined || !canvasBelongsTo(entry, key)) {
77
+ throw new AppError(
78
+ 'CANVAS_NOT_FOUND',
79
+ `${canvasSha.slice(0, 7)} is not a canvas of ${keyLabel(key)}`,
80
+ 404,
81
+ 'reload the page'
82
+ )
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The canvas the reviewer is signing off on, with the marks made on it: the canvas written for the
88
+ * pull request's head, or for another commit with an identical diff.
89
+ */
90
+ async function canvasForSignOff(
91
+ ctx: AppContext,
92
+ number: number,
93
+ pr: Pr
94
+ ): Promise<{ artifact: ReviewArtifact; state: PrState }> {
95
+ const stored = await ctx.state.read(number)
59
96
  if (ctx.fixtureArtifact !== null) {
60
- return { ...ctx.fixtureArtifact, pr }
97
+ return { artifact: { ...ctx.fixtureArtifact, pr }, state: stateForCanvas(stored, pr.headSha) }
61
98
  }
62
- const found = await ctx.canvases.findForPr(number, pr.headSha)
99
+ const found = await lookupCanvas(ctx, number, pr)
63
100
  if (found.status !== 'ready') {
64
101
  throw new AppError(
65
102
  'SIGNOFF_INCOMPLETE',
@@ -72,7 +109,93 @@ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise
72
109
  if (artifact === null) {
73
110
  throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
74
111
  }
75
- return artifact
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
76
199
  }
77
200
 
78
201
  export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
@@ -84,25 +207,25 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
84
207
  if (caps.canComment === false) {
85
208
  throw new AppError(
86
209
  'COMMENT_FORBIDDEN',
87
- caps.reason ?? 'this GitHub login cannot post on this repository',
210
+ caps.reason ?? `this ${ctx.config.host.label} login cannot post on this repository`,
88
211
  403,
89
212
  caps.hint
90
213
  )
91
214
  }
92
215
  }
93
216
 
94
- const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({
95
- prNumber: number,
217
+ const stateBody = (key: ReviewKey, state: StateResponse['state']): StateResponse => ({
218
+ prNumber: key,
96
219
  state,
97
220
  })
98
221
 
99
222
  api.get('/prs/:n/state', async c => {
100
- const number = parsePrNumber(c.req.param('n'))
101
- return c.json(stateBody(number, await ctx.state.read(number)))
223
+ const key = parseTargetKey(c.req.param('n'))
224
+ return c.json(stateBody(key, await ctx.state.read(key)))
102
225
  })
103
226
 
104
227
  api.put('/prs/:n/reviewed/:id{.+}', async c => {
105
- const number = parsePrNumber(c.req.param('n'))
228
+ const key = parseTargetKey(c.req.param('n'))
106
229
  const id = c.req.param('id')
107
230
  if (!isReviewedId(id)) {
108
231
  throw new AppError(
@@ -113,21 +236,34 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
113
236
  )
114
237
  }
115
238
  const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
116
- const pr = await loader.currentPr(number)
117
- requireSameHead(body.headSha, pr.headSha)
118
- return c.json(stateBody(number, await ctx.state.setReviewed(number, id, body.reviewed, pr.headSha)))
239
+ // A mark is made against the canvas on screen, which for a local review is the snapshot the
240
+ // page was drawn from, not whatever the working tree holds a keystroke later.
241
+ const pr = await loader.currentTarget(key)
242
+ if (!isLocalKey(key)) {
243
+ requireSameHead(body.headSha, pr.headSha)
244
+ }
245
+ // The page sends back the canvas commit the bundle keyed its marks to, so a toggle costs no
246
+ // git work; the index, not git, says the commit is a canvas of this target.
247
+ const canvasSha = body.canvasSha ?? pr.headSha
248
+ if (canvasSha !== pr.headSha) {
249
+ await requireCanvasOf(ctx, key, canvasSha)
250
+ }
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 })))
119
255
  })
120
256
 
121
257
  api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
122
- const number = parsePrNumber(c.req.param('n'))
258
+ const key = parseTargetKey(c.req.param('n'))
123
259
  const fingerprint = c.req.param('fingerprint')
124
260
  const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
125
- const next = await ctx.state.setDismissed(number, fingerprint, body.dismissed, body.reason)
126
- return c.json(stateBody(number, next))
261
+ const next = await ctx.state.setDismissed(key, fingerprint, body.dismissed, body.reason)
262
+ return c.json(stateBody(key, next))
127
263
  })
128
264
 
129
265
  api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
130
- const number = parsePrNumber(c.req.param('n'))
266
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'hiding a comment thread')
131
267
  const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
132
268
  if (!rootId.success) {
133
269
  throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
@@ -137,33 +273,26 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
137
273
  })
138
274
 
139
275
  api.get('/prs/:n/capabilities', async c => {
140
- parsePrNumber(c.req.param('n'))
276
+ const key = parseTargetKey(c.req.param('n'))
277
+ if (isLocalKey(key)) {
278
+ return c.json(LOCAL_CAPABILITIES)
279
+ }
141
280
  return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
142
281
  })
143
282
 
144
283
  api.post('/prs/:n/comments', async c => {
145
- const number = parsePrNumber(c.req.param('n'))
284
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'posting a comment')
146
285
  const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
147
286
  await requirePosting()
148
287
  const pr = await loader.currentPr(number)
149
288
  requireSameHead(input.headSha, pr.headSha)
289
+ let diff: Derived = { files: [], patches: {} }
150
290
  if (input.kind === 'inline') {
151
- const derived = await ctx.derived.read(pr.headSha)
152
- if (derived === null) {
153
- throw new AppError(
154
- 'NOT_FOUND',
155
- 'the diff of this head is not available locally, so the line cannot be checked',
156
- 404,
157
- 'fetch the PR head and reload'
158
- )
159
- }
160
- const problem = checkInlineTarget(derived.files, input)
161
- if (problem !== null) {
162
- throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
163
- }
291
+ diff = await requireDerived(ctx, pr.headSha)
292
+ requireInlineTarget(diff, input)
164
293
  }
165
- const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
166
- await appendComment(ctx, number, posted)
294
+ const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
295
+ await appendComments(ctx, number, [posted])
167
296
  const entry =
168
297
  input.kind === 'inline' && input.pointFingerprint !== undefined
169
298
  ? { commentId: posted.comment.id, pointFingerprint: input.pointFingerprint }
@@ -172,28 +301,63 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
172
301
  return c.json({ ...posted, state }, 201)
173
302
  })
174
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
+
175
340
  api.get('/prs/:n/review/body', async c => {
176
- const number = parsePrNumber(c.req.param('n'))
341
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
177
342
  const pr = await loader.currentPr(number)
178
- const artifact = await artifactForHead(ctx, number, pr)
179
- const state = stateForHead(await ctx.state.read(number), pr.headSha)
343
+ const { artifact, state } = await canvasForSignOff(ctx, number, pr)
180
344
  const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
181
345
  const body: ReviewBodyResponse = {
182
346
  headSha: pr.headSha,
183
347
  body: buildReviewBody({ artifact, state, comments, headSha: pr.headSha }),
184
348
  unreviewed: unreviewedLayers(artifact, state).map(l => l.title),
349
+ pending: state.pending.length,
185
350
  }
186
351
  return c.json(body)
187
352
  })
188
353
 
189
354
  api.post('/prs/:n/review', async c => {
190
- const number = parsePrNumber(c.req.param('n'))
355
+ const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'submitting a review')
191
356
  const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
192
357
  await requirePosting()
193
358
  const pr = await loader.currentPr(number)
194
359
  requireSameHead(input.headSha, pr.headSha)
195
- const artifact = await artifactForHead(ctx, number, pr)
196
- const state = stateForHead(await ctx.state.read(number), pr.headSha)
360
+ const { artifact, state } = await canvasForSignOff(ctx, number, pr)
197
361
  if (input.event === 'APPROVE') {
198
362
  const missing = unreviewedLayers(artifact, state)
199
363
  if (missing.length > 0) {
@@ -207,8 +371,54 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
207
371
  }
208
372
  const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
209
373
  const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
210
- const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, { event: input.event, body })
211
- 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
+ )
212
422
  })
213
423
 
214
424
  return api
@@ -217,8 +427,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
217
427
  /** One append at a time per PR, so two posts that land together do not overwrite each other. */
218
428
  const appendChains = new Map<number, Promise<unknown>>()
219
429
 
220
- function appendComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
221
- 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)
222
436
  const chained = (appendChains.get(number) ?? Promise.resolve()).then(run, run)
223
437
  appendChains.set(
224
438
  number,
@@ -231,30 +445,27 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
231
445
  * Keeps the cached comments in step with what was just posted, so a reload shows it once. With
232
446
  * nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
233
447
  */
234
- async function writeAppendedComment(
448
+ async function writeAppendedComments(
235
449
  ctx: AppContext,
236
450
  number: number,
237
- posted: PostCommentResult
451
+ posted: ReadonlyArray<PostCommentResult>
238
452
  ): Promise<void> {
239
453
  const comments = await ctx.prs.readComments(number)
240
454
  if (comments === null) {
241
455
  return
242
456
  }
243
- if (posted.kind === 'review') {
244
- if (comments.reviewComments.some(c => c.id === posted.comment.id)) {
245
- return
246
- }
247
- await ctx.prs.writeComments(number, {
248
- ...comments,
249
- reviewComments: [...comments.reviewComments, posted.comment],
250
- })
251
- 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)
252
464
  }
253
- if (comments.issueComments.some(c => c.id === posted.comment.id)) {
254
- 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 })
255
470
  }
256
- await ctx.prs.writeComments(number, {
257
- ...comments,
258
- issueComments: [...comments.issueComments, posted.comment],
259
- })
260
471
  }