@vintasoftware/pr-review-canvas 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
package/src/server/errors.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
import { inspect } from 'node:util'
|
|
1
2
|
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
|
2
3
|
import { ConfigError } from '../config.js'
|
|
3
4
|
import type { ErrorCode, ErrorEnvelope } from '../contract/api.js'
|
|
4
5
|
import { GitError } from '../git/git.js'
|
|
5
|
-
import {
|
|
6
|
-
import { PrNotFoundError } from '../
|
|
6
|
+
import { LocalTargetError } from '../git/local-target.js'
|
|
7
|
+
import { PrNotFoundError } from '../host/pr.js'
|
|
8
|
+
import { CLI_INFO, type HostCli, HostCliError } from '../host/client.js'
|
|
9
|
+
|
|
10
|
+
/** The codes each host CLI's failures map to. The front end and the docs name them one by one. */
|
|
11
|
+
const CLI_ERROR_CODES: Record<HostCli, { missing: ErrorCode; unauthenticated: ErrorCode; api: ErrorCode }> = {
|
|
12
|
+
gh: { missing: 'GH_MISSING', unauthenticated: 'GH_UNAUTHENTICATED', api: 'GITHUB_API_ERROR' },
|
|
13
|
+
glab: { missing: 'GLAB_MISSING', unauthenticated: 'GLAB_UNAUTHENTICATED', api: 'GITLAB_API_ERROR' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The codes that mean the host CLI itself, not the request, is the problem. */
|
|
17
|
+
export const CLI_SETUP_CODES: ReadonlySet<ErrorCode> = new Set(
|
|
18
|
+
Object.values(CLI_ERROR_CODES).flatMap(c => [c.missing, c.unauthenticated])
|
|
19
|
+
)
|
|
7
20
|
|
|
8
21
|
export class AppError extends Error {
|
|
9
22
|
readonly code: ErrorCode
|
|
@@ -12,7 +25,13 @@ export class AppError extends Error {
|
|
|
12
25
|
/** What a rejected file got wrong, one line each. Never the file's content. */
|
|
13
26
|
readonly issues: string[] | undefined
|
|
14
27
|
|
|
15
|
-
constructor(
|
|
28
|
+
constructor(
|
|
29
|
+
code: ErrorCode,
|
|
30
|
+
message: string,
|
|
31
|
+
status: ContentfulStatusCode,
|
|
32
|
+
hint?: string,
|
|
33
|
+
issues?: string[]
|
|
34
|
+
) {
|
|
16
35
|
super(message)
|
|
17
36
|
this.name = 'AppError'
|
|
18
37
|
this.code = code
|
|
@@ -39,21 +58,36 @@ export function toAppError(err: unknown): AppError {
|
|
|
39
58
|
return err
|
|
40
59
|
}
|
|
41
60
|
if (err instanceof PrNotFoundError) {
|
|
42
|
-
return new AppError(
|
|
61
|
+
return new AppError(
|
|
62
|
+
'PR_NOT_FOUND',
|
|
63
|
+
err.message,
|
|
64
|
+
404,
|
|
65
|
+
'check the number and that origin is the right repository'
|
|
66
|
+
)
|
|
43
67
|
}
|
|
44
|
-
if (err instanceof
|
|
68
|
+
if (err instanceof HostCliError) {
|
|
69
|
+
const codes = CLI_ERROR_CODES[err.cli]
|
|
70
|
+
const info = CLI_INFO[err.cli]
|
|
45
71
|
if (err.missingBinary) {
|
|
46
72
|
return new AppError(
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
codes.missing,
|
|
74
|
+
`the ${info.label} is not installed`,
|
|
49
75
|
500,
|
|
50
|
-
|
|
76
|
+
`install it from ${info.installUrl}`
|
|
51
77
|
)
|
|
52
78
|
}
|
|
53
79
|
if (err.unauthenticated) {
|
|
54
|
-
return new AppError(
|
|
80
|
+
return new AppError(
|
|
81
|
+
codes.unauthenticated,
|
|
82
|
+
`${err.cli} is not logged in`,
|
|
83
|
+
401,
|
|
84
|
+
`run \`${info.loginCommand}\``
|
|
85
|
+
)
|
|
55
86
|
}
|
|
56
|
-
return new AppError(
|
|
87
|
+
return new AppError(codes.api, err.message, 502)
|
|
88
|
+
}
|
|
89
|
+
if (err instanceof LocalTargetError) {
|
|
90
|
+
return new AppError('BAD_REQUEST', err.message, 400, err.hint)
|
|
57
91
|
}
|
|
58
92
|
if (err instanceof GitError) {
|
|
59
93
|
return new AppError('GIT_ERROR', err.message, 500)
|
|
@@ -63,3 +97,15 @@ export function toAppError(err: unknown): AppError {
|
|
|
63
97
|
}
|
|
64
98
|
return new AppError('INTERNAL', err instanceof Error ? err.message : String(err), 500)
|
|
65
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Keep the original error and its causes in the server log, before response mapping loses the stack. */
|
|
102
|
+
export function logRequestError(
|
|
103
|
+
log: (line: string) => void,
|
|
104
|
+
request: { method: string; path: string },
|
|
105
|
+
err: unknown
|
|
106
|
+
): void {
|
|
107
|
+
const mapped = toAppError(err)
|
|
108
|
+
if (mapped.status >= 500) {
|
|
109
|
+
log(`[serve] ${request.method} ${request.path} ${mapped.status} ${mapped.code}\n${inspect(err)}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/server/html.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { html, raw } from 'hono/html'
|
|
2
2
|
import type { HtmlEscapedString } from 'hono/utils/html'
|
|
3
3
|
import type { ErrorEnvelope, HomeData } from '../contract/api.js'
|
|
4
|
+
import { keyLabel, keyToString, type LocalKey, type ReviewKey } from '../contract/review-key.js'
|
|
4
5
|
import type { Appearance } from '../contract/settings.js'
|
|
6
|
+
import { type Host, publicHost } from '../host/host.js'
|
|
5
7
|
|
|
6
8
|
type Html = HtmlEscapedString | Promise<HtmlEscapedString>
|
|
7
9
|
|
|
@@ -68,50 +70,72 @@ ${opts.app ? html`<script type="module" src="/static/js/app.js"></script>` : ''}
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export function reviewPage(
|
|
71
|
-
|
|
73
|
+
page: { prNumber: ReviewKey; owner: string; repo: string; version: string; host: Host },
|
|
72
74
|
nonce: string,
|
|
73
75
|
appearance: Appearance
|
|
74
76
|
): Html {
|
|
77
|
+
const bootstrap = { ...page, host: publicHost(page.host) }
|
|
78
|
+
const key = keyToString(bootstrap.prNumber)
|
|
79
|
+
const what =
|
|
80
|
+
typeof bootstrap.prNumber === 'number' ? `${page.host.nounShort} #${key}` : keyLabel(bootstrap.prNumber)
|
|
75
81
|
return pageShell({
|
|
76
|
-
title:
|
|
82
|
+
title: `${what} · ${bootstrap.owner}/${bootstrap.repo} · review canvas`,
|
|
77
83
|
bootstrap,
|
|
78
84
|
nonce,
|
|
79
85
|
appearance,
|
|
80
86
|
app: true,
|
|
81
87
|
body: html`<a class="skip" href="#main">Skip to content</a>
|
|
82
|
-
<pr-app class="page" data-pr="${
|
|
88
|
+
<pr-app class="page" data-pr="${key}"><div class="loading">Loading ${what}…</div></pr-app>`,
|
|
83
89
|
})
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export function homePage(
|
|
87
|
-
data: HomeData & {
|
|
93
|
+
data: HomeData & {
|
|
94
|
+
owner: string
|
|
95
|
+
repo: string
|
|
96
|
+
version: string
|
|
97
|
+
port: number
|
|
98
|
+
host: Host
|
|
99
|
+
/** The local reviews prepared here, so the home page can link straight to them. */
|
|
100
|
+
localReviews: readonly LocalKey[]
|
|
101
|
+
},
|
|
88
102
|
nonce: string,
|
|
89
103
|
appearance: Appearance
|
|
90
104
|
): Html {
|
|
105
|
+
const { noun, nounShort, label } = data.host
|
|
91
106
|
return pageShell({
|
|
92
107
|
title: 'PR review canvas',
|
|
93
|
-
bootstrap: { owner: data.owner, repo: data.repo, version: data.version },
|
|
108
|
+
bootstrap: { owner: data.owner, repo: data.repo, version: data.version, host: publicHost(data.host) },
|
|
94
109
|
nonce,
|
|
95
110
|
appearance,
|
|
96
111
|
app: false,
|
|
97
112
|
body: html`<div class="page">
|
|
98
113
|
<header class="hdr">
|
|
99
|
-
<div class="hdr-bar"><div class="brand"><span class="
|
|
114
|
+
<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">localhost:${String(data.port)}</span></div>
|
|
100
115
|
<div class="hdr-actions"><a class="cmd" href="/api/health">health</a></div></div>
|
|
101
116
|
<div class="stripe" aria-hidden="true"></div>
|
|
102
117
|
<div class="hdr-title"><div class="title"><h1>${data.owner}/${data.repo}</h1></div>
|
|
103
|
-
<p class="meta"><span>Open a
|
|
118
|
+
<p class="meta"><span>Open a ${noun} by number. Diffs come from your local clone; the canvas from a published review.</span></p></div>
|
|
104
119
|
</header>
|
|
105
120
|
<main id="main" class="home">
|
|
106
|
-
<section class="panel"><div class="panel-h"><h2>Open a
|
|
121
|
+
<section class="panel"><div class="panel-h"><h2>Open a ${noun}</h2></div>
|
|
107
122
|
<form class="body home-form" method="get" action="/review">
|
|
108
|
-
<label
|
|
123
|
+
<label>${nounShort} number <input name="n" type="number" min="1" required inputmode="numeric"></label>
|
|
109
124
|
<button class="cmd fill" type="submit">open</button>
|
|
110
125
|
</form></section>
|
|
126
|
+
<section class="panel"><div class="panel-h"><h2>Before the ${noun}</h2></div>
|
|
127
|
+
<div class="body">${
|
|
128
|
+
data.localReviews.length === 0
|
|
129
|
+
? html`<p class="muted">Nothing reviewed here yet. Run <code>/pr-review-canvas branch</code> to read the current branch against the default one, or <code>/pr-review-canvas uncommitted</code> to read it with your working-tree edits on top, before opening a ${noun}.</p>`
|
|
130
|
+
: html`<ul class="plain">${data.localReviews.map(
|
|
131
|
+
key =>
|
|
132
|
+
html`<li><a href="/review/${key}">${keyLabel(key)}</a> <span class="muted">/review/${key}</span></li>`
|
|
133
|
+
)}</ul>`
|
|
134
|
+
}</div></section>
|
|
111
135
|
<section class="panel"><div class="panel-h"><h2>Recent</h2></div>
|
|
112
136
|
${
|
|
113
137
|
data.recentPrs.length === 0
|
|
114
|
-
? html`<div class="body muted">No
|
|
138
|
+
? html`<div class="body muted">No ${noun}s opened yet.</div>`
|
|
115
139
|
: html`<ul class="plain body">${data.recentPrs.map(
|
|
116
140
|
p =>
|
|
117
141
|
html`<li><a href="/review/${String(p.number)}"><span class="mono num">#${String(p.number)}</span> ${p.title}</a></li>`
|
|
@@ -119,7 +143,7 @@ ${
|
|
|
119
143
|
}
|
|
120
144
|
</section>
|
|
121
145
|
</main>
|
|
122
|
-
<footer><span>pr-review ${data.version}</span><span>localhost only · nothing leaves this machine except
|
|
146
|
+
<footer><span>pr-review ${data.version}</span><span>localhost only · nothing leaves this machine except ${label} posts you confirm</span></footer>
|
|
123
147
|
</div>`,
|
|
124
148
|
})
|
|
125
149
|
}
|
|
@@ -132,7 +156,7 @@ export function errorPage(error: ErrorEnvelope['error'], nonce: string, appearan
|
|
|
132
156
|
appearance,
|
|
133
157
|
app: false,
|
|
134
158
|
body: html`<div class="page"><header class="hdr">
|
|
135
|
-
<div class="hdr-bar"><div class="brand"><span class="
|
|
159
|
+
<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span></div><div class="hdr-actions"><a class="cmd" href="/">home</a></div></div>
|
|
136
160
|
<div class="stripe" aria-hidden="true"></div></header>
|
|
137
161
|
<main id="main" class="home"><section class="panel error-card"><div class="panel-h"><h2><span class="mono">${error.code}</span></h2></div>
|
|
138
162
|
<div class="body"><p>${error.message}</p>${error.hint ? html`<p class="muted">${error.hint}</p>` : ''}</div></section></main></div>`,
|
|
@@ -4,9 +4,11 @@ import type { AppContext } from './context.js'
|
|
|
4
4
|
|
|
5
5
|
/** Binds 127.0.0.1 only. The Host allowlist in security.ts covers the rest. */
|
|
6
6
|
export function startServer(ctx: AppContext, log: (line: string) => void): { close: () => void } {
|
|
7
|
-
const app = createApp(ctx)
|
|
7
|
+
const app = createApp({ ...ctx, log })
|
|
8
8
|
const server = serve({ fetch: app.fetch, port: ctx.config.port, hostname: '127.0.0.1' }, info => {
|
|
9
|
-
log(
|
|
9
|
+
log(
|
|
10
|
+
`pr-review ${ctx.version} · http://localhost:${info.port}/ · ${ctx.config.repo.owner}/${ctx.config.repo.name}`
|
|
11
|
+
)
|
|
10
12
|
log(`data dir ${ctx.config.dataDir}`)
|
|
11
13
|
if (ctx.fixtureArtifact !== null) {
|
|
12
14
|
log(`fixture canvas ${ctx.config.fixtureCanvasPath ?? ''} (dev only): every PR reports ready`)
|
package/src/server/routes/api.ts
CHANGED
|
@@ -3,23 +3,51 @@ import { z } from 'zod'
|
|
|
3
3
|
import { buildCanvasZipFor } from '../../canvas/export.js'
|
|
4
4
|
import { importCanvas } from '../../canvas/import.js'
|
|
5
5
|
import { CANVAS_ZIP_MAX_BYTES } from '../../canvas/zip.js'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ContextResponse,
|
|
8
|
+
HealthResponse,
|
|
9
|
+
PatchesResponse,
|
|
10
|
+
SharedCanvasFetchResponse,
|
|
11
|
+
} from '../../contract/api.js'
|
|
12
|
+
import { isLocalKey, parseReviewKey, type ReviewKey } from '../../contract/review-key.js'
|
|
7
13
|
import { AppearanceInputSchema, type AppearanceResponse } from '../../contract/settings.js'
|
|
8
|
-
import {
|
|
14
|
+
import { publicHost } from '../../host/host.js'
|
|
15
|
+
import { lookupCanvas } from '../../review/carry-over.js'
|
|
16
|
+
import { createPrLoader, resolveBundle, resolveLocalBundle, runDiscovery } from '../bundle.js'
|
|
9
17
|
import { BodyTooLargeError, readCappedBody } from '../capped-body.js'
|
|
10
18
|
import type { AppContext } from '../context.js'
|
|
11
19
|
import { AppError } from '../errors.js'
|
|
12
20
|
import { chatRoutes } from './chat-routes.js'
|
|
13
21
|
import { reviewRoutes } from './review-routes.js'
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
/** The target a route's `:n` names: a pull request number, or `local` for work with no PR yet. */
|
|
24
|
+
export function parseTargetKey(raw: string): ReviewKey {
|
|
25
|
+
const key = parseReviewKey(raw)
|
|
26
|
+
if (key === null) {
|
|
27
|
+
throw new AppError(
|
|
28
|
+
'BAD_REQUEST',
|
|
29
|
+
`not a review target: ${raw}`,
|
|
30
|
+
400,
|
|
31
|
+
'use a PR number, `branch`, or `uncommitted`'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return key
|
|
35
|
+
}
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The pull request behind a key, for the routes that talk to the forge. Local work has none, and
|
|
39
|
+
* saying so is better than letting the call fail somewhere inside the host client.
|
|
40
|
+
*/
|
|
41
|
+
export function requirePrNumber(key: ReviewKey, what: string): number {
|
|
42
|
+
if (isLocalKey(key)) {
|
|
43
|
+
throw new AppError(
|
|
44
|
+
'BAD_REQUEST',
|
|
45
|
+
`${what} needs a pull request, and this canvas describes work that has none yet`,
|
|
46
|
+
400,
|
|
47
|
+
'open the pull request, then review it at /review/<number>'
|
|
48
|
+
)
|
|
21
49
|
}
|
|
22
|
-
return
|
|
50
|
+
return key
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
const ContextQuerySchema = z.object({
|
|
@@ -69,12 +97,14 @@ function parseHeadShaQuery(raw: string | undefined): string | undefined {
|
|
|
69
97
|
async function currentCanvasSha(
|
|
70
98
|
ctx: AppContext,
|
|
71
99
|
loader: ReturnType<typeof createPrLoader>,
|
|
72
|
-
|
|
100
|
+
key: ReviewKey
|
|
73
101
|
): Promise<string> {
|
|
74
|
-
const pr = await loader.
|
|
75
|
-
const found =
|
|
102
|
+
const pr = await loader.currentTarget(key)
|
|
103
|
+
const found = isLocalKey(key)
|
|
104
|
+
? await ctx.canvases.findForLocal(key, pr.headSha)
|
|
105
|
+
: await lookupCanvas(ctx, key, pr)
|
|
76
106
|
if (found.status === 'missing') {
|
|
77
|
-
throw new AppError('CANVAS_NOT_FOUND', `no canvas for
|
|
107
|
+
throw new AppError('CANVAS_NOT_FOUND', `no canvas for ${String(key)}`, 404, 'generate one first')
|
|
78
108
|
}
|
|
79
109
|
return found.headSha
|
|
80
110
|
}
|
|
@@ -87,7 +117,10 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
87
117
|
|
|
88
118
|
// How the page is painted. It lives in the same settings file the chat settings do, but on its
|
|
89
119
|
// own route, because a repository with chat off still has a page to paint.
|
|
90
|
-
const appearanceOf = (settings: {
|
|
120
|
+
const appearanceOf = (settings: {
|
|
121
|
+
skin: AppearanceResponse['skin']
|
|
122
|
+
theme: AppearanceResponse['theme']
|
|
123
|
+
}) => ({
|
|
91
124
|
skin: settings.skin,
|
|
92
125
|
theme: settings.theme,
|
|
93
126
|
})
|
|
@@ -148,6 +181,7 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
148
181
|
: {}),
|
|
149
182
|
},
|
|
150
183
|
repo: ctx.config.repo,
|
|
184
|
+
host: publicHost(ctx.config.host),
|
|
151
185
|
dataDir: ctx.config.dataDir,
|
|
152
186
|
chat: {
|
|
153
187
|
enabled: chatEnabled,
|
|
@@ -159,15 +193,24 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
159
193
|
})
|
|
160
194
|
|
|
161
195
|
api.get('/prs/:n', async c => {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
196
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
197
|
+
const opts = { refresh: c.req.query('refresh') === '1', poll: c.req.query('poll') === '1' }
|
|
198
|
+
const bundle = isLocalKey(key)
|
|
199
|
+
? await resolveLocalBundle(ctx, loader, key, opts)
|
|
200
|
+
: await resolveBundle(ctx, loader, key, opts)
|
|
164
201
|
return c.json(bundle)
|
|
165
202
|
})
|
|
166
203
|
|
|
167
204
|
api.get('/prs/:n/patches', async c => {
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
205
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
206
|
+
const pr = await loader.currentTarget(key)
|
|
207
|
+
const headSha = parseHeadShaQuery(c.req.query('headSha')) ?? pr.headSha
|
|
208
|
+
// Only the target's own head has a merge base to build from; any other sha the page names is
|
|
209
|
+
// an older canvas, whose diffs were written when that canvas was drawn.
|
|
210
|
+
const derived = await ctx.derived.readOrBuild(
|
|
211
|
+
headSha,
|
|
212
|
+
headSha === pr.headSha ? pr.mergeBaseSha : undefined
|
|
213
|
+
)
|
|
171
214
|
if (derived === null) {
|
|
172
215
|
throw new AppError(
|
|
173
216
|
'NOT_FOUND',
|
|
@@ -181,13 +224,13 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
181
224
|
})
|
|
182
225
|
|
|
183
226
|
api.get('/prs/:n/comments', async c => {
|
|
184
|
-
const number =
|
|
227
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'reading comments')
|
|
185
228
|
const { comments } = await loader.refreshComments(number)
|
|
186
229
|
return c.json(comments)
|
|
187
230
|
})
|
|
188
231
|
|
|
189
232
|
api.get('/prs/:n/context', async c => {
|
|
190
|
-
const
|
|
233
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
191
234
|
const parsed = ContextQuerySchema.safeParse(c.req.query())
|
|
192
235
|
if (!parsed.success) {
|
|
193
236
|
throw new AppError('BAD_REQUEST', 'context needs path, side (new|old), from, to', 400)
|
|
@@ -196,20 +239,34 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
196
239
|
if (q.to < q.from || q.to - q.from + 1 > CONTEXT_MAX_LINES) {
|
|
197
240
|
throw new AppError('BAD_REQUEST', `request between 1 and ${CONTEXT_MAX_LINES} lines`, 400)
|
|
198
241
|
}
|
|
199
|
-
const pr = await loader.
|
|
200
|
-
const lines = await ctx.derived.readLines(
|
|
242
|
+
const pr = await loader.currentTarget(key)
|
|
243
|
+
const lines = await ctx.derived.readLines(
|
|
244
|
+
pr.headSha,
|
|
245
|
+
q.side === 'new' ? 'head' : 'base',
|
|
246
|
+
q.path,
|
|
247
|
+
q.from,
|
|
248
|
+
q.to
|
|
249
|
+
)
|
|
201
250
|
if (lines === null) {
|
|
202
251
|
throw new AppError('NOT_FOUND', `${q.path} is not materialized for this head`, 404)
|
|
203
252
|
}
|
|
204
|
-
const body: ContextResponse = {
|
|
253
|
+
const body: ContextResponse = {
|
|
254
|
+
path: q.path,
|
|
255
|
+
side: q.side,
|
|
256
|
+
from: q.from,
|
|
257
|
+
to: q.from + lines.length - 1,
|
|
258
|
+
lines,
|
|
259
|
+
}
|
|
205
260
|
return c.json(body)
|
|
206
261
|
})
|
|
207
262
|
|
|
208
263
|
api.get('/prs/:n/export', async c => {
|
|
209
|
-
const
|
|
264
|
+
const key = parseTargetKey(c.req.param('n'))
|
|
210
265
|
const requested = parseHeadShaQuery(c.req.query('headSha'))
|
|
211
|
-
const headSha = requested ?? (await currentCanvasSha(ctx, loader,
|
|
212
|
-
|
|
266
|
+
const headSha = requested ?? (await currentCanvasSha(ctx, loader, key))
|
|
267
|
+
// A local canvas is exported without a number, which is what makes it importable on the
|
|
268
|
+
// pull request once that exists.
|
|
269
|
+
const zip = await buildCanvasZipFor(ctx, headSha, isLocalKey(key) ? undefined : key)
|
|
213
270
|
// The name is built from the repo slug, the PR number, and the sha, so it holds no user text.
|
|
214
271
|
return new Response(new Blob([zip.bytes]), {
|
|
215
272
|
headers: {
|
|
@@ -220,29 +277,33 @@ export function apiRoutes(ctx: AppContext): Hono {
|
|
|
220
277
|
})
|
|
221
278
|
|
|
222
279
|
api.post('/prs/:n/import', async c => {
|
|
223
|
-
const number =
|
|
280
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'importing a shared canvas')
|
|
224
281
|
const { file, force } = await readUpload(c.req.raw)
|
|
225
282
|
if (!(file instanceof File)) {
|
|
226
283
|
throw new AppError('BAD_REQUEST', 'send the zip as the multipart field `file`', 400)
|
|
227
284
|
}
|
|
228
285
|
if (file.size > CANVAS_ZIP_MAX_BYTES) {
|
|
229
|
-
throw new AppError(
|
|
286
|
+
throw new AppError(
|
|
287
|
+
'CANVAS_TOO_LARGE',
|
|
288
|
+
`the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
|
|
289
|
+
413
|
|
290
|
+
)
|
|
230
291
|
}
|
|
231
292
|
const result = await importCanvas(ctx, {
|
|
232
293
|
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
233
294
|
prNumber: number,
|
|
234
|
-
|
|
295
|
+
currentHead: await loader.currentPr(number),
|
|
235
296
|
force: force === '1',
|
|
236
297
|
})
|
|
237
298
|
return c.json(result)
|
|
238
299
|
})
|
|
239
300
|
|
|
240
301
|
api.post('/prs/:n/shared-canvas/fetch', async c => {
|
|
241
|
-
const number =
|
|
302
|
+
const number = requirePrNumber(parseTargetKey(c.req.param('n')), 'looking for a shared canvas')
|
|
242
303
|
// Looking again means looking at the pull request as it is now, not at the cached copy.
|
|
243
304
|
const { pr, comments } = await loader.load(number, { refresh: true })
|
|
244
305
|
const discovery = await runDiscovery(ctx, pr, comments, { refresh: true })
|
|
245
|
-
const found = await ctx
|
|
306
|
+
const found = await lookupCanvas(ctx, number, pr)
|
|
246
307
|
const body: SharedCanvasFetchResponse = {
|
|
247
308
|
imported: discovery.imported,
|
|
248
309
|
status: found.status,
|