patches.tsx
⎇
Raw
1import { Elysia, t } from "elysia";
2import { sql } from "kysely";
3import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
4import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
5import { db, getRepo } from "../db/index.ts";
6import {
7 requireAdmin,
8 requireAuth,
9 resolveSession,
10} from "../middleware/session.ts";
11import { git } from "../services/git.ts";
12import { prepareDiff } from "../services/highlightWorker.ts";
13import { renderMarkdown } from "../services/markdown.ts";
14import { patchCache } from "../services/patchCache.ts";
15import { buildReactionCounts } from "../services/reactions.ts";
16import { NewPatch } from "../views/patches/NewPatch.tsx";
17import { PatchDetail } from "../views/patches/PatchDetail.tsx";
18import { PatchList } from "../views/patches/PatchList.tsx";
19import { html } from "../views/render.tsx";
20
21function isValidPatch(content: string): boolean {
22 const lines = content.split("\n");
23 return lines.some(
24 (l) =>
25 l.startsWith("diff --git ") ||
26 l.startsWith("--- ") ||
27 l.startsWith("+++ ") ||
28 l.startsWith("@@ ") ||
29 l.startsWith("Index: "),
30 );
31}
32
33async function runPatchCheck(
34 repoName: string,
35 patchId: number,
36 patchContent: string,
37) {
38 const result = await git.checkPatch(repoName, patchContent);
39 const applyResult = {
40 status: result.clean ? ("clean" as const) : ("conflict" as const),
41 output: result.output,
42 };
43 patchCache.set(patchId, applyResult);
44 return applyResult;
45}
46
47export const patchRoutes = new Elysia()
48 .guard({
49 cookie: t.Cookie({ session: t.Optional(t.String()) }),
50 })
51 .get(
52 "/:repo/patches",
53 async ({ params, query, cookie }) => {
54 const user = await resolveSession(cookie.session.value);
55 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
56 if (!repo) return new Response("Not found", { status: 404 });
57
58 const status = ["open", "merged", "closed"].includes(
59 query.status ?? "",
60 )
61 ? query.status!
62 : "open";
63 const page = Math.max(1, query.page ?? 1);
64
65 const allCounts = await db
66 .selectFrom("patches")
67 .select(["status", db.fn.countAll<number>().as("count")])
68 .where("repo_id", "=", repo.id)
69 .groupBy("status")
70 .execute();
71 const counts: Record<string, number> = Object.fromEntries(
72 allCounts.map((r) => [r.status, Number(r.count)]),
73 );
74 const totalPages = Math.max(
75 1,
76 Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
77 );
78 const safePage = Math.min(page, totalPages);
79 const offset = (safePage - 1) * PATCHES_PER_PAGE;
80
81 const patches = await db
82 .selectFrom("patches")
83 .leftJoin("users", "users.id", "patches.author_id")
84 .select([
85 "patches.id",
86 "patches.repo_id",
87 "patches.author_id",
88 "patches.number",
89 "patches.title",
90 "patches.description",
91 "patches.patch_content",
92 "patches.status",
93 "patches.created_at",
94 "patches.updated_at",
95 "patches.edited_at",
96 "users.username as author_username",
97 ])
98 .where("patches.repo_id", "=", repo.id)
99 .where("patches.status", "=", status)
100 .orderBy("patches.number", "desc")
101 .limit(PATCHES_PER_PAGE)
102 .offset(offset)
103 .execute();
104
105 const pagination = {
106 page: safePage,
107 totalPages,
108 pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
109 };
110 return html(
111 <PatchList
112 user={user}
113 repo={repo}
114 patches={
115 patches as ((typeof patches)[0] & {
116 author_username: string;
117 })[]
118 }
119 status={status}
120 counts={counts}
121 pagination={pagination}
122 />,
123 );
124 },
125 {
126 query: t.Object({
127 status: t.Optional(t.String()),
128 page: t.Optional(t.Numeric()),
129 }),
130 },
131 )
132
133 .get("/:repo/patches/new", async ({ params, cookie }) => {
134 const user = await resolveSession(cookie.session.value);
135 const deny = requireAuth(user);
136 if (deny) return deny;
137 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
138 if (!repo) return new Response("Not found", { status: 404 });
139 return html(<NewPatch user={user!} repo={repo} />);
140 })
141
142 .post(
143 "/:repo/patches",
144 async ({ params, body, cookie }) => {
145 const user = await resolveSession(cookie.session.value);
146 const deny = requireAuth(user);
147 if (deny) return deny;
148 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
149 if (!repo) return new Response("Not found", { status: 404 });
150
151 if (!body.title?.trim()) {
152 return html(
153 <NewPatch
154 user={user!}
155 repo={repo}
156 error="Title is required"
157 />,
158 );
159 }
160
161 if (!body.patch_file) {
162 return html(
163 <NewPatch
164 user={user!}
165 repo={repo}
166 error="Patch file is required"
167 />,
168 );
169 }
170
171 if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
172 return html(
173 <NewPatch
174 user={user!}
175 repo={repo}
176 error="Patch file is too large"
177 />,
178 );
179 }
180
181 const patchContent = await body.patch_file.text();
182 if (!patchContent.trim()) {
183 return html(
184 <NewPatch
185 user={user!}
186 repo={repo}
187 error="Patch file is empty"
188 />,
189 );
190 }
191
192 // Validate it looks like a patch/diff file
193 if (!isValidPatch(patchContent)) {
194 return html(
195 <NewPatch
196 user={user!}
197 repo={repo}
198 error="File does not appear to be a valid patch or diff file"
199 />,
200 );
201 }
202
203 const now = new Date().toISOString();
204 const { number, result } = await db
205 .transaction()
206 .execute(async (trx) => {
207 const { patch_seq } = await trx
208 .updateTable("repositories")
209 .set({ patch_seq: sql`patch_seq + 1` })
210 .where("id", "=", repo.id)
211 .returning("patch_seq")
212 .executeTakeFirstOrThrow();
213 const inserted = await trx
214 .insertInto("patches")
215 .values({
216 repo_id: repo.id,
217 author_id: user?.id,
218 number: patch_seq,
219 title: body.title!.trim(),
220 description: body.description?.trim() ?? "",
221 patch_content: patchContent,
222 status: "open",
223 created_at: now,
224 updated_at: now,
225 })
226 .returning("id")
227 .executeTakeFirstOrThrow();
228 return { number: patch_seq, result: inserted };
229 });
230
231 await runPatchCheck(repo.name, result.id, patchContent);
232
233 return new Response(null, {
234 status: 302,
235 headers: { Location: `/${repo.name}/patches/${number}` },
236 });
237 },
238 {
239 body: t.Object({
240 title: t.Optional(t.String()),
241 description: t.Optional(t.String()),
242 patch_file: t.Optional(t.File()),
243 }),
244 },
245 )
246
247 .get(
248 "/:repo/patches/:number",
249 async ({ params, query, cookie }) => {
250 const user = await resolveSession(cookie.session.value);
251 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
252 if (!repo) return new Response("Not found", { status: 404 });
253
254 const patchNum = parseInt(params.number, 10);
255 const patch = await db
256 .selectFrom("patches")
257 .leftJoin("users", "users.id", "patches.author_id")
258 .select([
259 "patches.id",
260 "patches.repo_id",
261 "patches.author_id",
262 "patches.number",
263 "patches.title",
264 "patches.description",
265 "patches.patch_content",
266 "patches.status",
267 "patches.created_at",
268 "patches.updated_at",
269 "patches.edited_at",
270 "users.username as author_username",
271 "users.avatar_version as author_avatar_version",
272 ])
273 .where("patches.repo_id", "=", repo.id)
274 .where("patches.number", "=", patchNum)
275 .executeTakeFirst();
276 if (!patch) return new Response("Not found", { status: 404 });
277
278 const descriptionHtml = patch.description
279 ? renderMarkdown(patch.description)
280 : "";
281
282 let applyResult = patchCache.get(patch.id) ?? null;
283 // Cold cache (e.g. server restart) — re-check synchronously for open patches only
284 if (!applyResult && patch.status === "open") {
285 applyResult = await runPatchCheck(
286 repo.name,
287 patch.id,
288 patch.patch_content,
289 );
290 }
291
292 const files = await prepareDiff(
293 patch.patch_content,
294 `patch:${patch.id}`,
295 );
296
297 const comments = await db
298 .selectFrom("patch_comments")
299 .leftJoin("users", "users.id", "patch_comments.author_id")
300 .select([
301 "patch_comments.id",
302 "patch_comments.patch_id",
303 "patch_comments.author_id",
304 "patch_comments.body",
305 "patch_comments.created_at",
306 "patch_comments.edited_at",
307 "users.username as author_username",
308 "users.avatar_version as author_avatar_version",
309 ])
310 .where("patch_comments.patch_id", "=", patch.id)
311 .orderBy("patch_comments.created_at", "asc")
312 .execute();
313
314 const commentsWithHtml = comments.map((c) => ({
315 ...c,
316 bodyHtml: renderMarkdown(c.body),
317 }));
318
319 const allReactions = await db
320 .selectFrom("patch_reactions")
321 .selectAll()
322 .where("patch_id", "=", patch.id)
323 .execute();
324
325 const reactions = buildReactionCounts(allReactions, null, user?.id);
326 const commentReactions = new Map(
327 comments.map((c) => [
328 c.id,
329 buildReactionCounts(allReactions, c.id, user?.id),
330 ]),
331 );
332
333 const tab =
334 query.tab === "changes"
335 ? ("changes" as const)
336 : ("conversation" as const);
337
338 return html(
339 <PatchDetail
340 user={user}
341 repo={repo}
342 patch={
343 patch as typeof patch & {
344 author_username: string;
345 author_avatar_version: number | null;
346 }
347 }
348 descriptionHtml={descriptionHtml}
349 applyResult={applyResult}
350 files={files}
351 tab={tab}
352 comments={
353 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
354 author_username: string;
355 author_avatar_version: number | null;
356 })[]
357 }
358 reactions={reactions}
359 commentReactions={commentReactions}
360 />,
361 );
362 },
363 {
364 query: t.Object({ tab: t.Optional(t.String()) }),
365 },
366 )
367
368 .post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
369 const user = await resolveSession(cookie.session.value);
370 const deny = requireAdmin(user);
371 if (deny) return deny;
372 const repo = await getRepo(params.repo, true);
373 if (!repo) return new Response("Not found", { status: 404 });
374
375 const patchNum = parseInt(params.number, 10);
376 const patch = await db
377 .selectFrom("patches")
378 .select(["id", "title", "description", "patch_content", "status"])
379 .where("repo_id", "=", repo.id)
380 .where("number", "=", patchNum)
381 .executeTakeFirst();
382 if (!patch) return new Response("Not found", { status: 404 });
383
384 // Atomically claim the merge slot before the slow git operation to
385 // prevent two concurrent requests from both applying the same patch.
386 const claimed = await db
387 .updateTable("patches")
388 .set({ status: "merged", updated_at: new Date().toISOString() })
389 .where("id", "=", patch.id)
390 .where("status", "=", "open")
391 .executeTakeFirst();
392 if (!claimed || claimed.numUpdatedRows === 0n)
393 return new Response("Patch is not open", { status: 400 });
394
395 try {
396 await git.applyPatch(
397 repo.name,
398 patch.patch_content,
399 patch.title,
400 patch.description,
401 );
402 } catch (err) {
403 // Roll back the status if the git operation fails
404 await db
405 .updateTable("patches")
406 .set({ status: "open", updated_at: new Date().toISOString() })
407 .where("id", "=", patch.id)
408 .execute();
409 throw err;
410 }
411 patchCache.invalidate(patch.id);
412
413 return new Response(null, {
414 status: 302,
415 headers: { Location: `/${repo.name}/patches/${patchNum}` },
416 });
417 })
418
419 .post("/:repo/patches/:number/close", async ({ params, cookie }) => {
420 const user = await resolveSession(cookie.session.value);
421 const deny = requireAdmin(user);
422 if (deny) return deny;
423 const repo = await getRepo(params.repo, true);
424 if (!repo) return new Response("Not found", { status: 404 });
425
426 const patchNum = parseInt(params.number, 10);
427 const patch = await db
428 .selectFrom("patches")
429 .select("id")
430 .where("repo_id", "=", repo.id)
431 .where("number", "=", patchNum)
432 .executeTakeFirst();
433 if (!patch) return new Response("Not found", { status: 404 });
434
435 // Toggle open↔closed atomically; exclude merged patches from the WHERE
436 // so that numUpdatedRows = 0 means the patch is merged (or gone).
437 const toggled = await db
438 .updateTable("patches")
439 .set({
440 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
441 updated_at: new Date().toISOString(),
442 })
443 .where("id", "=", patch.id)
444 .where("status", "!=", "merged")
445 .executeTakeFirst();
446 if (!toggled || toggled.numUpdatedRows === 0n)
447 return new Response("Patch is merged", { status: 400 });
448
449 return new Response(null, {
450 status: 302,
451 headers: { Location: `/${repo.name}/patches/${patchNum}` },
452 });
453 })
454
455 .post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
456 const user = await resolveSession(cookie.session.value);
457 const deny = requireAuth(user);
458 if (deny) return deny;
459 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
460 if (!repo) return new Response("Not found", { status: 404 });
461
462 const patchNum = parseInt(params.number, 10);
463 const patch = await db
464 .selectFrom("patches")
465 .select(["id", "author_id"])
466 .where("repo_id", "=", repo.id)
467 .where("number", "=", patchNum)
468 .executeTakeFirst();
469 if (!patch) return new Response("Not found", { status: 404 });
470 if (patch.author_id !== user?.id && !user?.isAdmin)
471 return new Response("Forbidden", { status: 403 });
472
473 patchCache.invalidate(patch.id);
474 await db.deleteFrom("patches").where("id", "=", patch.id).execute();
475
476 return new Response(null, {
477 status: 302,
478 headers: { Location: `/${repo.name}/patches` },
479 });
480 })
481
482 .post(
483 "/:repo/patches/:number/comments",
484 async ({ params, body, cookie }) => {
485 const user = await resolveSession(cookie.session.value);
486 const deny = requireAuth(user);
487 if (deny) return deny;
488 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
489 if (!repo) return new Response("Not found", { status: 404 });
490
491 const patchNum = parseInt(params.number, 10);
492 const patch = await db
493 .selectFrom("patches")
494 .select(["id", "status"])
495 .where("repo_id", "=", repo.id)
496 .where("number", "=", patchNum)
497 .executeTakeFirst();
498 if (!patch) return new Response("Not found", { status: 404 });
499
500 const { body: commentBody } = body;
501 if (!commentBody?.trim()) {
502 return new Response(null, {
503 status: 302,
504 headers: { Location: `/${repo.name}/patches/${patchNum}` },
505 });
506 }
507
508 await db.transaction().execute(async (trx) => {
509 const now = new Date().toISOString();
510 await trx
511 .insertInto("patch_comments")
512 .values({
513 patch_id: patch.id,
514 author_id: user?.id,
515 body: commentBody.trim(),
516 created_at: now,
517 })
518 .execute();
519 await trx
520 .updateTable("patches")
521 .set({ updated_at: now })
522 .where("id", "=", patch.id)
523 .execute();
524 });
525
526 return new Response(null, {
527 status: 302,
528 headers: { Location: `/${repo.name}/patches/${patchNum}` },
529 });
530 },
531 {
532 body: t.Object({ body: t.String() }),
533 },
534 )
535
536 .post(
537 "/:repo/patches/:number/react",
538 async ({ params, body, cookie }) => {
539 const user = await resolveSession(cookie.session.value);
540 const deny = requireAuth(user);
541 if (deny) return deny;
542 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
543 if (!repo) return new Response("Not found", { status: 404 });
544
545 const { emoji, comment_id } = body;
546 if (!ALLOWED_REACTIONS.has(emoji)) {
547 return new Response("Invalid emoji", { status: 400 });
548 }
549
550 const patchNum = parseInt(params.number, 10);
551 const patch = await db
552 .selectFrom("patches")
553 .select(["id"])
554 .where("repo_id", "=", repo.id)
555 .where("number", "=", patchNum)
556 .executeTakeFirst();
557 if (!patch) return new Response("Not found", { status: 404 });
558
559 const commentId = comment_id ? parseInt(comment_id, 10) : null;
560
561 await db.transaction().execute(async (trx) => {
562 const existing = await trx
563 .selectFrom("patch_reactions")
564 .select(["id", "emoji"])
565 .where("patch_id", "=", patch.id)
566 .where((eb) =>
567 commentId !== null
568 ? eb("comment_id", "=", commentId)
569 : eb("comment_id", "is", null),
570 )
571 .where("user_id", "=", user!.id)
572 .executeTakeFirst();
573
574 if (existing) {
575 if (existing.emoji === emoji) {
576 await trx
577 .deleteFrom("patch_reactions")
578 .where("id", "=", existing.id)
579 .execute();
580 } else {
581 await trx
582 .updateTable("patch_reactions")
583 .set({ emoji })
584 .where("id", "=", existing.id)
585 .execute();
586 }
587 } else {
588 await trx
589 .insertInto("patch_reactions")
590 .values({
591 patch_id: patch.id,
592 comment_id: commentId,
593 user_id: user!.id,
594 emoji,
595 })
596 .execute();
597 }
598 });
599
600 return new Response(null, {
601 status: 303,
602 headers: { Location: `/${repo.name}/patches/${patchNum}` },
603 });
604 },
605 {
606 body: t.Object({
607 emoji: t.String(),
608 comment_id: t.Optional(t.String()),
609 }),
610 },
611 )
612
613 .post(
614 "/:repo/patches/:number/comments/:id/edit",
615 async ({ params, body, cookie }) => {
616 const user = await resolveSession(cookie.session.value);
617 const deny = requireAuth(user);
618 if (deny) return deny;
619 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
620 if (!repo) return new Response("Not found", { status: 404 });
621
622 const comment = await db
623 .selectFrom("patch_comments")
624 .select(["id", "author_id"])
625 .where("id", "=", params.id)
626 .executeTakeFirst();
627 if (!comment) return new Response("Not found", { status: 404 });
628 if (comment.author_id !== user?.id && !user?.isAdmin)
629 return new Response("Forbidden", { status: 403 });
630
631 const patchNum = parseInt(params.number, 10);
632 await db
633 .updateTable("patch_comments")
634 .set({
635 body: body.edit_body.trim(),
636 edited_at: new Date().toISOString(),
637 })
638 .where("id", "=", comment.id)
639 .execute();
640
641 return new Response(null, {
642 status: 302,
643 headers: { Location: `/${repo.name}/patches/${patchNum}` },
644 });
645 },
646 {
647 params: t.Object({
648 repo: t.String(),
649 number: t.String(),
650 id: t.Numeric(),
651 }),
652 body: t.Object({ edit_body: t.String() }),
653 },
654 )
655
656 .post(
657 "/:repo/patches/:number/edit",
658 async ({ params, body, cookie }) => {
659 const user = await resolveSession(cookie.session.value);
660 const deny = requireAuth(user);
661 if (deny) return deny;
662 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
663 if (!repo) return new Response("Not found", { status: 404 });
664
665 const patchNum = parseInt(params.number, 10);
666 const patch = await db
667 .selectFrom("patches")
668 .select(["id", "author_id"])
669 .where("repo_id", "=", repo.id)
670 .where("number", "=", patchNum)
671 .executeTakeFirst();
672 if (!patch) return new Response("Not found", { status: 404 });
673 if (patch.author_id !== user?.id && !user?.isAdmin)
674 return new Response("Forbidden", { status: 403 });
675
676 await db
677 .updateTable("patches")
678 .set({
679 title: body.title.trim(),
680 description: body.edit_description ?? "",
681 edited_at: new Date().toISOString(),
682 updated_at: new Date().toISOString(),
683 })
684 .where("id", "=", patch.id)
685 .execute();
686
687 return new Response(null, {
688 status: 302,
689 headers: { Location: `/${repo.name}/patches/${patchNum}` },
690 });
691 },
692 {
693 body: t.Object({
694 title: t.String(),
695 edit_description: t.Optional(t.String()),
696 }),
697 },
698 );
699