@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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 (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
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 {
@@ -63,7 +64,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
63
64
  }
64
65
 
65
66
  function isParseArgsError(err: unknown): err is Error {
66
- return err instanceof Error && 'code' in err && typeof err.code === 'string' && err.code.startsWith('ERR_PARSE_ARGS')
67
+ return (
68
+ err instanceof Error &&
69
+ 'code' in err &&
70
+ typeof err.code === 'string' &&
71
+ err.code.startsWith('ERR_PARSE_ARGS')
72
+ )
67
73
  }
68
74
 
69
75
  /** Prints the envelope for any failure and picks the exit code. */
@@ -89,7 +95,7 @@ export function reportFailure(io: CliIo, err: unknown): number {
89
95
  }
90
96
  const appErr = toAppError(err)
91
97
  printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
92
- 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
93
99
  }
94
100
 
95
101
  /**
@@ -140,27 +146,58 @@ function parsePrNumber(raw: string): number {
140
146
  return n
141
147
  }
142
148
 
143
- 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
+ }
144
166
  if (values.pr !== undefined) {
145
- if (values.base !== undefined || values.head !== undefined) {
146
- 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)
147
169
  }
148
170
  return { kind: 'pr', number: parsePrNumber(values.pr) }
149
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
+ }
150
178
  if (values.base !== undefined && values.head !== undefined) {
151
179
  return { kind: 'refs', base: values.base, head: values.head }
152
180
  }
153
- throw new UsageError('prepare needs --pr <n> or --base <ref> --head <ref>')
181
+ throw new UsageError(`prepare needs a target: ${LOCAL_FLAGS}`)
154
182
  }
155
183
 
156
184
  export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
157
185
  const { values } = parseArgs({
158
186
  args: argv,
159
- options: { pr: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' }, force: { type: 'boolean' } },
187
+ options: {
188
+ pr: { type: 'string' },
189
+ base: { type: 'string' },
190
+ head: { type: 'string' },
191
+ branch: { type: 'boolean' },
192
+ uncommitted: { type: 'boolean' },
193
+ force: { type: 'boolean' },
194
+ },
160
195
  strict: true,
161
196
  })
162
- const target = parsePrepareTarget(values)
163
- 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
+ })
164
201
  printJson(io, result)
165
202
  return EXIT.ok
166
203
  }
@@ -194,7 +231,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
194
231
  })
195
232
  const file = positionals[0]
196
233
  if (file === undefined || positionals.length > 1) {
197
- throw new UsageError('validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>')
234
+ throw new UsageError(
235
+ 'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
236
+ )
198
237
  }
199
238
  if (values.canvas === undefined) {
200
239
  throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
@@ -202,7 +241,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
202
241
  const context = await readContext(path.resolve(values.canvas))
203
242
  const text = await readText(path.resolve(file))
204
243
  if (text === null) {
205
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'pass the model.json or review.json to check')
244
+ throw new PublishError(
245
+ 'NOT_FOUND',
246
+ `${file} does not exist`,
247
+ 'pass the model.json or review.json to check'
248
+ )
206
249
  }
207
250
  const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
208
251
  const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
@@ -214,7 +257,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
214
257
  if (trim.outcome === 'fixed') {
215
258
  io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
216
259
  } else {
217
- io.stdout(`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`)
260
+ io.stdout(
261
+ `unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
262
+ )
218
263
  }
219
264
  }
220
265
  if (report.ok) {
@@ -277,7 +322,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
277
322
  })
278
323
  const canvasDir = positionals[0]
279
324
  if (canvasDir === undefined || positionals.length > 1) {
280
- throw new UsageError('publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>')
325
+ throw new UsageError(
326
+ 'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
327
+ )
281
328
  }
282
329
  if (values.agent === undefined || values.agent === '') {
283
330
  throw new UsageError('publish needs --agent <id>')
@@ -288,6 +335,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
288
335
  harness: parseHarness(values.harness),
289
336
  allowStale: values['allow-stale'] === true,
290
337
  })
338
+ if (result.sharing.status === 'failed')
339
+ io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
291
340
  printJson(io, result)
292
341
  return EXIT.ok
293
342
  }
@@ -312,7 +361,11 @@ export interface InstallSkillEnv {
312
361
  export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
313
362
  const { values } = parseArgs({
314
363
  args: argv,
315
- options: { 'claude-dir': { type: 'string' }, 'codex-dir': { type: 'string' }, force: { type: 'boolean' } },
364
+ options: {
365
+ 'claude-dir': { type: 'string' },
366
+ 'codex-dir': { type: 'string' },
367
+ force: { type: 'boolean' },
368
+ },
316
369
  strict: true,
317
370
  })
318
371
  const resolve = (flag: string | undefined, fallback: string): string =>
@@ -344,8 +397,8 @@ async function resolveHead(
344
397
  if (prNumber === undefined) {
345
398
  throw new UsageError('export needs --pr <n> or --head <ref|sha>')
346
399
  }
347
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
348
- const { headSha } = await fetchPrRefs(ctx.git, meta)
400
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
401
+ const { headSha } = await fetchPrRefs(ctx.git, ctx.config.host, meta)
349
402
  return { headSha, prNumber }
350
403
  }
351
404
 
@@ -357,9 +410,13 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
357
410
  strict: true,
358
411
  })
359
412
  const target = await resolveHead(ctx, values)
360
- const result = await exportCanvas(ctx, { headSha: target.headSha, prNumber: target.prNumber, out: values.out })
413
+ const result = await exportCanvas(ctx, {
414
+ headSha: target.headSha,
415
+ prNumber: target.prNumber,
416
+ out: values.out,
417
+ })
361
418
  printJson(io, result)
362
- io.stderr(`drag ${result.path} into the pull request description or a comment`)
419
+ io.stderr(`drag ${result.path} into the ${ctx.config.host.noun} description or a comment`)
363
420
  return EXIT.ok
364
421
  }
365
422
 
@@ -375,7 +432,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
375
432
  } catch {
376
433
  throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
377
434
  }
378
- const tooLarge = new AppError('CANVAS_TOO_LARGE', `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`, 413)
435
+ const tooLarge = new AppError(
436
+ 'CANVAS_TOO_LARGE',
437
+ `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
438
+ 413
439
+ )
379
440
  try {
380
441
  // One byte past the cap is read, so a file of exactly the cap still fits and anything longer
381
442
  // is refused without the rest of it ever being in memory.
@@ -413,9 +474,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
413
474
  const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
414
475
  if (values.pr !== undefined) {
415
476
  const prNumber = parsePrNumber(values.pr)
416
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
477
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
417
478
  options.prNumber = prNumber
418
- options.currentHeadSha = (await fetchPrRefs(ctx.git, meta)).headSha
479
+ options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
419
480
  }
420
481
  printJson(io, await importCanvas(ctx, options))
421
482
  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. */
