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