@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
|
@@ -1,27 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
|
-
import { importCanvas } from '../canvas/import.js'
|
|
5
|
-
import { type ParsedCanvasName, parseCanvasZipName } from '../canvas/name.js'
|
|
6
|
-
import { CANVAS_ZIP_MAX_BYTES, hasZipMagic } from '../canvas/zip.js'
|
|
7
|
-
import type { ImportResult, SharedCanvasInfo } from '../contract/api.js'
|
|
8
|
-
import type { CommentsPayload } from '../contract/comments.js'
|
|
9
|
-
import type { Pr, Repo } from '../contract/review-artifact.js'
|
|
10
|
-
import { BodyTooLargeError, readCappedBody } from '../server/capped-body.js'
|
|
11
|
-
import type { AppContext } from '../server/context.js'
|
|
12
|
-
import { AppError } from '../server/errors.js'
|
|
13
|
-
|
|
14
|
-
/** Where an attachment may be served from. Everything else is refused before any request. */
|
|
15
|
-
export const ALLOWED_ATTACHMENT_HOSTS = new Set(['github.com', 'objects.githubusercontent.com'])
|
|
16
|
-
export const MAX_REDIRECTS = 3
|
|
17
|
-
export const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
18
|
-
|
|
19
|
-
export type DownloadFailure = NonNullable<SharedCanvasInfo['reason']>
|
|
20
|
-
|
|
21
|
-
export interface AttachmentLink {
|
|
22
|
-
url: string
|
|
23
|
-
name: string
|
|
24
|
-
}
|
|
1
|
+
import type { AttachmentLink } from '../host/attachments.js'
|
|
2
|
+
import type { HostAttachments } from '../host/host.js'
|
|
25
3
|
|
|
26
4
|
const FILE_LINK_RE = /https:\/\/github\.com\/user-attachments\/files\/\d+\/([A-Za-z0-9._-]+\.zip)/g
|
|
27
5
|
const ASSET_LINK_RE =
|
|
@@ -31,7 +9,7 @@ const ASSET_LINK_RE =
|
|
|
31
9
|
* Zip links in one markdown text. GitHub serves an attachment either under `files/<id>/<name>`,
|
|
32
10
|
* where the name is in the URL, or under `assets/<uuid>`, where only the markdown label has it.
|
|
33
11
|
*/
|
|
34
|
-
export function
|
|
12
|
+
export function findGithubAttachmentLinks(text: string): AttachmentLink[] {
|
|
35
13
|
const links: AttachmentLink[] = []
|
|
36
14
|
const seen = new Set<string>()
|
|
37
15
|
for (const m of text.matchAll(FILE_LINK_RE)) {
|
|
@@ -51,238 +29,12 @@ export function findAttachmentLinks(text: string): AttachmentLink[] {
|
|
|
51
29
|
return links
|
|
52
30
|
}
|
|
53
31
|
|
|
54
|
-
export interface AttachmentCandidate extends AttachmentLink {
|
|
55
|
-
parsed: ParsedCanvasName
|
|
56
|
-
/**
|
|
57
|
-
* When the text carrying the link was last edited. A link added to an old comment counts from
|
|
58
|
-
* the edit, which is when the attachment appeared.
|
|
59
|
-
*/
|
|
60
|
-
postedAt: string
|
|
61
|
-
/** Position in the scan, used when two links carry the same time. */
|
|
62
|
-
order: number
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface DiscoverySources {
|
|
66
|
-
/** The PR body and the time the pull request was last edited. */
|
|
67
|
-
body: string
|
|
68
|
-
bodyUpdatedAt: string
|
|
69
|
-
comments: CommentsPayload
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Every link in the PR body and its comments whose name is a canvas zip of this repository. */
|
|
73
|
-
export function collectCandidates(sources: DiscoverySources, repo: Repo): AttachmentCandidate[] {
|
|
74
|
-
const texts: Array<{ text: string; postedAt: string }> = [
|
|
75
|
-
{ text: sources.body, postedAt: sources.bodyUpdatedAt },
|
|
76
|
-
...sources.comments.issueComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
77
|
-
...sources.comments.reviewComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
78
|
-
]
|
|
79
|
-
const candidates: AttachmentCandidate[] = []
|
|
80
|
-
let order = 0
|
|
81
|
-
for (const { text, postedAt } of texts) {
|
|
82
|
-
for (const link of findAttachmentLinks(text)) {
|
|
83
|
-
const parsed = parseCanvasZipName(link.name, repo)
|
|
84
|
-
if (parsed !== null) {
|
|
85
|
-
candidates.push({ ...link, parsed, postedAt, order: order++ })
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return candidates
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** The canvas for this head wins, then one exported for this PR, then the one attached last. */
|
|
93
|
-
export function rankCandidates(
|
|
94
|
-
candidates: AttachmentCandidate[],
|
|
95
|
-
target: { headSha: string; prNumber: number }
|
|
96
|
-
): AttachmentCandidate[] {
|
|
97
|
-
const shaPrefix = target.headSha.slice(0, 8)
|
|
98
|
-
const score = (c: AttachmentCandidate): number =>
|
|
99
|
-
(c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
|
|
100
|
-
return [...candidates].sort(
|
|
101
|
-
(a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** How many attachments one scan tries before it reports the best one as unusable. */
|
|
106
|
-
export const MAX_CANDIDATES_TRIED = 3
|
|
107
|
-
|
|
108
32
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* head sha, because the ranking and `matchesHead` are answers about that commit.
|
|
33
|
+
* github.com answers an attachment request with a redirect to signed object storage, which must
|
|
34
|
+
* not see the token: the header is for github.com alone.
|
|
112
35
|
*/
|
|
113
|
-
export
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
hash.update(`\u0000${c.id}:${c.createdAt}:${c.body}`)
|
|
118
|
-
}
|
|
119
|
-
return hash.digest('hex')
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export type DownloadResult = { ok: true; bytes: Uint8Array } | { ok: false; reason: DownloadFailure }
|
|
123
|
-
|
|
124
|
-
function allowedUrl(raw: string, base?: string): URL | null {
|
|
125
|
-
let url: URL
|
|
126
|
-
try {
|
|
127
|
-
url = new URL(raw, base)
|
|
128
|
-
} catch {
|
|
129
|
-
return null
|
|
130
|
-
}
|
|
131
|
-
return url.protocol === 'https:' && ALLOWED_ATTACHMENT_HOSTS.has(url.hostname) ? url : null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Downloads one attachment. GitHub answers the first request with a redirect to a signed storage
|
|
138
|
-
* URL; that request carries no Authorization header, because storage refuses a request holding
|
|
139
|
-
* both a signature and a token.
|
|
140
|
-
*/
|
|
141
|
-
export async function downloadAttachment(ctx: AppContext, rawUrl: string): Promise<DownloadResult> {
|
|
142
|
-
const first = allowedUrl(rawUrl)
|
|
143
|
-
if (first === null) {
|
|
144
|
-
return { ok: false, reason: 'network' }
|
|
145
|
-
}
|
|
146
|
-
const token = await ctx.gh.authToken()
|
|
147
|
-
if (token === null) {
|
|
148
|
-
return { ok: false, reason: 'auth-required' }
|
|
149
|
-
}
|
|
150
|
-
let url = first
|
|
151
|
-
// Storage refuses a request that carries both a signature and a token, and the token has no
|
|
152
|
-
// business there anyway: only github.com is ever asked with it.
|
|
153
|
-
let withToken = url.hostname === 'github.com'
|
|
154
|
-
// One deadline for the redirects and the body together, so three slow hops cannot add up.
|
|
155
|
-
const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
156
|
-
try {
|
|
157
|
-
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
158
|
-
const accept = 'application/octet-stream'
|
|
159
|
-
const headers = withToken ? { accept, authorization: `token ${token}` } : { accept }
|
|
160
|
-
const res: Response = await ctx.fetch(url, {
|
|
161
|
-
headers,
|
|
162
|
-
redirect: 'manual',
|
|
163
|
-
signal: deadline,
|
|
164
|
-
})
|
|
165
|
-
if (REDIRECT_STATUSES.has(res.status)) {
|
|
166
|
-
const next = allowedUrl(res.headers.get('location') ?? '', url.toString())
|
|
167
|
-
if (next === null) {
|
|
168
|
-
return { ok: false, reason: 'network' }
|
|
169
|
-
}
|
|
170
|
-
url = next
|
|
171
|
-
withToken = false
|
|
172
|
-
continue
|
|
173
|
-
}
|
|
174
|
-
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
175
|
-
return { ok: false, reason: 'auth-required' }
|
|
176
|
-
}
|
|
177
|
-
if (res.status !== 200) {
|
|
178
|
-
return { ok: false, reason: 'network' }
|
|
179
|
-
}
|
|
180
|
-
let bytes: Uint8Array
|
|
181
|
-
try {
|
|
182
|
-
bytes = await readCappedBody(res, CANVAS_ZIP_MAX_BYTES)
|
|
183
|
-
} catch (err) {
|
|
184
|
-
if (err instanceof BodyTooLargeError) {
|
|
185
|
-
return { ok: false, reason: 'too-large' }
|
|
186
|
-
}
|
|
187
|
-
throw err
|
|
188
|
-
}
|
|
189
|
-
return hasZipMagic(bytes) ? { ok: true, bytes } : { ok: false, reason: 'not-zip' }
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
// A network failure, a timeout, or a body that stopped mid-stream all read the same here.
|
|
193
|
-
return { ok: false, reason: 'network' }
|
|
194
|
-
}
|
|
195
|
-
return { ok: false, reason: 'network' }
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** A downloaded zip that import refused, told in the words the callout uses. */
|
|
199
|
-
export function importFailureReason(err: unknown): DownloadFailure {
|
|
200
|
-
if (!(err instanceof AppError)) {
|
|
201
|
-
return 'not-zip'
|
|
202
|
-
}
|
|
203
|
-
if (err.code === 'CANVAS_TOO_LARGE') {
|
|
204
|
-
return 'too-large'
|
|
205
|
-
}
|
|
206
|
-
return err.code === 'CANVAS_REPO_MISMATCH' ? 'name-mismatch' : 'not-zip'
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export interface DiscoveryOutcome {
|
|
210
|
-
sharedCanvas: SharedCanvasInfo | null
|
|
211
|
-
imported: ImportResult | null
|
|
212
|
-
warnings: string[]
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Looks for a canvas attached to the PR and imports the best one. A found-but-unusable zip is
|
|
217
|
-
* reported to the page, which offers the link and the drop zone instead.
|
|
218
|
-
*/
|
|
219
|
-
export async function discoverSharedCanvas(
|
|
220
|
-
ctx: AppContext,
|
|
221
|
-
pr: Pr,
|
|
222
|
-
comments: CommentsPayload
|
|
223
|
-
): Promise<DiscoveryOutcome> {
|
|
224
|
-
if (pr.number === null) {
|
|
225
|
-
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
226
|
-
}
|
|
227
|
-
const sources: DiscoverySources = { body: pr.body, bodyUpdatedAt: pr.updatedAt ?? '', comments }
|
|
228
|
-
const ranked = rankCandidates(collectCandidates(sources, ctx.config.repo), {
|
|
229
|
-
headSha: pr.headSha,
|
|
230
|
-
prNumber: pr.number,
|
|
231
|
-
})
|
|
232
|
-
const best = ranked[0]
|
|
233
|
-
if (best === undefined) {
|
|
234
|
-
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
235
|
-
}
|
|
236
|
-
const warnings: string[] = []
|
|
237
|
-
const first = await tryCandidate(ctx, pr, pr.number, best)
|
|
238
|
-
warnings.push(...first.warnings)
|
|
239
|
-
if (first.imported !== null) {
|
|
240
|
-
return { ...first, warnings }
|
|
241
|
-
}
|
|
242
|
-
// A broken attachment must not hide a good one behind it, so the next ones are tried too.
|
|
243
|
-
for (const candidate of ranked.slice(1, MAX_CANDIDATES_TRIED)) {
|
|
244
|
-
const attempt = await tryCandidate(ctx, pr, pr.number, candidate)
|
|
245
|
-
warnings.push(...attempt.warnings)
|
|
246
|
-
if (attempt.imported !== null) {
|
|
247
|
-
return { ...attempt, warnings }
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
// Every attachment failed; the page shows the best one, its reason, and the drop zone.
|
|
251
|
-
return { ...first, warnings }
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** One attachment: download it and import it, or say why that did not work. */
|
|
255
|
-
async function tryCandidate(
|
|
256
|
-
ctx: AppContext,
|
|
257
|
-
pr: Pr,
|
|
258
|
-
prNumber: number,
|
|
259
|
-
candidate: AttachmentCandidate
|
|
260
|
-
): Promise<DiscoveryOutcome> {
|
|
261
|
-
const shared = {
|
|
262
|
-
url: candidate.url,
|
|
263
|
-
name: candidate.name,
|
|
264
|
-
matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
|
|
265
|
-
}
|
|
266
|
-
const download = await downloadAttachment(ctx, candidate.url)
|
|
267
|
-
if (!download.ok) {
|
|
268
|
-
return {
|
|
269
|
-
sharedCanvas: { ...shared, downloadable: false, reason: download.reason },
|
|
270
|
-
imported: null,
|
|
271
|
-
warnings: [],
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
try {
|
|
275
|
-
const imported = await importCanvas(ctx, {
|
|
276
|
-
bytes: download.bytes,
|
|
277
|
-
prNumber,
|
|
278
|
-
currentHeadSha: pr.headSha,
|
|
279
|
-
})
|
|
280
|
-
return { sharedCanvas: { ...shared, downloadable: true }, imported, warnings: imported.warnings }
|
|
281
|
-
} catch (err) {
|
|
282
|
-
return {
|
|
283
|
-
sharedCanvas: { ...shared, downloadable: false, reason: importFailureReason(err) },
|
|
284
|
-
imported: null,
|
|
285
|
-
warnings: [err instanceof Error ? err.message : String(err)],
|
|
286
|
-
}
|
|
287
|
-
}
|
|
36
|
+
export const GITHUB_ATTACHMENTS: HostAttachments = {
|
|
37
|
+
findLinks: findGithubAttachmentLinks,
|
|
38
|
+
allowedHosts: new Set(['github.com', 'objects.githubusercontent.com']),
|
|
39
|
+
authHeader: token => ({ authorization: `token ${token}` }),
|
|
288
40
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { CANVAS_COMMENT_MARKER } from '../canvas/comment.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
5
|
+
import { mapIssueComment } from './comments.js'
|
|
6
|
+
|
|
7
|
+
export async function shareGithubCanvas(
|
|
8
|
+
client: HostClient,
|
|
9
|
+
repo: Repo,
|
|
10
|
+
number: number,
|
|
11
|
+
body: string
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const user = z.object({ login: z.string() }).parse(await client.api('user'))
|
|
14
|
+
const base = `repos/${repo.owner}/${repo.name}`
|
|
15
|
+
const comments = (await fetchAllPages(client, `${base}/issues/${number}/comments`)).map(mapIssueComment)
|
|
16
|
+
const existing = comments.find(c => c.author === user.login && c.body.includes(CANVAS_COMMENT_MARKER))
|
|
17
|
+
const response =
|
|
18
|
+
existing === undefined
|
|
19
|
+
? await client.post(`${base}/issues/${number}/comments`, { body })
|
|
20
|
+
: await client.post(`${base}/issues/comments/${existing.id}`, { body }, 'PATCH')
|
|
21
|
+
return mapIssueComment(response).url
|
|
22
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import type { Capabilities } from '../contract/api.js'
|
|
3
3
|
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
-
import type {
|
|
4
|
+
import type { CliResponse, HostClient } from '../host/client.js'
|
|
5
5
|
|
|
6
6
|
const UserSchema = z.object({ login: z.string() })
|
|
7
7
|
const RepoBodySchema = z.object({
|
|
@@ -9,24 +9,14 @@ const RepoBodySchema = z.object({
|
|
|
9
9
|
permissions: z.object({ pull: z.boolean().optional(), push: z.boolean().optional() }).optional(),
|
|
10
10
|
})
|
|
11
11
|
|
|
12
|
-
/** What a page assumes before the probe answers: posting is tried and GitHub decides. */
|
|
13
|
-
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
14
|
-
canComment: 'unknown',
|
|
15
|
-
tokenKind: 'unprobed',
|
|
16
|
-
login: null,
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
|
|
20
13
|
|
|
21
|
-
/** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
|
|
22
|
-
export const CAPABILITY_TTL_MS = 10 * 60 * 1000
|
|
23
|
-
|
|
24
14
|
/**
|
|
25
15
|
* Whether this login may post on this repository, from the token's scopes and the repository
|
|
26
16
|
* permissions. A token without a scopes header is a fine-grained or app token, whose rights this
|
|
27
17
|
* check cannot read: posting stays enabled and GitHub's own answer decides.
|
|
28
18
|
*/
|
|
29
|
-
export function decideCapabilities(login: string | null, response:
|
|
19
|
+
export function decideCapabilities(login: string | null, response: CliResponse): Capabilities {
|
|
30
20
|
const parsed = RepoBodySchema.safeParse(response.body)
|
|
31
21
|
const body = parsed.success ? parsed.data : {}
|
|
32
22
|
const canPull = body.permissions?.pull === true
|
|
@@ -67,7 +57,7 @@ export function decideCapabilities(login: string | null, response: GhResponse):
|
|
|
67
57
|
}
|
|
68
58
|
|
|
69
59
|
/** The probe itself: who the token belongs to, and what it may do here. */
|
|
70
|
-
export async function probeCapabilities(gh:
|
|
60
|
+
export async function probeCapabilities(gh: HostClient, repo: Repo): Promise<Capabilities> {
|
|
71
61
|
let login: string | null = null
|
|
72
62
|
try {
|
|
73
63
|
login = UserSchema.parse(await gh.api('user')).login
|
|
@@ -86,31 +76,3 @@ export async function probeCapabilities(gh: GitHubClient, repo: Repo): Promise<C
|
|
|
86
76
|
}
|
|
87
77
|
}
|
|
88
78
|
}
|
|
89
|
-
|
|
90
|
-
export interface CapabilityProbe {
|
|
91
|
-
get(opts?: { refresh?: boolean }): Promise<Capabilities>
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The probe with its cache. One per server process; `refresh` skips the cache after the user
|
|
96
|
-
* changed their token.
|
|
97
|
-
*/
|
|
98
|
-
export function createCapabilityProbe(
|
|
99
|
-
gh: GitHubClient,
|
|
100
|
-
repo: Repo,
|
|
101
|
-
now: () => Date,
|
|
102
|
-
ttlMs = CAPABILITY_TTL_MS
|
|
103
|
-
): CapabilityProbe {
|
|
104
|
-
let cached: { at: number; value: Capabilities } | null = null
|
|
105
|
-
return {
|
|
106
|
-
get: async (opts = {}) => {
|
|
107
|
-
const at = now().getTime()
|
|
108
|
-
if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
|
|
109
|
-
return cached.value
|
|
110
|
-
}
|
|
111
|
-
const value = await probeCapabilities(gh, repo)
|
|
112
|
-
cached = { at, value }
|
|
113
|
-
return value
|
|
114
|
-
},
|
|
115
|
-
}
|
|
116
|
-
}
|
package/src/github/comments.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type {
|
|
2
|
+
import type { FetchCommentsResult, IssueComment, ReviewComment } from '../contract/comments.js'
|
|
3
3
|
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
-
import type
|
|
4
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
5
5
|
import { fetchResolvedCommentIds } from './threads.js'
|
|
6
6
|
|
|
7
7
|
const GhReviewCommentSchema = z.object({
|
|
@@ -71,33 +71,12 @@ export function mapIssueComment(raw: unknown): IssueComment {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export interface FetchCommentsResult {
|
|
75
|
-
payload: CommentsPayload
|
|
76
|
-
warnings: string[]
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export const COMMENTS_PAGE_SIZE = 100
|
|
80
|
-
|
|
81
|
-
/** Every item of a REST list endpoint, following `page=` until a page comes back short. */
|
|
82
|
-
export async function fetchAllPages(gh: GitHubClient, path: string): Promise<unknown[]> {
|
|
83
|
-
const out: unknown[] = []
|
|
84
|
-
for (let page = 1; ; page++) {
|
|
85
|
-
const batch = z
|
|
86
|
-
.array(z.unknown())
|
|
87
|
-
.parse(await gh.api(path, { per_page: String(COMMENTS_PAGE_SIZE), page: String(page) }))
|
|
88
|
-
out.push(...batch)
|
|
89
|
-
if (batch.length < COMMENTS_PAGE_SIZE) {
|
|
90
|
-
return out
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
74
|
/**
|
|
96
75
|
* Review comments (on diff lines) and issue comments (PR-level), with the resolved flag from
|
|
97
76
|
* GraphQL. A GraphQL failure degrades to `resolved: false` everywhere plus a warning.
|
|
98
77
|
*/
|
|
99
78
|
export async function fetchComments(
|
|
100
|
-
gh:
|
|
79
|
+
gh: HostClient,
|
|
101
80
|
repo: Repo,
|
|
102
81
|
number: number,
|
|
103
82
|
headSha: string,
|
|
@@ -1,46 +1,13 @@
|
|
|
1
1
|
import type { PostCommentInput, PostCommentResult } from '../contract/comments.js'
|
|
2
|
-
import type {
|
|
3
|
-
import { hunkForLine } from '../git/patch-lines.js'
|
|
2
|
+
import type { Repo, Side } from '../contract/review-artifact.js'
|
|
4
3
|
import { mapIssueComment, mapReviewComment } from './comments.js'
|
|
5
|
-
import type {
|
|
4
|
+
import type { HostClient } from '../host/client.js'
|
|
6
5
|
|
|
7
6
|
/** GitHub names the two sides of a diff LEFT and RIGHT. */
|
|
8
7
|
export function ghSide(side: Side): 'LEFT' | 'RIGHT' {
|
|
9
8
|
return side === 'old' ? 'LEFT' : 'RIGHT'
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export interface InlineTarget {
|
|
13
|
-
path: string
|
|
14
|
-
line: number
|
|
15
|
-
side: Side
|
|
16
|
-
startLine?: number | undefined
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Whether GitHub will accept a comment on these lines: the file is in the diff, and the whole
|
|
21
|
-
* range sits inside one hunk on that side. Returns the reason when it will not, so the route can
|
|
22
|
-
* refuse before the request leaves the machine.
|
|
23
|
-
*/
|
|
24
|
-
export function checkInlineTarget(files: ReadonlyArray<FileEntry>, target: InlineTarget): string | null {
|
|
25
|
-
const file = files.find(f => f.path === target.path)
|
|
26
|
-
if (file === undefined) {
|
|
27
|
-
return `${target.path} is not in the diff`
|
|
28
|
-
}
|
|
29
|
-
const hunk = hunkForLine(file.hunks, target.side, target.line)
|
|
30
|
-
if (hunk === null) {
|
|
31
|
-
return `${target.path}:${target.line} (${target.side}) is not in the diff`
|
|
32
|
-
}
|
|
33
|
-
if (target.startLine !== undefined) {
|
|
34
|
-
if (target.startLine > target.line) {
|
|
35
|
-
return `the first line of the range must come before ${target.line}`
|
|
36
|
-
}
|
|
37
|
-
if (hunkForLine(file.hunks, target.side, target.startLine) !== hunk) {
|
|
38
|
-
return `${target.path}:${target.startLine}-${target.line} (${target.side}) spans more than one hunk`
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
11
|
/** The fields GitHub reads for a comment on a diff line. */
|
|
45
12
|
interface InlineCommentBody {
|
|
46
13
|
body: string
|
|
@@ -92,7 +59,7 @@ export function commentRequest(
|
|
|
92
59
|
|
|
93
60
|
/** Posts one comment and maps GitHub's answer into the shape the page already renders. */
|
|
94
61
|
export async function postComment(
|
|
95
|
-
gh:
|
|
62
|
+
gh: HostClient,
|
|
96
63
|
repo: Repo,
|
|
97
64
|
number: number,
|
|
98
65
|
headSha: string,
|
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type {
|
|
2
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
3
|
+
import type { ReviewEvent } from '../contract/reviews.js'
|
|
4
|
+
import type { PostedReview } from '../host/host.js'
|
|
3
5
|
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
-
import type
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
|
|
8
|
-
export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
|
|
9
|
-
|
|
10
|
-
export const PostReviewInputSchema = z.object({
|
|
11
|
-
event: ReviewEventSchema,
|
|
12
|
-
/** The dialog sends the body the user read, edited or not. */
|
|
13
|
-
body: z.string().min(1).max(65536).optional(),
|
|
14
|
-
/** The commit the dialog named. The server refuses the review when the head moved on. */
|
|
15
|
-
headSha: z
|
|
16
|
-
.string()
|
|
17
|
-
.regex(/^[0-9a-f]{40}$/)
|
|
18
|
-
.optional(),
|
|
19
|
-
})
|
|
20
|
-
export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
|
|
6
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
7
|
+
import { mapReviewComment } from './comments.js'
|
|
8
|
+
import { ghSide } from './post-comment.js'
|
|
21
9
|
|
|
22
10
|
const GhReviewSchema = z.object({
|
|
23
11
|
id: z.number().int(),
|
|
@@ -26,19 +14,72 @@ const GhReviewSchema = z.object({
|
|
|
26
14
|
submitted_at: z.string().nullable().optional(),
|
|
27
15
|
})
|
|
28
16
|
|
|
29
|
-
/**
|
|
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
|
+
*/
|
|
30
47
|
export async function postReview(
|
|
31
|
-
gh:
|
|
48
|
+
gh: HostClient,
|
|
32
49
|
repo: Repo,
|
|
33
50
|
number: number,
|
|
34
51
|
headSha: string,
|
|
35
|
-
input: { event: ReviewEvent; body: string }
|
|
36
|
-
): Promise<
|
|
52
|
+
input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> }
|
|
53
|
+
): Promise<PostedReview> {
|
|
54
|
+
const comments = input.comments ?? []
|
|
37
55
|
const raw = await gh.post(`repos/${repo.owner}/${repo.name}/pulls/${number}/reviews`, {
|
|
38
56
|
event: input.event,
|
|
39
57
|
body: input.body,
|
|
40
58
|
commit_id: headSha,
|
|
59
|
+
...(comments.length === 0 ? {} : { comments: reviewComments(comments) }),
|
|
41
60
|
})
|
|
42
61
|
const r = GhReviewSchema.parse(raw)
|
|
43
|
-
|
|
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
|
|
44
85
|
}
|