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