@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,324 @@
1
+ import { z } from 'zod'
2
+
3
+ /** Character caps applied to model output. Numbers, so the prompt can print them. */
4
+ export const TEXT_CAPS = {
5
+ summary: 1200,
6
+ layerTitle: 60,
7
+ rationale: 300,
8
+ decisions: 600,
9
+ checkByHand: 400,
10
+ annotation: 240,
11
+ pointTitle: 90,
12
+ pointBody: 600,
13
+ testBehavior: 120,
14
+ /** Mermaid source. Measured raw, since it is code and not text a reader reads. */
15
+ diagram: 1500,
16
+ } as const
17
+ export type TextCaps = { -readonly [K in keyof typeof TEXT_CAPS]: number }
18
+
19
+ export const LIMITS = { maxPoints: 12, maxDiagramsPerLayer: 1, maxDiagramLinks: 12 } as const
20
+ export type Limits = { -readonly [K in keyof typeof LIMITS]: number }
21
+
22
+ export const TEXT_CAP_KEYS = [
23
+ 'summary',
24
+ 'layerTitle',
25
+ 'rationale',
26
+ 'decisions',
27
+ 'checkByHand',
28
+ 'annotation',
29
+ 'pointTitle',
30
+ 'pointBody',
31
+ 'testBehavior',
32
+ 'diagram',
33
+ ] as const satisfies ReadonlyArray<keyof TextCaps>
34
+
35
+ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
36
+ return {
37
+ summary: fn('summary'),
38
+ layerTitle: fn('layerTitle'),
39
+ rationale: fn('rationale'),
40
+ decisions: fn('decisions'),
41
+ checkByHand: fn('checkByHand'),
42
+ annotation: fn('annotation'),
43
+ pointTitle: fn('pointTitle'),
44
+ pointBody: fn('pointBody'),
45
+ testBehavior: fn('testBehavior'),
46
+ diagram: fn('diagram'),
47
+ }
48
+ }
49
+
50
+ /** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
51
+ export function effectiveCaps(overrides: { [K in keyof TextCaps]?: number | undefined } | undefined): TextCaps {
52
+ return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
53
+ }
54
+
55
+ /**
56
+ * The raw-text ceiling behind a cap: four times the cap. The cap itself is measured on the visible
57
+ * text (review/text-length.ts) by the validator; the schema only refuses pathological input.
58
+ */
59
+ export const HARD_CAP_FACTOR = 4
60
+ export function hardCaps(caps: TextCaps): TextCaps {
61
+ return mapCaps(key => caps[key] * HARD_CAP_FACTOR)
62
+ }
63
+
64
+ /**
65
+ * Ceilings on a stored or imported artifact: four times the default caps and point limit. A project
66
+ * may raise its caps, but a zip from elsewhere must not hand the browser unbounded text.
67
+ */
68
+ export const ARTIFACT_HARD_CAPS: TextCaps = hardCaps(TEXT_CAPS)
69
+ export const ARTIFACT_MAX_POINTS = LIMITS.maxPoints * HARD_CAP_FACTOR
70
+
71
+ export const FILE_STATUSES = ['added', 'modified', 'deleted', 'renamed', 'binary'] as const
72
+ export const POINT_KINDS = ['decision', 'risk', 'drift', 'tests', 'debt', 'question'] as const
73
+ export const POINT_LEVELS = ['decide', 'check', 'fyi'] as const
74
+ export const TEST_STATUSES = ['covered', 'missing', 'not-needed'] as const
75
+ export const HARNESSES = ['claude-code', 'codex', 'other'] as const
76
+
77
+ export const SideSchema = z.enum(['new', 'old'])
78
+ export type Side = z.infer<typeof SideSchema>
79
+
80
+ export const HunkSchema = z.object({
81
+ id: z.string().min(1),
82
+ header: z.string(),
83
+ oldStart: z.number().int().nonnegative(),
84
+ oldLines: z.number().int().nonnegative(),
85
+ newStart: z.number().int().nonnegative(),
86
+ newLines: z.number().int().nonnegative(),
87
+ })
88
+ export type Hunk = z.infer<typeof HunkSchema>
89
+
90
+ export const FileEntrySchema = z.object({
91
+ path: z.string().min(1),
92
+ oldPath: z.string().min(1).optional(),
93
+ key: z.string().min(1),
94
+ status: z.enum(FILE_STATUSES),
95
+ additions: z.number().int().nonnegative(),
96
+ deletions: z.number().int().nonnegative(),
97
+ lang: z.string().optional(),
98
+ hunks: z.array(HunkSchema),
99
+ })
100
+ export type FileEntry = z.infer<typeof FileEntrySchema>
101
+
102
+ export const RepoSchema = z.object({ owner: z.string().min(1), name: z.string().min(1) })
103
+ export type Repo = z.infer<typeof RepoSchema>
104
+
105
+ export const PrSchema = z.object({
106
+ number: z.number().int().positive().nullable(),
107
+ title: z.string(),
108
+ body: z.string(),
109
+ author: z.string(),
110
+ url: z.string(),
111
+ state: z.string(),
112
+ draft: z.boolean(),
113
+ /**
114
+ * When the pull request was last edited; the body's attachment links are ranked by it. Absent
115
+ * in a canvas written before this field existed, so an older zip still imports.
116
+ */
117
+ updatedAt: z.string().optional(),
118
+ baseRef: z.string(),
119
+ headRef: z.string(),
120
+ headSha: z.string().regex(/^[0-9a-f]{40}$/),
121
+ mergeBaseSha: z.string().regex(/^[0-9a-f]{40}$/),
122
+ additions: z.number().int().nonnegative(),
123
+ deletions: z.number().int().nonnegative(),
124
+ changedFiles: z.number().int().nonnegative(),
125
+ repo: RepoSchema,
126
+ })
127
+ export type Pr = z.infer<typeof PrSchema>
128
+
129
+ export const RiskTagSchema = z.object({
130
+ label: z.string().min(1),
131
+ source: z.enum(['config', 'model']),
132
+ reason: z.string().optional(),
133
+ })
134
+ export type RiskTag = z.infer<typeof RiskTagSchema>
135
+
136
+ /** A link in one of the four canvas forms. Parsed and resolved by contract/links. */
137
+ export const LinkSchema = z.string().regex(/^#(layer|file|hunk|line):.+/)
138
+
139
+ /**
140
+ * One diagram of a layer. `links` maps a node id of the mermaid source to a canvas link, so a
141
+ * reader can click a node and land on the code it stands for. The validator checks that every key
142
+ * names a node of the source and that every value resolves.
143
+ */
144
+ export function diagramSchema(caps: Caps) {
145
+ return z.object({
146
+ mermaid: z.string().min(1).max(caps.diagram),
147
+ links: z.record(z.string(), LinkSchema),
148
+ })
149
+ }
150
+ export const DiagramSchema = diagramSchema(ARTIFACT_HARD_CAPS)
151
+ export type Diagram = z.infer<typeof DiagramSchema>
152
+
153
+ /**
154
+ * The model output is checked against the caps in force; the artifact is the published result of
155
+ * a validated output, so it reads back under the generous hard ceilings instead.
156
+ */
157
+ type Caps = TextCaps
158
+
159
+ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
160
+ return textOrEmpty(caps, key).min(1)
161
+ }
162
+
163
+ function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
164
+ const visibleCap = caps[key] / HARD_CAP_FACTOR
165
+ const diagrams = key === 'summary' || key === 'rationale'
166
+ ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
167
+ : ''
168
+ return z.string().max(caps[key]).meta({
169
+ description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
170
+ 'x-visibleMaxLength': visibleCap,
171
+ })
172
+ }
173
+
174
+ export function testEntrySchema(caps: Caps) {
175
+ return z.object({
176
+ behavior: text(caps, 'testBehavior'),
177
+ status: z.enum(TEST_STATUSES),
178
+ testPath: z.string().min(1).optional(),
179
+ note: z.string().optional(),
180
+ })
181
+ }
182
+ export const TestEntrySchema = testEntrySchema(ARTIFACT_HARD_CAPS)
183
+ export type TestEntry = z.infer<typeof TestEntrySchema>
184
+
185
+ export function annotationSchema(caps: Caps) {
186
+ return z.object({
187
+ side: SideSchema,
188
+ startLine: z.number().int().positive(),
189
+ endLine: z.number().int().positive(),
190
+ text: text(caps, 'annotation'),
191
+ })
192
+ }
193
+ export const AnnotationSchema = annotationSchema(ARTIFACT_HARD_CAPS)
194
+ export type Annotation = z.infer<typeof AnnotationSchema>
195
+
196
+ function codeFoldSchema(caps: Caps) {
197
+ return z.object({
198
+ title: z.string().max(caps.pointTitle).regex(/\S/, 'A fold title must contain visible text'),
199
+ side: SideSchema,
200
+ startLine: z.number().int().positive(),
201
+ endLine: z.number().int().positive(),
202
+ })
203
+ }
204
+ export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS)
205
+ export type CodeFold = z.infer<typeof CodeFoldSchema>
206
+
207
+ function layerFileBase(caps: Caps, foldTitleCap = caps.pointTitle) {
208
+ return {
209
+ path: z.string().min(1),
210
+ hunks: z.array(z.string().min(1)).min(1),
211
+ note: textOrEmpty(caps, 'annotation').optional(),
212
+ annotations: z.array(annotationSchema(caps)),
213
+ collapsed: z.boolean().optional(),
214
+ folds: z.array(codeFoldSchema({ ...caps, pointTitle: foldTitleCap })).optional(),
215
+ }
216
+ }
217
+
218
+ export const LayerFileSchema = z.object({ ...layerFileBase(ARTIFACT_HARD_CAPS), isTest: z.boolean() })
219
+ export type LayerFile = z.infer<typeof LayerFileSchema>
220
+
221
+ export const LayerKeySchema = z
222
+ .string()
223
+ .min(1)
224
+ .max(40)
225
+ .regex(/^[a-z0-9][a-z0-9-]*$/)
226
+
227
+ function layerBase(caps: Caps) {
228
+ return {
229
+ key: LayerKeySchema,
230
+ title: text(caps, 'layerTitle'),
231
+ rationale: textOrEmpty(caps, 'rationale'),
232
+ /** Markdown: decisions and trade-offs a human should agree with. */
233
+ decisions: text(caps, 'decisions').optional(),
234
+ /** Markdown: what tests cannot verify (UI feel, migrations on real data, ...). */
235
+ checkByHand: text(caps, 'checkByHand').optional(),
236
+ kind: z.enum(['layer', 'other']),
237
+ defaultLayerId: z.string().optional(),
238
+ diagram: diagramSchema(caps).optional(),
239
+ tests: z.array(testEntrySchema(caps)),
240
+ }
241
+ }
242
+
243
+ export const LayerSchema = z.object({
244
+ ...layerBase(ARTIFACT_HARD_CAPS),
245
+ id: z.string().min(1),
246
+ risk: z.array(RiskTagSchema),
247
+ files: z.array(LayerFileSchema).min(1),
248
+ })
249
+ export type Layer = z.infer<typeof LayerSchema>
250
+
251
+ function pointBase(caps: Caps) {
252
+ return {
253
+ kind: z.enum(POINT_KINDS),
254
+ level: z.enum(POINT_LEVELS),
255
+ title: text(caps, 'pointTitle'),
256
+ path: z.string().min(1),
257
+ line: z.number().int().positive(),
258
+ endLine: z.number().int().positive().optional(),
259
+ side: SideSchema.optional(),
260
+ body: text(caps, 'pointBody'),
261
+ }
262
+ }
263
+
264
+ export const PointSchema = z.object({
265
+ ...pointBase(ARTIFACT_HARD_CAPS),
266
+ id: z.string().min(1),
267
+ fingerprint: z.string().min(1),
268
+ origin: z.enum(['model', 'tests']),
269
+ /** The layer that owns the point's hunk; publish fills it from the file's layer. */
270
+ layerId: z.string().optional(),
271
+ })
272
+ export type Point = z.infer<typeof PointSchema>
273
+
274
+ export const GeneratorSchema = z.object({
275
+ agent: z.string().min(1),
276
+ model: z.string().optional(),
277
+ harness: z.enum(HARNESSES),
278
+ attempts: z.number().int().positive(),
279
+ })
280
+ export type Generator = z.infer<typeof GeneratorSchema>
281
+
282
+ export const ReviewArtifactSchema = z.object({
283
+ version: z.literal(1),
284
+ pr: PrSchema,
285
+ files: z.array(FileEntrySchema),
286
+ /** Markdown, PR-wide "what changes": behavior before and after, no required sections. */
287
+ summary: textOrEmpty(ARTIFACT_HARD_CAPS, 'summary'),
288
+ risk: z.array(RiskTagSchema),
289
+ layers: z.array(LayerSchema).min(1),
290
+ points: z.array(PointSchema).max(ARTIFACT_MAX_POINTS),
291
+ generatedAt: z.string(),
292
+ generator: GeneratorSchema,
293
+ source: z.enum(['local', 'import']),
294
+ importedAt: z.string().optional(),
295
+ })
296
+ export type ReviewArtifact = z.infer<typeof ReviewArtifactSchema>
297
+
298
+ /**
299
+ * What the agent writes to model.json. The schema bounds raw text at the hard limit (4x the cap);
300
+ * the validator measures the visible text against the cap itself. Ids, fingerprints, layerId,
301
+ * isTest, and config risk are added by publish. A layer may list no files here so the validator
302
+ * can name the problem (`LAYER_EMPTY`) instead of a schema failure; the point count is checked by
303
+ * the validator too, together with the points that missing tests add.
304
+ */
305
+ export function modelOutputSchema(textCaps: TextCaps) {
306
+ const caps = hardCaps(textCaps)
307
+ return z.object({
308
+ summary: textOrEmpty(caps, 'summary'),
309
+ layers: z
310
+ .array(
311
+ z.object({
312
+ ...layerBase(caps),
313
+ risk: z.array(z.object({ label: z.string().min(1), reason: z.string().min(1) })).optional(),
314
+ files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle))),
315
+ })
316
+ )
317
+ .min(1),
318
+ points: z.array(z.object(pointBase(caps))),
319
+ })
320
+ }
321
+ export const ModelOutputSchema = modelOutputSchema(TEXT_CAPS)
322
+ export type ModelOutput = z.infer<typeof ModelOutputSchema>
323
+ export type ModelLayer = ModelOutput['layers'][number]
324
+ export type ModelPoint = ModelOutput['points'][number]
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod'
2
+ import { DEFAULT_SKIN, isSkin, SKINS, type Skin } from '../../static/js/skin.js'
3
+ import { DEFAULT_THEME, isTheme, THEMES, type Theme } from '../../static/js/theme.js'
4
+
5
+ export type { Skin } from '../../static/js/skin.js'
6
+ // The skin and theme names live with the browser modules that apply them, because the page needs
7
+ // them before any server type could reach it.
8
+ export { DEFAULT_SKIN, SKINS } from '../../static/js/skin.js'
9
+ export type { Theme } from '../../static/js/theme.js'
10
+ export { DEFAULT_THEME, THEMES } from '../../static/js/theme.js'
11
+
12
+ /** The agents the chat knows how to reach through acpx. */
13
+ export const CHAT_AGENTS = ['claude', 'codex'] as const
14
+ export type ChatAgent = (typeof CHAT_AGENTS)[number]
15
+
16
+ export function isChatAgent(value: string): value is ChatAgent {
17
+ return (CHAT_AGENTS as readonly string[]).includes(value)
18
+ }
19
+
20
+ export const CHAT_TIMEOUT_MIN_SEC = 30
21
+ export const CHAT_TIMEOUT_MAX_SEC = 3600
22
+
23
+ export const SettingsSchema = z.object({
24
+ version: z.literal(1),
25
+ skin: z.enum(SKINS),
26
+ theme: z.enum(THEMES),
27
+ agent: z.enum(CHAT_AGENTS),
28
+ model: z.string().min(1).nullable(),
29
+ chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC),
30
+ maxTurns: z.number().int().positive().max(100).nullable(),
31
+ })
32
+ export type Settings = z.infer<typeof SettingsSchema>
33
+
34
+ export const DEFAULT_SETTINGS: Settings = {
35
+ version: 1,
36
+ skin: DEFAULT_SKIN,
37
+ theme: DEFAULT_THEME,
38
+ agent: 'claude',
39
+ model: null,
40
+ chatTimeoutSec: 600,
41
+ maxTurns: null,
42
+ }
43
+
44
+ /** What `PUT /api/settings` accepts: every field optional, the rest stays as it was. */
45
+ export const SettingsInputSchema = z.object({
46
+ skin: z.enum(SKINS).optional(),
47
+ theme: z.enum(THEMES).optional(),
48
+ agent: z.enum(CHAT_AGENTS).optional(),
49
+ model: z.string().max(200).nullable().optional(),
50
+ chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC).optional(),
51
+ maxTurns: z.number().int().positive().max(100).nullable().optional(),
52
+ })
53
+ export type SettingsInput = z.infer<typeof SettingsInputSchema>
54
+
55
+ /** How the page is painted: the skin, and light or dark. Both live in the settings file. */
56
+ export interface Appearance {
57
+ skin: Skin
58
+ theme: Theme
59
+ }
60
+
61
+ /** The `?skin` and `?theme` of one request, either of which may be absent. */
62
+ export interface AppearanceQuery {
63
+ skin?: string | undefined
64
+ theme?: string | undefined
65
+ }
66
+
67
+ /** The appearance for one request: `?skin=` and `?theme=` win over the saved ones, for that load. */
68
+ export function appearanceForRequest(saved: Appearance, query: AppearanceQuery): Appearance {
69
+ return {
70
+ skin: isSkin(query.skin) ? query.skin : saved.skin,
71
+ theme: isTheme(query.theme) ? query.theme : saved.theme,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * What `PUT /api/appearance` accepts. It is its own route, apart from the chat settings, because a
77
+ * repository with chat turned off still has a page to paint. A body that names neither is refused
78
+ * rather than saved as a no-op.
79
+ */
80
+ export const AppearanceInputSchema = z
81
+ .object({ skin: z.enum(SKINS).optional(), theme: z.enum(THEMES).optional() })
82
+ .refine(input => input.skin !== undefined || input.theme !== undefined, {
83
+ message: 'name a skin, a theme, or both',
84
+ })
85
+ export type AppearanceInput = z.infer<typeof AppearanceInputSchema>
86
+
87
+ /** How the page is painted right now, as the server has it saved. */
88
+ export type AppearanceResponse = Appearance
89
+
90
+ /** A `serve --agent/--model` flag wins over the file, and the dialog says so. */
91
+ export interface SettingsOverrides {
92
+ agent?: ChatAgent
93
+ model?: string
94
+ }
95
+
96
+ export interface SettingsResponse {
97
+ settings: Settings
98
+ /** The flags that win over the file for this run. */
99
+ overrides: SettingsOverrides
100
+ /** Absolute path of `.pr-review/settings.yml`, shown in the dialog. */
101
+ file: string
102
+ /** The committed project config, read-only in the UI. */
103
+ project: {
104
+ file: string | null
105
+ chatEnabled: boolean
106
+ rulebook: string | null
107
+ maxRepairRounds: number
108
+ inlineDiffMaxLines: number
109
+ smallPrHunks: number
110
+ layers: number
111
+ highRisk: number
112
+ }
113
+ }
114
+
115
+ /** Whether an agent can run right now, and why not when it cannot. */
116
+ export interface AgentAvailability {
117
+ id: ChatAgent
118
+ /** True when the binary is on PATH and its auth check passed. */
119
+ available: boolean
120
+ installed: boolean
121
+ authenticated: boolean
122
+ reason?: string
123
+ }
124
+
125
+ export interface AgentsResponse {
126
+ /** False when `acpx` itself is missing: the whole pane is unusable then. */
127
+ acpx: { installed: boolean; version: string | null }
128
+ agents: AgentAvailability[]
129
+ }
130
+
131
+ /** The answer of `POST /api/settings/agents/:id/probe`: one real one-shot round trip. */
132
+ export interface AgentProbeResult {
133
+ id: ChatAgent
134
+ ok: boolean
135
+ /** How long the round trip took, in milliseconds. */
136
+ ms: number
137
+ /** The agent's reply, cut to one short line; empty when it failed. */
138
+ reply: string
139
+ code?: string
140
+ message?: string
141
+ /** When this result was produced; results are reused for ten minutes. */
142
+ at: string
143
+ cached: boolean
144
+ }
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ChatThreadSchema = z.object({
4
+ name: z.string(),
5
+ agent: z.string(),
6
+ rev: z.number().int(),
7
+ title: z.string(),
8
+ createdAt: z.string(),
9
+ seededHeadSha: z.string(),
10
+ })
11
+
12
+ export type ChatThread = z.infer<typeof ChatThreadSchema>
13
+
14
+ export const PrStateSchema = z.object({
15
+ version: z.literal(1),
16
+ /**
17
+ * Counts the writes to this file. The page uses it to tell a newer answer from an older one
18
+ * when two of its requests overlap. Absent in state files of older tool versions.
19
+ */
20
+ rev: z.number().int().nonnegative().optional(),
21
+ reviewed: z.record(z.string(), z.literal(true)),
22
+ /**
23
+ * The commit the reviewed marks describe. A canvas for another commit describes other code,
24
+ * so the marks start again when the head moves. Absent in state files of older tool versions.
25
+ */
26
+ reviewedHeadSha: z.string().optional(),
27
+ hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
28
+ posted: z.array(z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })),
29
+ dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
30
+ chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
31
+ updatedAt: z.string(),
32
+ })
33
+ export type PrState = z.infer<typeof PrStateSchema>
34
+
35
+ export function emptyState(updatedAt: string): PrState {
36
+ return {
37
+ version: 1,
38
+ rev: 0,
39
+ reviewed: {},
40
+ hiddenThreads: {},
41
+ posted: [],
42
+ dismissed: {},
43
+ chat: { threads: [] },
44
+ updatedAt,
45
+ }
46
+ }
@@ -0,0 +1,43 @@
1
+ /** The problems `pr-review validate` and `publish` report, one line each. */
2
+ export const VALIDATION_CODES = [
3
+ 'SCHEMA',
4
+ 'TEXT_TOO_LONG',
5
+ 'HUNK_UNASSIGNED',
6
+ 'HUNK_DUPLICATE',
7
+ 'HUNK_UNKNOWN',
8
+ 'PATH_UNKNOWN',
9
+ 'LAYER_EMPTY',
10
+ 'LAYER_KEY_DUPLICATE',
11
+ 'OTHER_DUPLICATE',
12
+ 'OTHER_NOT_LAST',
13
+ 'TEST_NOT_LAST',
14
+ 'TEST_IN_OTHER',
15
+ 'RISK_IN_OTHER',
16
+ 'ANNOTATION_OUTSIDE_HUNK',
17
+ 'FOLD_INVALID',
18
+ 'POINT_OUTSIDE_DIFF',
19
+ 'TOO_MANY_POINTS',
20
+ 'TEST_PATH_UNKNOWN',
21
+ 'LINK_UNRESOLVED',
22
+ 'DIAGRAM_NODE_UNKNOWN',
23
+ 'DIAGRAM_LIMIT',
24
+ ] as const
25
+ export type ValidationCode = (typeof VALIDATION_CODES)[number]
26
+
27
+ export interface ValidationError {
28
+ code: ValidationCode
29
+ /** One line that stands on its own, without the code. */
30
+ message: string
31
+ /** Where in the output the problem sits, for tools: `summary`, `layer:<key>`, `point:<n>`. */
32
+ where?: string
33
+ }
34
+
35
+ export interface ValidationReport {
36
+ ok: boolean
37
+ errors: ValidationError[]
38
+ }
39
+
40
+ /** The line a human reads: the code, then the message. */
41
+ export function formatValidationError(error: ValidationError): string {
42
+ return `${error.code} ${error.message}`
43
+ }