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.author_name",
94 "patches.author_email",
95 "patches.created_at",
96 "patches.updated_at",
97 "patches.edited_at",
98 "users.username as author_username",
99 "users.avatar_version as author_avatar_version",
100 ])
101 .where("patches.repo_id", "=", repo.id)
102 .where("patches.status", "=", status)
103 .orderBy("patches.number", "desc")
104 .limit(PATCHES_PER_PAGE)
105 .offset(offset)
106 .execute();
107
108 const pagination = {
109 page: safePage,
110 totalPages,
111 pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
112 };
113 return html(
114 <PatchList
115 user={user}
116 repo={repo}
117 patches={
118 patches as ((typeof patches)[0] & {
119 author_username: string;
120 author_avatar_version: number | null;
121 })[]
122 }
123 status={status}
124 counts={counts}
125 pagination={pagination}
126 />,
127 );
128 },
129 {
130 query: t.Object({
131 status: t.Optional(t.String()),
132 page: t.Optional(t.Numeric()),
133 }),
134 },
135 )
136
137 .get("/:repo/patches/new", async ({ params, cookie }) => {
138 const user = await resolveSession(cookie.session.value);
139 const deny = requireAuth(user);
140 if (deny) return deny;
141 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
142 if (!repo) return new Response("Not found", { status: 404 });
143 return html(<NewPatch user={user!} repo={repo} />);
144 })
145
146 .post(
147 "/:repo/patches",
148 async ({ params, body, cookie }) => {
149 const user = await resolveSession(cookie.session.value);
150 const deny = requireAuth(user);
151 if (deny) return deny;
152 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
153 if (!repo) return new Response("Not found", { status: 404 });
154
155 if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
156 return html(
157 <NewPatch
158 user={user!}
159 repo={repo}
160 error="You must set your git name and email in Settings before creating a patch"
161 />,
162 );
163 }
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/diff 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 or diff file"
213 />,
214 );
215 }
216
217 const now = new Date().toISOString();
218 const { number, result } = await db
219 .transaction()
220 .execute(async (trx) => {
221 const { patch_seq } = await trx
222 .updateTable("repositories")
223 .set({ patch_seq: sql`patch_seq + 1` })
224 .where("id", "=", repo.id)
225 .returning("patch_seq")
226 .executeTakeFirstOrThrow();
227 const inserted = await trx
228 .insertInto("patches")
229 .values({
230 repo_id: repo.id,
231 author_id: user?.id,
232 number: patch_seq,
233 title: body.title!.trim(),
234 description: body.description?.trim() ?? "",
235 patch_content: patchContent,
236 status: "open",
237 author_name: user!.git_name!,
238 author_email: user!.git_email!,
239 created_at: now,
240 updated_at: now,
241 })
242 .returning("id")
243 .executeTakeFirstOrThrow();
244 return { number: patch_seq, result: inserted };
245 });
246
247 await runPatchCheck(repo.name, result.id, patchContent);
248
249 return new Response(null, {
250 status: 302,
251 headers: { Location: `/${repo.name}/patches/${number}` },
252 });
253 },
254 {
255 body: t.Object({
256 title: t.Optional(t.String()),
257 description: t.Optional(t.String()),
258 patch_file: t.Optional(t.File()),
259 }),
260 },
261 )
262
263 .get(
264 "/:repo/patches/:number",
265 async ({ params, query, cookie }) => {
266 const user = await resolveSession(cookie.session.value);
267 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
268 if (!repo) return new Response("Not found", { status: 404 });
269
270 const patchNum = parseInt(params.number, 10);
271 const patch = await db
272 .selectFrom("patches")
273 .leftJoin("users", "users.id", "patches.author_id")
274 .select([
275 "patches.id",
276 "patches.repo_id",
277 "patches.author_id",
278 "patches.number",
279 "patches.title",
280 "patches.description",
281 "patches.patch_content",
282 "patches.status",
283 "patches.author_name",
284 "patches.author_email",
285 "patches.created_at",
286 "patches.updated_at",
287 "patches.edited_at",
288 "users.username as author_username",
289 "users.avatar_version as author_avatar_version",
290 ])
291 .where("patches.repo_id", "=", repo.id)
292 .where("patches.number", "=", patchNum)
293 .executeTakeFirst();
294 if (!patch) return new Response("Not found", { status: 404 });
295
296 const descriptionHtml = patch.description
297 ? renderMarkdown(patch.description)
298 : "";
299
300 let applyResult = patchCache.get(patch.id) ?? null;
301 // Cold cache (e.g. server restart) — re-check synchronously for open patches only
302 if (!applyResult && patch.status === "open") {
303 applyResult = await runPatchCheck(
304 repo.name,
305 patch.id,
306 patch.patch_content,
307 );
308 }
309
310 const files = await prepareDiff(
311 patch.patch_content,
312 `patch:${patch.id}`,
313 );
314
315 const comments = await db
316 .selectFrom("patch_comments")
317 .leftJoin("users", "users.id", "patch_comments.author_id")
318 .select([
319 "patch_comments.id",
320 "patch_comments.patch_id",
321 "patch_comments.author_id",
322 "patch_comments.body",
323 "patch_comments.created_at",
324 "patch_comments.edited_at",
325 "users.username as author_username",
326 "users.avatar_version as author_avatar_version",
327 ])
328 .where("patch_comments.patch_id", "=", patch.id)
329 .orderBy("patch_comments.created_at", "asc")
330 .execute();
331
332 const commentsWithHtml = comments.map((c) => ({
333 ...c,
334 bodyHtml: renderMarkdown(c.body),
335 }));
336
337 const allReactions = await db
338 .selectFrom("patch_reactions")
339 .selectAll()
340 .where("patch_id", "=", patch.id)
341 .execute();
342
343 const reactions = buildReactionCounts(allReactions, null, user?.id);
344 const commentReactions = new Map(
345 comments.map((c) => [
346 c.id,
347 buildReactionCounts(allReactions, c.id, user?.id),
348 ]),
349 );
350
351 const tab =
352 query.tab === "changes"
353 ? ("changes" as const)
354 : ("conversation" as const);
355
356 return html(
357 <PatchDetail
358 user={user}
359 repo={repo}
360 patch={
361 patch as typeof patch & {
362 author_username: string;
363 author_avatar_version: number | null;
364 author_name: string;
365 author_email: string;
366 }
367 }
368 descriptionHtml={descriptionHtml}
369 applyResult={applyResult}
370 files={files}
371 tab={tab}
372 comments={
373 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
374 author_username: string;
375 author_avatar_version: number | null;
376 })[]
377 }
378 reactions={reactions}
379 commentReactions={commentReactions}
380 />,
381 );
382 },
383 {
384 query: t.Object({ tab: t.Optional(t.String()) }),
385 },
386 )
387
388 .post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
389 const user = await resolveSession(cookie.session.value);
390 const deny = requireAdmin(user);
391 if (deny) return deny;
392
393 if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
394 return new Response(
395 "Set your git name and email in Settings before merging",
396 { status: 400, headers: { "Content-Type": "text/plain" } },
397 );
398 }
399
400 const repo = await getRepo(params.repo, true);
401 if (!repo) return new Response("Not found", { status: 404 });
402
403 const patchNum = parseInt(params.number, 10);
404 const patch = await db
405 .selectFrom("patches")
406 .select([
407 "id",
408 "title",
409 "description",
410 "patch_content",
411 "status",
412 "author_name",
413 "author_email",
414 ])
415 .where("repo_id", "=", repo.id)
416 .where("number", "=", patchNum)
417 .executeTakeFirst();
418 if (!patch) return new Response("Not found", { status: 404 });
419
420 // Atomically claim the merge slot before the slow git operation to
421 // prevent two concurrent requests from both applying the same patch.
422 const claimed = await db
423 .updateTable("patches")
424 .set({ status: "merged", updated_at: new Date().toISOString() })
425 .where("id", "=", patch.id)
426 .where("status", "=", "open")
427 .executeTakeFirst();
428 if (!claimed || claimed.numUpdatedRows === 0n)
429 return new Response("Patch is not open", { status: 400 });
430
431 try {
432 await git.applyPatch(
433 repo.name,
434 patch.patch_content,
435 patch.title,
436 patch.description,
437 patch.author_name,
438 patch.author_email,
439 user!.git_name!,
440 user!.git_email!,
441 );
442 } catch (err) {
443 // Roll back the status if the git operation fails
444 await db
445 .updateTable("patches")
446 .set({ status: "open", updated_at: new Date().toISOString() })
447 .where("id", "=", patch.id)
448 .execute();
449 throw err;
450 }
451 patchCache.invalidate(patch.id);
452
453 return new Response(null, {
454 status: 302,
455 headers: { Location: `/${repo.name}/patches/${patchNum}` },
456 });
457 })
458
459 .post("/:repo/patches/:number/close", async ({ params, cookie }) => {
460 const user = await resolveSession(cookie.session.value);
461 const deny = requireAdmin(user);
462 if (deny) return deny;
463 const repo = await getRepo(params.repo, true);
464 if (!repo) return new Response("Not found", { status: 404 });
465
466 const patchNum = parseInt(params.number, 10);
467 const patch = await db
468 .selectFrom("patches")
469 .select("id")
470 .where("repo_id", "=", repo.id)
471 .where("number", "=", patchNum)
472 .executeTakeFirst();
473 if (!patch) return new Response("Not found", { status: 404 });
474
475 // Toggle open↔closed atomically; exclude merged patches from the WHERE
476 // so that numUpdatedRows = 0 means the patch is merged (or gone).
477 const toggled = await db
478 .updateTable("patches")
479 .set({
480 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
481 updated_at: new Date().toISOString(),
482 })
483 .where("id", "=", patch.id)
484 .where("status", "!=", "merged")
485 .executeTakeFirst();
486 if (!toggled || toggled.numUpdatedRows === 0n)
487 return new Response("Patch is merged", { status: 400 });
488
489 return new Response(null, {
490 status: 302,
491 headers: { Location: `/${repo.name}/patches/${patchNum}` },
492 });
493 })
494
495 .post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
496 const user = await resolveSession(cookie.session.value);
497 const deny = requireAuth(user);
498 if (deny) return deny;
499 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
500 if (!repo) return new Response("Not found", { status: 404 });
501
502 const patchNum = parseInt(params.number, 10);
503 const patch = await db
504 .selectFrom("patches")
505 .select(["id", "author_id"])
506 .where("repo_id", "=", repo.id)
507 .where("number", "=", patchNum)
508 .executeTakeFirst();
509 if (!patch) return new Response("Not found", { status: 404 });
510 if (patch.author_id !== user?.id && !user?.isAdmin)
511 return new Response("Forbidden", { status: 403 });
512
513 patchCache.invalidate(patch.id);
514 await db.deleteFrom("patches").where("id", "=", patch.id).execute();
515
516 return new Response(null, {
517 status: 302,
518 headers: { Location: `/${repo.name}/patches` },
519 });
520 })
521
522 .post(
523 "/:repo/patches/:number/comments",
524 async ({ params, body, cookie }) => {
525 const user = await resolveSession(cookie.session.value);
526 const deny = requireAuth(user);
527 if (deny) return deny;
528 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
529 if (!repo) return new Response("Not found", { status: 404 });
530
531 const patchNum = parseInt(params.number, 10);
532 const patch = await db
533 .selectFrom("patches")
534 .select(["id", "status"])
535 .where("repo_id", "=", repo.id)
536 .where("number", "=", patchNum)
537 .executeTakeFirst();
538 if (!patch) return new Response("Not found", { status: 404 });
539
540 const { body: commentBody } = body;
541 if (!commentBody?.trim()) {
542 return new Response(null, {
543 status: 302,
544 headers: { Location: `/${repo.name}/patches/${patchNum}` },
545 });
546 }
547
548 await db.transaction().execute(async (trx) => {
549 const now = new Date().toISOString();
550 await trx
551 .insertInto("patch_comments")
552 .values({
553 patch_id: patch.id,
554 author_id: user?.id,
555 body: commentBody.trim(),
556 created_at: now,
557 })
558 .execute();
559 await trx
560 .updateTable("patches")
561 .set({ updated_at: now })
562 .where("id", "=", patch.id)
563 .execute();
564 });
565
566 return new Response(null, {
567 status: 302,
568 headers: { Location: `/${repo.name}/patches/${patchNum}` },
569 });
570 },
571 {
572 body: t.Object({ body: t.String() }),
573 },
574 )
575
576 .post(
577 "/:repo/patches/:number/react",
578 async ({ params, body, cookie }) => {
579 const user = await resolveSession(cookie.session.value);
580 const deny = requireAuth(user);
581 if (deny) return deny;
582 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
583 if (!repo) return new Response("Not found", { status: 404 });
584
585 const { emoji, comment_id } = body;
586 if (!ALLOWED_REACTIONS.has(emoji)) {
587 return new Response("Invalid emoji", { status: 400 });
588 }
589
590 const patchNum = parseInt(params.number, 10);
591 const patch = await db
592 .selectFrom("patches")
593 .select(["id"])
594 .where("repo_id", "=", repo.id)
595 .where("number", "=", patchNum)
596 .executeTakeFirst();
597 if (!patch) return new Response("Not found", { status: 404 });
598
599 const commentId = comment_id ? parseInt(comment_id, 10) : null;
600
601 await db.transaction().execute(async (trx) => {
602 const existing = await trx
603 .selectFrom("patch_reactions")
604 .select(["id", "emoji"])
605 .where("patch_id", "=", patch.id)
606 .where((eb) =>
607 commentId !== null
608 ? eb("comment_id", "=", commentId)
609 : eb("comment_id", "is", null),
610 )
611 .where("user_id", "=", user!.id)
612 .executeTakeFirst();
613
614 if (existing) {
615 if (existing.emoji === emoji) {
616 await trx
617 .deleteFrom("patch_reactions")
618 .where("id", "=", existing.id)
619 .execute();
620 } else {
621 await trx
622 .updateTable("patch_reactions")
623 .set({ emoji })
624 .where("id", "=", existing.id)
625 .execute();
626 }
627 } else {
628 await trx
629 .insertInto("patch_reactions")
630 .values({
631 patch_id: patch.id,
632 comment_id: commentId,
633 user_id: user!.id,
634 emoji,
635 })
636 .execute();
637 }
638 });
639
640 return new Response(null, {
641 status: 303,
642 headers: { Location: `/${repo.name}/patches/${patchNum}` },
643 });
644 },
645 {
646 body: t.Object({
647 emoji: t.String(),
648 comment_id: t.Optional(t.String()),
649 }),
650 },
651 )
652
653 .post(
654 "/:repo/patches/:number/comments/:id/edit",
655 async ({ params, body, cookie }) => {
656 const user = await resolveSession(cookie.session.value);
657 const deny = requireAuth(user);
658 if (deny) return deny;
659 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
660 if (!repo) return new Response("Not found", { status: 404 });
661
662 const comment = await db
663 .selectFrom("patch_comments")
664 .select(["id", "author_id", "patch_id"])
665 .where("id", "=", params.id)
666 .executeTakeFirst();
667 if (!comment) return new Response("Not found", { status: 404 });
668 if (comment.author_id !== user?.id && !user?.isAdmin)
669 return new Response("Forbidden", { status: 403 });
670 const parentPatch = await db
671 .selectFrom("patches")
672 .select("status")
673 .where("id", "=", comment.patch_id)
674 .executeTakeFirst();
675 if (parentPatch?.status !== "open" && !user?.isAdmin)
676 return new Response("Forbidden", { status: 403 });
677
678 const patchNum = parseInt(params.number, 10);
679 await db
680 .updateTable("patch_comments")
681 .set({
682 body: body.edit_body.trim(),
683 edited_at: new Date().toISOString(),
684 })
685 .where("id", "=", comment.id)
686 .execute();
687
688 return new Response(null, {
689 status: 302,
690 headers: { Location: `/${repo.name}/patches/${patchNum}` },
691 });
692 },
693 {
694 params: t.Object({
695 repo: t.String(),
696 number: t.String(),
697 id: t.Numeric(),
698 }),
699 body: t.Object({ edit_body: t.String() }),
700 },
701 )
702
703 .post(
704 "/:repo/patches/:number/edit",
705 async ({ params, body, cookie }) => {
706 const user = await resolveSession(cookie.session.value);
707 const deny = requireAuth(user);
708 if (deny) return deny;
709 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
710 if (!repo) return new Response("Not found", { status: 404 });
711
712 const patchNum = parseInt(params.number, 10);
713 const patch = await db
714 .selectFrom("patches")
715 .select(["id", "author_id", "status"])
716 .where("repo_id", "=", repo.id)
717 .where("number", "=", patchNum)
718 .executeTakeFirst();
719 if (!patch) return new Response("Not found", { status: 404 });
720 if (patch.author_id !== user?.id && !user?.isAdmin)
721 return new Response("Forbidden", { status: 403 });
722 if (patch.status !== "open" && !user?.isAdmin)
723 return new Response("Forbidden", { status: 403 });
724
725 await db
726 .updateTable("patches")
727 .set({
728 title: body.title.trim(),
729 description: body.edit_description ?? "",
730 edited_at: new Date().toISOString(),
731 updated_at: new Date().toISOString(),
732 })
733 .where("id", "=", patch.id)
734 .execute();
735
736 return new Response(null, {
737 status: 302,
738 headers: { Location: `/${repo.name}/patches/${patchNum}` },
739 });
740 },
741 {
742 body: t.Object({
743 title: t.String(),
744 edit_description: t.Optional(t.String()),
745 }),
746 },
747 );
748