@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
|
@@ -4,14 +4,19 @@ import { z } from 'zod'
|
|
|
4
4
|
import type { ReviewBodyResponse, StateResponse } from '../../contract/api.js'
|
|
5
5
|
import { PostCommentInputSchema, type PostCommentResult } from '../../contract/comments.js'
|
|
6
6
|
import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import type { PrState } from '../../contract/state.js'
|
|
8
|
+
import { checkInlineTarget } from '../../git/patch-lines.js'
|
|
9
|
+
import { PostReviewInputSchema } from '../../contract/reviews.js'
|
|
10
|
+
import { lookupCanvas } from '../../review/carry-over.js'
|
|
11
|
+
import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
|
|
12
|
+
import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
|
|
13
|
+
import { canvasBelongsTo } from '../../store/canvas-store.js'
|
|
10
14
|
import { isReviewedId } from '../../store/state-store.js'
|
|
11
|
-
import type {
|
|
15
|
+
import type { Derived } from '../../store/derived-store.js'
|
|
16
|
+
import { LOCAL_CAPABILITIES, type PrLoader } from '../bundle.js'
|
|
12
17
|
import type { AppContext } from '../context.js'
|
|
13
18
|
import { AppError } from '../errors.js'
|
|
14
|
-
import {
|
|
19
|
+
import { parseTargetKey, requirePrNumber } from './api.js'
|
|
15
20
|
|
|
16
21
|
const ReviewedBodySchema = z.object({
|
|
17
22
|
reviewed: z.boolean(),
|
|
@@ -20,6 +25,14 @@ const ReviewedBodySchema = z.object({
|
|
|
20
25
|
.string()
|
|
21
26
|
.regex(/^[0-9a-f]{40}$/)
|
|
22
27
|
.optional(),
|
|
28
|
+
/**
|
|
29
|
+
* The commit of the canvas on the page, which the marks are keyed to. The head when absent. Any
|
|
30
|
+
* other value must be a canvas indexed for this pull request.
|
|
31
|
+
*/
|
|
32
|
+
canvasSha: z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
35
|
+
.optional(),
|
|
23
36
|
})
|
|
24
37
|
const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
|
|
25
38
|
const HiddenBodySchema = z.object({ hidden: z.boolean() })
|
|
@@ -54,12 +67,34 @@ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: str
|
|
|
54
67
|
return parsed.data
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
/**
|
|
58
|
-
async function
|
|
70
|
+
/** Refuses a commit that is not an indexed canvas of this target. */
|
|
71
|
+
async function requireCanvasOf(ctx: AppContext, key: ReviewKey, canvasSha: string): Promise<void> {
|
|
72
|
+
const entry = (await ctx.canvases.readIndex()).canvases[canvasSha]
|
|
73
|
+
// The same rule the lookups use, so a mark can only be keyed to a canvas this target shows.
|
|
74
|
+
if (entry === undefined || !canvasBelongsTo(entry, key)) {
|
|
75
|
+
throw new AppError(
|
|
76
|
+
'CANVAS_NOT_FOUND',
|
|
77
|
+
`${canvasSha.slice(0, 7)} is not a canvas of ${keyLabel(key)}`,
|
|
78
|
+
404,
|
|
79
|
+
'reload the page'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The canvas the reviewer is signing off on, with the marks made on it: the canvas written for the
|
|
86
|
+
* pull request's head, or for another commit with an identical diff.
|
|
87
|
+
*/
|
|
88
|
+
async function canvasForSignOff(
|
|
89
|
+
ctx: AppContext,
|
|
90
|
+
number: number,
|
|
91
|
+
pr: Pr
|
|
92
|
+
): Promise<{ artifact: ReviewArtifact; state: PrState }> {
|
|
93
|
+
const stored = await ctx.state.read(number)
|
|
59
94
|
if (ctx.fixtureArtifact !== null) {
|
|
60
|
-
return { ...ctx.fixtureArtifact, pr }
|
|
95
|
+
return { artifact: { ...ctx.fixtureArtifact, pr }, state: stateForCanvas(stored, pr.headSha) }
|
|
61
96
|
}
|
|
62
|
-
const found = await ctx
|
|
97
|
+
const found = await lookupCanvas(ctx, number, pr)
|
|
63
98
|
if (found.status !== 'ready') {
|
|
64
99
|
throw new AppError(
|
|
65
100
|
'SIGNOFF_INCOMPLETE',
|
|
@@ -72,7 +107,7 @@ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise
|
|
|
72
107
|
if (artifact === null) {
|
|
73
108
|
throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
|
|
74
109
|
}
|
|
75
|
-
return artifact
|
|
110
|
+
return { artifact, state: stateForCanvas(stored, found.headSha) }
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
@@ -84,25 +119,25 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
84
119
|
if (caps.canComment === false) {
|
|
85
120
|
throw new AppError(
|
|
86
121
|
'COMMENT_FORBIDDEN',
|
|
87
|
-
caps.reason ??
|
|
122
|
+
caps.reason ?? `this ${ctx.config.host.label} login cannot post on this repository`,
|
|
88
123
|
403,
|
|
89
124
|
caps.hint
|
|
90
125
|
)
|
|
91
126
|
}
|
|
92
127
|
}
|
|
93
128
|
|
|
94
|
-
const stateBody = (
|
|
95
|
-
prNumber:
|
|
129
|
+
const stateBody = (key: ReviewKey, state: StateResponse['state']): StateResponse => ({
|
|
130
|
+
prNumber: key,
|
|
96
131
|
state,
|
|
97
132
|
})
|
|
98
133
|
|
|
99
134
|
api.get('/prs/:n/state', async c => {
|
|
100
|
-
const
|
|
101
|
-
return c.json(stateBody(
|
|
135
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
136
|
+
return c.json(stateBody(key, await ctx.state.read(key)))
|
|
102
137
|
})
|
|
103
138
|
|
|
104
139
|
api.put('/prs/:n/reviewed/:id{.+}', async c => {
|
|
105
|
-
const
|
|
140
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
106
141
|
const id = c.req.param('id')
|
|
107
142
|
if (!isReviewedId(id)) {
|
|
108
143
|
throw new AppError(
|
|
@@ -113,21 +148,31 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
113
148
|
)
|
|
114
149
|
}
|
|
115
150
|
const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
151
|
+
// A mark is made against the canvas on screen, which for a local review is the snapshot the
|
|
152
|
+
// page was drawn from, not whatever the working tree holds a keystroke later.
|
|
153
|
+
const pr = await loader.currentTarget(key)
|
|
154
|
+
if (!isLocalKey(key)) {
|
|
155
|
+
requireSameHead(body.headSha, pr.headSha)
|
|
156
|
+
}
|
|
157
|
+
// The page sends back the canvas commit the bundle keyed its marks to, so a toggle costs no
|
|
158
|
+
// git work; the index, not git, says the commit is a canvas of this target.
|
|
159
|
+
const canvasSha = body.canvasSha ?? pr.headSha
|
|
160
|
+
if (canvasSha !== pr.headSha) {
|
|
161
|
+
await requireCanvasOf(ctx, key, canvasSha)
|
|
162
|
+
}
|
|
163
|
+
return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, canvasSha)))
|
|
119
164
|
})
|
|
120
165
|
|
|
121
166
|
api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
|
|
122
|
-
const
|
|
167
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
123
168
|
const fingerprint = c.req.param('fingerprint')
|
|
124
169
|
const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
|
|
125
|
-
const next = await ctx.state.setDismissed(
|
|
126
|
-
return c.json(stateBody(
|
|
170
|
+
const next = await ctx.state.setDismissed(key, fingerprint, body.dismissed, body.reason)
|
|
171
|
+
return c.json(stateBody(key, next))
|
|
127
172
|
})
|
|
128
173
|
|
|
129
174
|
api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
|
|
130
|
-
const number =
|
|
175
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'hiding a comment thread')
|
|
131
176
|
const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
|
|
132
177
|
if (!rootId.success) {
|
|
133
178
|
throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
|
|
@@ -137,16 +182,20 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
137
182
|
})
|
|
138
183
|
|
|
139
184
|
api.get('/prs/:n/capabilities', async c => {
|
|
140
|
-
|
|
185
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
186
|
+
if (isLocalKey(key)) {
|
|
187
|
+
return c.json(LOCAL_CAPABILITIES)
|
|
188
|
+
}
|
|
141
189
|
return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
|
|
142
190
|
})
|
|
143
191
|
|
|
144
192
|
api.post('/prs/:n/comments', async c => {
|
|
145
|
-
const number =
|
|
193
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'posting a comment')
|
|
146
194
|
const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
|
|
147
195
|
await requirePosting()
|
|
148
196
|
const pr = await loader.currentPr(number)
|
|
149
197
|
requireSameHead(input.headSha, pr.headSha)
|
|
198
|
+
let diff: Derived = { files: [], patches: {} }
|
|
150
199
|
if (input.kind === 'inline') {
|
|
151
200
|
const derived = await ctx.derived.read(pr.headSha)
|
|
152
201
|
if (derived === null) {
|
|
@@ -161,8 +210,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
161
210
|
if (problem !== null) {
|
|
162
211
|
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
163
212
|
}
|
|
213
|
+
diff = derived
|
|
164
214
|
}
|
|
165
|
-
const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
|
|
215
|
+
const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
|
|
166
216
|
await appendComment(ctx, number, posted)
|
|
167
217
|
const entry =
|
|
168
218
|
input.kind === 'inline' && input.pointFingerprint !== undefined
|
|
@@ -173,10 +223,9 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
173
223
|
})
|
|
174
224
|
|
|
175
225
|
api.get('/prs/:n/review/body', async c => {
|
|
176
|
-
const number =
|
|
226
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
|
|
177
227
|
const pr = await loader.currentPr(number)
|
|
178
|
-
const artifact = await
|
|
179
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
228
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
180
229
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
181
230
|
const body: ReviewBodyResponse = {
|
|
182
231
|
headSha: pr.headSha,
|
|
@@ -187,13 +236,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
187
236
|
})
|
|
188
237
|
|
|
189
238
|
api.post('/prs/:n/review', async c => {
|
|
190
|
-
const number =
|
|
239
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'submitting a review')
|
|
191
240
|
const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
|
|
192
241
|
await requirePosting()
|
|
193
242
|
const pr = await loader.currentPr(number)
|
|
194
243
|
requireSameHead(input.headSha, pr.headSha)
|
|
195
|
-
const artifact = await
|
|
196
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
244
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
197
245
|
if (input.event === 'APPROVE') {
|
|
198
246
|
const missing = unreviewedLayers(artifact, state)
|
|
199
247
|
if (missing.length > 0) {
|
|
@@ -207,7 +255,10 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
207
255
|
}
|
|
208
256
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
209
257
|
const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
|
|
210
|
-
const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
|
|
258
|
+
const review = await ctx.config.host.postReview(ctx.gh, ctx.config.repo, number, pr.headSha, {
|
|
259
|
+
event: input.event,
|
|
260
|
+
body,
|
|
261
|
+
})
|
|
211
262
|
return c.json({ review }, 201)
|
|
212
263
|
})
|
|
213
264
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type {
|
|
2
|
+
import type { CarriedOverInfo } from '../contract/api.js'
|
|
3
3
|
import {
|
|
4
4
|
type CanvasIndex,
|
|
5
5
|
CanvasIndexSchema,
|
|
@@ -7,13 +7,43 @@ import {
|
|
|
7
7
|
CanvasManifestSchema,
|
|
8
8
|
} from '../contract/canvas-manifest.js'
|
|
9
9
|
import { type ReviewArtifact, ReviewArtifactSchema } from '../contract/review-artifact.js'
|
|
10
|
+
import { isLocalKey, type LocalKey, type ReviewKey } from '../contract/review-key.js'
|
|
10
11
|
import type { Git } from '../git/git.js'
|
|
11
12
|
import { readJson, readText, writeJsonAtomic } from './atomic-json.js'
|
|
12
13
|
|
|
14
|
+
/** A canvas of a commit the head was built on, and how many commits the head gained since. */
|
|
15
|
+
export interface AncestorCanvas {
|
|
16
|
+
status: 'stale'
|
|
17
|
+
headSha: string
|
|
18
|
+
relation: 'ancestor'
|
|
19
|
+
commitsBehind: number
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
export type CanvasLookup =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
/** `headSha` is the canvas's own commit: the head, or another commit with an identical diff. */
|
|
24
|
+
{ status: 'ready'; headSha: string; carriedOver?: CarriedOverInfo } | StaleCanvas | { status: 'missing' }
|
|
25
|
+
|
|
26
|
+
/** A canvas of another commit: one the head was built on, or one from a line the head no longer contains. */
|
|
27
|
+
export type StaleCanvas = AncestorCanvas | { status: 'stale'; headSha: string; relation: 'unrelated' }
|
|
28
|
+
|
|
29
|
+
/** One canvas as `index.json` lists it. */
|
|
30
|
+
export type CanvasEntry = CanvasIndex['canvases'][string]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a canvas is one of this target's. A canvas belongs to a pull request when it was
|
|
34
|
+
* generated for it or before it existed; to a local review when it belongs to no pull request. A
|
|
35
|
+
* snapshot of uncommitted work sits on no branch, so only the `uncommitted` review claims it.
|
|
36
|
+
*
|
|
37
|
+
* Every consumer asks through here: the lookups that pick the canvas to show, and the routes that
|
|
38
|
+
* accept a canvas a mark was made on. Two answers to this question is how marks end up keyed to a
|
|
39
|
+
* canvas their review will never show.
|
|
40
|
+
*/
|
|
41
|
+
export function canvasBelongsTo(entry: CanvasEntry, key: ReviewKey): boolean {
|
|
42
|
+
if (isLocalKey(key)) {
|
|
43
|
+
return entry.prNumber === undefined && (key === 'uncommitted' || entry.worktree !== true)
|
|
44
|
+
}
|
|
45
|
+
return entry.worktree !== true && (entry.prNumber === undefined || entry.prNumber === key)
|
|
46
|
+
}
|
|
17
47
|
|
|
18
48
|
export interface CanvasStore {
|
|
19
49
|
readonly root: string
|
|
@@ -24,12 +54,23 @@ export interface CanvasStore {
|
|
|
24
54
|
readArtifact(headSha: string): Promise<ReviewArtifact | null>
|
|
25
55
|
readManifest(headSha: string): Promise<CanvasManifest | null>
|
|
26
56
|
/** Writes review.json + manifest.json atomically and records the canvas in index.json. */
|
|
27
|
-
write(
|
|
57
|
+
write(
|
|
58
|
+
headSha: string,
|
|
59
|
+
artifact: ReviewArtifact,
|
|
60
|
+
manifest: CanvasManifest,
|
|
61
|
+
prNumber?: number,
|
|
62
|
+
flags?: { worktree?: boolean }
|
|
63
|
+
): Promise<void>
|
|
28
64
|
/**
|
|
29
65
|
* The best canvas for this PR: the one written for its current head, else the newest one the
|
|
30
66
|
* head was built on top of, else one from a branch the head no longer contains.
|
|
31
67
|
*/
|
|
32
68
|
findForPr(prNumber: number, currentHeadSha: string): Promise<CanvasLookup>
|
|
69
|
+
/**
|
|
70
|
+
* The best canvas for a local review: the one for this head, else the newest canvas that belongs
|
|
71
|
+
* to no pull request. A snapshot of uncommitted work counts only for the `uncommitted` review.
|
|
72
|
+
*/
|
|
73
|
+
findForLocal(key: LocalKey, currentHeadSha: string): Promise<CanvasLookup>
|
|
33
74
|
/** Records the PR number on a canvas that was exported before the pull request existed. */
|
|
34
75
|
attachPrNumber(headSha: string, prNumber: number): Promise<void>
|
|
35
76
|
}
|
|
@@ -47,6 +88,43 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
47
88
|
const readIndex = async (): Promise<CanvasIndex> =>
|
|
48
89
|
(await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
|
|
49
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The canvas for this head, or the closest one `accept` allows. Commits the head was built on
|
|
93
|
+
* come first, newest wins inside a relation, and a canvas off a discarded branch is the last
|
|
94
|
+
* resort.
|
|
95
|
+
*/
|
|
96
|
+
const rank = async (
|
|
97
|
+
currentHeadSha: string,
|
|
98
|
+
accept: (entry: CanvasEntry) => boolean
|
|
99
|
+
): Promise<CanvasLookup> => {
|
|
100
|
+
const index = await readIndex()
|
|
101
|
+
if (index.canvases[currentHeadSha] !== undefined) {
|
|
102
|
+
return { status: 'ready', headSha: currentHeadSha }
|
|
103
|
+
}
|
|
104
|
+
const candidates = Object.entries(index.canvases).filter(
|
|
105
|
+
([sha, entry]) => sha !== currentHeadSha && accept(entry)
|
|
106
|
+
)
|
|
107
|
+
const ranked: Array<{ generatedAt: string; found: StaleCanvas }> = []
|
|
108
|
+
for (const [sha, entry] of candidates) {
|
|
109
|
+
const found: StaleCanvas = (await git.isAncestor(sha, currentHeadSha))
|
|
110
|
+
? {
|
|
111
|
+
status: 'stale',
|
|
112
|
+
headSha: sha,
|
|
113
|
+
relation: 'ancestor',
|
|
114
|
+
commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
|
|
115
|
+
}
|
|
116
|
+
: { status: 'stale', headSha: sha, relation: 'unrelated' }
|
|
117
|
+
ranked.push({ generatedAt: entry.generatedAt, found })
|
|
118
|
+
}
|
|
119
|
+
ranked.sort((a, b) => {
|
|
120
|
+
if (a.found.relation !== b.found.relation) {
|
|
121
|
+
return a.found.relation === 'ancestor' ? -1 : 1
|
|
122
|
+
}
|
|
123
|
+
return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
|
|
124
|
+
})
|
|
125
|
+
return ranked[0]?.found ?? { status: 'missing' }
|
|
126
|
+
}
|
|
127
|
+
|
|
50
128
|
return {
|
|
51
129
|
root: repoRoot,
|
|
52
130
|
canvasDir,
|
|
@@ -54,12 +132,12 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
54
132
|
exists: async headSha => (await readText(path.join(canvasDir(headSha), 'review.json'))) !== null,
|
|
55
133
|
readArtifact: headSha => readJson(path.join(canvasDir(headSha), 'review.json'), ReviewArtifactSchema),
|
|
56
134
|
readManifest: headSha => readJson(path.join(canvasDir(headSha), 'manifest.json'), CanvasManifestSchema),
|
|
57
|
-
write: async (headSha, artifact, manifest, prNumber) => {
|
|
135
|
+
write: async (headSha, artifact, manifest, prNumber, flags) => {
|
|
58
136
|
const dir = canvasDir(headSha)
|
|
59
137
|
await writeJsonAtomic(path.join(dir, 'review.json'), artifact)
|
|
60
138
|
await writeJsonAtomic(path.join(dir, 'manifest.json'), manifest)
|
|
61
139
|
const index = await readIndex()
|
|
62
|
-
const entry:
|
|
140
|
+
const entry: CanvasEntry = {
|
|
63
141
|
generatedAt: artifact.generatedAt,
|
|
64
142
|
source: artifact.source,
|
|
65
143
|
}
|
|
@@ -70,57 +148,14 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
70
148
|
if (artifact.importedAt !== undefined) {
|
|
71
149
|
entry.importedAt = artifact.importedAt
|
|
72
150
|
}
|
|
151
|
+
if (flags?.worktree === true) {
|
|
152
|
+
entry.worktree = true
|
|
153
|
+
}
|
|
73
154
|
index.canvases[headSha] = entry
|
|
74
155
|
await writeJsonAtomic(indexFile, index)
|
|
75
156
|
},
|
|
76
|
-
findForPr:
|
|
77
|
-
|
|
78
|
-
if (index.canvases[currentHeadSha] !== undefined) {
|
|
79
|
-
return { status: 'ready', headSha: currentHeadSha }
|
|
80
|
-
}
|
|
81
|
-
// A canvas belongs to this PR when it was exported for it, or before the PR existed.
|
|
82
|
-
const candidates = Object.entries(index.canvases).filter(
|
|
83
|
-
([sha, entry]) =>
|
|
84
|
-
sha !== currentHeadSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
|
|
85
|
-
)
|
|
86
|
-
const ranked: Array<{
|
|
87
|
-
headSha: string
|
|
88
|
-
generatedAt: string
|
|
89
|
-
relation: CanvasRelation
|
|
90
|
-
commitsBehind?: number
|
|
91
|
-
}> = []
|
|
92
|
-
for (const [sha, entry] of candidates) {
|
|
93
|
-
if (await git.isAncestor(sha, currentHeadSha)) {
|
|
94
|
-
ranked.push({
|
|
95
|
-
headSha: sha,
|
|
96
|
-
generatedAt: entry.generatedAt,
|
|
97
|
-
relation: 'ancestor',
|
|
98
|
-
commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
|
|
99
|
-
})
|
|
100
|
-
} else {
|
|
101
|
-
ranked.push({ headSha: sha, generatedAt: entry.generatedAt, relation: 'unrelated' })
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// Commits the head was built on come first; a force-pushed-away canvas is the last resort.
|
|
105
|
-
ranked.sort((a, b) => {
|
|
106
|
-
if (a.relation !== b.relation) {
|
|
107
|
-
return a.relation === 'ancestor' ? -1 : 1
|
|
108
|
-
}
|
|
109
|
-
return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
|
|
110
|
-
})
|
|
111
|
-
const best = ranked[0]
|
|
112
|
-
if (best === undefined) {
|
|
113
|
-
return { status: 'missing' }
|
|
114
|
-
}
|
|
115
|
-
return best.commitsBehind === undefined
|
|
116
|
-
? { status: 'stale', headSha: best.headSha, relation: best.relation }
|
|
117
|
-
: {
|
|
118
|
-
status: 'stale',
|
|
119
|
-
headSha: best.headSha,
|
|
120
|
-
relation: best.relation,
|
|
121
|
-
commitsBehind: best.commitsBehind,
|
|
122
|
-
}
|
|
123
|
-
},
|
|
157
|
+
findForPr: (prNumber, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, prNumber)),
|
|
158
|
+
findForLocal: (key, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, key)),
|
|
124
159
|
attachPrNumber: async (headSha, prNumber) => {
|
|
125
160
|
const index = await readIndex()
|
|
126
161
|
const entry = index.canvases[headSha]
|
package/src/store/data-dir.ts
CHANGED
|
@@ -25,5 +25,6 @@ export async function ensureDataDir(dataDir: string): Promise<void> {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function repoDir(dataDir: string, repo: Repo): string {
|
|
28
|
-
|
|
28
|
+
const owner = repo.owner.replaceAll('/', '__')
|
|
29
|
+
return path.join(dataDir, 'repos', `${owner}__${repo.name}`)
|
|
29
30
|
}
|
|
@@ -24,6 +24,12 @@ export interface DerivedStore {
|
|
|
24
24
|
/** Reads `derived/` when it matches the commits, otherwise rebuilds it from local git. */
|
|
25
25
|
ensure(headSha: string, mergeBaseSha: string): Promise<Derived>
|
|
26
26
|
read(headSha: string): Promise<Derived | null>
|
|
27
|
+
/**
|
|
28
|
+
* The diffs built from the clone when the merge base is known and both commits are local, so a
|
|
29
|
+
* base that advanced under the head is seen; else the stored copy, which is all an imported
|
|
30
|
+
* canvas has.
|
|
31
|
+
*/
|
|
32
|
+
readOrBuild(headSha: string, mergeBaseSha: string | undefined): Promise<Derived | null>
|
|
27
33
|
/** Lines `from..to` (1-based, inclusive) of a materialized file, or null when not materialized. */
|
|
28
34
|
readLines(
|
|
29
35
|
headSha: string,
|
|
@@ -47,36 +53,44 @@ export function createDerivedStore(canvases: CanvasStore, git: Git, now: () => D
|
|
|
47
53
|
return { files, patches }
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
const derivable = async (headSha: string, mergeBaseSha: string): Promise<boolean> =>
|
|
57
|
+
(await git.commitExists(headSha)) && (await git.commitExists(mergeBaseSha))
|
|
58
|
+
|
|
59
|
+
const ensure = async (headSha: string, mergeBaseSha: string): Promise<Derived> => {
|
|
60
|
+
const dir = derivedDir(headSha)
|
|
61
|
+
const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
|
|
62
|
+
if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
|
|
63
|
+
const cached = await read(headSha)
|
|
64
|
+
if (cached !== null) {
|
|
65
|
+
return cached
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const collected = await collectDiffs(git, mergeBaseSha, headSha)
|
|
69
|
+
const files = collected.map(toFileEntry)
|
|
70
|
+
const patches = toPatchMap(collected)
|
|
71
|
+
await writeJsonAtomic(path.join(dir, 'files.json'), files)
|
|
72
|
+
await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
|
|
73
|
+
for (const f of collected) {
|
|
74
|
+
await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
|
|
75
|
+
}
|
|
76
|
+
await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
|
|
77
|
+
await writeJsonAtomic(path.join(dir, 'meta.json'), {
|
|
78
|
+
headSha,
|
|
79
|
+
mergeBaseSha,
|
|
80
|
+
builtAt: now().toISOString(),
|
|
81
|
+
})
|
|
82
|
+
return { files, patches }
|
|
83
|
+
}
|
|
84
|
+
|
|
50
85
|
return {
|
|
51
86
|
derivedDir,
|
|
52
|
-
derivable
|
|
53
|
-
|
|
54
|
-
ensure: async (headSha, mergeBaseSha) => {
|
|
55
|
-
const dir = derivedDir(headSha)
|
|
56
|
-
const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
|
|
57
|
-
if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
|
|
58
|
-
const cached = await read(headSha)
|
|
59
|
-
if (cached !== null) {
|
|
60
|
-
return cached
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const collected = await collectDiffs(git, mergeBaseSha, headSha)
|
|
64
|
-
const files = collected.map(toFileEntry)
|
|
65
|
-
const patches = toPatchMap(collected)
|
|
66
|
-
await writeJsonAtomic(path.join(dir, 'files.json'), files)
|
|
67
|
-
await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
|
|
68
|
-
for (const f of collected) {
|
|
69
|
-
await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
|
|
70
|
-
}
|
|
71
|
-
await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
|
|
72
|
-
await writeJsonAtomic(path.join(dir, 'meta.json'), {
|
|
73
|
-
headSha,
|
|
74
|
-
mergeBaseSha,
|
|
75
|
-
builtAt: now().toISOString(),
|
|
76
|
-
})
|
|
77
|
-
return { files, patches }
|
|
78
|
-
},
|
|
87
|
+
derivable,
|
|
88
|
+
ensure,
|
|
79
89
|
read,
|
|
90
|
+
readOrBuild: async (headSha, mergeBaseSha) =>
|
|
91
|
+
mergeBaseSha !== undefined && (await derivable(headSha, mergeBaseSha))
|
|
92
|
+
? ensure(headSha, mergeBaseSha)
|
|
93
|
+
: read(headSha),
|
|
80
94
|
readLines: async (headSha, side, filePath, from, to) => {
|
|
81
95
|
const root = path.join(derivedDir(headSha), side)
|
|
82
96
|
const full = path.resolve(root, filePath)
|
package/src/store/pr-store.ts
CHANGED
|
@@ -2,39 +2,48 @@ import { readdir, stat } from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { type CommentsPayload, CommentsPayloadSchema } from '../contract/comments.js'
|
|
4
4
|
import { type DiscoveryCache, DiscoveryCacheSchema } from '../contract/discovery.js'
|
|
5
|
+
import { type LocalPrepareTarget, LocalPrepareTargetSchema } from '../contract/generation-context.js'
|
|
6
|
+
import { isLocalKey, keyToString, type LocalKey, type ReviewKey } from '../contract/review-key.js'
|
|
5
7
|
import { type Pr, PrSchema } from '../contract/review-artifact.js'
|
|
6
8
|
import { readJson, readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
|
|
7
9
|
|
|
8
|
-
/**
|
|
10
|
+
/** Target-keyed files: the cached PR meta and the last comments payload. */
|
|
9
11
|
export interface PrStore {
|
|
10
|
-
prDir(
|
|
11
|
-
readPr(
|
|
12
|
-
|
|
12
|
+
prDir(key: ReviewKey): string
|
|
13
|
+
readPr(key: ReviewKey): Promise<Pr | null>
|
|
14
|
+
/** The key is passed in, because the local review's meta carries no number of its own. */
|
|
15
|
+
writePr(key: ReviewKey, pr: Pr): Promise<void>
|
|
13
16
|
readComments(number: number): Promise<CommentsPayload | null>
|
|
14
17
|
writeComments(number: number, comments: CommentsPayload): Promise<void>
|
|
15
18
|
/** The last attachment scan, so an unchanged PR is not scanned again. */
|
|
16
19
|
readDiscovery(number: number): Promise<DiscoveryCache | null>
|
|
17
20
|
writeDiscovery(number: number, discovery: DiscoveryCache): Promise<void>
|
|
18
|
-
/** PR numbers with a cached pr.json, newest first by file mtime. */
|
|
21
|
+
/** PR numbers with a cached pr.json, newest first by file mtime. Local reviews are not ones. */
|
|
19
22
|
listRecent(limit: number): Promise<Array<{ number: number; title: string; updatedAt: string }>>
|
|
23
|
+
/** What this local review was last prepared for, so the page can resolve the same head again. */
|
|
24
|
+
readLocalTarget(key: LocalKey): Promise<LocalPrepareTarget | null>
|
|
25
|
+
writeLocalTarget(key: LocalKey, target: LocalPrepareTarget): Promise<void>
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function createPrStore(repoRoot: string): PrStore {
|
|
23
|
-
const prDir = (
|
|
24
|
-
if (!Number.isInteger(
|
|
25
|
-
throw new Error(`not a pull request number: ${
|
|
29
|
+
const prDir = (key: ReviewKey): string => {
|
|
30
|
+
if (!isLocalKey(key) && (!Number.isInteger(key) || key <= 0)) {
|
|
31
|
+
throw new Error(`not a pull request number: ${String(key)}`)
|
|
26
32
|
}
|
|
27
|
-
return path.join(repoRoot, 'prs',
|
|
33
|
+
return path.join(repoRoot, 'prs', keyToString(key))
|
|
28
34
|
}
|
|
29
35
|
return {
|
|
30
36
|
prDir,
|
|
31
|
-
readPr:
|
|
32
|
-
writePr: async pr => {
|
|
33
|
-
if (pr.number
|
|
34
|
-
throw new Error(
|
|
37
|
+
readPr: key => readJson(path.join(prDir(key), 'pr.json'), PrSchema),
|
|
38
|
+
writePr: async (key, pr) => {
|
|
39
|
+
if (!isLocalKey(key) && pr.number !== key) {
|
|
40
|
+
throw new Error(`pull request ${String(key)} cannot hold the meta of ${String(pr.number)}`)
|
|
35
41
|
}
|
|
36
|
-
await writeJsonAtomic(path.join(prDir(
|
|
42
|
+
await writeJsonAtomic(path.join(prDir(key), 'pr.json'), pr)
|
|
37
43
|
},
|
|
44
|
+
readLocalTarget: key =>
|
|
45
|
+
readJsonOrDefault(path.join(prDir(key), 'target.json'), LocalPrepareTargetSchema, () => null),
|
|
46
|
+
writeLocalTarget: (key, target) => writeJsonAtomic(path.join(prDir(key), 'target.json'), target),
|
|
38
47
|
readComments: number => readJson(path.join(prDir(number), 'comments.json'), CommentsPayloadSchema),
|
|
39
48
|
writeComments: (number, comments) => writeJsonAtomic(path.join(prDir(number), 'comments.json'), comments),
|
|
40
49
|
readDiscovery: number =>
|