@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.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. 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
+ }