@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,64 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { type Appearance, type AppearanceQuery, appearanceForRequest } from '../../contract/settings.js'
|
|
3
|
+
import type { AppContext } from '../context.js'
|
|
4
|
+
import type { AppEnv } from '../env.js'
|
|
5
|
+
import { AppError } from '../errors.js'
|
|
6
|
+
import { homePage, reviewPage } from '../html.js'
|
|
7
|
+
import { parsePrNumber } from './api.js'
|
|
8
|
+
|
|
9
|
+
/** How the page is painted, rendered onto the tag so nothing flashes before the app module runs. */
|
|
10
|
+
export async function appearanceFor(ctx: AppContext, query: AppearanceQuery): Promise<Appearance> {
|
|
11
|
+
return appearanceForRequest(await ctx.settings.read(), query)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The `?skin` and `?theme` of one request, which pick an appearance for that load alone. */
|
|
15
|
+
export function appearanceQuery(c: { req: { query: (name: string) => string | undefined } }): AppearanceQuery {
|
|
16
|
+
return { skin: c.req.query('skin'), theme: c.req.query('theme') }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
|
|
20
|
+
const app = new Hono<AppEnv>()
|
|
21
|
+
|
|
22
|
+
app.get('/', async c => {
|
|
23
|
+
const recentPrs = await ctx.prs.listRecent(10)
|
|
24
|
+
return c.html(
|
|
25
|
+
homePage(
|
|
26
|
+
{
|
|
27
|
+
recentPrs,
|
|
28
|
+
owner: ctx.config.repo.owner,
|
|
29
|
+
repo: ctx.config.repo.name,
|
|
30
|
+
version: ctx.version,
|
|
31
|
+
port: ctx.config.port,
|
|
32
|
+
},
|
|
33
|
+
c.get('cspNonce'),
|
|
34
|
+
await appearanceFor(ctx, appearanceQuery(c))
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Where the home page's form lands. It is a plain GET form, so the page needs no script of its
|
|
40
|
+
// own and the number arrives as a query parameter.
|
|
41
|
+
app.get('/review', c => {
|
|
42
|
+
const raw = c.req.query('n') ?? ''
|
|
43
|
+
return c.redirect(`/review/${encodeURIComponent(raw)}`, 303)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
app.get('/review/:n', async c => {
|
|
47
|
+
const raw = c.req.param('n')
|
|
48
|
+
let prNumber: number
|
|
49
|
+
try {
|
|
50
|
+
prNumber = parsePrNumber(raw)
|
|
51
|
+
} catch {
|
|
52
|
+
throw new AppError('BAD_REQUEST', `"${raw}" is not a pull request number`, 400, 'use /review/<number>')
|
|
53
|
+
}
|
|
54
|
+
return c.html(
|
|
55
|
+
reviewPage(
|
|
56
|
+
{ prNumber, owner: ctx.config.repo.owner, repo: ctx.config.repo.name, version: ctx.version },
|
|
57
|
+
c.get('cspNonce'),
|
|
58
|
+
await appearanceFor(ctx, appearanceQuery(c))
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return app
|
|
64
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The routes that write: local review state, and the three things the tool posts to GitHub.
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import type { ReviewBodyResponse, StateResponse } from '../../contract/api.js'
|
|
5
|
+
import { PostCommentInputSchema, type PostCommentResult } from '../../contract/comments.js'
|
|
6
|
+
import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
|
|
7
|
+
import { checkInlineTarget, postComment } from '../../github/post-comment.js'
|
|
8
|
+
import { PostReviewInputSchema, postReview } from '../../github/post-review.js'
|
|
9
|
+
import { buildReviewBody, stateForHead, unreviewedLayers } from '../../github/review-body.js'
|
|
10
|
+
import { isReviewedId } from '../../store/state-store.js'
|
|
11
|
+
import type { PrLoader } from '../bundle.js'
|
|
12
|
+
import type { AppContext } from '../context.js'
|
|
13
|
+
import { AppError } from '../errors.js'
|
|
14
|
+
import { parsePrNumber } from './api.js'
|
|
15
|
+
|
|
16
|
+
const ReviewedBodySchema = z.object({
|
|
17
|
+
reviewed: z.boolean(),
|
|
18
|
+
/** The commit the page was showing; a mark made on another commit is refused. */
|
|
19
|
+
headSha: z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
22
|
+
.optional(),
|
|
23
|
+
})
|
|
24
|
+
const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
|
|
25
|
+
const HiddenBodySchema = z.object({ hidden: z.boolean() })
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page says which commit it was showing. When the pull request has moved on, the post is
|
|
29
|
+
* refused instead of landing on code the reader never saw.
|
|
30
|
+
*/
|
|
31
|
+
function requireSameHead(expected: string | undefined, current: string): void {
|
|
32
|
+
if (expected !== undefined && expected !== current) {
|
|
33
|
+
throw new AppError(
|
|
34
|
+
'CANVAS_STALE',
|
|
35
|
+
'the pull request has a new head commit since this page was drawn',
|
|
36
|
+
409,
|
|
37
|
+
'reload the page and try again'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A JSON body the route can read, or a 400 that says what shape it expected. */
|
|
43
|
+
async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: string): Promise<T> {
|
|
44
|
+
let raw: unknown
|
|
45
|
+
try {
|
|
46
|
+
raw = await request.json()
|
|
47
|
+
} catch {
|
|
48
|
+
throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400)
|
|
49
|
+
}
|
|
50
|
+
const parsed = schema.safeParse(raw)
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400, parsed.error.issues[0]?.message)
|
|
53
|
+
}
|
|
54
|
+
return parsed.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The canvas the reviewer is signing off on: the one written for the pull request's head. */
|
|
58
|
+
async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
|
|
59
|
+
if (ctx.fixtureArtifact !== null) {
|
|
60
|
+
return { ...ctx.fixtureArtifact, pr }
|
|
61
|
+
}
|
|
62
|
+
const found = await ctx.canvases.findForPr(number, pr.headSha)
|
|
63
|
+
if (found.status !== 'ready') {
|
|
64
|
+
throw new AppError(
|
|
65
|
+
'SIGNOFF_INCOMPLETE',
|
|
66
|
+
'the canvas on screen was generated for another commit',
|
|
67
|
+
409,
|
|
68
|
+
'regenerate the canvas for the current head, then review its layers'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
const artifact = await ctx.canvases.readArtifact(found.headSha)
|
|
72
|
+
if (artifact === null) {
|
|
73
|
+
throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
|
|
74
|
+
}
|
|
75
|
+
return artifact
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
79
|
+
const api = new Hono()
|
|
80
|
+
|
|
81
|
+
/** Posting is refused here as well as in the UI, so a stale page cannot post either. */
|
|
82
|
+
const requirePosting = async (): Promise<void> => {
|
|
83
|
+
const caps = await ctx.capabilities.get()
|
|
84
|
+
if (caps.canComment === false) {
|
|
85
|
+
throw new AppError(
|
|
86
|
+
'COMMENT_FORBIDDEN',
|
|
87
|
+
caps.reason ?? 'this GitHub login cannot post on this repository',
|
|
88
|
+
403,
|
|
89
|
+
caps.hint
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({ prNumber: number, state })
|
|
95
|
+
|
|
96
|
+
api.get('/prs/:n/state', async c => {
|
|
97
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
98
|
+
return c.json(stateBody(number, await ctx.state.read(number)))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
api.put('/prs/:n/reviewed/:id{.+}', async c => {
|
|
102
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
103
|
+
const id = c.req.param('id')
|
|
104
|
+
if (!isReviewedId(id)) {
|
|
105
|
+
throw new AppError('BAD_REQUEST', `not a reviewed id: ${id}`, 400, 'use layer:<id> or layer:<id>/file:<key>')
|
|
106
|
+
}
|
|
107
|
+
const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
|
|
108
|
+
const pr = await loader.currentPr(number)
|
|
109
|
+
requireSameHead(body.headSha, pr.headSha)
|
|
110
|
+
return c.json(stateBody(number, await ctx.state.setReviewed(number, id, body.reviewed, pr.headSha)))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
|
|
114
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
115
|
+
const fingerprint = c.req.param('fingerprint')
|
|
116
|
+
const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
|
|
117
|
+
const next = await ctx.state.setDismissed(number, fingerprint, body.dismissed, body.reason)
|
|
118
|
+
return c.json(stateBody(number, next))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
|
|
122
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
123
|
+
const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
|
|
124
|
+
if (!rootId.success) {
|
|
125
|
+
throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
|
|
126
|
+
}
|
|
127
|
+
const { hidden } = await readBody(c.req.raw, HiddenBodySchema, '{ "hidden": true }')
|
|
128
|
+
return c.json(stateBody(number, await ctx.state.setThreadHidden(number, rootId.data, hidden)))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
api.get('/prs/:n/capabilities', async c => {
|
|
132
|
+
parsePrNumber(c.req.param('n'))
|
|
133
|
+
return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
api.post('/prs/:n/comments', async c => {
|
|
137
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
138
|
+
const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
|
|
139
|
+
await requirePosting()
|
|
140
|
+
const pr = await loader.currentPr(number)
|
|
141
|
+
requireSameHead(input.headSha, pr.headSha)
|
|
142
|
+
if (input.kind === 'inline') {
|
|
143
|
+
const derived = await ctx.derived.read(pr.headSha)
|
|
144
|
+
if (derived === null) {
|
|
145
|
+
throw new AppError(
|
|
146
|
+
'NOT_FOUND',
|
|
147
|
+
'the diff of this head is not available locally, so the line cannot be checked',
|
|
148
|
+
404,
|
|
149
|
+
'fetch the PR head and reload'
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
const problem = checkInlineTarget(derived.files, input)
|
|
153
|
+
if (problem !== null) {
|
|
154
|
+
throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
|
|
158
|
+
await appendComment(ctx, number, posted)
|
|
159
|
+
const entry =
|
|
160
|
+
input.kind === 'inline' && input.pointFingerprint !== undefined
|
|
161
|
+
? { commentId: posted.comment.id, pointFingerprint: input.pointFingerprint }
|
|
162
|
+
: { commentId: posted.comment.id }
|
|
163
|
+
const state = await ctx.state.addPosted(number, entry)
|
|
164
|
+
return c.json({ ...posted, state }, 201)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
api.get('/prs/:n/review/body', async c => {
|
|
168
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
169
|
+
const pr = await loader.currentPr(number)
|
|
170
|
+
const artifact = await artifactForHead(ctx, number, pr)
|
|
171
|
+
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
172
|
+
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
173
|
+
const body: ReviewBodyResponse = {
|
|
174
|
+
headSha: pr.headSha,
|
|
175
|
+
body: buildReviewBody({ artifact, state, comments, headSha: pr.headSha }),
|
|
176
|
+
unreviewed: unreviewedLayers(artifact, state).map(l => l.title),
|
|
177
|
+
}
|
|
178
|
+
return c.json(body)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
api.post('/prs/:n/review', async c => {
|
|
182
|
+
const number = parsePrNumber(c.req.param('n'))
|
|
183
|
+
const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
|
|
184
|
+
await requirePosting()
|
|
185
|
+
const pr = await loader.currentPr(number)
|
|
186
|
+
requireSameHead(input.headSha, pr.headSha)
|
|
187
|
+
const artifact = await artifactForHead(ctx, number, pr)
|
|
188
|
+
const state = stateForHead(await ctx.state.read(number), pr.headSha)
|
|
189
|
+
if (input.event === 'APPROVE') {
|
|
190
|
+
const missing = unreviewedLayers(artifact, state)
|
|
191
|
+
if (missing.length > 0) {
|
|
192
|
+
throw new AppError(
|
|
193
|
+
'SIGNOFF_INCOMPLETE',
|
|
194
|
+
`${missing.length} ${missing.length === 1 ? 'layer is' : 'layers are'} not reviewed yet`,
|
|
195
|
+
409,
|
|
196
|
+
missing.map(l => l.title).join(', ')
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
|
|
201
|
+
const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
|
|
202
|
+
const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, { event: input.event, body })
|
|
203
|
+
return c.json({ review }, 201)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return api
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One append at a time per PR, so two posts that land together do not overwrite each other. */
|
|
210
|
+
const appendChains = new Map<number, Promise<unknown>>()
|
|
211
|
+
|
|
212
|
+
function appendComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
|
|
213
|
+
const run = () => writeAppendedComment(ctx, number, posted)
|
|
214
|
+
const chained = (appendChains.get(number) ?? Promise.resolve()).then(run, run)
|
|
215
|
+
appendChains.set(
|
|
216
|
+
number,
|
|
217
|
+
chained.catch(() => undefined)
|
|
218
|
+
)
|
|
219
|
+
return chained
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Keeps the cached comments in step with what was just posted, so a reload shows it once. With
|
|
224
|
+
* nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
|
|
225
|
+
*/
|
|
226
|
+
async function writeAppendedComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
|
|
227
|
+
const comments = await ctx.prs.readComments(number)
|
|
228
|
+
if (comments === null) {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (posted.kind === 'review') {
|
|
232
|
+
if (comments.reviewComments.some(c => c.id === posted.comment.id)) {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
await ctx.prs.writeComments(number, {
|
|
236
|
+
...comments,
|
|
237
|
+
reviewComments: [...comments.reviewComments, posted.comment],
|
|
238
|
+
})
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (comments.issueComments.some(c => c.id === posted.comment.id)) {
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
await ctx.prs.writeComments(number, { ...comments, issueComments: [...comments.issueComments, posted.comment] })
|
|
245
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { Hono } from 'hono'
|
|
4
|
+
import type { AppContext, VendorRoots } from '../context.js'
|
|
5
|
+
import { AppError } from '../errors.js'
|
|
6
|
+
|
|
7
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
8
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
9
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.map': 'application/json; charset=utf-8',
|
|
13
|
+
'.svg': 'image/svg+xml',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.ico': 'image/x-icon',
|
|
16
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function contentTypeFor(file: string): string {
|
|
20
|
+
return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function under(root: string, full: string): boolean {
|
|
24
|
+
return full === root || full.startsWith(root + path.sep)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves `rel` under `root`, or null when it escapes the root or is not a plain file. The
|
|
29
|
+
* real path is checked as well as the written one, so a symlink inside the root that points
|
|
30
|
+
* outside it names no file either.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveUnder(root: string, rel: string): Promise<string | null> {
|
|
33
|
+
let decoded: string
|
|
34
|
+
try {
|
|
35
|
+
decoded = decodeURIComponent(rel)
|
|
36
|
+
} catch {
|
|
37
|
+
// A malformed percent escape names no file.
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
if (decoded.includes('\0')) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
const full = path.resolve(root, decoded)
|
|
44
|
+
if (!under(root, full)) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const s = await stat(full)
|
|
49
|
+
if (!s.isFile()) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
const real = await realpath(full)
|
|
53
|
+
const realRoot = await realpath(root)
|
|
54
|
+
return under(realRoot, real) ? full : null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The browser-side libraries, served from node_modules. Single-file libraries map to one exact
|
|
62
|
+
* file; `diff` and `mermaid` ship many ES modules with relative imports, so their directories are
|
|
63
|
+
* served as a whole (JavaScript files only). Mermaid loads the chunk of a diagram type at render
|
|
64
|
+
* time, which is why its whole `dist/` has to be reachable.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveVendor(roots: VendorRoots, rel: string): Promise<string | null> {
|
|
67
|
+
const exact: Record<string, string> = {
|
|
68
|
+
'marked.js': roots.marked,
|
|
69
|
+
'purify.js': roots.dompurify,
|
|
70
|
+
'highlight.js': roots.hljs,
|
|
71
|
+
}
|
|
72
|
+
const hit = exact[rel]
|
|
73
|
+
if (hit !== undefined) {
|
|
74
|
+
return resolveUnder(path.dirname(hit), path.basename(hit))
|
|
75
|
+
}
|
|
76
|
+
if (rel.startsWith('diff/') && rel.endsWith('.js')) {
|
|
77
|
+
return resolveUnder(roots.diff, rel.slice('diff/'.length))
|
|
78
|
+
}
|
|
79
|
+
if (rel.startsWith('mermaid/') && rel.endsWith('.mjs')) {
|
|
80
|
+
return resolveUnder(roots.mermaid, rel.slice('mermaid/'.length))
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function staticRoutes(ctx: AppContext): Hono {
|
|
86
|
+
const app = new Hono()
|
|
87
|
+
|
|
88
|
+
async function send(file: string, cache: string): Promise<Response> {
|
|
89
|
+
const body = await readFile(file)
|
|
90
|
+
return new Response(new Uint8Array(body), {
|
|
91
|
+
headers: { 'content-type': contentTypeFor(file), 'cache-control': cache },
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
app.get('/static/*', async c => {
|
|
96
|
+
const rel = c.req.path.slice('/static/'.length)
|
|
97
|
+
const file = await resolveUnder(ctx.staticDir, rel)
|
|
98
|
+
if (file === null) {
|
|
99
|
+
throw new AppError('NOT_FOUND', `no static file ${rel}`, 404)
|
|
100
|
+
}
|
|
101
|
+
return send(file, 'no-cache')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
app.get('/vendor/*', async c => {
|
|
105
|
+
const rel = c.req.path.slice('/vendor/'.length)
|
|
106
|
+
const file = await resolveVendor(ctx.vendorRoots, rel)
|
|
107
|
+
if (file === null) {
|
|
108
|
+
throw new AppError('NOT_FOUND', `no vendored file ${rel}`, 404)
|
|
109
|
+
}
|
|
110
|
+
return send(file, 'public, max-age=86400')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return app
|
|
114
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import type { MiddlewareHandler } from 'hono'
|
|
3
|
+
import type { AppEnv } from './env.js'
|
|
4
|
+
import { AppError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
const ALLOWED_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
7
|
+
|
|
8
|
+
/** The hostname part of a Host header (`localhost:3010` → `localhost`, `[::1]:3010` → `[::1]`). */
|
|
9
|
+
export function hostnameOf(host: string): string {
|
|
10
|
+
const m = /^(\[[^\]]+\]|[^:]+)(?::\d+)?$/.exec(host.trim())
|
|
11
|
+
return m?.[1]?.toLowerCase() ?? ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAllowedHost(host: string | undefined): host is string {
|
|
15
|
+
return host !== undefined && ALLOWED_HOSTNAMES.has(hostnameOf(host))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Origin must name this very server. `null` (opaque origin) is rejected too. */
|
|
19
|
+
export function isSameOrigin(origin: string | undefined, host: string): boolean {
|
|
20
|
+
if (origin === undefined) {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
let url: URL
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(origin)
|
|
26
|
+
} catch {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
return url.host.toLowerCase() === host.toLowerCase() && isAllowedHost(url.host)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Two checks. The Host allowlist stops DNS rebinding: a page on an attacker's domain that
|
|
36
|
+
* resolves to 127.0.0.1 sends its own hostname. The same-origin check on state-changing methods
|
|
37
|
+
* stops cross-site requests, because this server can post to GitHub as the user.
|
|
38
|
+
*/
|
|
39
|
+
export const securityMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
40
|
+
const host = c.req.header('host')
|
|
41
|
+
if (!isAllowedHost(host)) {
|
|
42
|
+
throw new AppError('FORBIDDEN_HOST', 'this server only answers to localhost', 403)
|
|
43
|
+
}
|
|
44
|
+
if (!SAFE_METHODS.has(c.req.method)) {
|
|
45
|
+
const site = c.req.header('sec-fetch-site')
|
|
46
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none') {
|
|
47
|
+
throw new AppError('CROSS_ORIGIN', 'cross-site request rejected', 403)
|
|
48
|
+
}
|
|
49
|
+
if (!isSameOrigin(c.req.header('origin'), host)) {
|
|
50
|
+
throw new AppError('CROSS_ORIGIN', 'cross-origin request rejected', 403)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await next()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The page's own scripts and styles, nothing else. Inline styles are allowed because the page
|
|
58
|
+
* sets dynamic values that way (progress width, layer stripe colors) and mermaid injects a style
|
|
59
|
+
* element per drawing; inline scripts are not, except the two the shell carries under its nonce.
|
|
60
|
+
*/
|
|
61
|
+
export function contentSecurityPolicy(nonce: string): string {
|
|
62
|
+
return [
|
|
63
|
+
"default-src 'none'",
|
|
64
|
+
`script-src 'self' 'nonce-${nonce}'`,
|
|
65
|
+
"style-src 'self' 'unsafe-inline'",
|
|
66
|
+
"img-src 'self' data: https:",
|
|
67
|
+
"font-src 'self'",
|
|
68
|
+
"connect-src 'self'",
|
|
69
|
+
"form-action 'self'",
|
|
70
|
+
"base-uri 'none'",
|
|
71
|
+
"frame-ancestors 'none'",
|
|
72
|
+
"object-src 'none'",
|
|
73
|
+
].join('; ')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fresh nonce per HTML response, so the shell's inline scripts run and nothing else does. */
|
|
77
|
+
export function createNonce(): string {
|
|
78
|
+
return randomBytes(16).toString('base64')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Headers every answer carries. `no-store` on the API keeps PR text, comments, and canvases out
|
|
83
|
+
* of any cache; HTML pages carry the policy above.
|
|
84
|
+
*/
|
|
85
|
+
export function applyResponseHeaders(res: Response, path: string, nonce: string): void {
|
|
86
|
+
res.headers.set('x-content-type-options', 'nosniff')
|
|
87
|
+
res.headers.set('referrer-policy', 'no-referrer')
|
|
88
|
+
if (path.startsWith('/api/')) {
|
|
89
|
+
res.headers.set('cache-control', 'no-store')
|
|
90
|
+
}
|
|
91
|
+
if ((res.headers.get('content-type') ?? '').startsWith('text/html')) {
|
|
92
|
+
res.headers.set('content-security-policy', contentSecurityPolicy(nonce))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Puts the headers on every answer of the normal path. Errors never reach the code after
|
|
98
|
+
* `next()`, so `createApp` applies the same headers to what its error handlers build.
|
|
99
|
+
*/
|
|
100
|
+
export const responseHeaders: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
101
|
+
c.set('cspNonce', createNonce())
|
|
102
|
+
await next()
|
|
103
|
+
applyResponseHeaders(c.res, c.req.path, c.get('cspNonce'))
|
|
104
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Server-sent events, written by hand: one named event per frame, JSON in the data line.
|
|
2
|
+
import type { ChatEvent } from '../contract/chat.js'
|
|
3
|
+
|
|
4
|
+
export const SSE_HEADERS = {
|
|
5
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
6
|
+
'cache-control': 'no-store',
|
|
7
|
+
connection: 'keep-alive',
|
|
8
|
+
/** Nothing between the browser and this server should buffer a stream on localhost. */
|
|
9
|
+
'x-accel-buffering': 'no',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One frame. A data line cannot hold a newline, so the JSON is written as one line, which
|
|
14
|
+
* `JSON.stringify` already gives.
|
|
15
|
+
*/
|
|
16
|
+
export function sseFrame(event: ChatEvent): string {
|
|
17
|
+
const { event: name, ...rest } = event
|
|
18
|
+
return `event: ${name}\ndata: ${JSON.stringify(rest)}\n\n`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The event stream of one chat turn as a response body.
|
|
23
|
+
*
|
|
24
|
+
* `onCancel` runs the moment the reader goes away. Asking the iterator to return is not enough on
|
|
25
|
+
* its own: an async generator queues `return()` behind a `next()` that is still waiting, so a
|
|
26
|
+
* silent agent would keep running until it spoke again.
|
|
27
|
+
*/
|
|
28
|
+
export function sseStream(
|
|
29
|
+
events: AsyncIterable<ChatEvent>,
|
|
30
|
+
onClose?: () => void,
|
|
31
|
+
onCancel?: () => void
|
|
32
|
+
): ReadableStream<Uint8Array> {
|
|
33
|
+
const encoder = new TextEncoder()
|
|
34
|
+
const iterator = events[Symbol.asyncIterator]()
|
|
35
|
+
return new ReadableStream<Uint8Array>({
|
|
36
|
+
async pull(controller) {
|
|
37
|
+
let next: IteratorResult<ChatEvent>
|
|
38
|
+
try {
|
|
39
|
+
next = await iterator.next()
|
|
40
|
+
} catch (err) {
|
|
41
|
+
controller.enqueue(
|
|
42
|
+
encoder.encode(
|
|
43
|
+
sseFrame({
|
|
44
|
+
event: 'error',
|
|
45
|
+
code: 'INTERNAL',
|
|
46
|
+
message: err instanceof Error ? err.message : String(err),
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
controller.close()
|
|
51
|
+
onClose?.()
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (next.done === true) {
|
|
55
|
+
controller.close()
|
|
56
|
+
onClose?.()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
controller.enqueue(encoder.encode(sseFrame(next.value)))
|
|
60
|
+
},
|
|
61
|
+
cancel() {
|
|
62
|
+
onCancel?.()
|
|
63
|
+
void iterator.return?.(undefined)
|
|
64
|
+
onClose?.()
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/** Writes JSON through a temp file and a rename, so a reader never sees a half-written file. */
|
|
6
|
+
export async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
|
|
7
|
+
return writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function writeTextAtomic(file: string, text: string): Promise<void> {
|
|
11
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
12
|
+
const temporaryDir = await mkdtemp(`${file}.`)
|
|
13
|
+
try {
|
|
14
|
+
const tmp = path.join(temporaryDir, 'content')
|
|
15
|
+
await writeFile(tmp, text, 'utf8')
|
|
16
|
+
await rename(tmp, file)
|
|
17
|
+
} finally {
|
|
18
|
+
await rm(temporaryDir, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed and validated JSON, or null when the file does not exist. Invalid content throws. */
|
|
23
|
+
export async function readJson<T>(file: string, schema: z.ZodType<T>): Promise<T | null> {
|
|
24
|
+
let text: string
|
|
25
|
+
try {
|
|
26
|
+
text = await readFile(file, 'utf8')
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (isNotFound(err)) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
throw err
|
|
32
|
+
}
|
|
33
|
+
return schema.parse(JSON.parse(text))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Like readJson, but a missing file, invalid JSON, or a shape the schema rejects all return
|
|
38
|
+
* `fallback()`. Other read errors still throw.
|
|
39
|
+
*/
|
|
40
|
+
export async function readJsonOrDefault<T, D>(file: string, schema: z.ZodType<T>, fallback: () => D): Promise<T | D> {
|
|
41
|
+
const text = await readText(file)
|
|
42
|
+
if (text === null) {
|
|
43
|
+
return fallback()
|
|
44
|
+
}
|
|
45
|
+
let raw: unknown
|
|
46
|
+
try {
|
|
47
|
+
raw = JSON.parse(text)
|
|
48
|
+
} catch {
|
|
49
|
+
return fallback()
|
|
50
|
+
}
|
|
51
|
+
const parsed = schema.safeParse(raw)
|
|
52
|
+
return parsed.success ? parsed.data : fallback()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readText(file: string): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
return await readFile(file, 'utf8')
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (isNotFound(err)) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isNotFound(err: unknown): boolean {
|
|
67
|
+
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
|
|
68
|
+
}
|