@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
package/src/commands.ts CHANGED
@@ -7,10 +7,11 @@ import { exportCanvas } from './canvas/export.js'
7
7
  import { importCanvas } from './canvas/import.js'
8
8
  import { CANVAS_ZIP_MAX_BYTES } from './canvas/zip.js'
9
9
  import type { ErrorCode } from './contract/api.js'
10
- import type { GenerationContext, PrepareTarget } from './contract/generation-context.js'
10
+ import type { GenerationContext, PrepareTargetInput } from './contract/generation-context.js'
11
+ import type { LocalKey } from './contract/review-key.js'
11
12
  import { HARNESSES, type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
12
13
  import { formatValidationError, type ValidationReport } from './contract/validation.js'
13
- import { fetchPrMeta, fetchPrRefs } from './github/pr.js'
14
+ import { fetchPrRefs } from './git/pr-refs.js'
14
15
  import { type DoctorDeps, runDoctorChecks } from './review/doctor.js'
15
16
  import {
16
17
  CLAUDE_SKILLS_DIR,
@@ -32,7 +33,7 @@ import {
32
33
  import { applyTitleTrims, type TitleTrim } from './review/trim-caps.js'
33
34
  import { validateModelOutput } from './review/validate.js'
34
35
  import type { AppContext } from './server/context.js'
35
- import { AppError, toAppError } from './server/errors.js'
36
+ import { AppError, CLI_SETUP_CODES, toAppError } from './server/errors.js'
36
37
  import { readText, writeTextAtomic } from './store/atomic-json.js'
37
38
 
38
39
  export interface CliIo {
@@ -40,7 +41,7 @@ export interface CliIo {
40
41
  stderr(line: string): void
41
42
  }
42
43
 
43
- /** Exit codes: 0 ok, 1 error, 2 usage, 4 gh auth or missing, 5 invalid model output. */
44
+ /** Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab auth or missing, 5 invalid model output. */
44
45
  export const EXIT = { ok: 0, error: 1, usage: 2, gh: 4, invalid: 5 } as const
45
46
 
46
47
  export class UsageError extends Error {
@@ -94,7 +95,7 @@ export function reportFailure(io: CliIo, err: unknown): number {
94
95
  }
95
96
  const appErr = toAppError(err)
96
97
  printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
97
- return appErr.code === 'GH_UNAUTHENTICATED' || appErr.code === 'GH_MISSING' ? EXIT.gh : EXIT.error
98
+ return CLI_SETUP_CODES.has(appErr.code) ? EXIT.gh : EXIT.error
98
99
  }
99
100
 
100
101
  /**
@@ -145,17 +146,39 @@ function parsePrNumber(raw: string): number {
145
146
  return n
146
147
  }
147
148
 
148
- export function parsePrepareTarget(values: { pr?: string; base?: string; head?: string }): PrepareTarget {
149
+ export interface PrepareFlags {
150
+ pr?: string | undefined
151
+ base?: string | undefined
152
+ head?: string | undefined
153
+ branch?: boolean | undefined
154
+ uncommitted?: boolean | undefined
155
+ }
156
+
157
+ const LOCAL_FLAGS = 'pass one of --pr <n>, --branch, --uncommitted, or --base <ref> --head <ref>'
158
+
159
+ /** The target the flags name. `prepare` resolves a local review's base against the clone. */
160
+ export function parsePrepareTarget(values: PrepareFlags): PrepareTargetInput {
161
+ const local: LocalKey | undefined =
162
+ values.branch === true ? 'branch' : values.uncommitted === true ? 'uncommitted' : undefined
163
+ if (values.branch === true && values.uncommitted === true) {
164
+ throw new UsageError('--branch and --uncommitted are two reviews; ask for one of them')
165
+ }
149
166
  if (values.pr !== undefined) {
150
- if (values.base !== undefined || values.head !== undefined) {
151
- throw new UsageError('pass either --pr <n> or --base <ref> --head <ref>, not both')
167
+ if (values.base !== undefined || values.head !== undefined || local !== undefined) {
168
+ throw new UsageError(LOCAL_FLAGS)
152
169
  }
153
170
  return { kind: 'pr', number: parsePrNumber(values.pr) }
154
171
  }
172
+ if (local !== undefined) {
173
+ if (values.head !== undefined) {
174
+ throw new UsageError(`--${local} reviews this clone, so it takes no --head`)
175
+ }
176
+ return { kind: 'local', source: local, base: values.base }
177
+ }
155
178
  if (values.base !== undefined && values.head !== undefined) {
156
179
  return { kind: 'refs', base: values.base, head: values.head }
157
180
  }
158
- throw new UsageError('prepare needs --pr <n> or --base <ref> --head <ref>')
181
+ throw new UsageError(`prepare needs a target: ${LOCAL_FLAGS}`)
159
182
  }
160
183
 
161
184
  export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
@@ -165,12 +188,16 @@ export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Pr
165
188
  pr: { type: 'string' },
166
189
  base: { type: 'string' },
167
190
  head: { type: 'string' },
191
+ branch: { type: 'boolean' },
192
+ uncommitted: { type: 'boolean' },
168
193
  force: { type: 'boolean' },
169
194
  },
170
195
  strict: true,
171
196
  })
172
- const target = parsePrepareTarget(values)
173
- const result = await prepare(ctx, target, { force: values.force === true, log: phase => io.stderr(phase) })
197
+ const result = await prepare(ctx, parsePrepareTarget(values), {
198
+ force: values.force === true,
199
+ log: phase => io.stderr(phase),
200
+ })
174
201
  printJson(io, result)
175
202
  return EXIT.ok
176
203
  }
@@ -186,7 +213,11 @@ async function validateFile(
186
213
  }
187
214
  const artifact = ReviewArtifactSchema.safeParse(parsed.raw)
188
215
  const input = artifact.success ? artifactToModelOutput(artifact.data satisfies ReviewArtifact) : parsed.raw
189
- const result = validateModelOutput(input, await validationInput(ctx, context, input))
216
+ // A stored canvas may predate the rules about what a generation must hide; only its correctness is checked.
217
+ const result = validateModelOutput(input, {
218
+ ...(await validationInput(ctx, context, input)),
219
+ storedArtifact: artifact.success,
220
+ })
190
221
  return { ok: result.ok, errors: result.errors }
191
222
  }
