@vintasoftware/pr-review-canvas 0.1.0 → 0.3.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 +30 -28
- package/docs/reference.md +142 -98
- package/package.json +12 -3
- package/pr-review.config.example.yml +12 -31
- package/prompts/generation-format.md +4 -4
- package/prompts/layering-guidance.md +18 -0
- package/skills/pr-review-canvas/SKILL.md +8 -1
- 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/export.ts +11 -2
- package/src/canvas/name.ts +13 -13
- package/src/canvas/zip.ts +4 -1
- package/src/chat/chat-manager.ts +16 -3
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/cli.ts +16 -13
- package/src/commands.ts +41 -11
- package/src/config.ts +15 -3
- package/src/contract/comments.ts +3 -1
- package/src/contract/generation-context.ts +3 -1
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/state.ts +3 -1
- package/src/git/diff-collector.ts +2 -1
- package/src/git/git.ts +9 -4
- package/src/github/attachments.ts +3 -3
- package/src/github/capabilities.ts +5 -1
- package/src/github/comments.ts +6 -3
- package/src/github/gh.ts +18 -3
- package/src/github/post-comment.ts +4 -1
- package/src/github/pr.ts +4 -1
- package/src/github/threads.ts +5 -1
- package/src/project-config.ts +14 -54
- package/src/prompt-files.ts +4 -3
- package/src/review/doctor.ts +38 -10
- package/src/review/install-skill.ts +24 -28
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +24 -8
- package/src/review/prompt.ts +17 -11
- package/src/review/publish.ts +15 -3
- package/src/review/skill-content.ts +20 -0
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate.ts +65 -13
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +17 -4
- package/src/server/context.ts +14 -2
- package/src/server/errors.ts +26 -2
- package/src/server/html.ts +2 -2
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +29 -5
- package/src/server/routes/chat-routes.ts +30 -9
- package/src/server/routes/pages.ts +3 -1
- package/src/server/routes/review-routes.ts +19 -4
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +20 -6
- package/src/store/derived-store.ts +5 -1
- package/src/store/pr-store.ts +2 -1
- package/src/store/state-store.ts +2 -1
- package/static/brand.svg +19 -0
- package/static/js/api.js +15 -3
- package/static/js/app.js +8 -2
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +38 -15
- package/static/js/composer.js +6 -3
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +6 -1
- package/static/js/dom.js +5 -7
- package/static/js/empty-state.js +3 -1
- package/static/js/header.js +1 -1
- package/static/js/interactions.js +55 -15
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +23 -11
- package/static/js/links.js +8 -2
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +3 -2
- 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/settings.js +1 -1
- package/static/js/signoff.js +6 -2
- 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 +4 -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/responsive.css +1 -15
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +93 -100
- package/static/styles.css +13 -12
- package/prompts/layers-default.md +0 -13
package/src/acpx/acpx.ts
CHANGED
|
@@ -231,7 +231,11 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
231
231
|
// The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
|
|
232
232
|
// must neither hold the kill back nor stay behind as a process of its own.
|
|
233
233
|
agentArgs =>
|
|
234
|
-
execQuiet(bin, agentArgs, {
|
|
234
|
+
execQuiet(bin, agentArgs, {
|
|
235
|
+
cwd: options.cwd,
|
|
236
|
+
timeoutSec: CANCEL_TIMEOUT_SEC,
|
|
237
|
+
killSignal: 'SIGKILL',
|
|
238
|
+
}),
|
|
235
239
|
slackMs,
|
|
236
240
|
cancelGraceMs
|
|
237
241
|
)
|
|
@@ -268,7 +272,12 @@ export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRun
|
|
|
268
272
|
}
|
|
269
273
|
}
|
|
270
274
|
if (!ended) {
|
|
271
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
text: '',
|
|
278
|
+
code: 'AGENT_INCOMPLETE',
|
|
279
|
+
message: 'the agent stopped before finishing',
|
|
280
|
+
}
|
|
272
281
|
}
|
|
273
282
|
return text === ''
|
|
274
283
|
? { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent replied nothing' }
|
|
@@ -516,11 +525,19 @@ function startRun(
|
|
|
516
525
|
const exit = code ?? 1
|
|
517
526
|
const mapped = exitCodeToAgentCode(exit)
|
|
518
527
|
if (mapped !== null) {
|
|
519
|
-
finish({
|
|
528
|
+
finish({
|
|
529
|
+
type: 'error',
|
|
530
|
+
code: mapped,
|
|
531
|
+
message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim(),
|
|
532
|
+
})
|
|
520
533
|
return
|
|
521
534
|
}
|
|
522
535
|
// acpx 0.13.2 exits 0 when its own --timeout elapses, without a terminal event.
|
|
523
|
-
finish({
|
|
536
|
+
finish({
|
|
537
|
+
type: 'error',
|
|
538
|
+
code: 'AGENT_INCOMPLETE',
|
|
539
|
+
message: 'the agent stopped before finishing its answer',
|
|
540
|
+
})
|
|
524
541
|
})
|
|
525
542
|
|
|
526
543
|
child.stdin?.on('error', () => undefined)
|
package/src/acpx/events.ts
CHANGED
|
@@ -64,7 +64,11 @@ function contentText(value: unknown): string {
|
|
|
64
64
|
*/
|
|
65
65
|
export function mapAcpxMessage(message: unknown): AgentEvent | null {
|
|
66
66
|
if (!isRecord(message)) {
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
type: 'error',
|
|
69
|
+
code: 'AGENT_PROTOCOL_INVALID',
|
|
70
|
+
message: 'the agent wrote a line that is not a message',
|
|
71
|
+
}
|
|
68
72
|
}
|
|
69
73
|
const error = read(message, 'error')
|
|
70
74
|
if (isRecord(error)) {
|
package/src/acpx/ndjson.ts
CHANGED
|
@@ -50,7 +50,9 @@ export function createNdjsonSplitter(lineMax: number = NDJSON_LINE_MAX): NdjsonS
|
|
|
50
50
|
const raw = buffer.slice(0, at)
|
|
51
51
|
buffer = buffer.slice(at + 1)
|
|
52
52
|
if (raw.length > lineMax) {
|
|
53
|
-
throw new NdjsonError(
|
|
53
|
+
throw new NdjsonError(
|
|
54
|
+
`the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`
|
|
55
|
+
)
|
|
54
56
|
}
|
|
55
57
|
out.push(...take(raw))
|
|
56
58
|
at = buffer.indexOf('\n')
|
package/src/acpx/preflight.ts
CHANGED
|
@@ -16,7 +16,11 @@ export interface PreflightProbe {
|
|
|
16
16
|
* Is acpx on PATH? The answer barely changes while the server runs, so it is asked once and
|
|
17
17
|
* reused; the page shows a banner and hides the chat when it says no.
|
|
18
18
|
*/
|
|
19
|
-
export function createPreflightProbe(
|
|
19
|
+
export function createPreflightProbe(
|
|
20
|
+
runner: AgentRunner,
|
|
21
|
+
now: () => Date,
|
|
22
|
+
ttlMs = PREFLIGHT_TTL_MS
|
|
23
|
+
): PreflightProbe {
|
|
20
24
|
let cached: { at: number; value: AcpxPreflight } | null = null
|
|
21
25
|
let inFlight: Promise<AcpxPreflight> | null = null
|
|
22
26
|
return {
|
package/src/canvas/export.ts
CHANGED
|
@@ -37,7 +37,12 @@ export async function buildCanvasZipFor(
|
|
|
37
37
|
const number = prNumber ?? stored.prNumber
|
|
38
38
|
const manifest: CanvasManifest = number === undefined ? stored : { ...stored, prNumber: number }
|
|
39
39
|
const zip: CanvasZip = {
|
|
40
|
-
name: buildCanvasZipName({
|
|
40
|
+
name: buildCanvasZipName({
|
|
41
|
+
repo: manifest.repo,
|
|
42
|
+
headSha,
|
|
43
|
+
prNumber: number,
|
|
44
|
+
generatedAt: manifest.generatedAt,
|
|
45
|
+
}),
|
|
41
46
|
bytes: buildCanvasZip(manifest, artifact),
|
|
42
47
|
headSha,
|
|
43
48
|
}
|
|
@@ -64,7 +69,11 @@ async function isDirectory(target: string): Promise<boolean> {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/** `--out` names a file when it ends in `.zip` or is not an existing directory. */
|
|
67
|
-
export async function resolveOutPath(
|
|
72
|
+
export async function resolveOutPath(
|
|
73
|
+
out: string | undefined,
|
|
74
|
+
defaultDir: string,
|
|
75
|
+
name: string
|
|
76
|
+
): Promise<string> {
|
|
68
77
|
if (out === undefined) {
|
|
69
78
|
return path.join(defaultDir, name)
|
|
70
79
|
}
|
package/src/canvas/name.ts
CHANGED
|
@@ -4,11 +4,9 @@ import type { Repo } from '../contract/review-artifact.js'
|
|
|
4
4
|
|
|
5
5
|
export interface ParsedCanvasName {
|
|
6
6
|
prNumber?: number
|
|
7
|
-
|
|
7
|
+
shaPrefix: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export const CANVAS_NAME_PREFIX = 'pr-review-canvas'
|
|
11
|
-
|
|
12
10
|
/** Owner and repo names may hold characters a file name should not; runs of them become one dash. */
|
|
13
11
|
function slugPart(value: string): string {
|
|
14
12
|
return value
|
|
@@ -24,32 +22,34 @@ export function repoSlug(repo: Repo): string {
|
|
|
24
22
|
export interface BuildNameOptions {
|
|
25
23
|
repo: Repo
|
|
26
24
|
headSha: string
|
|
25
|
+
generatedAt: string
|
|
27
26
|
prNumber?: number | undefined
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/**
|
|
29
|
+
/** PR or ref, UTC generation time to seconds, commit prefix, repository, and canvas marker. */
|
|
31
30
|
export function buildCanvasZipName(opts: BuildNameOptions): string {
|
|
32
|
-
const
|
|
33
|
-
|
|
31
|
+
const target = opts.prNumber === undefined ? 'ref' : `pr-${opts.prNumber}`
|
|
32
|
+
const timestamp = new Date(opts.generatedAt).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z'
|
|
33
|
+
return `${target}-${timestamp}-${opts.headSha.slice(0, 8)}-${repoSlug(opts.repo)}-canvas.zip`
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const NAME_RE = /^(?:pr-([1-9]\d*)|ref)-\d{8}t\d{6}z-([0-9a-f]{8})$/
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Reads a file name as a canvas zip for `repo`. A name for another repository, or one that does
|
|
40
40
|
* not follow the grammar, returns null: discovery uses this to skip attachments that are not ours.
|
|
41
41
|
*/
|
|
42
42
|
export function parseCanvasZipName(filename: string, repo: Repo): ParsedCanvasName | null {
|
|
43
|
-
const
|
|
43
|
+
const suffix = `-${repoSlug(repo)}-canvas.zip`
|
|
44
44
|
const lower = filename.toLowerCase()
|
|
45
|
-
if (!lower.
|
|
45
|
+
if (!lower.endsWith(suffix)) {
|
|
46
46
|
return null
|
|
47
47
|
}
|
|
48
|
-
const m =
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
48
|
+
const m = NAME_RE.exec(lower.slice(0, -suffix.length))
|
|
49
|
+
const shaPrefix = m?.[2]
|
|
50
|
+
if (shaPrefix === undefined) {
|
|
51
51
|
return null
|
|
52
52
|
}
|
|
53
53
|
const pr = m?.[1]
|
|
54
|
-
return pr === undefined ? {
|
|
54
|
+
return pr === undefined ? { shaPrefix } : { prNumber: Number(pr), shaPrefix }
|
|
55
55
|
}
|
package/src/canvas/zip.ts
CHANGED
|
@@ -66,7 +66,10 @@ function decode(entry: Uint8Array | undefined, name: string, issues: string[]):
|
|
|
66
66
|
*/
|
|
67
67
|
export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
|
|
68
68
|
if (bytes.length > CANVAS_ZIP_MAX_BYTES) {
|
|
69
|
-
throw new CanvasZipError(
|
|
69
|
+
throw new CanvasZipError(
|
|
70
|
+
'CANVAS_TOO_LARGE',
|
|
71
|
+
`the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`
|
|
72
|
+
)
|
|
70
73
|
}
|
|
71
74
|
if (!hasZipMagic(bytes)) {
|
|
72
75
|
throw new CanvasZipError('CANVAS_INVALID', 'this file is not a zip', [
|
package/src/chat/chat-manager.ts
CHANGED
|
@@ -10,7 +10,13 @@ import type { SettingsStore } from '../store/settings-store.js'
|
|
|
10
10
|
import type { StateStore } from '../store/state-store.js'
|
|
11
11
|
import { type ContextSources, renderChatContext } from './context.js'
|
|
12
12
|
import { renderSeed, type SeedPaths } from './seed.js'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
NEW_THREAD_TITLE,
|
|
15
|
+
nextThreadIndex,
|
|
16
|
+
type TranscriptStore,
|
|
17
|
+
threadName,
|
|
18
|
+
threadTitle,
|
|
19
|
+
} from './threads.js'
|
|
14
20
|
|
|
15
21
|
export class ChatBusyError extends Error {
|
|
16
22
|
constructor() {
|
|
@@ -188,7 +194,11 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
async function* runTurn(
|
|
197
|
+
async function* runTurn(
|
|
198
|
+
target: ChatTarget,
|
|
199
|
+
input: ChatSendInput,
|
|
200
|
+
slot: RunningTurn
|
|
201
|
+
): AsyncIterable<ChatEvent> {
|
|
192
202
|
const settings = await effectiveSettings()
|
|
193
203
|
const thread = await resolveThread(target.prNumber, settings.agent, input.thread)
|
|
194
204
|
const seeded = thread.seededHeadSha !== target.headSha
|
|
@@ -350,7 +360,10 @@ export function createChatManager(deps: ChatManagerDeps): ChatManager {
|
|
|
350
360
|
if (thread === undefined) {
|
|
351
361
|
return null
|
|
352
362
|
}
|
|
353
|
-
await deps.state.update(prNumber, current => ({
|
|
363
|
+
await deps.state.update(prNumber, current => ({
|
|
364
|
+
...current,
|
|
365
|
+
chat: { ...current.chat, activeThread: name },
|
|
366
|
+
}))
|
|
354
367
|
return thread
|
|
355
368
|
},
|
|
356
369
|
send,
|
package/src/chat/context.ts
CHANGED
|
@@ -106,7 +106,10 @@ export async function renderChatContext(context: ChatContext, sources: ContextSo
|
|
|
106
106
|
* An attention point: what it says, plus the lines it sits on. The agent needs the text to know
|
|
107
107
|
* what the reader is reacting to; the lines alone leave it guessing. One context, one heading.
|
|
108
108
|
*/
|
|
109
|
-
async function renderPoint(
|
|
109
|
+
async function renderPoint(
|
|
110
|
+
context: Extract<ChatContext, { kind: 'point' }>,
|
|
111
|
+
sources: ContextSources
|
|
112
|
+
): Promise<string> {
|
|
110
113
|
const point = sources.artifact.points.find(p => p.fingerprint === context.fingerprint)
|
|
111
114
|
if (point === undefined) {
|
|
112
115
|
throw new ChatContextError('this canvas has no such attention point')
|
|
@@ -140,7 +143,10 @@ async function renderLines(
|
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** The quoted lines and the hunk around them, with no heading of their own. */
|
|
143
|
-
async function linesBody(
|
|
146
|
+
async function linesBody(
|
|
147
|
+
context: Extract<ChatContext, { kind: 'lines' }>,
|
|
148
|
+
sources: ContextSources
|
|
149
|
+
): Promise<string> {
|
|
144
150
|
if (context.end < context.start) {
|
|
145
151
|
throw new ChatContextError('the last line of a selection comes after the first')
|
|
146
152
|
}
|
|
@@ -155,6 +161,7 @@ async function linesBody(context: Extract<ChatContext, { kind: 'lines' }>, sourc
|
|
|
155
161
|
lines === null || lines.length === 0
|
|
156
162
|
? '_these lines are not available locally_'
|
|
157
163
|
: fence(lines.map((text, i) => `${context.start + i}: ${text}`).join('\n'))
|
|
158
|
-
const hunkBlock =
|
|
164
|
+
const hunkBlock =
|
|
165
|
+
hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
|
|
159
166
|
return `${numbered}${hunkBlock}`
|
|
160
167
|
}
|
package/src/chat/seed.ts
CHANGED
|
@@ -42,7 +42,9 @@ export function pointsMarkdown(artifact: ReviewArtifact): string {
|
|
|
42
42
|
if (artifact.points.length === 0) {
|
|
43
43
|
return '_none_'
|
|
44
44
|
}
|
|
45
|
-
return artifact.points
|
|
45
|
+
return artifact.points
|
|
46
|
+
.map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`)
|
|
47
|
+
.join('\n')
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export function testsMarkdown(artifact: ReviewArtifact): string {
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import { parseArgs } from 'node:util'
|
|
4
3
|
import { createAgentRunner } from './acpx/acpx.js'
|
|
@@ -21,12 +20,22 @@ import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-art
|
|
|
21
20
|
import { createGit } from './git/git.js'
|
|
22
21
|
import { createGitHubClient } from './github/gh.js'
|
|
23
22
|
import { loadProjectConfig } from './project-config.js'
|
|
23
|
+
import { checkSkill } from './review/doctor.js'
|
|
24
24
|
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
25
25
|
import { startServer } from './server/node-server.js'
|
|
26
26
|
import { readJson } from './store/atomic-json.js'
|
|
27
27
|
import { ensureDataDir } from './store/data-dir.js'
|
|
28
28
|
|
|
29
|
-
const SUBCOMMANDS = [
|
|
29
|
+
const SUBCOMMANDS = [
|
|
30
|
+
'serve',
|
|
31
|
+
'prepare',
|
|
32
|
+
'validate',
|
|
33
|
+
'publish',
|
|
34
|
+
'export',
|
|
35
|
+
'import',
|
|
36
|
+
'install-skill',
|
|
37
|
+
'doctor',
|
|
38
|
+
] as const
|
|
30
39
|
|
|
31
40
|
const USAGE = `usage: pr-review <command> [flags]
|
|
32
41
|
|
|
@@ -78,7 +87,8 @@ async function buildContext(
|
|
|
78
87
|
)
|
|
79
88
|
const projectConfig = await loadProjectConfig(config.repoRoot)
|
|
80
89
|
await ensureDataDir(config.dataDir)
|
|
81
|
-
const fixtureArtifact =
|
|
90
|
+
const fixtureArtifact =
|
|
91
|
+
config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
|
|
82
92
|
return createAppContext({ config, projectConfig, fixtureArtifact })
|
|
83
93
|
}
|
|
84
94
|
|
|
@@ -109,6 +119,8 @@ async function serve(argv: string[]): Promise<number> {
|
|
|
109
119
|
agent: values.agent,
|
|
110
120
|
model: values.model,
|
|
111
121
|
})
|
|
122
|
+
const skill = await checkSkill(ctx.config.repoRoot)
|
|
123
|
+
if (!skill.ok) io.stderr(`pr-review doctor: ${skill.detail}. ${skill.hint ?? ''}`)
|
|
112
124
|
startServer(ctx, line => process.stderr.write(`${line}\n`))
|
|
113
125
|
return EXIT.ok
|
|
114
126
|
}
|
|
@@ -125,15 +137,6 @@ async function doctorCommand(argv: string[]): Promise<number> {
|
|
|
125
137
|
version: readPackageVersion(),
|
|
126
138
|
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
127
139
|
dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
|
|
128
|
-
exists: async file => {
|
|
129
|
-
try {
|
|
130
|
-
// A read, so a dangling symlink and a directory both answer no.
|
|
131
|
-
await readFile(file)
|
|
132
|
-
return true
|
|
133
|
-
} catch {
|
|
134
|
-
return false
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
140
|
},
|
|
138
141
|
rest,
|
|
139
142
|
io
|
|
@@ -144,7 +147,7 @@ async function installSkillCommand(argv: string[]): Promise<number> {
|
|
|
144
147
|
const { repo, rest } = splitCommonFlags(argv)
|
|
145
148
|
const cwd = process.cwd()
|
|
146
149
|
const repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
|
|
147
|
-
return runInstallSkill({ repoRoot, cwd
|
|
150
|
+
return runInstallSkill({ repoRoot, cwd }, rest, io)
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
export async function main(argv: string[]): Promise<number> {
|
package/src/commands.ts
CHANGED
|
@@ -63,7 +63,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function isParseArgsError(err: unknown): err is Error {
|
|
66
|
-
return
|
|
66
|
+
return (
|
|
67
|
+
err instanceof Error &&
|
|
68
|
+
'code' in err &&
|
|
69
|
+
typeof err.code === 'string' &&
|
|
70
|
+
err.code.startsWith('ERR_PARSE_ARGS')
|
|
71
|
+
)
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** Prints the envelope for any failure and picks the exit code. */
|
|
@@ -156,7 +161,12 @@ export function parsePrepareTarget(values: { pr?: string; base?: string; head?:
|
|
|
156
161
|
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
157
162
|
const { values } = parseArgs({
|
|
158
163
|
args: argv,
|
|
159
|
-
options: {
|
|
164
|
+
options: {
|
|
165
|
+
pr: { type: 'string' },
|
|
166
|
+
base: { type: 'string' },
|
|
167
|
+
head: { type: 'string' },
|
|
168
|
+
force: { type: 'boolean' },
|
|
169
|
+
},
|
|
160
170
|
strict: true,
|
|
161
171
|
})
|
|
162
172
|
const target = parsePrepareTarget(values)
|
|
@@ -194,7 +204,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
194
204
|
})
|
|
195
205
|
const file = positionals[0]
|
|
196
206
|
if (file === undefined || positionals.length > 1) {
|
|
197
|
-
throw new UsageError(
|
|
207
|
+
throw new UsageError(
|
|
208
|
+
'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
|
|
209
|
+
)
|
|
198
210
|
}
|
|
199
211
|
if (values.canvas === undefined) {
|
|
200
212
|
throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
|
|
@@ -202,7 +214,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
202
214
|
const context = await readContext(path.resolve(values.canvas))
|
|
203
215
|
const text = await readText(path.resolve(file))
|
|
204
216
|
if (text === null) {
|
|
205
|
-
throw new PublishError(
|
|
217
|
+
throw new PublishError(
|
|
218
|
+
'NOT_FOUND',
|
|
219
|
+
`${file} does not exist`,
|
|
220
|
+
'pass the model.json or review.json to check'
|
|
221
|
+
)
|
|
206
222
|
}
|
|
207
223
|
const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
|
|
208
224
|
const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
|
|
@@ -214,7 +230,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
214
230
|
if (trim.outcome === 'fixed') {
|
|
215
231
|
io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
|
|
216
232
|
} else {
|
|
217
|
-
io.stdout(
|
|
233
|
+
io.stdout(
|
|
234
|
+
`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
|
|
235
|
+
)
|
|
218
236
|
}
|
|
219
237
|
}
|
|
220
238
|
if (report.ok) {
|
|
@@ -277,7 +295,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
277
295
|
})
|
|
278
296
|
const canvasDir = positionals[0]
|
|
279
297
|
if (canvasDir === undefined || positionals.length > 1) {
|
|
280
|
-
throw new UsageError(
|
|
298
|
+
throw new UsageError(
|
|
299
|
+
'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
|
|
300
|
+
)
|
|
281
301
|
}
|
|
282
302
|
if (values.agent === undefined || values.agent === '') {
|
|
283
303
|
throw new UsageError('publish needs --agent <id>')
|
|
@@ -306,20 +326,22 @@ export async function runDoctor(deps: DoctorDeps, argv: string[], io: CliIo): Pr
|
|
|
306
326
|
export interface InstallSkillEnv {
|
|
307
327
|
repoRoot: string
|
|
308
328
|
cwd: string
|
|
309
|
-
platform: NodeJS.Platform
|
|
310
329
|
}
|
|
311
330
|
|
|
312
331
|
/** `install-skill [--claude-dir <dir>] [--codex-dir <dir>] [--force]`, both dirs under the repo root by default. */
|
|
313
332
|
export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
|
|
314
333
|
const { values } = parseArgs({
|
|
315
334
|
args: argv,
|
|
316
|
-
options: {
|
|
335
|
+
options: {
|
|
336
|
+
'claude-dir': { type: 'string' },
|
|
337
|
+
'codex-dir': { type: 'string' },
|
|
338
|
+
force: { type: 'boolean' },
|
|
339
|
+
},
|
|
317
340
|
strict: true,
|
|
318
341
|
})
|
|
319
342
|
const resolve = (flag: string | undefined, fallback: string): string =>
|
|
320
343
|
flag === undefined ? path.join(env.repoRoot, fallback) : path.resolve(env.cwd, flag)
|
|
321
344
|
const result = await installSkill({
|
|
322
|
-
platform: env.platform,
|
|
323
345
|
force: values.force === true,
|
|
324
346
|
targets: [
|
|
325
347
|
{ kind: 'claude', dir: resolve(values['claude-dir'], CLAUDE_SKILLS_DIR) },
|
|
@@ -359,7 +381,11 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
359
381
|
strict: true,
|
|
360
382
|
})
|
|
361
383
|
const target = await resolveHead(ctx, values)
|
|
362
|
-
const result = await exportCanvas(ctx, {
|
|
384
|
+
const result = await exportCanvas(ctx, {
|
|
385
|
+
headSha: target.headSha,
|
|
386
|
+
prNumber: target.prNumber,
|
|
387
|
+
out: values.out,
|
|
388
|
+
})
|
|
363
389
|
printJson(io, result)
|
|
364
390
|
io.stderr(`drag ${result.path} into the pull request description or a comment`)
|
|
365
391
|
return EXIT.ok
|
|
@@ -377,7 +403,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
|
|
|
377
403
|
} catch {
|
|
378
404
|
throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
|
|
379
405
|
}
|
|
380
|
-
const tooLarge = new AppError(
|
|
406
|
+
const tooLarge = new AppError(
|
|
407
|
+
'CANVAS_TOO_LARGE',
|
|
408
|
+
`${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
|
|
409
|
+
413
|
|
410
|
+
)
|
|
381
411
|
try {
|
|
382
412
|
// One byte past the cap is read, so a file of exactly the cap still fits and anything longer
|
|
383
413
|
// is refused without the rest of it ever being in memory.
|
package/src/config.ts
CHANGED
|
@@ -47,7 +47,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
|
|
|
47
47
|
return await git.topLevel()
|
|
48
48
|
} catch (err) {
|
|
49
49
|
if (err instanceof GitError) {
|
|
50
|
-
throw new ConfigError(
|
|
50
|
+
throw new ConfigError(
|
|
51
|
+
'NOT_A_REPO',
|
|
52
|
+
'not inside a git repository',
|
|
53
|
+
'run from a clone or pass --repo <dir>'
|
|
54
|
+
)
|
|
51
55
|
}
|
|
52
56
|
throw err
|
|
53
57
|
}
|
|
@@ -72,7 +76,11 @@ export function parseGithubRemote(url: string): Repo | null {
|
|
|
72
76
|
export async function resolveGithubRepo(git: Git): Promise<Repo> {
|
|
73
77
|
const url = await git.remoteUrl('origin')
|
|
74
78
|
if (url === null) {
|
|
75
|
-
throw new ConfigError(
|
|
79
|
+
throw new ConfigError(
|
|
80
|
+
'NO_ORIGIN',
|
|
81
|
+
'the repository has no "origin" remote',
|
|
82
|
+
'add one that points at GitHub'
|
|
83
|
+
)
|
|
76
84
|
}
|
|
77
85
|
const repo = parseGithubRemote(url)
|
|
78
86
|
if (repo === null) {
|
|
@@ -90,7 +98,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
|
|
|
90
98
|
const overrides: SettingsOverrides = {}
|
|
91
99
|
if (flags.agent !== undefined) {
|
|
92
100
|
if (!isChatAgent(flags.agent)) {
|
|
93
|
-
throw new ConfigError(
|
|
101
|
+
throw new ConfigError(
|
|
102
|
+
'BAD_REQUEST',
|
|
103
|
+
`unknown chat agent: ${flags.agent}`,
|
|
104
|
+
'use --agent claude or --agent codex'
|
|
105
|
+
)
|
|
94
106
|
}
|
|
95
107
|
overrides.agent = flags.agent
|
|
96
108
|
}
|
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.
|
|
@@ -57,7 +57,9 @@ export const GenerationContextSchema = z.object({
|
|
|
57
57
|
}),
|
|
58
58
|
/** The globs that make a file a test here. Defaulted, so a context written before this
|
|
59
59
|
* field existed still reads. */
|
|
60
|
-
tests: z
|
|
60
|
+
tests: z
|
|
61
|
+
.object({ patterns: z.array(z.string().min(1)) })
|
|
62
|
+
.default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
|
|
61
63
|
/** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
|
|
62
64
|
smallPr: z.boolean(),
|
|
63
65
|
/** 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) {
|
package/src/contract/state.ts
CHANGED
|
@@ -25,7 +25,9 @@ export const PrStateSchema = z.object({
|
|
|
25
25
|
*/
|
|
26
26
|
reviewedHeadSha: z.string().optional(),
|
|
27
27
|
hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
|
|
28
|
-
posted: z.array(
|
|
28
|
+
posted: z.array(
|
|
29
|
+
z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
|
|
30
|
+
),
|
|
29
31
|
dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
|
|
30
32
|
chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
|
|
31
33
|
updatedAt: z.string(),
|
|
@@ -79,7 +79,8 @@ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
|
|
|
79
79
|
return null
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const isBinary =
|
|
82
|
+
const isBinary =
|
|
83
|
+
header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
|
|
83
84
|
let status: FileStatus
|
|
84
85
|
if (renameFrom !== null) {
|
|
85
86
|
status = 'renamed'
|
package/src/git/git.ts
CHANGED
|
@@ -62,10 +62,15 @@ interface ExecResult {
|
|
|
62
62
|
/** Runs git with an argument array; never a shell. */
|
|
63
63
|
export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
|
|
64
64
|
return new Promise(resolve => {
|
|
65
|
-
execFile(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
execFile(
|
|
66
|
+
'git',
|
|
67
|
+
args,
|
|
68
|
+
{ cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
|
|
69
|
+
(error, stdout, stderr) => {
|
|
70
|
+
const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
|
|
71
|
+
resolve({ stdout, stderr: stderr.toString('utf8'), code })
|
|
72
|
+
}
|
|
73
|
+
)
|
|
69
74
|
})
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -94,9 +94,9 @@ export function rankCandidates(
|
|
|
94
94
|
candidates: AttachmentCandidate[],
|
|
95
95
|
target: { headSha: string; prNumber: number }
|
|
96
96
|
): AttachmentCandidate[] {
|
|
97
|
-
const
|
|
97
|
+
const shaPrefix = target.headSha.slice(0, 8)
|
|
98
98
|
const score = (c: AttachmentCandidate): number =>
|
|
99
|
-
(c.parsed.
|
|
99
|
+
(c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
|
|
100
100
|
return [...candidates].sort(
|
|
101
101
|
(a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
|
|
102
102
|
)
|
|
@@ -261,7 +261,7 @@ async function tryCandidate(
|
|
|
261
261
|
const shared = {
|
|
262
262
|
url: candidate.url,
|
|
263
263
|
name: candidate.name,
|
|
264
|
-
matchesHead: candidate.parsed.
|
|
264
|
+
matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
|
|
265
265
|
}
|
|
266
266
|
const download = await downloadAttachment(ctx, candidate.url)
|
|
267
267
|
if (!download.ok) {
|
|
@@ -10,7 +10,11 @@ const RepoBodySchema = z.object({
|
|
|
10
10
|
})
|
|
11
11
|
|
|
12
12
|
/** What a page assumes before the probe answers: posting is tried and GitHub decides. */
|
|
13
|
-
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
13
|
+
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
14
|
+
canComment: 'unknown',
|
|
15
|
+
tokenKind: 'unprobed',
|
|
16
|
+
login: null,
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
|
|
16
20
|
|