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