@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/commands.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// The subcommands behind `pr-review`, with everything injected: the AppContext carries git, gh, and
|
|
2
|
+
// the stores; `io` carries stdout/stderr. cli.ts parses the command name and builds both.
|
|
3
|
+
import { type FileHandle, open } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
import { exportCanvas } from './canvas/export.js'
|
|
7
|
+
import { importCanvas } from './canvas/import.js'
|
|
8
|
+
import { CANVAS_ZIP_MAX_BYTES } from './canvas/zip.js'
|
|
9
|
+
import type { ErrorCode } from './contract/api.js'
|
|
10
|
+
import type { GenerationContext, PrepareTarget } from './contract/generation-context.js'
|
|
11
|
+
import { HARNESSES, type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
12
|
+
import { formatValidationError, type ValidationReport } from './contract/validation.js'
|
|
13
|
+
import { fetchPrMeta, fetchPrRefs } from './github/pr.js'
|
|
14
|
+
import { type DoctorDeps, runDoctorChecks } from './review/doctor.js'
|
|
15
|
+
import {
|
|
16
|
+
CLAUDE_SKILLS_DIR,
|
|
17
|
+
CODEX_SKILLS_DIR,
|
|
18
|
+
ignoreLocalSettings,
|
|
19
|
+
installSkill,
|
|
20
|
+
SkillDirExistsError,
|
|
21
|
+
} from './review/install-skill.js'
|
|
22
|
+
import { artifactToModelOutput } from './review/normalize.js'
|
|
23
|
+
import { prepare } from './review/prepare.js'
|
|
24
|
+
import {
|
|
25
|
+
ModelInvalidError,
|
|
26
|
+
PublishError,
|
|
27
|
+
parseModelText,
|
|
28
|
+
publish,
|
|
29
|
+
readContext,
|
|
30
|
+
validationInput,
|
|
31
|
+
} from './review/publish.js'
|
|
32
|
+
import { applyTitleTrims, type TitleTrim } from './review/trim-caps.js'
|
|
33
|
+
import { validateModelOutput } from './review/validate.js'
|
|
34
|
+
import type { AppContext } from './server/context.js'
|
|
35
|
+
import { AppError, toAppError } from './server/errors.js'
|
|
36
|
+
import { readText, writeTextAtomic } from './store/atomic-json.js'
|
|
37
|
+
|
|
38
|
+
export interface CliIo {
|
|
39
|
+
stdout(line: string): void
|
|
40
|
+
stderr(line: string): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Exit codes: 0 ok, 1 error, 2 usage, 4 gh auth or missing, 5 invalid model output. */
|
|
44
|
+
export const EXIT = { ok: 0, error: 1, usage: 2, gh: 4, invalid: 5 } as const
|
|
45
|
+
|
|
46
|
+
export class UsageError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message)
|
|
49
|
+
this.name = 'UsageError'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function printJson(io: CliIo, value: unknown): void {
|
|
54
|
+
io.stdout(JSON.stringify(value))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string, hint?: string): void {
|
|
58
|
+
const error: { code: ErrorCode; message: string; hint?: string } = { code, message }
|
|
59
|
+
if (hint !== undefined) {
|
|
60
|
+
error.hint = hint
|
|
61
|
+
}
|
|
62
|
+
printJson(io, { error })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isParseArgsError(err: unknown): err is Error {
|
|
66
|
+
return err instanceof Error && 'code' in err && typeof err.code === 'string' && err.code.startsWith('ERR_PARSE_ARGS')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prints the envelope for any failure and picks the exit code. */
|
|
70
|
+
export function reportFailure(io: CliIo, err: unknown): number {
|
|
71
|
+
if (err instanceof UsageError || isParseArgsError(err)) {
|
|
72
|
+
printErrorEnvelope(io, 'BAD_REQUEST', err.message, 'run pr-review --help')
|
|
73
|
+
return EXIT.usage
|
|
74
|
+
}
|
|
75
|
+
if (err instanceof ModelInvalidError) {
|
|
76
|
+
for (const e of err.report.errors) {
|
|
77
|
+
io.stdout(formatValidationError(e))
|
|
78
|
+
}
|
|
79
|
+
printErrorEnvelope(io, 'MODEL_INVALID', err.message, 'fix model.json and run publish again')
|
|
80
|
+
return EXIT.invalid
|
|
81
|
+
}
|
|
82
|
+
if (err instanceof PublishError) {
|
|
83
|
+
printErrorEnvelope(io, err.code, err.message, err.hint)
|
|
84
|
+
return EXIT.error
|
|
85
|
+
}
|
|
86
|
+
if (err instanceof SkillDirExistsError) {
|
|
87
|
+
printErrorEnvelope(io, 'SKILL_DIR_EXISTS', err.message, 'remove it, or pass --force to replace it')
|
|
88
|
+
return EXIT.error
|
|
89
|
+
}
|
|
90
|
+
const appErr = toAppError(err)
|
|
91
|
+
printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
|
|
92
|
+
return appErr.code === 'GH_UNAUTHENTICATED' || appErr.code === 'GH_MISSING' ? EXIT.gh : EXIT.error
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Splits `--repo` and `--data-dir`, which every repo-bound command shares, from the command's own
|
|
97
|
+
* flags. The rest keeps its order, so the command's parseArgs sees what the user typed.
|
|
98
|
+
*/
|
|
99
|
+
export function splitCommonFlags(argv: string[]): {
|
|
100
|
+
repo: string | undefined
|
|
101
|
+
dataDir: string | undefined
|
|
102
|
+
rest: string[]
|
|
103
|
+
} {
|
|
104
|
+
const { values, tokens } = parseArgs({
|
|
105
|
+
args: argv,
|
|
106
|
+
options: { repo: { type: 'string' }, 'data-dir': { type: 'string' } },
|
|
107
|
+
strict: false,
|
|
108
|
+
allowPositionals: true,
|
|
109
|
+
tokens: true,
|
|
110
|
+
})
|
|
111
|
+
const rest: string[] = []
|
|
112
|
+
for (const t of tokens) {
|
|
113
|
+
if (t.kind === 'option' && (t.name === 'repo' || t.name === 'data-dir')) {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (t.kind === 'option') {
|
|
117
|
+
if (t.value === undefined) {
|
|
118
|
+
rest.push(t.rawName)
|
|
119
|
+
} else if (t.inlineValue) {
|
|
120
|
+
rest.push(`${t.rawName}=${t.value}`)
|
|
121
|
+
} else {
|
|
122
|
+
rest.push(t.rawName, t.value)
|
|
123
|
+
}
|
|
124
|
+
} else if (t.kind === 'positional') {
|
|
125
|
+
rest.push(t.value)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
repo: typeof values.repo === 'string' ? values.repo : undefined,
|
|
130
|
+
dataDir: typeof values['data-dir'] === 'string' ? values['data-dir'] : undefined,
|
|
131
|
+
rest,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parsePrNumber(raw: string): number {
|
|
136
|
+
const n = Number(raw)
|
|
137
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
138
|
+
throw new UsageError(`--pr must be a positive integer, got "${raw}"`)
|
|
139
|
+
}
|
|
140
|
+
return n
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parsePrepareTarget(values: { pr?: string; base?: string; head?: string }): PrepareTarget {
|
|
144
|
+
if (values.pr !== undefined) {
|
|
145
|
+
if (values.base !== undefined || values.head !== undefined) {
|
|
146
|
+
throw new UsageError('pass either --pr <n> or --base <ref> --head <ref>, not both')
|
|
147
|
+
}
|
|
148
|
+
return { kind: 'pr', number: parsePrNumber(values.pr) }
|
|
149
|
+
}
|
|
150
|
+
if (values.base !== undefined && values.head !== undefined) {
|
|
151
|
+
return { kind: 'refs', base: values.base, head: values.head }
|
|
152
|
+
}
|
|
153
|
+
throw new UsageError('prepare needs --pr <n> or --base <ref> --head <ref>')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
157
|
+
const { values } = parseArgs({
|
|
158
|
+
args: argv,
|
|
159
|
+
options: { pr: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' }, force: { type: 'boolean' } },
|
|
160
|
+
strict: true,
|
|
161
|
+
})
|
|
162
|
+
const target = parsePrepareTarget(values)
|
|
163
|
+
const result = await prepare(ctx, target, { force: values.force === true, log: phase => io.stderr(phase) })
|
|
164
|
+
printJson(io, result)
|
|
165
|
+
return EXIT.ok
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A stored review.json is checked as the model would have written it; anything else is taken as model output. */
|
|
169
|
+
async function validateFile(
|
|
170
|
+
ctx: AppContext,
|
|
171
|
+
parsed: ReturnType<typeof parseModelText>,
|
|
172
|
+
context: GenerationContext
|
|
173
|
+
): Promise<ValidationReport> {
|
|
174
|
+
if ('error' in parsed) {
|
|
175
|
+
return { ok: false, errors: [parsed.error] }
|
|
176
|
+
}
|
|
177
|
+
const artifact = ReviewArtifactSchema.safeParse(parsed.raw)
|
|
178
|
+
const input = artifact.success ? artifactToModelOutput(artifact.data satisfies ReviewArtifact) : parsed.raw
|
|
179
|
+
const result = validateModelOutput(input, await validationInput(ctx, context, input))
|
|
180
|
+
return { ok: result.ok, errors: result.errors }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `validate <model.json|review.json> --canvas <dir> [--human] [--fix]`: the report as one JSON
|
|
185
|
+
* line, or as lines. `--fix` first trims the titles that are over their cap and writes the file
|
|
186
|
+
* back, so the only problems left to answer are the ones that need judgment.
|
|
187
|
+
*/
|
|
188
|
+
export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
189
|
+
const { values, positionals } = parseArgs({
|
|
190
|
+
args: argv,
|
|
191
|
+
options: { canvas: { type: 'string' }, human: { type: 'boolean' }, fix: { type: 'boolean' } },
|
|
192
|
+
allowPositionals: true,
|
|
193
|
+
strict: true,
|
|
194
|
+
})
|
|
195
|
+
const file = positionals[0]
|
|
196
|
+
if (file === undefined || positionals.length > 1) {
|
|
197
|
+
throw new UsageError('validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>')
|
|
198
|
+
}
|
|
199
|
+
if (values.canvas === undefined) {
|
|
200
|
+
throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
|
|
201
|
+
}
|
|
202
|
+
const context = await readContext(path.resolve(values.canvas))
|
|
203
|
+
const text = await readText(path.resolve(file))
|
|
204
|
+
if (text === null) {
|
|
205
|
+
throw new PublishError('NOT_FOUND', `${file} does not exist`, 'pass the model.json or review.json to check')
|
|
206
|
+
}
|
|
207
|
+
const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
|
|
208
|
+
const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
|
|
209
|
+
if (values.human !== true) {
|
|
210
|
+
printJson(io, values.fix === true ? { ...report, fixed: fixed.trims } : report)
|
|
211
|
+
return report.ok ? EXIT.ok : EXIT.invalid
|
|
212
|
+
}
|
|
213
|
+
for (const trim of fixed.trims) {
|
|
214
|
+
if (trim.outcome === 'fixed') {
|
|
215
|
+
io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
|
|
216
|
+
} else {
|
|
217
|
+
io.stdout(`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (report.ok) {
|
|
221
|
+
io.stdout(`ok: ${path.basename(file)} passes against ${context.files.length} files`)
|
|
222
|
+
} else {
|
|
223
|
+
for (const e of report.errors) {
|
|
224
|
+
io.stdout(formatValidationError(e))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return report.ok ? EXIT.ok : EXIT.invalid
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Trims the over-cap titles of a model file and writes it back. Returns the text to validate,
|
|
232
|
+
* unchanged when nothing needed trimming, so a file that is already fine is never rewritten.
|
|
233
|
+
*/
|
|
234
|
+
async function fixTitles(
|
|
235
|
+
file: string,
|
|
236
|
+
text: string,
|
|
237
|
+
context: GenerationContext
|
|
238
|
+
): Promise<{ text: string; trims: TitleTrim[] }> {
|
|
239
|
+
let parsed: unknown
|
|
240
|
+
try {
|
|
241
|
+
parsed = JSON.parse(text)
|
|
242
|
+
} catch {
|
|
243
|
+
// An unparseable file has no titles to trim; the validator reports the syntax error.
|
|
244
|
+
return { text, trims: [] }
|
|
245
|
+
}
|
|
246
|
+
const trims = applyTitleTrims(parsed, context.caps)
|
|
247
|
+
if (!trims.some(trim => trim.outcome === 'fixed')) {
|
|
248
|
+
return { text, trims }
|
|
249
|
+
}
|
|
250
|
+
const next = `${JSON.stringify(parsed, null, 2)}\n`
|
|
251
|
+
await writeTextAtomic(file, next)
|
|
252
|
+
return { text: next, trims }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseHarness(raw: string | undefined): (typeof HARNESSES)[number] {
|
|
256
|
+
if (raw === undefined) {
|
|
257
|
+
throw new UsageError(`publish needs --harness <${HARNESSES.join('|')}>`)
|
|
258
|
+
}
|
|
259
|
+
const hit = HARNESSES.find(h => h === raw)
|
|
260
|
+
if (hit === undefined) {
|
|
261
|
+
throw new UsageError(`--harness must be one of ${HARNESSES.join(', ')}, got "${raw}"`)
|
|
262
|
+
}
|
|
263
|
+
return hit
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
267
|
+
const { values, positionals } = parseArgs({
|
|
268
|
+
args: argv,
|
|
269
|
+
options: {
|
|
270
|
+
agent: { type: 'string' },
|
|
271
|
+
model: { type: 'string' },
|
|
272
|
+
harness: { type: 'string' },
|
|
273
|
+
'allow-stale': { type: 'boolean' },
|
|
274
|
+
},
|
|
275
|
+
allowPositionals: true,
|
|
276
|
+
strict: true,
|
|
277
|
+
})
|
|
278
|
+
const canvasDir = positionals[0]
|
|
279
|
+
if (canvasDir === undefined || positionals.length > 1) {
|
|
280
|
+
throw new UsageError('publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>')
|
|
281
|
+
}
|
|
282
|
+
if (values.agent === undefined || values.agent === '') {
|
|
283
|
+
throw new UsageError('publish needs --agent <id>')
|
|
284
|
+
}
|
|
285
|
+
const result = await publish(ctx, path.resolve(canvasDir), {
|
|
286
|
+
agent: values.agent,
|
|
287
|
+
model: values.model,
|
|
288
|
+
harness: parseHarness(values.harness),
|
|
289
|
+
allowStale: values['allow-stale'] === true,
|
|
290
|
+
})
|
|
291
|
+
printJson(io, result)
|
|
292
|
+
return EXIT.ok
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* `doctor`: every check the tool needs, as one JSON line. Exit 1 when one fails, so a script can
|
|
297
|
+
* read the code instead of the JSON.
|
|
298
|
+
*/
|
|
299
|
+
export async function runDoctor(deps: DoctorDeps, argv: string[], io: CliIo): Promise<number> {
|
|
300
|
+
const { values } = parseArgs({ args: argv, options: { 'all-checks': { type: 'boolean' } }, strict: true })
|
|
301
|
+
const report = await runDoctorChecks(deps, { allChecks: values['all-checks'] === true })
|
|
302
|
+
printJson(io, report)
|
|
303
|
+
return report.ok ? EXIT.ok : EXIT.error
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface InstallSkillEnv {
|
|
307
|
+
repoRoot: string
|
|
308
|
+
cwd: string
|
|
309
|
+
platform: NodeJS.Platform
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** `install-skill [--claude-dir <dir>] [--codex-dir <dir>] [--force]`, both dirs under the repo root by default. */
|
|
313
|
+
export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
|
|
314
|
+
const { values } = parseArgs({
|
|
315
|
+
args: argv,
|
|
316
|
+
options: { 'claude-dir': { type: 'string' }, 'codex-dir': { type: 'string' }, force: { type: 'boolean' } },
|
|
317
|
+
strict: true,
|
|
318
|
+
})
|
|
319
|
+
const resolve = (flag: string | undefined, fallback: string): string =>
|
|
320
|
+
flag === undefined ? path.join(env.repoRoot, fallback) : path.resolve(env.cwd, flag)
|
|
321
|
+
const result = await installSkill({
|
|
322
|
+
platform: env.platform,
|
|
323
|
+
force: values.force === true,
|
|
324
|
+
targets: [
|
|
325
|
+
{ kind: 'claude', dir: resolve(values['claude-dir'], CLAUDE_SKILLS_DIR) },
|
|
326
|
+
{ kind: 'codex', dir: resolve(values['codex-dir'], CODEX_SKILLS_DIR) },
|
|
327
|
+
],
|
|
328
|
+
})
|
|
329
|
+
await ignoreLocalSettings(env.repoRoot)
|
|
330
|
+
printJson(io, result)
|
|
331
|
+
return EXIT.ok
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The head a canvas is exported for: a PR's current head, or any ref or sha the user names. */
|
|
335
|
+
async function resolveHead(
|
|
336
|
+
ctx: AppContext,
|
|
337
|
+
values: { pr?: string; head?: string }
|
|
338
|
+
): Promise<{ headSha: string; prNumber?: number }> {
|
|
339
|
+
const prNumber = values.pr === undefined ? undefined : parsePrNumber(values.pr)
|
|
340
|
+
// With both flags the named commit is the one to export and the number only stamps the zip,
|
|
341
|
+
// which is what the generation skill does right after publishing a canvas for a pull request.
|
|
342
|
+
if (values.head !== undefined) {
|
|
343
|
+
const headSha = await ctx.git.revParse(values.head)
|
|
344
|
+
return prNumber === undefined ? { headSha } : { headSha, prNumber }
|
|
345
|
+
}
|
|
346
|
+
if (prNumber === undefined) {
|
|
347
|
+
throw new UsageError('export needs --pr <n> or --head <ref|sha>')
|
|
348
|
+
}
|
|
349
|
+
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
350
|
+
const { headSha } = await fetchPrRefs(ctx.git, meta)
|
|
351
|
+
return { headSha, prNumber }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** `export (--pr <n> | --head <ref|sha>) [--out <file|dir>]`: writes the zip and prints its path. */
|
|
355
|
+
export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
356
|
+
const { values } = parseArgs({
|
|
357
|
+
args: argv,
|
|
358
|
+
options: { pr: { type: 'string' }, head: { type: 'string' }, out: { type: 'string' } },
|
|
359
|
+
strict: true,
|
|
360
|
+
})
|
|
361
|
+
const target = await resolveHead(ctx, values)
|
|
362
|
+
const result = await exportCanvas(ctx, { headSha: target.headSha, prNumber: target.prNumber, out: values.out })
|
|
363
|
+
printJson(io, result)
|
|
364
|
+
io.stderr(`drag ${result.path} into the pull request description or a comment`)
|
|
365
|
+
return EXIT.ok
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The zip the user named, under the size cap. The cap is applied while reading, so a file that
|
|
370
|
+
* grows between the check and the read, and a named pipe that reports no size at all, both stop
|
|
371
|
+
* at the cap instead of filling memory.
|
|
372
|
+
*/
|
|
373
|
+
async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array> {
|
|
374
|
+
let handle: FileHandle
|
|
375
|
+
try {
|
|
376
|
+
handle = await open(zipPath, 'r')
|
|
377
|
+
} catch {
|
|
378
|
+
throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
|
|
379
|
+
}
|
|
380
|
+
const tooLarge = new AppError('CANVAS_TOO_LARGE', `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`, 413)
|
|
381
|
+
try {
|
|
382
|
+
// One byte past the cap is read, so a file of exactly the cap still fits and anything longer
|
|
383
|
+
// is refused without the rest of it ever being in memory.
|
|
384
|
+
const buffer = Buffer.alloc(CANVAS_ZIP_MAX_BYTES + 1)
|
|
385
|
+
let filled = 0
|
|
386
|
+
while (filled < buffer.length) {
|
|
387
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, null)
|
|
388
|
+
if (bytesRead === 0) {
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
filled += bytesRead
|
|
392
|
+
}
|
|
393
|
+
if (filled > CANVAS_ZIP_MAX_BYTES) {
|
|
394
|
+
throw tooLarge
|
|
395
|
+
}
|
|
396
|
+
return new Uint8Array(buffer.subarray(0, filled))
|
|
397
|
+
} finally {
|
|
398
|
+
await handle.close()
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `import <zip> [--pr <n>] [--force]`: the same path the drop zone and discovery use. */
|
|
403
|
+
export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
404
|
+
const { values, positionals } = parseArgs({
|
|
405
|
+
args: argv,
|
|
406
|
+
options: { pr: { type: 'string' }, force: { type: 'boolean' } },
|
|
407
|
+
allowPositionals: true,
|
|
408
|
+
strict: true,
|
|
409
|
+
})
|
|
410
|
+
const file = positionals[0]
|
|
411
|
+
if (file === undefined || positionals.length > 1) {
|
|
412
|
+
throw new UsageError('import takes one zip: pr-review import <zip> [--pr <n>] [--force]')
|
|
413
|
+
}
|
|
414
|
+
const bytes = await readZipFile(path.resolve(file), file)
|
|
415
|
+
const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
|
|
416
|
+
if (values.pr !== undefined) {
|
|
417
|
+
const prNumber = parsePrNumber(values.pr)
|
|
418
|
+
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
419
|
+
options.prNumber = prNumber
|
|
420
|
+
options.currentHeadSha = (await fetchPrRefs(ctx.git, meta)).headSha
|
|
421
|
+
}
|
|
422
|
+
printJson(io, await importCanvas(ctx, options))
|
|
423
|
+
return EXIT.ok
|
|
424
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { Repo } from './contract/review-artifact.js'
|
|
3
|
+
import { isChatAgent, type SettingsOverrides } from './contract/settings.js'
|
|
4
|
+
import { type Git, GitError } from './git/git.js'
|
|
5
|
+
import { resolveDataDir } from './store/data-dir.js'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PORT = 3010
|
|
8
|
+
|
|
9
|
+
export interface ServeFlags {
|
|
10
|
+
port?: number | undefined
|
|
11
|
+
repo?: string | undefined
|
|
12
|
+
dataDir?: string | undefined
|
|
13
|
+
fixtureCanvas?: string | undefined
|
|
14
|
+
/** Wins over `.pr-review/settings.yml` for this run; the settings dialog reports it. */
|
|
15
|
+
agent?: string | undefined
|
|
16
|
+
model?: string | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RuntimeConfig {
|
|
20
|
+
port: number
|
|
21
|
+
repoRoot: string
|
|
22
|
+
commonDir: string
|
|
23
|
+
dataDir: string
|
|
24
|
+
repo: Repo
|
|
25
|
+
/** Dev only: every PR reports `ready` with this artifact re-keyed to the live head. */
|
|
26
|
+
fixtureCanvasPath: string | null
|
|
27
|
+
/** Chat agent and model the flags force for this run, if any. */
|
|
28
|
+
chatOverrides: SettingsOverrides
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ConfigErrorCode = 'NOT_A_REPO' | 'NO_ORIGIN' | 'BAD_REQUEST'
|
|
32
|
+
|
|
33
|
+
export class ConfigError extends Error {
|
|
34
|
+
readonly code: ConfigErrorCode
|
|
35
|
+
readonly hint: string | undefined
|
|
36
|
+
|
|
37
|
+
constructor(code: ConfigErrorCode, message: string, hint?: string) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = 'ConfigError'
|
|
40
|
+
this.code = code
|
|
41
|
+
this.hint = hint
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resolveRepoRoot(git: Git): Promise<string> {
|
|
46
|
+
try {
|
|
47
|
+
return await git.topLevel()
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err instanceof GitError) {
|
|
50
|
+
throw new ConfigError('NOT_A_REPO', 'not inside a git repository', 'run from a clone or pass --repo <dir>')
|
|
51
|
+
}
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function resolveCommonDir(git: Git): Promise<string> {
|
|
57
|
+
return git.commonDir()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parses the two URL forms GitHub gives out: ssh (`git@github.com:o/r.git`) and https. */
|
|
61
|
+
export function parseGithubRemote(url: string): Repo | null {
|
|
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
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function resolveGithubRepo(git: Git): Promise<Repo> {
|
|
73
|
+
const url = await git.remoteUrl('origin')
|
|
74
|
+
if (url === null) {
|
|
75
|
+
throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', 'add one that points at GitHub')
|
|
76
|
+
}
|
|
77
|
+
const repo = parseGithubRemote(url)
|
|
78
|
+
if (repo === null) {
|
|
79
|
+
throw new ConfigError(
|
|
80
|
+
'NO_ORIGIN',
|
|
81
|
+
`origin is not a GitHub URL: ${url}`,
|
|
82
|
+
'only github.com repositories are supported'
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return repo
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `--agent` names one of the agents the chat knows; anything else is a usage error. */
|
|
89
|
+
export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
|
|
90
|
+
const overrides: SettingsOverrides = {}
|
|
91
|
+
if (flags.agent !== undefined) {
|
|
92
|
+
if (!isChatAgent(flags.agent)) {
|
|
93
|
+
throw new ConfigError('BAD_REQUEST', `unknown chat agent: ${flags.agent}`, 'use --agent claude or --agent codex')
|
|
94
|
+
}
|
|
95
|
+
overrides.agent = flags.agent
|
|
96
|
+
}
|
|
97
|
+
if (flags.model !== undefined && flags.model !== '') {
|
|
98
|
+
overrides.model = flags.model
|
|
99
|
+
}
|
|
100
|
+
return overrides
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parsePort(raw: string | undefined, fallback: number): number {
|
|
104
|
+
if (raw === undefined || raw === '') {
|
|
105
|
+
return fallback
|
|
106
|
+
}
|
|
107
|
+
const n = Number(raw)
|
|
108
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
109
|
+
throw new ConfigError('BAD_REQUEST', `invalid port: ${raw}`)
|
|
110
|
+
}
|
|
111
|
+
return n
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads one variable by name, which also keeps the linter's literal-key rule happy. */
|
|
115
|
+
export function readEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
116
|
+
return env[name]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Flags win over env; env over defaults. `git` must already run in the chosen repo directory.
|
|
121
|
+
*/
|
|
122
|
+
export async function loadRuntimeConfig(
|
|
123
|
+
flags: ServeFlags,
|
|
124
|
+
env: NodeJS.ProcessEnv,
|
|
125
|
+
git: Git,
|
|
126
|
+
cwd: string
|
|
127
|
+
): Promise<RuntimeConfig> {
|
|
128
|
+
const repoRoot = await resolveRepoRoot(git)
|
|
129
|
+
const commonDir = await resolveCommonDir(git)
|
|
130
|
+
const repo = await resolveGithubRepo(git)
|
|
131
|
+
const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
|
|
132
|
+
const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
|
|
133
|
+
return {
|
|
134
|
+
port,
|
|
135
|
+
repoRoot,
|
|
136
|
+
commonDir,
|
|
137
|
+
dataDir,
|
|
138
|
+
repo,
|
|
139
|
+
fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
|
|
140
|
+
chatOverrides: parseChatOverrides(flags),
|
|
141
|
+
}
|
|
142
|
+
}
|