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 { authorizeCommentEdit } from "../lib/commentAuth.ts";
8import { paginate } from "../lib/pagination.ts";
9import {
10 requireAdmin,
11 requireAuth,
12 resolveSession,
13} from "../middleware/session.ts";
14import { renderMarkdown } from "../services/markdown.ts";
15import { buildReactionCounts } from "../services/reactions.ts";
16import { IssueDetail } from "../views/issues/IssueDetail.tsx";
17import { IssueList } from "../views/issues/IssueList.tsx";
18import { NewIssue } from "../views/issues/NewIssue.tsx";
19import { html } from "../views/render.tsx";
20
21export const issueRoutes = new Elysia()
22 .guard({
23 cookie: t.Cookie({ session: t.Optional(t.String()) }),
24 })
25 .get(
26 "/:repo/issues",
27 async ({ params, query, cookie }) => {
28 const user = await resolveSession(cookie.session.value);
29 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
30 if (!repo) return new Response("Not found", { status: 404 });
31
32 const status =
33 query.status === "closed"
34 ? ("closed" as const)
35 : query.status === "completed"
36 ? ("completed" as const)
37 : ("open" as const);
38
39 // Parse label filter: query.labels may be a string or array of strings
40 const rawLabels = query.labels;
41 const labelIds: number[] = (
42 Array.isArray(rawLabels)
43 ? rawLabels
44 : rawLabels
45 ? [rawLabels]
46 : []
47 )
48 .map((v) => parseInt(v, 10))
49 .filter((n) => !Number.isNaN(n));
50
51 const repoLabels = await db
52 .selectFrom("labels")
53 .selectAll()
54 .where("repo_id", "=", repo.id)
55 .orderBy("name", "asc")
56 .execute();
57
58 let countQuery = db
59 .selectFrom("issues")
60 .select(["issues.status", db.fn.countAll<number>().as("count")])
61 .where("issues.repo_id", "=", repo.id);
62 if (labelIds.length > 0) {
63 countQuery = countQuery.where((eb) =>
64 issuesLabelFilter(eb, labelIds),
65 );
66 }
67 const allCounts = await countQuery
68 .groupBy("issues.status")
69 .execute();
70 const counts: Record<string, number> = Object.fromEntries(
71 allCounts.map((r) => [r.status, Number(r.count)]),
72 );
73 const {
74 page: safePage,
75 totalPages,
76 offset,
77 } = paginate(query.page, counts[status] ?? 0, 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 denyComment = await authorizeCommentEdit(
698 "issue",
699 params.id,
700 repo.id,
701 user,
702 );
703 if (denyComment) return denyComment;
704
705 const issueNum = parseInt(params.number, 10);
706 await db
707 .updateTable("issue_comments")
708 .set({
709 body: body.edit_body.trim(),
710 edited_at: new Date().toISOString(),
711 })
712 .where("id", "=", params.id)
713 .execute();
714
715 return new Response(null, {
716 status: 302,
717 headers: { Location: `/${repo.name}/issues/${issueNum}` },
718 });
719 },
720 {
721 params: t.Object({
722 repo: t.String(),
723 number: t.String(),
724 id: t.Numeric(),
725 }),
726 body: t.Object({
727 edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
728 }),
729 },
730 )
731
732 .post(
733 "/:repo/issues/:number/labels/add",
734 async ({ params, body, cookie }) => {
735 const user = await resolveSession(cookie.session.value);
736 if (!user) return new Response("Unauthorized", { status: 401 });
737 const repo = await getRepo(params.repo, user.isAdmin);
738 if (!repo) return new Response("Not found", { status: 404 });
739
740 const issueNum = parseInt(params.number, 10);
741 const issue = await db
742 .selectFrom("issues")
743 .select(["id", "author_id"])
744 .where("repo_id", "=", repo.id)
745 .where("number", "=", issueNum)
746 .executeTakeFirst();
747 if (!issue) return new Response("Not found", { status: 404 });
748
749 const canManage =
750 user.isAdmin ||
751 (repo.allow_user_labels === 1 && user.id === issue.author_id);
752 if (!canManage) return new Response("Forbidden", { status: 403 });
753
754 const label = await db
755 .selectFrom("labels")
756 .select(["id"])
757 .where("id", "=", body.label_id)
758 .where("repo_id", "=", repo.id)
759 .executeTakeFirst();
760 if (!label) {
761 return new Response(null, {
762 status: 302,
763 headers: { Location: `/${repo.name}/issues/${issueNum}` },
764 });
765 }
766
767 await db
768 .insertInto("issue_labels")
769 .values({ issue_id: issue.id, label_id: label.id })
770 .onConflict((oc) => oc.doNothing())
771 .execute();
772
773 return new Response(null, {
774 status: 302,
775 headers: { Location: `/${repo.name}/issues/${issueNum}` },
776 });
777 },
778 {
779 params: t.Object({ repo: t.String(), number: t.String() }),
780 body: t.Object({ label_id: t.Numeric() }),
781 },
782 )
783
784 .post(
785 "/:repo/issues/:number/labels/remove",
786 async ({ params, body, cookie }) => {
787 const user = await resolveSession(cookie.session.value);
788 if (!user) return new Response("Unauthorized", { status: 401 });
789 const repo = await getRepo(params.repo, user.isAdmin);
790 if (!repo) return new Response("Not found", { status: 404 });
791
792 const issueNum = parseInt(params.number, 10);
793 const issue = await db
794 .selectFrom("issues")
795 .select(["id", "author_id"])
796 .where("repo_id", "=", repo.id)
797 .where("number", "=", issueNum)
798 .executeTakeFirst();
799 if (!issue) return new Response("Not found", { status: 404 });
800
801 const canManage =
802 user.isAdmin ||
803 (repo.allow_user_labels === 1 && user.id === issue.author_id);
804 if (!canManage) return new Response("Forbidden", { status: 403 });
805
806 await db
807 .deleteFrom("issue_labels")
808 .where("issue_id", "=", issue.id)
809 .where("label_id", "=", body.label_id)
810 .execute();
811
812 return new Response(null, {
813 status: 302,
814 headers: { Location: `/${repo.name}/issues/${issueNum}` },
815 });
816 },
817 {
818 params: t.Object({ repo: t.String(), number: t.String() }),
819 body: t.Object({ label_id: t.Numeric() }),
820 },
821 );
822