@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- 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 +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- 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 +64 -23
- 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 +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -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/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
|
@@ -3,15 +3,22 @@ import { Hono } from 'hono'
|
|
|
3
3
|
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
|
+
import { AddPendingInputSchema, EditPendingInputSchema, type PendingComment } from '../../contract/pending.js'
|
|
6
7
|
import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import type { PrState } from '../../contract/state.js'
|
|
9
|
+
import { checkInlineTarget } from '../../git/patch-lines.js'
|
|
10
|
+
import { PostReviewInputSchema } from '../../contract/reviews.js'
|
|
11
|
+
import { lookupCanvas, samePatches } from '../../review/carry-over.js'
|
|
12
|
+
import { marksForCanvas } from '../../review/carry-marks.js'
|
|
13
|
+
import { buildReviewBody, stateForCanvas, unreviewedLayers } from '../../review/review-body.js'
|
|
14
|
+
import { isLocalKey, keyLabel, type ReviewKey } from '../../contract/review-key.js'
|
|
15
|
+
import { canvasBelongsTo } from '../../store/canvas-store.js'
|
|
10
16
|
import { isReviewedId } from '../../store/state-store.js'
|
|
11
|
-
import type {
|
|
17
|
+
import type { Derived } from '../../store/derived-store.js'
|
|
18
|
+
import { LOCAL_CAPABILITIES, type PrLoader } from '../bundle.js'
|
|
12
19
|
import type { AppContext } from '../context.js'
|
|
13
20
|
import { AppError } from '../errors.js'
|
|
14
|
-
import {
|
|
21
|
+
import { parseTargetKey, requirePrNumber } from './api.js'
|
|
15
22
|
|
|
16
23
|
const ReviewedBodySchema = z.object({
|
|
17
24
|
reviewed: z.boolean(),
|
|
@@ -20,6 +27,14 @@ const ReviewedBodySchema = z.object({
|
|
|
20
27
|
.string()
|
|
21
28
|
.regex(/^[0-9a-f]{40}$/)
|
|
22
29
|
.optional(),
|
|
30
|
+
/**
|
|
31
|
+
* The commit of the canvas on the page, which the marks are keyed to. The head when absent. Any
|
|
32
|
+
* other value must be a canvas indexed for this pull request.
|
|
33
|
+
*/
|
|
34
|
+
canvasSha: z
|
|
35
|
+
.string()
|
|
36
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
37
|
+
.optional(),
|
|
23
38
|
})
|
|
24
39
|
const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
|
|
25
40
|
const HiddenBodySchema = z.object({ hidden: z.boolean() })
|
|
@@ -54,12 +69,34 @@ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: str
|
|
|
54
69
|
return parsed.data
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
/**
|
|
58
|
-
async function
|
|
72
|
+
/** Refuses a commit that is not an indexed canvas of this target. */
|
|
73
|
+
async function requireCanvasOf(ctx: AppContext, key: ReviewKey, canvasSha: string): Promise<void> {
|
|
74
|
+
const entry = (await ctx.canvases.readIndex()).canvases[canvasSha]
|
|
75
|
+
// The same rule the lookups use, so a mark can only be keyed to a canvas this target shows.
|
|
76
|
+
if (entry === undefined || !canvasBelongsTo(entry, key)) {
|
|
77
|
+
throw new AppError(
|
|
78
|
+
'CANVAS_NOT_FOUND',
|
|
79
|
+
`${canvasSha.slice(0, 7)} is not a canvas of ${keyLabel(key)}`,
|
|
80
|
+
404,
|
|
81
|
+
'reload the page'
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The canvas the reviewer is signing off on, with the marks made on it: the canvas written for the
|
|
88
|
+
* pull request's head, or for another commit with an identical diff.
|
|
89
|
+
*/
|
|
90
|
+
async function canvasForSignOff(
|
|
91
|
+
ctx: AppContext,
|
|
92
|
+
number: number,
|
|
93
|
+
pr: Pr
|
|
94
|
+
): Promise<{ artifact: ReviewArtifact; state: PrState }> {
|
|
95
|
+
const stored = await ctx.state.read(number)
|
|
59
96
|
if (ctx.fixtureArtifact !== null) {
|
|
60
|
-
return { ...ctx.fixtureArtifact, pr }
|
|
97
|
+
return { artifact: { ...ctx.fixtureArtifact, pr }, state: stateForCanvas(stored, pr.headSha) }
|
|
61
98
|
}
|
|
62
|
-
const found = await ctx
|
|
99
|
+
const found = await lookupCanvas(ctx, number, pr)
|
|
63
100
|
if (found.status !== 'ready') {
|
|
64
101
|
throw new AppError(
|
|
65
102
|
'SIGNOFF_INCOMPLETE',
|
|
@@ -72,7 +109,93 @@ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise
|
|
|
72
109
|
if (artifact === null) {
|
|
73
110
|
throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
|
|
74
111
|
}
|
|
75
|
-
return artifact
|
|
112
|
+
return { artifact, state: (await marksForCanvas(ctx, artifact, found.headSha, stored)).state }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The marks the state should start from when the next write keys it to `canvasSha`: none when the
|
|
117
|
+
* marks already describe that canvas, otherwise the ones that may follow it from its basis. The
|
|
118
|
+
* same rule the bundle showed the reviewer, recomputed here rather than taken from the page.
|
|
119
|
+
*/
|
|
120
|
+
async function adoptedMarks(
|
|
121
|
+
ctx: AppContext,
|
|
122
|
+
key: ReviewKey,
|
|
123
|
+
canvasSha: string
|
|
124
|
+
): Promise<Record<string, true> | undefined> {
|
|
125
|
+
const stored = await ctx.state.read(key)
|
|
126
|
+
if (stored.reviewedCanvasSha === canvasSha) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const artifact = await ctx.canvases.readArtifact(canvasSha)
|
|
130
|
+
if (artifact === null) {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
return (await marksForCanvas(ctx, artifact, canvasSha, stored)).state.reviewed
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The diff of this head as it is stored locally, which every inline target is checked against.
|
|
138
|
+
* Without it the line a comment names cannot be checked, so the comment is refused rather than
|
|
139
|
+
* sent to a coordinate nobody verified.
|
|
140
|
+
*/
|
|
141
|
+
async function requireDerived(ctx: AppContext, headSha: string): Promise<Derived> {
|
|
142
|
+
const derived = await ctx.derived.read(headSha)
|
|
143
|
+
if (derived === null) {
|
|
144
|
+
throw new AppError(
|
|
145
|
+
'NOT_FOUND',
|
|
146
|
+
'the diff of this head is not available locally, so the line cannot be checked',
|
|
147
|
+
404,
|
|
148
|
+
'fetch the PR head and reload'
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return derived
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Refuses a line the diff on screen does not show. */
|
|
155
|
+
function requireInlineTarget(diff: Derived, target: Parameters<typeof checkInlineTarget>[1]): void {
|
|
156
|
+
const problem = checkInlineTarget(diff.files, target)
|
|
157
|
+
if (problem !== null) {
|
|
158
|
+
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The `posted` entries for drafts that came from attention points, found in the comment list the
|
|
164
|
+
* forge created in this submission. Match the full range and body inside that receipt,
|
|
165
|
+
* never against historical comments. A failed comment read is reported by the host adapter.
|
|
166
|
+
*/
|
|
167
|
+
export function postedFromPending(
|
|
168
|
+
pending: ReadonlyArray<PendingComment>,
|
|
169
|
+
comments: ReadonlyArray<{
|
|
170
|
+
id: number
|
|
171
|
+
path: string
|
|
172
|
+
line: number | null
|
|
173
|
+
startLine?: number | undefined
|
|
174
|
+
side: string
|
|
175
|
+
body: string
|
|
176
|
+
}>
|
|
177
|
+
): Array<{ commentId: number; pointFingerprint: string }> {
|
|
178
|
+
const entries: Array<{ commentId: number; pointFingerprint: string }> = []
|
|
179
|
+
const used = new Set<number>()
|
|
180
|
+
for (const draft of pending) {
|
|
181
|
+
if (draft.pointFingerprint === undefined) {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const match = comments.find(
|
|
185
|
+
c =>
|
|
186
|
+
!used.has(c.id) &&
|
|
187
|
+
c.path === draft.path &&
|
|
188
|
+
c.line === draft.line &&
|
|
189
|
+
(c.startLine ?? c.line) === (draft.startLine ?? draft.line) &&
|
|
190
|
+
c.side === draft.side &&
|
|
191
|
+
c.body === draft.body
|
|
192
|
+
)
|
|
193
|
+
if (match !== undefined) {
|
|
194
|
+
used.add(match.id)
|
|
195
|
+
entries.push({ commentId: match.id, pointFingerprint: draft.pointFingerprint })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return entries
|
|
76
199
|
}
|
|
77
200
|
|
|
78
201
|
export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
@@ -84,25 +207,25 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
84
207
|
if (caps.canComment === false) {
|
|
85
208
|
throw new AppError(
|
|
86
209
|
'COMMENT_FORBIDDEN',
|
|
87
|
-
caps.reason ??
|
|
210
|
+
caps.reason ?? `this ${ctx.config.host.label} login cannot post on this repository`,
|
|
88
211
|
403,
|
|
89
212
|
caps.hint
|
|
90
213
|
)
|
|
91
214
|
}
|
|
92
215
|
}
|
|
93
216
|
|
|
94
|
-
const stateBody = (
|
|
95
|
-
prNumber:
|
|
217
|
+
const stateBody = (key: ReviewKey, state: StateResponse['state']): StateResponse => ({
|
|
218
|
+
prNumber: key,
|
|
96
219
|
state,
|
|
97
220
|
})
|
|
98
221
|
|
|
99
222
|
api.get('/prs/:n/state', async c => {
|
|
100
|
-
const
|
|
101
|
-
return c.json(stateBody(
|
|
223
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
224
|
+
return c.json(stateBody(key, await ctx.state.read(key)))
|
|
102
225
|
})
|
|
103
226
|
|
|
104
227
|
api.put('/prs/:n/reviewed/:id{.+}', async c => {
|
|
105
|
-
const
|
|
228
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
106
229
|
const id = c.req.param('id')
|
|
107
230
|
if (!isReviewedId(id)) {
|
|
108
231
|
throw new AppError(
|
|
@@ -113,21 +236,34 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
113
236
|
)
|
|
114
237
|
}
|
|
115
238
|
const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
239
|
+
// A mark is made against the canvas on screen, which for a local review is the snapshot the
|
|
240
|
+
// page was drawn from, not whatever the working tree holds a keystroke later.
|
|
241
|
+
const pr = await loader.currentTarget(key)
|
|
242
|
+
if (!isLocalKey(key)) {
|
|
243
|
+
requireSameHead(body.headSha, pr.headSha)
|
|
244
|
+
}
|
|
245
|
+
// The page sends back the canvas commit the bundle keyed its marks to, so a toggle costs no
|
|
246
|
+
// git work; the index, not git, says the commit is a canvas of this target.
|
|
247
|
+
const canvasSha = body.canvasSha ?? pr.headSha
|
|
248
|
+
if (canvasSha !== pr.headSha) {
|
|
249
|
+
await requireCanvasOf(ctx, key, canvasSha)
|
|
250
|
+
}
|
|
251
|
+
// The first mark on a canvas adopts the marks that followed it from the canvas it descends
|
|
252
|
+
// from, so the page's carried ticks survive the write instead of being replaced.
|
|
253
|
+
const carried = await adoptedMarks(ctx, key, canvasSha)
|
|
254
|
+
return c.json(stateBody(key, await ctx.state.setReviewed(key, id, body.reviewed, { canvasSha, carried })))
|
|
119
255
|
})
|
|
120
256
|
|
|
121
257
|
api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
|
|
122
|
-
const
|
|
258
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
123
259
|
const fingerprint = c.req.param('fingerprint')
|
|
124
260
|
const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
|
|
125
|
-
const next = await ctx.state.setDismissed(
|
|
126
|
-
return c.json(stateBody(
|
|
261
|
+
const next = await ctx.state.setDismissed(key, fingerprint, body.dismissed, body.reason)
|
|
262
|
+
return c.json(stateBody(key, next))
|
|
127
263
|
})
|
|
128
264
|
|
|
129
265
|
api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
|
|
130
|
-
const number =
|
|
266
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'hiding a comment thread')
|
|
131
267
|
const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
|
|
132
268
|
if (!rootId.success) {
|
|
133
269
|
throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
|
|
@@ -137,33 +273,26 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
137
273
|
})
|
|
138
274
|
|
|
139
275
|
api.get('/prs/:n/capabilities', async c => {
|
|
140
|
-
|
|
276
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
277
|
+
if (isLocalKey(key)) {
|
|
278
|
+
return c.json(LOCAL_CAPABILITIES)
|
|
279
|
+
}
|
|
141
280
|
return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
|
|
142
281
|
})
|
|
143
282
|
|
|
144
283
|
api.post('/prs/:n/comments', async c => {
|
|
145
|
-
const number =
|
|
284
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'posting a comment')
|
|
146
285
|
const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
|
|
147
286
|
await requirePosting()
|
|
148
287
|
const pr = await loader.currentPr(number)
|
|
149
288
|
requireSameHead(input.headSha, pr.headSha)
|
|
289
|
+
let diff: Derived = { files: [], patches: {} }
|
|
150
290
|
if (input.kind === 'inline') {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw new AppError(
|
|
154
|
-
'NOT_FOUND',
|
|
155
|
-
'the diff of this head is not available locally, so the line cannot be checked',
|
|
156
|
-
404,
|
|
157
|
-
'fetch the PR head and reload'
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
const problem = checkInlineTarget(derived.files, input)
|
|
161
|
-
if (problem !== null) {
|
|
162
|
-
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
163
|
-
}
|
|
291
|
+
diff = await requireDerived(ctx, pr.headSha)
|
|
292
|
+
requireInlineTarget(diff, input)
|
|
164
293
|
}
|
|
165
|
-
const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
|
|
166
|
-
await
|
|
294
|
+
const posted = await ctx.config.host.postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input, diff)
|
|
295
|
+
await appendComments(ctx, number, [posted])
|
|
167
296
|
const entry =
|
|
168
297
|
input.kind === 'inline' && input.pointFingerprint !== undefined
|
|
169
298
|
? { commentId: posted.comment.id, pointFingerprint: input.pointFingerprint }
|
|
@@ -172,28 +301,63 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
172
301
|
return c.json({ ...posted, state }, 201)
|
|
173
302
|
})
|
|
174
303
|
|
|
304
|
+
// The pending review: comments the reviewer wrote and has not submitted. They are kept here,
|
|
305
|
+
// never on the forge, until a review carries them out in one go.
|
|
306
|
+
api.post('/prs/:n/pending', async c => {
|
|
307
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
308
|
+
const input = await readBody(c.req.raw, AddPendingInputSchema, 'a comment on a line of the diff')
|
|
309
|
+
const pr = await loader.currentPr(number)
|
|
310
|
+
requireSameHead(input.headSha, pr.headSha)
|
|
311
|
+
// A draft is checked against the diff when it is written, so a line that cannot take a comment
|
|
312
|
+
// is refused while the reviewer is still looking at it, not when the review is submitted.
|
|
313
|
+
requireInlineTarget(await requireDerived(ctx, pr.headSha), input)
|
|
314
|
+
const state = await ctx.state.addPending(number, input, pr.headSha)
|
|
315
|
+
return c.json(stateBody(number, state), 201)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
api.patch('/prs/:n/pending/:id', async c => {
|
|
319
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
320
|
+
const id = c.req.param('id')
|
|
321
|
+
const { body } = await readBody(c.req.raw, EditPendingInputSchema, '{ "body": "…" }')
|
|
322
|
+
const current = await ctx.state.read(number)
|
|
323
|
+
if (!current.pending.some(p => p.id === id)) {
|
|
324
|
+
throw new AppError('NOT_FOUND', `no pending comment ${id}`, 404, 'reload the page')
|
|
325
|
+
}
|
|
326
|
+
return c.json(stateBody(number, await ctx.state.editPending(number, id, body)))
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
api.delete('/prs/:n/pending/:id', async c => {
|
|
330
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review comment')
|
|
331
|
+
return c.json(stateBody(number, await ctx.state.removePending(number, c.req.param('id'))))
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/** Discards the whole pending review. Nothing was on the forge, so nothing is withdrawn. */
|
|
335
|
+
api.delete('/prs/:n/pending', async c => {
|
|
336
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'a pending review')
|
|
337
|
+
return c.json(stateBody(number, await ctx.state.clearPending(number)))
|
|
338
|
+
})
|
|
339
|
+
|
|
175
340
|
api.get('/prs/:n/review/body', async c => {
|
|
176
|
-
const number =
|
|
341
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'the sign-off summary')
|
|
177
342
|
const pr = await loader.currentPr(number)
|
|
178
|
-
const artifact = await
|
|
179
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
343
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
180
344
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
181
345
|
const body: ReviewBodyResponse = {
|
|
182
346
|
headSha: pr.headSha,
|
|
183
347
|
body: buildReviewBody({ artifact, state, comments, headSha: pr.headSha }),
|
|
184
348
|
unreviewed: unreviewedLayers(artifact, state).map(l => l.title),
|
|
349
|
+
pending: state.pending.length,
|
|
185
350
|
}
|
|
186
351
|
return c.json(body)
|
|
187
352
|
})
|
|
188
353
|
|
|
189
354
|
api.post('/prs/:n/review', async c => {
|
|
190
|
-
const number =
|
|
355
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'submitting a review')
|
|
191
356
|
const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
|
|
192
357
|
await requirePosting()
|
|
193
358
|
const pr = await loader.currentPr(number)
|
|
194
359
|
requireSameHead(input.headSha, pr.headSha)
|
|
195
|
-
const artifact = await
|
|
196
|
-
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
360
|
+
const { artifact, state } = await canvasForSignOff(ctx, number, pr)
|
|
197
361
|
if (input.event === 'APPROVE') {
|
|
198
362
|
const missing = unreviewedLayers(artifact, state)
|
|
199
363
|
if (missing.length > 0) {
|
|
@@ -207,8 +371,54 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
207
371
|
}
|
|
208
372
|
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
209
373
|
const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
|
|
210
|
-
|
|
211
|
-
|
|
374
|
+
// The drafts read here are the ones that go out. A draft written after this read stays in the
|
|
375
|
+
// pending review instead of being dropped by the clear below.
|
|
376
|
+
const pending = input.includePending ? (await ctx.state.read(number)).pending : []
|
|
377
|
+
// GitLab needs the diff to position its draft notes.
|
|
378
|
+
const diff = pending.length === 0 ? { files: [], patches: {} } : await requireDerived(ctx, pr.headSha)
|
|
379
|
+
for (const head of new Set(pending.map(p => p.headSha))) {
|
|
380
|
+
if (head === pr.headSha) continue
|
|
381
|
+
const original = await ctx.derived.read(head)
|
|
382
|
+
if (
|
|
383
|
+
!ctx.projectConfig.config.canvas.keepForIdenticalDiff ||
|
|
384
|
+
original === null ||
|
|
385
|
+
!samePatches(original, diff)
|
|
386
|
+
) {
|
|
387
|
+
throw new AppError(
|
|
388
|
+
'CANVAS_STALE',
|
|
389
|
+
'pending comments were written on a different diff',
|
|
390
|
+
409,
|
|
391
|
+
'copy or delete the earlier-commit drafts in the pending review, then comment on the current code'
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const {
|
|
396
|
+
comments: submittedComments,
|
|
397
|
+
warnings,
|
|
398
|
+
...review
|
|
399
|
+
} = await ctx.config.host.postReview(
|
|
400
|
+
ctx.gh,
|
|
401
|
+
ctx.config.repo,
|
|
402
|
+
number,
|
|
403
|
+
pr.headSha,
|
|
404
|
+
{ event: input.event, body, comments: pending },
|
|
405
|
+
diff
|
|
406
|
+
)
|
|
407
|
+
const next =
|
|
408
|
+
pending.length === 0
|
|
409
|
+
? await ctx.state.read(number)
|
|
410
|
+
: await ctx.state.completePending(number, pending, postedFromPending(pending, submittedComments))
|
|
411
|
+
if (submittedComments.length > 0) {
|
|
412
|
+
await appendComments(
|
|
413
|
+
ctx,
|
|
414
|
+
number,
|
|
415
|
+
submittedComments.map(comment => ({ kind: 'review', comment }))
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
return c.json(
|
|
419
|
+
{ review, submitted: pending.length, state: next, comments: submittedComments, warnings },
|
|
420
|
+
201
|
|
421
|
+
)
|
|
212
422
|
})
|
|
213
423
|
|
|
214
424
|
return api
|
|
@@ -217,8 +427,12 @@ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
|
217
427
|
/** One append at a time per PR, so two posts that land together do not overwrite each other. */
|
|
218
428
|
const appendChains = new Map<number, Promise<unknown>>()
|
|
219
429
|
|
|
220
|
-
function
|
|
221
|
-
|
|
430
|
+
function appendComments(
|
|
431
|
+
ctx: AppContext,
|
|
432
|
+
number: number,
|
|
433
|
+
posted: ReadonlyArray<PostCommentResult>
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
const run = () => writeAppendedComments(ctx, number, posted)
|
|
222
436
|
const chained = (appendChains.get(number) ?? Promise.resolve()).then(run, run)
|
|
223
437
|
appendChains.set(
|
|
224
438
|
number,
|
|
@@ -231,30 +445,27 @@ function appendComment(ctx: AppContext, number: number, posted: PostCommentResul
|
|
|
231
445
|
* Keeps the cached comments in step with what was just posted, so a reload shows it once. With
|
|
232
446
|
* nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
|
|
233
447
|
*/
|
|
234
|
-
async function
|
|
448
|
+
async function writeAppendedComments(
|
|
235
449
|
ctx: AppContext,
|
|
236
450
|
number: number,
|
|
237
|
-
posted: PostCommentResult
|
|
451
|
+
posted: ReadonlyArray<PostCommentResult>
|
|
238
452
|
): Promise<void> {
|
|
239
453
|
const comments = await ctx.prs.readComments(number)
|
|
240
454
|
if (comments === null) {
|
|
241
455
|
return
|
|
242
456
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
})
|
|
251
|
-
return
|
|
457
|
+
const reviewComments = [...comments.reviewComments]
|
|
458
|
+
const issueComments = [...comments.issueComments]
|
|
459
|
+
for (const entry of posted) {
|
|
460
|
+
const list = entry.kind === 'review' ? reviewComments : issueComments
|
|
461
|
+
if (list.some(c => c.id === entry.comment.id)) continue
|
|
462
|
+
if (entry.kind === 'review') reviewComments.push(entry.comment)
|
|
463
|
+
else issueComments.push(entry.comment)
|
|
252
464
|
}
|
|
253
|
-
if (
|
|
254
|
-
|
|
465
|
+
if (
|
|
466
|
+
reviewComments.length !== comments.reviewComments.length ||
|
|
467
|
+
issueComments.length !== comments.issueComments.length
|
|
468
|
+
) {
|
|
469
|
+
await ctx.prs.writeComments(number, { ...comments, reviewComments, issueComments })
|
|
255
470
|
}
|
|
256
|
-
await ctx.prs.writeComments(number, {
|
|
257
|
-
...comments,
|
|
258
|
-
issueComments: [...comments.issueComments, posted.comment],
|
|
259
|
-
})
|
|
260
471
|
}
|