@vintasoftware/pr-review-canvas 0.3.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 (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -1,9 +1,10 @@
1
1
  // A chat thread is one acpx session plus the transcript of what was said in it. Both are named
2
- // after the pull request, so two repositories never share a session and a name can never point
2
+ // after the review target, so two repositories never share a session and a name can never point
3
3
  // outside the chat directory.
4
4
  import { appendFile, mkdir, readFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { type ChatTurn, ChatTurnSchema } from '../contract/chat.js'
7
+ import { keyToString, type ReviewKey } from '../contract/review-key.js'
7
8
  import type { Repo } from '../contract/review-artifact.js'
8
9
  import type { ChatThread } from '../contract/state.js'
9
10
  import { isNotFound } from '../store/atomic-json.js'
@@ -17,19 +18,19 @@ export function slug(value: string): string {
17
18
  .slice(0, 40)
18
19
  }
19
20
 
20
- export function threadName(repo: Repo, prNumber: number, agent: string, index: number): string {
21
- return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${prNumber}-${slug(agent)}-t${index}`
21
+ export function threadName(repo: Repo, key: ReviewKey, agent: string, index: number): string {
22
+ return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${keyToString(key)}-${slug(agent)}-t${index}`
22
23
  }
23
24
 
24
- const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+)-[a-z0-9-]+-t(\d+)$/
25
+ const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+|branch|uncommitted)-[a-z0-9-]+-t(\d+)$/
25
26
 