192
223
 
@@ -308,6 +339,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
308
339
  harness: parseHarness(values.harness),
309
340
  allowStale: values['allow-stale'] === true,
310
341
  })
342
+ if (result.sharing.status === 'failed')
343
+ io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
311
344
  printJson(io, result)
312
345
  return EXIT.ok
313
346
  }
@@ -368,8 +401,8 @@ async function resolveHead(
368
401
  if (prNumber === undefined) {
369
402
  throw new UsageError('export needs --pr <n> or --head <ref|sha>')
370
403
  }
371
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
372
- const { headSha } = await fetchPrRefs(ctx.git, meta)
404
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
405
+ const { headSha } = await fetchPrRefs(ctx.git, ctx.config.host, meta)
373
406
  return { headSha, prNumber }
374
407
  }
375
408
 
@@ -387,7 +420,7 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
387
420
  out: values.out,
388
421
  })
389
422
  printJson(io, result)
390
- io.stderr(`drag ${result.path} into the pull request description or a comment`)
423
+ io.stderr(`drag ${result.path} into the ${ctx.config.host.noun} description or a comment`)
391
424
  return EXIT.ok
392
425
  }
393
426
 
@@ -445,9 +478,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
445
478
  const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
446
479
  if (values.pr !== undefined) {
447
480
  const prNumber = parsePrNumber(values.pr)
448
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
481
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
449
482
  options.prNumber = prNumber
450
- options.currentHeadSha = (await fetchPrRefs(ctx.git, meta)).headSha
483
+ options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
451
484
  }
452
485
  printJson(io, await importCanvas(ctx, options))
