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