patches.tsx
Raw
1import { Elysia, t } from "elysia";
2import { sql } from "kysely";
3import config from "../config.ts";
4import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
5import { db, getRepo, type LabelRow } from "../db/index.ts";
6import {
7 requireAdmin,
8 requireAuth,
9 resolveSession,
10} from "../middleware/session.ts";
11import { extractPatchMeta, 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 // Parse label filter
66 const rawLabels = query.labels;
67 const labelIds: number[] = (
68 Array.isArray(rawLabels)
69 ? rawLabels
70 : rawLabels
71 ? [rawLabels]
72 : []
73 )
74 .map((v) => parseInt(v, 10))
75 .filter((n) => !Number.isNaN(n));
76
77 const repoLabels = await db
78 .selectFrom("labels")
79 .selectAll()
80 .where("repo_id", "=", repo.id)
81 .orderBy("name", "asc")
82 .execute();
83
84 let countQuery = db
85 .selectFrom("patches")
86 .select([
87 "patches.status",
88 db.fn.countAll<number>().as("count"),
89 ])
90 .where("patches.repo_id", "=", repo.id);
91 if (labelIds.length > 0) {
92 countQuery = countQuery.where(({ exists, selectFrom }) =>
93 exists(
94 selectFrom("patch_labels")
95 .select("patch_labels.patch_id")
96 .whereRef(
97 "patch_labels.patch_id",
98 "=",
99 "patches.id",
100 )
101 .where("patch_labels.label_id", "in", labelIds),
102 ),
103 );
104 }
105 const allCounts = await countQuery
106 .groupBy("patches.status")
107 .execute();
108 const counts: Record<string, number> = Object.fromEntries(
109 allCounts.map((r) => [r.status, Number(r.count)]),
110 );
111 const totalPages = Math.max(
112 1,
113 Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
114 );
115 const safePage = Math.min(page, totalPages);
116 const offset = (safePage - 1) * PATCHES_PER_PAGE;
117
118 let listQuery = db
119 .selectFrom("patches")
120 .leftJoin("users", "users.id", "patches.author_id")
121 .select([
122 "patches.id",
123 "patches.repo_id",
124 "patches.author_id",
125 "patches.number",
126 "patches.title",
127 "patches.description",
128 "patches.patch_content",
129 "patches.status",
130 "patches.author_name",
131 "patches.author_email",
132 "patches.created_at",
133 "patches.updated_at",
134 "patches.edited_at",
135 "patches.version",
136 "users.username as author_username",
137 "users.avatar_version as author_avatar_version",
138 ])
139 .where("patches.repo_id", "=", repo.id)
140 .where("patches.status", "=", status);
141 if (labelIds.length > 0) {
142 listQuery = listQuery.where(({ exists, selectFrom }) =>
143 exists(
144 selectFrom("patch_labels")
145 .select("patch_labels.patch_id")
146 .whereRef(
147 "patch_labels.patch_id",
148 "=",
149 "patches.id",
150 )
151 .where("patch_labels.label_id", "in", labelIds),
152 ),
153 );
154 }
155 const patches = await listQuery
156 .orderBy("patches.number", "desc")
157 .limit(PATCHES_PER_PAGE)
158 .offset(offset)
159 .execute();
160
161 // Batch-fetch labels for displayed patches
162 const patchIds = patches.map((p) => p.id);
163 const patchLabelsRows =
164 patchIds.length > 0
165 ? await db
166 .selectFrom("patch_labels")
167 .innerJoin(
168 "labels",
169 "labels.id",
170 "patch_labels.label_id",
171 )
172 .select([
173 "patch_labels.patch_id",
174 "labels.id",
175 "labels.name",
176 "labels.color",
177 ])
178 .where("patch_labels.patch_id", "in", patchIds)
179 .execute()
180 : [];
181 const labelsByPatchId = new Map<number, LabelRow[]>();
182 for (const row of patchLabelsRows) {
183 const list = labelsByPatchId.get(row.patch_id) ?? [];
184 list.push({
185 id: row.id,
186 repo_id: repo.id,
187 name: row.name,
188 color: row.color,
189 created_at: "",
190 });
191 labelsByPatchId.set(row.patch_id, list);
192 }
193
194 const labelsParam =
195 labelIds.length > 0
196 ? `&labels=${labelIds.map(String).join(",")}`
197 : "";
198 const pagination = {
199 page: safePage,
200 totalPages,
201 pageUrlTemplate: `/${repo.name}/patches?status=${status}${labelsParam}&page={page}`,
202 };
203 return html(
204 <PatchList
205 user={user}
206 repo={repo}
207 patches={
208 patches as ((typeof patches)[0] & {
209 author_username: string;
210 author_avatar_version: number | null;
211 })[]
212 }
213 status={status}
214 counts={counts}
215 pagination={pagination}
216 repoLabels={repoLabels}
217 selectedLabelIds={labelIds}
218 labelsByPatchId={labelsByPatchId}
219 />,
220 );
221 },
222 {
223 query: t.Object({
224 status: t.Optional(t.String()),
225 page: t.Optional(t.Numeric()),
226 labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
227 }),
228 },
229 )
230
231 .get("/:repo/patches/new", async ({ params, cookie }) => {
232 const user = await resolveSession(cookie.session.value);
233 const deny = requireAuth(user);
234 if (deny) return deny;
235 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
236 if (!repo) return new Response("Not found", { status: 404 });
237 const labels = await db
238 .selectFrom("labels")
239 .selectAll()
240 .where("repo_id", "=", repo.id)
241 .orderBy("name", "asc")
242 .execute();
243 return html(
244 <NewPatch
245 user={user!}
246 repo={repo}
247 template={repo.patch_template ?? undefined}
248 labels={labels}
249 />,
250 );
251 })
252
253 .post(
254 "/:repo/patches",
255 async ({ params, body, cookie }) => {
256 const user = await resolveSession(cookie.session.value);
257 const deny = requireAuth(user);
258 if (deny) return deny;
259 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
260 if (!repo) return new Response("Not found", { status: 404 });
261
262 const getLabels = () =>
263 db
264 .selectFrom("labels")
265 .selectAll()
266 .where("repo_id", "=", repo.id)
267 .orderBy("name", "asc")
268 .execute();
269
270 if (!body.title?.trim()) {
271 return html(
272 <NewPatch
273 user={user!}
274 repo={repo}
275 error="Title is required"
276 labels={await getLabels()}
277 />,
278 );
279 }
280
281 if (!body.patch_file) {
282 return html(
283 <NewPatch
284 user={user!}
285 repo={repo}
286 error="Patch file is required"
287 labels={await getLabels()}
288 />,
289 );
290 }
291
292 if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
293 return html(
294 <NewPatch
295 user={user!}
296 repo={repo}
297 error="Patch file is too large"
298 labels={await getLabels()}
299 />,
300 );
301 }
302
303 const patchContent = await body.patch_file.text();
304 if (!patchContent.trim()) {
305 return html(
306 <NewPatch
307 user={user!}
308 repo={repo}
309 error="Patch file is empty"
310 labels={await getLabels()}
311 />,
312 );
313 }
314
315 // Validate it looks like a patch file
316 if (!isValidPatch(patchContent)) {
317 return html(
318 <NewPatch
319 user={user!}
320 repo={repo}
321 error="File does not appear to be a valid patch file"
322 labels={await getLabels()}
323 />,
324 );
325 }
326
327 const uploadMeta = extractPatchMeta(patchContent);
328 if (!uploadMeta.subject) {
329 return html(
330 <NewPatch
331 user={user!}
332 repo={repo}
333 error="Patch is missing a Subject header. Make sure to upload a patch created with git format-patch."
334 labels={await getLabels()}
335 />,
336 );
337 }
338 if (!uploadMeta.author || !uploadMeta.email) {
339 return html(
340 <NewPatch
341 user={user!}
342 repo={repo}
343 error="Patch is missing a From header with name and email."
344 labels={await getLabels()}
345 />,
346 );
347 }
348 if (!uploadMeta.date) {
349 return html(
350 <NewPatch
351 user={user!}
352 repo={repo}
353 error="Patch is missing a Date header."
354 labels={await getLabels()}
355 />,
356 );
357 }
358
359 const rawIds =
360 user!.isAdmin || repo.allow_user_labels === 1
361 ? body.label_ids
362 : undefined;
363 const labelIds = rawIds
364 ? (Array.isArray(rawIds) ? rawIds : [rawIds])
365 .map(Number)
366 .filter(Boolean)
367 : [];
368
369 const now = new Date().toISOString();
370 const { number, result } = await db
371 .transaction()
372 .execute(async (trx) => {
373 const { patch_seq } = await trx
374 .updateTable("repositories")
375 .set({ patch_seq: sql`patch_seq + 1` })
376 .where("id", "=", repo.id)
377 .returning("patch_seq")
378 .executeTakeFirstOrThrow();
379 const inserted = await trx
380 .insertInto("patches")
381 .values({
382 repo_id: repo.id,
383 author_id: user?.id,
384 number: patch_seq,
385 title: body.title!.trim(),
386 description: body.description?.trim() ?? "",
387 patch_content: patchContent,
388 status: "open",
389 author_name: uploadMeta.author,
390 author_email: uploadMeta.email,
391 created_at: now,
392 updated_at: now,
393 version: crypto.randomUUID(),
394 })
395 .returning("id")
396 .executeTakeFirstOrThrow();
397 if (labelIds.length > 0) {
398 const validLabels = await trx
399 .selectFrom("labels")
400 .select("id")
401 .where("repo_id", "=", repo.id)
402 .where("id", "in", labelIds)
403 .execute();
404 if (validLabels.length > 0) {
405 await trx
406 .insertInto("patch_labels")
407 .values(
408 validLabels.map((l) => ({
409 patch_id: inserted.id,
410 label_id: l.id,
411 })),
412 )
413 .onConflict((oc) => oc.doNothing())
414 .execute();
415 }
416 }
417 return { number: patch_seq, result: inserted };
418 });
419
420 await runPatchCheck(repo.name, result.id, patchContent);
421
422 return new Response(null, {
423 status: 302,
424 headers: { Location: `/${repo.name}/patches/${number}` },
425 });
426 },
427 {
428 body: t.Object({
429 title: t.Optional(t.String()),
430 description: t.Optional(t.String()),
431 patch_file: t.Optional(t.File()),
432 label_ids: t.Optional(
433 t.Union([t.String(), t.Array(t.String())]),
434 ),
435 }),
436 },
437 )
438
439 .get(
440 "/:repo/patches/:number",
441 async ({ params, query, cookie }) => {
442 const user = await resolveSession(cookie.session.value);
443 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
444 if (!repo) return new Response("Not found", { status: 404 });
445
446 const patchNum = parseInt(params.number, 10);
447 const patch = await db
448 .selectFrom("patches")
449 .leftJoin("users", "users.id", "patches.author_id")
450 .select([
451 "patches.id",
452 "patches.repo_id",
453 "patches.author_id",
454 "patches.number",
455 "patches.title",
456 "patches.description",
457 "patches.patch_content",
458 "patches.status",
459 "patches.author_name",
460 "patches.author_email",
461 "patches.created_at",
462 "patches.updated_at",
463 "patches.edited_at",
464 "patches.version",
465 "users.username as author_username",
466 "users.avatar_version as author_avatar_version",
467 ])
468 .where("patches.repo_id", "=", repo.id)
469 .where("patches.number", "=", patchNum)
470 .executeTakeFirst();
471 if (!patch) return new Response("Not found", { status: 404 });
472
473 const descriptionHtml = patch.description
474 ? renderMarkdown(patch.description)
475 : "";
476
477 let applyResult = patchCache.get(patch.id) ?? null;
478 // Cold cache (e.g. server restart) — re-check synchronously for open patches only
479 if (!applyResult && patch.status === "open") {
480 applyResult = await runPatchCheck(
481 repo.name,
482 patch.id,
483 patch.patch_content,
484 );
485 }
486
487 const files = await prepareDiff(
488 patch.patch_content,
489 `patch:${patch.id}`,
490 );
491
492 const comments = await db
493 .selectFrom("patch_comments")
494 .leftJoin("users", "users.id", "patch_comments.author_id")
495 .select([
496 "patch_comments.id",
497 "patch_comments.patch_id",
498 "patch_comments.author_id",
499 "patch_comments.body",
500 "patch_comments.created_at",
501 "patch_comments.edited_at",
502 "users.username as author_username",
503 "users.avatar_version as author_avatar_version",
504 ])
505 .where("patch_comments.patch_id", "=", patch.id)
506 .orderBy("patch_comments.created_at", "asc")
507 .execute();
508
509 const commentsWithHtml = comments.map((c) => ({
510 ...c,
511 bodyHtml: renderMarkdown(c.body),
512 }));
513
514 const allReactions = await db
515 .selectFrom("patch_reactions")
516 .selectAll()
517 .where("patch_id", "=", patch.id)
518 .execute();
519
520 const reactions = buildReactionCounts(allReactions, null, user?.id);
521 const commentReactions = new Map(
522 comments.map((c) => [
523 c.id,
524 buildReactionCounts(allReactions, c.id, user?.id),
525 ]),
526 );
527
528 const tab =
529 query.tab === "changes"
530 ? ("changes" as const)
531 : ("conversation" as const);
532
533 const patchMeta = extractPatchMeta(patch.patch_content);
534
535 const patchLabels = await db
536 .selectFrom("patch_labels")
537 .innerJoin("labels", "labels.id", "patch_labels.label_id")
538 .select([
539 "labels.id",
540 "labels.repo_id",
541 "labels.name",
542 "labels.color",
543 "labels.created_at",
544 ])
545 .where("patch_labels.patch_id", "=", patch.id)
546 .execute();
547
548 const repoLabels = await db
549 .selectFrom("labels")
550 .selectAll()
551 .where("repo_id", "=", repo.id)
552 .orderBy("name", "asc")
553 .execute();
554
555 return html(
556 <PatchDetail
557 user={user}
558 repo={repo}
559 patch={
560 patch as typeof patch & {
561 author_username: string;
562 author_avatar_version: number | null;
563 author_name: string;
564 author_email: string;
565 }
566 }
567 descriptionHtml={descriptionHtml}
568 applyResult={applyResult}
569 files={files}
570 tab={tab}
571 patchMeta={patchMeta}
572 comments={
573 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
574 author_username: string;
575 author_avatar_version: number | null;
576 })[]
577 }
578 reactions={reactions}
579 commentReactions={commentReactions}
580 patchLabels={patchLabels}
581 repoLabels={repoLabels}
582 />,
583 );
584 },
585 {
586 query: t.Object({ tab: t.Optional(t.String()) }),
587 },
588 )
589
590 .post(
591 "/:repo/patches/:number/merge",
592 async ({ params, body, cookie }) => {
593 const user = await resolveSession(cookie.session.value);
594 const deny = requireAdmin(user);
595 if (deny) return deny;
596
597 const repo = await getRepo(params.repo, true);
598 if (!repo) return new Response("Not found", { status: 404 });
599
600 const patchNum = parseInt(params.number, 10);
601 const patch = await db
602 .selectFrom("patches")
603 .select(["id", "patch_content", "status", "version"])
604 .where("repo_id", "=", repo.id)
605 .where("number", "=", patchNum)
606 .executeTakeFirst();
607 if (!patch) return new Response("Not found", { status: 404 });
608
609 // Reject if the patch file was changed after the admin loaded the page
610 if (body.version !== patch.version) {
611 return new Response(
612 "The patch file was updated after you loaded this page. Please review the new version before merging.",
613 { status: 409 },
614 );
615 }
616
617 // Atomically claim the merge slot before the slow git operation to
618 // prevent two concurrent requests from both applying the same patch.
619 const claimed = await db
620 .updateTable("patches")
621 .set({ status: "merged", updated_at: new Date().toISOString() })
622 .where("id", "=", patch.id)
623 .where("status", "=", "open")
624 .where("version", "=", patch.version)
625 .executeTakeFirst();
626 if (!claimed || claimed.numUpdatedRows === 0n)
627 return new Response("Patch is not open", { status: 400 });
628
629 const mergeMeta = extractPatchMeta(patch.patch_content);
630 try {
631 await git.applyPatch(
632 repo.name,
633 patch.patch_content,
634 mergeMeta.author,
635 mergeMeta.email,
636 config.COMMITTER_NAME,
637 config.COMMITTER_EMAIL,
638 );
639 } catch (err) {
640 // Roll back the status if the git operation fails
641 await db
642 .updateTable("patches")
643 .set({
644 status: "open",
645 updated_at: new Date().toISOString(),
646 })
647 .where("id", "=", patch.id)
648 .execute();
649 throw err;
650 }
651 patchCache.invalidate(patch.id);
652
653 return new Response(null, {
654 status: 302,
655 headers: { Location: `/${repo.name}/patches/${patchNum}` },
656 });
657 },
658 {
659 body: t.Object({ version: t.String() }),
660 },
661 )
662
663 .post(
664 "/:repo/patches/:number/upload",
665 async ({ params, body, cookie }) => {
666 const user = await resolveSession(cookie.session.value);
667 const deny = requireAuth(user);
668 if (deny) return deny;
669 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
670 if (!repo) return new Response("Not found", { status: 404 });
671
672 const patchNum = parseInt(params.number, 10);
673 const patch = await db
674 .selectFrom("patches")
675 .select(["id", "author_id", "status"])
676 .where("repo_id", "=", repo.id)
677 .where("number", "=", patchNum)
678 .executeTakeFirst();
679 if (!patch) return new Response("Not found", { status: 404 });
680 if (patch.author_id !== user?.id && !user?.isAdmin)
681 return new Response("Forbidden", { status: 403 });
682 if (patch.status !== "open")
683 return new Response("Patch is not open", { status: 400 });
684
685 if (!body.patch_file || body.patch_file.size === 0) {
686 return new Response("Patch file is required", { status: 400 });
687 }
688 if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
689 return new Response("Patch file is too large", { status: 400 });
690 }
691
692 const patchContent = await body.patch_file.text();
693 if (!patchContent.trim()) {
694 return new Response("Patch file is empty", { status: 400 });
695 }
696 if (!isValidPatch(patchContent)) {
697 return new Response(
698 "File does not appear to be a valid patch file",
699 { status: 400 },
700 );
701 }
702
703 const uploadMeta = extractPatchMeta(patchContent);
704 if (
705 !uploadMeta.subject ||
706 !uploadMeta.author ||
707 !uploadMeta.email ||
708 !uploadMeta.date
709 ) {
710 return new Response(
711 "Patch is missing required headers (Subject, From, Date)",
712 { status: 400 },
713 );
714 }
715
716 const newVersion = crypto.randomUUID();
717 await db
718 .updateTable("patches")
719 .set({
720 patch_content: patchContent,
721 author_name: uploadMeta.author,
722 author_email: uploadMeta.email,
723 version: newVersion,
724 updated_at: new Date().toISOString(),
725 })
726 .where("id", "=", patch.id)
727 .execute();
728
729 patchCache.invalidate(patch.id);
730 runPatchCheck(repo.name, patch.id, patchContent);
731
732 return new Response(null, {
733 status: 302,
734 headers: { Location: `/${repo.name}/patches/${patchNum}` },
735 });
736 },
737 {
738 body: t.Object({
739 patch_file: t.Optional(t.File()),
740 }),
741 },
742 )
743
744 .post("/:repo/patches/:number/close", async ({ params, cookie }) => {
745 const user = await resolveSession(cookie.session.value);
746 const deny = requireAdmin(user);
747 if (deny) return deny;
748 const repo = await getRepo(params.repo, true);
749 if (!repo) return new Response("Not found", { status: 404 });
750
751 const patchNum = parseInt(params.number, 10);
752 const patch = await db
753 .selectFrom("patches")
754 .select("id")
755 .where("repo_id", "=", repo.id)
756 .where("number", "=", patchNum)
757 .executeTakeFirst();
758 if (!patch) return new Response("Not found", { status: 404 });
759
760 // Toggle open↔closed atomically; exclude merged patches from the WHERE
761 // so that numUpdatedRows = 0 means the patch is merged (or gone).
762 const toggled = await db
763 .updateTable("patches")
764 .set({
765 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
766 updated_at: new Date().toISOString(),
767 })
768 .where("id", "=", patch.id)
769 .where("status", "!=", "merged")
770 .executeTakeFirst();
771 if (!toggled || toggled.numUpdatedRows === 0n)
772 return new Response("Patch is merged", { status: 400 });
773
774 return new Response(null, {
775 status: 302,
776 headers: { Location: `/${repo.name}/patches/${patchNum}` },
777 });
778 })
779
780 .post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
781 const user = await resolveSession(cookie.session.value);
782 const deny = requireAuth(user);
783 if (deny) return deny;
784 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
785 if (!repo) return new Response("Not found", { status: 404 });
786
787 const patchNum = parseInt(params.number, 10);
788 const patch = await db
789 .selectFrom("patches")
790 .select(["id", "author_id"])
791 .where("repo_id", "=", repo.id)
792 .where("number", "=", patchNum)
793 .executeTakeFirst();
794 if (!patch) return new Response("Not found", { status: 404 });
795 if (patch.author_id !== user?.id && !user?.isAdmin)
796 return new Response("Forbidden", { status: 403 });
797
798 patchCache.invalidate(patch.id);
799 await db.deleteFrom("patches").where("id", "=", patch.id).execute();
800
801 return new Response(null, {
802 status: 302,
803 headers: { Location: `/${repo.name}/patches` },
804 });
805 })
806
807 .post(
808 "/:repo/patches/:number/comments",
809 async ({ params, body, cookie }) => {
810 const user = await resolveSession(cookie.session.value);
811 const deny = requireAuth(user);
812 if (deny) return deny;
813 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
814 if (!repo) return new Response("Not found", { status: 404 });
815
816 const patchNum = parseInt(params.number, 10);
817 const patch = await db
818 .selectFrom("patches")
819 .select(["id", "status"])
820 .where("repo_id", "=", repo.id)
821 .where("number", "=", patchNum)
822 .executeTakeFirst();
823 if (!patch) return new Response("Not found", { status: 404 });
824
825 const { body: commentBody } = body;
826 if (!commentBody?.trim()) {
827 return new Response(null, {
828 status: 302,
829 headers: { Location: `/${repo.name}/patches/${patchNum}` },
830 });
831 }
832
833 await db.transaction().execute(async (trx) => {
834 const now = new Date().toISOString();
835 await trx
836 .insertInto("patch_comments")
837 .values({
838 patch_id: patch.id,
839 author_id: user?.id,
840 body: commentBody.trim(),
841 created_at: now,
842 })
843 .execute();
844 await trx
845 .updateTable("patches")
846 .set({ updated_at: now })
847 .where("id", "=", patch.id)
848 .execute();
849 });
850
851 return new Response(null, {
852 status: 302,
853 headers: { Location: `/${repo.name}/patches/${patchNum}` },
854 });
855 },
856 {
857 body: t.Object({ body: t.String() }),
858 },
859 )
860
861 .post(
862 "/:repo/patches/:number/react",
863 async ({ params, body, cookie }) => {
864 const user = await resolveSession(cookie.session.value);
865 const deny = requireAuth(user);
866 if (deny) return deny;
867 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
868 if (!repo) return new Response("Not found", { status: 404 });
869
870 const { emoji, comment_id } = body;
871 if (!ALLOWED_REACTIONS.has(emoji)) {
872 return new Response("Invalid emoji", { status: 400 });
873 }
874
875 const patchNum = parseInt(params.number, 10);
876 const patch = await db
877 .selectFrom("patches")
878 .select(["id"])
879 .where("repo_id", "=", repo.id)
880 .where("number", "=", patchNum)
881 .executeTakeFirst();
882 if (!patch) return new Response("Not found", { status: 404 });
883
884 const commentId = comment_id ? parseInt(comment_id, 10) : null;
885
886 await db.transaction().execute(async (trx) => {
887 const existing = await trx
888 .selectFrom("patch_reactions")
889 .select(["id", "emoji"])
890 .where("patch_id", "=", patch.id)
891 .where((eb) =>
892 commentId !== null
893 ? eb("comment_id", "=", commentId)
894 : eb("comment_id", "is", null),
895 )
896 .where("user_id", "=", user!.id)
897 .executeTakeFirst();
898
899 if (existing) {
900 if (existing.emoji === emoji) {
901 await trx
902 .deleteFrom("patch_reactions")
903 .where("id", "=", existing.id)
904 .execute();
905 } else {
906 await trx
907 .updateTable("patch_reactions")
908 .set({ emoji })
909 .where("id", "=", existing.id)
910 .execute();
911 }
912 } else {
913 await trx
914 .insertInto("patch_reactions")
915 .values({
916 patch_id: patch.id,
917 comment_id: commentId,
918 user_id: user!.id,
919 emoji,
920 })
921 .execute();
922 }
923 });
924
925 return new Response(null, {
926 status: 303,
927 headers: { Location: `/${repo.name}/patches/${patchNum}` },
928 });
929 },
930 {
931 body: t.Object({
932 emoji: t.String(),
933 comment_id: t.Optional(t.String()),
934 }),
935 },
936 )
937
938 .post(
939 "/:repo/patches/:number/comments/:id/edit",
940 async ({ params, body, cookie }) => {
941 const user = await resolveSession(cookie.session.value);
942 const deny = requireAuth(user);
943 if (deny) return deny;
944 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
945 if (!repo) return new Response("Not found", { status: 404 });
946
947 const comment = await db
948 .selectFrom("patch_comments")
949 .select(["id", "author_id", "patch_id"])
950 .where("id", "=", params.id)
951 .executeTakeFirst();
952 if (!comment) return new Response("Not found", { status: 404 });
953 if (comment.author_id !== user?.id && !user?.isAdmin)
954 return new Response("Forbidden", { status: 403 });
955 const parentPatch = await db
956 .selectFrom("patches")
957 .select("status")
958 .where("id", "=", comment.patch_id)
959 .executeTakeFirst();
960 if (parentPatch?.status !== "open" && !user?.isAdmin)
961 return new Response("Forbidden", { status: 403 });
962
963 const patchNum = parseInt(params.number, 10);
964 await db
965 .updateTable("patch_comments")
966 .set({
967 body: body.edit_body.trim(),
968 edited_at: new Date().toISOString(),
969 })
970 .where("id", "=", comment.id)
971 .execute();
972
973 return new Response(null, {
974 status: 302,
975 headers: { Location: `/${repo.name}/patches/${patchNum}` },
976 });
977 },
978 {
979 params: t.Object({
980 repo: t.String(),
981 number: t.String(),
982 id: t.Numeric(),
983 }),
984 body: t.Object({ edit_body: t.String() }),
985 },
986 )
987
988 .post(
989 "/:repo/patches/:number/edit",
990 async ({ params, body, cookie }) => {
991 const user = await resolveSession(cookie.session.value);
992 const deny = requireAuth(user);
993 if (deny) return deny;
994 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
995 if (!repo) return new Response("Not found", { status: 404 });
996
997 const patchNum = parseInt(params.number, 10);
998 const patch = await db
999 .selectFrom("patches")
1000 .select(["id", "author_id", "status"])
1001 .where("repo_id", "=", repo.id)
1002 .where("number", "=", patchNum)
1003 .executeTakeFirst();
1004 if (!patch) return new Response("Not found", { status: 404 });
1005 if (patch.author_id !== user?.id && !user?.isAdmin)
1006 return new Response("Forbidden", { status: 403 });
1007 if (patch.status !== "open" && !user?.isAdmin)
1008 return new Response("Forbidden", { status: 403 });
1009
1010 await db
1011 .updateTable("patches")
1012 .set({
1013 title: body.title.trim(),
1014 description: body.edit_description ?? "",
1015 edited_at: new Date().toISOString(),
1016 updated_at: new Date().toISOString(),
1017 })
1018 .where("id", "=", patch.id)
1019 .execute();
1020
1021 return new Response(null, {
1022 status: 302,
1023 headers: { Location: `/${repo.name}/patches/${patchNum}` },
1024 });
1025 },
1026 {
1027 body: t.Object({
1028 title: t.String(),
1029 edit_description: t.Optional(t.String()),
1030 }),
1031 },
1032 )
1033
1034 .post(
1035 "/:repo/patches/:number/labels/add",
1036 async ({ params, body, cookie }) => {
1037 const user = await resolveSession(cookie.session.value);
1038 if (!user) return new Response("Unauthorized", { status: 401 });
1039 const repo = await getRepo(params.repo, user.isAdmin);
1040 if (!repo) return new Response("Not found", { status: 404 });
1041
1042 const patchNum = parseInt(params.number, 10);
1043 const patch = await db
1044 .selectFrom("patches")
1045 .select(["id", "author_id"])
1046 .where("repo_id", "=", repo.id)
1047 .where("number", "=", patchNum)
1048 .executeTakeFirst();
1049 if (!patch) return new Response("Not found", { status: 404 });
1050
1051 const canManage =
1052 user.isAdmin ||
1053 (repo.allow_user_labels === 1 && user.id === patch.author_id);
1054 if (!canManage) return new Response("Forbidden", { status: 403 });
1055
1056 const label = await db
1057 .selectFrom("labels")
1058 .select(["id"])
1059 .where("id", "=", body.label_id)
1060 .where("repo_id", "=", repo.id)
1061 .executeTakeFirst();
1062 if (!label) {
1063 return new Response(null, {
1064 status: 302,
1065 headers: { Location: `/${repo.name}/patches/${patchNum}` },
1066 });
1067 }
1068
1069 await db
1070 .insertInto("patch_labels")
1071 .values({ patch_id: patch.id, label_id: label.id })
1072 .onConflict((oc) => oc.doNothing())
1073 .execute();
1074
1075 return new Response(null, {
1076 status: 302,
1077 headers: { Location: `/${repo.name}/patches/${patchNum}` },
1078 });
1079 },
1080 {
1081 params: t.Object({ repo: t.String(), number: t.String() }),
1082 body: t.Object({ label_id: t.Numeric() }),
1083 },
1084 )
1085
1086 .post(
1087 "/:repo/patches/:number/labels/remove",
1088 async ({ params, body, cookie }) => {
1089 const user = await resolveSession(cookie.session.value);
1090 if (!user) return new Response("Unauthorized", { status: 401 });
1091 const repo = await getRepo(params.repo, user.isAdmin);
1092 if (!repo) return new Response("Not found", { status: 404 });
1093
1094 const patchNum = parseInt(params.number, 10);
1095 const patch = await db
1096 .selectFrom("patches")
1097 .select(["id", "author_id"])
1098 .where("repo_id", "=", repo.id)
1099 .where("number", "=", patchNum)
1100 .executeTakeFirst();
1101 if (!patch) return new Response("Not found", { status: 404 });
1102
1103 const canManage =
1104 user.isAdmin ||
1105 (repo.allow_user_labels === 1 && user.id === patch.author_id);
1106 if (!canManage) return new Response("Forbidden", { status: 403 });
1107
1108 await db
1109 .deleteFrom("patch_labels")
1110 .where("patch_id", "=", patch.id)
1111 .where("label_id", "=", body.label_id)
1112 .execute();
1113
1114 return new Response(null, {
1115 status: 302,
1116 headers: { Location: `/${repo.name}/patches/${patchNum}` },
1117 });
1118 },
1119 {
1120 params: t.Object({ repo: t.String(), number: t.String() }),
1121 body: t.Object({ label_id: t.Numeric() }),
1122 },
1123 );
1124