@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
@@ -2,16 +2,25 @@
2
2
  // the canvas directory. The agent reads those two files; publish reads context.json back.
3
3
  import { appendFile, readdir, rm } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { type GenerationContext, isLargePr, type PrepareTarget } from '../contract/generation-context.js'
5
+ import {
6
+ type GenerationContext,
7
+ isLargePr,
8
+ type PrepareTarget,
9
+ type PrepareTargetInput,
10
+ } from '../contract/generation-context.js'
6
11
  import { effectiveCaps, LIMITS, type Pr } from '../contract/review-artifact.js'
7
- import { fetchPrMeta, fetchPrRefs, toPr } from '../github/pr.js'
12
+ import type { LocalKey } from '../contract/review-key.js'
13
+ import { describeLocalWork, resolveLocalBase, UNCOMMITTED_STATE } from '../git/local-target.js'
14
+ import { fetchPrRefs } from '../git/pr-refs.js'
15
+
16
+ import { toPr } from '../host/pr.js'
8
17
  import type { AppContext } from '../server/context.js'
9
18
  import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
10
19
  import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
11
20
 
12
21
  export interface PrepareOptions {
13
22
  force: boolean
14
- /** Progress lines: `fetch-pr`, `fetch-refs`, `collect-diffs`, `prompt`. */
23
+ /** Progress lines: `fetch-pr`, `fetch-refs`, `snapshot`, `collect-diffs`, `prompt`. */
15
24
  log: (phase: string) => void
16
25
  promptSources?: PromptSources
17
26
  }
@@ -23,21 +32,50 @@ export interface PrepareResult {
23
32
  promptPath: string
24
33
  contextPath: string
25
34
  status: 'prepared' | 'exists'
35
+ /** Local targets only: which review it is, the base resolved for it, and what its head holds. */
36
+ local?: { review: LocalKey; base: string; headRef: string; uncommitted: boolean }
26
37
  }
27
38
 
28
39
  /** The PR meta, live from GitHub, with the head and base refs fetched into the local clone. */
29
40
  async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['log']): Promise<Pr> {
30
41
  log('fetch-pr')
31
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
42
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, number)
32
43
  log('fetch-refs')
33
- const shas = await fetchPrRefs(ctx.git, meta)
44
+ const shas = await fetchPrRefs(ctx.git, ctx.config.host, meta)
34
45
  const pr = toPr(meta, ctx.config.repo, shas)
35
- await ctx.prs.writePr(pr)
46
+ await ctx.prs.writePr(number, pr)
47
+ return pr
48
+ }
49
+
50
+ /**
51
+ * The work in this clone that has no pull request yet: the current branch, or a snapshot commit
52
+ * of the working tree when it carries edits. The meta is cached under `prs/<branch|uncommitted>/`,
53
+ * which is what the matching page reads.
54
+ */
55
+ async function resolveLocal(
56
+ ctx: AppContext,
57
+ target: Extract<PrepareTarget, { kind: 'local' }>,
58
+ log: PrepareOptions['log']
59
+ ): Promise<Pr> {
60
+ log('snapshot')
61
+ const pr = await describeLocalWork(ctx.git, {
62
+ base: target.base,
63
+ source: target.source,
64
+ repo: ctx.config.repo,
65
+ now: ctx.now,
66
+ })
67
+ await ctx.prs.writePr(target.source, pr)
68
+ await ctx.prs.writeLocalTarget(target.source, target)
36
69
  return pr
37
70
  }
38
71
 
39
72
  /** A change set before a PR exists: the refs must already be in the clone. */
