@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,216 @@
1
+ /**
2
+ * Turns acpx's `--format json` lines into the small event set the chat needs, and decides what
3
+ * may be written to the raw event log.
4
+ *
5
+ * Verified against acpx 0.13.2 in the phase 6 spike: `--format json` prints one ACP JSON-RPC
6
+ * message per line. `session/update` notifications carry `params.update.sessionUpdate`; the
7
+ * `session/prompt` result carries `stopReason`; failures arrive as a JSON-RPC error whose
8
+ * `data.detailCode` names the cause (`AUTH_REQUIRED` and friends).
9
+ */
10
+
11
+ export const AGENT_ERROR_CODES = [
12
+ 'AGENT_AUTH_REQUIRED',
13
+ 'AGENT_PROTOCOL_INVALID',
14
+ 'AGENT_INCOMPLETE',
15
+ 'AGENT_TIMEOUT',
16
+ 'AGENT_PERMISSION_DENIED',
17
+ 'AGENT_NO_SESSION',
18
+ 'AGENT_USAGE',
19
+ 'AGENT_INTERRUPTED',
20
+ 'AGENT_FAILED',
21
+ 'AGENT_MISSING',
22
+ ] as const
23
+ export type AgentErrorCode = (typeof AGENT_ERROR_CODES)[number]
24
+
25
+ export type AgentEvent =
26
+ | { type: 'chunk'; text: string }
27
+ | { type: 'thought'; text: string }
28
+ | { type: 'tool'; id: string; title: string; status: string }
29
+ | { type: 'usage'; used: number; size: number }
30
+ | { type: 'plan'; entries: number }
31
+ | { type: 'done'; stopReason: string }
32
+ | { type: 'error'; code: AgentErrorCode; message: string }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
36
+ }
37
+
38
+ /** One field by name. Reading through a helper keeps the literal-key lint rule happy. */
39
+ function read(source: Record<string, unknown>, key: string): unknown {
40
+ return source[key]
41
+ }
42
+
43
+ function readString(source: Record<string, unknown>, key: string): string | undefined {
44
+ const value = read(source, key)
45
+ return typeof value === 'string' ? value : undefined
46
+ }
47
+
48
+ function readNumber(source: Record<string, unknown>, key: string): number {
49
+ const value = read(source, key)
50
+ return typeof value === 'number' ? value : 0
51
+ }
52
+
53
+ /** The text of an ACP content block, which is `{ type: 'text', text }` for everything we show. */
54
+ function contentText(value: unknown): string {
55
+ if (!isRecord(value)) {
56
+ return ''
57
+ }
58
+ return readString(value, 'text') ?? ''
59
+ }
60
+
61
+ /**
62
+ * The event one acpx line means, or null for a line the chat ignores (the handshake, the command
63
+ * list, session titles).
64
+ */
65
+ export function mapAcpxMessage(message: unknown): AgentEvent | null {
66
+ if (!isRecord(message)) {
67
+ return { type: 'error', code: 'AGENT_PROTOCOL_INVALID', message: 'the agent wrote a line that is not a message' }
68
+ }
69
+ const error = read(message, 'error')
70
+ if (isRecord(error)) {
71
+ return mapErrorObject(error)
72
+ }
73
+ const result = read(message, 'result')
74
+ if (isRecord(result)) {
75
+ const stopReason = readString(result, 'stopReason')
76
+ return stopReason === undefined ? null : { type: 'done', stopReason }
77
+ }
78
+ if (read(message, 'method') !== 'session/update') {
79
+ return null
80
+ }
81
+ const params = read(message, 'params')
82
+ const update = isRecord(params) ? read(params, 'update') : undefined
83
+ return isRecord(update) ? mapUpdate(update) : null
84
+ }
85
+
86
+ function mapUpdate(update: Record<string, unknown>): AgentEvent | null {
87
+ switch (read(update, 'sessionUpdate')) {
88
+ case 'agent_message_chunk':
89
+ return { type: 'chunk', text: contentText(read(update, 'content')) }
90
+ case 'agent_thought_chunk':
91
+ return { type: 'thought', text: contentText(read(update, 'content')) }
92
+ case 'tool_call':
93
+ case 'tool_call_update':
94
+ return {
95
+ type: 'tool',
96
+ id: readString(update, 'toolCallId') ?? '',
97
+ title: readString(update, 'title') ?? readString(update, 'kind') ?? 'tool',
98
+ status: readString(update, 'status') ?? 'pending',
99
+ }
100
+ case 'usage_update':
101
+ return { type: 'usage', used: readNumber(update, 'used'), size: readNumber(update, 'size') }
102
+ case 'plan': {
103
+ const entries = read(update, 'entries')
104
+ return { type: 'plan', entries: Array.isArray(entries) ? entries.length : 0 }
105
+ }
106
+ default:
107
+ return null
108
+ }
109
+ }
110
+
111
+ /** acpx names the cause in `data.detailCode`; the JSON-RPC code alone says only "it failed". */
112
+ function mapErrorObject(error: Record<string, unknown>): AgentEvent {
113
+ const data = read(error, 'data')
114
+ const detail = isRecord(data) ? readString(data, 'detailCode') : undefined
115
+ const message = readString(error, 'message') ?? 'the agent reported an error'
116
+ return { type: 'error', code: detailCodeToAgentCode(detail), message }
117
+ }
118
+
119
+ export function detailCodeToAgentCode(detail: string | undefined): AgentErrorCode {
120
+ switch (detail) {
121
+ case 'AUTH_REQUIRED':
122
+ return 'AGENT_AUTH_REQUIRED'
123
+ case 'PERMISSION_DENIED':
124
+ return 'AGENT_PERMISSION_DENIED'
125
+ case 'TIMEOUT':
126
+ return 'AGENT_TIMEOUT'
127
+ case 'NO_SESSION':
128
+ return 'AGENT_NO_SESSION'
129
+ default:
130
+ return 'AGENT_FAILED'
131
+ }
132
+ }
133
+
134
+ /**
135
+ * What an acpx exit code means. Verified in the spike: a usage error exits 2, a failed spawn
136
+ * exits 1, and acpx's own `--timeout` exits 0 with no terminal event at all.
137
+ */
138
+ export function exitCodeToAgentCode(code: number): AgentErrorCode | null {
139
+ switch (code) {
140
+ case 0:
141
+ return null
142
+ case 2:
143
+ return 'AGENT_USAGE'
144
+ case 3:
145
+ return 'AGENT_TIMEOUT'
146
+ case 4:
147
+ return 'AGENT_NO_SESSION'
148
+ case 5:
149
+ return 'AGENT_PERMISSION_DENIED'
150
+ case 130:
151
+ return 'AGENT_INTERRUPTED'
152
+ default:
153
+ return 'AGENT_FAILED'
154
+ }
155
+ }
156
+
157
+ export function exitCodeMessage(code: number): string {
158
+ switch (code) {
159
+ case 2:
160
+ return 'acpx rejected the command line'
161
+ case 3:
162
+ return 'acpx timed out waiting for the agent'
163
+ case 4:
164
+ return 'the chat session no longer exists'
165
+ case 5:
166
+ return 'the agent was denied a permission it needed'
167
+ case 130:
168
+ return 'the agent was interrupted'
169
+ default:
170
+ return `acpx exited with code ${code}`
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Lines the raw event log may keep, with everything sensitive taken out.
176
+ *
177
+ * `--suppress-reads` blanks the visible tool output but leaves the file's content under
178
+ * `_meta.claudeCode.toolResponse`, so `_meta` is dropped everywhere. Two whole lines are dropped
179
+ * as well: the auth notification names the signed-in account, and acpx echoes the `session/prompt`
180
+ * request, whose text holds the lines the reader selected.
181
+ */
182
+ export function scrubForLog(message: unknown): unknown | null {
183
+ if (!isRecord(message)) {
184
+ return null
185
+ }
186
+ const method = read(message, 'method')
187
+ if (method === '_auth/status_update' || method === 'session/prompt') {
188
+ return null
189
+ }
190
+ const params = read(message, 'params')
191
+ const update = isRecord(params) ? read(params, 'update') : undefined
192
+ if (isRecord(update)) {
193
+ const kind = read(update, 'sessionUpdate')
194
+ if (kind === 'available_commands_update') {
195
+ return null
196
+ }
197
+ }
198
+ return withoutMeta(message)
199
+ }
200
+
201
+ function withoutMeta(value: unknown): unknown {
202
+ if (Array.isArray(value)) {
203
+ return value.map(withoutMeta)
204
+ }
205
+ if (!isRecord(value)) {
206
+ return value
207
+ }
208
+ const out: Record<string, unknown> = {}
209
+ for (const [key, item] of Object.entries(value)) {
210
+ if (key === '_meta') {
211
+ continue
212
+ }
213
+ out[key] = withoutMeta(item)
214
+ }
215
+ return out
216
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Splits a byte stream into JSON lines. acpx writes one JSON-RPC message per line, so a line
3
+ * that never ends, or one too long to be a message, is a protocol failure rather than something
4
+ * to buffer without limit.
5
+ */
6
+
7
+ /** A single ACP message is small; anything past this is not one. */
8
+ export const NDJSON_LINE_MAX = 1024 * 1024
9
+
10
+ export class NdjsonError extends Error {
11
+ readonly code = 'AGENT_PROTOCOL_INVALID'
12
+
13
+ constructor(message: string) {
14
+ super(message)
15
+ this.name = 'NdjsonError'
16
+ }
17
+ }
18
+
19
+ export interface NdjsonSplitter {
20
+ /** The messages completed by this chunk, in order. */
21
+ push(chunk: string): unknown[]
22
+ /** The last message when the stream ended without a newline. */
23
+ flush(): unknown[]
24
+ }
25
+
26
+ /**
27
+ * `JSON.parse` is one of the two places this package casts: what comes back is unknown, and the
28
+ * caller validates it.
29
+ */
30
+ function parseLine(line: string): unknown {
31
+ try {
32
+ return JSON.parse(line) as unknown
33
+ } catch {
34
+ throw new NdjsonError(`the agent wrote a line that is not JSON (${line.length} characters)`)
35
+ }
36
+ }
37
+
38
+ export function createNdjsonSplitter(lineMax: number = NDJSON_LINE_MAX): NdjsonSplitter {
39
+ let buffer = ''
40
+ const take = (raw: string): unknown[] => {
41
+ const line = raw.trim()
42
+ return line === '' ? [] : [parseLine(line)]
43
+ }
44
+ return {
45
+ push(chunk) {
46
+ buffer += chunk
47
+ const out: unknown[] = []
48
+ let at = buffer.indexOf('\n')
49
+ while (at !== -1) {
50
+ const raw = buffer.slice(0, at)
51
+ buffer = buffer.slice(at + 1)
52
+ if (raw.length > lineMax) {
53
+ throw new NdjsonError(`the agent wrote a line of ${raw.length} characters, over the ${lineMax} limit`)
54
+ }
55
+ out.push(...take(raw))
56
+ at = buffer.indexOf('\n')
57
+ }
58
+ if (buffer.length > lineMax) {
59
+ throw new NdjsonError(`the agent wrote over ${lineMax} characters without ending the line`)
60
+ }
61
+ return out
62
+ },
63
+ flush() {
64
+ const rest = buffer
65
+ buffer = ''
66
+ return take(rest)
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,44 @@
1
+ import type { AgentRunner } from './acpx.js'
2
+
3
+ /** How long the acpx presence check is reused. */
4
+ export const PREFLIGHT_TTL_MS = 10 * 60 * 1000
5
+
6
+ export interface AcpxPreflight {
7
+ installed: boolean
8
+ version: string | null
9
+ }
10
+
11
+ export interface PreflightProbe {
12
+ get(opts?: { refresh?: boolean }): Promise<AcpxPreflight>
13
+ }
14
+
15
+ /**
16
+ * Is acpx on PATH? The answer barely changes while the server runs, so it is asked once and
17
+ * reused; the page shows a banner and hides the chat when it says no.
18
+ */
19
+ export function createPreflightProbe(runner: AgentRunner, now: () => Date, ttlMs = PREFLIGHT_TTL_MS): PreflightProbe {
20
+ let cached: { at: number; value: AcpxPreflight } | null = null
21
+ let inFlight: Promise<AcpxPreflight> | null = null
22
+ return {
23
+ async get(opts = {}) {
24
+ const at = now().getTime()
25
+ if (opts.refresh !== true && cached !== null && at - cached.at < ttlMs) {
26
+ return cached.value
27
+ }
28
+ if (inFlight !== null) {
29
+ return inFlight
30
+ }
31
+ inFlight = runner
32
+ .acpxVersion()
33
+ .then(version => {
34
+ const value: AcpxPreflight = { installed: version !== null, version }
35
+ cached = { at: now().getTime(), value }
36
+ return value
37
+ })
38
+ .finally(() => {
39
+ inFlight = null
40
+ })
41
+ return inFlight
42
+ },
43
+ }
44
+ }
@@ -0,0 +1,95 @@
1
+ // Building the zip a human attaches to the pull request. The bytes are built once and either
2
+ // written to a file (CLI) or streamed to the browser (the header's export command).
3
+ import { mkdir, stat, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import type { CanvasManifest } from '../contract/canvas-manifest.js'
6
+ import type { AppContext } from '../server/context.js'
7
+ import { AppError } from '../server/errors.js'
8
+ import { buildCanvasZipName } from './name.js'
9
+ import { buildCanvasZip } from './zip.js'
10
+
11
+ export interface CanvasZip {
12
+ name: string
13
+ bytes: Uint8Array<ArrayBuffer>
14
+ headSha: string
15
+ prNumber?: number
16
+ }
17
+
18
+ /**
19
+ * The zip for one stored canvas. `prNumber` stamps a canvas exported before the pull request
20
+ * existed, so the file name and the manifest name the PR.
21
+ */
22
+ export async function buildCanvasZipFor(
23
+ ctx: AppContext,
24
+ headSha: string,
25
+ prNumber?: number | undefined
26
+ ): Promise<CanvasZip> {
27
+ const artifact = await ctx.canvases.readArtifact(headSha)
28
+ const stored = await ctx.canvases.readManifest(headSha)
29
+ if (artifact === null || stored === null) {
30
+ throw new AppError(
31
+ 'CANVAS_NOT_FOUND',
32
+ `no canvas for ${headSha.slice(0, 7)}`,
33
+ 404,
34
+ 'generate one with the pr-review-canvas skill, or pass a head that has one'
35
+ )
36
+ }
37
+ const number = prNumber ?? stored.prNumber
38
+ const manifest: CanvasManifest = number === undefined ? stored : { ...stored, prNumber: number }
39
+ const zip: CanvasZip = {
40
+ name: buildCanvasZipName({ repo: manifest.repo, headSha, prNumber: number }),
41
+ bytes: buildCanvasZip(manifest, artifact),
42
+ headSha,
43
+ }
44
+ if (number !== undefined) {
45
+ zip.prNumber = number
46
+ }
47
+ return zip
48
+ }
49
+
50
+ export interface ExportResult {
51
+ status: 'exported'
52
+ path: string
53
+ name: string
54
+ headSha: string
55
+ prNumber?: number
56
+ }
57
+
58
+ async function isDirectory(target: string): Promise<boolean> {
59
+ try {
60
+ return (await stat(target)).isDirectory()
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ /** `--out` names a file when it ends in `.zip` or is not an existing directory. */
67
+ export async function resolveOutPath(out: string | undefined, defaultDir: string, name: string): Promise<string> {
68
+ if (out === undefined) {
69
+ return path.join(defaultDir, name)
70
+ }
71
+ const resolved = path.resolve(out)
72
+ if (resolved.toLowerCase().endsWith('.zip')) {
73
+ return resolved
74
+ }
75
+ return (await isDirectory(resolved)) ? path.join(resolved, name) : resolved
76
+ }
77
+
78
+ export function defaultExportDir(ctx: AppContext): string {
79
+ return path.join(ctx.config.dataDir, 'exports')
80
+ }
81
+
82
+ export async function exportCanvas(
83
+ ctx: AppContext,
84
+ opts: { headSha: string; prNumber?: number | undefined; out?: string | undefined }
85
+ ): Promise<ExportResult> {
86
+ const zip = await buildCanvasZipFor(ctx, opts.headSha, opts.prNumber)
87
+ const file = await resolveOutPath(opts.out, defaultExportDir(ctx), zip.name)
88
+ await mkdir(path.dirname(file), { recursive: true })
89
+ await writeFile(file, zip.bytes)
90
+ const result: ExportResult = { status: 'exported', path: file, name: zip.name, headSha: zip.headSha }
91
+ if (zip.prNumber !== undefined) {
92
+ result.prNumber = zip.prNumber
93
+ }
94
+ return result
95
+ }
@@ -0,0 +1,138 @@
1
+ // One import path for the three ways a canvas arrives: the drop zone, `pr-review import`, and a
2
+ // zip discovered on the pull request. Everything is checked here, so no caller can skip a step.
3
+ import type { CanvasRelation, ImportResult } from '../contract/api.js'
4
+ import type { CanvasManifest } from '../contract/canvas-manifest.js'
5
+ import type { ReviewArtifact } from '../contract/review-artifact.js'
6
+ import type { AppContext } from '../server/context.js'
7
+ import { AppError } from '../server/errors.js'
8
+ import { CanvasZipError, readCanvasZip } from './zip.js'
9
+
10
+ export interface ImportOptions {
11
+ bytes: Uint8Array
12
+ /** The pull request the canvas is imported for; recorded on the canvas. */
13
+ prNumber?: number | undefined
14
+ /** The head the page is looking at. Absent means the canvas is taken as the current one. */
15
+ currentHeadSha?: string | undefined
16
+ /** Accepts a canvas exported from another repository. */
17
+ force?: boolean | undefined
18
+ }
19
+
20
+ function sameRepo(a: { owner: string; name: string }, b: { owner: string; name: string }): boolean {
21
+ return a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase()
22
+ }
23
+
24
+ /** The zip error as the HTTP envelope the routes and the CLI both report. */
25
+ export function toAppErrorFromZip(err: CanvasZipError): AppError {
26
+ if (err.code === 'CANVAS_TOO_LARGE') {
27
+ return new AppError('CANVAS_TOO_LARGE', err.message, 413, 'a review canvas is a few hundred kilobytes')
28
+ }
29
+ return new AppError('CANVAS_INVALID', err.message, 400, err.issues[0], err.issues)
30
+ }
31
+
32
+ /**
33
+ * Fetches the head commit when the clone does not have it, so an imported canvas can show its
34
+ * diffs. A clone that cannot reach the commit reports `derivable: false` instead of failing.
35
+ */
36
+ async function ensureDerived(
37
+ ctx: AppContext,
38
+ headSha: string,
39
+ mergeBaseSha: string,
40
+ warnings: string[]
41
+ ): Promise<boolean> {
42
+ if (!(await ctx.derived.derivable(headSha, mergeBaseSha))) {
43
+ try {
44
+ await ctx.git.fetch('origin', [headSha, mergeBaseSha])
45
+ } catch {
46
+ // Servers may refuse a fetch by sha; the next check reports the canvas as not derivable.
47
+ }
48
+ }
49
+ if (!(await ctx.derived.derivable(headSha, mergeBaseSha))) {
50
+ warnings.push(`${headSha.slice(0, 7)} is not in this clone, so the diffs are not available`)
51
+ return false
52
+ }
53
+ try {
54
+ await ctx.derived.ensure(headSha, mergeBaseSha)
55
+ return true
56
+ } catch (err) {
57
+ warnings.push(`the diffs for ${headSha.slice(0, 7)} could not be rebuilt: ${errorText(err)}`)
58
+ return false
59
+ }
60
+ }
61
+
62
+ function errorText(err: unknown): string {
63
+ return err instanceof Error ? err.message : String(err)
64
+ }
65
+
66
+ async function relateToHead(
67
+ ctx: AppContext,
68
+ headSha: string,
69
+ currentHeadSha: string
70
+ ): Promise<{ relation: CanvasRelation; commitsBehind?: number }> {
71
+ if (!(await ctx.git.isAncestor(headSha, currentHeadSha))) {
72
+ return { relation: 'unrelated' }
73
+ }
74
+ return { relation: 'ancestor', commitsBehind: await ctx.git.countCommitsBetween(headSha, currentHeadSha) }
75
+ }
76
+
77
+ /**
78
+ * Validates the zip, stores it under its head sha, and says how it relates to the PR head. A
79
+ * canvas already on disk is kept unless the incoming one was generated later.
80
+ */
81
+ export async function importCanvas(ctx: AppContext, opts: ImportOptions): Promise<ImportResult> {
82
+ let contents: { manifest: CanvasManifest; artifact: ReviewArtifact }
83
+ try {
84
+ contents = readCanvasZip(opts.bytes)
85
+ } catch (err) {
86
+ throw err instanceof CanvasZipError ? toAppErrorFromZip(err) : err
87
+ }
88
+ const { manifest, artifact } = contents
89
+ const warnings: string[] = []
90
+ if (!sameRepo(manifest.repo, ctx.config.repo)) {
91
+ const from = `${manifest.repo.owner}/${manifest.repo.name}`
92
+ if (opts.force !== true) {
93
+ throw new AppError(
94
+ 'CANVAS_REPO_MISMATCH',
95
+ `this canvas was exported from ${from}, and origin here is ${ctx.config.repo.owner}/${ctx.config.repo.name}`,
96
+ 400,
97
+ 'import it with --force to use it anyway'
98
+ )
99
+ }
100
+ warnings.push(`imported a canvas exported from ${from}`)
101
+ }
102
+
103
+ const headSha = manifest.headSha
104
+ const currentHeadSha = opts.currentHeadSha ?? headSha
105
+ const index = await ctx.canvases.readIndex()
106
+ const stored = index.canvases[headSha]
107
+ const keepStored = stored !== undefined && stored.generatedAt >= artifact.generatedAt
108
+ if (!keepStored) {
109
+ const importedAt = ctx.now().toISOString()
110
+ await ctx.canvases.write(headSha, { ...artifact, source: 'import', importedAt }, manifest, opts.prNumber)
111
+ } else if (opts.prNumber !== undefined) {
112
+ await ctx.canvases.attachPrNumber(headSha, opts.prNumber)
113
+ }
114
+
115
+ // On `exists` the stored canvas stays, so its own merge base decides what derived/ holds.
116
+ const storedManifest = keepStored ? await ctx.canvases.readManifest(headSha) : null
117
+ const mergeBaseSha = storedManifest?.mergeBaseSha ?? manifest.mergeBaseSha
118
+ const derivable = await ensureDerived(ctx, headSha, mergeBaseSha, warnings)
119
+ if (keepStored) {
120
+ return { status: 'exists', headSha, currentHeadSha, derivable, warnings }
121
+ }
122
+ if (headSha === currentHeadSha) {
123
+ return { status: 'ready', headSha, currentHeadSha, derivable, warnings }
124
+ }
125
+ const related = await relateToHead(ctx, headSha, currentHeadSha)
126
+ const result: ImportResult = {
127
+ status: 'stale',
128
+ headSha,
129
+ currentHeadSha,
130
+ relation: related.relation,
131
+ derivable,
132
+ warnings,
133
+ }
134
+ if (related.commitsBehind !== undefined) {
135
+ result.commitsBehind = related.commitsBehind
136
+ }
137
+ return result
138
+ }
@@ -0,0 +1,55 @@
1
+ // The zip file name carries the repo, the PR number when there is one, and the head sha, so a
2
+ // file attached to a pull request can be recognised before it is downloaded.
3
+ import type { Repo } from '../contract/review-artifact.js'
4
+
5
+ export interface ParsedCanvasName {
6
+ prNumber?: number
7
+ sha7: string
8
+ }
9
+
10
+ export const CANVAS_NAME_PREFIX = 'pr-review-canvas'
11
+
12
+ /** Owner and repo names may hold characters a file name should not; runs of them become one dash. */
13
+ function slugPart(value: string): string {
14
+ return value
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/^-|-$/g, '')
18
+ }
19
+
20
+ export function repoSlug(repo: Repo): string {
21
+ return `${slugPart(repo.owner)}-${slugPart(repo.name)}`
22
+ }
23
+
24
+ export interface BuildNameOptions {
25
+ repo: Repo
26
+ headSha: string
27
+ prNumber?: number | undefined
28
+ }
29
+
30
+ /** `pr-review-canvas-<owner>-<repo>-pr<n>-<sha7>.zip`, without `-pr<n>` before the PR exists. */
31
+ export function buildCanvasZipName(opts: BuildNameOptions): string {
32
+ const pr = opts.prNumber === undefined ? '' : `-pr${opts.prNumber}`
33
+ return `${CANVAS_NAME_PREFIX}-${repoSlug(opts.repo)}${pr}-${opts.headSha.slice(0, 7)}.zip`
34
+ }
35
+
36
+ const TAIL_RE = /^(?:pr(\d+)-)?([0-9a-f]{7})\.zip$/
37
+
38
+ /**
39
+ * Reads a file name as a canvas zip for `repo`. A name for another repository, or one that does
40
+ * not follow the grammar, returns null: discovery uses this to skip attachments that are not ours.
41
+ */
42
+ export function parseCanvasZipName(filename: string, repo: Repo): ParsedCanvasName | null {
43
+ const prefix = `${CANVAS_NAME_PREFIX}-${repoSlug(repo)}-`
44
+ const lower = filename.toLowerCase()
45
+ if (!lower.startsWith(prefix)) {
46
+ return null
47
+ }
48
+ const m = TAIL_RE.exec(lower.slice(prefix.length))
49
+ const sha7 = m?.[2]
50
+ if (sha7 === undefined) {
51
+ return null
52
+ }
53
+ const pr = m?.[1]
54
+ return pr === undefined ? { sha7 } : { prNumber: Number(pr), sha7 }
55
+ }