@vintasoftware/pr-review-canvas 0.3.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 (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -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 {
@@ -58,15 +76,37 @@ export async function runDiscovery(
58
76
  */
59
77
  export function createPrLoader(ctx: AppContext) {
60
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
+ }
61
100
 
62
101
  async function refreshPr(
63
102
  number: number
64
103
  ): Promise<{ pr: Pr; comments: CommentsPayload; warnings: string[] }> {
65
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
66
- const shas = await fetchPrRefs(ctx.git, meta)
67
- const pr = toPr(meta, ctx.config.repo, shas)
68
- await ctx.prs.writePr(pr)
69
- const { payload, warnings } = await fetchComments(ctx.gh, ctx.config.repo, number, shas.headSha, ctx.now)
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)
70
110
  await ctx.prs.writeComments(number, payload)
71
111
  refreshed.add(number)
72
112
  return { pr, comments: payload, warnings }
@@ -87,24 +127,56 @@ export function createPrLoader(ctx: AppContext) {
87
127
  },
88
128
  async refreshComments(number: number): Promise<{ comments: CommentsPayload; warnings: string[] }> {
89
129
  const pr = (await ctx.prs.readPr(number)) ?? (await refreshPr(number)).pr
90
- 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)
91
132
  await ctx.prs.writeComments(number, payload)
92
133
  return { comments: payload, warnings }
93
134
  },
94
135
  async currentPr(number: number): Promise<Pr> {
95
136
  return (await ctx.prs.readPr(number)) ?? (await refreshPr(number)).pr
96
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
+ },
97
159
  }
98
160
  }
99
161
  export type PrLoader = ReturnType<typeof createPrLoader>
100
162
 
101
- /** Whether the change set on screen is large, counted from the files the page is showing. */
102
- function largePrOf(files: readonly FileEntry[]): boolean {
103
- return isLargePr({
104
- 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 {
105
170
  additions: files.reduce((n, f) => n + f.additions, 0),
106
171
  deletions: files.reduce((n, f) => n + f.deletions, 0),
107
- })
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 })
108
180
  }
109
181
 
110
182
  /** Dev fixture: the committed artifact presented as the canvas of the live PR head. */
