issues.tsx
Raw
1import { Elysia, t } from "elysia";
2import { sql } from "kysely";
3import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
4import { db, getRepo } 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 const allCounts = await db
37 .selectFrom("issues")
38 .select(["status", db.fn.countAll<number>().as("count")])
39 .where("repo_id", "=", repo.id)
40 .groupBy("status")
41 .execute();
42 const counts: Record<string, number> = Object.fromEntries(
43 allCounts.map((r) => [r.status, Number(r.count)]),
44 );
45 const totalPages = Math.max(
46 1,
47 Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
48 );
49 const safePage = Math.min(page, totalPages);
50 const offset = (safePage - 1) * ISSUES_PER_PAGE;
51
52 const issues = await db
53 .selectFrom("issues")
54 .leftJoin("users", "users.id", "issues.author_id")
55 .select([
56 "issues.id",
57 "issues.repo_id",
58 "issues.author_id",
59 "issues.number",
60 "issues.title",
61 "issues.body",
62 "issues.status",
63 "issues.created_at",
64 "issues.updated_at",
65 "issues.edited_at",
66 "users.username as author_username",
67 "users.avatar_version as author_avatar_version",
68 ])
69 .where("issues.repo_id", "=", repo.id)
70 .where("issues.status", "=", status)
71 .orderBy("issues.number", "desc")
72 .limit(ISSUES_PER_PAGE)
73 .offset(offset)
74 .execute();
75
76 const pagination = {
77 page: safePage,
78 totalPages,
79 pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`,
80 };
81 return html(
82 <IssueList
83 user={user}
84 repo={repo}
85 issues={
86 issues as ((typeof issues)[0] & {
87 author_username: string;
88 author_avatar_version: number | null;
89 })[]
90 }
91 status={status}
92 counts={counts}
93 pagination={pagination}
94 />,
95 );
96 },
97 {
98 query: t.Object({
99 status: t.Optional(t.String()),
100 page: t.Optional(t.Numeric()),
101 }),
102 },
103 )
104
105 .get("/:repo/issues/new", async ({ params, cookie }) => {
106 const user = await resolveSession(cookie.session.value);
107 const deny = requireAuth(user);
108 if (deny) return deny;
109 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
110 if (!repo) return new Response("Not found", { status: 404 });
111 return html(
112 <NewIssue
113 user={user!}
114 repo={repo}
115 template={repo.issue_template ?? undefined}
116 />,
117 );
118 })
119
120 .post(
121 "/:repo/issues",
122 async ({ params, body, cookie }) => {
123 const user = await resolveSession(cookie.session.value);
124 const deny = requireAuth(user);
125 if (deny) return deny;
126 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
127 if (!repo) return new Response("Not found", { status: 404 });
128
129 const { title, body: issueBody } = body;
130 if (!title?.trim()) {
131 return html(
132 <NewIssue
133 user={user!}
134 repo={repo}
135 error="Title is required"
136 />,
137 );
138 }
139
140 const now = new Date().toISOString();
141 const { number } = await db.transaction().execute(async (trx) => {
142 const { issue_seq } = await trx
143 .updateTable("repositories")
144 .set({ issue_seq: sql`issue_seq + 1` })
145 .where("id", "=", repo.id)
146 .returning("issue_seq")
147 .executeTakeFirstOrThrow();
148 await trx
149 .insertInto("issues")
150 .values({
151 repo_id: repo.id,
152 author_id: user?.id,
153 number: issue_seq,
154 title: title.trim(),
155 body: issueBody ?? "",
156 status: "open",
157 created_at: now,
158 updated_at: now,
159 })
160 .execute();
161 return { number: issue_seq };
162 });
163
164 return new Response(null, {
165 status: 302,
166 headers: { Location: `/${repo.name}/issues/${number}` },
167 });
168 },
169 {
170 body: t.Object({
171 title: t.String(),
172 body: t.Optional(t.String()),
173 }),
174 },
175 )
176
177 .get("/:repo/issues/:number", async ({ params, cookie }) => {
178 const user = await resolveSession(cookie.session.value);
179 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
180 if (!repo) return new Response("Not found", { status: 404 });
181
182 const issueNum = parseInt(params.number, 10);
183 const issue = await db
184 .selectFrom("issues")
185 .leftJoin("users", "users.id", "issues.author_id")
186 .select([
187 "issues.id",
188 "issues.repo_id",
189 "issues.author_id",
190 "issues.number",
191 "issues.title",
192 "issues.body",
193 "issues.status",
194 "issues.created_at",
195 "issues.updated_at",
196 "issues.edited_at",
197 "users.username as author_username",
198 "users.avatar_version as author_avatar_version",
199 ])
200 .where("issues.repo_id", "=", repo.id)
201 .where("issues.number", "=", issueNum)
202 .executeTakeFirst();
203 if (!issue) return new Response("Not found", { status: 404 });
204
205 const bodyHtml = renderMarkdown(issue.body);
206
207 const comments = await db
208 .selectFrom("issue_comments")
209 .leftJoin("users", "users.id", "issue_comments.author_id")
210 .select([
211 "issue_comments.id",
212 "issue_comments.issue_id",
213 "issue_comments.author_id",
214 "issue_comments.body",
215 "issue_comments.created_at",
216 "issue_comments.edited_at",
217 "users.username as author_username",
218 "users.avatar_version as author_avatar_version",
219 ])
220 .where("issue_comments.issue_id", "=", issue.id)
221 .orderBy("issue_comments.created_at", "asc")
222 .execute();
223
224 const commentsWithHtml = comments.map((c) => ({
225 ...c,
226 bodyHtml: renderMarkdown(c.body),
227 }));
228
229 // Reactions on the issue itself
230 const allReactions = await db
231 .selectFrom("issue_reactions")
232 .selectAll()
233 .where("issue_id", "=", issue.id)
234 .execute();
235
236 const reactions = buildReactionCounts(allReactions, null, user?.id);
237 const commentReactions = new Map(
238 comments.map((c) => [
239 c.id,
240 buildReactionCounts(allReactions, c.id, user?.id),
241 ]),
242 );
243
244 return html(
245 <IssueDetail
246 user={user}
247 repo={repo}
248 issue={
249 issue as typeof issue & {
250 author_username: string;
251 author_avatar_version: number | null;
252 }
253 }
254 bodyHtml={bodyHtml}
255 comments={
256 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
257 author_username: string;
258 author_avatar_version: number | null;
259 })[]
260 }
261 reactions={reactions}
262 commentReactions={commentReactions}
263 />,
264 );
265 })
266
267 .post(
268 "/:repo/issues/:number/comments",
269 async ({ params, body, cookie }) => {
270 const user = await resolveSession(cookie.session.value);
271 const deny = requireAuth(user);
272 if (deny) return deny;
273 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
274 if (!repo) return new Response("Not found", { status: 404 });
275
276 const issueNum = parseInt(params.number, 10);
277 const issue = await db
278 .selectFrom("issues")
279 .select(["id", "status"])
280 .where("repo_id", "=", repo.id)
281 .where("number", "=", issueNum)
282 .executeTakeFirst();
283 if (!issue) return new Response("Not found", { status: 404 });
284
285 // Only admin can comment on closed issues
286 if (issue.status === "closed" && !user?.isAdmin) {
287 return new Response(null, {
288 status: 302,
289 headers: { Location: `/${repo.name}/issues/${issueNum}` },
290 });
291 }
292
293 const { body: commentBody } = body;
294 if (!commentBody?.trim()) {
295 return new Response(null, {
296 status: 302,
297 headers: { Location: `/${repo.name}/issues/${issueNum}` },
298 });
299 }
300
301 await db.transaction().execute(async (trx) => {
302 const now = new Date().toISOString();
303 await trx
304 .insertInto("issue_comments")
305 .values({
306 issue_id: issue.id,
307 author_id: user?.id,
308 body: commentBody.trim(),
309 created_at: now,
310 })
311 .execute();
312 await trx
313 .updateTable("issues")
314 .set({ updated_at: now })
315 .where("id", "=", issue.id)
316 .execute();
317 });
318
319 return new Response(null, {
320 status: 302,
321 headers: { Location: `/${repo.name}/issues/${issueNum}` },
322 });
323 },
324 {
325 body: t.Object({ body: t.String() }),
326 },
327 )
328
329 .post(
330 "/:repo/issues/:number/react",
331 async ({ params, body, cookie }) => {
332 const user = await resolveSession(cookie.session.value);
333 const deny = requireAuth(user);
334 if (deny) return deny;
335 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
336 if (!repo) return new Response("Not found", { status: 404 });
337
338 const { emoji, comment_id } = body;
339 if (!ALLOWED_REACTIONS.has(emoji)) {
340 return new Response("Invalid emoji", { status: 400 });
341 }
342
343 const issueNum = parseInt(params.number, 10);
344 const issue = await db
345 .selectFrom("issues")
346 .select(["id"])
347 .where("repo_id", "=", repo.id)
348 .where("number", "=", issueNum)
349 .executeTakeFirst();
350 if (!issue) return new Response("Not found", { status: 404 });
351
352 const commentId = comment_id ? parseInt(comment_id, 10) : null;
353
354 // One reaction per user per target: toggle off if same emoji, replace if different
355 await db.transaction().execute(async (trx) => {
356 const existing = await trx
357 .selectFrom("issue_reactions")
358 .select(["id", "emoji"])
359 .where("issue_id", "=", issue.id)
360 .where((eb) =>
361 commentId !== null
362 ? eb("comment_id", "=", commentId)
363 : eb("comment_id", "is", null),
364 )
365 .where("user_id", "=", user!.id)
366 .executeTakeFirst();
367
368 if (existing) {
369 if (existing.emoji === emoji) {
370 await trx
371 .deleteFrom("issue_reactions")
372 .where("id", "=", existing.id)
373 .execute();
374 } else {
375 await trx
376 .updateTable("issue_reactions")
377 .set({ emoji })
378 .where("id", "=", existing.id)
379 .execute();
380 }
381 } else {
382 await trx
383 .insertInto("issue_reactions")
384 .values({
385 issue_id: issue.id,
386 comment_id: commentId,
387 user_id: user!.id,
388 emoji,
389 })
390 .execute();
391 }
392 });
393
394 return new Response(null, {
395 status: 303,
396 headers: { Location: `/${repo.name}/issues/${issueNum}` },
397 });
398 },
399 {
400 body: t.Object({
401 emoji: t.String(),
402 comment_id: t.Optional(t.String()),
403 }),
404 },
405 )
406
407 .post("/:repo/issues/:number/complete", async ({ params, cookie }) => {
408 const user = await resolveSession(cookie.session.value);
409 const deny = requireAdmin(user);
410 if (deny) return deny;
411 const repo = await getRepo(params.repo, true);
412 if (!repo) return new Response("Not found", { status: 404 });
413
414 const issueNum = parseInt(params.number, 10);
415 const issue = await db
416 .selectFrom("issues")
417 .select("id")
418 .where("repo_id", "=", repo.id)
419 .where("number", "=", issueNum)
420 .executeTakeFirst();
421 if (!issue) return new Response("Not found", { status: 404 });
422
423 await db
424 .updateTable("issues")
425 .set({ status: "completed", updated_at: new Date().toISOString() })
426 .where("id", "=", issue.id)
427 .execute();
428
429 return new Response(null, {
430 status: 302,
431 headers: { Location: `/${repo.name}/issues/${issueNum}` },
432 });
433 })
434
435 .post("/:repo/issues/:number/close", async ({ params, cookie }) => {
436 const user = await resolveSession(cookie.session.value);
437 const deny = requireAdmin(user);
438 if (deny) return deny;
439 const repo = await getRepo(params.repo, true);
440 if (!repo) return new Response("Not found", { status: 404 });
441
442 const issueNum = parseInt(params.number, 10);
443 const issue = await db
444 .selectFrom("issues")
445 .select("id")
446 .where("repo_id", "=", repo.id)
447 .where("number", "=", issueNum)
448 .executeTakeFirst();
449 if (!issue) return new Response("Not found", { status: 404 });
450
451 await db
452 .updateTable("issues")
453 .set({
454 status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
455 updated_at: new Date().toISOString(),
456 })
457 .where("id", "=", issue.id)
458 .execute();
459
460 return new Response(null, {
461 status: 302,
462 headers: { Location: `/${repo.name}/issues/${issueNum}` },
463 });
464 })
465
466 .post("/:repo/issues/:number/delete", async ({ params, cookie }) => {
467 const user = await resolveSession(cookie.session.value);
468 const deny = requireAuth(user);
469 if (deny) return deny;
470 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
471 if (!repo) return new Response("Not found", { status: 404 });
472
473 const issueNum = parseInt(params.number, 10);
474 const issue = await db
475 .selectFrom("issues")
476 .select(["id", "author_id"])
477 .where("repo_id", "=", repo.id)
478 .where("number", "=", issueNum)
479 .executeTakeFirst();
480 if (!issue) return new Response("Not found", { status: 404 });
481 if (issue.author_id !== user?.id && !user?.isAdmin)
482 return new Response("Forbidden", { status: 403 });
483
484 await db.deleteFrom("issues").where("id", "=", issue.id).execute();
485
486 return new Response(null, {
487 status: 302,
488 headers: { Location: `/${repo.name}/issues` },
489 });
490 })
491
492 .post(
493 "/:repo/issues/:number/edit",
494 async ({ params, body, cookie }) => {
495 const user = await resolveSession(cookie.session.value);
496 const deny = requireAuth(user);
497 if (deny) return deny;
498 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
499 if (!repo) return new Response("Not found", { status: 404 });
500
501 const issueNum = parseInt(params.number, 10);
502 const issue = await db
503 .selectFrom("issues")
504 .select(["id", "author_id", "status"])
505 .where("repo_id", "=", repo.id)
506 .where("number", "=", issueNum)
507 .executeTakeFirst();
508 if (!issue) return new Response("Not found", { status: 404 });
509 if (issue.author_id !== user?.id && !user?.isAdmin)
510 return new Response("Forbidden", { status: 403 });
511 if (issue.status !== "open" && !user?.isAdmin)
512 return new Response("Forbidden", { status: 403 });
513
514 await db
515 .updateTable("issues")
516 .set({
517 title: body.title.trim(),
518 body: body.edit_body ?? "",
519 edited_at: new Date().toISOString(),
520 updated_at: new Date().toISOString(),
521 })
522 .where("id", "=", issue.id)
523 .execute();
524
525 return new Response(null, {
526 status: 302,
527 headers: { Location: `/${repo.name}/issues/${issueNum}` },
528 });
529 },
530 {
531 body: t.Object({
532 title: t.String(),
533 edit_body: t.Optional(t.String()),
534 }),
535 },
536 )
537
538 .post(
539 "/:repo/issues/:number/comments/:id/edit",
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 comment = await db
548 .selectFrom("issue_comments")
549 .select(["id", "author_id", "issue_id"])
550 .where("id", "=", params.id)
551 .executeTakeFirst();
552 if (!comment) return new Response("Not found", { status: 404 });
553 if (comment.author_id !== user?.id && !user?.isAdmin)
554 return new Response("Forbidden", { status: 403 });
555 const parentIssue = await db
556 .selectFrom("issues")
557 .select("status")
558 .where("id", "=", comment.issue_id)
559 .executeTakeFirst();
560 if (parentIssue?.status !== "open" && !user?.isAdmin)
561 return new Response("Forbidden", { status: 403 });
562
563 const issueNum = parseInt(params.number, 10);
564 await db
565 .updateTable("issue_comments")
566 .set({
567 body: body.edit_body.trim(),
568 edited_at: new Date().toISOString(),
569 })
570 .where("id", "=", comment.id)
571 .execute();
572
573 return new Response(null, {
574 status: 302,
575 headers: { Location: `/${repo.name}/issues/${issueNum}` },
576 });
577 },
578 {
579 params: t.Object({
580 repo: t.String(),
581 number: t.String(),
582 id: t.Numeric(),
583 }),
584 body: t.Object({ edit_body: t.String() }),
585 },
586 );
587