@@ -47,7 +51,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
47
51
  return await git.topLevel()
48
52
  } catch (err) {
49
53
  if (err instanceof GitError) {
50
- throw new ConfigError('NOT_A_REPO', 'not inside a git repository', 'run from a clone or pass --repo <dir>')
54
+ throw new ConfigError(
55
+ 'NOT_A_REPO',
56
+ 'not inside a git repository',
57
+ 'run from a clone or pass --repo <dir>'
58
+ )
51
59
  }
52
60
  throw err
53
61
  }
@@ -57,32 +65,19 @@ export async function resolveCommonDir(git: Git): Promise<string> {
57
65
  return git.commonDir()
58
66
  }
59
67
 
60
- /** Parses the two URL forms GitHub gives out: ssh (`git@github.com:o/r.git`) and https. */
61
- export function parseGithubRemote(url: string): Repo | null {
62
- const m =
63
- /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
64
- url.trim()
65
- )
66
- if (!m || m[1] === undefined || m[2] === undefined) {
67
- return null
68
- }
69
- return { owner: m[1], name: m[2] }
70
- }
68
+ export const ORIGIN_HINT =
69
+ 'add a github.com or GitLab origin, or set PR_REVIEW_HOST=gitlab for self-hosted GitLab'
71
70
 
72
- export async function resolveGithubRepo(git: Git): Promise<Repo> {
71
+ export async function resolveOrigin(git: Git, env: NodeJS.ProcessEnv = {}): Promise<OriginRemote> {
73
72
  const url = await git.remoteUrl('origin')
74
73
  if (url === null) {
75
- throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', 'add one that points at GitHub')
74
+ throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', ORIGIN_HINT)
76
75
  }
77
- const repo = parseGithubRemote(url)
78
- if (repo === null) {
79
- throw new ConfigError(
80
- 'NO_ORIGIN',
81
- `origin is not a GitHub URL: ${url}`,
82
- 'only github.com repositories are supported'
83
- )
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)
84
79
  }
85
- return repo
80
+ return parsed
86
81
  }
