@vintasoftware/pr-review-canvas 0.3.0 → 0.5.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 +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- 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 +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- 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 +64 -23
- 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 +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -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/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
package/src/review/prompt.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Renders prompt.md from the selected generation template and the prepared context. The template carries
|
|
2
2
|
// the prose; this module fills the `{{TOKENS}}` with data so a wording change never touches code.
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import { type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
|
|
4
|
+
import { type BasisSplit, type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
|
|
5
5
|
import { type FileEntry, modelOutputSchema } from '../contract/review-artifact.js'
|
|
6
6
|
import { labelPatch } from '../git/patch-lines.js'
|
|
7
7
|
import { loadPromptFile, type ProjectPrompts } from '../prompt-files.js'
|
|
@@ -12,20 +12,48 @@ export { PROMPTS_DIR }
|
|
|
12
12
|
|
|
13
13
|
export interface PromptSources {
|
|
14
14
|
generation: Record<GenerationMode, string>
|
|
15
|
+
/** The same two modes, worded as an update of a basis canvas. Used when `ctx.basis` is set. */
|
|
16
|
+
incremental: Record<GenerationMode, string>
|
|
17
|
+
/**
|
|
18
|
+
* The judging rules of each mode: the half of the task that does not change between writing a
|
|
19
|
+
* canvas and updating one. Both task files of a mode end with it, so its wording has one home.
|
|
20
|
+
*/
|
|
21
|
+
judging: Record<GenerationMode, string>
|
|
15
22
|
format: string
|
|
16
23
|
layeringGuidance: string
|
|
17
24
|
qualityStandards: string
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
|
|
21
|
-
const [
|
|
28
|
+
const [
|
|
29
|
+
format,
|
|
30
|
+
layeringGuidance,
|
|
31
|
+
qualityStandards,
|
|
32
|
+
strict,
|
|
33
|
+
surfacing,
|
|
34
|
+
strictInc,
|
|
35
|
+
surfacingInc,
|
|
36
|
+
judgingStrict,
|
|
37
|
+
judgingSurfacing,
|
|
38
|
+
] = await Promise.all([
|
|
22
39
|
loadPromptFile('generation-format.md', dir, project),
|
|
23
40
|
loadPromptFile('layering-guidance.md', dir, project),
|
|
24
41
|
loadPromptFile('quality-standards.md', dir, project),
|
|
25
42
|
loadPromptFile('generation-strict.md', dir, project),
|
|
26
43
|
loadPromptFile('generation-surfacing.md', dir, project),
|
|
44
|
+
loadPromptFile('generation-strict-incremental.md', dir, project),
|
|
45
|
+
loadPromptFile('generation-surfacing-incremental.md', dir, project),
|
|
46
|
+
loadPromptFile('judging-strict.md', dir, project),
|
|
47
|
+
loadPromptFile('judging-surfacing.md', dir, project),
|
|
27
48
|
])
|
|
28
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
format,
|
|
51
|
+
layeringGuidance,
|
|
52
|
+
qualityStandards,
|
|
53
|
+
generation: { strict, surfacing },
|
|
54
|
+
incremental: { strict: strictInc, surfacing: surfacingInc },
|
|
55
|
+
judging: { strict: judgingStrict, surfacing: judgingSurfacing },
|
|
56
|
+
}
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/** The line ranges of a hunk header; the trailing function context can hold backticks. */
|
|
@@ -197,6 +225,74 @@ export function schemaMarkdown(ctx: GenerationContext): string {
|
|
|
197
225
|
return `\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\``
|
|
198
226
|
}
|
|
199
227
|
|
|
228
|
+
function list(items: readonly string[], empty: string): string {
|
|
229
|
+
return items.length === 0 ? `_${empty}_` : items.map(i => `- \`${i}\``).join('\n')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function basisMarkdown(basis: BasisSplit | undefined): string {
|
|
233
|
+
if (basis === undefined) {
|
|
234
|
+
return ''
|
|
235
|
+
}
|
|
236
|
+
return [
|
|
237
|
+
`- Basis canvas: \`${basis.canvasSha}\``,
|
|
238
|
+
`- Its canvas file: \`${basis.reviewJsonPath}\` — read it for the wording you carry`,
|
|
239
|
+
].join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fileDeltaMarkdown(basis: BasisSplit | undefined): string {
|
|
243
|
+
if (basis === undefined) {
|
|
244
|
+
return ''
|
|
245
|
+
}
|
|
246
|
+
const f = basis.files
|
|
247
|
+
return [
|
|
248
|
+
`**Untouched** — the patch is byte-identical to the basis canvas's:\n\n${list(f.unchanged, 'none')}`,
|
|
249
|
+
`**Changed** — the patch differs, so every line number in it may have moved:\n\n${list(f.changed, 'none')}`,
|
|
250
|
+
`**New** — not in the basis canvas at all:\n\n${list(f.added, 'none')}`,
|
|
251
|
+
`**Gone** — in the basis canvas, not in this diff:\n\n${list(f.removed, 'none')}`,
|
|
252
|
+
].join('\n\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The two lists the generator works from: what to copy across, and what to decide anew. */
|
|
256
|
+
function carriedMarkdown(basis: BasisSplit | undefined): string {
|
|
257
|
+
if (basis === undefined) {
|
|
258
|
+
return ''
|
|
259
|
+
}
|
|
260
|
+
const layers = basis.layers
|
|
261
|
+
.filter(l => l.status === 'carried')
|
|
262
|
+
.map(l => `- layer \`${l.key}\` — **${l.title}** (${l.carriedFiles.length} files, all untouched)`)
|
|
263
|
+
const files = basis.layers
|
|
264
|
+
.filter(l => l.status === 're-judged')
|
|
265
|
+
.flatMap(l => l.carriedFiles.map(p => `- \`${p}\`, from layer \`${l.key}\``))
|
|
266
|
+
const points = basis.points
|
|
267
|
+
.filter(p => p.status === 'carried')
|
|
268
|
+
.map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
|
|
269
|
+
return [
|
|
270
|
+
`**Whole layers** — copy the layer with its title, rationale, decisions, checkByHand, tests, files, notes, folds, and annotations:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
|
|
271
|
+
`**Single files of a re-judged layer** — the file is untouched, so its note, folds, and annotations still fit wherever you put the file:\n\n${files.length === 0 ? '_none_' : files.join('\n')}`,
|
|
272
|
+
`**Attention points** — repeat the kind, path, and title exactly, so the point keeps its identity and any dismissal the reviewer made:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
|
|
273
|
+
].join('\n\n')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function reJudgedMarkdown(basis: BasisSplit | undefined): string {
|
|
277
|
+
if (basis === undefined) {
|
|
278
|
+
return ''
|
|
279
|
+
}
|
|
280
|
+
const layers = basis.layers
|
|
281
|
+
.filter(l => l.status === 're-judged')
|
|
282
|
+
.map(
|
|
283
|
+
l =>
|
|
284
|
+
`- layer \`${l.key}\` — **${l.title}**; touched: ${l.reJudgedFiles.map(p => `\`${p}\``).join(', ')}`
|
|
285
|
+
)
|
|
286
|
+
const points = basis.points
|
|
287
|
+
.filter(p => p.status === 're-judged')
|
|
288
|
+
.map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
|
|
289
|
+
return [
|
|
290
|
+
`**Layers** — the head touched at least one of their files, so decide the grouping, the prose, and the anchors again:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
|
|
291
|
+
`**Attention points** — the code under them moved; keep one only if you read the new code and it still holds:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
|
|
292
|
+
'**The summary and the pull-request-wide risk** are always written again: they describe the whole change set, which the new commits changed.',
|
|
293
|
+
].join('\n\n')
|
|
294
|
+
}
|
|
295
|
+
|
|
200
296
|
/** Selects one task and fills its data and format placeholders before the generator sees it. */
|
|
201
297
|
export function renderPrompt(
|
|
202
298
|
ctx: GenerationContext,
|
|
@@ -227,12 +323,23 @@ export function renderPrompt(
|
|
|
227
323
|
TEST_PATTERNS: testPatternsMarkdown(ctx),
|
|
228
324
|
SMALL_PR: smallPrMarkdown(ctx),
|
|
229
325
|
MAX_REPAIR_ROUNDS: String(ctx.generation.maxRepairRounds),
|
|
326
|
+
BASIS: basisMarkdown(ctx.basis),
|
|
327
|
+
FILE_DELTA: fileDeltaMarkdown(ctx.basis),
|
|
328
|
+
CARRIED: carriedMarkdown(ctx.basis),
|
|
329
|
+
RE_JUDGED: reJudgedMarkdown(ctx.basis),
|
|
230
330
|
}
|
|
231
|
-
|
|
331
|
+
// A prepared basis picks the incremental wording: one prompt states one job, with no conditions.
|
|
332
|
+
// Both wordings end with the mode's judging rules, which are assembled first so the tokens inside
|
|
333
|
+
// them are filled by the one pass below.
|
|
334
|
+
const task = ctx.basis === undefined ? sources.generation : sources.incremental
|
|
335
|
+
const template = task[ctx.generation.mode]
|
|
336
|
+
.replace('{{JUDGING}}', () => sources.judging[ctx.generation.mode])
|
|
337
|
+
.replace('{{FORMAT}}', () => sources.format)
|
|
232
338
|
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_m, name: string) => {
|
|
233
339
|
const value = tokens[name]
|
|
234
340
|
if (value === undefined) {
|
|
235
|
-
|
|
341
|
+
const suffix = ctx.basis === undefined ? '' : '-incremental'
|
|
342
|
+
throw new Error(`generation-${ctx.generation.mode}${suffix}.md uses an unknown token {{${name}}}`)
|
|
236
343
|
}
|
|
237
344
|
return value
|
|
238
345
|
})
|
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
|
}
|
|
@@ -205,17 +232,42 @@ export async function publish(
|
|
|
205
232
|
generatedAt: now,
|
|
206
233
|
generator,
|
|
207
234
|
testPatterns: context.tests.patterns,
|
|
235
|
+
basisCanvasSha: context.basis?.canvasSha,
|
|
208
236
|
})
|
|
209
237
|
const manifest = buildManifest(context, artifact, ctx.version)
|
|
210
|
-
|
|
238
|
+
// A snapshot commit is on no branch, so it must never be offered as a pull request's canvas.
|
|
239
|
+
const worktree = context.target.kind === 'local' && context.pr.state === UNCOMMITTED_STATE
|
|
240
|
+
await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber, { worktree })
|
|
241
|
+
if (worktree) {
|
|
242
|
+
// The snapshot ref moves with the working tree. This canvas stays, and the page reads its
|
|
243
|
+
// diffs from its own commit, so it gets an anchor that the next edit cannot take away.
|
|
244
|
+
await ctx.git.anchorCommit(context.headSha)
|
|
245
|
+
}
|
|
211
246
|
const published: PublishResult = {
|
|
212
247
|
status: 'published',
|
|
248
|
+
sharing: { status: 'local' },
|
|
213
249
|
headSha: context.headSha,
|
|
214
250
|
reviewJsonPath: path.join(ctx.canvases.canvasDir(context.headSha), 'review.json'),
|
|
215
251
|
attempts,
|
|
216
252
|
}
|
|
253
|
+
if (context.target.kind === 'local') {
|
|
254
|
+
published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.source}`
|
|
255
|
+
}
|
|
217
256
|
if (context.target.kind === 'pr') {
|
|
218
257
|
published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.number}`
|
|
258
|
+
try {
|
|
259
|
+
const zip = await buildCanvasZipFor(ctx, context.headSha, context.target.number)
|
|
260
|
+
const body = buildCanvasComment(zip, ctx.config.host.canvasCommentLimit)
|
|
261
|
+
const url = await ctx.config.host.shareCanvas(ctx.gh, ctx.config.repo, context.target.number, body)
|
|
262
|
+
published.sharing = { status: 'shared', url }
|
|
263
|
+
} catch (err) {
|
|
264
|
+
const exported = await exportCanvas(ctx, { headSha: context.headSha, prNumber: context.target.number })
|
|
265
|
+
published.sharing = {
|
|
266
|
+
status: 'failed',
|
|
267
|
+
warning: `Automatic canvas sharing failed: ${err instanceof Error ? err.message : String(err)}. Upload the ZIP to the ${ctx.config.host.noun} description manually.`,
|
|
268
|
+
zipPath: exported.path,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
219
271
|
}
|
|
220
272
|
return published
|
|
221
273
|
}
|
|
@@ -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/test-paths.ts
CHANGED
|
@@ -2,11 +2,51 @@ import path from 'node:path'
|
|
|
2
2
|
import { matchesGlob } from './glob.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* so a Python or Go project can name `test_*.py` or `*_test.go` instead.
|
|
5
|
+
* The languages whose test classes are named `XTest` or `XTests`. Only these extensions, so a
|
|
6
|
+
* component such as `ABTest.tsx` is not taken for a test.
|
|
8
7
|
*/
|
|
9
|
-
|
|
8
|
+
const CLASS_TEST_EXTENSIONS = ['java', 'kt', 'scala', 'groovy', 'cs', 'fs', 'vb', 'swift', 'php'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The test conventions found across stacks, by directory and by file-name shape rather than by
|
|
12
|
+
* language, so a repository without a `pr-review.config.yml` still gets its tests labelled,
|
|
13
|
+
* ordered after the code they cover, and kept open at the light reading level. Directories:
|
|
14
|
+
* `__tests__`, `tests`, `test`. Names: `x.test.*` and `x.spec.*` (JS/TS), `x_test.*` (Go,
|
|
15
|
+
* Python, Elixir, Dart, Rust), `test_x.py` and `conftest.py` (pytest), `x_spec.*` (Ruby),
|
|
16
|
+
* `XTest` and `XTests` in the class-test languages above. A `spec/` directory is left out: it
|
|
17
|
+
* often holds API specs rather than tests. `tests.patterns` replaces the list.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_TEST_PATTERNS: readonly string[] = [
|
|
20
|
+
'**/__tests__/**',
|
|
21
|
+
'**/tests/**',
|
|
22
|
+
'**/test/**',
|
|
23
|
+
'**/*.test.*',
|
|
24
|
+
'**/*.spec.*',
|
|
25
|
+
'**/*_test.*',
|
|
26
|
+
'**/*_spec.*',
|
|
27
|
+
'**/test_*.py',
|
|
28
|
+
'**/conftest.py',
|
|
29
|
+
...CLASS_TEST_EXTENSIONS.flatMap(ext => [`**/*Test.${ext}`, `**/*Tests.${ext}`]),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generated content that lives beside tests: snapshots and fixtures. They are test paths, but
|
|
34
|
+
* nobody reads them line by line, so they may hide at the light level like any generated file.
|
|
35
|
+
*/
|
|
36
|
+
export const GENERATED_TEST_PATTERNS: readonly string[] = [
|
|
37
|
+
'**/__snapshots__/**',
|
|
38
|
+
'**/*.snap',
|
|
39
|
+
'**/fixtures/**',
|
|
40
|
+
'**/__fixtures__/**',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** True for a test path a reviewer reads as written: a test path that is not a snapshot or fixture. */
|
|
44
|
+
export function isHandWrittenTest(
|
|
45
|
+
filePath: string,
|
|
46
|
+
patterns: readonly string[] = DEFAULT_TEST_PATTERNS
|
|
47
|
+
): boolean {
|
|
48
|
+
return isTestPath(filePath, patterns) && !isTestPath(filePath, GENERATED_TEST_PATTERNS)
|
|
49
|
+
}
|
|
10
50
|
|
|
11
51
|
/** True when one of the patterns matches the repo-relative path. */
|
|
12
52
|
export function isTestPath(filePath: string, patterns: readonly string[] = DEFAULT_TEST_PATTERNS): boolean {
|