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