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