@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.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- 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 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- 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 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -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 +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
package/src/review/prepare.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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
|
}
|
package/src/review/prompt.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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),
|
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
|
|
|
@@ -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(
|
|
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
|
-
/**
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/review/trim-caps.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
@@ -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(
|
|
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}
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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) =>
|
|
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 =>
|
|
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))
|