453
486
  return EXIT.ok
package/src/config.ts CHANGED
@@ -2,6 +2,8 @@ import path from 'node:path'
2
2
  import type { Repo } from './contract/review-artifact.js'
3
3
  import { isChatAgent, type SettingsOverrides } from './contract/settings.js'
4
4
  import { type Git, GitError } from './git/git.js'
5
+ import type { Host } from './host/host.js'
6
+ import { type OriginRemote, parseOriginRemote } from './host/remote.js'
5
7
  import { resolveDataDir } from './store/data-dir.js'
6
8
 
7
9
  export const DEFAULT_PORT = 3010
@@ -22,6 +24,8 @@ export interface RuntimeConfig {
22
24
  commonDir: string
23
25
  dataDir: string
24
26
  repo: Repo
27
+ /** The forge origin points at, which owns every request or answer that differs between them. */
28
+ host: Host
25
29
  /** Dev only: every PR reports `ready` with this artifact re-keyed to the live head. */
26
30
  fixtureCanvasPath: string | null
27
31
  /** Chat agent and model the flags force for this run, if any. */
@@ -61,36 +65,19 @@ export async function resolveCommonDir(git: Git): Promise<string> {
61
65
  return git.commonDir()
62
66
  }
63
67
 
64
- /** Parses the two URL forms GitHub gives out: ssh (`git@github.com:o/r.git`) and https. */
65
- export function parseGithubRemote(url: string): Repo | null {
66
- const m =
67
- /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
68
- url.trim()
69
- )
70
- if (!m || m[1] === undefined || m[2] === undefined) {
71
- return null
72
- }
73
- return { owner: m[1], name: m[2] }
74
- }
68
+ export const ORIGIN_HINT =
69
+ 'add a github.com or GitLab origin, or set PR_REVIEW_HOST=gitlab for self-hosted GitLab'
75
70
 
