@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
package/src/chat/seed.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Renders the seed that opens a chat thread: who the agent is, what the pull request changes,
|
|
2
|
+
// and where the code sits on disk. The prose lives in prompts/chat-seed.md.
|
|
3
|
+
import type { Pr, ReviewArtifact } from '../contract/review-artifact.js'
|
|
4
|
+
import { loadPromptFile, type ProjectPrompts } from '../prompt-files.js'
|
|
5
|
+
import { PROMPTS_DIR } from '../paths.js'
|
|
6
|
+
|
|
7
|
+
export interface SeedPaths {
|
|
8
|
+
headDir: string
|
|
9
|
+
baseDir: string
|
|
10
|
+
patchDir: string
|
|
11
|
+
repoRoot: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function loadSeedTemplate(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<string> {
|
|
15
|
+
return loadPromptFile('chat-seed.md', dir, project)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The pull request in six lines: what a reviewer would read before opening the diff. */
|
|
19
|
+
export function prMetaMarkdown(pr: Pr): string {
|
|
20
|
+
const number = pr.number === null ? '(not opened yet)' : `#${pr.number}`
|
|
21
|
+
return [
|
|
22
|
+
`- ${number} **${pr.title}** by ${pr.author}`,
|
|
23
|
+
`- \`${pr.headRef}\` → \`${pr.baseRef}\`, head \`${pr.headSha.slice(0, 7)}\``,
|
|
24
|
+
`- ${pr.changedFiles} files changed, +${pr.additions} −${pr.deletions}`,
|
|
25
|
+
].join('\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function layersMarkdown(artifact: ReviewArtifact): string {
|
|
29
|
+
if (artifact.layers.length === 0) {
|
|
30
|
+
return '_none_'
|
|
31
|
+
}
|
|
32
|
+
return artifact.layers
|
|
33
|
+
.map(layer => {
|
|
34
|
+
const files = layer.files.map(f => ` - \`${f.path}\``).join('\n')
|
|
35
|
+
const other = layer.kind === 'other' ? ' (mechanical)' : ''
|
|
36
|
+
return files === '' ? `- **${layer.title}**${other}` : `- **${layer.title}**${other}\n${files}`
|
|
37
|
+
})
|
|
38
|
+
.join('\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pointsMarkdown(artifact: ReviewArtifact): string {
|
|
42
|
+
if (artifact.points.length === 0) {
|
|
43
|
+
return '_none_'
|
|
44
|
+
}
|
|
45
|
+
return artifact.points.map(p => `- ${p.level} · ${p.kind} · ${p.title} (\`${p.path}:${p.line}\`)`).join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function testsMarkdown(artifact: ReviewArtifact): string {
|
|
49
|
+
const rows = artifact.layers.flatMap(layer =>
|
|
50
|
+
layer.tests.map(t => {
|
|
51
|
+
const where = t.testPath === undefined ? '' : ` — \`${t.testPath}\``
|
|
52
|
+
return `- ${t.status} · ${t.behavior}${where}`
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
return rows.length === 0 ? '_none_' : rows.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fills the template. Every token is replaced, so a template typo shows up as a missing section. */
|
|
59
|
+
export function renderSeed(template: string, artifact: ReviewArtifact, paths: SeedPaths): string {
|
|
60
|
+
const values: Record<string, string> = {
|
|
61
|
+
PR_META: prMetaMarkdown(artifact.pr),
|
|
62
|
+
LAYERS: layersMarkdown(artifact),
|
|
63
|
+
POINTS: pointsMarkdown(artifact),
|
|
64
|
+
TESTS: testsMarkdown(artifact),
|
|
65
|
+
HEAD_DIR: paths.headDir,
|
|
66
|
+
BASE_DIR: paths.baseDir,
|
|
67
|
+
PATCH_DIR: paths.patchDir,
|
|
68
|
+
REPO_ROOT: paths.repoRoot,
|
|
69
|
+
}
|
|
70
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (whole, token: string) => values[token] ?? whole)
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A chat thread is one acpx session plus the transcript of what was said in it. Both are named
|
|
2
|
+
// after the pull request, so two repositories never share a session and a name can never point
|
|
3
|
+
// outside the chat directory.
|
|
4
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { type ChatTurn, ChatTurnSchema } from '../contract/chat.js'
|
|
7
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
8
|
+
import type { ChatThread } from '../contract/state.js'
|
|
9
|
+
import { isNotFound } from '../store/atomic-json.js'
|
|
10
|
+
|
|
11
|
+
/** Only these characters make it into a session name, so the name is also a safe file name. */
|
|
12
|
+
export function slug(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
16
|
+
.replace(/^-+|-+$/g, '')
|
|
17
|
+
.slice(0, 40)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function threadName(repo: Repo, prNumber: number, agent: string, index: number): string {
|
|
21
|
+
return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${prNumber}-${slug(agent)}-t${index}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+)-[a-z0-9-]+-t(\d+)$/
|
|
25
|
+
|
|
26
|
+
/** True when the name is one this tool could have made for this pull request. */
|
|
27
|
+
export function isThreadNameFor(name: string, prNumber: number): boolean {
|
|
28
|
+
const m = NAME_RE.exec(name)
|
|
29
|
+
return m !== null && m[1] === String(prNumber)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The next `t<k>`: one past the highest index already used on this pull request. */
|
|
33
|
+
export function nextThreadIndex(threads: readonly ChatThread[]): number {
|
|
34
|
+
let highest = 0
|
|
35
|
+
for (const thread of threads) {
|
|
36
|
+
const m = NAME_RE.exec(thread.name)
|
|
37
|
+
const index = m?.[2] === undefined ? 0 : Number(m[2])
|
|
38
|
+
highest = Math.max(highest, index)
|
|
39
|
+
}
|
|
40
|
+
return highest + 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The thread list's label for a thread, taken from its first message. */
|
|
44
|
+
export const THREAD_TITLE_MAX = 60
|
|
45
|
+
|
|
46
|
+
/** What a thread is called until it carries a message. */
|
|
47
|
+
export const NEW_THREAD_TITLE = 'New thread'
|
|
48
|
+
|
|
49
|
+
export function threadTitle(firstMessage: string): string {
|
|
50
|
+
const oneLine = firstMessage.replace(/\s+/g, ' ').trim()
|
|
51
|
+
if (oneLine === '') {
|
|
52
|
+
return NEW_THREAD_TITLE
|
|
53
|
+
}
|
|
54
|
+
return oneLine.length <= THREAD_TITLE_MAX ? oneLine : `${oneLine.slice(0, THREAD_TITLE_MAX - 1)}…`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TranscriptStore {
|
|
58
|
+
dir(prNumber: number): string
|
|
59
|
+
file(prNumber: number, name: string): string
|
|
60
|
+
eventsFile(prNumber: number, name: string): string
|
|
61
|
+
read(prNumber: number, name: string): Promise<ChatTurn[]>
|
|
62
|
+
append(prNumber: number, name: string, turn: ChatTurn): Promise<void>
|
|
63
|
+
/** Appends one already-scrubbed acpx line to the raw event log. */
|
|
64
|
+
appendEvent(prNumber: number, name: string, line: string): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createTranscriptStore(prDir: (prNumber: number) => string): TranscriptStore {
|
|
68
|
+
const dir = (prNumber: number): string => path.join(prDir(prNumber), 'chat')
|
|
69
|
+
const named = (prNumber: number, name: string, suffix: string): string => {
|
|
70
|
+
if (!isThreadNameFor(name, prNumber)) {
|
|
71
|
+
throw new Error(`not a chat thread of pull request ${prNumber}: ${name}`)
|
|
72
|
+
}
|
|
73
|
+
return path.join(dir(prNumber), `${name}${suffix}`)
|
|
74
|
+
}
|
|
75
|
+
const appendLine = async (file: string, line: string): Promise<void> => {
|
|
76
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
77
|
+
await appendFile(file, `${line}\n`, 'utf8')
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
dir,
|
|
81
|
+
file: (prNumber, name) => named(prNumber, name, '.jsonl'),
|
|
82
|
+
eventsFile: (prNumber, name) => named(prNumber, name, '.events.ndjson'),
|
|
83
|
+
async read(prNumber, name) {
|
|
84
|
+
let text: string
|
|
85
|
+
try {
|
|
86
|
+
text = await readFile(named(prNumber, name, '.jsonl'), 'utf8')
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (isNotFound(err)) {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
const turns: ChatTurn[] = []
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
if (line.trim() === '') {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
let raw: unknown
|
|
99
|
+
try {
|
|
100
|
+
raw = JSON.parse(line) as unknown
|
|
101
|
+
} catch {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const parsed = ChatTurnSchema.safeParse(raw)
|
|
105
|
+
if (parsed.success) {
|
|
106
|
+
turns.push(parsed.data)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return turns
|
|
110
|
+
},
|
|
111
|
+
append: (prNumber, name, turn) => appendLine(named(prNumber, name, '.jsonl'), JSON.stringify(turn)),
|
|
112
|
+
appendEvent: (prNumber, name, line) => appendLine(named(prNumber, name, '.events.ndjson'), line),
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { createAgentRunner } from './acpx/acpx.js'
|
|
5
|
+
import {
|
|
6
|
+
type CliIo,
|
|
7
|
+
EXIT,
|
|
8
|
+
printErrorEnvelope,
|
|
9
|
+
reportFailure,
|
|
10
|
+
runDoctor,
|
|
11
|
+
runExport,
|
|
12
|
+
runImport,
|
|
13
|
+
runInstallSkill,
|
|
14
|
+
runPrepare,
|
|
15
|
+
runPublish,
|
|
16
|
+
runValidate,
|
|
17
|
+
splitCommonFlags,
|
|
18
|
+
} from './commands.js'
|
|
19
|
+
import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
|
|
20
|
+
import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
21
|
+
import { createGit } from './git/git.js'
|
|
22
|
+
import { createGitHubClient } from './github/gh.js'
|
|
23
|
+
import { loadProjectConfig } from './project-config.js'
|
|
24
|
+
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
25
|
+
import { startServer } from './server/node-server.js'
|
|
26
|
+
import { readJson } from './store/atomic-json.js'
|
|
27
|
+
import { ensureDataDir } from './store/data-dir.js'
|
|
28
|
+
|
|
29
|
+
const SUBCOMMANDS = ['serve', 'prepare', 'validate', 'publish', 'export', 'import', 'install-skill', 'doctor'] as const
|
|
30
|
+
|
|
31
|
+
const USAGE = `usage: pr-review <command> [flags]
|
|
32
|
+
|
|
33
|
+
serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
|
|
34
|
+
[--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
|
|
35
|
+
prepare (--pr <n> | --base <ref> --head <ref>) [--force] [--repo <dir>] [--data-dir <dir>]
|
|
36
|
+
validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
|
|
37
|
+
(--fix trims over-cap titles in place and reports each one)
|
|
38
|
+
publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
|
|
39
|
+
install-skill [--claude-dir .claude/skills] [--codex-dir .agents/skills] [--force] [--repo <dir>]
|
|
40
|
+
export (--pr <n> | --head <ref|sha>) [--out <file|dir>] [--repo <dir>] [--data-dir <dir>]
|
|
41
|
+
(both flags: the named commit is exported and the number stamps the zip)
|
|
42
|
+
import <zip> [--pr <n>] [--force] [--repo <dir>] [--data-dir <dir>]
|
|
43
|
+
doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
|
|
44
|
+
|
|
45
|
+
Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
|
|
46
|
+
Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
const io: CliIo = {
|
|
50
|
+
stdout: line => process.stdout.write(`${line}\n`),
|
|
51
|
+
stderr: line => process.stderr.write(`${line}\n`),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function buildContext(
|
|
55
|
+
repo: string | undefined,
|
|
56
|
+
dataDir: string | undefined,
|
|
57
|
+
extra: {
|
|
58
|
+
port?: string | undefined
|
|
59
|
+
fixtureCanvas?: string | undefined
|
|
60
|
+
agent?: string | undefined
|
|
61
|
+
model?: string | undefined
|
|
62
|
+
} = {}
|
|
63
|
+
): Promise<AppContext> {
|
|
64
|
+
const cwd = process.cwd()
|
|
65
|
+
const repoDir = repo === undefined ? cwd : path.resolve(cwd, repo)
|
|
66
|
+
const git = createGit(repoDir)
|
|
67
|
+
const config = await loadRuntimeConfig(
|
|
68
|
+
{
|
|
69
|
+
port: extra.port === undefined ? undefined : parsePort(extra.port, 0),
|
|
70
|
+
dataDir,
|
|
71
|
+
fixtureCanvas: extra.fixtureCanvas,
|
|
72
|
+
agent: extra.agent,
|
|
73
|
+
model: extra.model,
|
|
74
|
+
},
|
|
75
|
+
process.env,
|
|
76
|
+
git,
|
|
77
|
+
cwd
|
|
78
|
+
)
|
|
79
|
+
const projectConfig = await loadProjectConfig(config.repoRoot)
|
|
80
|
+
await ensureDataDir(config.dataDir)
|
|
81
|
+
const fixtureArtifact = config.fixtureCanvasPath === null ? null : await loadFixture(config.fixtureCanvasPath)
|
|
82
|
+
return createAppContext({ config, projectConfig, fixtureArtifact })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function loadFixture(file: string): Promise<ReviewArtifact> {
|
|
86
|
+
const artifact = await readJson(file, ReviewArtifactSchema)
|
|
87
|
+
if (artifact === null) {
|
|
88
|
+
throw new ConfigError('BAD_REQUEST', `fixture canvas not found: ${file}`)
|
|
89
|
+
}
|
|
90
|
+
return artifact
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function serve(argv: string[]): Promise<number> {
|
|
94
|
+
const { values } = parseArgs({
|
|
95
|
+
args: argv,
|
|
96
|
+
options: {
|
|
97
|
+
port: { type: 'string' },
|
|
98
|
+
repo: { type: 'string' },
|
|
99
|
+
'data-dir': { type: 'string' },
|
|
100
|
+
'fixture-canvas': { type: 'string' },
|
|
101
|
+
agent: { type: 'string' },
|
|
102
|
+
model: { type: 'string' },
|
|
103
|
+
},
|
|
104
|
+
strict: true,
|
|
105
|
+
})
|
|
106
|
+
const ctx = await buildContext(values.repo, values['data-dir'], {
|
|
107
|
+
port: values.port,
|
|
108
|
+
fixtureCanvas: values['fixture-canvas'],
|
|
109
|
+
agent: values.agent,
|
|
110
|
+
model: values.model,
|
|
111
|
+
})
|
|
112
|
+
startServer(ctx, line => process.stderr.write(`${line}\n`))
|
|
113
|
+
return EXIT.ok
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** doctor builds no AppContext: it has to answer even when the repo or `gh` is the problem. */
|
|
117
|
+
async function doctorCommand(argv: string[]): Promise<number> {
|
|
118
|
+
const { repo, dataDir, rest } = splitCommonFlags(argv)
|
|
119
|
+
const cwd = process.cwd()
|
|
120
|
+
const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
|
|
121
|
+
return runDoctor(
|
|
122
|
+
{
|
|
123
|
+
git,
|
|
124
|
+
gh: createGitHubClient(),
|
|
125
|
+
version: readPackageVersion(),
|
|
126
|
+
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
127
|
+
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
|
+
},
|
|
138
|
+
rest,
|
|
139
|
+
io
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function installSkillCommand(argv: string[]): Promise<number> {
|
|
144
|
+
const { repo, rest } = splitCommonFlags(argv)
|
|
145
|
+
const cwd = process.cwd()
|
|
146
|
+
const repoRoot = await resolveRepoRoot(createGit(repo === undefined ? cwd : path.resolve(cwd, repo)))
|
|
147
|
+
return runInstallSkill({ repoRoot, cwd, platform: process.platform }, rest, io)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function main(argv: string[]): Promise<number> {
|
|
151
|
+
// `pnpm review -- --port 3011` forwards the `--` itself; drop it so parseArgs sees the flags.
|
|
152
|
+
const [command, ...rest] = argv.filter(a => a !== '--')
|
|
153
|
+
if (command === undefined || command === '--help' || command === '-h') {
|
|
154
|
+
process.stderr.write(USAGE)
|
|
155
|
+
return command === undefined ? EXIT.usage : EXIT.ok
|
|
156
|
+
}
|
|
157
|
+
if (!(SUBCOMMANDS as readonly string[]).includes(command)) {
|
|
158
|
+
printErrorEnvelope(io, 'BAD_REQUEST', `unknown command: ${command}`, 'run pr-review --help')
|
|
159
|
+
return EXIT.usage
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
switch (command) {
|
|
163
|
+
case 'serve':
|
|
164
|
+
return await serve(rest)
|
|
165
|
+
case 'install-skill':
|
|
166
|
+
return await installSkillCommand(rest)
|
|
167
|
+
case 'doctor':
|
|
168
|
+
return await doctorCommand(rest)
|
|
169
|
+
default: {
|
|
170
|
+
const { repo, dataDir, rest: own } = splitCommonFlags(rest)
|
|
171
|
+
const ctx = await buildContext(repo, dataDir)
|
|
172
|
+
switch (command) {
|
|
173
|
+
case 'prepare':
|
|
174
|
+
return await runPrepare(ctx, own, io)
|
|
175
|
+
case 'validate':
|
|
176
|
+
return await runValidate(ctx, own, io)
|
|
177
|
+
case 'export':
|
|
178
|
+
return await runExport(ctx, own, io)
|
|
179
|
+
case 'import':
|
|
180
|
+
return await runImport(ctx, own, io)
|
|
181
|
+
default:
|
|
182
|
+
return await runPublish(ctx, own, io)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return reportFailure(io, err)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Run directly (`tsx src/cli.ts …`) or through bin/pr-review.mjs, which calls main() itself.
|
|
192
|
+
const invokedDirectly =
|
|
193
|
+
process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
|
|
194
|
+
if (invokedDirectly) {
|
|
195
|
+
const code = await main(process.argv.slice(2))
|
|
196
|
+
if (code !== 0) {
|
|
197
|
+
process.exit(code)
|
|
198
|
+
}
|
|
199
|
+
}
|