40
- async function resolveRefs(ctx: AppContext, base: string, head: string, log: PrepareOptions['log']): Promise<Pr> {
73
+ async function resolveRefs(
74
+ ctx: AppContext,
75
+ base: string,
76
+ head: string,
77
+ log: PrepareOptions['log']
78
+ ): Promise<Pr> {
41
79
  log('fetch-refs')
42
80
  const headSha = await ctx.git.revParse(head)
43
81
  const mergeBaseSha = await ctx.git.mergeBase(base, headSha)
@@ -48,7 +86,7 @@ async function resolveRefs(ctx: AppContext, base: string, head: string, log: Pre
48
86
  title: head,
49
87
  body: '',
50
88
  author,
51
- url: `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
89
+ url: ctx.config.host.compareUrl(repo, base, head),
52
90
  state: 'pre-pr',
53
91
  draft: false,
54
92
  updatedAt: ctx.now().toISOString(),
@@ -93,21 +131,44 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
93
131
  }
94
132
  }
95
133
 
96
- export async function prepare(ctx: AppContext, target: PrepareTarget, opts: PrepareOptions): Promise<PrepareResult> {
134
+ /** The target with its base resolved, which is the form `context.json` records. */
135
+ async function resolveTarget(ctx: AppContext, input: PrepareTargetInput): Promise<PrepareTarget> {
136
+ if (input.kind !== 'local') {
137
+ return input
138
+ }
139
+ return { kind: 'local', source: input.source, base: await resolveLocalBase(ctx.git, input.base) }
140
+ }
141
+
142
+ export async function prepare(
143
+ ctx: AppContext,
144
+ input: PrepareTargetInput,
145
+ opts: PrepareOptions
146
+ ): Promise<PrepareResult> {
147
+ const target = await resolveTarget(ctx, input)
97
148
  const pr =
98
149
  target.kind === 'pr'
99
150
  ? await resolvePr(ctx, target.number, opts.log)
100
- : await resolveRefs(ctx, target.base, target.head, opts.log)
151
+ : target.kind === 'local'
152
+ ? await resolveLocal(ctx, target, opts.log)
153
+ : await resolveRefs(ctx, target.base, target.head, opts.log)
101
154
  const canvasDir = ctx.canvases.canvasDir(pr.headSha)
102
155
  const promptPath = path.join(canvasDir, 'prompt.md')
103
156
  const contextPath = path.join(canvasDir, 'context.json')
104
- const result = {
157
+ const result: Omit<PrepareResult, 'status'> = {
105
158
  canvasDir,
106
159
  headSha: pr.headSha,
107
160
  mergeBaseSha: pr.mergeBaseSha,
108
161
  promptPath,
109
162
  contextPath,
110
163
  }
164
+ if (target.kind === 'local') {
165
+ result.local = {
166
+ review: target.source,
167
+ base: target.base,
168
+ headRef: pr.headRef,
169
+ uncommitted: pr.state === UNCOMMITTED_STATE,
170
+ }
171
+ }
111
172
  if (!opts.force && (await ctx.canvases.exists(pr.headSha))) {
112
173
  return { ...result, status: 'exists' }
113
174
  }
@@ -116,7 +177,10 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
116
177
  const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
117
178
  const additions = derived.files.reduce((n, f) => n + f.additions, 0)
118
179
  const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
119
- const fullPr: Pr = target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
180
+ // A pull request's counts are the forge's; anything else is counted from the diff itself. The
181
+ // page derives its own from the files it shows, so this is only what the canvas records.
182
+ const fullPr: Pr =
183
+ target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
120
184
  const derivedDir = ctx.derived.derivedDir(pr.headSha)
121
185
  const config = ctx.projectConfig.config
122
186
 
@@ -152,14 +216,20 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
152
216
  largePr: isLargePr({ files: derived.files.length, additions, deletions }),
153
217
  preparedAt: ctx.now().toISOString(),
154
218
  }
155
- const sources = opts.promptSources ?? (await loadPromptSources(undefined, {
156
- repoRoot: ctx.config.repoRoot,
157
- overrides: ctx.projectConfig.config.prompts,
158
- }))
219
+ const sources =
220
+ opts.promptSources ??
221
+ (await loadPromptSources(undefined, {
222
+ repoRoot: ctx.config.repoRoot,
223
+ overrides: ctx.projectConfig.config.prompts,
224
+ }))
159
225
  await clearCanvasDir(canvasDir)
160
226
  await writeTextAtomic(promptPath, renderPrompt(context, derived.patches, sources))
161
227
  await writeJsonAtomic(contextPath, context)
162
228
  // The log keeps the whole history; publish counts attempts from this line on.
163
- await appendFile(path.join(canvasDir, 'publish.log'), `${context.preparedAt} prepared ${pr.headSha}\n`, 'utf8')
229
+ await appendFile(
230
+ path.join(canvasDir, 'publish.log'),
231
+ `${context.preparedAt} prepared ${pr.headSha}\n`,
232
+ 'utf8'
233
+ )
164
234
  return { ...result, status: 'prepared' }
165
235
  }
@@ -71,7 +71,9 @@ function configuredLayersMarkdown(ctx: GenerationContext): string {
71
71
  return ctx.defaultLayers
72
72
  .map((l, i) => {
73
73
  const paths =
74
- l.paths === undefined || l.paths.length === 0 ? '' : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
74
+ l.paths === undefined || l.paths.length === 0
75
+ ? ''
76
+ : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
75
77
  return `${i + 1}. \`${l.id}\` **${l.title}** — ${l.description}${paths}`
76
78
  })
77
79
  .join('\n')
@@ -196,7 +198,11 @@ export function schemaMarkdown(ctx: GenerationContext): string {
196
198
  }
197
199
 
198
200
  /** Selects one task and fills its data and format placeholders before the generator sees it. */
199
- export function renderPrompt(ctx: GenerationContext, patches: Record<string, string>, sources: PromptSources): string {
201
+ export function renderPrompt(
202
+ ctx: GenerationContext,
203
+ patches: Record<string, string>,
204
+ sources: PromptSources
205
+ ): string {
200
206
  const tokens: Record<string, string> = {
201
207
  TARGET_WORD: ctx.target.kind === 'pr' ? 'pull request' : 'change set',
202
208
  META: metaMarkdown(ctx),
@@ -1,14 +1,18 @@
1
1
  // `pr-review publish`: validate model.json against context.json, normalize, and store the
2
- // canvas. Nothing is written when the report is not clean.
2
+ // canvas, then share it on the PR/MR. Invalid models are never stored or shared.
3
+ import { buildCanvasComment } from '../canvas/comment.js'
4
+ import { buildCanvasZipFor, exportCanvas } from '../canvas/export.js'
3
5
  import { appendFile } from 'node:fs/promises'
4
6
  import path from 'node:path'
5
7
  import type { CanvasManifest } from '../contract/canvas-manifest.js'
6
8
  import { type GenerationContext, GenerationContextSchema } from '../contract/generation-context.js'
7
9
  import type { Generator, ReviewArtifact } from '../contract/review-artifact.js'
10
+ import { UNCOMMITTED_STATE, resolveLocalHead } from '../git/local-target.js'
8
11
  import type { ValidationError, ValidationReport } from '../contract/validation.js'
9
- import { fetchPrMeta } from '../github/pr.js'
12
+ import { fetchPrRefs } from '../git/pr-refs.js'
10
13
  import type { AppContext } from '../server/context.js'
11
14
  import { readJson, readText } from '../store/atomic-json.js'
15
+ import { standsForHead } from './carry-over.js'
12
16
  import { normalize } from './normalize.js'
13
17
  import { coveredTestPaths, type ValidationInput, validateModelOutput } from './validate.js'
14
18
 
@@ -24,7 +28,11 @@ export interface PublishResult {
24
28
  headSha: string
25
29
  reviewJsonPath: string
26
30
  attempts: number
27
- /** Where the canvas shows once the server runs; absent for a change set without a PR. */
31
+ sharing:
32
+ | { status: 'shared'; url: string }
33
+ | { status: 'failed'; warning: string; zipPath: string }
34
+ | { status: 'local' }
35
+ /** Where the canvas shows once the server runs; absent only for a `--base/--head` change set. */
28
36
  reviewUrl?: string
29
37
  }
30
38
 
@@ -75,17 +83,40 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
75
83
  const file = path.join(canvasDir, 'model.json')
76
84
  const text = await readText(file)
77
85
  if (text === null) {
78
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'write the model output there and publish again')
86
+ throw new PublishError(
87
+ 'NOT_FOUND',
88
+ `${file} does not exist`,
89
+ 'write the model output there and publish again'
90
+ )
79
91
  }
80
92
  return parseModelText(text, 'model.json')
81
93
  }
82
94
 
83
- /** The current head of the target; a push during generation makes the prepared context stale. */
84
- async function currentHead(ctx: AppContext, context: GenerationContext): Promise<string> {
95
+ /**
96
+ * The current head of the target; a push during generation makes the prepared context stale. For
97
+ * a pull request whose head moved, the head and base are fetched again, and a head whose diff is
98
+ * identical to the prepared commit's still counts as that commit. For a local target the working
99
+ * tree is snapshotted again, so an edit made while the agent worked is caught the same way a push
100
+ * is.
101
+ */
102
+ async function currentHead(
103
+ ctx: AppContext,
104
+ context: GenerationContext
105
+ ): Promise<{ headSha: string; moved: boolean }> {
85
106
  if (context.target.kind === 'pr') {
86
- return (await fetchPrMeta(ctx.gh, ctx.config.repo, context.target.number)).headSha
107
+ const { host, repo } = ctx.config
108
+ const meta = await host.fetchPrMeta(ctx.gh, repo, context.target.number)
109
+ if (meta.headSha === context.headSha || !ctx.projectConfig.config.canvas.keepForIdenticalDiff) {
110
+ return { headSha: meta.headSha, moved: meta.headSha !== context.headSha }
111
+ }
112
+ const head = await fetchPrRefs(ctx.git, host, meta)
113
+ return { headSha: head.headSha, moved: !(await standsForHead(ctx, head, context)) }
87
114
  }
88
- return ctx.git.revParse(context.target.head)
115
+ const headSha =
116
+ context.target.kind === 'local'
117
+ ? (await resolveLocalHead(ctx.git, context.target.source)).headSha
118
+ : await ctx.git.revParse(context.target.head)
119
+ return { headSha, moved: headSha !== context.headSha }
89
120
  }
90
121
 
91
122
  /** Publish runs since prepare last wrote a context (its `prepared` line), this one included. */
@@ -141,7 +172,11 @@ export async function validationInput(
141
172
  }
142
173
  }
143
174
 
144
- export function buildManifest(context: GenerationContext, artifact: ReviewArtifact, version: string): CanvasManifest {
175
+ export function buildManifest(
176
+ context: GenerationContext,
177
+ artifact: ReviewArtifact,
178
+ version: string
179
+ ): CanvasManifest {
145
180
  const manifest: CanvasManifest = {
146
181
  formatVersion: 1,
147
182
  tool: { name: 'pr-review', version },
@@ -159,14 +194,18 @@ export function buildManifest(context: GenerationContext, artifact: ReviewArtifa
159
194
  return manifest
160
195
  }
161
196
 
162
- export async function publish(ctx: AppContext, canvasDir: string, opts: PublishOptions): Promise<PublishResult> {
197
+ export async function publish(
198
+ ctx: AppContext,
199
+ canvasDir: string,
200
+ opts: PublishOptions
201
+ ): Promise<PublishResult> {
163
202
  const context = await readContext(canvasDir)
164
203
  if (!opts.allowStale) {
165
204
  const head = await currentHead(ctx, context)
166
- if (head !== context.headSha) {
205
+ if (head.moved) {
167
206
  throw new PublishError(
168
207
  'CANVAS_STALE',
169
- `the target moved to ${head.slice(0, 7)} while this canvas was prepared for ${context.headSha.slice(0, 7)}`,
208
+ `the target moved to ${head.headSha.slice(0, 7)} while this canvas was prepared for ${context.headSha.slice(0, 7)}`,
170
209
  'run `pr-review prepare` again, or pass --allow-stale to publish for the old commit'
171
210
  )
172
211
  }
@@ -195,15 +234,39 @@ export async function publish(ctx: AppContext, canvasDir: string, opts: PublishO
195
234
  testPatterns: context.tests.patterns,
196
235
  })
197
236
  const manifest = buildManifest(context, artifact, ctx.version)
198
- await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber)
237
+ // A snapshot commit is on no branch, so it must never be offered as a pull request's canvas.
238
+ const worktree = context.target.kind === 'local' && context.pr.state === UNCOMMITTED_STATE
239
+ await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber, { worktree })
240
+ if (worktree) {
241
+ // The snapshot ref moves with the working tree. This canvas stays, and the page reads its
242
+ // diffs from its own commit, so it gets an anchor that the next edit cannot take away.
243
+ await ctx.git.anchorCommit(context.headSha)
244
+ }
199
245
  const published: PublishResult = {
200
246
  status: 'published',
247
+ sharing: { status: 'local' },
201
248
  headSha: context.headSha,
202
249
  reviewJsonPath: path.join(ctx.canvases.canvasDir(context.headSha), 'review.json'),
203
250
  attempts,
204
251
  }
252
+ if (context.target.kind === 'local') {
253
+ published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.source}`
254
+ }
205
255
  if (context.target.kind === 'pr') {
206
256
  published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.number}`
257
+ try {
258
+ const zip = await buildCanvasZipFor(ctx, context.headSha, context.target.number)
259
+ const body = buildCanvasComment(zip, ctx.config.host.canvasCommentLimit)
260
+ const url = await ctx.config.host.shareCanvas(ctx.gh, ctx.config.repo, context.target.number, body)
261
+ published.sharing = { status: 'shared', url }
262
+ } catch (err) {
263
+ const exported = await exportCanvas(ctx, { headSha: context.headSha, prNumber: context.target.number })
264
+ published.sharing = {
265
+ status: 'failed',
266
+ warning: `Automatic canvas sharing failed: ${err instanceof Error ? err.message : String(err)}. Upload the ZIP to the ${ctx.config.host.noun} description manually.`,
267
+ zipPath: exported.path,
268
+ }
269
+ }
207
270
  }
208
271
  return published
209
272
  }
@@ -1,14 +1,15 @@
1
1
  // The same reviewed rules the page uses, so the body and the header never disagree.
2
2
  import { layerProgress } from '../../static/js/progress.js'
3
3
  import type { CommentsPayload } from '../contract/comments.js'
4
- import type { Layer, ReviewArtifact } from '../contract/review-artifact.js'
4
+ import type { Layer, Pr, ReviewArtifact } from '../contract/review-artifact.js'
5
5
  import type { PrState } from '../contract/state.js'
6
+ import type { CanvasLookup } from '../store/canvas-store.js'
6
7
 
7
8
  export const REVIEW_BODY_FOOTER = 'Reviewed with the pr-review canvas (localhost).'
8
9
 
9
10
  /**
10
11
  * Model text on one line of a list. Markdown characters are escaped so a title cannot open a
11
- * heading, a link, or a code span in the review GitHub publishes.
12
+ * heading, a link, or a code span in the published review.
12
13
  */
13
14
  export function inlineText(text: string): string {
14
15
  return text
@@ -18,11 +19,22 @@ export function inlineText(text: string): string {
18
19
  }
19
20
 
20
21
  /**
21
- * The state as it applies to one commit. Marks made on another commit, and marks whose commit
22
+ * The state as it applies to one canvas. Marks made on another canvas, and marks whose canvas
22
23
  * is unknown, describe other code, so they count for nothing here.
23
24
  */
24
- export function stateForHead(state: PrState, headSha: string): PrState {
25
- return state.reviewedHeadSha === headSha ? state : { ...state, reviewed: {} }
25
+ export function stateForCanvas(state: PrState, canvasSha: string): PrState {
26
+ return state.reviewedCanvasSha === canvasSha ? state : { ...state, reviewed: {} }
27
+ }
28
+
29
+ /**
30
+ * The commit the marks on this page describe: the commit of the canvas on the page, whether it is
31
+ * current, carried over, or read as outdated, since its diff is what the reviewer looked at. The
32
+ * head only when there is no canvas. Keyed this way, a carried-over canvas keeps its marks however
33
+ * often the head moves, and marks made on an outdated canvas never credit a later one. The page
34
+ * sends this commit back with every mark it makes, as `bundle.canvas.headSha`.
35
+ */
36
+ export function reviewedCommit(found: CanvasLookup, pr: Pr): string {
37
+ return found.status === 'missing' ? pr.headSha : found.headSha
26
38
  }
27
39
 
28
40
  /** The layers a human still has to look at. Empty means approve is allowed. */
@@ -1,4 +1,6 @@
1
- /** The exact command the user runs in Claude Code or Codex to generate a canvas for a PR. */
2
- export function buildSkillCommand(prNumber: number, opts: { force: boolean }): string {
3
- return `/pr-review-canvas ${prNumber}${opts.force ? ' --force' : ''}`
1
+ import { keyToString, type ReviewKey } from '../contract/review-key.js'
2
+
3
+ /** The exact command the user runs in Claude Code or Codex to generate a canvas for a target. */
4
+ export function buildSkillCommand(key: ReviewKey, opts: { force: boolean }): string {
5
+ return `/pr-review-canvas ${keyToString(key)}${opts.force ? ' --force' : ''}`
4
6
  }
@@ -77,7 +77,10 @@ interface TitledOutput {
77
77
  * Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
78
78
  * call, not the tool's.
79
79
  */
80
- export function applyTitleTrims(output: unknown, caps: { layerTitle: number; pointTitle: number }): TitleTrim[] {
80
+ export function applyTitleTrims(
81
+ output: unknown,
82
+ caps: { layerTitle: number; pointTitle: number }
83
+ ): TitleTrim[] {
81
84
  const trims: TitleTrim[] = []
82
85
  const trim = (where: string, holder: { title?: string }, cap: number): void => {
83
86
  if (typeof holder.title !== 'string') {
@@ -89,11 +92,12 @@ export function applyTitleTrims(output: unknown, caps: { layerTitle: number; poi
89
92
  holder.title = trimmed
90
93
  } else if (visibleLength(holder.title) > cap) {
91
94
  const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
92
- const reason = match === null
93
- ? 'no explainer separator (:, —, – or -) to drop'
94
- : match.index === 0
95
- ? 'dropping the explainer would leave an empty title'
96
- : 'the title before the explainer still exceeds the cap'
95
+ const reason =
96
+ match === null
97
+ ? 'no explainer separator (:, —, – or -) to drop'
98
+ : match.index === 0
99
+ ? 'dropping the explainer would leave an empty title'
100
+ : 'the title before the explainer still exceeds the cap'
97
101
  trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
98
102
  }
99
103
  }
@@ -83,13 +83,13 @@ function validateFileFolds(
83
83
 
84
84
  for (const [index, fold] of folds.entries()) {
85
85
  if (assignedHunk(fold, file, hunks) === null) {
86
- fail(`fold ${index + 1} must be an ordered range inside one hunk assigned to this file in this layer`)
86
+ fail(`fold ${index + 1} must be an ordered range inside one chunk assigned to this file in this layer`)
87
87
  continue
88
88
  }
89
89
 
90
90
  const earlierFolds = folds.slice(0, index)
91
91
  if (earlierFolds.some(earlier => rangesOverlap(fold, earlier, hunks))) {
92
- fail(`fold ${index + 1} overlaps an earlier fold or uses another coordinate side in the same hunk`)
92
+ fail(`fold ${index + 1} overlaps an earlier fold or uses another coordinate side in the same chunk`)
93
93
  }
94
94
 
95
95
  if (protectedCode.some(range => rangesOverlap(fold, range, hunks))) {
@@ -152,7 +152,11 @@ function checkLengths(output: ModelOutput, caps: TextCaps, report: Report): void
152
152
  f.folds?.forEach((fold, k) => {
153
153
  const where = `${at}.files.${j}.folds.${k}.title`
154
154
  if (fold.title.length > caps.pointTitle) {
155
- report.add('TEXT_TOO_LONG', where, `${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`)
155
+ report.add(
156
+ 'TEXT_TOO_LONG',
157
+ where,
158
+ `${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`
159
+ )
156
160
  }
157
161
  })
158
162
  f.annotations.forEach((a, k) => {
@@ -213,12 +217,16 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
213
217
  for (const id of file.hunks) {
214
218
  const hit = index.byHunkId.get(id)
215
219
  if (hit === undefined) {
216
- const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} hunks)`
220
+ const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} chunks)`
217
221
  report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} does not exist${hint}`)
218
222
  continue
219
223
  }
220
224
  if (hit.file.path !== file.path) {
221
- report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`)
225
+ report.add(
226
+ 'HUNK_UNKNOWN',
227
+ where,
228
+ `${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`
229
+ )
222
230
  continue
223
231
  }
224
232
  const first = owner.get(id)
@@ -236,7 +244,11 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
236
244
  }
237
245
  for (const { file, hunk } of index.byHunkId.values()) {
238
246
  if (!owner.has(hunk.id)) {
239
- report.add('HUNK_UNASSIGNED', `hunk:${hunk.id}`, `${hunk.id} in ${file.path} (${hunk.header}) is in no layer`)
247
+ report.add(
248
+ 'HUNK_UNASSIGNED',
249
+ `hunk:${hunk.id}`,
250
+ `${hunk.id} in ${file.path} (${hunk.header}) is in no layer`
251
+ )
240
252
  }
241
253
  }
242
254
  return owner
@@ -248,12 +260,16 @@ function checkLayers(output: ModelOutput, report: Report): void {
248
260
  const where = `layer:${layer.key}`
249
261
  const seen = keys.get(layer.key)
250
262
  if (seen !== undefined) {
251
- report.add('LAYER_KEY_DUPLICATE', where, `layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`)
263
+ report.add(
264
+ 'LAYER_KEY_DUPLICATE',
265
+ where,
266
+ `layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`
267
+ )
252
268
  } else {
253
269
  keys.set(layer.key, i)
254
270
  }
255
271
  if (layer.files.length === 0) {
256
- report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no hunks`)
272
+ report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no chunks`)
257
273
  }
258
274
  })
259
275
  // Other is optional: at most one, and last when present.
@@ -268,7 +284,11 @@ function checkLayers(output: ModelOutput, report: Report): void {
268
284
  }
269
285
  const last = output.layers[output.layers.length - 1]
270
286
  if (first !== undefined && first !== last) {
271
- report.add('OTHER_NOT_LAST', `layer:${first.key}`, `layer ${first.key} (kind other) must be the last layer`)
287
+ report.add(
288
+ 'OTHER_NOT_LAST',
289
+ `layer:${first.key}`,
290
+ `layer ${first.key} (kind other) must be the last layer`
291
+ )
272
292
  }
273
293
  }
274
294
 
@@ -308,7 +328,11 @@ function checkTests(
308
328
  continue
309
329
  }
310
330
  if (t.testPath === undefined) {
311
- report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`)
331
+ report.add(
332
+ 'TEST_PATH_UNKNOWN',
333
+ where,
334
+ `${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`
335
+ )
312
336
  } else if (!(index.byPath.has(t.testPath) || headPaths.has(t.testPath))) {
313
337
  report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: ${t.testPath} is not in the PR head`)
314
338
  }
@@ -339,12 +363,20 @@ function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], repor
339
363
  }
340
364
  const where = `layer:${other.key}`
341
365
  for (const tag of other.risk ?? []) {
342
- report.add('RISK_IN_OTHER', where, `other: carries the risk tag "${tag.label}"; move the hunks to a real layer`)
366
+ report.add(
367
+ 'RISK_IN_OTHER',
368
+ where,
369
+ `other: carries the risk tag "${tag.label}"; move the chunks to a real layer`
370
+ )
343
371
  }
344
372
  for (const file of other.files) {
345
373
  const rule = highRisk.find(r => matchesGlob(r.pattern, file.path))
346
374
  if (rule !== undefined) {
347
- report.add('RISK_IN_OTHER', where, `other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`)
375
+ report.add(
376
+ 'RISK_IN_OTHER',
377
+ where,
378
+ `other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`
379
+ )
348
380
  }
349
381
  }
350
382
  }
@@ -368,9 +400,17 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
368
400
  const start = hunkAt(entry, a.side, a.startLine)
369
401
  const end = hunkAt(entry, a.side, a.endLine)
370
402
  if (start === null || end === null || start.id !== end.id) {
371
- report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`)
403
+ report.add(
404
+ 'ANNOTATION_OUTSIDE_HUNK',
405
+ where,
406
+ `${label} is not inside one chunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
407
+ )
372
408
  } else if (!file.hunks.includes(start.id)) {
373
- report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is in ${start.id}, which this layer does not list`)
409
+ report.add(
410
+ 'ANNOTATION_OUTSIDE_HUNK',
411
+ where,
412
+ `${label} is in ${start.id}, which this layer does not list`
413
+ )
374
414
  }
375
415
  })
376
416
  }
@@ -378,7 +418,10 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
378
418
  }
379
419
 
380
420
  function checkPoints(output: ModelOutput, index: Index, limits: Limits, report: Report): void {
381
- const missingTests = output.layers.reduce((n, l) => n + l.tests.filter(t => t.status === 'missing').length, 0)
421
+ const missingTests = output.layers.reduce(
422
+ (n, l) => n + l.tests.filter(t => t.status === 'missing').length,
423
+ 0
424
+ )
382
425
  const total = output.points.length + missingTests
383
426
  if (total > limits.maxPoints) {
384
427
  report.add(
@@ -464,7 +507,12 @@ function checkLinks(output: ModelOutput, files: readonly FileEntry[], report: Re
464
507
  * The sidecar map of a layer's diagram: each key names a node of the mermaid source, each value
465
508
  * is a canvas link, and a diagram carries at most `maxDiagramLinks` of them.
466
509
  */
467
- function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], limits: Limits, report: Report): void {
510
+ function checkDiagramLinks(
511
+ output: ModelOutput,
512
+ files: readonly FileEntry[],
513
+ limits: Limits,
514
+ report: Report
515
+ ): void {
468
516
  const targets: LinkTargets = { layers: output.layers, files }
469
517
  for (const layer of output.layers) {
470
518
  if (layer.diagram === undefined) {
@@ -474,7 +522,11 @@ function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], lim
474
522
  const label = `${layerLabel(layer)} diagram`
475
523
  const entries = Object.entries(layer.diagram.links)
476
524
  if (entries.length > limits.maxDiagramLinks) {
477
- report.add('DIAGRAM_LIMIT', where, `${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`)
525
+ report.add(
526
+ 'DIAGRAM_LIMIT',
527
+ where,
528
+ `${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`
529
+ )
478
530
  }
479
531
  const kind = diagramKind(layer.diagram.mermaid)
480
532
  const nodes = diagramNodeIds(layer.diagram.mermaid)
package/src/server/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Context, Hono } from 'hono'
2
2
  import type { AppContext } from './context.js'
3
3
  import type { AppEnv } from './env.js'
4
- import { AppError, toAppError } from './errors.js'
4
+ import { AppError, logRequestError, toAppError } from './errors.js'
5
5
  import { errorPage } from './html.js'
6
6
  import { apiRoutes } from './routes/api.js'
7
7
  import { appearanceFor, appearanceQuery, pageRoutes } from './routes/pages.js'
@@ -29,14 +29,22 @@ export function createApp(ctx: AppContext): Hono<AppEnv> {
29
29
  const nonce = c.get('cspNonce') ?? createNonce()
30
30
  const res = wantsJson(c.req.path)
31
31
  ? c.json(envelope, err.status)
32
- : await c.html(errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))), err.status)
32
+ : await c.html(
33
+ errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))),
34
+ err.status
35
+ )
33
36
  applyResponseHeaders(res, c.req.path, nonce)
34
37
  return res
35
38
  }
36
39
 
37
- app.onError((err, c) => errorResponse(c, toAppError(err)))
40
+ app.onError((err, c) => {
41
+ logRequestError(ctx.log, c.req, err)
42
+ return errorResponse(c, toAppError(err))
43
+ })
38
44
 
39
- app.notFound(c => errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404)))
45
+ app.notFound(c =>
46
+ errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404))
47
+ )
40
48
 
41
49
  app.route('/api', apiRoutes(ctx))
42
50
  app.route('/', staticRoutes(ctx))