76
- export async function resolveGithubRepo(git: Git): Promise<Repo> {
71
+ export async function resolveOrigin(git: Git, env: NodeJS.ProcessEnv = {}): Promise<OriginRemote> {
77
72
  const url = await git.remoteUrl('origin')
78
73
  if (url === null) {
79
- throw new ConfigError(
80
- 'NO_ORIGIN',
81
- 'the repository has no "origin" remote',
82
- 'add one that points at GitHub'
83
- )
74
+ throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', ORIGIN_HINT)
84
75
  }
85
- const repo = parseGithubRemote(url)
86
- if (repo === null) {
87
- throw new ConfigError(
88
- 'NO_ORIGIN',
89
- `origin is not a GitHub URL: ${url}`,
90
- 'only github.com repositories are supported'
91
- )
76
+ const parsed = parseOriginRemote(url, env)
77
+ if (parsed === null) {
78
+ throw new ConfigError('NO_ORIGIN', `origin is not a GitHub or GitLab URL: ${url}`, ORIGIN_HINT)
92
79
  }
93
- return repo
80
+ return parsed
94
81
  }
95
82
 
96
83
  /** `--agent` names one of the agents the chat knows; anything else is a usage error. */
@@ -139,7 +126,7 @@ export async function loadRuntimeConfig(
139
126
  ): Promise<RuntimeConfig> {
140
127
  const repoRoot = await resolveRepoRoot(git)
141
128
  const commonDir = await resolveCommonDir(git)
142
- const repo = await resolveGithubRepo(git)
129
+ const { repo, host } = await resolveOrigin(git, env)
143
130
  const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
144
131
  const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
145
132
  return {
@@ -148,6 +135,7 @@ export async function loadRuntimeConfig(
148
135
  commonDir,
149
136
  dataDir,
150
137
  repo,
138
+ host,
151
139
  fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
152
140
  chatOverrides: parseChatOverrides(flags),
153
141
  }
@@ -2,7 +2,8 @@ import type { z } from 'zod'
2
2
  import type { CanvasManifest } from './canvas-manifest.js'
3
3
  import type { CommentsPayload, IssueComment, ReviewComment } from './comments.js'
4
4
  import type { SharedCanvasInfoSchema } from './discovery.js'
5
- import type { FileEntry, Pr, ReviewArtifact } from './review-artifact.js'
5
+ import type { FileEntry, FoldLevel, Pr, ReviewArtifact } from './review-artifact.js'
6
+ import type { LocalKey, ReviewKey } from './review-key.js'
6
7
  import type { PrState } from './state.js'
7
8
 
8
9
  export const ERROR_CODES = [
@@ -16,10 +17,14 @@ export const ERROR_CODES = [
16
17
  'GH_MISSING',
17
18
  'GH_UNAUTHENTICATED',
18
19
  'GITHUB_API_ERROR',
20
+ 'GLAB_MISSING',
21
+ 'GLAB_UNAUTHENTICATED',
22
+ 'GITLAB_API_ERROR',
19
23
  'PR_NOT_FOUND',
20
24
  'CANVAS_NOT_FOUND',
21
25
  'CANVAS_INVALID',
22
26
  'CANVAS_REPO_MISMATCH',
27
+ 'CANVAS_PR_MISMATCH',
23
28
  'CANVAS_TOO_LARGE',
24
29
  'CANVAS_STALE',
25
30
  'MODEL_INVALID',
@@ -65,6 +70,17 @@ export interface StaleInfo {
65
70
  commitsBehind?: number
66
71
  }
67
72
 
73
+ /**
74
+ * A ready canvas generated for an earlier commit of the pull request whose diff is identical to
75
+ * the head's, so the page keeps it and says so.
76
+ */
77
+ export interface CarriedOverInfo {
78
+ canvasHeadSha: string
79
+ currentHeadSha: string
80
+ /** How many commits the head is ahead of the canvas's commit; absent when the head does not contain it. */
81
+ commitsBehind?: number
82
+ }
83
+
68
84
  /** The answer of `POST /import`, of `pr-review import`, and of an imported shared canvas. */
69
85
  export interface ImportResult {
70
86
  status: 'ready' | 'stale' | 'exists'
@@ -88,6 +104,15 @@ export interface SharedCanvasFetchResponse {
88
104
  /** A canvas zip found on the pull request. Shaped by the schema the discovery cache stores. */
89
105
  export type SharedCanvasInfo = z.infer<typeof SharedCanvasInfoSchema>
90
106
 
107
+ /** The forge the server talks to, as the page and the health endpoint learn it. */
108
+ export interface PublicHost {
109
+ kind: 'github' | 'gitlab'
110
+ /** `GitHub` or `GitLab`, for the words on the page. */
111
+ label: string
112
+ /** `https://github.com` or the GitLab instance, for links to profiles. */
113
+ webBase: string
114
+ }
115
+
91
116
  export interface PrBundle {
92
117
  status: BundleStatus
93
118
  pr: Pr
@@ -96,8 +121,18 @@ export interface PrBundle {
96
121
  artifact?: ReviewArtifact
97
122
  canvas?: CanvasInfo
98
123
  stale?: StaleInfo
124
+ /** Set on a ready bundle whose canvas was generated for another commit with an identical diff. */
125
+ carriedOver?: CarriedOverInfo
126
+ /**
127
+ * The canvas the reviewed marks on this page were made on, when they followed a line of descent
128
+ * onto this one; absent when none did. Not necessarily this canvas's own basis: the reviewer may
129
+ * have marked nothing on the canvases in between.
130
+ */
131
+ marksCarriedFrom?: string
99
132
  sharedCanvas?: SharedCanvasInfo
100
133
  skillCommand: string
134
+ /** Set on a local review: work that has no pull request, so the forge side of the page is off. */
135
+ local?: LocalKey
101
136
  comments: CommentsPayload
102
137
  state: PrState
103
138
  capabilities: Capabilities
@@ -111,7 +146,8 @@ export interface PrBundle {
111
146
 
112
147
  /** Every answer of a state route, so the page can replace its copy of the state in one step. */
113
148
  export interface StateResponse {
114
- prNumber: number
149
+ /** The target the state belongs to: a pull request number, or `local`. */
150
+ prNumber: ReviewKey
115
151
  state: PrState
116
152
  }
117
153
 
@@ -132,12 +168,20 @@ export interface ReviewSummary {
132
168
  export interface ReviewBodyResponse {
133
169
  headSha: string
134
170
  body: string
171
+ /** How many pending comments would go out with the review. */
172
+ pending: number
135
173
  /** The titles of the layers that still need a look; approve is refused while this is not empty. */
136
174
  unreviewed: string[]
137
175
  }
138
176
 
139
177
  export interface PostReviewResponse {
140
178
  review: ReviewSummary
179
+ comments: ReviewComment[]
180
+ warnings: string[]
181
+ /** How many pending comments went out with the review. */
182
+ submitted: number
183
+ /** The state after the pending review was cleared, so the page drops its drafts in one step. */
184
+ state: PrState
141
185
  }
142
186
 
143
187
  export interface PatchesResponse {
@@ -172,6 +216,7 @@ export interface HealthResponse {
172
216
  agentAuth?: HealthCheck
173
217
  }
174
218
  repo: { owner: string; name: string } | null
219
+ host: PublicHost
175
220
  dataDir: string
176
221
  chat: ChatStatus
177
222
  }
@@ -188,3 +233,14 @@ export interface ChatStatus {
188
233
  export interface HomeData {
189
234
  recentPrs: Array<{ number: number; title: string; updatedAt: string }>
190
235
  }
236
+
237
+ /** The JSON the review page carries in its bootstrap script; the app reads it before any request. */
238
+ export interface ReviewBootstrap {
239
+ prNumber: ReviewKey
240
+ owner: string
241
+ repo: string
242
+ version: string
243
+ host: PublicHost
244
+ /** The reading level the canvas opens at, from the settings file. */
245
+ foldLevel: FoldLevel
246
+ }
@@ -23,6 +23,13 @@ export const CanvasIndexSchema = z.object({
23
23
  generatedAt: z.string(),
24
24
  source: z.enum(['local', 'import']),
25
25
  importedAt: z.string().optional(),
26
+ /**
27
+ * The canvas this one was generated from. Kept here as well as on the artifact so the line
28
+ * of descent can be walked from the index alone, without opening every canvas on the way.
29
+ */
30
+ basisCanvasSha: z.string().optional(),
31
+ /** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
32
+ worktree: z.boolean().optional(),
26
33
  })
27
34
  ),
28
35
  })
@@ -96,3 +96,8 @@ export {
96
96
  export function emptyComments(headSha: string, fetchedAt: string): CommentsPayload {
97
97
  return { fetchedAt, headSha, reviewComments: [], issueComments: [] }
98
98
  }
99
+
100
+ export interface FetchCommentsResult {
101
+ payload: CommentsPayload
102
+ warnings: string[]
103
+ }
@@ -3,9 +3,12 @@ import { z } from 'zod'
3
3
  export const SharedCanvasInfoSchema = z.object({
4
4
  url: z.string(),
5
5
  name: z.string(),
6
- matchesHead: z.boolean(),
6
+ /** The attachment's file name carries the head's sha; it says nothing about whether it is current. */
7
+ namesHead: z.boolean(),
7
8
  downloadable: z.boolean(),
8
- reason: z.enum(['auth-required', 'not-zip', 'too-large', 'network', 'name-mismatch']).optional(),
9
+ reason: z
10
+ .enum(['auth-required', 'not-zip', 'too-large', 'network', 'name-mismatch', 'pr-mismatch'])
11
+ .optional(),
9
12
  })
10
13
 
11
14
  /**
@@ -1,15 +1,45 @@
1
1
  import { z } from 'zod'
2
2
  import { DefaultLayerSchema, GenerationModeSchema, HighRiskRuleSchema } from '../project-config.js'
3
3
  import { DEFAULT_TEST_PATTERNS } from '../review/test-paths.js'
4
- import { FileEntrySchema, LIMITS, PrSchema, RepoSchema, type TextCaps } from './review-artifact.js'
4
+ import { type LocalKey, LocalKeySchema } from './review-key.js'
5
+ import {
6
+ FileEntrySchema,
7
+ LIMITS,
8
+ POINT_KINDS,
9
+ PrSchema,
10
+ RepoSchema,
11
+ type TextCaps,
12
+ } from './review-artifact.js'
13
+
14
+ /**
15
+ * What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
16
+ * work in this clone that has no pull request yet. A `local` target names only its base, because
17
+ * its head is whatever the branch or the working tree holds when the command runs.
18
+ */
19
+ export const LocalPrepareTargetSchema = z.object({
20
+ kind: z.literal('local'),
21
+ base: z.string().min(1),
22
+ /** Which local review this is, which is also the key it is filed and served under. */
23
+ source: LocalKeySchema,
24
+ })
25
+ export type LocalPrepareTarget = z.infer<typeof LocalPrepareTargetSchema>
5
26
 
6
- /** What `prepare` was asked to describe: a pull request, or two refs before a PR exists. */
7
27
  export const PrepareTargetSchema = z.discriminatedUnion('kind', [
8
28
  z.object({ kind: z.literal('pr'), number: z.number().int().positive() }),
9
29
  z.object({ kind: z.literal('refs'), base: z.string().min(1), head: z.string().min(1) }),
30
+ LocalPrepareTargetSchema,
10
31
  ])
11
32
  export type PrepareTarget = z.infer<typeof PrepareTargetSchema>
12
33
 
34
+ /**
35
+ * What the CLI parsed, before git has been asked anything. A local review's base may still be
36
+ * open here: only the clone knows which branch `origin/HEAD` points at. `prepare` resolves it and
37
+ * records the answer, so `publish` reads a base that cannot drift.
38
+ */
39
+ export type PrepareTargetInput =
40
+ | Extract<PrepareTarget, { kind: 'pr' } | { kind: 'refs' }>
41
+ | { kind: 'local'; base?: string | undefined; source: LocalKey }
42
+
13
43
  const capsShape = {
14
44
  summary: z.number().int().positive(),
15
45
  layerTitle: z.number().int().positive(),
@@ -23,6 +53,48 @@ const capsShape = {
23
53
  diagram: z.number().int().positive(),
24
54
  } satisfies Record<keyof TextCaps, z.ZodNumber>
25
55
 
56
+ /** Which files the head changes relative to the basis canvas's diff, by path. */
57
+ export const FileDeltaSchema = z.object({
58
+ unchanged: z.array(z.string()),
59
+ changed: z.array(z.string()),
60
+ added: z.array(z.string()),
61
+ removed: z.array(z.string()),
62
+ })
63
+ export type FileDelta = z.infer<typeof FileDeltaSchema>
64
+
65
+ export const BasisSplitLayerSchema = z.object({
66
+ key: z.string().min(1),
67
+ title: z.string(),
68
+ /** `carried` when the head touches none of the layer's files. */
69
+ status: z.enum(['carried', 're-judged']),
70
+ carriedFiles: z.array(z.string()),
71
+ reJudgedFiles: z.array(z.string()),
72
+ })
73
+ export type BasisSplitLayer = z.infer<typeof BasisSplitLayerSchema>
74
+
75
+ export const BasisSplitPointSchema = z.object({
76
+ kind: z.enum(POINT_KINDS),
77
+ path: z.string().min(1),
78
+ title: z.string(),
79
+ status: z.enum(['carried', 're-judged']),
80
+ })
81
+ export type BasisSplitPoint = z.infer<typeof BasisSplitPointSchema>
82
+
83
+ /**
84
+ * The basis canvas of an incremental run, already divided into what the head leaves untouched and
85
+ * what has to be decided anew. `prepare` computes it, so the generator reads two lists instead of
86
+ * comparing diffs itself, and `publish` records `canvasSha` on the canvas it stores.
87
+ */
88
+ export const BasisSplitSchema = z.object({
89
+ canvasSha: z.string().regex(/^[0-9a-f]{40}$/),
90
+ /** Absolute path of the basis canvas's `review.json`, in the canvas store. */
91
+ reviewJsonPath: z.string().min(1),
92
+ files: FileDeltaSchema,
93
+ layers: z.array(BasisSplitLayerSchema),
94
+ points: z.array(BasisSplitPointSchema),
95
+ })
96
+ export type BasisSplit = z.infer<typeof BasisSplitSchema>
97
+
26
98
  /**
27
99
  * `context.json`: everything the agent and `publish` need about one prepared canvas. Written by
28
100
  * `prepare` next to `prompt.md`; `publish` validates `model.json` against the hunk index and the
@@ -64,6 +136,8 @@ export const GenerationContextSchema = z.object({
64
136
  smallPr: z.boolean(),
65
137
  /** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
66
138
  largePr: z.boolean(),
139
+ /** Absent when this canvas is generated from a blank page: no basis, `--force`, or turned off. */
140
+ basis: BasisSplitSchema.optional(),
67
141
  preparedAt: z.string(),
68
142
  })
69
143
  export type GenerationContext = z.infer<typeof GenerationContextSchema>
@@ -9,6 +9,7 @@ export {
9
9
  parseHunkId,
10
10
  parseLineId,
11
11
  pointAnchorId,
12
+ reviewedId,
12
13
  sanitizeKey,
13
14
  uniqueKey,
14
15
  } from '../../static/js/keys.js'
@@ -0,0 +1,49 @@
1
+ // A review the reviewer is still writing: comments kept on this machine until the review is
2
+ // submitted, the way a pending review works on the forge's own page.
3
+ import { z } from 'zod'
4
+ import { SideSchema } from './review-artifact.js'
5
+ import { COMMENT_BODY_MAX } from './comments.js'
6
+
7
+ /**
8
+ * One comment waiting in the pending review. It names a line the way a posted inline comment
9
+ * does, so submitting it needs nothing the page has to look up again.
10
+ */
11
+ export const PendingCommentSchema = z.object({
12
+ /** Local id, unique inside one target's state. Never a forge id: nothing was posted yet. */
13
+ id: z.string().min(1),
14
+ path: z.string().min(1),
15
+ line: z.number().int().positive(),
16
+ side: SideSchema,
17
+ /** The first line of a multi-line comment, absent when it covers one line. */
18
+ startLine: z.number().int().positive().optional(),
19
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
20
+ /** Set when the comment came from an attention point, so the point can be marked posted. */
21
+ pointFingerprint: z.string().min(1).optional(),
22
+ /** The commit the reviewer was reading when they wrote it. */
23
+ headSha: z.string(),
24
+ createdAt: z.string(),
25
+ /** Creation time until the reviewer edits the draft. */
26
+ updatedAt: z.string(),
27
+ })
28
+ export type PendingComment = z.infer<typeof PendingCommentSchema>
29
+
30
+ /** What the page sends to `POST /api/prs/:n/pending`. */
31
+ export const AddPendingInputSchema = z.object({
32
+ path: z.string().min(1),
33
+ line: z.number().int().positive(),
34
+ side: SideSchema,
35
+ startLine: z.number().int().positive().optional(),
36
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
37
+ pointFingerprint: z.string().min(1).optional(),
38
+ headSha: z
39
+ .string()
40
+ .regex(/^[0-9a-f]{40}$/)
41
+ .optional(),
42
+ })
43
+ export type AddPendingInput = z.infer<typeof AddPendingInputSchema>
44
+
45
+ /** What the page sends to `PATCH /api/prs/:n/pending/:id`. */
46
+ export const EditPendingInputSchema = z.object({
47
+ body: z.string().min(1).max(COMMENT_BODY_MAX),
48
+ })
49
+ export type EditPendingInput = z.infer<typeof EditPendingInputSchema>
@@ -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),