@vintasoftware/pr-review-canvas 0.4.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 +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -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 +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- 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/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -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 +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- 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/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
|
|
2
3
|
|
|
3
4
|
/** Character caps applied to model output. Numbers, so the prompt can print them. */
|
|
4
5
|
export const TEXT_CAPS = {
|
|
@@ -79,6 +80,33 @@ export const HARNESSES = ['claude-code', 'codex', 'other'] as const
|
|
|
79
80
|
export const SideSchema = z.enum(['new', 'old'])
|
|
80
81
|
export type Side = z.infer<typeof SideSchema>
|
|
81
82
|
|
|
83
|
+
export {
|
|
84
|
+
contains,
|
|
85
|
+
coveredRows,
|
|
86
|
+
fileRows,
|
|
87
|
+
foldLevelRank,
|
|
88
|
+
hiddenLines,
|
|
89
|
+
UNDISCUSSED,
|
|
90
|
+
} from '../../static/js/fold-levels.js'
|
|
91
|
+
export { FOLD_LEVELS }
|
|
92
|
+
export const FoldLevelSchema = z.enum(FOLD_LEVELS)
|
|
93
|
+
export type FoldLevel = z.infer<typeof FoldLevelSchema>
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How a stored canvas names its levels. One written before levels existed says `collapsed: true`
|
|
97
|
+
* and gives its folds no level; both read as light, so it hides exactly what it hid before. Past
|
|
98
|
+
* this parse every canvas names a level wherever it hides something.
|
|
99
|
+
*/
|
|
100
|
+
const STORED_LEVELS = {
|
|
101
|
+
collapsed: z
|
|
102
|
+
.union([z.boolean(), FoldLevelSchema])
|
|
103
|
+
.transform(value => (value === true ? DEFAULT_FOLD_LEVEL : value === false ? undefined : value)),
|
|
104
|
+
fold: FoldLevelSchema.default(DEFAULT_FOLD_LEVEL),
|
|
105
|
+
}
|
|
106
|
+
/** The model names a level for every fold and every collapsed file. */
|
|
107
|
+
const MODEL_LEVELS = { collapsed: FoldLevelSchema, fold: FoldLevelSchema }
|
|
108
|
+
type Levels = typeof STORED_LEVELS | typeof MODEL_LEVELS
|
|
109
|
+
|
|
82
110
|
export const HunkSchema = z.object({
|
|
83
111
|
id: z.string().min(1),
|
|
84
112
|
header: z.string(),
|
|
@@ -199,29 +227,35 @@ export function annotationSchema(caps: Caps) {
|
|
|
199
227
|
export const AnnotationSchema = annotationSchema(ARTIFACT_HARD_CAPS)
|
|
200
228
|
export type Annotation = z.infer<typeof AnnotationSchema>
|
|
201
229
|
|
|
202
|
-
function codeFoldSchema(caps: Caps) {
|
|
230
|
+
function codeFoldSchema(caps: Caps, level: Levels['fold']) {
|
|
203
231
|
return z.object({
|
|
204
232
|
title: z.string().max(caps.pointTitle).regex(/\S/, 'A fold title must contain visible text'),
|
|
205
233
|
side: SideSchema,
|
|
206
234
|
startLine: z.number().int().positive(),
|
|
207
235
|
endLine: z.number().int().positive(),
|
|
236
|
+
/** The lowest reading level that hides this range. */
|
|
237
|
+
level,
|
|
208
238
|
})
|
|
209
239
|
}
|
|
210
|
-
export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS)
|
|
240
|
+
export const CodeFoldSchema = codeFoldSchema(ARTIFACT_HARD_CAPS, STORED_LEVELS.fold)
|
|
211
241
|
export type CodeFold = z.infer<typeof CodeFoldSchema>
|
|
212
242
|
|
|
213
|
-
function layerFileBase(caps: Caps, foldTitleCap
|
|
243
|
+
function layerFileBase<L extends Levels>(caps: Caps, foldTitleCap: number, levels: L) {
|
|
214
244
|
return {
|
|
215
245
|
path: z.string().min(1),
|
|
216
246
|
hunks: z.array(z.string().min(1)).min(1),
|
|
217
247
|
note: textOrEmpty(caps, 'annotation').optional(),
|
|
218
248
|
annotations: z.array(annotationSchema(caps)),
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
/** The lowest reading level that hides the whole file body. */
|
|
250
|
+
collapsed: levels.collapsed.optional(),
|
|
251
|
+
folds: z.array(codeFoldSchema({ ...caps, pointTitle: foldTitleCap }, levels.fold)).optional(),
|
|
221
252
|
}
|
|
222
253
|
}
|
|
223
254
|
|
|
224
|
-
export const LayerFileSchema = z.object({
|
|
255
|
+
export const LayerFileSchema = z.object({
|
|
256
|
+
...layerFileBase(ARTIFACT_HARD_CAPS, ARTIFACT_HARD_CAPS.pointTitle, STORED_LEVELS),
|
|
257
|
+
isTest: z.boolean(),
|
|
258
|
+
})
|
|
225
259
|
export type LayerFile = z.infer<typeof LayerFileSchema>
|
|
226
260
|
|
|
227
261
|
export const LayerKeySchema = z
|
|
@@ -298,6 +332,15 @@ export const ReviewArtifactSchema = z.object({
|
|
|
298
332
|
generator: GeneratorSchema,
|
|
299
333
|
source: z.enum(['local', 'import']),
|
|
300
334
|
importedAt: z.string().optional(),
|
|
335
|
+
/**
|
|
336
|
+
* The basis canvas this one was generated from, when the run was incremental. It records where
|
|
337
|
+
* the carried content came from; a reviewer's server recomputes for itself which marks may
|
|
338
|
+
* follow, so this sha is a pointer and never a claim about what was reviewed.
|
|
339
|
+
*/
|
|
340
|
+
basisCanvasSha: z
|
|
341
|
+
.string()
|
|
342
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
343
|
+
.optional(),
|
|
301
344
|
})
|
|
302
345
|
export type ReviewArtifact = z.infer<typeof ReviewArtifactSchema>
|
|
303
346
|
|
|
@@ -317,7 +360,7 @@ export function modelOutputSchema(textCaps: TextCaps) {
|
|
|
317
360
|
z.object({
|
|
318
361
|
...layerBase(caps),
|
|
319
362
|
risk: z.array(z.object({ label: z.string().min(1), reason: z.string().min(1) })).optional(),
|
|
320
|
-
files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle))),
|
|
363
|
+
files: z.array(z.object(layerFileBase(caps, textCaps.pointTitle, MODEL_LEVELS))),
|
|
321
364
|
})
|
|
322
365
|
)
|
|
323
366
|
.min(1),
|
package/src/contract/reviews.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `COMMENT` leaves a review with no verdict, the way a thread-level review does: the body and the
|
|
5
|
+
* comments land, and nothing is approved or rejected.
|
|
6
|
+
*/
|
|
7
|
+
export const REVIEW_EVENTS = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'] as const
|
|
4
8
|
export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
|
|
5
9
|
export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
|
|
6
10
|
|
|
@@ -8,6 +12,11 @@ export const PostReviewInputSchema = z.object({
|
|
|
8
12
|
event: ReviewEventSchema,
|
|
9
13
|
/** The dialog sends the body the user read, edited or not. */
|
|
10
14
|
body: z.string().min(1).max(65536).optional(),
|
|
15
|
+
/**
|
|
16
|
+
* Whether the pending comments go out with this review. True unless the page says otherwise, so
|
|
17
|
+
* a reviewer who wrote drafts never submits a review that silently leaves them behind.
|
|
18
|
+
*/
|
|
19
|
+
includePending: z.boolean().default(true),
|
|
11
20
|
/** The commit the dialog named. The server refuses the review when the head moved on. */
|
|
12
21
|
headSha: z
|
|
13
22
|
.string()
|
package/src/contract/settings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
|
|
2
3
|
import { DEFAULT_SKIN, isSkin, SKINS, type Skin } from '../../static/js/skin.js'
|
|
3
4
|
import { DEFAULT_THEME, isTheme, THEMES, type Theme } from '../../static/js/theme.js'
|
|
4
5
|
|
|
@@ -24,6 +25,8 @@ export const SettingsSchema = z.object({
|
|
|
24
25
|
version: z.literal(1),
|
|
25
26
|
skin: z.enum(SKINS),
|
|
26
27
|
theme: z.enum(THEMES),
|
|
28
|
+
/** The reading level every review opens at; the control on the page changes it for one page. */
|
|
29
|
+
foldLevel: z.enum(FOLD_LEVELS),
|
|
27
30
|
agent: z.enum(CHAT_AGENTS),
|
|
28
31
|
model: z.string().min(1).nullable(),
|
|
29
32
|
chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC),
|
|
@@ -35,6 +38,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|
|
35
38
|
version: 1,
|
|
36
39
|
skin: DEFAULT_SKIN,
|
|
37
40
|
theme: DEFAULT_THEME,
|
|
41
|
+
foldLevel: DEFAULT_FOLD_LEVEL,
|
|
38
42
|
agent: 'claude',
|
|
39
43
|
model: null,
|
|
40
44
|
chatTimeoutSec: 600,
|
|
@@ -45,6 +49,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|
|
45
49
|
export const SettingsInputSchema = z.object({
|
|
46
50
|
skin: z.enum(SKINS).optional(),
|
|
47
51
|
theme: z.enum(THEMES).optional(),
|
|
52
|
+
foldLevel: z.enum(FOLD_LEVELS).optional(),
|
|
48
53
|
agent: z.enum(CHAT_AGENTS).optional(),
|
|
49
54
|
model: z.string().max(200).nullable().optional(),
|
|
50
55
|
chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC).optional(),
|
package/src/contract/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { PendingCommentSchema } from './pending.js'
|
|
2
3
|
|
|
3
4
|
export const ChatThreadSchema = z.object({
|
|
4
5
|
name: z.string(),
|
|
@@ -12,25 +13,27 @@ export const ChatThreadSchema = z.object({
|
|
|
12
13
|
export type ChatThread = z.infer<typeof ChatThreadSchema>
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Version 1 keyed a reviewed mark by the layer's position in the canvas (`layer:layer-2`). Marks
|
|
17
|
+
* are keyed by the layer's own key now, so a regenerated canvas that reorders its layers keeps
|
|
18
|
+
* them straight, and the old positional marks cannot be translated without the canvas they were
|
|
19
|
+
* made on. They are dropped; the rest of the file (dismissals, posted comments, chat threads)
|
|
20
|
+
* carries over untouched.
|
|
18
21
|
*/
|
|
19
|
-
const
|
|
22
|
+
const dropPositionalMarks = (raw: unknown): unknown => {
|
|
20
23
|
if (raw === null || typeof raw !== 'object') {
|
|
21
24
|
return raw
|
|
22
25
|
}
|
|
23
|
-
const { reviewedHeadSha, ...rest } = raw as Record<string, unknown>
|
|
24
|
-
if (
|
|
25
|
-
return
|
|
26
|
+
const { reviewedHeadSha: _reviewedHeadSha, ...rest } = raw as Record<string, unknown>
|
|
27
|
+
if (rest['version'] !== 1) {
|
|
28
|
+
return rest
|
|
26
29
|
}
|
|
27
|
-
return { ...rest, reviewedCanvasSha:
|
|
30
|
+
return { ...rest, version: 2, reviewed: {}, reviewedCanvasSha: undefined }
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export const PrStateSchema = z.preprocess(
|
|
31
|
-
|
|
34
|
+
dropPositionalMarks,
|
|
32
35
|
z.object({
|
|
33
|
-
version: z.literal(
|
|
36
|
+
version: z.literal(2),
|
|
34
37
|
/**
|
|
35
38
|
* Counts the writes to this file. The page uses it to tell a newer answer from an older one
|
|
36
39
|
* when two of its requests overlap. Absent in state files of older tool versions.
|
|
@@ -40,7 +43,8 @@ export const PrStateSchema = z.preprocess(
|
|
|
40
43
|
/**
|
|
41
44
|
* The commit of the canvas the reviewed marks were made on: the head, or the commit a carried-over
|
|
42
45
|
* canvas was generated for. A canvas for another commit describes other code, so the marks start
|
|
43
|
-
* again with it
|
|
46
|
+
* again with it, unless that canvas names this one as its basis and the code behind a mark is
|
|
47
|
+
* untouched. Absent in state files of older tool versions.
|
|
44
48
|
*/
|
|
45
49
|
reviewedCanvasSha: z.string().optional(),
|
|
46
50
|
hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
|
|
@@ -48,6 +52,12 @@ export const PrStateSchema = z.preprocess(
|
|
|
48
52
|
z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
|
|
49
53
|
),
|
|
50
54
|
dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
|
|
55
|
+
/**
|
|
56
|
+
* The comments of the review being written, in the order they were added. They live here and
|
|
57
|
+
* nowhere else until the review is submitted, so a reload does not lose a draft. A state file
|
|
58
|
+
* of an older tool version has none, which reads as an empty list.
|
|
59
|
+
*/
|
|
60
|
+
pending: z.array(PendingCommentSchema).default([]),
|
|
51
61
|
chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
|
|
52
62
|
updatedAt: z.string(),
|
|
53
63
|
})
|
|
@@ -56,12 +66,13 @@ export type PrState = z.infer<typeof PrStateSchema>
|
|
|
56
66
|
|
|
57
67
|
export function emptyState(updatedAt: string): PrState {
|
|
58
68
|
return {
|
|
59
|
-
version:
|
|
69
|
+
version: 2,
|
|
60
70
|
rev: 0,
|
|
61
71
|
reviewed: {},
|
|
62
72
|
hiddenThreads: {},
|
|
63
73
|
posted: [],
|
|
64
74
|
dismissed: {},
|
|
75
|
+
pending: [],
|
|
65
76
|
chat: { threads: [] },
|
|
66
77
|
updatedAt,
|
|
67
78
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
2
3
|
import type { ReviewEvent } from '../contract/reviews.js'
|
|
3
|
-
import type {
|
|
4
|
+
import type { PostedReview } from '../host/host.js'
|
|
4
5
|
import type { Repo } from '../contract/review-artifact.js'
|
|
5
|
-
import type
|
|
6
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
7
|
+
import { mapReviewComment } from './comments.js'
|
|
8
|
+
import { ghSide } from './post-comment.js'
|
|
6
9
|
|
|
7
10
|
const GhReviewSchema = z.object({
|
|
8
11
|
id: z.number().int(),
|
|
@@ -11,19 +14,72 @@ const GhReviewSchema = z.object({
|
|
|
11
14
|
submitted_at: z.string().nullable().optional(),
|
|
12
15
|
})
|
|
13
16
|
|
|
14
|
-
/**
|
|
17
|
+
/** One entry of the `comments` array GitHub reads when a review is created with its comments. */
|
|
18
|
+
interface GhReviewComment {
|
|
19
|
+
path: string
|
|
20
|
+
body: string
|
|
21
|
+
line: number
|
|
22
|
+
side: 'LEFT' | 'RIGHT'
|
|
23
|
+
start_line?: number
|
|
24
|
+
start_side?: 'LEFT' | 'RIGHT'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The pending comments as GitHub's review payload names them. A range is sent only when it covers
|
|
29
|
+
* more than the anchor line, because GitHub rejects a start_line equal to line.
|
|
30
|
+
*/
|
|
31
|
+
export function reviewComments(pending: ReadonlyArray<PendingComment>): GhReviewComment[] {
|
|
32
|
+
return pending.map(p => ({
|
|
33
|
+
path: p.path,
|
|
34
|
+
body: p.body,
|
|
35
|
+
line: p.line,
|
|
36
|
+
side: ghSide(p.side),
|
|
37
|
+
...(p.startLine !== undefined && p.startLine !== p.line
|
|
38
|
+
? { start_line: p.startLine, start_side: ghSide(p.side) }
|
|
39
|
+
: {}),
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Posts the review. GitHub decides what the event means; the tool only fills the body, the sha,
|
|
45
|
+
* and the comments the reviewer had waiting, which land as part of the same review.
|
|
46
|
+
*/
|
|
15
47
|
export async function postReview(
|
|
16
48
|
gh: HostClient,
|
|
17
49
|
repo: Repo,
|
|
18
50
|
number: number,
|
|
19
51
|
headSha: string,
|
|
20
|
-
input: { event: ReviewEvent; body: string }
|
|
21
|
-
): Promise<
|
|
52
|
+
input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> }
|
|
53
|
+
): Promise<PostedReview> {
|
|
54
|
+
const comments = input.comments ?? []
|
|
22
55
|
const raw = await gh.post(`repos/${repo.owner}/${repo.name}/pulls/${number}/reviews`, {
|
|
23
56
|
event: input.event,
|
|
24
57
|
body: input.body,
|
|
25
58
|
commit_id: headSha,
|
|
59
|
+
...(comments.length === 0 ? {} : { comments: reviewComments(comments) }),
|
|
26
60
|
})
|
|
27
61
|
const r = GhReviewSchema.parse(raw)
|
|
28
|
-
|
|
62
|
+
const posted: PostedReview = {
|
|
63
|
+
id: r.id,
|
|
64
|
+
state: r.state,
|
|
65
|
+
url: r.html_url,
|
|
66
|
+
submittedAt: r.submitted_at ?? null,
|
|
67
|
+
comments: [],
|
|
68
|
+
warnings: [],
|
|
69
|
+
}
|
|
70
|
+
if (comments.length > 0) {
|
|
71
|
+
try {
|
|
72
|
+
const rawComments = await fetchAllPages(
|
|
73
|
+
gh,
|
|
74
|
+
`repos/${repo.owner}/${repo.name}/pulls/${number}/reviews/${r.id}/comments`
|
|
75
|
+
)
|
|
76
|
+
posted.comments = rawComments.map(c => mapReviewComment(c, new Set()))
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// The write succeeded. A failed read must not invite submitting the same review again.
|
|
79
|
+
posted.warnings.push(
|
|
80
|
+
`Review posted, but its comments could not be loaded: ${err instanceof Error ? err.message : String(err)}. Refresh to see them.`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return posted
|
|
29
85
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type {
|
|
2
|
+
import type { PostedReview } from '../host/host.js'
|
|
3
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
3
4
|
import type { Repo } from '../contract/review-artifact.js'
|
|
4
5
|
import type { ReviewEvent } from '../contract/reviews.js'
|
|
5
6
|
import type { HostClient } from '../host/client.js'
|
|
7
|
+
import type { Derived } from '../store/derived-store.js'
|
|
8
|
+
import { publishGitlabDrafts } from './publish-drafts.js'
|
|
6
9
|
import { gitlabMrUrl, gitlabNoteUrl, gitlabProjectApi } from './project.js'
|
|
7
10
|
|
|
8
11
|
const GlNoteSchema = z.object({
|
|
@@ -17,37 +20,73 @@ const GlMrSchema = z.object({
|
|
|
17
20
|
})
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
|
-
* GitLab has no GitHub-style review event. Approve calls the approvals API; request-changes
|
|
21
|
-
* the review body as an MR note so the text still lands on the merge
|
|
23
|
+
* GitLab has no GitHub-style review event. Approve calls the approvals API; request-changes and a
|
|
24
|
+
* comment-only review post the review body as an MR note so the text still lands on the merge
|
|
25
|
+
* request. Pending comments and the summary publish together through the Draft Notes API.
|
|
22
26
|
*/
|
|
23
27
|
export async function postGitlabReview(
|
|
24
28
|
client: HostClient,
|
|
25
29
|
repo: Repo,
|
|
26
30
|
number: number,
|
|
27
31
|
headSha: string,
|
|
28
|
-
input: { event: ReviewEvent; body: string },
|
|
29
|
-
webBase: string
|
|
30
|
-
|
|
32
|
+
input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
|
|
33
|
+
webBase: string,
|
|
34
|
+
diff: Derived = { files: [], patches: {} }
|
|
35
|
+
): Promise<PostedReview> {
|
|
31
36
|
const base = `${gitlabProjectApi(repo)}/merge_requests/${number}`
|
|
32
37
|
const mrUrl = gitlabMrUrl(webBase, repo, number)
|
|
38
|
+
const batched = (input.comments?.length ?? 0) > 0
|
|
39
|
+
const { comments, warnings } = batched
|
|
40
|
+
? await publishGitlabDrafts(client, repo, number, headSha, input, webBase, diff)
|
|
41
|
+
: { comments: [], warnings: [] }
|
|
33
42
|
if (input.event === 'APPROVE') {
|
|
34
|
-
if (input.body.trim() !== '') {
|
|
43
|
+
if (!batched && input.body.trim() !== '') {
|
|
35
44
|
await client.post(`${base}/notes`, { body: input.body })
|
|
36
45
|
}
|
|
37
|
-
|
|
46
|
+
let raw: unknown
|
|
47
|
+
try {
|
|
48
|
+
raw = await client.post(`${base}/approve`, { sha: headSha })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!batched) throw error
|
|
51
|
+
return {
|
|
52
|
+
comments,
|
|
53
|
+
warnings: [
|
|
54
|
+
...warnings,
|
|
55
|
+
'Comments published, but approval failed. Approve the merge request in GitLab.',
|
|
56
|
+
],
|
|
57
|
+
id: number,
|
|
58
|
+
state: 'COMMENTED',
|
|
59
|
+
url: mrUrl,
|
|
60
|
+
submittedAt: new Date().toISOString(),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
38
63
|
const mr = GlMrSchema.safeParse(raw).data ?? {}
|
|
39
64
|
return {
|
|
65
|
+
comments,
|
|
66
|
+
warnings,
|
|
40
67
|
id: mr.id ?? mr.iid ?? number,
|
|
41
68
|
state: 'APPROVED',
|
|
42
69
|
url: mr.web_url ?? mrUrl,
|
|
43
70
|
submittedAt: new Date().toISOString(),
|
|
44
71
|
}
|
|
45
72
|
}
|
|
73
|
+
if (batched) {
|
|
74
|
+
return {
|
|
75
|
+
comments,
|
|
76
|
+
warnings,
|
|
77
|
+
id: number,
|
|
78
|
+
state: input.event === 'COMMENT' ? 'COMMENTED' : 'CHANGES_REQUESTED',
|
|
79
|
+
url: mrUrl,
|
|
80
|
+
submittedAt: new Date().toISOString(),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
46
83
|
const raw = await client.post(`${base}/notes`, { body: input.body })
|
|
47
84
|
const note = GlNoteSchema.parse(raw)
|
|
48
85
|
return {
|
|
86
|
+
comments,
|
|
87
|
+
warnings: [],
|
|
49
88
|
id: note.id,
|
|
50
|
-
state: 'CHANGES_REQUESTED',
|
|
89
|
+
state: input.event === 'COMMENT' ? 'COMMENTED' : 'CHANGES_REQUESTED',
|
|
51
90
|
url: gitlabNoteUrl(webBase, repo, number, note.id),
|
|
52
91
|
submittedAt: note.created_at ?? new Date().toISOString(),
|
|
53
92
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import type { HostClient } from '../host/client.js'
|
|
5
|
+
import type { Derived } from '../store/derived-store.js'
|
|
6
|
+
import { fetchGitlabComments } from './comments.js'
|
|
7
|
+
import { fetchMrDiffRefs } from './mr.js'
|
|
8
|
+
import { inlinePosition } from './post-comment.js'
|
|
9
|
+
import { gitlabProjectApi } from './project.js'
|
|
10
|
+
|
|
11
|
+
const DraftSchema = z.object({ id: z.number().int() })
|
|
12
|
+
|
|
13
|
+
/** Stage this submission privately, then publish it as one GitLab review. */
|
|
14
|
+
export async function publishGitlabDrafts(
|
|
15
|
+
client: HostClient,
|
|
16
|
+
repo: Repo,
|
|
17
|
+
number: number,
|
|
18
|
+
headSha: string,
|
|
19
|
+
input: { body: string; comments?: ReadonlyArray<PendingComment> },
|
|
20
|
+
webBase: string,
|
|
21
|
+
diff: Derived
|
|
22
|
+
) {
|
|
23
|
+
const base = `${gitlabProjectApi(repo)}/merge_requests/${number}/draft_notes`
|
|
24
|
+
const list = async () => z.array(DraftSchema).parse(await client.api(base))
|
|
25
|
+
if ((await list()).length > 0) {
|
|
26
|
+
throw new Error('Finish or discard your pending review in GitLab before submitting from the canvas.')
|
|
27
|
+
}
|
|
28
|
+
const refs = await fetchMrDiffRefs(client, repo, number)
|
|
29
|
+
if (refs.head_sha !== headSha) throw new Error('The merge request changed. Refresh before submitting.')
|
|
30
|
+
const notes: Array<{ note: string; position?: Record<string, unknown> }> = (input.comments ?? []).map(
|
|
31
|
+
p => ({
|
|
32
|
+
note: p.body,
|
|
33
|
+
position: inlinePosition(refs, { ...p, kind: 'inline' }, diff),
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
if (input.body.trim() !== '') notes.push({ note: input.body })
|
|
37
|
+
const fetchComments = () => fetchGitlabComments(client, repo, number, headSha, () => new Date(), webBase)
|
|
38
|
+
const before = new Set((await fetchComments()).payload.reviewComments.map(c => c.id))
|
|
39
|
+
const staged: number[] = []
|
|
40
|
+
try {
|
|
41
|
+
for (const note of notes) staged.push(DraftSchema.parse(await client.post(base, note)).id)
|
|
42
|
+
const current = await list()
|
|
43
|
+
if (current.length !== staged.length || current.some(d => !staged.includes(d.id))) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Your pending review changed in GitLab. Finish it there before submitting from the canvas.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
await client.post(`${base}/bulk_publish`, {})
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Delete only drafts created by this attempt; local copies remain available for retry.
|
|
51
|
+
const cleanup = await Promise.allSettled(staged.map(id => client.post(`${base}/${id}`, {}, 'DELETE')))
|
|
52
|
+
if (cleanup.some(result => result.status === 'rejected')) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'Submission failed and some staged drafts remain in GitLab. Inspect your pending review there before retrying.',
|
|
55
|
+
{ cause: error }
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const { payload } = await fetchComments()
|
|
62
|
+
return { comments: payload.reviewComments.filter(c => !before.has(c.id)), warnings: [] as string[] }
|
|
63
|
+
} catch {
|
|
64
|
+
return {
|
|
65
|
+
comments: [],
|
|
66
|
+
warnings: ['Review published, but its comments could not be refreshed. Reload to see them.'],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/host/client.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface HostClient {
|
|
|
13
13
|
/** `<cli> api -i --method GET <path>`: the response headers as well as the body. */
|
|
14
14
|
apiWithHeaders(path: string): Promise<CliResponse>
|
|
15
15
|
/** Send JSON over stdin. Defaults to POST; comment edits use PATCH or PUT. */
|
|
16
|
-
post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT'): Promise<unknown>
|
|
16
|
+
post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'): Promise<unknown>
|
|
17
17
|
/** `<cli> api graphql`; returns the parsed `data` object. */
|
|
18
18
|
graphql(query: string, variables: Record<string, string | number>): Promise<unknown>
|
|
19
19
|
/** `<cli> auth status`: is the CLI installed and logged in? */
|
|
@@ -196,7 +196,8 @@ export function createHostClient(spec: HostCliSpec, exec: CliExec = execCli): Ho
|
|
|
196
196
|
// The payload goes over stdin, so no comment text ever appears in an argument list. The
|
|
197
197
|
// content type is named because `glab` does not infer it and GitLab answers 415 without it.
|
|
198
198
|
const args = ['api', '--method', method, path, '--input', '-', '-H', 'Content-Type: application/json']
|
|
199
|
-
|
|
199
|
+
const { stdout } = await run(path, args, JSON.stringify(body))
|
|
200
|
+
return stdout.trim() === '' ? null : (JSON.parse(stdout) as unknown)
|
|
200
201
|
},
|
|
201
202
|
graphql: async (query, variables) => {
|
|
202
203
|
const args = ['api', 'graphql', '-f', `query=${query}`]
|
package/src/host/host.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { shareGithubCanvas } from '../github/canvas-comment.js'
|
|
2
2
|
import { shareGitlabCanvas } from '../gitlab/canvas-comment.js'
|
|
3
3
|
import type { Capabilities, PublicHost, ReviewSummary } from '../contract/api.js'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
FetchCommentsResult,
|
|
6
|
+
PostCommentInput,
|
|
7
|
+
PostCommentResult,
|
|
8
|
+
ReviewComment,
|
|
9
|
+
} from '../contract/comments.js'
|
|
5
10
|
import type { Repo } from '../contract/review-artifact.js'
|
|
6
11
|
import { GITHUB_ATTACHMENTS } from '../github/attachments.js'
|
|
7
12
|
import { probeCapabilities } from '../github/capabilities.js'
|
|
8
13
|
import { fetchComments } from '../github/comments.js'
|
|
9
14
|
import { postComment } from '../github/post-comment.js'
|
|
15
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
10
16
|
import type { ReviewEvent } from '../contract/reviews.js'
|
|
11
17
|
import { postReview } from '../github/post-review.js'
|
|
12
18
|
import { fetchPrMeta } from '../github/pr.js'
|
|
@@ -23,6 +29,12 @@ import { GH_CLI, glabCli, type HostClient, type HostCliSpec } from './client.js'
|
|
|
23
29
|
|
|
24
30
|
export type HostKind = PublicHost['kind']
|
|
25
31
|
|
|
32
|
+
/** The review and the comments created by this submission. */
|
|
33
|
+
export interface PostedReview extends ReviewSummary {
|
|
34
|
+
comments: ReviewComment[]
|
|
35
|
+
warnings: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/** How canvas zips attached to a review are found and fetched on one forge. */
|
|
27
39
|
export interface HostAttachments {
|
|
28
40
|
/** Zip links in one markdown text, as absolute URLs this host serves. */
|
|
@@ -68,13 +80,19 @@ export interface Host {
|
|
|
68
80
|
input: PostCommentInput,
|
|
69
81
|
diff: Derived
|
|
70
82
|
): Promise<PostCommentResult>
|
|
83
|
+
/**
|
|
84
|
+
* Submits the review, with the comments the reviewer had waiting. GitHub takes them in the one
|
|
85
|
+
* call that creates the review; GitLab stages and batch-publishes draft notes, which is why the
|
|
86
|
+
* diff is passed here too.
|
|
87
|
+
*/
|
|
71
88
|
postReview(
|
|
72
89
|
client: HostClient,
|
|
73
90
|
repo: Repo,
|
|
74
91
|
number: number,
|
|
75
92
|
headSha: string,
|
|
76
|
-
input: { event: ReviewEvent; body: string }
|
|
77
|
-
|
|
93
|
+
input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
|
|
94
|
+
diff: Derived
|
|
95
|
+
): Promise<PostedReview>
|
|
78
96
|
probeCapabilities(client: HostClient, repo: Repo): Promise<Capabilities>
|
|
79
97
|
canvasCommentLimit: number
|
|
80
98
|
shareCanvas(client: HostClient, repo: Repo, number: number, body: string): Promise<string>
|
|
@@ -121,8 +139,8 @@ export function gitlabHost(hostname: string): Host {
|
|
|
121
139
|
fetchGitlabComments(client, repo, number, headSha, now, webBase),
|
|
122
140
|
postComment: (client, repo, number, headSha, input, diff) =>
|
|
123
141
|
postGitlabComment(client, repo, number, headSha, input, { webBase, ...diff }),
|
|
124
|
-
postReview: (client, repo, number, headSha, input) =>
|
|
125
|
-
postGitlabReview(client, repo, number, headSha, input, webBase),
|
|
142
|
+
postReview: (client, repo, number, headSha, input, diff) =>
|
|
143
|
+
postGitlabReview(client, repo, number, headSha, input, webBase, diff),
|
|
126
144
|
probeCapabilities: probeGitlabCapabilities,
|
|
127
145
|
canvasCommentLimit: 1_000_000,
|
|
128
146
|
shareCanvas: (client, repo, number, body) => shareGitlabCanvas(client, repo, number, body, webBase),
|
package/src/project-config.ts
CHANGED
|
@@ -41,6 +41,10 @@ export const PromptOverridesSchema = z
|
|
|
41
41
|
'generation-format.md': z.string().min(1).optional(),
|
|
42
42
|
'generation-strict.md': z.string().min(1).optional(),
|
|
43
43
|
'generation-surfacing.md': z.string().min(1).optional(),
|
|
44
|
+
'generation-strict-incremental.md': z.string().min(1).optional(),
|
|
45
|
+
'generation-surfacing-incremental.md': z.string().min(1).optional(),
|
|
46
|
+
'judging-strict.md': z.string().min(1).optional(),
|
|
47
|
+
'judging-surfacing.md': z.string().min(1).optional(),
|
|
44
48
|
'quality-standards.md': z.string().min(1).optional(),
|
|
45
49
|
'layering-guidance.md': z.string().min(1).optional(),
|
|
46
50
|
'chat-seed.md': z.string().min(1).optional(),
|
|
@@ -71,6 +75,12 @@ export const ProjectConfigSchema = z.object({
|
|
|
71
75
|
* from, as after merging the base branch in. False marks the canvas outdated on any commit.
|
|
72
76
|
*/
|
|
73
77
|
keepForIdenticalDiff: z.boolean(),
|
|
78
|
+
/**
|
|
79
|
+
* Regenerating a canvas for a new head starts from the newest canvas of a commit the head was
|
|
80
|
+
* built on, carrying what the head's diff leaves untouched. False generates every canvas from
|
|
81
|
+
* a blank page, as `--force` always does.
|
|
82
|
+
*/
|
|
83
|
+
incremental: z.boolean(),
|
|
74
84
|
}),
|
|
75
85
|
})
|
|
76
86
|
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
|
|
@@ -93,7 +103,9 @@ const PartialProjectConfigSchema = z.object({
|
|
|
93
103
|
.optional(),
|
|
94
104
|
tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
|
|
95
105
|
chat: z.object({ enabled: z.boolean().optional() }).optional(),
|
|
96
|
-
canvas: z
|
|
106
|
+
canvas: z
|
|
107
|
+
.object({ keepForIdenticalDiff: z.boolean().optional(), incremental: z.boolean().optional() })
|
|
108
|
+
.optional(),
|
|
97
109
|
})
|
|
98
110
|
|
|
99
111
|
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
@@ -103,7 +115,7 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
|
103
115
|
generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
|
|
104
116
|
tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
|
|
105
117
|
chat: { enabled: true },
|
|
106
|
-
canvas: { keepForIdenticalDiff: true },
|
|
118
|
+
canvas: { keepForIdenticalDiff: true, incremental: true },
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
export interface LoadedProjectConfig {
|
|
@@ -147,6 +159,7 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
147
159
|
canvas: {
|
|
148
160
|
keepForIdenticalDiff:
|
|
149
161
|
user.canvas?.keepForIdenticalDiff ?? DEFAULT_PROJECT_CONFIG.canvas.keepForIdenticalDiff,
|
|
162
|
+
incremental: user.canvas?.incremental ?? DEFAULT_PROJECT_CONFIG.canvas.incremental,
|
|
150
163
|
},
|
|
151
164
|
}
|
|
152
165
|
if (user.rulebook !== undefined) {
|