@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,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
 
@@ -84,12 +92,31 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
84
92
  return parseModelText(text, 'model.json')
85
93
  }
86
94
 
87
- /** The current head of the target; a push during generation makes the prepared context stale. */
88
- 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 }> {
89
106
  if (context.target.kind === 'pr') {
90
- 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)) }
91
114
  }
92
- 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 }
93
120
  }
94
121
 
95
122
  /** Publish runs since prepare last wrote a context (its `prepared` line), this one included. */
@@ -175,10 +202,10 @@ export async function publish(
175
202
  const context = await readContext(canvasDir)
176
203
  if (!opts.allowStale) {
177
204
  const head = await currentHead(ctx, context)
178
- if (head !== context.headSha) {
205
+ if (head.moved) {
179
206
  throw new PublishError(
180
207
  'CANVAS_STALE',
181
- `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)}`,
182
209
  'run `pr-review prepare` again, or pass --allow-stale to publish for the old commit'
183
210
  )
184
211
  }
@@ -207,15 +234,39 @@ export async function publish(
207
234
  testPatterns: context.tests.patterns,
208
235
  })
209
236
  const manifest = buildManifest(context, artifact, ctx.version)
210
- 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
+ }
211
245
  const published: PublishResult = {
212
246
  status: 'published',
247
+ sharing: { status: 'local' },
213
248
  headSha: context.headSha,
214
249
  reviewJsonPath: path.join(ctx.canvases.canvasDir(context.headSha), 'review.json'),
215
250
  attempts,
216
251
  }
252
+ if (context.target.kind === 'local') {
253
+ published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.source}`
254
+ }
217
255
  if (context.target.kind === 'pr') {
218
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
+ }
219
270
  }
220
271
  return published
221
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
  }
@@ -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))) {
@@ -217,7 +217,7 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
217
217
  for (const id of file.hunks) {
218
218
  const hit = index.byHunkId.get(id)
219
219
  if (hit === undefined) {
220
- const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} hunks)`
220
+ const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} chunks)`
221
221
  report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} does not exist${hint}`)
222
222
  continue
223
223
  }
@@ -269,7 +269,7 @@ function checkLayers(output: ModelOutput, report: Report): void {
269
269
  keys.set(layer.key, i)
270
270
  }
271
271
  if (layer.files.length === 0) {
272
- report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no hunks`)
272
+ report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no chunks`)
273
273
  }
274
274
  })
275
275
  // Other is optional: at most one, and last when present.
@@ -366,7 +366,7 @@ function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], repor
366
366
  report.add(
367
367
  'RISK_IN_OTHER',
368
368
  where,
369
- `other: carries the risk tag "${tag.label}"; move the hunks to a real layer`
369
+ `other: carries the risk tag "${tag.label}"; move the chunks to a real layer`
370
370
  )
371
371
  }
372
372
  for (const file of other.files) {
@@ -403,7 +403,7 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
403
403
  report.add(
404
404
  'ANNOTATION_OUTSIDE_HUNK',
405
405
  where,
406
- `${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
406
+ `${label} is not inside one chunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
407
407
  )
408
408
  } else if (!file.hunks.includes(start.id)) {
409
409
  report.add(