@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
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
2
3
 
3
4
  /** Character caps applied to model output. Numbers, so the prompt can print them. */
4
5
  export const TEXT_CAPS = {
@@ -79,6 +80,33 @@ export const HARNESSES = ['claude-code', 'codex', 'other'] as const
79
80
  export const SideSchema = z.enum(['new', 'old'])
80
81
  export type Side = z.infer<typeof SideSchema>
81
82
 
83
+ export {
84
+ contains,
85
+ coveredRows,
86
+ fileRows,
87
+ foldLevelRank,
88
+ hiddenLines,
89
+ UNDISCUSSED,
90
+ } from '../../static/js/fold-levels.js'
91
+ export { FOLD_LEVELS }
92
+ export const FoldLevelSchema = z.enum(FOLD_LEVELS)
93
+ export type FoldLevel = z.infer<typeof FoldLevelSchema>
94
+
95
+ /**
96
+ * How a stored canvas names its levels. One written before levels existed says `collapsed: true`
97
+ * and gives its folds no level; both read as light, so it hides exactly what it hid before. Past
98
+ * this parse every canvas names a level wherever it hides something.
99
+ */
100
+ const STORED_LEVELS = {
101
+ collapsed: z
102
+ .union([z.boolean(), FoldLevelSchema])
103
+ .transform(value => (value === true ? DEFAULT_FOLD_LEVEL : value === false ? undefined : value)),
104
+ fold: FoldLevelSchema.default(DEFAULT_FOLD_LEVEL),
105
+ }
106
+ /** The model names a level for every fold and every collapsed file. */
107
+ const MODEL_LEVELS = { collapsed: FoldLevelSchema, fold: FoldLevelSchema }
108
+ type Levels = typeof STORED_LEVELS | typeof MODEL_LEVELS
109
+
82
110
  export const HunkSchema = z.object({
83
111
  id: z.string().min(1),
84
112
  header: z.string(),
@@ -199,29 +227,35 @@ export function annotationSchema(caps: Caps) {
199
227
  export const AnnotationSchema = annotationSchema(ARTIFACT_HARD_CAPS)
200
228
  export type Annotation = z.infer<typeof AnnotationSchema>
201
229
 
202
- function codeFoldSchema(caps: Caps) {
230
+ function codeFoldSchema(caps: Caps, level: Levels['fold']) {
203
231
  return z.object({
204
232
  title: z.string().max(caps.pointTitle).regex(/\S/, 'A fold title must contain visible text'),
205
233
  side: SideSchema,
206
234
  startLine: z.number().int().positive(),
207
235
  endLine: z.number().int().positive(),
236
+ /** The lowest reading level that hides this range. */
237
+ level,
208
238
  })
209
239
  }
210
- export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS)
240
+ export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS, STORED_LEVELS.fold)
211
241
  export type CodeFold = z.infer<typeof CodeFoldSchema>
212
242
 
213
- function layerFileBase(caps: Caps, foldTitleCap = caps.pointTitle) {
243
+ function layerFileBase<L extends Levels>(caps: Caps, foldTitleCap: number, levels: L) {
214
244
  return {
215
245
  path: z.string().min(1),
216
246
  hunks: z.array(z.string().min(1)).min(1),
217
247
  note: textOrEmpty(caps, 'annotation').optional(),
218
248
  annotations: z.array(annotationSchema(caps)),
219
- collapsed: z.boolean().optional(),
220
- folds: z.array(codeFoldSchema({ ...caps, pointTitle: foldTitleCap })).optional(),
249
+ /** The lowest reading level that hides the whole file body. */
250
+ collapsed: levels.collapsed.optional(),
251
+ folds: z.array(codeFoldSchema({ ...caps, pointTitle: foldTitleCap }, levels.fold)).optional(),
221
252
  }
222
253
  }
223
254
 
224
- export const LayerFileSchema = z.object({ ...layerFileBase(ARTIFACT_HARD_CAPS), isTest: z.boolean() })
255
+ export const LayerFileSchema = z.object({
256
+ ...layerFileBase(ARTIFACT_HARD_CAPS, ARTIFACT_HARD_CAPS.pointTitle, STORED_LEVELS),
257
+ isTest: z.boolean(),
258
+ })
225
259
  export type LayerFile = z.infer<typeof LayerFileSchema>
226
260
 
227
261
  export const LayerKeySchema = z
@@ -298,6 +332,15 @@ export const ReviewArtifactSchema = z.object({
298
332
  generator: GeneratorSchema,
299
333
  source: z.enum(['local', 'import']),
300
334
  importedAt: z.string().optional(),
335
+ /**
336
+ * The basis canvas this one was generated from, when the run was incremental. It records where
337
+ * the carried content came from; a reviewer's server recomputes for itself which marks may
338
+ * follow, so this sha is a pointer and never a claim about what was reviewed.
339
+ */
340
+ basisCanvasSha: z
341
+ .string()
342
+ .regex(/^[0-9a-f]{40}$/)
343
+ .optional(),
301
344
  })
302
345
  export type ReviewArtifact = z.infer<typeof ReviewArtifactSchema>
303
346
 
@@ -317,7 +360,7 @@ export function modelOutputSchema(textCaps: TextCaps) {
317
360
  z.object({
318
361
  ...layerBase(caps),
319
362
  risk: z.array(z.object({ label: z.string().min(1), reason: z.string().min(1) })).optional(),
320
- files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle))),
363
+ files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle, MODEL_LEVELS))),
321
364
  })
