@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.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
package/src/review/publish.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// `pr-review publish`: validate model.json against context.json, normalize, and store the
|
|
2
|
-
// canvas
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
25
|
-
return state.
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
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))) {
|
package/src/review/validate.ts
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
|
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
|
|
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(
|