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