@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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 (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -1,11 +1,22 @@
1
- import type { CanvasInfo, PrBundle, SharedCanvasInfo, StaleInfo } from '../contract/api.js'
1
+ import type {
2
+ BundleStatus,
3
+ CanvasInfo,
4
+ Capabilities,
5
+ CarriedOverInfo,
6
+ PrBundle,
7
+ SharedCanvasInfo,
8
+ StaleInfo,
9
+ } from '../contract/api.js'
2
10
  import type { CommentsPayload } from '../contract/comments.js'
3
- import { isLargePr } from '../contract/generation-context.js'
11
+ import { isLargePr, type LocalPrepareTarget } from '../contract/generation-context.js'
4
12
  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'
13
+ import { isLocalKey, type LocalKey, type ReviewKey } from '../contract/review-key.js'
14
+ import { describeLocalWork, resolveLocalBase } from '../git/local-target.js'
15
+ import { fetchPrRefs } from '../git/pr-refs.js'
16
+ import { toPr } from '../host/pr.js'
17
+ import { lookupCanvas } from '../review/carry-over.js'
18
+ import { reviewedCommit, stateForCanvas } from '../review/review-body.js'
19
+ import { discoverSharedCanvas, discoveryFingerprint } from '../host/attachments.js'
9
20
  import { buildSkillCommand } from '../review/skill-command.js'
10
21
  import type { CanvasLookup } from '../store/canvas-store.js'
11
22
  import type { AppContext } from './context.js'
@@ -13,6 +24,13 @@ import { AppError } from './errors.js'
13
24
 
14
25
  export interface BundleOptions {
15
26
  refresh: boolean
27
+ /**
28
+ * The background poller asked, rather than a reader opening or refreshing the page. A local
29
+ * review answers a poll from the head it last resolved: snapshotting the working tree every few
30
+ * seconds would stat the whole tree again and again, and every edit in between would leave
31
+ * another commit and another `derived/` tree behind it.
32
+ */
33
+ poll?: boolean
16
34
  }
17
35
 
18
36
  export interface DiscoveryRun {
@@ -45,7 +63,11 @@ export async function runDiscovery(
45
63
  checkedAt: ctx.now().toISOString(),
46
64
  sharedCanvas: outcome.sharedCanvas,
47
65
  })
48
- return { sharedCanvas: outcome.sharedCanvas, imported: outcome.imported !== null, warnings: outcome.warnings }
66
+ return {
67
+ sharedCanvas: outcome.sharedCanvas,
68
+ imported: outcome.imported !== null,
69
+ warnings: outcome.warnings,
70
+ }
49
71
  }
50
72
 
51
73
  /**
@@ -54,13 +76,37 @@ export async function runDiscovery(
54
76
  */
55
77
  export function createPrLoader(ctx: AppContext) {
56
78
  const refreshed = new Set<number>()
79
+ /**
80
+ * The head each local review was last resolved at in this process, with the head `prepare` had
81
+ * on file at the time. A poll answers from it, so it can never report a status the page's own
82
+ * load contradicts; a later `prepare` files another head, and that is the one answer a waiting
83
+ * page is polling for, so it resolves again.
84
+ */
85
+ const localHeads = new Map<LocalKey, { head: Pr; preparedSha: string | null }>()
86
+
87
+ /** One local review's cached meta, which `prepare` wrote. */
88
+ async function localPr(key: LocalKey): Promise<Pr> {
89
+ const stored = await ctx.prs.readPr(key)
90
+ if (stored === null) {
91
+ throw new AppError(
92
+ 'CANVAS_NOT_FOUND',
93
+ `no ${key} review has been prepared in this repository yet`,
94
+ 404,
95
+ `run /pr-review-canvas ${key} to generate one`
96
+ )
97
+ }
98
+ return stored
99
+ }
57
100
 
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)
101
+ async function refreshPr(
102
+ number: number
103
+ ): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
104
+ const { host, repo } = ctx.config
105
+ const meta = await host.fetchPrMeta(ctx.gh, repo, number)
106
+ const shas = await fetchPrRefs(ctx.git, host, meta)
107
+ const pr = toPr(meta, repo, shas)
108
+ await ctx.prs.writePr(number, pr)
109
+ const { payload, warnings } = await host.fetchComments(ctx.gh, repo, number, shas.headSha, ctx.now)
64
110
  await ctx.prs.writeComments(number, payload)