@@ -116,10 +188,15 @@ export function rekeyFixture(fixture: ReviewArtifact, pr: Pr): ReviewArtifact {
116
188
  * The stored canvas and the block that describes it. Null when review.json was written by an
117
189
  * older tool version: the page then offers to generate a new one.
118
190
  */
191
+ export interface LoadedCanvas {
192
+ artifact: ReviewArtifact
193
+ canvas: CanvasInfo
194
+ }
195
+
119
196
  async function loadCanvas(
120
197
  ctx: AppContext,
121
198
  found: Extract<CanvasLookup, { headSha: string }>
122
- ): Promise<{ artifact: ReviewArtifact; canvas: CanvasInfo } | null> {
199
+ ): Promise<LoadedCanvas | null> {
123
200
  let artifact: ReviewArtifact | null
124
201
  try {
125
202
  artifact = await ctx.canvases.readArtifact(found.headSha)
@@ -140,140 +217,264 @@ async function loadCanvas(
140
217
  return { artifact, canvas }
141
218
  }
142
219
 
143
- /** The diffs of one canvas: the stored ones, or freshly built when both commits are local. */
144
- 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(
145
247
  ctx: AppContext,
146
- headSha: string,
147
- mergeBaseSha: string | undefined
148
- ): Promise<{ files: FileEntry[] } | null> {
149
- const stored = await ctx.derived.read(headSha)
150
- if (stored !== null || mergeBaseSha === undefined) {
151
- 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 }
152
256
  }
153
- if (!(await ctx.derived.derivable(headSha, mergeBaseSha))) {
154
- 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',
155
264
  }
156
- return ctx.derived.ensure(headSha, mergeBaseSha)
157
265
  }
158
266
 
159
- export async function resolveBundle(
160
- ctx: AppContext,
161
- loader: PrLoader,
162
- number: number,
163
- opts: BundleOptions
164
- ): Promise<PrBundle> {
165
- const { pr, comments, warnings } = await loader.load(number, opts)
166
- const allWarnings = [...ctx.projectConfig.warnings, ...warnings]
167
-
168
- const derivable = await ctx.derived.derivable(pr.headSha, pr.mergeBaseSha)
169
- let files: FileEntry[] = []
170
- if (derivable) {
171
- files = (await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)).files
172
- } else {
173
- 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,
174
304
  }
305
+ if (found.relation === 'ancestor') {
306
+ stale.commitsBehind = found.commitsBehind
307
+ }
308
+ return { status: 'stale', skillCommand, stale }
309
+ }
175
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'>> {
176
326
  const chatEnabled = ctx.projectConfig.config.chat.enabled
177
327
  const [stored, capabilities, settings, acpx] = await Promise.all([
178
- ctx.state.read(number),
179
- ctx.capabilities.get(),
328
+ ctx.state.read(key),
329
+ input.capabilities,
180
330
  chatEnabled ? ctx.chat.effectiveSettings() : Promise.resolve(null),
181
331
  chatEnabled ? ctx.preflight.get() : Promise.resolve({ installed: false, version: null }),
182
332
  ])
183
333
  if (chatEnabled && !acpx.installed) {
184
- 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')
185
335
  }
186
- // Marks made on another commit describe other code, so the page never shows them as reviewed.
187
- const state = stateForHead(stored, pr.headSha)
188
- const base = {
189
- pr,
190
- files,
191
- derivable,
192
- comments,
193
- 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),
194
343
  capabilities,
195
344
  chat: {
196
345
  enabled: chatEnabled && acpx.installed,
197
346
  acpx: acpx.installed,
198
347
  ...(settings === null ? {} : { agent: settings.agent, model: settings.model }),
199
348
  },
200
- largePr: largePrOf(files),
201
- warnings: allWarnings,
349
+ largePr: largePrOf(input.diff.files),
350
+ warnings: input.warnings,
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)
202
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()
203
418
 
204
419
  if (ctx.fixtureArtifact !== null) {
205
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
+ })
206
435
  const canvas: CanvasInfo = { headSha: pr.headSha, source: 'fixture', manifest: null }
207
- allWarnings.push('showing the --fixture-canvas artifact (dev only)')
208
436
  return {
209
437
  ...base,
210
438
  status: 'ready',
211
439
  artifact,
212
440
  canvas,
213
- files: files.length > 0 ? files : artifact.files,
214
- largePr: largePrOf(files.length > 0 ? files : artifact.files),
215
441
  skillCommand: buildSkillCommand(number, { force: true }),
216
442
  }
217
443
  }
218
444
 
219
- let found = await ctx.canvases.findForPr(number, pr.headSha)
445
+ let found = await lookupCanvas(ctx, number, pr)
220
446
  let sharedCanvas: SharedCanvasInfo | null = null
221
- // A canvas for this very head beats anything attached to the PR, so discovery runs only
222
- // when there is none, and its import can turn a stale or missing bundle into a ready one.
223
- 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) {
224
452
  const discovery = await runDiscovery(ctx, pr, comments, { refresh: opts.refresh })
225
453
  sharedCanvas = discovery.sharedCanvas
226
- allWarnings.push(...discovery.warnings)
454
+ warnings.push(...discovery.warnings)
227
455
  if (discovery.imported) {
228
- found = await ctx.canvases.findForPr(number, pr.headSha)
456
+ found = await lookupCanvas(ctx, number, pr)
229
457
  }
230
458
  }
231
- const shared = sharedCanvas === null ? {} : { sharedCanvas }
232
459
 
233
- if (found.status === 'missing') {
234
- return {
235
- ...base,
236
- ...shared,
237
- status: 'missing',
238
- skillCommand: buildSkillCommand(number, { force: false }),
239
- }
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)
240
464
  }
