@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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -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
+ }