@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/commands.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { exportCanvas } from './canvas/export.js'
|
|
|
7
7
|
import { importCanvas } from './canvas/import.js'
|
|
8
8
|
import { CANVAS_ZIP_MAX_BYTES } from './canvas/zip.js'
|
|
9
9
|
import type { ErrorCode } from './contract/api.js'
|
|
10
|
-
import type { GenerationContext,
|
|
10
|
+
import type { GenerationContext, PrepareTargetInput } from './contract/generation-context.js'
|
|
11
|
+
import type { LocalKey } from './contract/review-key.js'
|
|
11
12
|
import { HARNESSES, type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
12
13
|
import { formatValidationError, type ValidationReport } from './contract/validation.js'
|
|
13
|
-
import {
|
|
14
|
+
import { fetchPrRefs } from './git/pr-refs.js'
|
|
14
15
|
import { type DoctorDeps, runDoctorChecks } from './review/doctor.js'
|
|
15
16
|
import {
|
|
16
17
|
CLAUDE_SKILLS_DIR,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
import { applyTitleTrims, type TitleTrim } from './review/trim-caps.js'
|
|
33
34
|
import { validateModelOutput } from './review/validate.js'
|
|
34
35
|
import type { AppContext } from './server/context.js'
|
|
35
|
-
import { AppError, toAppError } from './server/errors.js'
|
|
36
|
+
import { AppError, CLI_SETUP_CODES, toAppError } from './server/errors.js'
|
|
36
37
|
import { readText, writeTextAtomic } from './store/atomic-json.js'
|
|
37
38
|
|
|
38
39
|
export interface CliIo {
|
|
@@ -40,7 +41,7 @@ export interface CliIo {
|
|
|
40
41
|
stderr(line: string): void
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Exit codes: 0 ok, 1 error, 2 usage, 4 gh auth or missing, 5 invalid model output. */
|
|
44
|
+
/** Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab auth or missing, 5 invalid model output. */
|
|
44
45
|
export const EXIT = { ok: 0, error: 1, usage: 2, gh: 4, invalid: 5 } as const
|
|
45
46
|
|
|
46
47
|
export class UsageError extends Error {
|
|
@@ -94,7 +95,7 @@ export function reportFailure(io: CliIo, err: unknown): number {
|
|
|
94
95
|
}
|
|
95
96
|
const appErr = toAppError(err)
|
|
96
97
|
printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
|
|
97
|
-
return
|
|
98
|
+
return CLI_SETUP_CODES.has(appErr.code) ? EXIT.gh : EXIT.error
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
@@ -145,17 +146,39 @@ function parsePrNumber(raw: string): number {
|
|
|
145
146
|
return n
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
export
|
|
149
|
+
export interface PrepareFlags {
|
|
150
|
+
pr?: string | undefined
|
|
151
|
+
base?: string | undefined
|
|
152
|
+
head?: string | undefined
|
|
153
|
+
branch?: boolean | undefined
|
|
154
|
+
uncommitted?: boolean | undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LOCAL_FLAGS = 'pass one of --pr <n>, --branch, --uncommitted, or --base <ref> --head <ref>'
|
|
158
|
+
|
|
159
|
+
/** The target the flags name. `prepare` resolves a local review's base against the clone. */
|
|
160
|
+
export function parsePrepareTarget(values: PrepareFlags): PrepareTargetInput {
|
|
161
|
+
const local: LocalKey | undefined =
|
|
162
|
+
values.branch === true ? 'branch' : values.uncommitted === true ? 'uncommitted' : undefined
|
|
163
|
+
if (values.branch === true && values.uncommitted === true) {
|
|
164
|
+
throw new UsageError('--branch and --uncommitted are two reviews; ask for one of them')
|
|
165
|
+
}
|
|
149
166
|
if (values.pr !== undefined) {
|
|
150
|
-
if (values.base !== undefined || values.head !== undefined) {
|
|
151
|
-
throw new UsageError(
|
|
167
|
+
if (values.base !== undefined || values.head !== undefined || local !== undefined) {
|
|
168
|
+
throw new UsageError(LOCAL_FLAGS)
|
|
152
169
|
}
|
|
153
170
|
return { kind: 'pr', number: parsePrNumber(values.pr) }
|
|
154
171
|
}
|
|
172
|
+
if (local !== undefined) {
|
|
173
|
+
if (values.head !== undefined) {
|
|
174
|
+
throw new UsageError(`--${local} reviews this clone, so it takes no --head`)
|
|
175
|
+
}
|
|
176
|
+
return { kind: 'local', source: local, base: values.base }
|
|
177
|
+
}
|
|
155
178
|
if (values.base !== undefined && values.head !== undefined) {
|
|
156
179
|
return { kind: 'refs', base: values.base, head: values.head }
|
|
157
180
|
}
|
|
158
|
-
throw new UsageError(
|
|
181
|
+
throw new UsageError(`prepare needs a target: ${LOCAL_FLAGS}`)
|
|
159
182
|
}
|
|
160
183
|
|
|
161
184
|
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
@@ -165,12 +188,16 @@ export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
165
188
|
pr: { type: 'string' },
|
|
166
189
|
base: { type: 'string' },
|
|
167
190
|
head: { type: 'string' },
|
|
191
|
+
branch: { type: 'boolean' },
|
|
192
|
+
uncommitted: { type: 'boolean' },
|
|
168
193
|
force: { type: 'boolean' },
|
|
169
194
|
},
|
|
170
195
|
strict: true,
|
|
171
196
|
})
|
|
172
|
-
const
|
|
173
|
-
|
|
197
|
+
const result = await prepare(ctx, parsePrepareTarget(values), {
|
|
198
|
+
force: values.force === true,
|
|
199
|
+
log: phase => io.stderr(phase),
|
|
200
|
+
})
|
|
174
201
|
printJson(io, result)
|
|
175
202
|
return EXIT.ok
|
|
176
203
|
}
|
|
@@ -186,7 +213,11 @@ async function validateFile(
|
|
|
186
213
|
}
|
|
187
214
|
const artifact = ReviewArtifactSchema.safeParse(parsed.raw)
|
|
188
215
|
const input = artifact.success ? artifactToModelOutput(artifact.data satisfies ReviewArtifact) : parsed.raw
|
|
189
|
-
|
|
216
|
+
// A stored canvas may predate the rules about what a generation must hide; only its correctness is checked.
|
|
217
|
+
const result = validateModelOutput(input, {
|
|
218
|
+
...(await validationInput(ctx, context, input)),
|
|
219
|
+
storedArtifact: artifact.success,
|
|
220
|
+
})
|
|
190
221
|
return { ok: result.ok, errors: result.errors }
|
|
191
222
|
}
|
|
192
223
|
|
|
@@ -308,6 +339,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
308
339
|
harness: parseHarness(values.harness),
|
|
309
340
|
allowStale: values['allow-stale'] === true,
|
|
310
341
|
})
|
|
342
|
+
if (result.sharing.status === 'failed')
|
|
343
|
+
io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
|
|
311
344
|
printJson(io, result)
|
|
312
345
|
return EXIT.ok
|
|
313
346
|
}
|
|
@@ -368,8 +401,8 @@ async function resolveHead(
|
|
|
368
401
|
if (prNumber === undefined) {
|
|
369
402
|
throw new UsageError('export needs --pr <n> or --head <ref|sha>')
|
|
370
403
|
}
|
|
371
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
372
|
-
const { headSha } = await fetchPrRefs(ctx.git, meta)
|
|
404
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
405
|
+
const { headSha } = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
373
406
|
return { headSha, prNumber }
|
|
374
407
|
}
|
|
375
408
|
|
|
@@ -387,7 +420,7 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
387
420
|
out: values.out,
|
|
388
421
|
})
|
|
389
422
|
printJson(io, result)
|
|
390
|
-
io.stderr(`drag ${result.path} into the
|
|
423
|
+
io.stderr(`drag ${result.path} into the ${ctx.config.host.noun} description or a comment`)
|
|
391
424
|
return EXIT.ok
|
|
392
425
|
}
|
|
393
426
|
|
|
@@ -445,9 +478,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
445
478
|
const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
|
|
446
479
|
if (values.pr !== undefined) {
|
|
447
480
|
const prNumber = parsePrNumber(values.pr)
|
|
448
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
481
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
449
482
|
options.prNumber = prNumber
|
|
450
|
-
options.
|
|
483
|
+
options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
451
484
|
}
|
|
452
485
|
printJson(io, await importCanvas(ctx, options))
|
|
453
486
|
return EXIT.ok
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,8 @@ import path from 'node:path'
|
|
|
2
2
|
import type { Repo } from './contract/review-artifact.js'
|
|
3
3
|
import { isChatAgent, type SettingsOverrides } from './contract/settings.js'
|
|
4
4
|
import { type Git, GitError } from './git/git.js'
|
|
5
|
+
import type { Host } from './host/host.js'
|
|
6
|
+
import { type OriginRemote, parseOriginRemote } from './host/remote.js'
|
|
5
7
|
import { resolveDataDir } from './store/data-dir.js'
|
|
6
8
|
|
|
7
9
|
export const DEFAULT_PORT = 3010
|
|
@@ -22,6 +24,8 @@ export interface RuntimeConfig {
|
|
|
22
24
|
commonDir: string
|
|
23
25
|
dataDir: string
|
|
24
26
|
repo: Repo
|
|
27
|
+
/** The forge origin points at, which owns every request or answer that differs between them. */
|
|
28
|
+
host: Host
|
|
25
29
|
/** Dev only: every PR reports `ready` with this artifact re-keyed to the live head. */
|
|
26
30
|
fixtureCanvasPath: string | null
|
|
27
31
|
/** Chat agent and model the flags force for this run, if any. */
|
|
@@ -61,36 +65,19 @@ export async function resolveCommonDir(git: Git): Promise<string> {
|
|
|
61
65
|
return git.commonDir()
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const m =
|
|
67
|
-
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
|
|
68
|
-
url.trim()
|
|
69
|
-
)
|
|
70
|
-
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
return { owner: m[1], name: m[2] }
|
|
74
|
-
}
|
|
68
|
+
export const ORIGIN_HINT =
|
|
69
|
+
'add a github.com or GitLab origin, or set PR_REVIEW_HOST=gitlab for self-hosted GitLab'
|
|
75
70
|
|
|
76
|
-
export async function
|
|
71
|
+
export async function resolveOrigin(git: Git, env: NodeJS.ProcessEnv = {}): Promise<OriginRemote> {
|
|
77
72
|
const url = await git.remoteUrl('origin')
|
|
78
73
|
if (url === null) {
|
|
79
|
-
throw new ConfigError(
|
|
80
|
-
'NO_ORIGIN',
|
|
81
|
-
'the repository has no "origin" remote',
|
|
82
|
-
'add one that points at GitHub'
|
|
83
|
-
)
|
|
74
|
+
throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', ORIGIN_HINT)
|
|
84
75
|
}
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
throw new ConfigError(
|
|
88
|
-
'NO_ORIGIN',
|
|
89
|
-
`origin is not a GitHub URL: ${url}`,
|
|
90
|
-
'only github.com repositories are supported'
|
|
91
|
-
)
|
|
76
|
+
const parsed = parseOriginRemote(url, env)
|
|
77
|
+
if (parsed === null) {
|
|
78
|
+
throw new ConfigError('NO_ORIGIN', `origin is not a GitHub or GitLab URL: ${url}`, ORIGIN_HINT)
|
|
92
79
|
}
|
|
93
|
-
return
|
|
80
|
+
return parsed
|
|
94
81
|
}
|
|
95
82
|
|
|
96
83
|
/** `--agent` names one of the agents the chat knows; anything else is a usage error. */
|
|
@@ -139,7 +126,7 @@ export async function loadRuntimeConfig(
|
|
|
139
126
|
): Promise<RuntimeConfig> {
|
|
140
127
|
const repoRoot = await resolveRepoRoot(git)
|
|
141
128
|
const commonDir = await resolveCommonDir(git)
|
|
142
|
-
const repo = await
|
|
129
|
+
const { repo, host } = await resolveOrigin(git, env)
|
|
143
130
|
const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
|
|
144
131
|
const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
|
|
145
132
|
return {
|
|
@@ -148,6 +135,7 @@ export async function loadRuntimeConfig(
|
|
|
148
135
|
commonDir,
|
|
149
136
|
dataDir,
|
|
150
137
|
repo,
|
|
138
|
+
host,
|
|
151
139
|
fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
|
|
152
140
|
chatOverrides: parseChatOverrides(flags),
|
|
153
141
|
}
|
package/src/contract/api.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { z } from 'zod'
|
|
|
2
2
|
import type { CanvasManifest } from './canvas-manifest.js'
|
|
3
3
|
import type { CommentsPayload, IssueComment, ReviewComment } from './comments.js'
|
|
4
4
|
import type { SharedCanvasInfoSchema } from './discovery.js'
|
|
5
|
-
import type { FileEntry, Pr, ReviewArtifact } from './review-artifact.js'
|
|
5
|
+
import type { FileEntry, FoldLevel, Pr, ReviewArtifact } from './review-artifact.js'
|
|
6
|
+
import type { LocalKey, ReviewKey } from './review-key.js'
|
|
6
7
|
import type { PrState } from './state.js'
|
|
7
8
|
|
|
8
9
|
export const ERROR_CODES = [
|
|
@@ -16,10 +17,14 @@ export const ERROR_CODES = [
|
|
|
16
17
|
'GH_MISSING',
|
|
17
18
|
'GH_UNAUTHENTICATED',
|
|
18
19
|
'GITHUB_API_ERROR',
|
|
20
|
+
'GLAB_MISSING',
|
|
21
|
+
'GLAB_UNAUTHENTICATED',
|
|
22
|
+
'GITLAB_API_ERROR',
|
|
19
23
|
'PR_NOT_FOUND',
|
|
20
24
|
'CANVAS_NOT_FOUND',
|
|
21
25
|
'CANVAS_INVALID',
|
|
22
26
|
'CANVAS_REPO_MISMATCH',
|
|
27
|
+
'CANVAS_PR_MISMATCH',
|
|
23
28
|
'CANVAS_TOO_LARGE',
|
|
24
29
|
'CANVAS_STALE',
|
|
25
30
|
'MODEL_INVALID',
|
|
@@ -65,6 +70,17 @@ export interface StaleInfo {
|
|
|
65
70
|
commitsBehind?: number
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A ready canvas generated for an earlier commit of the pull request whose diff is identical to
|
|
75
|
+
* the head's, so the page keeps it and says so.
|
|
76
|
+
*/
|
|
77
|
+
export interface CarriedOverInfo {
|
|
78
|
+
canvasHeadSha: string
|
|
79
|
+
currentHeadSha: string
|
|
80
|
+
/** How many commits the head is ahead of the canvas's commit; absent when the head does not contain it. */
|
|
81
|
+
commitsBehind?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/** The answer of `POST /import`, of `pr-review import`, and of an imported shared canvas. */
|
|
69
85
|
export interface ImportResult {
|
|
70
86
|
status: 'ready' | 'stale' | 'exists'
|
|
@@ -88,6 +104,15 @@ export interface SharedCanvasFetchResponse {
|
|
|
88
104
|
/** A canvas zip found on the pull request. Shaped by the schema the discovery cache stores. */
|
|
89
105
|
export type SharedCanvasInfo = z.infer<typeof SharedCanvasInfoSchema>
|
|
90
106
|
|
|
107
|
+
/** The forge the server talks to, as the page and the health endpoint learn it. */
|
|
108
|
+
export interface PublicHost {
|
|
109
|
+
kind: 'github' | 'gitlab'
|
|
110
|
+
/** `GitHub` or `GitLab`, for the words on the page. */
|
|
111
|
+
label: string
|
|
112
|
+
/** `https://github.com` or the GitLab instance, for links to profiles. */
|
|
113
|
+
webBase: string
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
export interface PrBundle {
|
|
92
117
|
status: BundleStatus
|
|
93
118
|
pr: Pr
|
|
@@ -96,8 +121,18 @@ export interface PrBundle {
|
|
|
96
121
|
artifact?: ReviewArtifact
|
|
97
122
|
canvas?: CanvasInfo
|
|
98
123
|
stale?: StaleInfo
|
|
124
|
+
/** Set on a ready bundle whose canvas was generated for another commit with an identical diff. */
|
|
125
|
+
carriedOver?: CarriedOverInfo
|
|
126
|
+
/**
|
|
127
|
+
* The canvas the reviewed marks on this page were made on, when they followed a line of descent
|
|
128
|
+
* onto this one; absent when none did. Not necessarily this canvas's own basis: the reviewer may
|
|
129
|
+
* have marked nothing on the canvases in between.
|
|
130
|
+
*/
|
|
131
|
+
marksCarriedFrom?: string
|
|
99
132
|
sharedCanvas?: SharedCanvasInfo
|
|
100
133
|
skillCommand: string
|
|
134
|
+
/** Set on a local review: work that has no pull request, so the forge side of the page is off. */
|
|
135
|
+
local?: LocalKey
|
|
101
136
|
comments: CommentsPayload
|
|
102
137
|
state: PrState
|
|
103
138
|
capabilities: Capabilities
|
|
@@ -111,7 +146,8 @@ export interface PrBundle {
|
|
|
111
146
|
|
|
112
147
|
/** Every answer of a state route, so the page can replace its copy of the state in one step. */
|
|
113
148
|
export interface StateResponse {
|
|
114
|
-
|
|
149
|
+
/** The target the state belongs to: a pull request number, or `local`. */
|
|
150
|
+
prNumber: ReviewKey
|
|
115
151
|
state: PrState
|
|
116
152
|
}
|
|
117
153
|
|
|
@@ -132,12 +168,20 @@ export interface ReviewSummary {
|
|
|
132
168
|
export interface ReviewBodyResponse {
|
|
133
169
|
headSha: string
|
|
134
170
|
body: string
|
|
171
|
+
/** How many pending comments would go out with the review. */
|
|
172
|
+
pending: number
|
|
135
173
|
/** The titles of the layers that still need a look; approve is refused while this is not empty. */
|
|
136
174
|
unreviewed: string[]
|
|
137
175
|
}
|
|
138
176
|
|
|
139
177
|
export interface PostReviewResponse {
|
|
140
178
|
review: ReviewSummary
|
|
179
|
+
comments: ReviewComment[]
|
|
180
|
+
warnings: string[]
|
|
181
|
+
/** How many pending comments went out with the review. */
|
|
182
|
+
submitted: number
|
|
183
|
+
/** The state after the pending review was cleared, so the page drops its drafts in one step. */
|
|
184
|
+
state: PrState
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
export interface PatchesResponse {
|
|
@@ -172,6 +216,7 @@ export interface HealthResponse {
|
|
|
172
216
|
agentAuth?: HealthCheck
|
|
173
217
|
}
|
|
174
218
|
repo: { owner: string; name: string } | null
|
|
219
|
+
host: PublicHost
|
|
175
220
|
dataDir: string
|
|
176
221
|
chat: ChatStatus
|
|
177
222
|
}
|
|
@@ -188,3 +233,14 @@ export interface ChatStatus {
|
|
|
188
233
|
export interface HomeData {
|
|
189
234
|
recentPrs: Array<{ number: number; title: string; updatedAt: string }>
|
|
190
235
|
}
|
|
236
|
+
|
|
237
|
+
/** The JSON the review page carries in its bootstrap script; the app reads it before any request. */
|
|
238
|
+
export interface ReviewBootstrap {
|
|
239
|
+
prNumber: ReviewKey
|
|
240
|
+
owner: string
|
|
241
|
+
repo: string
|
|
242
|
+
version: string
|
|
243
|
+
host: PublicHost
|
|
244
|
+
/** The reading level the canvas opens at, from the settings file. */
|
|
245
|
+
foldLevel: FoldLevel
|
|
246
|
+
}
|
|
@@ -23,6 +23,13 @@ export const CanvasIndexSchema = z.object({
|
|
|
23
23
|
generatedAt: z.string(),
|
|
24
24
|
source: z.enum(['local', 'import']),
|
|
25
25
|
importedAt: z.string().optional(),
|
|
26
|
+
/**
|
|
27
|
+
* The canvas this one was generated from. Kept here as well as on the artifact so the line
|
|
28
|
+
* of descent can be walked from the index alone, without opening every canvas on the way.
|
|
29
|
+
*/
|
|
30
|
+
basisCanvasSha: z.string().optional(),
|
|
31
|
+
/** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
|
|
32
|
+
worktree: z.boolean().optional(),
|
|
26
33
|
})
|
|
27
34
|
),
|
|
28
35
|
})
|
package/src/contract/comments.ts
CHANGED
|
@@ -96,3 +96,8 @@ export {
|
|
|
96
96
|
export function emptyComments(headSha: string, fetchedAt: string): CommentsPayload {
|
|
97
97
|
return { fetchedAt, headSha, reviewComments: [], issueComments: [] }
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
export interface FetchCommentsResult {
|
|
101
|
+
payload: CommentsPayload
|
|
102
|
+
warnings: string[]
|
|
103
|
+
}
|
|
@@ -3,9 +3,12 @@ import { z } from 'zod'
|
|
|
3
3
|
export const SharedCanvasInfoSchema = z.object({
|
|
4
4
|
url: z.string(),
|
|
5
5
|
name: z.string(),
|
|
6
|
-
|
|
6
|
+
/** The attachment's file name carries the head's sha; it says nothing about whether it is current. */
|
|
7
|
+
namesHead: z.boolean(),
|
|
7
8
|
downloadable: z.boolean(),
|
|
8
|
-
reason: z
|
|
9
|
+
reason: z
|
|
10
|
+
.enum(['auth-required', 'not-zip', 'too-large', 'network', 'name-mismatch', 'pr-mismatch'])
|
|
11
|
+
.optional(),
|
|
9
12
|
})
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { DefaultLayerSchema, GenerationModeSchema, HighRiskRuleSchema } from '../project-config.js'
|
|
3
3
|
import { DEFAULT_TEST_PATTERNS } from '../review/test-paths.js'
|
|
4
|
-
import {
|
|
4
|
+
import { type LocalKey, LocalKeySchema } from './review-key.js'
|
|
5
|
+
import {
|
|
6
|
+
FileEntrySchema,
|
|
7
|
+
LIMITS,
|
|
8
|
+
POINT_KINDS,
|
|
9
|
+
PrSchema,
|
|
10
|
+
RepoSchema,
|
|
11
|
+
type TextCaps,
|
|
12
|
+
} from './review-artifact.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
|
|
16
|
+
* work in this clone that has no pull request yet. A `local` target names only its base, because
|
|
17
|
+
* its head is whatever the branch or the working tree holds when the command runs.
|
|
18
|
+
*/
|
|
19
|
+
export const LocalPrepareTargetSchema = z.object({
|
|
20
|
+
kind: z.literal('local'),
|
|
21
|
+
base: z.string().min(1),
|
|
22
|
+
/** Which local review this is, which is also the key it is filed and served under. */
|
|
23
|
+
source: LocalKeySchema,
|
|
24
|
+
})
|
|
25
|
+
export type LocalPrepareTarget = z.infer<typeof LocalPrepareTargetSchema>
|
|
5
26
|
|
|
6
|
-
/** What `prepare` was asked to describe: a pull request, or two refs before a PR exists. */
|
|
7
27
|
export const PrepareTargetSchema = z.discriminatedUnion('kind', [
|
|
8
28
|
z.object({ kind: z.literal('pr'), number: z.number().int().positive() }),
|
|
9
29
|
z.object({ kind: z.literal('refs'), base: z.string().min(1), head: z.string().min(1) }),
|
|
30
|
+
LocalPrepareTargetSchema,
|
|
10
31
|
])
|
|
11
32
|
export type PrepareTarget = z.infer<typeof PrepareTargetSchema>
|
|
12
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What the CLI parsed, before git has been asked anything. A local review's base may still be
|
|
36
|
+
* open here: only the clone knows which branch `origin/HEAD` points at. `prepare` resolves it and
|
|
37
|
+
* records the answer, so `publish` reads a base that cannot drift.
|
|
38
|
+
*/
|
|
39
|
+
export type PrepareTargetInput =
|
|
40
|
+
| Extract<PrepareTarget, { kind: 'pr' } | { kind: 'refs' }>
|
|
41
|
+
| { kind: 'local'; base?: string | undefined; source: LocalKey }
|
|
42
|
+
|
|
13
43
|
const capsShape = {
|
|
14
44
|
summary: z.number().int().positive(),
|
|
15
45
|
layerTitle: z.number().int().positive(),
|
|
@@ -23,6 +53,48 @@ const capsShape = {
|
|
|
23
53
|
diagram: z.number().int().positive(),
|
|
24
54
|
} satisfies Record<keyof TextCaps, z.ZodNumber>
|
|
25
55
|
|
|
56
|
+
/** Which files the head changes relative to the basis canvas's diff, by path. */
|
|
57
|
+
export const FileDeltaSchema = z.object({
|
|
58
|
+
unchanged: z.array(z.string()),
|
|
59
|
+
changed: z.array(z.string()),
|
|
60
|
+
added: z.array(z.string()),
|
|
61
|
+
removed: z.array(z.string()),
|
|
62
|
+
})
|
|
63
|
+
export type FileDelta = z.infer<typeof FileDeltaSchema>
|
|
64
|
+
|
|
65
|
+
export const BasisSplitLayerSchema = z.object({
|
|
66
|
+
key: z.string().min(1),
|
|
67
|
+
title: z.string(),
|
|
68
|
+
/** `carried` when the head touches none of the layer's files. */
|
|
69
|
+
status: z.enum(['carried', 're-judged']),
|
|
70
|
+
carriedFiles: z.array(z.string()),
|
|
71
|
+
reJudgedFiles: z.array(z.string()),
|
|
72
|
+
})
|
|
73
|
+
export type BasisSplitLayer = z.infer<typeof BasisSplitLayerSchema>
|
|
74
|
+
|
|
75
|
+
export const BasisSplitPointSchema = z.object({
|
|
76
|
+
kind: z.enum(POINT_KINDS),
|
|
77
|
+
path: z.string().min(1),
|
|
78
|
+
title: z.string(),
|
|
79
|
+
status: z.enum(['carried', 're-judged']),
|
|
80
|
+
})
|
|
81
|
+
export type BasisSplitPoint = z.infer<typeof BasisSplitPointSchema>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The basis canvas of an incremental run, already divided into what the head leaves untouched and
|
|
85
|
+
* what has to be decided anew. `prepare` computes it, so the generator reads two lists instead of
|
|
86
|
+
* comparing diffs itself, and `publish` records `canvasSha` on the canvas it stores.
|
|
87
|
+
*/
|
|
88
|
+
export const BasisSplitSchema = z.object({
|
|
89
|
+
canvasSha: z.string().regex(/^[0-9a-f]{40}$/),
|
|
90
|
+
/** Absolute path of the basis canvas's `review.json`, in the canvas store. */
|
|
91
|
+
reviewJsonPath: z.string().min(1),
|
|
92
|
+
files: FileDeltaSchema,
|
|
93
|
+
layers: z.array(BasisSplitLayerSchema),
|
|
94
|
+
points: z.array(BasisSplitPointSchema),
|
|
95
|
+
})
|
|
96
|
+
export type BasisSplit = z.infer<typeof BasisSplitSchema>
|
|
97
|
+
|
|
26
98
|
/**
|
|
27
99
|
* `context.json`: everything the agent and `publish` need about one prepared canvas. Written by
|
|
28
100
|
* `prepare` next to `prompt.md`; `publish` validates `model.json` against the hunk index and the
|
|
@@ -64,6 +136,8 @@ export const GenerationContextSchema = z.object({
|
|
|
64
136
|
smallPr: z.boolean(),
|
|
65
137
|
/** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
|
|
66
138
|
largePr: z.boolean(),
|
|
139
|
+
/** Absent when this canvas is generated from a blank page: no basis, `--force`, or turned off. */
|
|
140
|
+
basis: BasisSplitSchema.optional(),
|
|
67
141
|
preparedAt: z.string(),
|
|
68
142
|
})
|
|
69
143
|
export type GenerationContext = z.infer<typeof GenerationContextSchema>
|
package/src/contract/keys.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A review the reviewer is still writing: comments kept on this machine until the review is
|
|
2
|
+
// submitted, the way a pending review works on the forge's own page.
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { SideSchema } from './review-artifact.js'
|
|
5
|
+
import { COMMENT_BODY_MAX } from './comments.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One comment waiting in the pending review. It names a line the way a posted inline comment
|
|
9
|
+
* does, so submitting it needs nothing the page has to look up again.
|
|
10
|
+
*/
|
|
11
|
+
export const PendingCommentSchema = z.object({
|
|
12
|
+
/** Local id, unique inside one target's state. Never a forge id: nothing was posted yet. */
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
path: z.string().min(1),
|
|
15
|
+
line: z.number().int().positive(),
|
|
16
|
+
side: SideSchema,
|
|
17
|
+
/** The first line of a multi-line comment, absent when it covers one line. */
|
|
18
|
+
startLine: z.number().int().positive().optional(),
|
|
19
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
20
|
+
/** Set when the comment came from an attention point, so the point can be marked posted. */
|
|
21
|
+
pointFingerprint: z.string().min(1).optional(),
|
|
22
|
+
/** The commit the reviewer was reading when they wrote it. */
|
|
23
|
+
headSha: z.string(),
|
|
24
|
+
createdAt: z.string(),
|
|
25
|
+
/** Creation time until the reviewer edits the draft. */
|
|
26
|
+
updatedAt: z.string(),
|
|
27
|
+
})
|
|
28
|
+
export type PendingComment = z.infer<typeof PendingCommentSchema>
|
|
29
|
+
|
|
30
|
+
/** What the page sends to `POST /api/prs/:n/pending`. */
|
|
31
|
+
export const AddPendingInputSchema = z.object({
|
|
32
|
+
path: z.string().min(1),
|
|
33
|
+
line: z.number().int().positive(),
|
|
34
|
+
side: SideSchema,
|
|
35
|
+
startLine: z.number().int().positive().optional(),
|
|
36
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
37
|
+
pointFingerprint: z.string().min(1).optional(),
|
|
38
|
+
headSha: z
|
|
39
|
+
.string()
|
|
40
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
41
|
+
.optional(),
|
|
42
|
+
})
|
|
43
|
+
export type AddPendingInput = z.infer<typeof AddPendingInputSchema>
|
|
44
|
+
|
|
45
|
+
/** What the page sends to `PATCH /api/prs/:n/pending/:id`. */
|
|
46
|
+
export const EditPendingInputSchema = z.object({
|
|
47
|
+
body: z.string().min(1).max(COMMENT_BODY_MAX),
|
|
48
|
+
})
|
|
49
|
+
export type EditPendingInput = z.infer<typeof EditPendingInputSchema>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
|
|
2
3
|
|
|
3
4
|
/** Character caps applied to model output. Numbers, so the prompt can print them. */
|
|
4
5
|
export const TEXT_CAPS = {
|
|
@@ -79,6 +80,33 @@ export const HARNESSES = ['claude-code', 'codex', 'other'] as const
|
|
|
79
80
|
export const SideSchema = z.enum(['new', 'old'])
|
|
80
81
|
export type Side = z.infer<typeof SideSchema>
|
|
81
82
|
|
|
83
|
+
export {
|
|
84
|
+
contains,
|
|
85
|
+
coveredRows,
|
|
86
|
+
fileRows,
|
|
87
|
+
foldLevelRank,
|
|
88
|
+
hiddenLines,
|
|
89
|
+
UNDISCUSSED,
|
|
90
|
+
} from '../../static/js/fold-levels.js'
|
|
91
|
+
export { FOLD_LEVELS }
|
|
92
|
+
export const FoldLevelSchema = z.enum(FOLD_LEVELS)
|
|
93
|
+
export type FoldLevel = z.infer<typeof FoldLevelSchema>
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How a stored canvas names its levels. One written before levels existed says `collapsed: true`
|
|
97
|
+
* and gives its folds no level; both read as light, so it hides exactly what it hid before. Past
|
|
98
|
+
* this parse every canvas names a level wherever it hides something.
|
|
99
|
+
*/
|
|
100
|
+
const STORED_LEVELS = {
|
|
101
|
+
collapsed: z
|
|
102
|
+
.union([z.boolean(), FoldLevelSchema])
|
|
103
|
+
.transform(value => (value === true ? DEFAULT_FOLD_LEVEL : value === false ? undefined : value)),
|
|
104
|
+
fold: FoldLevelSchema.default(DEFAULT_FOLD_LEVEL),
|
|
105
|
+
}
|
|
106
|
+
/** The model names a level for every fold and every collapsed file. */
|
|
107
|
+
const MODEL_LEVELS = { collapsed: FoldLevelSchema, fold: FoldLevelSchema }
|
|
108
|
+
type Levels = typeof STORED_LEVELS | typeof MODEL_LEVELS
|
|
109
|
+
|
|
82
110
|
export const HunkSchema = z.object({
|
|
83
111
|
id: z.string().min(1),
|
|
84
112
|
header: z.string(),
|
|
@@ -199,29 +227,35 @@ export function annotationSchema(caps: Caps) {
|
|
|
199
227
|
export const AnnotationSchema = annotationSchema(ARTIFACT_HARD_CAPS)
|
|
200
228
|
export type Annotation = z.infer<typeof AnnotationSchema>
|
|
201
229
|
|
|
202
|
-
function codeFoldSchema(caps: Caps) {
|
|
230
|
+
function codeFoldSchema(caps: Caps, level: Levels['fold']) {
|
|
203
231
|
return z.object({
|
|
204
232
|
title: z.string().max(caps.pointTitle).regex(/\S/, 'A fold title must contain visible text'),
|
|
205
233
|
side: SideSchema,
|
|
206
234
|
startLine: z.number().int().positive(),
|
|
207
235
|
endLine: z.number().int().positive(),
|
|
236
|
+
/** The lowest reading level that hides this range. */
|
|
237
|
+
level,
|
|
208
238
|
})
|
|
209
239
|
}
|
|
210
|
-
export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS)
|
|
240
|
+
export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS, STORED_LEVELS.fold)
|
|
211
241
|
export type CodeFold = z.infer<typeof CodeFoldSchema>
|
|
212
242
|
|
|
213
|
-
function layerFileBase(caps: Caps, foldTitleCap
|
|
243
|
+
function layerFileBase<L extends Levels>(caps: Caps, foldTitleCap: number, levels: L) {
|
|
214
244
|
return {
|
|
215
245
|
path: z.string().min(1),
|
|
216
246
|
hunks: z.array(z.string().min(1)).min(1),
|
|
217
247
|
note: textOrEmpty(caps, 'annotation').optional(),
|
|
218
248
|
annotations: z.array(annotationSchema(caps)),
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
/** The lowest reading level that hides the whole file body. */
|
|
250
|
+
collapsed: levels.collapsed.optional(),
|
|
251
|
+
folds: z.array(codeFoldSchema({ ...caps, pointTitle: foldTitleCap }, levels.fold)).optional(),
|
|
221
252
|
}
|
|
222
253
|
}
|
|
223
254
|
|
|
224
|
-
export const LayerFileSchema = z.object({
|
|
255
|
+
export const LayerFileSchema = z.object({
|
|
256
|
+
...layerFileBase(ARTIFACT_HARD_CAPS, ARTIFACT_HARD_CAPS.pointTitle, STORED_LEVELS),
|
|
257
|
+
isTest: z.boolean(),
|
|
258
|
+
})
|
|
225
259
|
export type LayerFile = z.infer<typeof LayerFileSchema>
|
|
226
260
|
|
|
227
261
|
export const LayerKeySchema = z
|
|
@@ -298,6 +332,15 @@ export const ReviewArtifactSchema = z.object({
|
|
|
298
332
|
generator: GeneratorSchema,
|
|
299
333
|
source: z.enum(['local', 'import']),
|
|
300
334
|
importedAt: z.string().optional(),
|
|
335
|
+
/**
|
|
336
|
+
* The basis canvas this one was generated from, when the run was incremental. It records where
|
|
337
|
+
* the carried content came from; a reviewer's server recomputes for itself which marks may
|
|
338
|
+
* follow, so this sha is a pointer and never a claim about what was reviewed.
|
|
339
|
+
*/
|
|
340
|
+
basisCanvasSha: z
|
|
341
|
+
.string()
|
|
342
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
343
|
+
.optional(),
|
|
301
344
|
})
|
|
302
345
|
export type ReviewArtifact = z.infer<typeof ReviewArtifactSchema>
|
|
303
346
|
|
|
@@ -317,7 +360,7 @@ export function modelOutputSchema(textCaps: TextCaps) {
|
|
|
317
360
|
z.object({
|
|
318
361
|
...layerBase(caps),
|
|
319
362
|
risk: z.array(z.object({ label: z.string().min(1), reason: z.string().min(1) })).optional(),
|
|
320
|
-
files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle))),
|
|
363
|
+
files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle, MODEL_LEVELS))),
|
|
321
364
|
})
|
|
322
365
|
)
|
|
323
366
|
.min(1),
|