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