@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,151 @@
1
+ import { sanitizeKey, uniqueKey } from '../contract/keys.js'
2
+ import type { FileEntry } from '../contract/review-artifact.js'
3
+ import { FILE_STATUSES } from '../contract/review-artifact.js'
4
+ import type { Git } from './git.js'
5
+ import { langForPath } from './lang.js'
6
+ import { buildHunkIndex } from './patch-lines.js'
7
+
8
+ export type FileStatus = (typeof FILE_STATUSES)[number]
9
+
10
+ /** One file of the diff with its patch. The patch starts at the first `@@` line. */
11
+ export interface CollectedFile {
12
+ path: string
13
+ oldPath?: string
14
+ key: string
15
+ status: FileStatus
16
+ additions: number
17
+ deletions: number
18
+ lang?: string
19
+ patch: string
20
+ }
21
+
22
+ /** Splits `git diff` output into one block per `diff --git` header. */
23
+ export function splitBlocks(diffText: string): string[][] {
24
+ const blocks: string[][] = []
25
+ let cur: string[] | null = null
26
+ for (const line of diffText.split('\n')) {
27
+ if (line.startsWith('diff --git ')) {
28
+ if (cur !== null) {
29
+ blocks.push(cur)
30
+ }
31
+ cur = []
32
+ }
33
+ if (cur !== null) {
34
+ cur.push(line)
35
+ }
36
+ }
37
+ if (cur !== null) {
38
+ blocks.push(cur)
39
+ }
40
+ return blocks
41
+ }
42
+
43
+ function stripPrefix(p: string | null): string | null {
44
+ if (p === null || p === '/dev/null') {
45
+ return null
46
+ }
47
+ return p.length > 2 && p[1] === '/' ? p.slice(2) : p
48
+ }
49
+
50
+ /** Parses one `diff --git` block. Returns null when no path can be found. */
51
+ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
52
+ const hunkAt = lines.findIndex(l => l.startsWith('@@'))
53
+ const header = hunkAt === -1 ? lines : lines.slice(0, hunkAt)
54
+ const body = hunkAt === -1 ? [] : lines.slice(hunkAt)
55
+
56
+ let oldRaw: string | null = null
57
+ let newRaw: string | null = null
58
+ let renameFrom: string | null = null
59
+ for (const l of header) {
60
+ if (l.startsWith('--- ')) {
61
+ oldRaw = l.slice(4).trim()
62
+ } else if (l.startsWith('+++ ')) {
63
+ newRaw = l.slice(4).trim()
64
+ } else if (l.startsWith('rename from ')) {
65
+ renameFrom = l.slice('rename from '.length)
66
+ }
67
+ }
68
+ const oldPath = stripPrefix(oldRaw)
69
+ const newPath = stripPrefix(newRaw)
70
+ let path = newPath ?? oldPath
71
+
72
+ if (path === null) {
73
+ // No ---/+++ pair: a pure mode change, a rename without content change, or a binary file.
74
+ // Take the b-side of the "diff --git" line.
75
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(lines[0] ?? '')
76
+ path = m?.[2] ?? null
77
+ }
78
+ if (path === null) {
79
+ return null
80
+ }
81
+
82
+ const isBinary = header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
83
+ let status: FileStatus
84
+ if (renameFrom !== null) {
85
+ status = 'renamed'
86
+ } else if (oldPath === null && newPath !== null) {
87
+ status = 'added'
88
+ } else if (newPath === null && oldPath !== null) {
89
+ status = 'deleted'
90
+ } else if (isBinary) {
91
+ status = 'binary'
92
+ } else {
93
+ status = 'modified'
94
+ }
95
+
96
+ // Trailing empty line from the final "\n" split belongs to no hunk.
97
+ const bodyLines = body.length > 0 && body[body.length - 1] === '' ? body.slice(0, -1) : body
98
+ const additions = bodyLines.filter(l => l.startsWith('+')).length
99
+ const deletions = bodyLines.filter(l => l.startsWith('-')).length
100
+
101
+ const file: Omit<CollectedFile, 'key'> = { path, status, additions, deletions, patch: bodyLines.join('\n') }
102
+ const lang = langForPath(path)
103
+ if (lang !== undefined) {
104
+ file.lang = lang
105
+ }
106
+ if (renameFrom !== null) {
107
+ file.oldPath = renameFrom
108
+ }
109
+ return file
110
+ }
111
+
112
+ /** Pure parse of a whole `git diff` output. Keys are unique across the result. */
113
+ export function parseUnifiedDiff(diffText: string): CollectedFile[] {
114
+ const used = new Set<string>()
115
+ const out: CollectedFile[] = []
116
+ for (const block of splitBlocks(diffText)) {
117
+ const parsed = parseBlock(block)
118
+ if (parsed === null) {
119
+ continue
120
+ }
121
+ out.push({ ...parsed, key: uniqueKey(sanitizeKey(parsed.path), used) })
122
+ }
123
+ return out
124
+ }
125
+
126
+ export async function collectDiffs(git: Git, base: string, head: string): Promise<CollectedFile[]> {
127
+ return parseUnifiedDiff(await git.diff(base, head))
128
+ }
129
+
130
+ /** The manifest entry for a collected file: everything but the patch, plus the hunk index. */
131
+ export function toFileEntry(file: CollectedFile): FileEntry {
132
+ const entry: FileEntry = {
133
+ path: file.path,
134
+ key: file.key,
135
+ status: file.status,
136
+ additions: file.additions,
137
+ deletions: file.deletions,
138
+ hunks: buildHunkIndex(file.key, file.patch),
139
+ }
140
+ if (file.oldPath !== undefined) {
141
+ entry.oldPath = file.oldPath
142
+ }
143
+ if (file.lang !== undefined) {
144
+ entry.lang = file.lang
145
+ }
146
+ return entry
147
+ }
148
+
149
+ export function toPatchMap(files: CollectedFile[]): Record<string, string> {
150
+ return Object.fromEntries(files.map(f => [f.key, f.patch]))
151
+ }
package/src/git/git.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /**
4
+ * The git operations the tool needs. Routes, stores, and the CLI receive an implementation
5
+ * through AppContext and never spawn git themselves. Tests use an in-memory fake.
6
+ */
7
+ export interface Git {
8
+ /** `git rev-parse <ref>`; throws GitError when the ref is unknown. */
9
+ revParse(ref: string): Promise<string>
10
+ mergeBase(a: string, b: string): Promise<string>
11
+ /** True when the commit object exists locally. */
12
+ commitExists(sha: string): Promise<boolean>
13
+ /** `git merge-base --is-ancestor a b`: false when either commit is missing. */
14
+ isAncestor(a: string, b: string): Promise<boolean>
15
+ /** `git rev-list --count a..b`: how many commits b is ahead of a. */
16
+ countCommitsBetween(a: string, b: string): Promise<number>
17
+ /** Full unified diff between two commits, rename detection on, 3 lines of context. */
18
+ diff(base: string, head: string): Promise<string>
19
+ fetch(remote: string, refspecs: string[]): Promise<void>
20
+ /** Content of `<ref>:<path>`; null when the path does not exist at that ref. */
21
+ show(ref: string, path: string): Promise<Buffer | null>
22
+ /** Byte size of `<ref>:<path>`; null when missing. */
23
+ blobSize(ref: string, path: string): Promise<number | null>
24
+ /** Author name of one commit (`git log -1 --format=%an`). */
25
+ commitAuthor(ref: string): Promise<string>
26
+ topLevel(): Promise<string>
27
+ commonDir(): Promise<string>
28
+ remoteUrl(name: string): Promise<string | null>
29
+ }
30
+
31
+ export const STDERR_MESSAGE_MAX = 300
32
+
33
+ /**
34
+ * git stderr fit for an error message that reaches the JSON envelope or the CLI: credentials in
35
+ * URLs (`https://user:token@host`) are replaced by `***`, and the text is cut at STDERR_MESSAGE_MAX.
36
+ */
37
+ export function redactStderr(stderr: string): string {
38
+ const redacted = stderr.trim().replace(/(\w+:\/\/)[^/\s@]+@/g, '$1***@')
39
+ return redacted.length > STDERR_MESSAGE_MAX ? `${redacted.slice(0, STDERR_MESSAGE_MAX)}…` : redacted
40
+ }
41
+
42
+ export class GitError extends Error {
43
+ readonly args: string[]
44
+ readonly stderr: string
45
+ readonly exitCode: number
46
+
47
+ constructor(args: string[], stderr: string, exitCode: number) {
48
+ super(`git ${args.join(' ')} failed (${exitCode}): ${redactStderr(stderr)}`)
49
+ this.name = 'GitError'
50
+ this.args = args
51
+ this.stderr = stderr
52
+ this.exitCode = exitCode
53
+ }
54
+ }
55
+
56
+ interface ExecResult {
57
+ stdout: Buffer
58
+ stderr: string
59
+ code: number
60
+ }
61
+
62
+ /** Runs git with an argument array; never a shell. */
63
+ export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
64
+ return new Promise(resolve => {
65
+ execFile('git', args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
66
+ const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
67
+ resolve({ stdout, stderr: stderr.toString('utf8'), code })
68
+ })
69
+ })
70
+ }
71
+
72
+ export type GitExec = typeof execGit
73
+
74
+ export function createGit(cwd: string, exec: GitExec = execGit): Git {
75
+ async function run(args: string[]): Promise<string> {
76
+ const r = await exec(cwd, args)
77
+ if (r.code !== 0) {
78
+ throw new GitError(args, r.stderr, r.code)
79
+ }
80
+ return r.stdout.toString('utf8').replace(/\n$/, '')
81
+ }
82
+
83
+ return {
84
+ revParse: ref => run(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]),
85
+ mergeBase: (a, b) => run(['merge-base', a, b]),
86
+ commitExists: async sha => {
87
+ const r = await exec(cwd, ['cat-file', '-e', `${sha}^{commit}`])
88
+ return r.code === 0
89
+ },
90
+ isAncestor: async (a, b) => {
91
+ const r = await exec(cwd, ['merge-base', '--is-ancestor', a, b])
92
+ return r.code === 0
93
+ },
94
+ countCommitsBetween: async (a, b) => Number(await run(['rev-list', '--count', `${a}..${b}`])),
95
+ diff: (base, head) => run(['diff', '--no-color', '--no-ext-diff', '-M', '-U3', base, head]),
96
+ fetch: async (remote, refspecs) => {
97
+ await run(['fetch', '--no-tags', '--quiet', remote, ...refspecs])
98
+ },
99
+ show: async (ref, path) => {
100
+ const r = await exec(cwd, ['show', `${ref}:${path}`])
101
+ return r.code === 0 ? r.stdout : null
102
+ },
103
+ blobSize: async (ref, path) => {
104
+ const r = await exec(cwd, ['cat-file', '-s', `${ref}:${path}`])
105
+ return r.code === 0 ? Number(r.stdout.toString('utf8').trim()) : null
106
+ },
107
+ commitAuthor: ref => run(['log', '-1', '--format=%an', ref]),
108
+ topLevel: () => run(['rev-parse', '--show-toplevel']),
109
+ commonDir: () => run(['rev-parse', '--path-format=absolute', '--git-common-dir']),
110
+ remoteUrl: async name => {
111
+ const r = await exec(cwd, ['remote', 'get-url', name])
112
+ return r.code === 0 ? r.stdout.toString('utf8').trim() : null
113
+ },
114
+ }
115
+ }
@@ -0,0 +1 @@
1
+ export { LANG_BY_EXT, langForPath } from '../../static/js/lang.js'
@@ -0,0 +1,79 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { CollectedFile } from './diff-collector.js'
4
+ import type { Git } from './git.js'
5
+
6
+ export const MATERIALIZE_MAX_BYTES = 1024 * 1024
7
+
8
+ export interface MaterializeOptions {
9
+ headSha: string
10
+ mergeBaseSha: string
11
+ files: readonly CollectedFile[]
12
+ outDir: string
13
+ }
14
+
15
+ export interface MaterializeResult {
16
+ written: string[]
17
+ skipped: Array<{ path: string; reason: 'binary' | 'too-large' | 'missing' }>
18
+ }
19
+
20
+ /** Rejects paths that would escape the output directory. */
21
+ export function safeJoin(root: string, rel: string): string | null {
22
+ const full = path.resolve(root, rel)
23
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep
24
+ return full.startsWith(rootWithSep) ? full : null
25
+ }
26
+
27
+ async function writeSide(
28
+ git: Git,
29
+ ref: string,
30
+ filePath: string,
31
+ sideDir: string,
32
+ result: MaterializeResult
33
+ ): Promise<void> {
34
+ const target = safeJoin(sideDir, filePath)
35
+ if (target === null) {
36
+ result.skipped.push({ path: filePath, reason: 'missing' })
37
+ return
38
+ }
39
+ const size = await git.blobSize(ref, filePath)
40
+ if (size === null) {
41
+ result.skipped.push({ path: filePath, reason: 'missing' })
42
+ return
43
+ }
44
+ if (size > MATERIALIZE_MAX_BYTES) {
45
+ result.skipped.push({ path: filePath, reason: 'too-large' })
46
+ return
47
+ }
48
+ const content = await git.show(ref, filePath)
49
+ if (content === null) {
50
+ result.skipped.push({ path: filePath, reason: 'missing' })
51
+ return
52
+ }
53
+ await mkdir(path.dirname(target), { recursive: true })
54
+ await writeFile(target, content)
55
+ result.written.push(path.relative(path.dirname(sideDir), target))
56
+ }
57
+
58
+ /**
59
+ * Writes the changed files as they are at the PR head (`head/<path>`) and at the merge base
60
+ * (`base/<path>`), so the agent and the context route read the PR without a checkout.
61
+ */
62
+ export async function materialize(git: Git, opts: MaterializeOptions): Promise<MaterializeResult> {
63
+ const result: MaterializeResult = { written: [], skipped: [] }
64
+ const headDir = path.join(opts.outDir, 'head')
65
+ const baseDir = path.join(opts.outDir, 'base')
66
+ for (const f of opts.files) {
67
+ if (f.status === 'binary') {
68
+ result.skipped.push({ path: f.path, reason: 'binary' })
69
+ continue
70
+ }
71
+ if (f.status !== 'deleted') {
72
+ await writeSide(git, opts.headSha, f.path, headDir, result)
73
+ }
74
+ if (f.status !== 'added') {
75
+ await writeSide(git, opts.mergeBaseSha, f.oldPath ?? f.path, baseDir, result)
76
+ }
77
+ }
78
+ return result
79
+ }
@@ -0,0 +1,60 @@
1
+ // The header parser and the line lookup live in static/js/hunks.js so the browser can load them
2
+ // without a bundler; the server imports the same code here.
3
+ import { parseHunkHeader } from '../../static/js/hunks.js'
4
+ import { hunkId } from '../contract/keys.js'
5
+ import type { Hunk } from '../contract/review-artifact.js'
6
+
7
+ export { hunkForLine, hunkLineRanges, parseHunkHeader } from '../../static/js/hunks.js'
8
+
9
+ export interface ParsedHunk {
10
+ header: string
11
+ oldStart: number
12
+ oldLines: number
13
+ newStart: number
14
+ newLines: number
15
+ /** Body lines with their leading marker (`+`, `-`, ` `, `\`). */
16
+ lines: string[]
17
+ }
18
+
19
+ /** Splits a patch (starting at its first `@@`) into hunks. Lines before the first header are dropped. */
20
+ export function splitHunks(patch: string): ParsedHunk[] {
21
+ const out: ParsedHunk[] = []
22
+ if (patch === '') {
23
+ return out
24
+ }
25
+ let cur: ParsedHunk | null = null
26
+ for (const line of patch.split('\n')) {
27
+ const head = parseHunkHeader(line)
28
+ if (head !== null) {
29
+ cur = { ...head, header: line, lines: [] }
30
+ out.push(cur)
31
+ continue
32
+ }
33
+ if (cur !== null) {
34
+ cur.lines.push(line)
35
+ }
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** Hunk ids are `<key>#<n>` with n the 1-based position in the file's patch. */
41
+ export function buildHunkIndex(key: string, patch: string): Hunk[] {
42
+ return splitHunks(patch).map((h, i) => ({
43
+ id: hunkId(key, i + 1),
44
+ header: h.header,
45
+ oldStart: h.oldStart,
46
+ oldLines: h.oldLines,
47
+ newStart: h.newStart,
48
+ newLines: h.newLines,
49
+ }))
50
+ }
51
+
52
+ /**
53
+ * The patch with a `### hunk <id>` line before every hunk header, so the agent reads the same
54
+ * ids from `derived/patches/<key>.diff` that the manifest and the prompt use.
55
+ */
56
+ export function labelPatch(key: string, patch: string): string {
57
+ return splitHunks(patch)
58
+ .map((h, i) => [`### hunk ${hunkId(key, i + 1)}`, h.header, ...h.lines].join('\n'))
59
+ .join('\n')
60
+ }
@@ -0,0 +1,288 @@
1
+ // Finding the canvas zip a human attached to the pull request and downloading it with the gh
2
+ // token. The token is read per request, passed to github.com only, and never logged or stored.
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
+ }
25
+
26
+ const FILE_LINK_RE = /https:\/\/github\.com\/user-attachments\/files\/\d+\/([A-Za-z0-9._-]+\.zip)/g
27
+ const ASSET_LINK_RE =
28
+ /\[([A-Za-z0-9._-]+\.zip)\]\((https:\/\/github\.com\/user-attachments\/assets\/[0-9a-fA-F-]{36})\)/g
29
+
30
+ /**
31
+ * Zip links in one markdown text. GitHub serves an attachment either under `files/<id>/<name>`,
32
+ * where the name is in the URL, or under `assets/<uuid>`, where only the markdown label has it.
33
+ */
34
+ export function findAttachmentLinks(text: string): AttachmentLink[] {
35
+ const links: AttachmentLink[] = []
36
+ const seen = new Set<string>()
37
+ for (const m of text.matchAll(FILE_LINK_RE)) {
38
+ const [url, name] = [m[0], m[1]]
39
+ if (name !== undefined && !seen.has(url)) {
40
+ seen.add(url)
41
+ links.push({ url, name })
42
+ }
43
+ }
44
+ for (const m of text.matchAll(ASSET_LINK_RE)) {
45
+ const [, name, url] = m
46
+ if (name !== undefined && url !== undefined && !seen.has(url)) {
47
+ seen.add(url)
48
+ links.push({ url, name })
49
+ }
50
+ }
51
+ return links
52
+ }
53
+
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 sha7 = target.headSha.slice(0, 7)
98
+ const score = (c: AttachmentCandidate): number =>
99
+ (c.parsed.sha7 === sha7 ? 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
+ /**
109
+ * A fingerprint of everything discovery reads, so an unchanged pull request is not scanned again.
110
+ * It covers the text of every comment, because an edit can swap one zip link for another, and the
111
+ * head sha, because the ranking and `matchesHead` are answers about that commit.
112
+ */
113
+ export function discoveryFingerprint(body: string, comments: CommentsPayload, headSha: string): string {
114
+ const hash = createHash('sha1')
115
+ hash.update(`${headSha}\n${body}`)
116
+ for (const c of [...comments.issueComments, ...comments.reviewComments]) {
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.sha7 === pr.headSha.slice(0, 7),
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
+ }
288
+ }