@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,141 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Ids and lookups for diff rows. The id builders live in keys.js (shared with the server);
|
|
3
|
+
// this module adds the DOM side: finding a row, the nearest visible row, and jumping to a link.
|
|
4
|
+
import { flash, scrollIntoViewSafe } from './dom.js'
|
|
5
|
+
import { buildLineId, fileAnchorId, sanitizeKey } from './keys.js'
|
|
6
|
+
import { linkTargetId, parseLink } from './links.js'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
buildLineId,
|
|
10
|
+
fileAnchorId,
|
|
11
|
+
hunkAnchorId,
|
|
12
|
+
hunkId,
|
|
13
|
+
layerAnchorId,
|
|
14
|
+
parseHunkId,
|
|
15
|
+
parseLineId,
|
|
16
|
+
pointAnchorId,
|
|
17
|
+
sanitizeKey,
|
|
18
|
+
} from './keys.js'
|
|
19
|
+
|
|
20
|
+
/** @param {string} path */
|
|
21
|
+
export function keyFromPath(path) {
|
|
22
|
+
return sanitizeKey(path)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The row for a line, searched inside `root` (a file card or the document).
|
|
27
|
+
* @param {ParentNode} root
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @param {'new' | 'old'} side
|
|
30
|
+
* @param {number} line
|
|
31
|
+
* @returns {HTMLTableRowElement | null}
|
|
32
|
+
*/
|
|
33
|
+
export function findRow(root, key, side, line) {
|
|
34
|
+
const el = root.querySelector(`#${cssEscape(buildLineId(key, side, line))}`)
|
|
35
|
+
if (el instanceof HTMLTableRowElement) {
|
|
36
|
+
return el
|
|
37
|
+
}
|
|
38
|
+
if (side === 'old') {
|
|
39
|
+
// Context rows carry the new-side id and the old line as data; deleted rows have the old-side id.
|
|
40
|
+
const ctx = root.querySelector(`table[data-key="${cssEscape(key)}"] tr[data-old="${line}"]`)
|
|
41
|
+
if (ctx instanceof HTMLTableRowElement) {
|
|
42
|
+
return ctx
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The closest visible row at or before `line` on `side`, for anchors that fall on a folded-away
|
|
50
|
+
* line or in a gap between rows. Null when the table has no earlier row.
|
|
51
|
+
* @param {ParentNode} root
|
|
52
|
+
* @param {string} key
|
|
53
|
+
* @param {'new' | 'old'} side
|
|
54
|
+
* @param {number} line
|
|
55
|
+
* @param {number} [maxDistance]
|
|
56
|
+
* @returns {{ row: HTMLTableRowElement, approx: boolean } | null}
|
|
57
|
+
*/
|
|
58
|
+
export function nearestRow(root, key, side, line, maxDistance = 40) {
|
|
59
|
+
for (let d = 0; d <= maxDistance; d++) {
|
|
60
|
+
for (const candidate of d === 0 ? [line] : [line - d, line + d]) {
|
|
61
|
+
if (candidate < 1) {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const row = findRow(root, key, side, candidate)
|
|
65
|
+
if (row && isRowVisible(row)) {
|
|
66
|
+
return { row, approx: d !== 0 }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Can an anchor land on this row? A row the diff folded away (noise, or a block that only moved)
|
|
75
|
+
* is skipped, so the anchor lands nearby and reads `near line`. A row inside a canvas code fold
|
|
76
|
+
* carries `data-code-fold` and stays reachable: that fold opens when the anchor reveals it.
|
|
77
|
+
* @param {HTMLTableRowElement} row
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function isRowVisible(row) {
|
|
81
|
+
if (row.hasAttribute('data-code-fold')) {
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
return !row.classList.contains('folded') || row.classList.contains('shown')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scrolls to the element a canvas link points at and highlights it. Returns false when the
|
|
89
|
+
* link does not parse or its target is not on the page.
|
|
90
|
+
* @param {string} href
|
|
91
|
+
* @param {ParentNode} [root]
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function jumpTo(href, root = document) {
|
|
95
|
+
const link = parseLink(href)
|
|
96
|
+
if (!link) {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
const id = linkTargetId(link, keyFromPath)
|
|
100
|
+
if (link.kind !== 'layer') {
|
|
101
|
+
drawCardOf(root, link.path)
|
|
102
|
+
}
|
|
103
|
+
const el = root.querySelector(`#${cssEscape(id)}`)
|
|
104
|
+
if (!(el instanceof HTMLElement)) {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
const card = el.closest('.file')
|
|
108
|
+
const body = card?.querySelector('.file-body')
|
|
109
|
+
if (body instanceof HTMLElement && body.hidden) {
|
|
110
|
+
body.hidden = false
|
|
111
|
+
}
|
|
112
|
+
scrollIntoViewSafe(el)
|
|
113
|
+
flash(el)
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Draws the diff of a file card that is waiting to be seen, so a link into it has a row to land
|
|
119
|
+
* on. The card draws itself (`pr-file` in layers.js); this only asks it to do so now.
|
|
120
|
+
* @param {ParentNode} root
|
|
121
|
+
* @param {string} path
|
|
122
|
+
*/
|
|
123
|
+
export function drawCardOf(root, path) {
|
|
124
|
+
const card = root.querySelector(`#${cssEscape(fileAnchorId(keyFromPath(path)))}`)?.closest('pr-file')
|
|
125
|
+
const draw = /** @type {{ renderNow?: (force?: boolean) => boolean }} */ (card)?.renderNow
|
|
126
|
+
if (typeof draw === 'function' && card !== null && card !== undefined) {
|
|
127
|
+
// Force, so a card holding a huge patch behind `[ show diff ]` still has the row to land on.
|
|
128
|
+
draw.call(card, true)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* CSS.escape with a fallback for environments without it.
|
|
134
|
+
* @param {string} id
|
|
135
|
+
*/
|
|
136
|
+
export function cssEscape(id) {
|
|
137
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
138
|
+
return CSS.escape(id)
|
|
139
|
+
}
|
|
140
|
+
return id.replace(/([^a-zA-Z0-9_-])/g, '\\$1')
|
|
141
|
+
}
|
package/static/js/api.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
|
|
3
|
+
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
4
|
+
/** @typedef {import('./contract-types.js').PatchesResponse} PatchesResponse */
|
|
5
|
+
|
|
6
|
+
/** The server's error envelope as a thrown value. */
|
|
7
|
+
export class ApiError extends Error {
|
|
8
|
+
/**
|
|
9
|
+
* @param {ErrorEnvelope['error']} error
|
|
10
|
+
* @param {number} status
|
|
11
|
+
*/
|
|
12
|
+
constructor(error, status) {
|
|
13
|
+
super(error.message)
|
|
14
|
+
this.name = 'ApiError'
|
|
15
|
+
/** @type {ErrorEnvelope['error']['code']} */
|
|
16
|
+
this.code = error.code
|
|
17
|
+
/** @type {string | undefined} */
|
|
18
|
+
this.hint = error.hint
|
|
19
|
+
/** @type {number} */
|
|
20
|
+
this.status = status
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} body
|
|
26
|
+
* @returns {body is ErrorEnvelope}
|
|
27
|
+
*/
|
|
28
|
+
function isEnvelope(body) {
|
|
29
|
+
return (
|
|
30
|
+
typeof body === 'object' &&
|
|
31
|
+
body !== null &&
|
|
32
|
+
'error' in body &&
|
|
33
|
+
typeof body.error === 'object' &&
|
|
34
|
+
body.error !== null &&
|
|
35
|
+
'code' in body.error
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* GET/PUT/POST JSON. Non-2xx answers with the envelope become ApiError; anything else INTERNAL.
|
|
41
|
+
* @template T
|
|
42
|
+
* @param {string} url
|
|
43
|
+
* @param {{ method?: string, body?: unknown, fetchImpl?: typeof fetch | undefined }} [opts]
|
|
44
|
+
* @returns {Promise<T>}
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchJson(url, opts = {}) {
|
|
47
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
48
|
+
/** @type {RequestInit} */
|
|
49
|
+
const init = { method: opts.method ?? 'GET', headers: { accept: 'application/json' } }
|
|
50
|
+
if (opts.body !== undefined) {
|
|
51
|
+
init.headers = { ...init.headers, 'content-type': 'application/json' }
|
|
52
|
+
init.body = JSON.stringify(opts.body)
|
|
53
|
+
}
|
|
54
|
+
const res = await doFetch(url, init)
|
|
55
|
+
/** @type {unknown} */
|
|
56
|
+
let body = null
|
|
57
|
+
try {
|
|
58
|
+
body = await res.json()
|
|
59
|
+
} catch {
|
|
60
|
+
body = null
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
if (isEnvelope(body)) {
|
|
64
|
+
throw new ApiError(body.error, res.status)
|
|
65
|
+
}
|
|
66
|
+
throw new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
|
|
67
|
+
}
|
|
68
|
+
return /** @type {T} */ (body)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {number} prNumber
|
|
73
|
+
* @param {{ refresh?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
|
|
74
|
+
* @returns {Promise<PrBundle>}
|
|
75
|
+
*/
|
|
76
|
+
export function fetchBundle(prNumber, opts = {}) {
|
|
77
|
+
const q = opts.refresh ? '?refresh=1' : ''
|
|
78
|
+
return fetchJson(`/api/prs/${prNumber}${q}`, { fetchImpl: opts.fetchImpl })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} prNumber
|
|
83
|
+
* @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
|
|
84
|
+
* @returns {Promise<PatchesResponse>}
|
|
85
|
+
*/
|
|
86
|
+
export function fetchPatches(prNumber, opts = {}) {
|
|
87
|
+
const q = opts.headSha ? `?headSha=${encodeURIComponent(opts.headSha)}` : ''
|
|
88
|
+
return fetchJson(`/api/prs/${prNumber}/patches${q}`, { fetchImpl: opts.fetchImpl })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {number} prNumber
|
|
93
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
94
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
95
|
+
*/
|
|
96
|
+
export function fetchState(prNumber, opts = {}) {
|
|
97
|
+
return fetchJson(`/api/prs/${prNumber}/state`, { fetchImpl: opts.fetchImpl })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {number} prNumber
|
|
102
|
+
* @param {string} id `layer:<id>` or `layer:<id>/file:<key>`
|
|
103
|
+
* @param {boolean} reviewed
|
|
104
|
+
* @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
|
|
105
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
106
|
+
*/
|
|
107
|
+
export function putReviewed(prNumber, id, reviewed, opts = {}) {
|
|
108
|
+
const body = opts.headSha === undefined ? { reviewed } : { reviewed, headSha: opts.headSha }
|
|
109
|
+
return fetchJson(`/api/prs/${prNumber}/reviewed/${id}`, {
|
|
110
|
+
method: 'PUT',
|
|
111
|
+
body,
|
|
112
|
+
fetchImpl: opts.fetchImpl,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {number} prNumber
|
|
118
|
+
* @param {string} fingerprint
|
|
119
|
+
* @param {boolean} dismissed
|
|
120
|
+
* @param {{ reason?: string, fetchImpl?: typeof fetch }} [opts]
|
|
121
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
122
|
+
*/
|
|
123
|
+
export function putDismissed(prNumber, fingerprint, dismissed, opts = {}) {
|
|
124
|
+
const body = opts.reason === undefined ? { dismissed } : { dismissed, reason: opts.reason }
|
|
125
|
+
return fetchJson(`/api/prs/${prNumber}/points/${encodeURIComponent(fingerprint)}/dismissed`, {
|
|
126
|
+
method: 'PUT',
|
|
127
|
+
body,
|
|
128
|
+
fetchImpl: opts.fetchImpl,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {number} prNumber
|
|
134
|
+
* @param {number} rootCommentId
|
|
135
|
+
* @param {boolean} hidden
|
|
136
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
137
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
138
|
+
*/
|
|
139
|
+
export function putThreadHidden(prNumber, rootCommentId, hidden, opts = {}) {
|
|
140
|
+
return fetchJson(`/api/prs/${prNumber}/threads/${rootCommentId}/hidden`, {
|
|
141
|
+
method: 'PUT',
|
|
142
|
+
body: { hidden },
|
|
143
|
+
fetchImpl: opts.fetchImpl,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {number} prNumber
|
|
149
|
+
* @param {{ refresh?: boolean, fetchImpl?: typeof fetch }} [opts]
|
|
150
|
+
* @returns {Promise<import('./contract-types.js').Capabilities>}
|
|
151
|
+
*/
|
|
152
|
+
export function fetchCapabilities(prNumber, opts = {}) {
|
|
153
|
+
const q = opts.refresh ? '?refresh=1' : ''
|
|
154
|
+
return fetchJson(`/api/prs/${prNumber}/capabilities${q}`, { fetchImpl: opts.fetchImpl })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {number} prNumber
|
|
159
|
+
* @param {import('./contract-types.js').PostCommentInput} input
|
|
160
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
161
|
+
* @returns {Promise<import('./contract-types.js').PostCommentResponse>}
|
|
162
|
+
*/
|
|
163
|
+
export function postComment(prNumber, input, opts = {}) {
|
|
164
|
+
return fetchJson(`/api/prs/${prNumber}/comments`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {number} prNumber
|
|
169
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
170
|
+
* @returns {Promise<import('./contract-types.js').ReviewBodyResponse>}
|
|
171
|
+
*/
|
|
172
|
+
export function fetchReviewBody(prNumber, opts = {}) {
|
|
173
|
+
return fetchJson(`/api/prs/${prNumber}/review/body`, { fetchImpl: opts.fetchImpl })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {number} prNumber
|
|
178
|
+
* @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
|
|
179
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
180
|
+
* @returns {Promise<import('./contract-types.js').PostReviewResponse>}
|
|
181
|
+
*/
|
|
182
|
+
export function postReview(prNumber, input, opts = {}) {
|
|
183
|
+
return fetchJson(`/api/prs/${prNumber}/review`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A canvas takes minutes to generate, so the page stops asking every five seconds after a while. */
|
|
187
|
+
export const POLL_INTERVAL_MS = 5000
|
|
188
|
+
export const POLL_SLOW_AFTER_MS = 5 * 60 * 1000
|
|
189
|
+
export const POLL_SLOW_INTERVAL_MS = 15_000
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @typedef {{
|
|
193
|
+
* intervalMs?: number,
|
|
194
|
+
* slowAfterMs?: number,
|
|
195
|
+
* slowIntervalMs?: number,
|
|
196
|
+
* now?: () => number,
|
|
197
|
+
* fetchImpl?: typeof fetch | undefined,
|
|
198
|
+
* until?: (bundle: PrBundle) => boolean,
|
|
199
|
+
* onBundle?: (bundle: PrBundle) => void,
|
|
200
|
+
* onError?: (err: unknown) => void,
|
|
201
|
+
* }} PollOptions
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Re-fetches the bundle every 5 s until `until` says so (default: `ready`) or `stop()` is called.
|
|
206
|
+
* After five minutes of waiting the gap grows to 15 s, because a generation that has not finished
|
|
207
|
+
* by then takes minutes more. A failed poll is reported and polling goes on; the page never
|
|
208
|
+
* blocks on it.
|
|
209
|
+
* @param {number} prNumber
|
|
210
|
+
* @param {PollOptions} [opts]
|
|
211
|
+
* @returns {{ stop: () => void }}
|
|
212
|
+
*/
|
|
213
|
+
export function pollBundle(prNumber, opts = {}) {
|
|
214
|
+
const interval = opts.intervalMs ?? POLL_INTERVAL_MS
|
|
215
|
+
const slowAfter = opts.slowAfterMs ?? POLL_SLOW_AFTER_MS
|
|
216
|
+
const slowInterval = opts.slowIntervalMs ?? POLL_SLOW_INTERVAL_MS
|
|
217
|
+
const now = opts.now ?? (() => Date.now())
|
|
218
|
+
const startedAt = now()
|
|
219
|
+
const nextGap = () => (now() - startedAt >= slowAfter ? Math.max(interval, slowInterval) : interval)
|
|
220
|
+
const until = opts.until ?? (b => b.status === 'ready')
|
|
221
|
+
let stopped = false
|
|
222
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
223
|
+
let timer = null
|
|
224
|
+
const tick = async () => {
|
|
225
|
+
timer = null
|
|
226
|
+
/** @type {PrBundle} */
|
|
227
|
+
let bundle
|
|
228
|
+
try {
|
|
229
|
+
bundle = await fetchBundle(prNumber, { fetchImpl: opts.fetchImpl })
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (!stopped) {
|
|
232
|
+
opts.onError?.(err)
|
|
233
|
+
timer = setTimeout(tick, nextGap())
|
|
234
|
+
}
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (stopped) {
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (until(bundle)) {
|
|
241
|
+
stopped = true
|
|
242
|
+
} else {
|
|
243
|
+
timer = setTimeout(tick, nextGap())
|
|
244
|
+
}
|
|
245
|
+
opts.onBundle?.(bundle)
|
|
246
|
+
}
|
|
247
|
+
timer = setTimeout(tick, interval)
|
|
248
|
+
return {
|
|
249
|
+
stop: () => {
|
|
250
|
+
stopped = true
|
|
251
|
+
if (timer !== null) {
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
timer = null
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A multipart POST through XHR, so the drop zone can show upload progress. The response is the
|
|
261
|
+
* server's JSON, and a non-2xx answer becomes the same ApiError as fetchJson's.
|
|
262
|
+
* @template T
|
|
263
|
+
* @param {string} url
|
|
264
|
+
* @param {FormData} form
|
|
265
|
+
* @param {{ onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
|
|
266
|
+
* @returns {Promise<T>}
|
|
267
|
+
*/
|
|
268
|
+
export function uploadForm(url, form, opts = {}) {
|
|
269
|
+
const xhr = opts.xhrImpl ? opts.xhrImpl() : new XMLHttpRequest()
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
xhr.open('POST', url)
|
|
272
|
+
xhr.setRequestHeader('accept', 'application/json')
|
|
273
|
+
const onProgress = opts.onProgress
|
|
274
|
+
if (onProgress !== undefined) {
|
|
275
|
+
xhr.upload.onprogress = event => {
|
|
276
|
+
if (event.lengthComputable && event.total > 0) {
|
|
277
|
+
onProgress(event.loaded / event.total)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
xhr.onerror = () => reject(new ApiError({ code: 'INTERNAL', message: 'the upload failed' }, 0))
|
|
282
|
+
xhr.onload = () => {
|
|
283
|
+
/** @type {unknown} */
|
|
284
|
+
let body = null
|
|
285
|
+
try {
|
|
286
|
+
body = JSON.parse(xhr.responseText)
|
|
287
|
+
} catch {
|
|
288
|
+
body = null
|
|
289
|
+
}
|
|
290
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
291
|
+
resolve(/** @type {T} */ (body))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
reject(
|
|
295
|
+
isEnvelope(body)
|
|
296
|
+
? new ApiError(body.error, xhr.status)
|
|
297
|
+
: new ApiError({ code: 'INTERNAL', message: `${xhr.status} ${xhr.statusText}` }, xhr.status)
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
xhr.send(form)
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @param {number} prNumber
|
|
306
|
+
* @param {File} file
|
|
307
|
+
* @param {{ force?: boolean, onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
|
|
308
|
+
* @returns {Promise<import('./contract-types.js').ImportResult>}
|
|
309
|
+
*/
|
|
310
|
+
export function importCanvas(prNumber, file, opts = {}) {
|
|
311
|
+
const form = new FormData()
|
|
312
|
+
form.append('file', file)
|
|
313
|
+
if (opts.force) {
|
|
314
|
+
form.append('force', '1')
|
|
315
|
+
}
|
|
316
|
+
return uploadForm(`/api/prs/${prNumber}/import`, form, opts)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {number} prNumber
|
|
321
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
322
|
+
* @returns {Promise<import('./contract-types.js').SharedCanvasFetchResponse>}
|
|
323
|
+
*/
|
|
324
|
+
export function fetchSharedCanvas(prNumber, opts = {}) {
|
|
325
|
+
return fetchJson(`/api/prs/${prNumber}/shared-canvas/fetch`, { method: 'POST', fetchImpl: opts.fetchImpl })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The zip as a blob plus the name the server chose, ready to hand to the browser.
|
|
330
|
+
* @param {number} prNumber
|
|
331
|
+
* @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
|
|
332
|
+
* @returns {Promise<{ blob: Blob, filename: string }>}
|
|
333
|
+
*/
|
|
334
|
+
export async function fetchCanvasZip(prNumber, opts = {}) {
|
|
335
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
336
|
+
const q = opts.headSha ? `?headSha=${encodeURIComponent(opts.headSha)}` : ''
|
|
337
|
+
const res = await doFetch(`/api/prs/${prNumber}/export${q}`, { headers: { accept: 'application/zip' } })
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
/** @type {unknown} */
|
|
340
|
+
let body = null
|
|
341
|
+
try {
|
|
342
|
+
body = await res.json()
|
|
343
|
+
} catch {
|
|
344
|
+
body = null
|
|
345
|
+
}
|
|
346
|
+
throw isEnvelope(body)
|
|
347
|
+
? new ApiError(body.error, res.status)
|
|
348
|
+
: new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
|
|
349
|
+
}
|
|
350
|
+
return { blob: await res.blob(), filename: filenameFromDisposition(res.headers.get('content-disposition')) }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {string | null} header
|
|
355
|
+
* @returns {string}
|
|
356
|
+
*/
|
|
357
|
+
export function filenameFromDisposition(header) {
|
|
358
|
+
const m = header === null ? null : /filename="([^"]+)"/.exec(header)
|
|
359
|
+
return m?.[1] ?? 'pr-review-canvas.zip'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ---- The look of the page ---- */
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Saves the skin, the theme, or both. Its own route, so it works in a repository with chat off.
|
|
366
|
+
* @param {import('./contract-types.js').AppearanceInput} input
|
|
367
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
368
|
+
* @returns {Promise<import('./contract-types.js').AppearanceResponse>}
|
|
369
|
+
*/
|
|
370
|
+
export function saveAppearance(input, opts = {}) {
|
|
371
|
+
return fetchJson('/api/appearance', { method: 'PUT', body: input, fetchImpl: opts.fetchImpl })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ---- AI Chat and its settings ---- */
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @param {number} prNumber
|
|
378
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
379
|
+
* @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
|
|
380
|
+
*/
|
|
381
|
+
export function fetchThreads(prNumber, opts = {}) {
|
|
382
|
+
return fetchJson(`/api/prs/${prNumber}/chat/threads`, { fetchImpl: opts.fetchImpl })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {number} prNumber
|
|
387
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
388
|
+
* @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
|
|
389
|
+
*/
|
|
390
|
+
export function createThread(prNumber, opts = {}) {
|
|
391
|
+
return fetchJson(`/api/prs/${prNumber}/chat/threads`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {number} prNumber
|
|
396
|
+
* @param {string} name
|
|
397
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
398
|
+
* @returns {Promise<import('./contract-types.js').ChatHistoryResponse>}
|
|
399
|
+
*/
|
|
400
|
+
export function fetchThreadHistory(prNumber, name, opts = {}) {
|
|
401
|
+
return fetchJson(`/api/prs/${prNumber}/chat/threads/${encodeURIComponent(name)}/history`, {
|
|
402
|
+
fetchImpl: opts.fetchImpl,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {number} prNumber
|
|
408
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
409
|
+
* @returns {Promise<{ cancelled: boolean }>}
|
|
410
|
+
*/
|
|
411
|
+
export function cancelChat(prNumber, opts = {}) {
|
|
412
|
+
return fetchJson(`/api/prs/${prNumber}/chat/cancel`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
417
|
+
* @returns {Promise<import('./contract-types.js').SettingsResponse>}
|
|
418
|
+
*/
|
|
419
|
+
export function fetchSettings(opts = {}) {
|
|
420
|
+
return fetchJson('/api/settings', { fetchImpl: opts.fetchImpl })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {import('./contract-types.js').SettingsInput} input
|
|
425
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
426
|
+
* @returns {Promise<import('./contract-types.js').SettingsResponse>}
|
|
427
|
+
*/
|
|
428
|
+
export function saveSettings(input, opts = {}) {
|
|
429
|
+
return fetchJson('/api/settings', { method: 'PUT', body: input, fetchImpl: opts.fetchImpl })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
434
|
+
* @returns {Promise<import('./contract-types.js').AgentsResponse>}
|
|
435
|
+
*/
|
|
436
|
+
export function fetchAgents(opts = {}) {
|
|
437
|
+
return fetchJson('/api/settings/agents', { fetchImpl: opts.fetchImpl })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {string} id
|
|
442
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
443
|
+
* @returns {Promise<import('./contract-types.js').AgentProbeResult>}
|
|
444
|
+
*/
|
|
445
|
+
export function probeAgent(id, opts = {}) {
|
|
446
|
+
return fetchJson(`/api/settings/agents/${encodeURIComponent(id)}/probe`, {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
body: {},
|
|
449
|
+
fetchImpl: opts.fetchImpl,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Reads an `event:`/`data:` stream into named events. A frame the server did not finish writing
|
|
455
|
+
* stays in the buffer until its blank line arrives.
|
|
456
|
+
* @param {string} buffer what is left over from the last chunk
|
|
457
|
+
* @param {string} chunk
|
|
458
|
+
* @returns {{ events: Array<{ event: string, data: unknown }>, buffer: string }}
|
|
459
|
+
*/
|
|
460
|
+
export function readSseFrames(buffer, chunk) {
|
|
461
|
+
const text = `${buffer}${chunk}`.replace(/\r\n/g, '\n')
|
|
462
|
+
const parts = text.split('\n\n')
|
|
463
|
+
const rest = parts.pop() ?? ''
|
|
464
|
+
/** @type {Array<{ event: string, data: unknown }>} */
|
|
465
|
+
const events = []
|
|
466
|
+
for (const frame of parts) {
|
|
467
|
+
let name = 'message'
|
|
468
|
+
/** @type {string[]} */
|
|
469
|
+
const data = []
|
|
470
|
+
for (const line of frame.split('\n')) {
|
|
471
|
+
if (line.startsWith('event:')) {
|
|
472
|
+
name = line.slice(6).trim()
|
|
473
|
+
} else if (line.startsWith('data:')) {
|
|
474
|
+
data.push(line.slice(5).trim())
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (data.length === 0) {
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
events.push({ event: name, data: JSON.parse(data.join('\n')) })
|
|
482
|
+
} catch {
|
|
483
|
+
events.push({ event: name, data: null })
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { events, buffer: rest }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Sends one chat message and calls `onEvent` for every frame until the turn ends. Rejects with
|
|
491
|
+
* an ApiError when the server refuses the message (a busy chat, a context it cannot resolve).
|
|
492
|
+
* @param {number} prNumber
|
|
493
|
+
* @param {{ message: string, context: import('./chat-context.js').ChatContext, thread?: string }} input
|
|
494
|
+
* @param {{
|
|
495
|
+
* onEvent: (event: { event: string, data: unknown }) => void,
|
|
496
|
+
* signal?: AbortSignal,
|
|
497
|
+
* fetchImpl?: typeof fetch,
|
|
498
|
+
* }} opts
|
|
499
|
+
* @returns {Promise<void>}
|
|
500
|
+
*/
|
|
501
|
+
export async function streamChat(prNumber, input, opts) {
|
|
502
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
503
|
+
/** @type {RequestInit} */
|
|
504
|
+
const init = {
|
|
505
|
+
method: 'POST',
|
|
506
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
507
|
+
body: JSON.stringify(input),
|
|
508
|
+
}
|
|
509
|
+
if (opts.signal !== undefined) {
|
|
510
|
+
init.signal = opts.signal
|
|
511
|
+
}
|
|
512
|
+
const res = await doFetch(`/api/prs/${prNumber}/chat`, init)
|
|
513
|
+
if (!res.ok) {
|
|
514
|
+
/** @type {unknown} */
|
|
515
|
+
let body = null
|
|
516
|
+
try {
|
|
517
|
+
body = await res.json()
|
|
518
|
+
} catch {
|
|
519
|
+
body = null
|
|
520
|
+
}
|
|
521
|
+
throw isEnvelope(body)
|
|
522
|
+
? new ApiError(body.error, res.status)
|
|
523
|
+
: new ApiError({ code: 'INTERNAL', message: `${res.status} ${res.statusText}` }, res.status)
|
|
524
|
+
}
|
|
525
|
+
const reader = res.body?.getReader()
|
|
526
|
+
if (reader === undefined) {
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const decoder = new TextDecoder()
|
|
530
|
+
let buffer = ''
|
|
531
|
+
for (;;) {
|
|
532
|
+
const { value, done } = await reader.read()
|
|
533
|
+
if (done) {
|
|
534
|
+
break
|
|
535
|
+
}
|
|
536
|
+
const read = readSseFrames(buffer, decoder.decode(value, { stream: true }))
|
|
537
|
+
buffer = read.buffer
|
|
538
|
+
for (const event of read.events) {
|
|
539
|
+
opts.onEvent(event)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|