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