65
111
  refreshed.add(number)
66
112
  return { pr, comments: payload, warnings }
@@ -81,24 +127,56 @@ export function createPrLoader(ctx: AppContext) {
81
127
  },
82
128
  async refreshComments(number: number): Promise<{ comments: CommentsPayload; warnings: string[] }> {
83
129
  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)
130
+ const { host, repo } = ctx.config
131
+ const { payload, warnings } = await host.fetchComments(ctx.gh, repo, number, pr.headSha, ctx.now)
85
132
  await ctx.prs.writeComments(number, payload)
86
133
  return { comments: payload, warnings }
87
134
  },
88
135
  async currentPr(number: number): Promise<Pr> {
89
136
  return (await ctx.prs.readPr(number)) ?? (await refreshPr(number)).pr
90
137
  },
138
+ /**
139
+ * The head a local review describes. Every request but a poll reads the work again, so opening
140
+ * or refreshing the page catches an edit made since the canvas was generated. A poll reads it
141
+ * again only when this process has not resolved a head yet, or when `prepare` has filed one
142
+ * since: the rest of the time it answers about the head the page is showing, at no cost.
143
+ */
144
+ async localHead(key: LocalKey, opts: BundleOptions): Promise<Pr> {
145
+ const stored = await ctx.prs.readPr(key)
146
+ const preparedSha = stored?.headSha ?? null
147
+ const seen = localHeads.get(key)
148
+ if (opts.poll === true && seen !== undefined && seen.preparedSha === preparedSha) {
149
+ return seen.head
150
+ }
151
+ const head = await currentLocalPr(ctx, key, stored)
152
+ localHeads.set(key, { head, preparedSha })
153
+ return head
154
+ },
155
+ /** The head of either kind of target, so the routes both kinds serve need no branch. */
156
+ async currentTarget(key: ReviewKey): Promise<Pr> {
157
+ return isLocalKey(key) ? localPr(key) : ((await ctx.prs.readPr(key)) ?? (await refreshPr(key)).pr)
158
+ },
91
159
  }
92
160
  }
93
161
  export type PrLoader = ReturnType<typeof createPrLoader>
94
162
 
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,
163
+ /** The diffstat of the files the page is showing. */
164
+ function countDiff(files: readonly FileEntry[]): {
165
+ additions: number
166
+ deletions: number
167
+ changedFiles: number
168
+ } {
169
+ return {
99
170
  additions: files.reduce((n, f) => n + f.additions, 0),
100
171
  deletions: files.reduce((n, f) => n + f.deletions, 0),
101
- })
172
+ changedFiles: files.length,
173
+ }
174
+ }
175
+
176
+ /** Whether the change set on screen is large, counted from the files the page is showing. */
177
+ function largePrOf(files: readonly FileEntry[]): boolean {
178
+ const counts = countDiff(files)
179
+ return isLargePr({ files: counts.changedFiles, ...counts })
102
180
  }
103
181
 
104
182
  /** Dev fixture: the committed artifact presented as the canvas of the live PR head. */
