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