322
365
  )
323
366
  .min(1),
@@ -1,6 +1,10 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
3
+ /**
4
+ * `COMMENT` leaves a review with no verdict, the way a thread-level review does: the body and the
5
+ * comments land, and nothing is approved or rejected.
6
+ */
7
+ export const REVIEW_EVENTS = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'] as const
4
8
  export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
5
9
  export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
6
10
 
@@ -8,6 +12,11 @@ export const PostReviewInputSchema = z.object({
8
12
  event: ReviewEventSchema,
9
13
  /** The dialog sends the body the user read, edited or not. */
10
14
  body: z.string().min(1).max(65536).optional(),
15
+ /**
16
+ * Whether the pending comments go out with this review. True unless the page says otherwise, so
17
+ * a reviewer who wrote drafts never submits a review that silently leaves them behind.
18
+ */
19
+ includePending: z.boolean().default(true),
11
20
  /** The commit the dialog named. The server refuses the review when the head moved on. */
12
21
  headSha: z
13
22
  .string()
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
2
3
  import { DEFAULT_SKIN, isSkin, SKINS, type Skin } from '../../static/js/skin.js'
3
4
  import { DEFAULT_THEME, isTheme, THEMES, type Theme } from '../../static/js/theme.js'
4
5
 
@@ -24,6 +25,8 @@ export const SettingsSchema = z.object({
24
25
  version: z.literal(1),
25
26
  skin: z.enum(SKINS),
26
27
  theme: z.enum(THEMES),
28
+ /** The reading level every review opens at; the control on the page changes it for one page. */
29
+ foldLevel: z.enum(FOLD_LEVELS),
27
30
  agent: z.enum(CHAT_AGENTS),
28
31
  model: z.string().min(1).nullable(),
29
32
  chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC),
@@ -35,6 +38,7 @@ export const DEFAULT_SETTINGS: Settings = {
35
38
  version: 1,
36
39
  skin: DEFAULT_SKIN,
37
40
  theme: DEFAULT_THEME,
41
+ foldLevel: DEFAULT_FOLD_LEVEL,
38
42
  agent: 'claude',
39
43
  model: null,
40
44
  chatTimeoutSec: 600,
@@ -45,6 +49,7 @@ export const DEFAULT_SETTINGS: Settings = {
45
49
  export const SettingsInputSchema = z.object({
46
50
  skin: z.enum(SKINS).optional(),
47
51
  theme: z.enum(THEMES).optional(),
52
+ foldLevel: z.enum(FOLD_LEVELS).optional(),
48
53
  agent: z.enum(CHAT_AGENTS).optional(),
49
54
  model: z.string().max(200).nullable().optional(),
50
55
  chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC).optional(),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { PendingCommentSchema } from './pending.js'
2
3
 
3
4
  export const ChatThreadSchema = z.object({
4
5
  name: z.string(),
@@ -12,25 +13,27 @@ export const ChatThreadSchema = z.object({
12
13
  export type ChatThread = z.infer<typeof ChatThreadSchema>
13
14
 
14
15
  /**
15
- * State files written before the marks were keyed to the canvas carry `reviewedHeadSha`, which
16
- * held the same commit under its older name. The old key is read when the new one is absent, so
17
- * a reviewer's marks survive the upgrade; the next write stores the new name.
16
+ * Version 1 keyed a reviewed mark by the layer's position in the canvas (`layer:layer-2`). Marks
17
+ * are keyed by the layer's own key now, so a regenerated canvas that reorders its layers keeps
18
+ * them straight, and the old positional marks cannot be translated without the canvas they were
19
+ * made on. They are dropped; the rest of the file (dismissals, posted comments, chat threads)
20
+ * carries over untouched.
18
21
  */
19
- const withRenamedMarksKey = (raw: unknown): unknown => {
22
+ const dropPositionalMarks = (raw: unknown): unknown => {
20
23
  if (raw === null || typeof raw !== 'object') {
21
24
  return raw
22
25
  }
23
- const { reviewedHeadSha, ...rest } = raw as Record<string, unknown>
24
- if (reviewedHeadSha === undefined || 'reviewedCanvasSha' in rest) {
25
- return raw
26
+ const { reviewedHeadSha: _reviewedHeadSha, ...rest } = raw as Record<string, unknown>
27
+ if (rest['version'] !== 1) {
28
+ return rest
26
29
  }
27
- return { ...rest, reviewedCanvasSha: reviewedHeadSha }
30
+ return { ...rest, version: 2, reviewed: {}, reviewedCanvasSha: undefined }
28
31
  }
29
32
 
30
33
  export const PrStateSchema = z.preprocess(
31
- withRenamedMarksKey,
34
+ dropPositionalMarks,
32
35
  z.object({
33
- version: z.literal(1),
36
+ version: z.literal(2),
34
37
  /**
35
38
  * Counts the writes to this file. The page uses it to tell a newer answer from an older one
36
39
  * when two of its requests overlap. Absent in state files of older tool versions.
@@ -40,7 +43,8 @@ export const PrStateSchema = z.preprocess(
40
43
  /**
41
44
  * The commit of the canvas the reviewed marks were made on: the head, or the commit a carried-over
42
45
  * canvas was generated for. A canvas for another commit describes other code, so the marks start
43
- * again with it. Absent in state files of older tool versions.
46
+ * again with it, unless that canvas names this one as its basis and the code behind a mark is
47
+ * untouched. Absent in state files of older tool versions.
44
48
  */
45
49
  reviewedCanvasSha: z.string().optional(),
46
50
  hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
@@ -48,6 +52,12 @@ export const PrStateSchema = z.preprocess(
48
52
  z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
49
53
  ),
50
54
  dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
55
+ /**
56
+ * The comments of the review being written, in the order they were added. They live here and
57
+ * nowhere else until the review is submitted, so a reload does not lose a draft. A state file
58
+ * of an older tool version has none, which reads as an empty list.
59
+ */
60
+ pending: z.array(PendingCommentSchema).default([]),
51
61
  chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
52
62
  updatedAt: z.string(),
53
63
  })
@@ -56,12 +66,13 @@ export type PrState = z.infer<typeof PrStateSchema>
56
66
 
57
67
  export function emptyState(updatedAt: string): PrState {
58
68
  return {
59
- version: 1,
69
+ version: 2,
60
70
  rev: 0,
61
71
  reviewed: {},
62
72
  hiddenThreads: {},
63
73
  posted: [],
64
74
  dismissed: {},
75
+ pending: [],
65
76
  chat: { threads: [] },
66
77
  updatedAt,
67
78
  }
@@ -15,6 +15,7 @@ export const VALIDATION_CODES = [
15
15
  'RISK_IN_OTHER',
16
16
  'ANNOTATION_OUTSIDE_HUNK',
17
17
  'FOLD_INVALID',
18
+ 'FOLD_MISSING',
18
19
  'POINT_OUTSIDE_DIFF',
19
20
  'TOO_MANY_POINTS',
20
21
  'TEST_PATH_UNKNOWN',
@@ -1,8 +1,11 @@
1
1
  import { z } from 'zod'
2
+ import type { PendingComment } from '../contract/pending.js'
2
3
  import type { ReviewEvent } from '../contract/reviews.js'
3
- import type { ReviewSummary } from '../contract/api.js'
4
+ import type { PostedReview } from '../host/host.js'
4
5
  import type { Repo } from '../contract/review-artifact.js'
5
- import type { HostClient } from '../host/client.js'
6
+ import { fetchAllPages, type HostClient } from '../host/client.js'
7
+ import { mapReviewComment } from './comments.js'
8
+ import { ghSide } from './post-comment.js'
6
9
 
7
10
  const GhReviewSchema = z.object({
8
11
  id: z.number().int(),
@@ -11,19 +14,72 @@ const GhReviewSchema = z.object({
11
14
  submitted_at: z.string().nullable().optional(),
12
15
  })
13
16
 
14
- /** Posts the review. GitHub decides what the event means; the tool only fills the body and sha. */
17
+ /** One entry of the `comments` array GitHub reads when a review is created with its comments. */
18
+ interface GhReviewComment {
19
+ path: string
20
+ body: string
21
+ line: number
22
+ side: 'LEFT' | 'RIGHT'
23
+ start_line?: number
24
+ start_side?: 'LEFT' | 'RIGHT'
25
+ }
26
+
27
+ /**
28
+ * The pending comments as GitHub's review payload names them. A range is sent only when it covers
29
+ * more than the anchor line, because GitHub rejects a start_line equal to line.
30
+ */
31
+ export function reviewComments(pending: ReadonlyArray<PendingComment>): GhReviewComment[] {
32
+ return pending.map(p => ({
33
+ path: p.path,
34
+ body: p.body,
35
+ line: p.line,
36
+ side: ghSide(p.side),
37
+ ...(p.startLine !== undefined && p.startLine !== p.line
38
+ ? { start_line: p.startLine, start_side: ghSide(p.side) }
39
+ : {}),
40
+ }))
41
+ }
42
+
43
+ /**
44
+ * Posts the review. GitHub decides what the event means; the tool only fills the body, the sha,
45
+ * and the comments the reviewer had waiting, which land as part of the same review.
46
+ */
15
47
  export async function postReview(
16
48
  gh: HostClient,
17
49
  repo: Repo,
18
50
  number: number,
19
51
  headSha: string,
20
- input: { event: ReviewEvent; body: string }
21
- ): Promise<ReviewSummary> {
52
+ input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> }
53
+ ): Promise<PostedReview> {
54
+ const comments = input.comments ?? []
22
55
  const raw = await gh.post(`repos/${repo.owner}/${repo.name}/pulls/${number}/reviews`, {
23
56
  event: input.event,
24
57
  body: input.body,
25
58
  commit_id: headSha,
59
+ ...(comments.length === 0 ? {} : { comments: reviewComments(comments) }),
26
60
  })
27
61
  const r = GhReviewSchema.parse(raw)
28
- return { id: r.id, state: r.state, url: r.html_url, submittedAt: r.submitted_at ?? null }
62
+ const posted: PostedReview = {
63
+ id: r.id,
64
+ state: r.state,
65
+ url: r.html_url,
66
+ submittedAt: r.submitted_at ?? null,
67
+ comments: [],
68
+ warnings: [],
69
+ }
70
+ if (comments.length > 0) {
71
+ try {
72
+ const rawComments = await fetchAllPages(
73
+ gh,
74
+ `repos/${repo.owner}/${repo.name}/pulls/${number}/reviews/${r.id}/comments`
75
+ )
76
+ posted.comments = rawComments.map(c => mapReviewComment(c, new Set()))
77
+ } catch (err) {
78
+ // The write succeeded. A failed read must not invite submitting the same review again.
79
+ posted.warnings.push(
80
+ `Review posted, but its comments could not be loaded: ${err instanceof Error ? err.message : String(err)}. Refresh to see them.`
81
+ )
82
+ }
83
+ }
84
+ return posted
29
85
  }
@@ -1,8 +1,11 @@
1
1
  import { z } from 'zod'
2
- import type { ReviewSummary } from '../contract/api.js'
2
+ import type { PostedReview } from '../host/host.js'
3
+ import type { PendingComment } from '../contract/pending.js'
3
4
  import type { Repo } from '../contract/review-artifact.js'
4
5
  import type { ReviewEvent } from '../contract/reviews.js'
5
6
  import type { HostClient } from '../host/client.js'
7
+ import type { Derived } from '../store/derived-store.js'
8
+ import { publishGitlabDrafts } from './publish-drafts.js'
6
9
  import { gitlabMrUrl, gitlabNoteUrl, gitlabProjectApi } from './project.js'
7
10
 
8
11
  const GlNoteSchema = z.object({
@@ -17,37 +20,73 @@ const GlMrSchema = z.object({
17
20
  })
18
21
 
19
22
  /**
20
- * GitLab has no GitHub-style review event. Approve calls the approvals API; request-changes posts
21
- * the review body as an MR note so the text still lands on the merge request.
23
+ * GitLab has no GitHub-style review event. Approve calls the approvals API; request-changes and a
24
+ * comment-only review post the review body as an MR note so the text still lands on the merge
25
+ * request. Pending comments and the summary publish together through the Draft Notes API.
22
26
  */
23
27
  export async function postGitlabReview(
24
28
  client: HostClient,
25
29
  repo: Repo,
26
30
  number: number,
27
31
  headSha: string,
28
- input: { event: ReviewEvent; body: string },
29
- webBase: string
30
- ): Promise<ReviewSummary> {
32
+ input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
33
+ webBase: string,
34
+ diff: Derived = { files: [], patches: {} }
35
+ ): Promise<PostedReview> {
31
36
  const base = `${gitlabProjectApi(repo)}/merge_requests/${number}`
32
37
  const mrUrl = gitlabMrUrl(webBase, repo, number)
38
+ const batched = (input.comments?.length ?? 0) > 0
39
+ const { comments, warnings } = batched
40
+ ? await publishGitlabDrafts(client, repo, number, headSha, input, webBase, diff)
41
+ : { comments: [], warnings: [] }
33
42
  if (input.event === 'APPROVE') {
34
- if (input.body.trim() !== '') {
43
+ if (!batched && input.body.trim() !== '') {
35
44
  await client.post(`${base}/notes`, { body: input.body })
36
45
  }
37
- const raw = await client.post(`${base}/approve`, { sha: headSha })
46
+ let raw: unknown
47
+ try {
48
+ raw = await client.post(`${base}/approve`, { sha: headSha })
49
+ } catch (error) {
50
+ if (!batched) throw error
51
+ return {
52
+ comments,
53
+ warnings: [
54
+ ...warnings,
55
+ 'Comments published, but approval failed. Approve the merge request in GitLab.',
56
+ ],
57
+ id: number,
58
+ state: 'COMMENTED',
59
+ url: mrUrl,
60
+ submittedAt: new Date().toISOString(),
61
+ }
62
+ }
38
63
  const mr = GlMrSchema.safeParse(raw).data ?? {}
39
64
  return {
65
+ comments,
66
+ warnings,
40
67
  id: mr.id ?? mr.iid ?? number,
41
68
  state: 'APPROVED',
42
69
  url: mr.web_url ?? mrUrl,
43
70
  submittedAt: new Date().toISOString(),
44
71
  }
45
72
  }
73
+ if (batched) {
74
+ return {
75
+ comments,
76
+ warnings,
77
+ id: number,
78
+ state: input.event === 'COMMENT' ? 'COMMENTED' : 'CHANGES_REQUESTED',
79
+ url: mrUrl,
80
+ submittedAt: new Date().toISOString(),
81
+ }
82
+ }
46
83
  const raw = await client.post(`${base}/notes`, { body: input.body })
47
84
  const note = GlNoteSchema.parse(raw)
48
85
  return {
86
+ comments,
87
+ warnings: [],
49
88
  id: note.id,
50
- state: 'CHANGES_REQUESTED',
89
+ state: input.event === 'COMMENT' ? 'COMMENTED' : 'CHANGES_REQUESTED',
51
90
  url: gitlabNoteUrl(webBase, repo, number, note.id),
52
91
  submittedAt: note.created_at ?? new Date().toISOString(),
53
92
  }
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod'
2
+ import type { PendingComment } from '../contract/pending.js'
3
+ import type { Repo } from '../contract/review-artifact.js'
4
+ import type { HostClient } from '../host/client.js'
5
+ import type { Derived } from '../store/derived-store.js'
6
+ import { fetchGitlabComments } from './comments.js'
7
+ import { fetchMrDiffRefs } from './mr.js'
8
+ import { inlinePosition } from './post-comment.js'
9
+ import { gitlabProjectApi } from './project.js'
10
+
11
+ const DraftSchema = z.object({ id: z.number().int() })
12
+
13
+ /** Stage this submission privately, then publish it as one GitLab review. */
14
+ export async function publishGitlabDrafts(
15
+ client: HostClient,
16
+ repo: Repo,
17
+ number: number,
18
+ headSha: string,
19
+ input: { body: string; comments?: ReadonlyArray<PendingComment> },
20
+ webBase: string,
21
+ diff: Derived
22
+ ) {
23
+ const base = `${gitlabProjectApi(repo)}/merge_requests/${number}/draft_notes`
24
+ const list = async () => z.array(DraftSchema).parse(await client.api(base))
25
+ if ((await list()).length > 0) {
26
+ throw new Error('Finish or discard your pending review in GitLab before submitting from the canvas.')
27
+ }
28
+ const refs = await fetchMrDiffRefs(client, repo, number)
29
+ if (refs.head_sha !== headSha) throw new Error('The merge request changed. Refresh before submitting.')
30
+ const notes: Array<{ note: string; position?: Record<string, unknown> }> = (input.comments ?? []).map(
31
+ p => ({
32
+ note: p.body,
33
+ position: inlinePosition(refs, { ...p, kind: 'inline' }, diff),
34
+ })
35
+ )
36
+ if (input.body.trim() !== '') notes.push({ note: input.body })
37
+ const fetchComments = () => fetchGitlabComments(client, repo, number, headSha, () => new Date(), webBase)
38
+ const before = new Set((await fetchComments()).payload.reviewComments.map(c => c.id))
39
+ const staged: number[] = []
40
+ try {
41
+ for (const note of notes) staged.push(DraftSchema.parse(await client.post(base, note)).id)
42
+ const current = await list()
43
+ if (current.length !== staged.length || current.some(d => !staged.includes(d.id))) {
44
+ throw new Error(
45
+ 'Your pending review changed in GitLab. Finish it there before submitting from the canvas.'
46
+ )
47
+ }
48
+ await client.post(`${base}/bulk_publish`, {})
49
+ } catch (error) {
50
+ // Delete only drafts created by this attempt; local copies remain available for retry.
51
+ const cleanup = await Promise.allSettled(staged.map(id => client.post(`${base}/${id}`, {}, 'DELETE')))
52
+ if (cleanup.some(result => result.status === 'rejected')) {
53
+ throw new Error(
54
+ 'Submission failed and some staged drafts remain in GitLab. Inspect your pending review there before retrying.',
55
+ { cause: error }
56
+ )
57
+ }
58
+ throw error
59
+ }
60
+ try {
61
+ const { payload } = await fetchComments()
62
+ return { comments: payload.reviewComments.filter(c => !before.has(c.id)), warnings: [] as string[] }
63
+ } catch {
64
+ return {
65
+ comments: [],
66
+ warnings: ['Review published, but its comments could not be refreshed. Reload to see them.'],
67
+ }
68
+ }
69
+ }
@@ -13,7 +13,7 @@ export interface HostClient {
13
13
  /** `<cli> api -i --method GET <path>`: the response headers as well as the body. */
14
14
  apiWithHeaders(path: string): Promise<CliResponse>
15
15
  /** Send JSON over stdin. Defaults to POST; comment edits use PATCH or PUT. */
16
- post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT'): Promise<unknown>
16
+ post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'): Promise<unknown>
17
17
  /** `<cli> api graphql`; returns the parsed `data` object. */
18
18
  graphql(query: string, variables: Record<string, string | number>): Promise<unknown>
19
19
  /** `<cli> auth status`: is the CLI installed and logged in? */
@@ -196,7 +196,8 @@ export function createHostClient(spec: HostCliSpec, exec: CliExec = execCli): Ho
196
196
  // The payload goes over stdin, so no comment text ever appears in an argument list. The
197
197
  // content type is named because `glab` does not infer it and GitLab answers 415 without it.
198
198
  const args = ['api', '--method', method, path, '--input', '-', '-H', 'Content-Type: application/json']
199
- return JSON.parse((await run(path, args, JSON.stringify(body))).stdout) as unknown
199
+ const { stdout } = await run(path, args, JSON.stringify(body))
200
+ return stdout.trim() === '' ? null : (JSON.parse(stdout) as unknown)
200
201
  },
201
202
  graphql: async (query, variables) => {
202
203
  const args = ['api', 'graphql', '-f', `query=${query}`]
package/src/host/host.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import { shareGithubCanvas } from '../github/canvas-comment.js'
2
2
  import { shareGitlabCanvas } from '../gitlab/canvas-comment.js'
3
3
  import type { Capabilities, PublicHost, ReviewSummary } from '../contract/api.js'
4
- import type { FetchCommentsResult, PostCommentInput, PostCommentResult } from '../contract/comments.js'
4
+ import type {
5
+ FetchCommentsResult,
6
+ PostCommentInput,
7
+ PostCommentResult,
8
+ ReviewComment,
9
+ } from '../contract/comments.js'
5
10
  import type { Repo } from '../contract/review-artifact.js'
6
11
  import { GITHUB_ATTACHMENTS } from '../github/attachments.js'
7
12
  import { probeCapabilities } from '../github/capabilities.js'
8
13
  import { fetchComments } from '../github/comments.js'
9
14
  import { postComment } from '../github/post-comment.js'
15
+ import type { PendingComment } from '../contract/pending.js'
10
16
  import type { ReviewEvent } from '../contract/reviews.js'
11
17
  import { postReview } from '../github/post-review.js'
12
18
  import { fetchPrMeta } from '../github/pr.js'
@@ -23,6 +29,12 @@ import { GH_CLI, glabCli, type HostClient, type HostCliSpec } from './client.js'
23
29
 
24
30
  export type HostKind = PublicHost['kind']
25
31
 
32
+ /** The review and the comments created by this submission. */
33
+ export interface PostedReview extends ReviewSummary {
34
+ comments: ReviewComment[]
35
+ warnings: string[]
36
+ }
37
+
26
38
  /** How canvas zips attached to a review are found and fetched on one forge. */
27
39
  export interface HostAttachments {
28
40
  /** Zip links in one markdown text, as absolute URLs this host serves. */
@@ -68,13 +80,19 @@ export interface Host {
68
80
  input: PostCommentInput,
69
81
  diff: Derived
70
82
  ): Promise<PostCommentResult>
83
+ /**
84
+ * Submits the review, with the comments the reviewer had waiting. GitHub takes them in the one
85
+ * call that creates the review; GitLab stages and batch-publishes draft notes, which is why the
86
+ * diff is passed here too.
87
+ */
71
88
  postReview(
72
89
  client: HostClient,
73
90
  repo: Repo,
74
91
  number: number,
75
92
  headSha: string,
76
- input: { event: ReviewEvent; body: string }
77
- ): Promise<ReviewSummary>
93
+ input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
94
+ diff: Derived
95
+ ): Promise<PostedReview>
78
96
  probeCapabilities(client: HostClient, repo: Repo): Promise<Capabilities>
79
97
  canvasCommentLimit: number
80
98
  shareCanvas(client: HostClient, repo: Repo, number: number, body: string): Promise<string>
@@ -121,8 +139,8 @@ export function gitlabHost(hostname: string): Host {
121
139
  fetchGitlabComments(client, repo, number, headSha, now, webBase),
122
140
  postComment: (client, repo, number, headSha, input, diff) =>
123
141
  postGitlabComment(client, repo, number, headSha, input, { webBase, ...diff }),
124
- postReview: (client, repo, number, headSha, input) =>
125
- postGitlabReview(client, repo, number, headSha, input, webBase),
142
+ postReview: (client, repo, number, headSha, input, diff) =>
143
+ postGitlabReview(client, repo, number, headSha, input, webBase, diff),
126
144
  probeCapabilities: probeGitlabCapabilities,
127
145
  canvasCommentLimit: 1_000_000,
128
146
  shareCanvas: (client, repo, number, body) => shareGitlabCanvas(client, repo, number, body, webBase),
@@ -41,6 +41,10 @@ export const PromptOverridesSchema = z
41
41
  'generation-format.md': z.string().min(1).optional(),
42
42
  'generation-strict.md': z.string().min(1).optional(),
43
43
  'generation-surfacing.md': z.string().min(1).optional(),
44
+ 'generation-strict-incremental.md': z.string().min(1).optional(),
45
+ 'generation-surfacing-incremental.md': z.string().min(1).optional(),
46
+ 'judging-strict.md': z.string().min(1).optional(),
47
+ 'judging-surfacing.md': z.string().min(1).optional(),
44
48
  'quality-standards.md': z.string().min(1).optional(),
45
49
  'layering-guidance.md': z.string().min(1).optional(),
46
50
  'chat-seed.md': z.string().min(1).optional(),
@@ -71,6 +75,12 @@ export const ProjectConfigSchema = z.object({
71
75
  * from, as after merging the base branch in. False marks the canvas outdated on any commit.
72
76
  */
73
77
  keepForIdenticalDiff: z.boolean(),
78
+ /**
79
+ * Regenerating a canvas for a new head starts from the newest canvas of a commit the head was
80
+ * built on, carrying what the head's diff leaves untouched. False generates every canvas from
81
+ * a blank page, as `--force` always does.
82
+ */
83
+ incremental: z.boolean(),
74
84
  }),
75
85
  })
76
86
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
@@ -93,7 +103,9 @@ const PartialProjectConfigSchema = z.object({
93
103
  .optional(),
94
104
  tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
95
105
  chat: z.object({ enabled: z.boolean().optional() }).optional(),
96
- canvas: z.object({ keepForIdenticalDiff: z.boolean().optional() }).optional(),
106
+ canvas: z
107
+ .object({ keepForIdenticalDiff: z.boolean().optional(), incremental: z.boolean().optional() })
108
+ .optional(),
97
109
  })
98
110
 
99
111
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -103,7 +115,7 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
103
115
  generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
104
116
  tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
105
117
  chat: { enabled: true },
106
- canvas: { keepForIdenticalDiff: true },
118
+ canvas: { keepForIdenticalDiff: true, incremental: true },
107
119
  }
108
120
 
109
121
  export interface LoadedProjectConfig {
@@ -147,6 +159,7 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
147
159
  canvas: {
148
160
  keepForIdenticalDiff:
149
161
  user.canvas?.keepForIdenticalDiff ?? DEFAULT_PROJECT_CONFIG.canvas.keepForIdenticalDiff,
162
+ incremental: user.canvas?.incremental ?? DEFAULT_PROJECT_CONFIG.canvas.incremental,
150
163
  },
151
164
  }
152
165
  if (user.rulebook !== undefined) {