@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,266 @@
|
|
|
1
|
+
import type { CanvasInfo, PrBundle, SharedCanvasInfo, StaleInfo } from '../contract/api.js'
|
|
2
|
+
import type { CommentsPayload } from '../contract/comments.js'
|
|
3
|
+
import { isLargePr } from '../contract/generation-context.js'
|
|
4
|
+
import type { FileEntry, Pr, ReviewArtifact } from '../contract/review-artifact.js'
|
|
5
|
+
import { discoverSharedCanvas, discoveryFingerprint } from '../github/attachments.js'
|
|
6
|
+
import { fetchComments } from '../github/comments.js'
|
|
7
|
+
import { fetchPrMeta, fetchPrRefs, toPr } from '../github/pr.js'
|
|
8
|
+
import { stateForHead } from '../github/review-body.js'
|
|
9
|
+
import { buildSkillCommand } from '../review/skill-command.js'
|
|
10
|
+
import type { CanvasLookup } from '../store/canvas-store.js'
|
|
11
|
+
import type { AppContext } from './context.js'
|
|
12
|
+
import { AppError } from './errors.js'
|
|
13
|
+
|
|
14
|
+
export interface BundleOptions {
|
|
15
|
+
refresh: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiscoveryRun {
|
|
19
|
+
sharedCanvas: SharedCanvasInfo | null
|
|
20
|
+
imported: boolean
|
|
21
|
+
warnings: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Scans the pull request for an attached canvas and imports the best one. The scan is skipped
|
|
26
|
+
* while the PR text is the same as at the last scan, unless the caller asks for a fresh run.
|
|
27
|
+
*/
|
|
28
|
+
export async function runDiscovery(
|
|
29
|
+
ctx: AppContext,
|
|
30
|
+
pr: Pr,
|
|
31
|
+
comments: CommentsPayload,
|
|
32
|
+
opts: { refresh: boolean }
|
|
33
|
+
): Promise<DiscoveryRun> {
|
|
34
|
+
if (pr.number === null) {
|
|
35
|
+
return { sharedCanvas: null, imported: false, warnings: [] }
|
|
36
|
+
}
|
|
37
|
+
const fingerprint = discoveryFingerprint(pr.body, comments, pr.headSha)
|
|
38
|
+
const cached = await ctx.prs.readDiscovery(pr.number)
|
|
39
|
+
if (!opts.refresh && cached !== null && cached.fingerprint === fingerprint) {
|
|
40
|
+
return { sharedCanvas: cached.sharedCanvas, imported: false, warnings: [] }
|
|
41
|
+
}
|
|
42
|
+
const outcome = await discoverSharedCanvas(ctx, pr, comments)
|
|
43
|
+
await ctx.prs.writeDiscovery(pr.number, {
|
|
44
|
+
fingerprint,
|
|
45
|
+
checkedAt: ctx.now().toISOString(),
|
|
46
|
+
sharedCanvas: outcome.sharedCanvas,
|
|
47
|
+
})
|
|
48
|
+
return { sharedCanvas: outcome.sharedCanvas, imported: outcome.imported !== null, warnings: outcome.warnings }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Live PR meta and comments. The first request for a PR in this process always asks GitHub;
|
|
53
|
+
* later ones answer from `prs/<n>/` unless `refresh` is set, so polling stays cheap.
|
|
54
|
+
*/
|
|
55
|
+
export function createPrLoader(ctx: AppContext) {
|
|
56
|
+
const refreshed = new Set<number>()
|
|
57
|
+
|
|
58
|
+
async function refreshPr(number: number): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
|
|
59
|
+
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
|
|
60
|
+
const shas = await fetchPrRefs(ctx.git, meta)
|
|
61
|
+
const pr = toPr(meta, ctx.config.repo, shas)
|
|
62
|
+
await ctx.prs.writePr(pr)
|
|
63
|
+
const { payload, warnings } = await fetchComments(ctx.gh, ctx.config.repo, number, shas.headSha, ctx.now)
|
|
64
|
+
await ctx.prs.writeComments(number, payload)
|
|
65
|
+
refreshed.add(number)
|
|
66
|
+
return { pr, comments: payload, warnings }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async load(
|
|
71
|
+
number: number,
|
|
72
|
+
opts: BundleOptions
|
|
73
|
+
): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
|
|
74
|
+
if (!opts.refresh && refreshed.has(number)) {
|
|
75
|
+
const [pr, comments] = await Promise.all([ctx.prs.readPr(number), ctx.prs.readComments(number)])
|
|
76
|
+
if (pr !== null && comments !== null) {
|
|
77
|
+
return { pr, comments, warnings: [] }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return refreshPr(number)
|
|
81
|
+
},
|
|
82
|
+
async refreshComments(number: number): Promise<{ comments: CommentsPayload; warnings: string[] }> {
|
|
83
|
+
const pr = (await ctx.prs.readPr(number)) ?? (await refreshPr(number)).pr
|
|
84
|
+
const { payload, warnings } = await fetchComments(ctx.gh, ctx.config.repo, number, pr.headSha, ctx.now)
|
|
85
|
+
await ctx.prs.writeComments(number, payload)
|
|
86
|
+
return { comments: payload, warnings }
|
|
87
|
+
},
|
|
88
|
+
async currentPr(number: number): Promise<Pr> {
|
|
89
|
+
return (await ctx.prs.readPr(number)) ?? (await refreshPr(number)).pr
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export type PrLoader = ReturnType<typeof createPrLoader>
|
|
94
|
+
|
|
95
|
+
/** Whether the change set on screen is large, counted from the files the page is showing. */
|
|
96
|
+
function largePrOf(files: readonly FileEntry[]): boolean {
|
|
97
|
+
return isLargePr({
|
|
98
|
+
files: files.length,
|
|
99
|
+
additions: files.reduce((n, f) => n + f.additions, 0),
|
|
100
|
+
deletions: files.reduce((n, f) => n + f.deletions, 0),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Dev fixture: the committed artifact presented as the canvas of the live PR head. */
|
|
105
|
+
export function rekeyFixture(fixture: ReviewArtifact, pr: Pr): ReviewArtifact {
|
|
106
|
+
return { ...fixture, pr, source: 'local' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The stored canvas and the block that describes it. Null when review.json was written by an
|
|
111
|
+
* older tool version: the page then offers to generate a new one.
|
|
112
|
+
*/
|
|
113
|
+
async function loadCanvas(
|
|
114
|
+
ctx: AppContext,
|
|
115
|
+
found: Extract<CanvasLookup, { headSha: string }>
|
|
116
|
+
): Promise<{ artifact: ReviewArtifact; canvas: CanvasInfo } | null> {
|
|
117
|
+
let artifact: ReviewArtifact | null
|
|
118
|
+
try {
|
|
119
|
+
artifact = await ctx.canvases.readArtifact(found.headSha)
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
if (artifact === null) {
|
|
124
|
+
throw new AppError('CANVAS_INVALID', `index lists ${found.headSha} but review.json is missing`, 500)
|
|
125
|
+
}
|
|
126
|
+
const canvas: CanvasInfo = {
|
|
127
|
+
headSha: found.headSha,
|
|
128
|
+
source: artifact.source,
|
|
129
|
+
manifest: await ctx.canvases.readManifest(found.headSha),
|
|
130
|
+
}
|
|
131
|
+
if (artifact.importedAt !== undefined) {
|
|
132
|
+
canvas.importedAt = artifact.importedAt
|
|
133
|
+
}
|
|
134
|
+
return { artifact, canvas }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The diffs of one canvas: the stored ones, or freshly built when both commits are local. */
|
|
138
|
+
async function readOrBuildDerived(
|
|
139
|
+
ctx: AppContext,
|
|
140
|
+
headSha: string,
|
|
141
|
+
mergeBaseSha: string | undefined
|
|
142
|
+
): Promise<{ files: FileEntry[] } | null> {
|
|
143
|
+
const stored = await ctx.derived.read(headSha)
|
|
144
|
+
if (stored !== null || mergeBaseSha === undefined) {
|
|
145
|
+
return stored
|
|
146
|
+
}
|
|
147
|
+
if (!(await ctx.derived.derivable(headSha, mergeBaseSha))) {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
return ctx.derived.ensure(headSha, mergeBaseSha)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function resolveBundle(
|
|
154
|
+
ctx: AppContext,
|
|
155
|
+
loader: PrLoader,
|
|
156
|
+
number: number,
|
|
157
|
+
opts: BundleOptions
|
|
158
|
+
): Promise<PrBundle> {
|
|
159
|
+
const { pr, comments, warnings } = await loader.load(number, opts)
|
|
160
|
+
const allWarnings = [...ctx.projectConfig.warnings, ...warnings]
|
|
161
|
+
|
|
162
|
+
const derivable = await ctx.derived.derivable(pr.headSha, pr.mergeBaseSha)
|
|
163
|
+
let files: FileEntry[] = []
|
|
164
|
+
if (derivable) {
|
|
165
|
+
files = (await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)).files
|
|
166
|
+
} else {
|
|
167
|
+
allWarnings.push('the PR head or merge base is not in the local clone; diffs are not available')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const chatEnabled = ctx.projectConfig.config.chat.enabled
|
|
171
|
+
const [stored, capabilities, settings, acpx] = await Promise.all([
|
|
172
|
+
ctx.state.read(number),
|
|
173
|
+
ctx.capabilities.get(),
|
|
174
|
+
chatEnabled ? ctx.chat.effectiveSettings() : Promise.resolve(null),
|
|
175
|
+
chatEnabled ? ctx.preflight.get() : Promise.resolve({ installed: false, version: null }),
|
|
176
|
+
])
|
|
177
|
+
if (chatEnabled && !acpx.installed) {
|
|
178
|
+
allWarnings.push('acpx is not on PATH, so the AI Chat pane is off; install acpx to turn it on')
|
|
179
|
+
}
|
|
180
|
+
// Marks made on another commit describe other code, so the page never shows them as reviewed.
|
|
181
|
+
const state = stateForHead(stored, pr.headSha)
|
|
182
|
+
const base = {
|
|
183
|
+
pr,
|
|
184
|
+
files,
|
|
185
|
+
derivable,
|
|
186
|
+
comments,
|
|
187
|
+
state,
|
|
188
|
+
capabilities,
|
|
189
|
+
chat: {
|
|
190
|
+
enabled: chatEnabled && acpx.installed,
|
|
191
|
+
acpx: acpx.installed,
|
|
192
|
+
...(settings === null ? {} : { agent: settings.agent, model: settings.model }),
|
|
193
|
+
},
|
|
194
|
+
largePr: largePrOf(files),
|
|
195
|
+
warnings: allWarnings,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (ctx.fixtureArtifact !== null) {
|
|
199
|
+
const artifact = rekeyFixture(ctx.fixtureArtifact, pr)
|
|
200
|
+
const canvas: CanvasInfo = { headSha: pr.headSha, source: 'fixture', manifest: null }
|
|
201
|
+
allWarnings.push('showing the --fixture-canvas artifact (dev only)')
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
status: 'ready',
|
|
205
|
+
artifact,
|
|
206
|
+
canvas,
|
|
207
|
+
files: files.length > 0 ? files : artifact.files,
|
|
208
|
+
largePr: largePrOf(files.length > 0 ? files : artifact.files),
|
|
209
|
+
skillCommand: buildSkillCommand(number, { force: true }),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let found = await ctx.canvases.findForPr(number, pr.headSha)
|
|
214
|
+
let sharedCanvas: SharedCanvasInfo | null = null
|
|
215
|
+
// A canvas for this very head beats anything attached to the PR, so discovery runs only
|
|
216
|
+
// when there is none, and its import can turn a stale or missing bundle into a ready one.
|
|
217
|
+
if (found.status !== 'ready') {
|
|
218
|
+
const discovery = await runDiscovery(ctx, pr, comments, { refresh: opts.refresh })
|
|
219
|
+
sharedCanvas = discovery.sharedCanvas
|
|
220
|
+
allWarnings.push(...discovery.warnings)
|
|
221
|
+
if (discovery.imported) {
|
|
222
|
+
found = await ctx.canvases.findForPr(number, pr.headSha)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const shared = sharedCanvas === null ? {} : { sharedCanvas }
|
|
226
|
+
|
|
227
|
+
if (found.status === 'missing') {
|
|
228
|
+
return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: false }) }
|
|
229
|
+
}
|
|
230
|
+
const loaded = await loadCanvas(ctx, found)
|
|
231
|
+
if (loaded === null) {
|
|
232
|
+
allWarnings.push(`the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`)
|
|
233
|
+
return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: true }) }
|
|
234
|
+
}
|
|
235
|
+
// A canvas exists for this PR, so regenerating always needs --force.
|
|
236
|
+
const skillCommand = buildSkillCommand(number, { force: true })
|
|
237
|
+
if (found.status === 'ready') {
|
|
238
|
+
return { ...base, ...shared, ...loaded, status: 'ready', skillCommand }
|
|
239
|
+
}
|
|
240
|
+
const stale: StaleInfo = {
|
|
241
|
+
canvasHeadSha: found.headSha,
|
|
242
|
+
currentHeadSha: pr.headSha,
|
|
243
|
+
relation: found.relation,
|
|
244
|
+
}
|
|
245
|
+
if (found.commitsBehind !== undefined) {
|
|
246
|
+
stale.commitsBehind = found.commitsBehind
|
|
247
|
+
}
|
|
248
|
+
// The page shows the canvas of the older commit, so the files and the diffs are that commit's.
|
|
249
|
+
// They are rebuilt when the clone has the commits, which is how a canvas imported before the
|
|
250
|
+
// fetch becomes readable once the commits arrive.
|
|
251
|
+
const staleDerived = await readOrBuildDerived(ctx, found.headSha, loaded.canvas.manifest?.mergeBaseSha)
|
|
252
|
+
// A stale canvas describes an older commit, so its own files decide both the diffs and
|
|
253
|
+
// whether the notice about large change sets belongs on the page.
|
|
254
|
+
const staleFiles = staleDerived?.files ?? loaded.artifact.files
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
...shared,
|
|
258
|
+
...loaded,
|
|
259
|
+
files: staleFiles,
|
|
260
|
+
largePr: largePrOf(staleFiles),
|
|
261
|
+
derivable: staleDerived !== null,
|
|
262
|
+
status: 'stale',
|
|
263
|
+
stale,
|
|
264
|
+
skillCommand,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Reading a request or response body under a size limit. Both the attachment download and the
|
|
2
|
+
// canvas upload go through this, so a body that never ends, or one that lies about its length,
|
|
3
|
+
// stops at the limit instead of growing without bound.
|
|
4
|
+
|
|
5
|
+
/** The bytes of the body. Throws BodyTooLargeError as soon as it grows past `cap`. */
|
|
6
|
+
export async function readCappedStream(
|
|
7
|
+
body: ReadableStream<Uint8Array> | null,
|
|
8
|
+
cap: number
|
|
9
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
10
|
+
if (body === null) {
|
|
11
|
+
return new Uint8Array(0)
|
|
12
|
+
}
|
|
13
|
+
const reader = body.getReader()
|
|
14
|
+
const chunks: Uint8Array[] = []
|
|
15
|
+
let total = 0
|
|
16
|
+
try {
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { done, value } = await reader.read()
|
|
19
|
+
if (done) {
|
|
20
|
+
break
|
|
21
|
+
}
|
|
22
|
+
total += value.length
|
|
23
|
+
if (total > cap) {
|
|
24
|
+
throw new BodyTooLargeError(cap)
|
|
25
|
+
}
|
|
26
|
+
chunks.push(value)
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
await reader.cancel().catch(() => undefined)
|
|
30
|
+
}
|
|
31
|
+
const out = new Uint8Array(total)
|
|
32
|
+
let at = 0
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
out.set(chunk, at)
|
|
35
|
+
at += chunk.length
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class BodyTooLargeError extends Error {
|
|
41
|
+
readonly cap: number
|
|
42
|
+
constructor(cap: number) {
|
|
43
|
+
super(`the body is larger than ${cap} bytes`)
|
|
44
|
+
this.name = 'BodyTooLargeError'
|
|
45
|
+
this.cap = cap
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The body of one HTTP message under a cap. A `Content-Length` above the cap is refused before a
|
|
51
|
+
* byte is read; a body that lies about its length is stopped mid-stream.
|
|
52
|
+
*/
|
|
53
|
+
export async function readCappedBody(
|
|
54
|
+
message: { headers: Headers; body: ReadableStream<Uint8Array> | null },
|
|
55
|
+
cap: number
|
|
56
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
57
|
+
const declared = Number(message.headers.get('content-length') ?? '')
|
|
58
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
59
|
+
throw new BodyTooLargeError(cap)
|
|
60
|
+
}
|
|
61
|
+
return readCappedStream(message.body, cap)
|
|
62
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { type AgentRunner, createAgentRunner } from '../acpx/acpx.js'
|
|
5
|
+
import { type AgentDirectory, createAgentDirectory } from '../acpx/agents.js'
|
|
6
|
+
import { createPreflightProbe, type PreflightProbe } from '../acpx/preflight.js'
|
|
7
|
+
import { type ChatManager, createChatManager } from '../chat/chat-manager.js'
|
|
8
|
+
import type { PromptOverrides } from '../project-config.js'
|
|
9
|
+
import { loadSeedTemplate } from '../chat/seed.js'
|
|
10
|
+
import { createTranscriptStore, type TranscriptStore } from '../chat/threads.js'
|
|
11
|
+
import type { RuntimeConfig } from '../config.js'
|
|
12
|
+
import type { ReviewArtifact } from '../contract/review-artifact.js'
|
|
13
|
+
import { createGit, type Git } from '../git/git.js'
|
|
14
|
+
import { type CapabilityProbe, createCapabilityProbe } from '../github/capabilities.js'
|
|
15
|
+
import { createGitHubClient, type GitHubClient } from '../github/gh.js'
|
|
16
|
+
import { PACKAGE_ROOT, STATIC_DIR } from '../paths.js'
|
|
17
|
+
import type { LoadedProjectConfig } from '../project-config.js'
|
|
18
|
+
import { type CanvasStore, createCanvasStore } from '../store/canvas-store.js'
|
|
19
|
+
import { repoDir } from '../store/data-dir.js'
|
|
20
|
+
import { createDerivedStore, type DerivedStore } from '../store/derived-store.js'
|
|
21
|
+
import { createPrStore, type PrStore } from '../store/pr-store.js'
|
|
22
|
+
import { createSettingsStore, type SettingsStore } from '../store/settings-store.js'
|
|
23
|
+
import { createStateStore, type StateStore } from '../store/state-store.js'
|
|
24
|
+
|
|
25
|
+
/** Directory roots the `/vendor/*` route may serve from. Exact files are listed in routes/static.ts. */
|
|
26
|
+
export interface VendorRoots {
|
|
27
|
+
diff: string
|
|
28
|
+
marked: string
|
|
29
|
+
dompurify: string
|
|
30
|
+
hljs: string
|
|
31
|
+
mermaid: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Everything a route or pipeline needs, as a plain object. Real adapters are built once by
|
|
36
|
+
* `createAppContext`; tests assemble their own with fakes and a temp data dir.
|
|
37
|
+
*/
|
|
38
|
+
export interface AppContext {
|
|
39
|
+
config: RuntimeConfig
|
|
40
|
+
projectConfig: LoadedProjectConfig
|
|
41
|
+
git: Git
|
|
42
|
+
gh: GitHubClient
|
|
43
|
+
/** The only outbound HTTP the tool makes: attachment downloads. Tests inject a fake. */
|
|
44
|
+
fetch: typeof fetch
|
|
45
|
+
canvases: CanvasStore
|
|
46
|
+
derived: DerivedStore
|
|
47
|
+
prs: PrStore
|
|
48
|
+
state: StateStore
|
|
49
|
+
/** What this GitHub login may post here, probed once and reused for ten minutes. */
|
|
50
|
+
capabilities: CapabilityProbe
|
|
51
|
+
/** Personal chat settings, in `.pr-review/settings.yml`. */
|
|
52
|
+
settings: SettingsStore
|
|
53
|
+
/** Which chat agents can run right now, and the one-shot probe behind the settings dialog. */
|
|
54
|
+
agents: AgentDirectory
|
|
55
|
+
/** Is acpx itself installed? A missing acpx disables the chat pane with a banner. */
|
|
56
|
+
preflight: PreflightProbe
|
|
57
|
+
chat: ChatManager
|
|
58
|
+
transcripts: TranscriptStore
|
|
59
|
+
now: () => Date
|
|
60
|
+
version: string
|
|
61
|
+
staticDir: string
|
|
62
|
+
vendorRoots: VendorRoots
|
|
63
|
+
fixtureArtifact: ReviewArtifact | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { PACKAGE_ROOT, STATIC_DIR }
|
|
67
|
+
|
|
68
|
+
const require = createRequire(import.meta.url)
|
|
69
|
+
|
|
70
|
+
export function resolveVendorRoots(): VendorRoots {
|
|
71
|
+
return {
|
|
72
|
+
diff: path.dirname(fileURLToPath(import.meta.resolve('diff'))),
|
|
73
|
+
marked: fileURLToPath(import.meta.resolve('marked')),
|
|
74
|
+
dompurify: fileURLToPath(import.meta.resolve('dompurify')),
|
|
75
|
+
hljs: path.join(path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')), 'es', 'highlight.min.js'),
|
|
76
|
+
mermaid: path.dirname(fileURLToPath(import.meta.resolve('mermaid'))),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readPackageVersion(): string {
|
|
81
|
+
const pkg = require(path.join(PACKAGE_ROOT, 'package.json')) as { version: string }
|
|
82
|
+
return pkg.version
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StoreSet {
|
|
86
|
+
canvases: CanvasStore
|
|
87
|
+
derived: DerivedStore
|
|
88
|
+
prs: PrStore
|
|
89
|
+
state: StateStore
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The four stores over one repo directory. Shared by the real context and by tests. */
|
|
93
|
+
export function createStores(dataDir: string, config: RuntimeConfig, git: Git, now: () => Date): StoreSet {
|
|
94
|
+
const root = repoDir(dataDir, config.repo)
|
|
95
|
+
const canvases = createCanvasStore(root, git)
|
|
96
|
+
const prs = createPrStore(root)
|
|
97
|
+
return {
|
|
98
|
+
canvases,
|
|
99
|
+
derived: createDerivedStore(canvases, git, now),
|
|
100
|
+
prs,
|
|
101
|
+
state: createStateStore(prs, now),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CreateAppContextOptions {
|
|
106
|
+
config: RuntimeConfig
|
|
107
|
+
projectConfig: LoadedProjectConfig
|
|
108
|
+
fixtureArtifact: ReviewArtifact | null
|
|
109
|
+
git?: Git
|
|
110
|
+
gh?: GitHubClient
|
|
111
|
+
fetch?: typeof fetch
|
|
112
|
+
runner?: AgentRunner
|
|
113
|
+
now?: () => Date
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ChatSet {
|
|
117
|
+
settings: SettingsStore
|
|
118
|
+
agents: AgentDirectory
|
|
119
|
+
preflight: PreflightProbe
|
|
120
|
+
chat: ChatManager
|
|
121
|
+
transcripts: TranscriptStore
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The chat side of the context, built over the same stores. Shared by the real context and tests. */
|
|
125
|
+
export function createChatSet(
|
|
126
|
+
config: RuntimeConfig,
|
|
127
|
+
runner: AgentRunner,
|
|
128
|
+
stores: StoreSet,
|
|
129
|
+
now: () => Date,
|
|
130
|
+
prompts?: PromptOverrides
|
|
131
|
+
): ChatSet {
|
|
132
|
+
const settings = createSettingsStore(config.dataDir)
|
|
133
|
+
const preflight = createPreflightProbe(runner, now)
|
|
134
|
+
const transcripts = createTranscriptStore(number => stores.prs.prDir(number))
|
|
135
|
+
return {
|
|
136
|
+
settings,
|
|
137
|
+
preflight,
|
|
138
|
+
transcripts,
|
|
139
|
+
agents: createAgentDirectory({ runner, preflight, cwd: config.repoRoot, now }),
|
|
140
|
+
chat: createChatManager({
|
|
141
|
+
runner,
|
|
142
|
+
settings,
|
|
143
|
+
state: stores.state,
|
|
144
|
+
transcripts,
|
|
145
|
+
repo: config.repo,
|
|
146
|
+
repoRoot: config.repoRoot,
|
|
147
|
+
overrides: config.chatOverrides,
|
|
148
|
+
loadSeedTemplate: () => loadSeedTemplate(undefined, { repoRoot: config.repoRoot, overrides: prompts }),
|
|
149
|
+
now,
|
|
150
|
+
}),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createAppContext(opts: CreateAppContextOptions): AppContext {
|
|
155
|
+
const git = opts.git ?? createGit(opts.config.repoRoot)
|
|
156
|
+
const now = opts.now ?? (() => new Date())
|
|
157
|
+
const gh = opts.gh ?? createGitHubClient()
|
|
158
|
+
const stores = createStores(opts.config.dataDir, opts.config, git, now)
|
|
159
|
+
return {
|
|
160
|
+
config: opts.config,
|
|
161
|
+
projectConfig: opts.projectConfig,
|
|
162
|
+
git,
|
|
163
|
+
gh,
|
|
164
|
+
capabilities: createCapabilityProbe(gh, opts.config.repo, now),
|
|
165
|
+
fetch: opts.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
166
|
+
...stores,
|
|
167
|
+
...createChatSet(opts.config, opts.runner ?? createAgentRunner(), stores, now, opts.projectConfig.config.prompts),
|
|
168
|
+
now,
|
|
169
|
+
version: readPackageVersion(),
|
|
170
|
+
staticDir: STATIC_DIR,
|
|
171
|
+
vendorRoots: resolveVendorRoots(),
|
|
172
|
+
fixtureArtifact: opts.fixtureArtifact,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
|
2
|
+
import { ConfigError } from '../config.js'
|
|
3
|
+
import type { ErrorCode, ErrorEnvelope } from '../contract/api.js'
|
|
4
|
+
import { GitError } from '../git/git.js'
|
|
5
|
+
import { GitHubApiError } from '../github/gh.js'
|
|
6
|
+
import { PrNotFoundError } from '../github/pr.js'
|
|
7
|
+
|
|
8
|
+
export class AppError extends Error {
|
|
9
|
+
readonly code: ErrorCode
|
|
10
|
+
readonly status: ContentfulStatusCode
|
|
11
|
+
readonly hint: string | undefined
|
|
12
|
+
/** What a rejected file got wrong, one line each. Never the file's content. */
|
|
13
|
+
readonly issues: string[] | undefined
|
|
14
|
+
|
|
15
|
+
constructor(code: ErrorCode, message: string, status: ContentfulStatusCode, hint?: string, issues?: string[]) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'AppError'
|
|
18
|
+
this.code = code
|
|
19
|
+
this.status = status
|
|
20
|
+
this.hint = hint
|
|
21
|
+
this.issues = issues
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toEnvelope(): ErrorEnvelope {
|
|
25
|
+
const error: ErrorEnvelope['error'] = { code: this.code, message: this.message }
|
|
26
|
+
if (this.hint !== undefined) {
|
|
27
|
+
error.hint = this.hint
|
|
28
|
+
}
|
|
29
|
+
if (this.issues !== undefined) {
|
|
30
|
+
error.issues = this.issues
|
|
31
|
+
}
|
|
32
|
+
return { error }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Maps every error the routes can see to one AppError, so the envelope is built in one place. */
|
|
37
|
+
export function toAppError(err: unknown): AppError {
|
|
38
|
+
if (err instanceof AppError) {
|
|
39
|
+
return err
|
|
40
|
+
}
|
|
41
|
+
if (err instanceof PrNotFoundError) {
|
|
42
|
+
return new AppError('PR_NOT_FOUND', err.message, 404, 'check the number and that origin is the right repository')
|
|
43
|
+
}
|
|
44
|
+
if (err instanceof GitHubApiError) {
|
|
45
|
+
if (err.missingBinary) {
|
|
46
|
+
return new AppError(
|
|
47
|
+
'GH_MISSING',
|
|
48
|
+
'the GitHub CLI (gh) is not installed',
|
|
49
|
+
500,
|
|
50
|
+
'install it from https://cli.github.com'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
if (err.unauthenticated) {
|
|
54
|
+
return new AppError('GH_UNAUTHENTICATED', 'gh is not logged in', 401, 'run `gh auth login`')
|
|
55
|
+
}
|
|
56
|
+
return new AppError('GITHUB_API_ERROR', err.message, 502)
|
|
57
|
+
}
|
|
58
|
+
if (err instanceof GitError) {
|
|
59
|
+
return new AppError('GIT_ERROR', err.message, 500)
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof ConfigError) {
|
|
62
|
+
return new AppError(err.code, err.message, 500, err.hint)
|
|
63
|
+
}
|
|
64
|
+
return new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
|
|
65
|
+
}
|