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