87
82
 
88
83
  /** `--agent` names one of the agents the chat knows; anything else is a usage error. */
@@ -90,7 +85,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
90
85
  const overrides: SettingsOverrides = {}
91
86
  if (flags.agent !== undefined) {
92
87
  if (!isChatAgent(flags.agent)) {
93
- throw new ConfigError('BAD_REQUEST', `unknown chat agent: ${flags.agent}`, 'use --agent claude or --agent codex')
88
+ throw new ConfigError(
89
+ 'BAD_REQUEST',
90
+ `unknown chat agent: ${flags.agent}`,
91
+ 'use --agent claude or --agent codex'
92
+ )
94
93
  }
95
94
  overrides.agent = flags.agent
96
95
  }
@@ -127,7 +126,7 @@ export async function loadRuntimeConfig(
127
126
  ): Promise<RuntimeConfig> {
128
127
  const repoRoot = await resolveRepoRoot(git)
129
128
  const commonDir = await resolveCommonDir(git)
130
- const repo = await resolveGithubRepo(git)
129
+ const { repo, host } = await resolveOrigin(git, env)
131
130
  const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
132
131
  const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
133
132
  return {
@@ -136,6 +135,7 @@ export async function loadRuntimeConfig(
136
135
  commonDir,
137
136
  dataDir,
138
137
  repo,
138
+ host,
139
139
  fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
140
140
  chatOverrides: parseChatOverrides(flags),
141
141
  }
@@ -3,6 +3,7 @@ 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
5
  import type { FileEntry, 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,12 @@ 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
99
126
  sharedCanvas?: SharedCanvasInfo
100
127
  skillCommand: string
128
+ /** Set on a local review: work that has no pull request, so the forge side of the page is off. */
129
+ local?: LocalKey
101
130
  comments: CommentsPayload
102
131
  state: PrState
103
132
  capabilities: Capabilities
@@ -111,7 +140,8 @@ export interface PrBundle {
111
140
 
112
141
  /** Every answer of a state route, so the page can replace its copy of the state in one step. */
113
142
  export interface StateResponse {
114
- prNumber: number
143
+ /** The target the state belongs to: a pull request number, or `local`. */
144
+ prNumber: ReviewKey
115
145
  state: PrState
116
146
  }
117
147
 
@@ -172,6 +202,7 @@ export interface HealthResponse {
172
202
  agentAuth?: HealthCheck
173
203
  }
174
204
  repo: { owner: string; name: string } | null
205
+ host: PublicHost
175
206
  dataDir: string
176
207
  chat: ChatStatus
177
208
  }
@@ -23,6 +23,8 @@ export const CanvasIndexSchema = z.object({
23
23
  generatedAt: z.string(),
24
24
  source: z.enum(['local', 'import']),
25
25
  importedAt: z.string().optional(),
26
+ /** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
27
+ worktree: z.boolean().optional(),
26
28
  })
27
29
  ),
28
30
  })
@@ -79,7 +79,9 @@ export const PostCommentInputSchema = z.discriminatedUnion('kind', [
79
79
  export type PostCommentInput = z.infer<typeof PostCommentInputSchema>
80
80
 
81
81
  /** The posted comment: a review comment for inline and reply, an issue comment for PR-level. */
82
- export type PostCommentResult = { kind: 'review'; comment: ReviewComment } | { kind: 'issue'; comment: IssueComment }
82
+ export type PostCommentResult =
83
+ | { kind: 'review'; comment: ReviewComment }
84
+ | { kind: 'issue'; comment: IssueComment }
83
85
 
84
86
  // The proposed-comment block a chat answer can carry. The parser lives in
85
87
  // static/js/proposed-comment.js so the browser loads the same code without a bundler.
@@ -94,3 +96,8 @@ export {
94
96
  export function emptyComments(headSha: string, fetchedAt: string): CommentsPayload {
95
97
  return { fetchedAt, headSha, reviewComments: [], issueComments: [] }
96
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,38 @@
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 { type LocalKey, LocalKeySchema } from './review-key.js'
4
5
  import { FileEntrySchema, LIMITS, PrSchema, RepoSchema, type TextCaps } from './review-artifact.js'
5
6
 
6
- /** What `prepare` was asked to describe: a pull request, or two refs before a PR exists. */
7
+ /**
8
+ * What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
9
+ * work in this clone that has no pull request yet. A `local` target names only its base, because
10
+ * its head is whatever the branch or the working tree holds when the command runs.
11
+ */
12
+ export const LocalPrepareTargetSchema = z.object({
13
+ kind: z.literal('local'),
14
+ base: z.string().min(1),
15
+ /** Which local review this is, which is also the key it is filed and served under. */
16
+ source: LocalKeySchema,
17
+ })
18
+ export type LocalPrepareTarget = z.infer<typeof LocalPrepareTargetSchema>
19
+
7
20
  export const PrepareTargetSchema = z.discriminatedUnion('kind', [
8
21
  z.object({ kind: z.literal('pr'), number: z.number().int().positive() }),
9
22
  z.object({ kind: z.literal('refs'), base: z.string().min(1), head: z.string().min(1) }),
23
+ LocalPrepareTargetSchema,
10
24
  ])
11
25
  export type PrepareTarget = z.infer<typeof PrepareTargetSchema>
12
26
 
27
+ /**
28
+ * What the CLI parsed, before git has been asked anything. A local review's base may still be
29
+ * open here: only the clone knows which branch `origin/HEAD` points at. `prepare` resolves it and
30
+ * records the answer, so `publish` reads a base that cannot drift.
31
+ */
32
+ export type PrepareTargetInput =
33
+ | Extract<PrepareTarget, { kind: 'pr' } | { kind: 'refs' }>
34
+ | { kind: 'local'; base?: string | undefined; source: LocalKey }
35
+
13
36
  const capsShape = {
14
37
  summary: z.number().int().positive(),
15
38
  layerTitle: z.number().int().positive(),
@@ -57,7 +80,9 @@ export const GenerationContextSchema = z.object({
57
80
  }),
58
81
  /** The globs that make a file a test here. Defaulted, so a context written before this
59
82
  * field existed still reads. */
60
- tests: z.object({ patterns: z.array(z.string().min(1)) }).default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
83
+ tests: z
84
+ .object({ patterns: z.array(z.string().min(1)) })
85
+ .default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
61
86
  /** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
62
87
  smallPr: z.boolean(),
63
88
  /** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
@@ -48,7 +48,9 @@ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
48
48
  }
49
49
 
50
50
  /** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
51
- export function effectiveCaps(overrides: { [K in keyof TextCaps]?: number | undefined } | undefined): TextCaps {
51
+ export function effectiveCaps(
52
+ overrides: { [K in keyof TextCaps]?: number | undefined } | undefined
53
+ ): TextCaps {
52
54
  return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
53
55
  }
54
56
 
@@ -162,13 +164,17 @@ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
162
164
 
163
165
  function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
164
166
  const visibleCap = caps[key] / HARD_CAP_FACTOR
165
- const diagrams = key === 'summary' || key === 'rationale'
166
- ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
167
- : ''
168
- return z.string().max(caps[key]).meta({
169
- description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
170
- 'x-visibleMaxLength': visibleCap,
171
- })
167
+ const diagrams =
168
+ key === 'summary' || key === 'rationale'
169
+ ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
170
+ : ''
171
+ return z
172
+ .string()
173
+ .max(caps[key])
174
+ .meta({
175
+ description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
176
+ 'x-visibleMaxLength': visibleCap,
177
+ })
172
178
  }
173
179
 
174
180
  export function testEntrySchema(caps: Caps) {
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * The two reviews of work that has no pull request yet. They are separate targets, so reviewing a
5
+ * branch does not disturb the review of what is not committed, and each keeps its own progress
6
+ * marks and chat threads.
7
+ */
8
+ export const LOCAL_KEYS = ['branch', 'uncommitted'] as const
9
+
10
+ /**
11
+ * Which local review: `branch` is the current branch against the base it will be opened against,
12
+ * uncommitted edits left out; `uncommitted` is that branch with the working tree on top.
13
+ */
14
+ export type LocalKey = (typeof LOCAL_KEYS)[number]
15
+
16
+ /**
17
+ * What a canvas, its review state, and its chat threads are filed under: a pull request or merge
18
+ * request number, or one of the local reviews. It is also what the URLs carry, so `/review/12`
19
+ * and `/review/uncommitted` are the same page over different targets.
20
+ */
21
+ export type ReviewKey = number | LocalKey
22
+
23
+ export const LocalKeySchema = z.enum(LOCAL_KEYS)
24
+ export const ReviewKeySchema = z.union([LocalKeySchema, z.number().int().positive()])
25
+
26
+ export function isLocalKey(key: ReviewKey): key is LocalKey {
27
+ return LOCAL_KEYS.some(local => local === key)
28
+ }
29
+
30
+ /** The key as it appears in a URL segment and as a directory name. */
31
+ export function keyToString(key: ReviewKey): string {
32
+ return String(key)
33
+ }
34
+
35
+ /** What the page calls the target, for a title or a heading. */
36
+ export function keyLabel(key: ReviewKey): string {
37
+ if (key === 'branch') {
38
+ return 'Branch review'
39
+ }
40
+ return key === 'uncommitted' ? 'Uncommitted work' : `#${String(key)}`
41
+ }
42
+
43
+ /** The key a URL segment names, or null when it names none of them. */
44
+ export function parseReviewKey(raw: string): ReviewKey | null {
45
+ const local = LocalKeySchema.safeParse(raw)
46
+ if (local.success) {
47
+ return local.data
48
+ }
49
+ const n = Number(raw)
50
+ return Number.isInteger(n) && n > 0 ? n : null
51
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod'
2
+
3
+ export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
4
+ export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
5
+ export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
6
+
7
+ export const PostReviewInputSchema = z.object({
8
+ event: ReviewEventSchema,
9
+ /** The dialog sends the body the user read, edited or not. */
10
+ body: z.string().min(1).max(65536).optional(),
11
+ /** The commit the dialog named. The server refuses the review when the head moved on. */
12
+ headSha: z
13
+ .string()
14
+ .regex(/^[0-9a-f]{40}$/)
15
+ .optional(),
16
+ })
17
+ export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
@@ -107,6 +107,8 @@ export interface SettingsResponse {
107
107
  maxRepairRounds: number
108
108
  inlineDiffMaxLines: number
109
109
  smallPrHunks: number
110
+ /** Whether a canvas still stands for a later head with an identical diff. */
111
+ keepForIdenticalDiff: boolean
110
112
  layers: number
111
113
  highRisk: number
112
114
  }