@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.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
package/src/server/bundle.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
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<
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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:
|
|
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
|
|
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
|
|
222
|
-
// when there is none, and its import can turn a
|
|
223
|
-
|
|
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
|
-
|
|
454
|
+
warnings.push(...discovery.warnings)
|
|
227
455
|
if (discovery.imported) {
|
|
228
|
-
found = await ctx
|
|
456
|
+
found = await lookupCanvas(ctx, number, pr)
|
|
229
457
|
}
|
|
230
458
|
}
|
|
231
|
-
const shared = sharedCanvas === null ? {} : { sharedCanvas }
|
|
232
459
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
242
|
-
if (
|
|
243
|
-
|
|
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
|
}
|
package/src/server/context.ts
CHANGED
|
@@ -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 '../
|
|
15
|
-
import {
|
|
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
|
-
|
|
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
|
|
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?:
|
|
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(
|
|
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
|
|
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,
|
|
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(
|
package/src/server/errors.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import { PrNotFoundError } from '../
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
73
|
+
codes.missing,
|
|
74
|
+
`the ${info.label} is not installed`,
|
|
61
75
|
500,
|
|
62
|
-
|
|
76
|
+
`install it from ${info.installUrl}`
|
|
63
77
|
)
|
|
64
78
|
}
|
|
65
79
|
if (err.unauthenticated) {
|
|
66
|
-
return new AppError(
|
|
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(
|
|
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)
|