@vintasoftware/pr-review-canvas 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
|
@@ -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,20 +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 = { canComment: 'unknown', tokenKind: 'unprobed', login: null }
|
|
14
|
-
|
|
15
12
|
export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
|
|
16
13
|
|
|
17
|
-
/** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
|
|
18
|
-
export const CAPABILITY_TTL_MS = 10 * 60 * 1000
|
|
19
|
-
|
|
20
14
|
/**
|
|
21
15
|
* Whether this login may post on this repository, from the token's scopes and the repository
|
|
22
16
|
* permissions. A token without a scopes header is a fine-grained or app token, whose rights this
|
|
23
17
|
* check cannot read: posting stays enabled and GitHub's own answer decides.
|
|
24
18
|
*/
|
|
25
|
-
export function decideCapabilities(login: string | null, response:
|
|
19
|
+
export function decideCapabilities(login: string | null, response: CliResponse): Capabilities {
|
|
26
20
|
const parsed = RepoBodySchema.safeParse(response.body)
|
|
27
21
|
const body = parsed.success ? parsed.data : {}
|
|
28
22
|
const canPull = body.permissions?.pull === true
|
|
@@ -63,7 +57,7 @@ export function decideCapabilities(login: string | null, response: GhResponse):
|
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
/** The probe itself: who the token belongs to, and what it may do here. */
|
|
66
|
-
export async function probeCapabilities(gh:
|
|
60
|
+
export async function probeCapabilities(gh: HostClient, repo: Repo): Promise<Capabilities> {
|
|
67
61
|
let login: string | null = null
|
|
68
62
|
try {
|
|
69
63
|
login = UserSchema.parse(await gh.api('user')).login
|
|
@@ -82,31 +76,3 @@ export async function probeCapabilities(gh: GitHubClient, repo: Repo): Promise<C
|
|
|
82
76
|
}
|
|
83
77
|
}
|
|
84
78
|
}
|
|
85
|
-
|
|
86
|
-
export interface CapabilityProbe {
|
|
87
|
-
get(opts?: { refresh?: boolean }): Promise<Capabilities>
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The probe with its cache. One per server process; `refresh` skips the cache after the user
|
|
92
|
-
* changed their token.
|
|
93
|
-
*/
|
|
94
|
-
export function createCapabilityProbe(
|
|
95
|
-
gh: GitHubClient,
|
|
96
|
-
repo: Repo,
|
|
97
|
-
now: () => Date,
|
|
98
|
-
ttlMs = CAPABILITY_TTL_MS
|
|
99
|
-
): CapabilityProbe {
|
|
100
|
-
let cached: { at: number; value: Capabilities } | null = null
|
|
101
|
-
return {
|
|
102
|
-
get: async (opts = {}) => {
|
|
103
|
-
const at = now().getTime()
|
|
104
|
-
if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
|
|
105
|
-
return cached.value
|
|
106
|
-
}
|
|
107
|
-
const value = await probeCapabilities(gh, repo)
|
|
108
|
-
cached = { at, value }
|
|
109
|
-
return value
|
|
110
|
-
},
|
|
111
|
-
}
|
|
112
|
-
}
|
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,
|
|
@@ -110,9 +89,12 @@ export async function fetchComments(
|
|
|
110
89
|
fetchAllPages(gh, `${base}/issues/${number}/comments`),
|
|
111
90
|
])
|
|
112
91
|
const reviews = (await fetchAllPages(gh, `${base}/pulls/${number}/reviews`)).flatMap(raw => {
|
|
113
|
-
const review = GhIssueCommentSchema.omit({ created_at: true })
|
|
114
|
-
|
|
115
|
-
|
|
92
|
+
const review = GhIssueCommentSchema.omit({ created_at: true })
|
|
93
|
+
.extend({
|
|
94
|
+
submitted_at: z.string().nullable().optional(),
|
|
95
|
+
state: z.string(),
|
|
96
|
+
})
|
|
97
|
+
.parse(raw)
|
|
116
98
|
if (!review.submitted_at || review.state === 'PENDING') return []
|
|
117
99
|
return [{ ...mapIssueComment({ ...review, created_at: review.submitted_at }), state: review.state }]
|
|
118
100
|
})
|
|
@@ -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
|
|
@@ -81,7 +48,10 @@ export function commentRequest(
|
|
|
81
48
|
return { path: `${base}/pulls/${number}/comments`, body }
|
|
82
49
|
}
|
|
83
50
|
case 'reply':
|
|
84
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`,
|
|
53
|
+
body: { body: input.body },
|
|
54
|
+
}
|
|
85
55
|
case 'issue':
|
|
86
56
|
return { path: `${base}/issues/${number}/comments`, body: { body: input.body } }
|
|
87
57
|
}
|
|
@@ -89,7 +59,7 @@ export function commentRequest(
|
|
|
89
59
|
|
|
90
60
|
/** Posts one comment and maps GitHub's answer into the shape the page already renders. */
|
|
91
61
|
export async function postComment(
|
|
92
|
-
gh:
|
|
62
|
+
gh: HostClient,
|
|
93
63
|
repo: Repo,
|
|
94
64
|
number: number,
|
|
95
65
|
headSha: string,
|
|
@@ -1,23 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReviewEvent } from '../contract/reviews.js'
|
|
3
|
+
import type { ReviewSummary } from '../contract/api.js'
|
|
3
4
|
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
|
|
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>
|
|
5
|
+
import type { HostClient } from '../host/client.js'
|
|
21
6
|
|
|
22
7
|
const GhReviewSchema = z.object({
|
|
23
8
|
id: z.number().int(),
|
|
@@ -28,7 +13,7 @@ const GhReviewSchema = z.object({
|
|
|
28
13
|
|
|
29
14
|
/** Posts the review. GitHub decides what the event means; the tool only fills the body and sha. */
|
|
30
15
|
export async function postReview(
|
|
31
|
-
gh:
|
|
16
|
+
gh: HostClient,
|
|
32
17
|
repo: Repo,
|
|
33
18
|
number: number,
|
|
34
19
|
headSha: string,
|
package/src/github/pr.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
2
|
+
import { PrNotFoundError, type PrMeta } from '../host/pr.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import { type HostClient, HostCliError } from '../host/client.js'
|
|
5
5
|
|
|
6
6
|
/** The subset of GitHub's pull request object the tool reads. */
|
|
7
7
|
const GhPullSchema = z.object({
|
|
@@ -22,35 +22,6 @@ const GhPullSchema = z.object({
|
|
|
22
22
|
head: z.object({ ref: z.string(), sha: z.string() }),
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
export class PrNotFoundError extends Error {
|
|
26
|
-
readonly number: number
|
|
27
|
-
|
|
28
|
-
constructor(number: number) {
|
|
29
|
-
super(`pull request #${number} not found`)
|
|
30
|
-
this.name = 'PrNotFoundError'
|
|
31
|
-
this.number = number
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface PrMeta {
|
|
36
|
-
number: number
|
|
37
|
-
title: string
|
|
38
|
-
body: string
|
|
39
|
-
author: string
|
|
40
|
-
url: string
|
|
41
|
-
state: string
|
|
42
|
-
draft: boolean
|
|
43
|
-
updatedAt: string
|
|
44
|
-
baseRef: string
|
|
45
|
-
headRef: string
|
|
46
|
-
headSha: string
|
|
47
|
-
/** The commit that merged the PR into its base, once merged. */
|
|
48
|
-
mergeCommitSha: string | null
|
|
49
|
-
additions: number
|
|
50
|
-
deletions: number
|
|
51
|
-
changedFiles: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
25
|
export function mapPull(raw: unknown): PrMeta {
|
|
55
26
|
const p = GhPullSchema.parse(raw)
|
|
56
27
|
return {
|
|
@@ -72,62 +43,13 @@ export function mapPull(raw: unknown): PrMeta {
|
|
|
72
43
|
}
|
|
73
44
|
}
|
|
74
45
|
|
|
75
|
-
export async function fetchPrMeta(
|
|
46
|
+
export async function fetchPrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta> {
|
|
76
47
|
try {
|
|
77
|
-
return mapPull(await
|
|
48
|
+
return mapPull(await client.api(`repos/${repo.owner}/${repo.name}/pulls/${number}`))
|
|
78
49
|
} catch (err) {
|
|
79
|
-
if (err instanceof
|
|
50
|
+
if (err instanceof HostCliError && err.notFound) {
|
|
80
51
|
throw new PrNotFoundError(number)
|
|
81
52
|
}
|
|
82
53
|
throw err
|
|
83
54
|
}
|
|
84
55
|
}
|
|
85
|
-
|
|
86
|
-
export function prHeadRef(number: number): string {
|
|
87
|
-
return `refs/pr/${number}/head`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function prBaseRef(number: number): string {
|
|
91
|
-
return `refs/pr/${number}/base`
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Fetches the PR head and base into local refs and resolves the two commits the diff needs.
|
|
96
|
-
* The refs are the same ones the prior-art skill used, so an existing clone keeps working.
|
|
97
|
-
*
|
|
98
|
-
* A merged PR is diffed against the base as it was at merge time: the merge commit's first
|
|
99
|
-
* parent (a merge commit, a squash, or the last rebased commit all sit on the base branch, so
|
|
100
|
-
* it is local once the base was fetched). Today's base tip would contain the PR and give an
|
|
101
|
-
* empty diff.
|
|
102
|
-
*/
|
|
103
|
-
export async function fetchPrRefs(git: Git, meta: PrMeta): Promise<{ headSha: string; mergeBaseSha: string }> {
|
|
104
|
-
await git.fetch('origin', [
|
|
105
|
-
`+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
|
|
106
|
-
`+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
|
|
107
|
-
])
|
|
108
|
-
const headSha = await git.revParse(prHeadRef(meta.number))
|
|
109
|
-
const base = meta.mergeCommitSha === null ? prBaseRef(meta.number) : `${meta.mergeCommitSha}^1`
|
|
110
|
-
const mergeBaseSha = await git.mergeBase(base, headSha)
|
|
111
|
-
return { headSha, mergeBaseSha }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function toPr(meta: PrMeta, repo: Repo, shas: { headSha: string; mergeBaseSha: string }): Pr {
|
|
115
|
-
return {
|
|
116
|
-
number: meta.number,
|
|
117
|
-
title: meta.title,
|
|
118
|
-
body: meta.body,
|
|
119
|
-
author: meta.author,
|
|
120
|
-
url: meta.url,
|
|
121
|
-
state: meta.state,
|
|
122
|
-
draft: meta.draft,
|
|
123
|
-
updatedAt: meta.updatedAt,
|
|
124
|
-
baseRef: meta.baseRef,
|
|
125
|
-
headRef: meta.headRef,
|
|
126
|
-
headSha: shas.headSha,
|
|
127
|
-
mergeBaseSha: shas.mergeBaseSha,
|
|
128
|
-
additions: meta.additions,
|
|
129
|
-
deletions: meta.deletions,
|
|
130
|
-
changedFiles: meta.changedFiles,
|
|
131
|
-
repo,
|
|
132
|
-
}
|
|
133
|
-
}
|
package/src/github/threads.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import type { Repo } from '../contract/review-artifact.js'
|
|
3
|
-
import type {
|
|
3
|
+
import type { HostClient } from '../host/client.js'
|
|
4
4
|
|
|
5
5
|
const ThreadsPageSchema = z.object({
|
|
6
6
|
repository: z.object({
|
|
@@ -33,7 +33,11 @@ export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: In
|
|
|
33
33
|
* The ids of every review comment that sits in a resolved thread. REST has no resolved flag,
|
|
34
34
|
* so this one GraphQL query is joined to the REST comments by id.
|
|
35
35
|
*/
|
|
36
|
-
export async function fetchResolvedCommentIds(
|
|
36
|
+
export async function fetchResolvedCommentIds(
|
|
37
|
+
gh: HostClient,
|
|
38
|
+
repo: Repo,
|
|
39
|
+
number: number
|
|
40
|
+
): Promise<Set<number>> {
|
|
37
41
|
const resolved = new Set<number>()
|
|
38
42
|
let after: string | null = null
|
|
39
43
|
for (;;) {
|