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