issues.tsx
Raw
1import { Elysia, t } from "elysia";
2import { sql } from "kysely";
3import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
4import { db, getRepo, type LabelRow } from "../db/index.ts";
5import {
6 requireAdmin,
7 requireAuth,
8 resolveSession,
9} from "../middleware/session.ts";
10import { renderMarkdown } from "../services/markdown.ts";
11import { buildReactionCounts } from "../services/reactions.ts";
12import { IssueDetail } from "../views/issues/IssueDetail.tsx";
13import { IssueList } from "../views/issues/IssueList.tsx";
14import { NewIssue } from "../views/issues/NewIssue.tsx";
15import { html } from "../views/render.tsx";
16
17export const issueRoutes = new Elysia()
18 .guard({
19 cookie: t.Cookie({ session: t.Optional(t.String()) }),
20 })
21 .get(
22 "/:repo/issues",
23 async ({ params, query, cookie }) => {
24 const user = await resolveSession(cookie.session.value);
25 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
26 if (!repo) return new Response("Not found", { status: 404 });
27
28 const status =
29 query.status === "closed"
30 ? ("closed" as const)
31 : query.status === "completed"
32 ? ("completed" as const)
33 : ("open" as const);
34 const page = Math.max(1, query.page ?? 1);
35
36 // Parse label filter: query.labels may be a string or array of strings
37 const rawLabels = query.labels;
38 const labelIds: number[] = (
39 Array.isArray(rawLabels)
40 ? rawLabels
41 : rawLabels
42 ? [rawLabels]
43 : []
44 )
45 .map((v) => parseInt(v, 10))
46 .filter((n) => !isNaN(n));
47
48 const repoLabels = await db
49 .selectFrom("labels")
50 .selectAll()
51 .where("repo_id", "=", repo.id)
52 .orderBy("name", "asc")
53 .execute();
54
55 let countQuery = db
56 .selectFrom("issues")
57 .select(["issues.status", db.fn.countAll<number>().as("count")])
58 .where("issues.repo_id", "=", repo.id);
59 if (labelIds.length > 0) {
60 countQuery = countQuery.where(({ exists, selectFrom }) =>
61 exists(
62 selectFrom("issue_labels")
63 .select("issue_labels.issue_id")
64 .whereRef(
65 "issue_labels.issue_id",
66 "=",
67 "issues.id",
68 )
69 .where("issue_labels.label_id", "in", labelIds),
70 ),
71 );
72 }
73 const allCounts = await countQuery.groupBy("issues.status").execute();
74 const counts: Record<string, number> = Object.fromEntries(
75 allCounts.map((r) => [r.status, Number(r.count)]),
76 );
77 const totalPages = Math.max(
78 1,
79 Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
80 );
81 const safePage = Math.min(page, totalPages);
82 const offset = (safePage - 1) * ISSUES_PER_PAGE;
83
84 let listQuery = db
85 .selectFrom("issues")
86 .leftJoin("users", "users.id", "issues.author_id")
87 .select([
88 "issues.id",
89 "issues.repo_id",
90 "issues.author_id",
91 "issues.number",
92 "issues.title",
93 "issues.body",
94 "issues.status",
95 "issues.created_at",
96 "issues.updated_at",
97 "issues.edited_at",
98 "users.username as author_username",
99 "users.avatar_version as author_avatar_version",
100 ])
101 .where("issues.repo_id", "=", repo.id)
102 .where("issues.status", "=", status);
103 if (labelIds.length > 0) {
104 listQuery = listQuery.where(({ exists, selectFrom }) =>
105 exists(
106 selectFrom("issue_labels")
107 .select("issue_labels.issue_id")
108 .whereRef(
109 "issue_labels.issue_id",
110 "=",
111 "issues.id",
112 )
113 .where("issue_labels.label_id", "in", labelIds),
114 ),
115 );
116 }
117 const issues = await listQuery
118 .orderBy("issues.number", "desc")
119 .limit(ISSUES_PER_PAGE)
120 .offset(offset)
121 .execute();
122
123 // Batch-fetch labels for displayed issues
124 const issueIds = issues.map((i) => i.id);
125 const issueLabelsRows =
126 issueIds.length > 0
127 ? await db
128 .selectFrom("issue_labels")
129 .innerJoin(
130 "labels",
131 "labels.id",
132 "issue_labels.label_id",
133 )
134 .select([
135 "issue_labels.issue_id",
136 "labels.id",
137 "labels.name",
138 "labels.color",
139 ])
140 .where("issue_labels.issue_id", "in", issueIds)
141 .execute()
142 : [];
143 const labelsByIssueId = new Map<number, LabelRow[]>();
144 for (const row of issueLabelsRows) {
145 const list = labelsByIssueId.get(row.issue_id) ?? [];
146 list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" });
147 labelsByIssueId.set(row.issue_id, list);
148 }
149
150 const labelsParam =
151 labelIds.length > 0
152 ? `&labels=${labelIds.map(String).join(",")}`
153 : "";
154 const pagination = {
155 page: safePage,
156 totalPages,
157 pageUrlTemplate: `/${repo.name}/issues?status=${status}${labelsParam}&page={page}`,
158 };
159 return html(
160 <IssueList
161 user={user}
162 repo={repo}
163 issues={
164 issues as ((typeof issues)[0] & {
165 author_username: string;
166 author_avatar_version: number | null;
167 })[]
168 }
169 status={status}
170 counts={counts}
171 pagination={pagination}
172 repoLabels={repoLabels}
173 selectedLabelIds={labelIds}
174 labelsByIssueId={labelsByIssueId}
175 />,
176 );
177 },
178 {
179 query: t.Object({
180 status: t.Optional(t.String()),
181 page: t.Optional(t.Numeric()),
182 labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
183 }),
184 },
185 )
186
187 .get("/:repo/issues/new", async ({ params, cookie }) => {
188 const user = await resolveSession(cookie.session.value);
189 const deny = requireAuth(user);
190 if (deny) return deny;
191 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
192 if (!repo) return new Response("Not found", { status: 404 });
193 return html(
194 <NewIssue
195 user={user!}
196 repo={repo}
197 template={repo.issue_template ?? undefined}
198 />,
199 );
200 })
201
202 .post(
203 "/:repo/issues",
204 async ({ params, body, cookie }) => {
205 const user = await resolveSession(cookie.session.value);
206 const deny = requireAuth(user);
207 if (deny) return deny;
208 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
209 if (!repo) return new Response("Not found", { status: 404 });
210
211 const { title, body: issueBody } = body;
212 if (!title?.trim()) {
213 return html(
214 <NewIssue
215 user={user!}
216 repo={repo}
217 error="Title is required"
218 />,
219 );
220 }
221
222 const now = new Date().toISOString();
223 const { number } = await db.transaction().execute(async (trx) => {
224 const { issue_seq } = await trx
225 .updateTable("repositories")
226 .set({ issue_seq: sql`issue_seq + 1` })
227 .where("id", "=", repo.id)
228 .returning("issue_seq")
229 .executeTakeFirstOrThrow();
230 await trx
231 .insertInto("issues")
232 .values({
233 repo_id: repo.id,
234 author_id: user?.id,
235 number: issue_seq,
236 title: title.trim(),
237 body: issueBody ?? "",
238 status: "open",
239 created_at: now,
240 updated_at: now,
241 })
242 .execute();
243 return { number: issue_seq };
244 });
245
246 return new Response(null, {
247 status: 302,
248 headers: { Location: `/${repo.name}/issues/${number}` },
249 });
250 },
251 {
252 body: t.Object({
253 title: t.String(),
254 body: t.Optional(t.String()),
255 }),
256 },
257 )
258
259 .get("/:repo/issues/:number", async ({ params, cookie }) => {
260 const user = await resolveSession(cookie.session.value);
261 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
262 if (!repo) return new Response("Not found", { status: 404 });
263
264 const issueNum = parseInt(params.number, 10);
265 const issue = await db
266 .selectFrom("issues")
267 .leftJoin("users", "users.id", "issues.author_id")
268 .select([
269 "issues.id",
270 "issues.repo_id",
271 "issues.author_id",
272 "issues.number",
273 "issues.title",
274 "issues.body",
275 "issues.status",
276 "issues.created_at",
277 "issues.updated_at",
278 "issues.edited_at",
279 "users.username as author_username",
280 "users.avatar_version as author_avatar_version",
281 ])
282 .where("issues.repo_id", "=", repo.id)
283 .where("issues.number", "=", issueNum)
284 .executeTakeFirst();
285 if (!issue) return new Response("Not found", { status: 404 });
286
287 const bodyHtml = renderMarkdown(issue.body);
288
289 const comments = await db
290 .selectFrom("issue_comments")
291 .leftJoin("users", "users.id", "issue_comments.author_id")
292 .select([
293 "issue_comments.id",
294 "issue_comments.issue_id",
295 "issue_comments.author_id",
296 "issue_comments.body",
297 "issue_comments.created_at",
298 "issue_comments.edited_at",
299 "users.username as author_username",
300 "users.avatar_version as author_avatar_version",
301 ])
302 .where("issue_comments.issue_id", "=", issue.id)
303 .orderBy("issue_comments.created_at", "asc")
304 .execute();
305
306 const commentsWithHtml = comments.map((c) => ({
307 ...c,
308 bodyHtml: renderMarkdown(c.body),
309 }));
310
311 // Reactions on the issue itself
312 const allReactions = await db
313 .selectFrom("issue_reactions")
314 .selectAll()
315 .where("issue_id", "=", issue.id)
316 .execute();
317
318 const reactions = buildReactionCounts(allReactions, null, user?.id);
319 const commentReactions = new Map(
320 comments.map((c) => [
321 c.id,
322 buildReactionCounts(allReactions, c.id, user?.id),
323 ]),
324 );
325
326 const issueLabels = await db
327 .selectFrom("issue_labels")
328 .innerJoin("labels", "labels.id", "issue_labels.label_id")
329 .select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"])
330 .where("issue_labels.issue_id", "=", issue.id)
331 .execute();
332
333 const repoLabels = await db
334 .selectFrom("labels")
335 .selectAll()
336 .where("repo_id", "=", repo.id)
337 .orderBy("name", "asc")
338 .execute();
339
340 return html(
341 <IssueDetail
342 user={user}
343 repo={repo}
344 issue={
345 issue as typeof issue & {
346 author_username: string;
347 author_avatar_version: number | null;
348 }
349 }
350 bodyHtml={bodyHtml}
351 comments={
352 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
353 author_username: string;
354 author_avatar_version: number | null;
355 })[]
356 }
357 reactions={reactions}
358 commentReactions={commentReactions}
359 issueLabels={issueLabels}
360 repoLabels={repoLabels}
361 />,
362 );
363 })
364
365 .post(
366 "/:repo/issues/:number/comments",
367 async ({ params, body, cookie }) => {
368 const user = await resolveSession(cookie.session.value);
369 const deny = requireAuth(user);
370 if (deny) return deny;
371 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
372 if (!repo) return new Response("Not found", { status: 404 });
373
374 const issueNum = parseInt(params.number, 10);
375 const issue = await db
376 .selectFrom("issues")
377 .select(["id", "status"])
378 .where("repo_id", "=", repo.id)
379 .where("number", "=", issueNum)
380 .executeTakeFirst();
381 if (!issue) return new Response("Not found", { status: 404 });
382
383 // Only admin can comment on closed issues
384 if (issue.status === "closed" && !user?.isAdmin) {
385 return new Response(null, {
386 status: 302,
387 headers: { Location: `/${repo.name}/issues/${issueNum}` },
388 });
389 }
390
391 const { body: commentBody } = body;
392 if (!commentBody?.trim()) {
393 return new Response(null, {
394 status: 302,
395 headers: { Location: `/${repo.name}/issues/${issueNum}` },
396 });
397 }
398
399 await db.transaction().execute(async (trx) => {
400 const now = new Date().toISOString();
401 await trx
402 .insertInto("issue_comments")
403 .values({
404 issue_id: issue.id,
405 author_id: user?.id,
406 body: commentBody.trim(),
407 created_at: now,
408 })
409 .execute();
410 await trx
411 .updateTable("issues")
412 .set({ updated_at: now })
413 .where("id", "=", issue.id)
414 .execute();
415 });
416
417 return new Response(null, {
418 status: 302,
419 headers: { Location: `/${repo.name}/issues/${issueNum}` },
420 });
421 },
422 {
423 body: t.Object({ body: t.String() }),
424 },
425 )
426
427 .post(
428 "/:repo/issues/:number/react",
429 async ({ params, body, cookie }) => {
430 const user = await resolveSession(cookie.session.value);
431 const deny = requireAuth(user);
432 if (deny) return deny;
433 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
434 if (!repo) return new Response("Not found", { status: 404 });
435
436 const { emoji, comment_id } = body;
437 if (!ALLOWED_REACTIONS.has(emoji)) {
438 return new Response("Invalid emoji", { status: 400 });
439 }
440
441 const issueNum = parseInt(params.number, 10);
442 const issue = await db
443 .selectFrom("issues")
444 .select(["id"])
445 .where("repo_id", "=", repo.id)
446 .where("number", "=", issueNum)
447 .executeTakeFirst();
448 if (!issue) return new Response("Not found", { status: 404 });
449
450 const commentId = comment_id ? parseInt(comment_id, 10) : null;
451
452 // One reaction per user per target: toggle off if same emoji, replace if different
453 await db.transaction().execute(async (trx) => {
454 const existing = await trx
455 .selectFrom("issue_reactions")
456 .select(["id", "emoji"])
457 .where("issue_id", "=", issue.id)
458 .where((eb) =>
459 commentId !== null
460 ? eb("comment_id", "=", commentId)
461 : eb("comment_id", "is", null),
462 )
463 .where("user_id", "=", user!.id)
464 .executeTakeFirst();
465
466 if (existing) {
467 if (existing.emoji === emoji) {
468 await trx
469 .deleteFrom("issue_reactions")
470 .where("id", "=", existing.id)
471 .execute();
472 } else {
473 await trx
474 .updateTable("issue_reactions")
475 .set({ emoji })
476 .where("id", "=", existing.id)
477 .execute();
478 }
479 } else {
480 await trx
481 .insertInto("issue_reactions")
482 .values({
483 issue_id: issue.id,
484 comment_id: commentId,
485 user_id: user!.id,
486 emoji,
487 })
488 .execute();
489 }
490 });
491
492 return new Response(null, {
493 status: 303,
494 headers: { Location: `/${repo.name}/issues/${issueNum}` },
495 });
496 },
497 {
498 body: t.Object({
499 emoji: t.String(),
500 comment_id: t.Optional(t.String()),
501 }),
502 },
503 )
504
505 .post("/:repo/issues/:number/complete", async ({ params, cookie }) => {
506 const user = await resolveSession(cookie.session.value);
507 const deny = requireAdmin(user);
508 if (deny) return deny;
509 const repo = await getRepo(params.repo, true);
510 if (!repo) return new Response("Not found", { status: 404 });
511
512 const issueNum = parseInt(params.number, 10);
513 const issue = await db
514 .selectFrom("issues")
515 .select("id")
516 .where("repo_id", "=", repo.id)
517 .where("number", "=", issueNum)
518 .executeTakeFirst();
519 if (!issue) return new Response("Not found", { status: 404 });
520
521 await db
522 .updateTable("issues")
523 .set({ status: "completed", updated_at: new Date().toISOString() })
524 .where("id", "=", issue.id)
525 .execute();
526
527 return new Response(null, {
528 status: 302,
529 headers: { Location: `/${repo.name}/issues/${issueNum}` },
530 });
531 })
532
533 .post("/:repo/issues/:number/close", async ({ params, cookie }) => {
534 const user = await resolveSession(cookie.session.value);
535 const deny = requireAdmin(user);
536 if (deny) return deny;
537 const repo = await getRepo(params.repo, true);
538 if (!repo) return new Response("Not found", { status: 404 });
539
540 const issueNum = parseInt(params.number, 10);
541 const issue = await db
542 .selectFrom("issues")
543 .select("id")
544 .where("repo_id", "=", repo.id)
545 .where("number", "=", issueNum)
546 .executeTakeFirst();
547 if (!issue) return new Response("Not found", { status: 404 });
548
549 await db
550 .updateTable("issues")
551 .set({
552 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
553 updated_at: new Date().toISOString(),
554 })
555 .where("id", "=", issue.id)
556 .execute();
557
558 return new Response(null, {
559 status: 302,
560 headers: { Location: `/${repo.name}/issues/${issueNum}` },
561 });
562 })
563
564 .post("/:repo/issues/:number/delete", async ({ params, cookie }) => {
565 const user = await resolveSession(cookie.session.value);
566 const deny = requireAuth(user);
567 if (deny) return deny;
568 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
569 if (!repo) return new Response("Not found", { status: 404 });
570
571 const issueNum = parseInt(params.number, 10);
572 const issue = await db
573 .selectFrom("issues")
574 .select(["id", "author_id"])
575 .where("repo_id", "=", repo.id)
576 .where("number", "=", issueNum)
577 .executeTakeFirst();
578 if (!issue) return new Response("Not found", { status: 404 });
579 if (issue.author_id !== user?.id && !user?.isAdmin)
580 return new Response("Forbidden", { status: 403 });
581
582 await db.deleteFrom("issues").where("id", "=", issue.id).execute();
583
584 return new Response(null, {
585 status: 302,
586 headers: { Location: `/${repo.name}/issues` },
587 });
588 })
589
590 .post(
591 "/:repo/issues/:number/edit",
592 async ({ params, body, cookie }) => {
593 const user = await resolveSession(cookie.session.value);
594 const deny = requireAuth(user);
595 if (deny) return deny;
596 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
597 if (!repo) return new Response("Not found", { status: 404 });
598
599 const issueNum = parseInt(params.number, 10);
600 const issue = await db
601 .selectFrom("issues")
602 .select(["id", "author_id", "status"])
603 .where("repo_id", "=", repo.id)
604 .where("number", "=", issueNum)
605 .executeTakeFirst();
606 if (!issue) return new Response("Not found", { status: 404 });
607 if (issue.author_id !== user?.id && !user?.isAdmin)
608 return new Response("Forbidden", { status: 403 });
609 if (issue.status !== "open" && !user?.isAdmin)
610 return new Response("Forbidden", { status: 403 });
611
612 await db
613 .updateTable("issues")
614 .set({
615 title: body.title.trim(),
616 body: body.edit_body ?? "",
617 edited_at: new Date().toISOString(),
618 updated_at: new Date().toISOString(),
619 })
620 .where("id", "=", issue.id)
621 .execute();
622
623 return new Response(null, {
624 status: 302,
625 headers: { Location: `/${repo.name}/issues/${issueNum}` },
626 });
627 },
628 {
629 body: t.Object({
630 title: t.String(),
631 edit_body: t.Optional(t.String()),
632 }),
633 },
634 )
635
636 .post(
637 "/:repo/issues/:number/comments/:id/edit",
638 async ({ params, body, cookie }) => {
639 const user = await resolveSession(cookie.session.value);
640 const deny = requireAuth(user);
641 if (deny) return deny;
642 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
643 if (!repo) return new Response("Not found", { status: 404 });
644
645 const comment = await db
646 .selectFrom("issue_comments")
647 .select(["id", "author_id", "issue_id"])
648 .where("id", "=", params.id)
649 .executeTakeFirst();
650 if (!comment) return new Response("Not found", { status: 404 });
651 if (comment.author_id !== user?.id && !user?.isAdmin)
652 return new Response("Forbidden", { status: 403 });
653 const parentIssue = await db
654 .selectFrom("issues")
655 .select("status")
656 .where("id", "=", comment.issue_id)
657 .executeTakeFirst();
658 if (parentIssue?.status !== "open" && !user?.isAdmin)
659 return new Response("Forbidden", { status: 403 });
660
661 const issueNum = parseInt(params.number, 10);
662 await db
663 .updateTable("issue_comments")
664 .set({
665 body: body.edit_body.trim(),
666 edited_at: new Date().toISOString(),
667 })
668 .where("id", "=", comment.id)
669 .execute();
670
671 return new Response(null, {
672 status: 302,
673 headers: { Location: `/${repo.name}/issues/${issueNum}` },
674 });
675 },
676 {
677 params: t.Object({
678 repo: t.String(),
679 number: t.String(),
680 id: t.Numeric(),
681 }),
682 body: t.Object({ edit_body: t.String() }),
683 },
684 )
685
686 .post(
687 "/:repo/issues/:number/labels/add",
688 async ({ params, body, cookie }) => {
689 const user = await resolveSession(cookie.session.value);
690 const deny = requireAdmin(user);
691 if (deny) return deny;
692 const repo = await getRepo(params.repo, true);
693 if (!repo) return new Response("Not found", { status: 404 });
694
695 const issueNum = parseInt(params.number, 10);
696 const issue = await db
697 .selectFrom("issues")
698 .select(["id"])
699 .where("repo_id", "=", repo.id)
700 .where("number", "=", issueNum)
701 .executeTakeFirst();
702 if (!issue) return new Response("Not found", { status: 404 });
703
704 const label = await db
705 .selectFrom("labels")
706 .select(["id"])
707 .where("id", "=", body.label_id)
708 .where("repo_id", "=", repo.id)
709 .executeTakeFirst();
710 if (!label) {
711 return new Response(null, {
712 status: 302,
713 headers: { Location: `/${repo.name}/issues/${issueNum}` },
714 });
715 }
716
717 await db
718 .insertInto("issue_labels")
719 .values({ issue_id: issue.id, label_id: label.id })
720 .onConflict((oc) => oc.doNothing())
721 .execute();
722
723 return new Response(null, {
724 status: 302,
725 headers: { Location: `/${repo.name}/issues/${issueNum}` },
726 });
727 },
728 {
729 params: t.Object({ repo: t.String(), number: t.String() }),
730 body: t.Object({ label_id: t.Numeric() }),
731 },
732 )
733
734 .post(
735 "/:repo/issues/:number/labels/remove",
736 async ({ params, body, cookie }) => {
737 const user = await resolveSession(cookie.session.value);
738 const deny = requireAdmin(user);
739 if (deny) return deny;
740 const repo = await getRepo(params.repo, true);
741 if (!repo) return new Response("Not found", { status: 404 });
742
743 const issueNum = parseInt(params.number, 10);
744 const issue = await db
745 .selectFrom("issues")
746 .select(["id"])
747 .where("repo_id", "=", repo.id)
748 .where("number", "=", issueNum)
749 .executeTakeFirst();
750 if (!issue) return new Response("Not found", { status: 404 });
751
752 await db
753 .deleteFrom("issue_labels")
754 .where("issue_id", "=", issue.id)
755 .where("label_id", "=", body.label_id)
756 .execute();
757
758 return new Response(null, {
759 status: 302,
760 headers: { Location: `/${repo.name}/issues/${issueNum}` },
761 });
762 },
763 {
764 params: t.Object({ repo: t.String(), number: t.String() }),
765 body: t.Object({ label_id: t.Numeric() }),
766 },
767 );
768