@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/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 {
|
|
@@ -63,7 +64,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
function isParseArgsError(err: unknown): err is Error {
|
|
66
|
-
return
|
|
67
|
+
return (
|
|
68
|
+
err instanceof Error &&
|
|
69
|
+
'code' in err &&
|
|
70
|
+
typeof err.code === 'string' &&
|
|
71
|
+
err.code.startsWith('ERR_PARSE_ARGS')
|
|
72
|
+
)
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/** Prints the envelope for any failure and picks the exit code. */
|
|
@@ -89,7 +95,7 @@ export function reportFailure(io: CliIo, err: unknown): number {
|
|
|
89
95
|
}
|
|
90
96
|
const appErr = toAppError(err)
|
|
91
97
|
printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
|
|
92
|
-
return
|
|
98
|
+
return CLI_SETUP_CODES.has(appErr.code) ? EXIT.gh : EXIT.error
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
/**
|
|
@@ -140,27 +146,58 @@ function parsePrNumber(raw: string): number {
|
|
|
140
146
|
return n
|
|
141
147
|
}
|
|
142
148
|
|
|
143
|
-
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
|
+
}
|
|
144
166
|
if (values.pr !== undefined) {
|
|
145
|
-
if (values.base !== undefined || values.head !== undefined) {
|
|
146
|
-
throw new UsageError(
|
|
167
|
+
if (values.base !== undefined || values.head !== undefined || local !== undefined) {
|
|
168
|
+
throw new UsageError(LOCAL_FLAGS)
|
|
147
169
|
}
|
|
148
170
|
return { kind: 'pr', number: parsePrNumber(values.pr) }
|
|
149
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
|
+
}
|
|
150
178
|
if (values.base !== undefined && values.head !== undefined) {
|
|
151
179
|
return { kind: 'refs', base: values.base, head: values.head }
|
|
152
180
|
}
|
|
153
|
-
throw new UsageError(
|
|
181
|
+
throw new UsageError(`prepare needs a target: ${LOCAL_FLAGS}`)
|
|
154
182
|
}
|
|
155
183
|
|
|
156
184
|
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
157
185
|
const { values } = parseArgs({
|
|
158
186
|
args: argv,
|
|
159
|
-
options: {
|
|
187
|
+
options: {
|
|
188
|
+
pr: { type: 'string' },
|
|
189
|
+
base: { type: 'string' },
|
|
190
|
+
head: { type: 'string' },
|
|
191
|
+
branch: { type: 'boolean' },
|
|
192
|
+
uncommitted: { type: 'boolean' },
|
|
193
|
+
force: { type: 'boolean' },
|
|
194
|
+
},
|
|
160
195
|
strict: true,
|
|
161
196
|
})
|
|
162
|
-
const
|
|
163
|
-
|
|
197
|
+
const result = await prepare(ctx, parsePrepareTarget(values), {
|
|
198
|
+
force: values.force === true,
|
|
199
|
+
log: phase => io.stderr(phase),
|
|
200
|
+
})
|
|
164
201
|
printJson(io, result)
|
|
165
202
|
return EXIT.ok
|
|
166
203
|
}
|
|
@@ -194,7 +231,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
194
231
|
})
|
|
195
232
|
const file = positionals[0]
|
|
196
233
|
if (file === undefined || positionals.length > 1) {
|
|
197
|
-
throw new UsageError(
|
|
234
|
+
throw new UsageError(
|
|
235
|
+
'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
|
|
236
|
+
)
|
|
198
237
|
}
|
|
199
238
|
if (values.canvas === undefined) {
|
|
200
239
|
throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
|
|
@@ -202,7 +241,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
202
241
|
const context = await readContext(path.resolve(values.canvas))
|
|
203
242
|
const text = await readText(path.resolve(file))
|
|
204
243
|
if (text === null) {
|
|
205
|
-
throw new PublishError(
|
|
244
|
+
throw new PublishError(
|
|
245
|
+
'NOT_FOUND',
|
|
246
|
+
`${file} does not exist`,
|
|
247
|
+
'pass the model.json or review.json to check'
|
|
248
|
+
)
|
|
206
249
|
}
|
|
207
250
|
const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
|
|
208
251
|
const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
|
|
@@ -214,7 +257,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
214
257
|
if (trim.outcome === 'fixed') {
|
|
215
258
|
io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
|
|
216
259
|
} else {
|
|
217
|
-
io.stdout(
|
|
260
|
+
io.stdout(
|
|
261
|
+
`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
|
|
262
|
+
)
|
|
218
263
|
}
|
|
219
264
|
}
|
|
220
265
|
if (report.ok) {
|
|
@@ -277,7 +322,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
277
322
|
})
|
|
278
323
|
const canvasDir = positionals[0]
|
|
279
324
|
if (canvasDir === undefined || positionals.length > 1) {
|
|
280
|
-
throw new UsageError(
|
|
325
|
+
throw new UsageError(
|
|
326
|
+
'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
|
|
327
|
+
)
|
|
281
328
|
}
|
|
282
329
|
if (values.agent === undefined || values.agent === '') {
|
|
283
330
|
throw new UsageError('publish needs --agent <id>')
|
|
@@ -288,6 +335,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
288
335
|
harness: parseHarness(values.harness),
|
|
289
336
|
allowStale: values['allow-stale'] === true,
|
|
290
337
|
})
|
|
338
|
+
if (result.sharing.status === 'failed')
|
|
339
|
+
io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
|
|
291
340
|
printJson(io, result)
|
|
292
341
|
return EXIT.ok
|
|
293
342
|
}
|
|
@@ -312,7 +361,11 @@ export interface InstallSkillEnv {
|
|
|
312
361
|
export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
|
|
313
362
|
const { values } = parseArgs({
|
|
314
363
|
args: argv,
|
|
315
|
-
options: {
|
|
364
|
+
options: {
|
|
365
|
+
'claude-dir': { type: 'string' },
|
|
366
|
+
'codex-dir': { type: 'string' },
|
|
367
|
+
force: { type: 'boolean' },
|
|
368
|
+
},
|
|
316
369
|
strict: true,
|
|
317
370
|
})
|
|
318
371
|
const resolve = (flag: string | undefined, fallback: string): string =>
|
|
@@ -344,8 +397,8 @@ async function resolveHead(
|
|
|
344
397
|
if (prNumber === undefined) {
|
|
345
398
|
throw new UsageError('export needs --pr <n> or --head <ref|sha>')
|
|
346
399
|
}
|
|
347
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
348
|
-
const { headSha } = await fetchPrRefs(ctx.git, meta)
|
|
400
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
401
|
+
const { headSha } = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
349
402
|
return { headSha, prNumber }
|
|
350
403
|
}
|
|
351
404
|
|
|
@@ -357,9 +410,13 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
357
410
|
strict: true,
|
|
358
411
|
})
|
|
359
412
|
const target = await resolveHead(ctx, values)
|
|
360
|
-
const result = await exportCanvas(ctx, {
|
|
413
|
+
const result = await exportCanvas(ctx, {
|
|
414
|
+
headSha: target.headSha,
|
|
415
|
+
prNumber: target.prNumber,
|
|
416
|
+
out: values.out,
|
|
417
|
+
})
|
|
361
418
|
printJson(io, result)
|
|
362
|
-
io.stderr(`drag ${result.path} into the
|
|
419
|
+
io.stderr(`drag ${result.path} into the ${ctx.config.host.noun} description or a comment`)
|
|
363
420
|
return EXIT.ok
|
|
364
421
|
}
|
|
365
422
|
|
|
@@ -375,7 +432,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
|
|
|
375
432
|
} catch {
|
|
376
433
|
throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
|
|
377
434
|
}
|
|
378
|
-
const tooLarge = new AppError(
|
|
435
|
+
const tooLarge = new AppError(
|
|
436
|
+
'CANVAS_TOO_LARGE',
|
|
437
|
+
`${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
|
|
438
|
+
413
|
|
439
|
+
)
|
|
379
440
|
try {
|
|
380
441
|
// One byte past the cap is read, so a file of exactly the cap still fits and anything longer
|
|
381
442
|
// is refused without the rest of it ever being in memory.
|
|
@@ -413,9 +474,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
413
474
|
const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
|
|
414
475
|
if (values.pr !== undefined) {
|
|
415
476
|
const prNumber = parsePrNumber(values.pr)
|
|
416
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
477
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
417
478
|
options.prNumber = prNumber
|
|
418
|
-
options.
|
|
479
|
+
options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
419
480
|
}
|
|
420
481
|
printJson(io, await importCanvas(ctx, options))
|
|
421
482
|
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. */
|
|
@@ -47,7 +51,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
|
|
|
47
51
|
return await git.topLevel()
|
|
48
52
|
} catch (err) {
|
|
49
53
|
if (err instanceof GitError) {
|
|
50
|
-
throw new ConfigError(
|
|
54
|
+
throw new ConfigError(
|
|
55
|
+
'NOT_A_REPO',
|
|
56
|
+
'not inside a git repository',
|
|
57
|
+
'run from a clone or pass --repo <dir>'
|
|
58
|
+
)
|
|
51
59
|
}
|
|
52
60
|
throw err
|
|
53
61
|
}
|
|
@@ -57,32 +65,19 @@ export async function resolveCommonDir(git: Git): Promise<string> {
|
|
|
57
65
|
return git.commonDir()
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const m =
|
|
63
|
-
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
|
|
64
|
-
url.trim()
|
|
65
|
-
)
|
|
66
|
-
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
67
|
-
return null
|
|
68
|
-
}
|
|
69
|
-
return { owner: m[1], name: m[2] }
|
|
70
|
-
}
|
|
68
|
+
export const ORIGIN_HINT =
|
|
69
|
+
'add a github.com or GitLab origin, or set PR_REVIEW_HOST=gitlab for self-hosted GitLab'
|
|
71
70
|
|
|
72
|
-
export async function
|
|
71
|
+
export async function resolveOrigin(git: Git, env: NodeJS.ProcessEnv = {}): Promise<OriginRemote> {
|
|
73
72
|
const url = await git.remoteUrl('origin')
|
|
74
73
|
if (url === null) {
|
|
75
|
-
throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote',
|
|
74
|
+
throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', ORIGIN_HINT)
|
|
76
75
|
}
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
throw new ConfigError(
|
|
80
|
-
'NO_ORIGIN',
|
|
81
|
-
`origin is not a GitHub URL: ${url}`,
|
|
82
|
-
'only github.com repositories are supported'
|
|
83
|
-
)
|
|
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)
|
|
84
79
|
}
|
|
85
|
-
return
|
|
80
|
+
return parsed
|
|
86
81
|
}
|
|
87
82
|
|
|
88
83
|
/** `--agent` names one of the agents the chat knows; anything else is a usage error. */
|
|
@@ -90,7 +85,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
|
|
|
90
85
|
const overrides: SettingsOverrides = {}
|
|
91
86
|
if (flags.agent !== undefined) {
|
|
92
87
|
if (!isChatAgent(flags.agent)) {
|
|
93
|
-
throw new ConfigError(
|
|
88
|
+
throw new ConfigError(
|
|
89
|
+
'BAD_REQUEST',
|
|
90
|
+
`unknown chat agent: ${flags.agent}`,
|
|
91
|
+
'use --agent claude or --agent codex'
|
|
92
|
+
)
|
|
94
93
|
}
|
|
95
94
|
overrides.agent = flags.agent
|
|
96
95
|
}
|
|
@@ -127,7 +126,7 @@ export async function loadRuntimeConfig(
|
|
|
127
126
|
): Promise<RuntimeConfig> {
|
|
128
127
|
const repoRoot = await resolveRepoRoot(git)
|
|
129
128
|
const commonDir = await resolveCommonDir(git)
|
|
130
|
-
const repo = await
|
|
129
|
+
const { repo, host } = await resolveOrigin(git, env)
|
|
131
130
|
const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
|
|
132
131
|
const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
|
|
133
132
|
return {
|
|
@@ -136,6 +135,7 @@ export async function loadRuntimeConfig(
|
|
|
136
135
|
commonDir,
|
|
137
136
|
dataDir,
|
|
138
137
|
repo,
|
|
138
|
+
host,
|
|
139
139
|
fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
|
|
140
140
|
chatOverrides: parseChatOverrides(flags),
|
|
141
141
|
}
|
package/src/contract/api.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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
5
|
import type { FileEntry, 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,12 @@ 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
|
|
99
126
|
sharedCanvas?: SharedCanvasInfo
|
|
100
127
|
skillCommand: string
|
|
128
|
+
/** Set on a local review: work that has no pull request, so the forge side of the page is off. */
|
|
129
|
+
local?: LocalKey
|
|
101
130
|
comments: CommentsPayload
|
|
102
131
|
state: PrState
|
|
103
132
|
capabilities: Capabilities
|
|
@@ -111,7 +140,8 @@ export interface PrBundle {
|
|
|
111
140
|
|
|
112
141
|
/** Every answer of a state route, so the page can replace its copy of the state in one step. */
|
|
113
142
|
export interface StateResponse {
|
|
114
|
-
|
|
143
|
+
/** The target the state belongs to: a pull request number, or `local`. */
|
|
144
|
+
prNumber: ReviewKey
|
|
115
145
|
state: PrState
|
|
116
146
|
}
|
|
117
147
|
|
|
@@ -172,6 +202,7 @@ export interface HealthResponse {
|
|
|
172
202
|
agentAuth?: HealthCheck
|
|
173
203
|
}
|
|
174
204
|
repo: { owner: string; name: string } | null
|
|
205
|
+
host: PublicHost
|
|
175
206
|
dataDir: string
|
|
176
207
|
chat: ChatStatus
|
|
177
208
|
}
|
|
@@ -23,6 +23,8 @@ export const CanvasIndexSchema = z.object({
|
|
|
23
23
|
generatedAt: z.string(),
|
|
24
24
|
source: z.enum(['local', 'import']),
|
|
25
25
|
importedAt: z.string().optional(),
|
|
26
|
+
/** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
|
|
27
|
+
worktree: z.boolean().optional(),
|
|
26
28
|
})
|
|
27
29
|
),
|
|
28
30
|
})
|
package/src/contract/comments.ts
CHANGED
|
@@ -79,7 +79,9 @@ export const PostCommentInputSchema = z.discriminatedUnion('kind', [
|
|
|
79
79
|
export type PostCommentInput = z.infer<typeof PostCommentInputSchema>
|
|
80
80
|
|
|
81
81
|
/** The posted comment: a review comment for inline and reply, an issue comment for PR-level. */
|
|
82
|
-
export type PostCommentResult =
|
|
82
|
+
export type PostCommentResult =
|
|
83
|
+
| { kind: 'review'; comment: ReviewComment }
|
|
84
|
+
| { kind: 'issue'; comment: IssueComment }
|
|
83
85
|
|
|
84
86
|
// The proposed-comment block a chat answer can carry. The parser lives in
|
|
85
87
|
// static/js/proposed-comment.js so the browser loads the same code without a bundler.
|
|
@@ -94,3 +96,8 @@ export {
|
|
|
94
96
|
export function emptyComments(headSha: string, fetchedAt: string): CommentsPayload {
|
|
95
97
|
return { fetchedAt, headSha, reviewComments: [], issueComments: [] }
|
|
96
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,38 @@
|
|
|
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 { type LocalKey, LocalKeySchema } from './review-key.js'
|
|
4
5
|
import { FileEntrySchema, LIMITS, PrSchema, RepoSchema, type TextCaps } from './review-artifact.js'
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
|
|
9
|
+
* work in this clone that has no pull request yet. A `local` target names only its base, because
|
|
10
|
+
* its head is whatever the branch or the working tree holds when the command runs.
|
|
11
|
+
*/
|
|
12
|
+
export const LocalPrepareTargetSchema = z.object({
|
|
13
|
+
kind: z.literal('local'),
|
|
14
|
+
base: z.string().min(1),
|
|
15
|
+
/** Which local review this is, which is also the key it is filed and served under. */
|
|
16
|
+
source: LocalKeySchema,
|
|
17
|
+
})
|
|
18
|
+
export type LocalPrepareTarget = z.infer<typeof LocalPrepareTargetSchema>
|
|
19
|
+
|
|
7
20
|
export const PrepareTargetSchema = z.discriminatedUnion('kind', [
|
|
8
21
|
z.object({ kind: z.literal('pr'), number: z.number().int().positive() }),
|
|
9
22
|
z.object({ kind: z.literal('refs'), base: z.string().min(1), head: z.string().min(1) }),
|
|
23
|
+
LocalPrepareTargetSchema,
|
|
10
24
|
])
|
|
11
25
|
export type PrepareTarget = z.infer<typeof PrepareTargetSchema>
|
|
12
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What the CLI parsed, before git has been asked anything. A local review's base may still be
|
|
29
|
+
* open here: only the clone knows which branch `origin/HEAD` points at. `prepare` resolves it and
|
|
30
|
+
* records the answer, so `publish` reads a base that cannot drift.
|
|
31
|
+
*/
|
|
32
|
+
export type PrepareTargetInput =
|
|
33
|
+
| Extract<PrepareTarget, { kind: 'pr' } | { kind: 'refs' }>
|
|
34
|
+
| { kind: 'local'; base?: string | undefined; source: LocalKey }
|
|
35
|
+
|
|
13
36
|
const capsShape = {
|
|
14
37
|
summary: z.number().int().positive(),
|
|
15
38
|
layerTitle: z.number().int().positive(),
|
|
@@ -57,7 +80,9 @@ export const GenerationContextSchema = z.object({
|
|
|
57
80
|
}),
|
|
58
81
|
/** The globs that make a file a test here. Defaulted, so a context written before this
|
|
59
82
|
* field existed still reads. */
|
|
60
|
-
tests: z
|
|
83
|
+
tests: z
|
|
84
|
+
.object({ patterns: z.array(z.string().min(1)) })
|
|
85
|
+
.default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
|
|
61
86
|
/** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
|
|
62
87
|
smallPr: z.boolean(),
|
|
63
88
|
/** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
|
|
@@ -48,7 +48,9 @@ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
|
|
51
|
-
export function effectiveCaps(
|
|
51
|
+
export function effectiveCaps(
|
|
52
|
+
overrides: { [K in keyof TextCaps]?: number | undefined } | undefined
|
|
53
|
+
): TextCaps {
|
|
52
54
|
return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -162,13 +164,17 @@ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
|
|
|
162
164
|
|
|
163
165
|
function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
|
|
164
166
|
const visibleCap = caps[key] / HARD_CAP_FACTOR
|
|
165
|
-
const diagrams =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
const diagrams =
|
|
168
|
+
key === 'summary' || key === 'rationale'
|
|
169
|
+
? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
|
|
170
|
+
: ''
|
|
171
|
+
return z
|
|
172
|
+
.string()
|
|
173
|
+
.max(caps[key])
|
|
174
|
+
.meta({
|
|
175
|
+
description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
|
|
176
|
+
'x-visibleMaxLength': visibleCap,
|
|
177
|
+
})
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
export function testEntrySchema(caps: Caps) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two reviews of work that has no pull request yet. They are separate targets, so reviewing a
|
|
5
|
+
* branch does not disturb the review of what is not committed, and each keeps its own progress
|
|
6
|
+
* marks and chat threads.
|
|
7
|
+
*/
|
|
8
|
+
export const LOCAL_KEYS = ['branch', 'uncommitted'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which local review: `branch` is the current branch against the base it will be opened against,
|
|
12
|
+
* uncommitted edits left out; `uncommitted` is that branch with the working tree on top.
|
|
13
|
+
*/
|
|
14
|
+
export type LocalKey = (typeof LOCAL_KEYS)[number]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a canvas, its review state, and its chat threads are filed under: a pull request or merge
|
|
18
|
+
* request number, or one of the local reviews. It is also what the URLs carry, so `/review/12`
|
|
19
|
+
* and `/review/uncommitted` are the same page over different targets.
|
|
20
|
+
*/
|
|
21
|
+
export type ReviewKey = number | LocalKey
|
|
22
|
+
|
|
23
|
+
export const LocalKeySchema = z.enum(LOCAL_KEYS)
|
|
24
|
+
export const ReviewKeySchema = z.union([LocalKeySchema, z.number().int().positive()])
|
|
25
|
+
|
|
26
|
+
export function isLocalKey(key: ReviewKey): key is LocalKey {
|
|
27
|
+
return LOCAL_KEYS.some(local => local === key)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The key as it appears in a URL segment and as a directory name. */
|
|
31
|
+
export function keyToString(key: ReviewKey): string {
|
|
32
|
+
return String(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the page calls the target, for a title or a heading. */
|
|
36
|
+
export function keyLabel(key: ReviewKey): string {
|
|
37
|
+
if (key === 'branch') {
|
|
38
|
+
return 'Branch review'
|
|
39
|
+
}
|
|
40
|
+
return key === 'uncommitted' ? 'Uncommitted work' : `#${String(key)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The key a URL segment names, or null when it names none of them. */
|
|
44
|
+
export function parseReviewKey(raw: string): ReviewKey | null {
|
|
45
|
+
const local = LocalKeySchema.safeParse(raw)
|
|
46
|
+
if (local.success) {
|
|
47
|
+
return local.data
|
|
48
|
+
}
|
|
49
|
+
const n = Number(raw)
|
|
50
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
|
|
4
|
+
export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
|
|
5
|
+
export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
|
|
6
|
+
|
|
7
|
+
export const PostReviewInputSchema = z.object({
|
|
8
|
+
event: ReviewEventSchema,
|
|
9
|
+
/** The dialog sends the body the user read, edited or not. */
|
|
10
|
+
body: z.string().min(1).max(65536).optional(),
|
|
11
|
+
/** The commit the dialog named. The server refuses the review when the head moved on. */
|
|
12
|
+
headSha: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
15
|
+
.optional(),
|
|
16
|
+
})
|
|
17
|
+
export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
|
package/src/contract/settings.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface SettingsResponse {
|
|
|
107
107
|
maxRepairRounds: number
|
|
108
108
|
inlineDiffMaxLines: number
|
|
109
109
|
smallPrHunks: number
|
|
110
|
+
/** Whether a canvas still stands for a later head with an identical diff. */
|
|
111
|
+
keepForIdenticalDiff: boolean
|
|
110
112
|
layers: number
|
|
111
113
|
highRisk: number
|
|
112
114
|
}
|