@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,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,7 @@
1
+ /** What the middleware puts on the request context. Hono types every route from this. */
2
+ export interface AppEnv {
3
+ Variables: {
4
+ /** The nonce of this response's inline scripts, set by `responseHeaders`. */
5
+ cspNonce: string
6
+ }
7
+ }
@@ -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
+ }