@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|
package/src/git/lang.ts
ADDED
|
@@ -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
|
+
}
|