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