patches.tsx
⎇
Raw
1import { Elysia, t } from "elysia";
2import { sql } from "kysely";
3import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
4import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
5import { db, getRepo } from "../db/index.ts";
6import {
7 requireAdmin,
8 requireAuth,
9 resolveSession,
10} from "../middleware/session.ts";
11import { git } from "../services/git.ts";
12import { prepareDiff } from "../services/highlightWorker.ts";
13import { renderMarkdown } from "../services/markdown.ts";
14import { patchCache } from "../services/patchCache.ts";
15import { buildReactionCounts } from "../services/reactions.ts";
16import { NewPatch } from "../views/patches/NewPatch.tsx";
17import { PatchDetail } from "../views/patches/PatchDetail.tsx";
18import { PatchList } from "../views/patches/PatchList.tsx";
19import { html } from "../views/render.tsx";
20
21function isValidPatch(content: string): boolean {
22 const lines = content.split("\n");
23 return lines.some(
24 (l) =>
25 l.startsWith("diff --git ") ||
26 l.startsWith("--- ") ||
27 l.startsWith("+++ ") ||
28 l.startsWith("@@ ") ||
29 l.startsWith("Index: "),
30 );
31}
32
33async function runPatchCheck(
34 repoName: string,
35 patchId: number,
36 patchContent: string,
37) {
38 const result = await git.checkPatch(repoName, patchContent);
39 const applyResult = {
40 status: result.clean ? ("clean" as const) : ("conflict" as const),
41 output: result.output,
42 };
43 patchCache.set(patchId, applyResult);
44 return applyResult;
45}
46
47export const patchRoutes = new Elysia()
48 .guard({
49 cookie: t.Cookie({ session: t.Optional(t.String()) }),
50 })
51 .get(
52 "/:repo/patches",
53 async ({ params, query, cookie }) => {
54 const user = await resolveSession(cookie.session.value);
55 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
56 if (!repo) return new Response("Not found", { status: 404 });
57
58 const status = ["open", "merged", "closed"].includes(
59 query.status ?? "",
60 )
61 ? query.status!
62 : "open";
63 const page = Math.max(1, query.page ?? 1);
64
65 const allCounts = await db
66 .selectFrom("patches")
67 .select(["status", db.fn.countAll<number>().as("count")])
68 .where("repo_id", "=", repo.id)
69 .groupBy("status")
70 .execute();
71 const counts: Record<string, number> = Object.fromEntries(
72 allCounts.map((r) => [r.status, Number(r.count)]),
73 );
74 const totalPages = Math.max(
75 1,
76 Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
77 );
78 const safePage = Math.min(page, totalPages);
79 const offset = (safePage - 1) * PATCHES_PER_PAGE;
80
81 const patches = await db
82 .selectFrom("patches")
83 .leftJoin("users", "users.id", "patches.author_id")
84 .select([
85 "patches.id",
86 "patches.repo_id",
87 "patches.author_id",
88 "patches.number",
89 "patches.title",
90 "patches.description",
91 "patches.patch_content",
92 "patches.status",
93 "patches.created_at",
94 "patches.updated_at",
95 "patches.edited_at",
96 "users.username as author_username",
97 "users.avatar_version as author_avatar_version",
98 ])
99 .where("patches.repo_id", "=", repo.id)
100 .where("patches.status", "=", status)
101 .orderBy("patches.number", "desc")
102 .limit(PATCHES_PER_PAGE)
103 .offset(offset)
104 .execute();
105
106 const pagination = {
107 page: safePage,
108 totalPages,
109 pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
110 };
111 return html(
112 <PatchList
113 user={user}
114 repo={repo}
115 patches={
116 patches as ((typeof patches)[0] & {
117 author_username: string;
118 author_avatar_version: number | null;
119 })[]
120 }
121 status={status}
122 counts={counts}
123 pagination={pagination}
124 />,
125 );
126 },
127 {
128 query: t.Object({
129 status: t.Optional(t.String()),
130 page: t.Optional(t.Numeric()),
131 }),
132 },
133 )
134
135 .get("/:repo/patches/new", async ({ params, cookie }) => {
136 const user = await resolveSession(cookie.session.value);
137 const deny = requireAuth(user);
138 if (deny) return deny;
139 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
140 if (!repo) return new Response("Not found", { status: 404 });
141 return html(<NewPatch user={user!} repo={repo} />);
142 })
143
144 .post(
145 "/:repo/patches",
146 async ({ params, body, cookie }) => {
147 const user = await resolveSession(cookie.session.value);
148 const deny = requireAuth(user);
149 if (deny) return deny;
150 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
151 if (!repo) return new Response("Not found", { status: 404 });
152
153 if (!body.title?.trim()) {
154 return html(
155 <NewPatch
156 user={user!}
157 repo={repo}
158 error="Title is required"
159 />,
160 );
161 }
162
163 if (!body.patch_file) {
164 return html(
165 <NewPatch
166 user={user!}
167 repo={repo}
168 error="Patch file is required"
169 />,
170 );
171 }
172
173 if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
174 return html(
175 <NewPatch
176 user={user!}
177 repo={repo}
178 error="Patch file is too large"
179 />,
180 );
181 }
182
183 const patchContent = await body.patch_file.text();
184 if (!patchContent.trim()) {
185 return html(
186 <NewPatch
187 user={user!}
188 repo={repo}
189 error="Patch file is empty"
190 />,
191 );
192 }
193
194 // Validate it looks like a patch/diff file
195 if (!isValidPatch(patchContent)) {
196 return html(
197 <NewPatch
198 user={user!}
199 repo={repo}
200 error="File does not appear to be a valid patch or diff file"
201 />,
202 );
203 }
204
205 const now = new Date().toISOString();
206 const { number, result } = await db
207 .transaction()
208 .execute(async (trx) => {
209 const { patch_seq } = await trx
210 .updateTable("repositories")
211 .set({ patch_seq: sql`patch_seq + 1` })
212 .where("id", "=", repo.id)
213 .returning("patch_seq")
214 .executeTakeFirstOrThrow();
215 const inserted = await trx
216 .insertInto("patches")
217 .values({
218 repo_id: repo.id,
219 author_id: user?.id,
220 number: patch_seq,
221 title: body.title!.trim(),
222 description: body.description?.trim() ?? "",
223 patch_content: patchContent,
224 status: "open",
225 created_at: now,
226 updated_at: now,
227 })
228 .returning("id")
229 .executeTakeFirstOrThrow();
230 return { number: patch_seq, result: inserted };
231 });
232
233 await runPatchCheck(repo.name, result.id, patchContent);
234
235 return new Response(null, {
236 status: 302,
237 headers: { Location: `/${repo.name}/patches/${number}` },
238 });
239 },
240 {
241 body: t.Object({
242 title: t.Optional(t.String()),
243 description: t.Optional(t.String()),
244 patch_file: t.Optional(t.File()),
245 }),
246 },
247 )
248
249 .get(
250 "/:repo/patches/:number",
251 async ({ params, query, cookie }) => {
252 const user = await resolveSession(cookie.session.value);
253 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
254 if (!repo) return new Response("Not found", { status: 404 });
255
256 const patchNum = parseInt(params.number, 10);
257 const patch = await db
258 .selectFrom("patches")
259 .leftJoin("users", "users.id", "patches.author_id")
260 .select([
261 "patches.id",
262 "patches.repo_id",
263 "patches.author_id",
264 "patches.number",
265 "patches.title",
266 "patches.description",
267 "patches.patch_content",
268 "patches.status",
269 "patches.created_at",
270 "patches.updated_at",
271 "patches.edited_at",
272 "users.username as author_username",
273 "users.avatar_version as author_avatar_version",
274 ])
275 .where("patches.repo_id", "=", repo.id)
276 .where("patches.number", "=", patchNum)
277 .executeTakeFirst();
278 if (!patch) return new Response("Not found", { status: 404 });
279
280 const descriptionHtml = patch.description
281 ? renderMarkdown(patch.description)
282 : "";
283
284 let applyResult = patchCache.get(patch.id) ?? null;
285 // Cold cache (e.g. server restart) — re-check synchronously for open patches only
286 if (!applyResult && patch.status === "open") {
287 applyResult = await runPatchCheck(
288 repo.name,
289 patch.id,
290 patch.patch_content,
291 );
292 }
293
294 const files = await prepareDiff(
295 patch.patch_content,
296 `patch:${patch.id}`,
297 );
298
299 const comments = await db
300 .selectFrom("patch_comments")
301 .leftJoin("users", "users.id", "patch_comments.author_id")
302 .select([
303 "patch_comments.id",
304 "patch_comments.patch_id",
305 "patch_comments.author_id",
306 "patch_comments.body",
307 "patch_comments.created_at",
308 "patch_comments.edited_at",
309 "users.username as author_username",
310 "users.avatar_version as author_avatar_version",
311 ])
312 .where("patch_comments.patch_id", "=", patch.id)
313 .orderBy("patch_comments.created_at", "asc")
314 .execute();
315
316 const commentsWithHtml = comments.map((c) => ({
317 ...c,
318 bodyHtml: renderMarkdown(c.body),
319 }));
320
321 const allReactions = await db
322 .selectFrom("patch_reactions")
323 .selectAll()
324 .where("patch_id", "=", patch.id)
325 .execute();
326
327 const reactions = buildReactionCounts(allReactions, null, user?.id);
328 const commentReactions = new Map(
329 comments.map((c) => [
330 c.id,
331 buildReactionCounts(allReactions, c.id, user?.id),
332 ]),
333 );
334
335 const tab =
336 query.tab === "changes"
337 ? ("changes" as const)
338 : ("conversation" as const);
339
340 return html(
341 <PatchDetail
342 user={user}
343 repo={repo}
344 patch={
345 patch as typeof patch & {
346 author_username: string;
347 author_avatar_version: number | null;
348 }
349 }
350 descriptionHtml={descriptionHtml}
351 applyResult={applyResult}
352 files={files}
353 tab={tab}
354 comments={
355 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
356 author_username: string;
357 author_avatar_version: number | null;
358 })[]
359 }
360 reactions={reactions}
361 commentReactions={commentReactions}
362 />,
363 );
364 },
365 {
366 query: t.Object({ tab: t.Optional(t.String()) }),
367 },
368 )
369
370 .post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
371 const user = await resolveSession(cookie.session.value);
372 const deny = requireAdmin(user);
373 if (deny) return deny;
374 const repo = await getRepo(params.repo, true);
375 if (!repo) return new Response("Not found", { status: 404 });
376
377 const patchNum = parseInt(params.number, 10);
378 const patch = await db
379 .selectFrom("patches")
380 .select(["id", "title", "description", "patch_content", "status"])
381 .where("repo_id", "=", repo.id)
382 .where("number", "=", patchNum)
383 .executeTakeFirst();
384 if (!patch) return new Response("Not found", { status: 404 });
385
386 // Atomically claim the merge slot before the slow git operation to
387 // prevent two concurrent requests from both applying the same patch.
388 const claimed = await db
389 .updateTable("patches")
390 .set({ status: "merged", updated_at: new Date().toISOString() })
391 .where("id", "=", patch.id)
392 .where("status", "=", "open")
393 .executeTakeFirst();
394 if (!claimed || claimed.numUpdatedRows === 0n)
395 return new Response("Patch is not open", { status: 400 });
396
397 try {
398 await git.applyPatch(
399 repo.name,
400 patch.patch_content,
401 patch.title,
402 patch.description,
403 );
404 } catch (err) {
405 // Roll back the status if the git operation fails
406 await db
407 .updateTable("patches")
408 .set({ status: "open", updated_at: new Date().toISOString() })
409 .where("id", "=", patch.id)
410 .execute();
411 throw err;
412 }
413 patchCache.invalidate(patch.id);
414
415 return new Response(null, {
416 status: 302,
417 headers: { Location: `/${repo.name}/patches/${patchNum}` },
418 });
419 })
420
421 .post("/:repo/patches/:number/close", async ({ params, cookie }) => {
422 const user = await resolveSession(cookie.session.value);
423 const deny = requireAdmin(user);
424 if (deny) return deny;
425 const repo = await getRepo(params.repo, true);
426 if (!repo) return new Response("Not found", { status: 404 });
427
428 const patchNum = parseInt(params.number, 10);
429 const patch = await db
430 .selectFrom("patches")
431 .select("id")
432 .where("repo_id", "=", repo.id)
433 .where("number", "=", patchNum)
434 .executeTakeFirst();
435 if (!patch) return new Response("Not found", { status: 404 });
436
437 // Toggle open↔closed atomically; exclude merged patches from the WHERE
438 // so that numUpdatedRows = 0 means the patch is merged (or gone).
439 const toggled = await db
440 .updateTable("patches")
441 .set({
442 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
443 updated_at: new Date().toISOString(),
444 })
445 .where("id", "=", patch.id)
446 .where("status", "!=", "merged")
447 .executeTakeFirst();
448 if (!toggled || toggled.numUpdatedRows === 0n)
449 return new Response("Patch is merged", { status: 400 });
450
451 return new Response(null, {
452 status: 302,
453 headers: { Location: `/${repo.name}/patches/${patchNum}` },
454 });
455 })
456
457 .post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
458 const user = await resolveSession(cookie.session.value);
459 const deny = requireAuth(user);
460 if (deny) return deny;
461 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
462 if (!repo) return new Response("Not found", { status: 404 });
463
464 const patchNum = parseInt(params.number, 10);
465 const patch = await db
466 .selectFrom("patches")
467 .select(["id", "author_id"])
468 .where("repo_id", "=", repo.id)
469 .where("number", "=", patchNum)
470 .executeTakeFirst();
471 if (!patch) return new Response("Not found", { status: 404 });
472 if (patch.author_id !== user?.id && !user?.isAdmin)
473 return new Response("Forbidden", { status: 403 });
474
475 patchCache.invalidate(patch.id);
476 await db.deleteFrom("patches").where("id", "=", patch.id).execute();
477
478 return new Response(null, {
479 status: 302,
480 headers: { Location: `/${repo.name}/patches` },
481 });
482 })
483
484 .post(
485 "/:repo/patches/:number/comments",
486 async ({ params, body, cookie }) => {
487 const user = await resolveSession(cookie.session.value);
488 const deny = requireAuth(user);
489 if (deny) return deny;
490 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
491 if (!repo) return new Response("Not found", { status: 404 });
492
493 const patchNum = parseInt(params.number, 10);
494 const patch = await db
495 .selectFrom("patches")
496 .select(["id", "status"])
497 .where("repo_id", "=", repo.id)
498 .where("number", "=", patchNum)
499 .executeTakeFirst();
500 if (!patch) return new Response("Not found", { status: 404 });
501
502 const { body: commentBody } = body;
503 if (!commentBody?.trim()) {
504 return new Response(null, {
505 status: 302,
506 headers: { Location: `/${repo.name}/patches/${patchNum}` },
507 });
508 }
509
510 await db.transaction().execute(async (trx) => {
511 const now = new Date().toISOString();
512 await trx
513 .insertInto("patch_comments")
514 .values({
515 patch_id: patch.id,
516 author_id: user?.id,
517 body: commentBody.trim(),
518 created_at: now,
519 })
520 .execute();
521 await trx
522 .updateTable("patches")
523 .set({ updated_at: now })
524 .where("id", "=", patch.id)
525 .execute();
526 });
527
528 return new Response(null, {
529 status: 302,
530 headers: { Location: `/${repo.name}/patches/${patchNum}` },
531 });
532 },
533 {
534 body: t.Object({ body: t.String() }),
535 },
536 )
537
538 .post(
539 "/:repo/patches/:number/react",
540 async ({ params, body, cookie }) => {
541 const user = await resolveSession(cookie.session.value);
542 const deny = requireAuth(user);
543 if (deny) return deny;
544 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
545 if (!repo) return new Response("Not found", { status: 404 });
546
547 const { emoji, comment_id } = body;
548 if (!ALLOWED_REACTIONS.has(emoji)) {
549 return new Response("Invalid emoji", { status: 400 });
550 }
551
552 const patchNum = parseInt(params.number, 10);
553 const patch = await db
554 .selectFrom("patches")
555 .select(["id"])
556 .where("repo_id", "=", repo.id)
557 .where("number", "=", patchNum)
558 .executeTakeFirst();
559 if (!patch) return new Response("Not found", { status: 404 });
560
561 const commentId = comment_id ? parseInt(comment_id, 10) : null;
562
563 await db.transaction().execute(async (trx) => {
564 const existing = await trx
565 .selectFrom("patch_reactions")
566 .select(["id", "emoji"])
567 .where("patch_id", "=", patch.id)
568 .where((eb) =>
569 commentId !== null
570 ? eb("comment_id", "=", commentId)
571 : eb("comment_id", "is", null),
572 )
573 .where("user_id", "=", user!.id)
574 .executeTakeFirst();
575
576 if (existing) {
577 if (existing.emoji === emoji) {
578 await trx
579 .deleteFrom("patch_reactions")
580 .where("id", "=", existing.id)
581 .execute();
582 } else {
583 await trx
584 .updateTable("patch_reactions")
585 .set({ emoji })
586 .where("id", "=", existing.id)
587 .execute();
588 }
589 } else {
590 await trx
591 .insertInto("patch_reactions")
592 .values({
593 patch_id: patch.id,
594 comment_id: commentId,
595 user_id: user!.id,
596 emoji,
597 })
598 .execute();
599 }
600 });
601
602 return new Response(null, {
603 status: 303,
604 headers: { Location: `/${repo.name}/patches/${patchNum}` },
605 });
606 },
607 {
608 body: t.Object({
609 emoji: t.String(),
610 comment_id: t.Optional(t.String()),
611 }),
612 },
613 )
614
615 .post(
616 "/:repo/patches/:number/comments/:id/edit",
617 async ({ params, body, cookie }) => {
618 const user = await resolveSession(cookie.session.value);
619 const deny = requireAuth(user);
620 if (deny) return deny;
621 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
622 if (!repo) return new Response("Not found", { status: 404 });
623
624 const comment = await db
625 .selectFrom("patch_comments")
626 .select(["id", "author_id", "patch_id"])
627 .where("id", "=", params.id)
628 .executeTakeFirst();
629 if (!comment) return new Response("Not found", { status: 404 });
630 if (comment.author_id !== user?.id && !user?.isAdmin)
631 return new Response("Forbidden", { status: 403 });
632 const parentPatch = await db
633 .selectFrom("patches")
634 .select("status")
635 .where("id", "=", comment.patch_id)
636 .executeTakeFirst();
637 if (parentPatch?.status !== "open" && !user?.isAdmin)
638 return new Response("Forbidden", { status: 403 });
639
640 const patchNum = parseInt(params.number, 10);
641 await db
642 .updateTable("patch_comments")
643 .set({
644 body: body.edit_body.trim(),
645 edited_at: new Date().toISOString(),
646 })
647 .where("id", "=", comment.id)
648 .execute();
649
650 return new Response(null, {
651 status: 302,
652 headers: { Location: `/${repo.name}/patches/${patchNum}` },
653 });
654 },
655 {
656 params: t.Object({
657 repo: t.String(),
658 number: t.String(),
659 id: t.Numeric(),
660 }),
661 body: t.Object({ edit_body: t.String() }),
662 },
663 )
664
665 .post(
666 "/:repo/patches/:number/edit",
667 async ({ params, body, cookie }) => {
668 const user = await resolveSession(cookie.session.value);
669 const deny = requireAuth(user);
670 if (deny) return deny;
671 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
672 if (!repo) return new Response("Not found", { status: 404 });
673
674 const patchNum = parseInt(params.number, 10);
675 const patch = await db
676 .selectFrom("patches")
677 .select(["id", "author_id", "status"])
678 .where("repo_id", "=", repo.id)
679 .where("number", "=", patchNum)
680 .executeTakeFirst();
681 if (!patch) return new Response("Not found", { status: 404 });
682 if (patch.author_id !== user?.id && !user?.isAdmin)
683 return new Response("Forbidden", { status: 403 });
684 if (patch.status !== "open" && !user?.isAdmin)
685 return new Response("Forbidden", { status: 403 });
686
687 await db
688 .updateTable("patches")
689 .set({
690 title: body.title.trim(),
691 description: body.edit_description ?? "",
692 edited_at: new Date().toISOString(),
693 updated_at: new Date().toISOString(),
694 })
695 .where("id", "=", patch.id)
696 .execute();
697
698 return new Response(null, {
699 status: 302,
700 headers: { Location: `/${repo.name}/patches/${patchNum}` },
701 });
702 },
703 {
704 body: t.Object({
705 title: t.String(),
706 edit_description: t.Optional(t.String()),
707 }),
708 },
709 );
710