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