26
- /** True when the name is one this tool could have made for this pull request. */
27
- export function isThreadNameFor(name: string, prNumber: number): boolean {
27
+ /** True when the name is one this tool could have made for this target. */
28
+ export function isThreadNameFor(name: string, key: ReviewKey): boolean {
28
29
  const m = NAME_RE.exec(name)
29
- return m !== null && m[1] === String(prNumber)
30
+ return m !== null && m[1] === keyToString(key)
30
31
  }
31
32
 
32
- /** The next `t<k>`: one past the highest index already used on this pull request. */
33
+ /** The next `t<k>`: one past the highest index already used on this target. */
33
34
  export function nextThreadIndex(threads: readonly ChatThread[]): number {
34
35
  let highest = 0
35
36
  for (const thread of threads) {
@@ -55,22 +56,22 @@ export function threadTitle(firstMessage: string): string {
55
56
  }
56
57
 
57
58
  export interface TranscriptStore {
58
- dir(prNumber: number): string
59
- file(prNumber: number, name: string): string
60
- eventsFile(prNumber: number, name: string): string
61
- read(prNumber: number, name: string): Promise<ChatTurn[]>
62
- append(prNumber: number, name: string, turn: ChatTurn): Promise<void>
59
+ dir(key: ReviewKey): string
60
+ file(key: ReviewKey, name: string): string
61
+ eventsFile(key: ReviewKey, name: string): string
62
+ read(key: ReviewKey, name: string): Promise<ChatTurn[]>
63
+ append(key: ReviewKey, name: string, turn: ChatTurn): Promise<void>
63
64
  /** Appends one already-scrubbed acpx line to the raw event log. */
64
- appendEvent(prNumber: number, name: string, line: string): Promise<void>
65
+ appendEvent(key: ReviewKey, name: string, line: string): Promise<void>
65
66
  }
66
67
 
67
- export function createTranscriptStore(prDir: (prNumber: number) => string): TranscriptStore {
68
- const dir = (prNumber: number): string => path.join(prDir(prNumber), 'chat')
69
- const named = (prNumber: number, name: string, suffix: string): string => {
70
- if (!isThreadNameFor(name, prNumber)) {
71
- throw new Error(`not a chat thread of pull request ${prNumber}: ${name}`)
68
+ export function createTranscriptStore(prDir: (key: ReviewKey) => string): TranscriptStore {
69
+ const dir = (key: ReviewKey): string => path.join(prDir(key), 'chat')
70
+ const named = (key: ReviewKey, name: string, suffix: string): string => {
71
+ if (!isThreadNameFor(name, key)) {
72
+ throw new Error(`not a chat thread of ${keyToString(key)}: ${name}`)
72
73
  }
73
- return path.join(dir(prNumber), `${name}${suffix}`)
74
+ return path.join(dir(key), `${name}${suffix}`)
74
75
  }
75
76
  const appendLine = async (file: string, line: string): Promise<void> => {
76
77
  await mkdir(path.dirname(file), { recursive: true })
@@ -78,12 +79,12 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
78
79
  }
79
80
  return {
80
81
  dir,
81
- file: (prNumber, name) => named(prNumber, name, '.jsonl'),
82
- eventsFile: (prNumber, name) => named(prNumber, name, '.events.ndjson'),
83
- async read(prNumber, name) {
82
+ file: (key, name) => named(key, name, '.jsonl'),
83
+ eventsFile: (key, name) => named(key, name, '.events.ndjson'),
84
+ async read(key, name) {
84
85
  let text: string
85
86
  try {
86
- text = await readFile(named(prNumber, name, '.jsonl'), 'utf8')
87
+ text = await readFile(named(key, name, '.jsonl'), 'utf8')
87
88
  } catch (err) {
88
89
  if (isNotFound(err)) {
89
90
  return []
@@ -108,7 +109,7 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
108
109
  }
109
110
  return turns
110
111
  },
111
- append: (prNumber, name, turn) => appendLine(named(prNumber, name, '.jsonl'), JSON.stringify(turn)),
112
- appendEvent: (prNumber, name, line) => appendLine(named(prNumber, name, '.events.ndjson'), line),
112
+ append: (key, name, turn) => appendLine(named(key, name, '.jsonl'), JSON.stringify(turn)),
113
+ appendEvent: (key, name, line) => appendLine(named(key, name, '.events.ndjson'), line),
113
114
  }
114
115
  }
package/src/cli.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
19
19
  import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
20
20
  import { createGit } from './git/git.js'
21
- import { createGitHubClient } from './github/gh.js'
21
+ import { createHostClient } from './host/client.js'
22
22
  import { loadProjectConfig } from './project-config.js'
23
23
  import { checkSkill } from './review/doctor.js'
24
24
  import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
@@ -41,7 +41,12 @@ const USAGE = `usage: pr-review <command> [flags]
41
41
 
42
42
  serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
43
43
  [--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
44
- prepare (--pr <n> | --base <ref> --head <ref>) [--force] [--repo <dir>] [--data-dir <dir>]
44
+ prepare (--pr <n> | --branch | --uncommitted | --base <ref> --head <ref>) [--force]
45
+ [--base <ref>] [--repo <dir>] [--data-dir <dir>]
46
+ (--branch reviews the current branch against the default branch, and
47
+ --uncommitted reviews it with the working tree's edits and new files on top.
48
+ Both are for work with no PR yet, take --base <ref> to compare against
49
+ another branch, and show at /review/branch and /review/uncommitted.)
45
50
  validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
46
51
  (--fix trims over-cap titles in place and reports each one)
47
52
  publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
@@ -52,7 +57,7 @@ const USAGE = `usage: pr-review <command> [flags]
52
57
  doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
53
58
 
54
59
  Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
55
- Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
60
+ Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
56
61
  `
57
62
 
58
63
  const io: CliIo = {
@@ -125,15 +130,15 @@ async function serve(argv: string[]): Promise<number> {
125
130
  return EXIT.ok
126
131
  }
127
132
 
128
- /** doctor builds no AppContext: it has to answer even when the repo or `gh` is the problem. */
133
+ /** doctor builds no AppContext: it has to answer even when the repo or host CLI is the problem. */
129
134
  async function doctorCommand(argv: string[]): Promise<number> {
130
135
  const { repo, dataDir, rest } = splitCommonFlags(argv)
131
136
  const cwd = process.cwd()
132
- const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
133
137
  return runDoctor(
134
138
  {
135
- git,
136
- gh: createGitHubClient(),
139
+ git: createGit(repo === undefined ? cwd : path.resolve(cwd, repo)),
140
+ env: process.env,
141
+ client: host => createHostClient(host.cli),
137
142
  version: readPackageVersion(),
138
143
  acpxVersion: () => createAgentRunner().acpxVersion(),
139
144
  dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
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
  }
@@ -308,6 +335,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
308
335
  harness: parseHarness(values.harness),
309
336
  allowStale: values['allow-stale'] === true,
310
337
  })
338
+ if (result.sharing.status === 'failed')
339
+ io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
311
340
  printJson(io, result)
312
341
  return EXIT.ok
313
342
  }
@@ -368,8 +397,8 @@ async function resolveHead(
368
397
  if (prNumber === undefined) {
369
398
  throw new UsageError('export needs --pr <n> or --head <ref|sha>')
370
399
  }
371
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
372
- 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)
373
402
  return { headSha, prNumber }
374
403
  }
375
404
 
@@ -387,7 +416,7 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
387
416
  out: values.out,
388
417
  })
389
418
  printJson(io, result)
390
- 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`)
391
420
  return EXIT.ok
392
421
  }
393
422
 
@@ -445,9 +474,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
445
474
  const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
446
475
  if (values.pr !== undefined) {
447
476
  const prNumber = parsePrNumber(values.pr)
448
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
477
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
449
478
  options.prNumber = prNumber
450
- options.currentHeadSha = (await fetchPrRefs(ctx.git, meta)).headSha
479
+ options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
451
480
  }
452
481
  printJson(io, await importCanvas(ctx, options))
453
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. */
@@ -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
  }
@@ -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
  })
@@ -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,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(),
@@ -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
  }