@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { PostedReview } from '../host/host.js'
|
|
3
|
+
import type { PendingComment } from '../contract/pending.js'
|
|
4
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
5
|
+
import type { ReviewEvent } from '../contract/reviews.js'
|
|
6
|
+
import type { HostClient } from '../host/client.js'
|
|
7
|
+
import type { Derived } from '../store/derived-store.js'
|
|
8
|
+
import { publishGitlabDrafts } from './publish-drafts.js'
|
|
9
|
+
import { gitlabMrUrl, gitlabNoteUrl, gitlabProjectApi } from './project.js'
|
|
10
|
+
|
|
11
|
+
const GlNoteSchema = z.object({
|
|
12
|
+
id: z.number().int(),
|
|
13
|
+
created_at: z.string().optional(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const GlMrSchema = z.object({
|
|
17
|
+
id: z.number().int().optional(),
|
|
18
|
+
iid: z.number().int().optional(),
|
|
19
|
+
web_url: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/**
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export async function postGitlabReview(
|
|
28
|
+
client: HostClient,
|
|
29
|
+
repo: Repo,
|
|
30
|
+
number: number,
|
|
31
|
+
headSha: string,
|
|
32
|
+
input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
|
|
33
|
+
webBase: string,
|
|
34
|
+
diff: Derived = { files: [], patches: {} }
|
|
35
|
+
): Promise<PostedReview> {
|
|
36
|
+
const base = `${gitlabProjectApi(repo)}/merge_requests/${number}`
|
|
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: [] }
|
|
42
|
+
if (input.event === 'APPROVE') {
|
|
43
|
+
if (!batched && input.body.trim() !== '') {
|
|
44
|
+
await client.post(`${base}/notes`, { body: input.body })
|
|
45
|
+
}
|
|
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
|
+
}
|
|
63
|
+
const mr = GlMrSchema.safeParse(raw).data ?? {}
|
|
64
|
+
return {
|
|
65
|
+
comments,
|
|
66
|
+
warnings,
|
|
67
|
+
id: mr.id ?? mr.iid ?? number,
|
|
68
|
+
state: 'APPROVED',
|
|
69
|
+
url: mr.web_url ?? mrUrl,
|
|
70
|
+
submittedAt: new Date().toISOString(),
|
|
71
|
+
}
|
|
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
|
+
}
|
|
83
|
+
const raw = await client.post(`${base}/notes`, { body: input.body })
|
|
84
|
+
const note = GlNoteSchema.parse(raw)
|
|
85
|
+
return {
|
|
86
|
+
comments,
|
|
87
|
+
warnings: [],
|
|
88
|
+
id: note.id,
|
|
89
|
+
state: input.event === 'COMMENT' ? 'COMMENTED' : 'CHANGES_REQUESTED',
|
|
90
|
+
url: gitlabNoteUrl(webBase, repo, number, note.id),
|
|
91
|
+
submittedAt: note.created_at ?? new Date().toISOString(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
2
|
+
|
|
3
|
+
export function gitlabProjectApi(repo: Repo): string {
|
|
4
|
+
return `projects/${encodeURIComponent(`${repo.owner}/${repo.name}`)}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function gitlabMrUrl(webBase: string, repo: Repo, iid: number): string {
|
|
8
|
+
return `${webBase}/${repo.owner}/${repo.name}/-/merge_requests/${iid}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function gitlabNoteUrl(webBase: string, repo: Repo, iid: number, noteId: number): string {
|
|
12
|
+
return `${gitlabMrUrl(webBase, repo, iid)}#note_${noteId}`
|
|
13
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Finding the canvas zip a human attached to the review and downloading it with the host CLI's
|
|
2
|
+
// token. The token is read per request, sent to the forge alone, and never logged or stored.
|
|
3
|
+
import { readCanvasComment } from '../canvas/comment.js'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { importCanvas } from '../canvas/import.js'
|
|
6
|
+
import { type ParsedCanvasName, parseCanvasZipName } from '../canvas/name.js'
|
|
7
|
+
import { CANVAS_ZIP_MAX_BYTES, hasZipMagic } from '../canvas/zip.js'
|
|
8
|
+
import type { ImportResult, SharedCanvasInfo } from '../contract/api.js'
|
|
9
|
+
import type { CommentsPayload } from '../contract/comments.js'
|
|
10
|
+
import type { Pr, Repo } from '../contract/review-artifact.js'
|
|
11
|
+
import { BodyTooLargeError, readCappedBody } from '../server/capped-body.js'
|
|
12
|
+
import type { AppContext } from '../server/context.js'
|
|
13
|
+
import { AppError } from '../server/errors.js'
|
|
14
|
+
import type { HostAttachments } from './host.js'
|
|
15
|
+
|
|
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
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AttachmentCandidate extends AttachmentLink {
|
|
27
|
+
bytes?: Uint8Array
|
|
28
|
+
parsed: ParsedCanvasName
|
|
29
|
+
/**
|
|
30
|
+
* When the text carrying the link was last edited. A link added to an old comment counts from
|
|
31
|
+
* the edit, which is when the attachment appeared.
|
|
32
|
+
*/
|
|
33
|
+
postedAt: string
|
|
34
|
+
/** Position in the scan, used when two links carry the same time. */
|
|
35
|
+
order: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DiscoverySources {
|
|
39
|
+
/** The PR body and the time the pull request was last edited. */
|
|
40
|
+
body: string
|
|
41
|
+
bodyUpdatedAt: string
|
|
42
|
+
comments: CommentsPayload
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every link in the PR body and its comments whose name is a canvas zip of this repository. */
|
|
46
|
+
export function collectCandidates(
|
|
47
|
+
sources: DiscoverySources,
|
|
48
|
+
repo: Repo,
|
|
49
|
+
attachments: HostAttachments
|
|
50
|
+
): AttachmentCandidate[] {
|
|
51
|
+
const texts: Array<{ text: string; postedAt: string }> = [
|
|
52
|
+
{ text: sources.body, postedAt: sources.bodyUpdatedAt },
|
|
53
|
+
...sources.comments.issueComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
54
|
+
...sources.comments.reviewComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
|
|
55
|
+
]
|
|
56
|
+
const candidates: AttachmentCandidate[] = []
|
|
57
|
+
let order = 0
|
|
58
|
+
for (const { text, postedAt } of texts) {
|
|
59
|
+
for (const link of attachments.findLinks(text, repo)) {
|
|
60
|
+
const parsed = parseCanvasZipName(link.name, repo)
|
|
61
|
+
if (parsed !== null) {
|
|
62
|
+
candidates.push({ ...link, parsed, postedAt, order: order++ })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const comment of sources.comments.issueComments) {
|
|
67
|
+
const embedded = readCanvasComment(comment.body)
|
|
68
|
+
if (embedded === null) continue
|
|
69
|
+
const parsed = parseCanvasZipName(embedded.name, repo)
|
|
70
|
+
if (parsed !== null) {
|
|
71
|
+
candidates.push({ ...embedded, url: comment.url, parsed, postedAt: comment.updatedAt, order: order++ })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return candidates
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the name names a pull request, and it is not the one being looked at. */
|
|
78
|
+
function namesAnotherPr(c: AttachmentCandidate, prNumber: number): boolean {
|
|
79
|
+
return c.parsed.prNumber !== undefined && c.parsed.prNumber !== prNumber
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The canvas for this head wins, then one exported for this PR, then the one attached last. A name
|
|
84
|
+
* that carries another pull request's number sorts below all of them: import refuses it, so it may
|
|
85
|
+
* never take a place in the try budget from an attachment that could be used.
|
|
86
|
+
*/
|
|
87
|
+
export function rankCandidates(
|
|
88
|
+
candidates: AttachmentCandidate[],
|
|
89
|
+
target: { headSha: string; prNumber: number }
|
|
90
|
+
): AttachmentCandidate[] {
|
|
91
|
+
const shaPrefix = target.headSha.slice(0, 8)
|
|
92
|
+
const score = (c: AttachmentCandidate): number =>
|
|
93
|
+
namesAnotherPr(c, target.prNumber)
|
|
94
|
+
? -1
|
|
95
|
+
: (c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
|
|
96
|
+
return [...candidates].sort(
|
|
97
|
+
(a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How many attachments one scan tries before it reports the best one as unusable. */
|
|
102
|
+
export const MAX_CANDIDATES_TRIED = 3
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A fingerprint of everything discovery reads, so an unchanged pull request is not scanned again.
|
|
106
|
+
* It covers the text of every comment, because an edit can swap one zip link for another, and the
|
|
107
|
+
* head sha, because the ranking and `namesHead` are answers about that commit.
|
|
108
|
+
*/
|
|
109
|
+
export function discoveryFingerprint(body: string, comments: CommentsPayload, headSha: string): string {
|
|
110
|
+
const hash = createHash('sha1')
|
|
111
|
+
hash.update(`${headSha}\n${body}`)
|
|
112
|
+
for (const c of [...comments.issueComments, ...comments.reviewComments]) {
|
|
113
|
+
hash.update(`\u0000${c.id}:${c.createdAt}:${c.body}`)
|
|
114
|
+
}
|
|
115
|
+
return hash.digest('hex')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type DownloadResult = { ok: true; bytes: Uint8Array } | { ok: false; reason: DownloadFailure }
|
|
119
|
+
|
|
120
|
+
function allowedUrl(raw: string, hosts: ReadonlySet<string>, base?: string): URL | null {
|
|
121
|
+
let url: URL
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(raw, base)
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return url.protocol === 'https:' && hosts.has(url.host) ? url : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Downloads one attachment. The CLI token goes to the forge alone: a redirect leads to signed
|
|
134
|
+
* storage, which refuses a request that holds both a signature and a token, and a link that
|
|
135
|
+
* points at storage from the start is fetched bare for the same reason.
|
|
136
|
+
*/
|
|
137
|
+
export async function downloadAttachment(ctx: AppContext, rawUrl: string): Promise<DownloadResult> {
|
|
138
|
+
const { hostname, attachments } = ctx.config.host
|
|
139
|
+
const { allowedHosts, authHeader } = attachments
|
|
140
|
+
const first = allowedUrl(rawUrl, allowedHosts)
|
|
141
|
+
if (first === null) {
|
|
142
|
+
return { ok: false, reason: 'network' }
|
|
143
|
+
}
|
|
144
|
+
const token = await ctx.gh.authToken()
|
|
145
|
+
if (token === null) {
|
|
146
|
+
return { ok: false, reason: 'auth-required' }
|
|
147
|
+
}
|
|
148
|
+
let url = first
|
|
149
|
+
// One deadline for the redirects and the body together, so three slow hops cannot add up.
|
|
150
|
+
const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
151
|
+
try {
|
|
152
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
153
|
+
const accept = 'application/octet-stream'
|
|
154
|
+
const headers = url.host === hostname ? { accept, ...authHeader(token) } : { accept }
|
|
155
|
+
const res: Response = await ctx.fetch(url, {
|
|
156
|
+
headers,
|
|
157
|
+
redirect: 'manual',
|
|
158
|
+
signal: deadline,
|
|
159
|
+
})
|
|
160
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
161
|
+
const next = allowedUrl(res.headers.get('location') ?? '', allowedHosts, url.toString())
|
|
162
|
+
if (next === null) {
|
|
163
|
+
return { ok: false, reason: 'network' }
|
|
164
|
+
}
|
|
165
|
+
url = next
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
169
|
+
return { ok: false, reason: 'auth-required' }
|
|
170
|
+
}
|
|
171
|
+
if (res.status !== 200) {
|
|
172
|
+
return { ok: false, reason: 'network' }
|
|
173
|
+
}
|
|
174
|
+
let bytes: Uint8Array
|
|
175
|
+
try {
|
|
176
|
+
bytes = await readCappedBody(res, CANVAS_ZIP_MAX_BYTES)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof BodyTooLargeError) {
|
|
179
|
+
return { ok: false, reason: 'too-large' }
|
|
180
|
+
}
|
|
181
|
+
throw err
|
|
182
|
+
}
|
|
183
|
+
return hasZipMagic(bytes) ? { ok: true, bytes } : { ok: false, reason: 'not-zip' }
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// A network failure, a timeout, or a body that stopped mid-stream all read the same here.
|
|
187
|
+
return { ok: false, reason: 'network' }
|
|
188
|
+
}
|
|
189
|
+
return { ok: false, reason: 'network' }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A downloaded zip that import refused, told in the words the callout uses. */
|
|
193
|
+
export function importFailureReason(err: unknown): DownloadFailure {
|
|
194
|
+
if (!(err instanceof AppError)) {
|
|
195
|
+
return 'not-zip'
|
|
196
|
+
}
|
|
197
|
+
if (err.code === 'CANVAS_TOO_LARGE') {
|
|
198
|
+
return 'too-large'
|
|
199
|
+
}
|
|
200
|
+
if (err.code === 'CANVAS_PR_MISMATCH') {
|
|
201
|
+
return 'pr-mismatch'
|
|
202
|
+
}
|
|
203
|
+
return err.code === 'CANVAS_REPO_MISMATCH' ? 'name-mismatch' : 'not-zip'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface DiscoveryOutcome {
|
|
207
|
+
sharedCanvas: SharedCanvasInfo | null
|
|
208
|
+
imported: ImportResult | null
|
|
209
|
+
warnings: string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Looks for a canvas attached to the PR and imports the best one. A found-but-unusable zip is
|
|
214
|
+
* reported to the page, which offers the link and the drop zone instead.
|
|
215
|
+
*/
|
|
216
|
+
export async function discoverSharedCanvas(
|
|
217
|
+
ctx: AppContext,
|
|
218
|
+
pr: Pr,
|
|
219
|
+
comments: CommentsPayload
|
|
220
|
+
): Promise<DiscoveryOutcome> {
|
|
221
|
+
if (pr.number === null) {
|
|
222
|
+
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
223
|
+
}
|
|
224
|
+
const sources: DiscoverySources = { body: pr.body, bodyUpdatedAt: pr.updatedAt ?? '', comments }
|
|
225
|
+
const ranked = rankCandidates(collectCandidates(sources, ctx.config.repo, ctx.config.host.attachments), {
|
|
226
|
+
headSha: pr.headSha,
|
|
227
|
+
prNumber: pr.number,
|
|
228
|
+
})
|
|
229
|
+
const best = ranked[0]
|
|
230
|
+
if (best === undefined) {
|
|
231
|
+
return { sharedCanvas: null, imported: null, warnings: [] }
|
|
232
|
+
}
|
|
233
|
+
const warnings: string[] = []
|
|
234
|
+
const first = await tryCandidate(ctx, pr, pr.number, best)
|
|
235
|
+
warnings.push(...first.warnings)
|
|
236
|
+
if (first.imported !== null) {
|
|
237
|
+
return { ...first, warnings }
|
|
238
|
+
}
|
|
239
|
+
// A broken attachment must not hide a good one behind it, so the next ones are tried too.
|
|
240
|
+
for (const candidate of ranked.slice(1, MAX_CANDIDATES_TRIED)) {
|
|
241
|
+
const attempt = await tryCandidate(ctx, pr, pr.number, candidate)
|
|
242
|
+
warnings.push(...attempt.warnings)
|
|
243
|
+
if (attempt.imported !== null) {
|
|
244
|
+
return { ...attempt, warnings }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Every attachment failed; the page shows the best one, its reason, and the drop zone.
|
|
248
|
+
return { ...first, warnings }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One attachment: download it and import it, or say why that did not work. */
|
|
252
|
+
async function tryCandidate(
|
|
253
|
+
ctx: AppContext,
|
|
254
|
+
pr: Pr,
|
|
255
|
+
prNumber: number,
|
|
256
|
+
candidate: AttachmentCandidate
|
|
257
|
+
): Promise<DiscoveryOutcome> {
|
|
258
|
+
const shared = {
|
|
259
|
+
url: candidate.url,
|
|
260
|
+
name: candidate.name,
|
|
261
|
+
namesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
|
|
262
|
+
}
|
|
263
|
+
// A name that carries another pull request's number is a zip attached to the wrong PR. Import
|
|
264
|
+
// would refuse it anyway, so it is reported without spending a download on it.
|
|
265
|
+
if (namesAnotherPr(candidate, prNumber)) {
|
|
266
|
+
return {
|
|
267
|
+
sharedCanvas: { ...shared, downloadable: false, reason: 'pr-mismatch' },
|
|
268
|
+
imported: null,
|
|
269
|
+
warnings: [`${candidate.name} was exported for #${candidate.parsed.prNumber}, not #${prNumber}`],
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const download: DownloadResult =
|
|
273
|
+
candidate.bytes === undefined
|
|
274
|
+
? await downloadAttachment(ctx, candidate.url)
|
|
275
|
+
: { ok: true, bytes: candidate.bytes }
|
|
276
|
+
if (!download.ok) {
|
|
277
|
+
return {
|
|
278
|
+
sharedCanvas: { ...shared, downloadable: false, reason: download.reason },
|
|
279
|
+
imported: null,
|
|
280
|
+
warnings: [],
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const imported = await importCanvas(ctx, { bytes: download.bytes, prNumber, currentHead: pr })
|
|
285
|
+
return { sharedCanvas: { ...shared, downloadable: true }, imported, warnings: imported.warnings }
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return {
|
|
288
|
+
sharedCanvas: { ...shared, downloadable: false, reason: importFailureReason(err) },
|
|
289
|
+
imported: null,
|
|
290
|
+
warnings: [err instanceof Error ? err.message : String(err)],
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Capabilities } from '../contract/api.js'
|
|
2
|
+
|
|
3
|
+
/** What a page assumes before the probe answers: posting is tried and the host decides. */
|
|
4
|
+
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
5
|
+
canComment: 'unknown',
|
|
6
|
+
tokenKind: 'unprobed',
|
|
7
|
+
login: null,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
|
|
11
|
+
export const CAPABILITY_TTL_MS = 10 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
export interface CapabilityProbe {
|
|
14
|
+
get(opts?: { refresh?: boolean }): Promise<Capabilities>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The probe with its cache. One per server process; `refresh` skips the cache after the user
|
|
19
|
+
* changed their token.
|
|
20
|
+
*/
|
|
21
|
+
export function createCapabilityProbe(
|
|
22
|
+
probe: () => Promise<Capabilities>,
|
|
23
|
+
now: () => Date,
|
|
24
|
+
ttlMs = CAPABILITY_TTL_MS
|
|
25
|
+
): CapabilityProbe {
|
|
26
|
+
let cached: { at: number; value: Capabilities } | null = null
|
|
27
|
+
return {
|
|
28
|
+
get: async (opts = {}) => {
|
|
29
|
+
const at = now().getTime()
|
|
30
|
+
if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
|
|
31
|
+
return cached.value
|
|
32
|
+
}
|
|
33
|
+
const value = await probe()
|
|
34
|
+
cached = { at, value }
|
|
35
|
+
return value
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|