@@ -110,10 +188,15 @@ export function rekeyFixture(fixture: ReviewArtifact, pr: Pr): ReviewArtifact {
110
188
  * The stored canvas and the block that describes it. Null when review.json was written by an
111
189
  * older tool version: the page then offers to generate a new one.
112
190
  */
191
+ export interface LoadedCanvas {
192
+ artifact: ReviewArtifact
193
+ canvas: CanvasInfo
194
+ }
195
+
113
196
  async function loadCanvas(
114
197
  ctx: AppContext,
115
198
  found: Extract<CanvasLookup, { headSha: string }>
116
- ): Promise<{ artifact: ReviewArtifact; canvas: CanvasInfo } | null> {
199
+ ): Promise<LoadedCanvas | null> {
117
200
  let artifact: ReviewArtifact | null
118
201
  try {
119
202
  artifact = await ctx.canvases.readArtifact(found.headSha)
@@ -134,133 +217,264 @@ async function loadCanvas(
134
217
  return { artifact, canvas }
135
218
  }
136
219
 
137
- /** The diffs of one canvas: the stored ones, or freshly built when both commits are local. */
138
- async function readOrBuildDerived(
220
+ /** The shape of an empty comment payload: a local review has no forge thread to read. */
221
+ function noComments(headSha: string, now: () => Date): CommentsPayload {
222
+ return { fetchedAt: now().toISOString(), headSha, reviewComments: [], issueComments: [], reviews: [] }
223
+ }
224
+
225
+ /** Posting is a forge operation, and local work is on no forge. The page hides every post button. */
226
+ export const LOCAL_CAPABILITIES: Capabilities = {
227
+ canComment: false,
228
+ tokenKind: 'none',
229
+ login: null,
230
+ reason: 'this work is not on a pull request yet, so there is nothing to comment on',
231
+ hint: 'open the pull request, then review it at /review/<number>',
232
+ }
233
+
234
+ /** Which diffs the page shows, and what to say when their commits are not in the clone. */
235
+ interface ShownDiff {
236
+ files: FileEntry[]
237
+ derivable: boolean
238
+ warning: string | null
239
+ }
240
+
241
+ /**
242
+ * The diffs of the canvas on screen, which is the one `found` names rather than the target's
243
+ * current head: a stale canvas describes an older commit, and its own files are what the reader
244
+ * is looking at.
245
+ */
246
+ async function shownDiff(
139
247
  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
248
+ pr: Pr,
249
+ found: CanvasLookup,
250
+ loaded: LoadedCanvas | null
251
+ ): Promise<ShownDiff> {
252
+ const mergeBaseSha = loaded?.canvas.manifest?.mergeBaseSha ?? pr.mergeBaseSha
253
+ const derived = await ctx.derived.readOrBuild(reviewedCommit(found, pr), mergeBaseSha)
254
+ if (derived !== null) {
255
+ return { files: derived.files, derivable: true, warning: null }
146
256
  }
147
- if (!(await ctx.derived.derivable(headSha, mergeBaseSha))) {
148
- return null
257
+ return {
258
+ files: loaded?.artifact.files ?? [],
259
+ derivable: false,
260
+ warning:
261
+ loaded === null
262
+ ? 'the head or merge base is not in the local clone; diffs are not available'
263
+ : 'the commits behind this canvas are not in the clone; diffs are not available',
149
264
  }
150
- return ctx.derived.ensure(headSha, mergeBaseSha)
151
265
  }
152
266
 
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')
267
+ /**
268
+ * The screen a canvas lookup produces: its status, the notice a stale one carries, and the command
269
+ * that regenerates it. Both targets go through this, so neither can drift from the other.
270
+ */
271
+ function canvasScreen(
272
+ key: ReviewKey,
273
+ pr: Pr,
274
+ found: CanvasLookup,
275
+ loaded: LoadedCanvas | null
276
+ ): {
277
+ status: BundleStatus
278
+ skillCommand: string
279
+ stale?: StaleInfo
280
+ carriedOver?: CarriedOverInfo
281
+ warning?: string
282
+ } {
283
+ if (found.status === 'missing') {
284
+ return { status: 'missing', skillCommand: buildSkillCommand(key, { force: false }) }
285
+ }
286
+ // A canvas exists for this target, so regenerating always needs --force.
287
+ const skillCommand = buildSkillCommand(key, { force: true })
288
+ if (loaded === null) {
289
+ return {
290
+ status: 'missing',
291
+ skillCommand,
292
+ warning: `the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`,
293
+ }
294
+ }
295
+ if (found.status === 'ready') {
296
+ return found.carriedOver === undefined
297
+ ? { status: 'ready', skillCommand }
298
+ : { status: 'ready', skillCommand, carriedOver: found.carriedOver }
299
+ }
300
+ const stale: StaleInfo = {
301
+ canvasHeadSha: found.headSha,
302
+ currentHeadSha: pr.headSha,
303
+ relation: found.relation,
168
304
  }
305
+ if (found.relation === 'ancestor') {
306
+ stale.commitsBehind = found.commitsBehind
307
+ }
308
+ return { status: 'stale', skillCommand, stale }
309
+ }
169
310
 
311
+ /** Everything a bundle holds that does not depend on which canvas was found. */
312
+ async function bundleBase(
313
+ ctx: AppContext,
314
+ key: ReviewKey,
315
+ input: {
316
+ pr: Pr
317
+ comments: CommentsPayload
318
+ /** A promise, so probing the forge runs alongside the chat and state reads. */
319
+ capabilities: Promise<Capabilities>
320
+ diff: ShownDiff
321
+ /** The commit of the canvas on screen: the marks the page shows are the ones made on it. */
322
+ canvasSha: string
323
+ warnings: string[]
324
+ }
325
+ ): Promise<Omit<PrBundle, 'status' | 'skillCommand'>> {
170
326
  const chatEnabled = ctx.projectConfig.config.chat.enabled
171
327
  const [stored, capabilities, settings, acpx] = await Promise.all([
172
- ctx.state.read(number),
173
- ctx.capabilities.get(),
328
+ ctx.state.read(key),
329
+ input.capabilities,
174
330
  chatEnabled ? ctx.chat.effectiveSettings() : Promise.resolve(null),
175
331
  chatEnabled ? ctx.preflight.get() : Promise.resolve({ installed: false, version: null }),
176
332
  ])
177
333
  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')
334
+ input.warnings.push('acpx is not on PATH, so the AI Chat pane is off; install acpx to turn it on')
179
335
  }
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,
336
+ return {
337
+ pr: input.pr,
338
+ files: input.diff.files,
339
+ derivable: input.diff.derivable,
340
+ comments: input.comments,
341
+ // Marks made on another canvas describe other code, so the page never shows them as reviewed.
342
+ state: stateForCanvas(stored, input.canvasSha),
188
343
  capabilities,
189
344
  chat: {
190
345
  enabled: chatEnabled && acpx.installed,
191
346
  acpx: acpx.installed,
192
347
  ...(settings === null ? {} : { agent: settings.agent, model: settings.model }),
193
348
  },
194
- largePr: largePrOf(files),
195
- warnings: allWarnings,
349
+ largePr: largePrOf(input.diff.files),
350
+ warnings: input.warnings,
196
351
  }
352
+ }
353
+
354
+ /**
355
+ * The head the local review describes right now, resolved the way `prepare` would.
356
+ *
357
+ * `source` is what decides whether the working tree is snapshotted, so an unprepared review asks
358
+ * for the branch tip: there is no canvas for a snapshot to be stale against yet, and that screen
359
+ * is the one the page polls until one exists.
360
+ */
361
+ async function currentLocalPr(ctx: AppContext, key: LocalKey, stored: Pr | null): Promise<Pr> {
362
+ const target: LocalPrepareTarget | null = await ctx.prs.readLocalTarget(key)
363
+ return describeLocalWork(ctx.git, {
364
+ base: target?.base ?? (await resolveLocalBase(ctx.git, stored?.baseRef)),
365
+ source: stored === null ? 'branch' : (target?.source ?? key),
366
+ repo: ctx.config.repo,
367
+ now: ctx.now,
368
+ })
369
+ }
370
+
371
+ /**
372
+ * The bundle for `/review/branch` and `/review/uncommitted`: the same screen over work that has no
373
+ * pull request. There is no forge side to it, so no comments, no capabilities, no attachment
374
+ * discovery and no import.
375
+ */
376
+ export async function resolveLocalBundle(
377
+ ctx: AppContext,
378
+ loader: PrLoader,
379
+ key: LocalKey,
380
+ opts: BundleOptions
381
+ ): Promise<PrBundle> {
382
+ const head = await loader.localHead(key, opts)
383
+ const warnings = [...ctx.projectConfig.warnings]
384
+
385
+ const found = await ctx.canvases.findForLocal(key, head.headSha)
386
+ const loaded = found.status === 'missing' ? null : await loadCanvas(ctx, found)
387
+ const diff = await shownDiff(ctx, head, found, loaded)
388
+ if (diff.warning !== null && loaded !== null) {
389
+ warnings.push(diff.warning)
390
+ }
391
+ const screen = canvasScreen(key, head, found, loaded)
392
+ if (screen.warning !== undefined) {
393
+ warnings.push(screen.warning)
394
+ }
395
+ // Local work is on no forge, so the only diffstat there is is the one in the diff on screen.
396
+ const pr: Pr = { ...head, ...countDiff(diff.files) }
397
+ const base = await bundleBase(ctx, key, {
398
+ pr,
399
+ comments: noComments(pr.headSha, ctx.now),
400
+ capabilities: Promise.resolve(LOCAL_CAPABILITIES),
401
+ diff,
402
+ canvasSha: reviewedCommit(found, pr),
403
+ warnings,
404
+ })
405
+ const { warning: _screenWarning, ...screenFields } = screen
406
+ return { ...base, local: key, ...(loaded ?? {}), ...screenFields }
407
+ }
408
+
409
+ export async function resolveBundle(
410
+ ctx: AppContext,
411
+ loader: PrLoader,
412
+ number: number,
413
+ opts: BundleOptions
414
+ ): Promise<PrBundle> {
415
+ const { pr, comments, warnings: fetched } = await loader.load(number, opts)
416
+ const warnings = [...ctx.projectConfig.warnings, ...fetched]
417
+ const capabilities = ctx.capabilities.get()
197
418
 
198
419
  if (ctx.fixtureArtifact !== null) {
199
420
  const artifact = rekeyFixture(ctx.fixtureArtifact, pr)
421
+ const live = await shownDiff(ctx, pr, { status: 'missing' }, null)
422
+ if (live.warning !== null) {
423
+ warnings.push(live.warning)
424
+ }
425
+ warnings.push('showing the --fixture-canvas artifact (dev only)')
426
+ const diff = live.files.length > 0 ? live : { ...live, files: artifact.files }
427
+ const base = await bundleBase(ctx, number, {
428
+ pr,
429
+ comments,
430
+ capabilities,
431
+ diff,
432
+ canvasSha: pr.headSha,
433
+ warnings,
434
+ })
200
435
  const canvas: CanvasInfo = { headSha: pr.headSha, source: 'fixture', manifest: null }
201
- allWarnings.push('showing the --fixture-canvas artifact (dev only)')
202
436
  return {
203
437
  ...base,
204
438
  status: 'ready',
205
439
  artifact,
206
440
  canvas,
207
- files: files.length > 0 ? files : artifact.files,
208
- largePr: largePrOf(files.length > 0 ? files : artifact.files),
209
441
  skillCommand: buildSkillCommand(number, { force: true }),
210
442
  }
211
443
  }
212
444
 
213
- let found = await ctx.canvases.findForPr(number, pr.headSha)
445
+ let found = await lookupCanvas(ctx, number, pr)
214
446
  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') {
447
+ // A canvas for this very head beats anything attached to the PR, so an ordinary load runs
448
+ // discovery only when there is none, a carried-over canvas included, and its import can turn a
449
+ // stale, missing, or carried-over bundle into one with the head's own canvas. Refresh runs it
450
+ // whatever was found, to pick up a newer generation at the same head.
451
+ if (found.status !== 'ready' || found.carriedOver !== undefined || opts.refresh) {
218
452
  const discovery = await runDiscovery(ctx, pr, comments, { refresh: opts.refresh })
219
453
  sharedCanvas = discovery.sharedCanvas
220
- allWarnings.push(...discovery.warnings)
454
+ warnings.push(...discovery.warnings)
221
455
  if (discovery.imported) {
222
- found = await ctx.canvases.findForPr(number, pr.headSha)
456
+ found = await lookupCanvas(ctx, number, pr)
223
457
  }
224
458
  }
225
- const shared = sharedCanvas === null ? {} : { sharedCanvas }
226
459
 
227
- if (found.status === 'missing') {
228
- return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: false }) }
460
+ const loaded = found.status === 'missing' ? null : await loadCanvas(ctx, found)
461
+ const diff = await shownDiff(ctx, pr, found, loaded)
462
+ if (diff.warning !== null) {
463
+ warnings.push(diff.warning)
229
464
  }
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,
465
+ const screen = canvasScreen(number, pr, found, loaded)
466
+ if (screen.warning !== undefined) {
467
+ warnings.push(screen.warning)
265
468
  }
469
+ const base = await bundleBase(ctx, number, {
470
+ pr,
471
+ comments,
472
+ capabilities,
473
+ diff,
474
+ canvasSha: reviewedCommit(found, pr),
475
+ warnings,
476
+ })
477
+ const shared = sharedCanvas === null ? {} : { sharedCanvas }
478
+ const { warning: _screenWarning, ...screenFields } = screen
479
+ return { ...base, ...shared, ...(loaded ?? {}), ...screenFields }
266
480
  }
@@ -11,8 +11,8 @@ import { createTranscriptStore, type TranscriptStore } from '../chat/threads.js'
11
11
  import type { RuntimeConfig } from '../config.js'
12
12
  import type { ReviewArtifact } from '../contract/review-artifact.js'
13
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'
14
+ import { type CapabilityProbe, createCapabilityProbe } from '../host/capabilities.js'
15
+ import { createHostClient, type HostClient } from '../host/client.js'
16
16
  import { PACKAGE_ROOT, STATIC_DIR } from '../paths.js'
17
17
  import type { LoadedProjectConfig } from '../project-config.js'
18
18
  import { type CanvasStore, createCanvasStore } from '../store/canvas-store.js'
@@ -36,17 +36,19 @@ export interface VendorRoots {
36
36
  * `createAppContext`; tests assemble their own with fakes and a temp data dir.
37
37
  */
38
38
  export interface AppContext {
39
+ log: (line: string) => void
39
40
  config: RuntimeConfig
40
41
  projectConfig: LoadedProjectConfig
41
42
  git: Git
42
- gh: GitHubClient
43
+ /** The host CLI client: `gh` or `glab`, whichever `config.host` names. */
44
+ gh: HostClient
43
45
  /** The only outbound HTTP the tool makes: attachment downloads. Tests inject a fake. */
44
46
  fetch: typeof fetch
45
47
  canvases: CanvasStore
46
48
  derived: DerivedStore
47
49
  prs: PrStore
48
50
  state: StateStore
49
- /** What this GitHub login may post here, probed once and reused for ten minutes. */
51
+ /** What this forge login may post here, probed once and reused for ten minutes. */
50
52
  capabilities: CapabilityProbe
51
53
  /** Personal chat settings, in `.pr-review/settings.yml`. */
52
54
  settings: SettingsStore
@@ -72,7 +74,11 @@ export function resolveVendorRoots(): VendorRoots {
72
74
  diff: path.dirname(fileURLToPath(import.meta.resolve('diff'))),
73
75
  marked: fileURLToPath(import.meta.resolve('marked')),
74
76
  dompurify: fileURLToPath(import.meta.resolve('dompurify')),
75
- hljs: path.join(path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')), 'es', 'highlight.min.js'),
77
+ hljs: path.join(
78
+ path.dirname(require.resolve('@highlightjs/cdn-assets/package.json')),
79
+ 'es',
80
+ 'highlight.min.js'
81
+ ),
76
82
  mermaid: path.dirname(fileURLToPath(import.meta.resolve('mermaid'))),
77
83
  }
78
84
  }
@@ -107,7 +113,7 @@ export interface CreateAppContextOptions {
107
113
  projectConfig: LoadedProjectConfig
108
114
  fixtureArtifact: ReviewArtifact | null
109
115
  git?: Git
110
- gh?: GitHubClient
116
+ gh?: HostClient
111
117
  fetch?: typeof fetch
112
118
  runner?: AgentRunner
113
119
  now?: () => Date
@@ -131,7 +137,7 @@ export function createChatSet(
131
137
  ): ChatSet {
132
138
  const settings = createSettingsStore(config.dataDir)
133
139
  const preflight = createPreflightProbe(runner, now)
134
- const transcripts = createTranscriptStore(number => stores.prs.prDir(number))
140
+ const transcripts = createTranscriptStore(key => stores.prs.prDir(key))
135
141
  return {
136
142
  settings,
137
143
  preflight,
@@ -154,17 +160,25 @@ export function createChatSet(
154
160
  export function createAppContext(opts: CreateAppContextOptions): AppContext {
155
161
  const git = opts.git ?? createGit(opts.config.repoRoot)
156
162
  const now = opts.now ?? (() => new Date())
157
- const gh = opts.gh ?? createGitHubClient()
163
+ const { host, repo } = opts.config
164
+ const gh = opts.gh ?? createHostClient(host.cli)
158
165
  const stores = createStores(opts.config.dataDir, opts.config, git, now)
159
166
  return {
160
167
  config: opts.config,
168
+ log: line => process.stderr.write(`${line}\n`),
161
169
  projectConfig: opts.projectConfig,
162
170
  git,
163
171
  gh,
164
- capabilities: createCapabilityProbe(gh, opts.config.repo, now),
172
+ capabilities: createCapabilityProbe(() => host.probeCapabilities(gh, repo), now),
165
173
  fetch: opts.fetch ?? ((input, init) => globalThis.fetch(input, init)),
166
174
  ...stores,
167
- ...createChatSet(opts.config, opts.runner ?? createAgentRunner(), stores, now, opts.projectConfig.config.prompts),
175
+ ...createChatSet(
176
+ opts.config,
177
+ opts.runner ?? createAgentRunner(),
178
+ stores,
179
+ now,
180
+ opts.projectConfig.config.prompts
181
+ ),
168
182
  now,
169
183
  version: readPackageVersion(),
170
184
  staticDir: STATIC_DIR,