@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
|
@@ -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
|
+
}
|