241
- const loaded = await loadCanvas(ctx, found)
242
- if (loaded === null) {
243
- allWarnings.push(
244
- `the canvas for ${found.headSha.slice(0, 7)} does not match the current format; regenerate it`
245
- )
246
- return { ...base, ...shared, status: 'missing', skillCommand: buildSkillCommand(number, { force: true }) }
247
- }
248
- // A canvas exists for this PR, so regenerating always needs --force.
249
- const skillCommand = buildSkillCommand(number, { force: true })
250
- if (found.status === 'ready') {
251
- return { ...base, ...shared, ...loaded, status: 'ready', skillCommand }
252
- }
253
- const stale: StaleInfo = {
254
- canvasHeadSha: found.headSha,
255
- currentHeadSha: pr.headSha,
256
- relation: found.relation,
257
- }
258
- if (found.commitsBehind !== undefined) {
259
- stale.commitsBehind = found.commitsBehind
260
- }
261
- // The page shows the canvas of the older commit, so the files and the diffs are that commit's.
262
- // They are rebuilt when the clone has the commits, which is how a canvas imported before the
263
- // fetch becomes readable once the commits arrive.
264
- const staleDerived = await readOrBuildDerived(ctx, found.headSha, loaded.canvas.manifest?.mergeBaseSha)
265
- // A stale canvas describes an older commit, so its own files decide both the diffs and
266
- // whether the notice about large change sets belongs on the page.
267
- const staleFiles = staleDerived?.files ?? loaded.artifact.files
268
- return {
269
- ...base,
270
- ...shared,
271
- ...loaded,
272
- files: staleFiles,
273
- largePr: largePrOf(staleFiles),
274
- derivable: staleDerived !== null,
275
- status: 'stale',
276
- stale,
277
- skillCommand,
465
+ const screen = canvasScreen(number, pr, found, loaded)
466
+ if (screen.warning !== undefined) {
467
+ warnings.push(screen.warning)
278
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 }
279
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'
@@ -40,14 +40,15 @@ export interface AppContext {
40
40
  config: RuntimeConfig
41
41
  projectConfig: LoadedProjectConfig
42
42
  git: Git
43
- gh: GitHubClient
43
+ /** The host CLI client: `gh` or `glab`, whichever `config.host` names. */
44
+ gh: HostClient
44
45
  /** The only outbound HTTP the tool makes: attachment downloads. Tests inject a fake. */
45
46
  fetch: typeof fetch
46
47
  canvases: CanvasStore
47
48
  derived: DerivedStore
48
49
  prs: PrStore
49
50
  state: StateStore
50
- /** 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. */
51
52
  capabilities: CapabilityProbe
52
53
  /** Personal chat settings, in `.pr-review/settings.yml`. */
53
54
  settings: SettingsStore
@@ -112,7 +113,7 @@ export interface CreateAppContextOptions {
112
113
  projectConfig: LoadedProjectConfig
113
114
  fixtureArtifact: ReviewArtifact | null
114
115
  git?: Git
115
- gh?: GitHubClient
116
+ gh?: HostClient
116
117
  fetch?: typeof fetch
117
118
  runner?: AgentRunner
118
119
  now?: () => Date
@@ -136,7 +137,7 @@ export function createChatSet(
136
137
  ): ChatSet {
137
138
  const settings = createSettingsStore(config.dataDir)
138
139
  const preflight = createPreflightProbe(runner, now)
139
- const transcripts = createTranscriptStore(number => stores.prs.prDir(number))
140
+ const transcripts = createTranscriptStore(key => stores.prs.prDir(key))
140
141
  return {
141
142
  settings,
142
143
  preflight,
@@ -159,7 +160,8 @@ export function createChatSet(
159
160
  export function createAppContext(opts: CreateAppContextOptions): AppContext {
160
161
  const git = opts.git ?? createGit(opts.config.repoRoot)
161
162
  const now = opts.now ?? (() => new Date())
162
- const gh = opts.gh ?? createGitHubClient()
163
+ const { host, repo } = opts.config
164
+ const gh = opts.gh ?? createHostClient(host.cli)
163
165
  const stores = createStores(opts.config.dataDir, opts.config, git, now)
164
166
  return {
165
167
  config: opts.config,
@@ -167,7 +169,7 @@ export function createAppContext(opts: CreateAppContextOptions): AppContext {
167
169
  projectConfig: opts.projectConfig,
168
170
  git,
169
171
  gh,
170
- capabilities: createCapabilityProbe(gh, opts.config.repo, now),
172
+ capabilities: createCapabilityProbe(() => host.probeCapabilities(gh, repo), now),
171
173
  fetch: opts.fetch ?? ((input, init) => globalThis.fetch(input, init)),
172
174
  ...stores,
173
175
  ...createChatSet(
@@ -3,8 +3,20 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'
3
3
  import { ConfigError } from '../config.js'
4
4
  import type { ErrorCode, ErrorEnvelope } from '../contract/api.js'
5
5
  import { GitError } from '../git/git.js'
6
- import { GitHubApiError } from '../github/gh.js'
7
- import { PrNotFoundError } from '../github/pr.js'
6
+ import { LocalTargetError } from '../git/local-target.js'
7
+ import { PrNotFoundError } from '../host/pr.js'
8
+ import { CLI_INFO, type HostCli, HostCliError } from '../host/client.js'
9
+
10
+ /** The codes each host CLI's failures map to. The front end and the docs name them one by one. */
11
+ const CLI_ERROR_CODES: Record<HostCli, { missing: ErrorCode; unauthenticated: ErrorCode; api: ErrorCode }> = {
12
+ gh: { missing: 'GH_MISSING', unauthenticated: 'GH_UNAUTHENTICATED', api: 'GITHUB_API_ERROR' },
13
+ glab: { missing: 'GLAB_MISSING', unauthenticated: 'GLAB_UNAUTHENTICATED', api: 'GITLAB_API_ERROR' },
14
+ }
15
+
16
+ /** The codes that mean the host CLI itself, not the request, is the problem. */
17
+ export const CLI_SETUP_CODES: ReadonlySet<ErrorCode> = new Set(
18
+ Object.values(CLI_ERROR_CODES).flatMap(c => [c.missing, c.unauthenticated])
19
+ )
8
20
 
9
21
  export class AppError extends Error {
10
22
  readonly code: ErrorCode
@@ -53,19 +65,29 @@ export function toAppError(err: unknown): AppError {
53
65
  'check the number and that origin is the right repository'
54
66
  )
55
67
  }
56
- if (err instanceof GitHubApiError) {
68
+ if (err instanceof HostCliError) {
69
+ const codes = CLI_ERROR_CODES[err.cli]
70
+ const info = CLI_INFO[err.cli]
57
71
  if (err.missingBinary) {
58
72
  return new AppError(
59
- 'GH_MISSING',
60
- 'the GitHub CLI (gh) is not installed',
73
+ codes.missing,
74
+ `the ${info.label} is not installed`,
61
75
  500,
62
- 'install it from https://cli.github.com'
76
+ `install it from ${info.installUrl}`
63
77
  )
64
78
  }
65
79
  if (err.unauthenticated) {
66
- return new AppError('GH_UNAUTHENTICATED', 'gh is not logged in', 401, 'run `gh auth login`')
80
+ return new AppError(
81
+ codes.unauthenticated,
82
+ `${err.cli} is not logged in`,
83
+ 401,
84
+ `run \`${info.loginCommand}\``
85
+ )
67
86
  }
68
- return new AppError('GITHUB_API_ERROR', err.message, 502)
87
+ return new AppError(codes.api, err.message, 502)
88
+ }
89
+ if (err instanceof LocalTargetError) {
90
+ return new AppError('BAD_REQUEST', err.message, 400, err.hint)
69
91
  }
70
92
  if (err instanceof GitError) {
71
93
  return new AppError('GIT_ERROR', err.message, 500)