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