import { Elysia, t } from "elysia"; import { sql } from "kysely"; import config from "../config.ts"; import { ALLOWED_REACTIONS, COMMENT_MAX_PER_MIN, ISSUE_CREATE_MAX_PER_MIN, ISSUES_PER_PAGE, LABEL_WRITE_MAX_PER_MIN, RATE_WINDOW_MIN_MS, REACTION_MAX_PER_MIN, } from "../constants.ts"; import { issuesLabelFilter } from "../db/helpers.ts"; import { db, getRepo, type LabelRow } from "../db/index.ts"; import { authorizeCommentEdit } from "../lib/commentAuth.ts"; import { paginate } from "../lib/pagination.ts"; import { rateLimit } from "../lib/rateLimiter.ts"; import { requireAdmin, requireAuth, resolveSession, } from "../middleware/session.ts"; import { renderMarkdown } from "../services/markdown.ts"; import { buildReactionCounts } from "../services/reactions.ts"; import { IssueDetail } from "../views/issues/IssueDetail.tsx"; import { IssueList } from "../views/issues/IssueList.tsx"; import { NewIssue } from "../views/issues/NewIssue.tsx"; import { html } from "../views/render.tsx"; export const issueRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }), }) .get( "/:repo/issues", async ({ params, query, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const status = query.status === "closed" ? ("closed" as const) : query.status === "completed" ? ("completed" as const) : ("open" as const); // Parse label filter: query.labels may be a string or array of strings const rawLabels = query.labels; const labelIds: number[] = ( Array.isArray(rawLabels) ? rawLabels : rawLabels ? [rawLabels] : [] ) .map((v) => parseInt(v, 10)) .filter((n) => !Number.isNaN(n)); const repoLabels = await db .selectFrom("labels") .selectAll() .where("repo_id", "=", repo.id) .orderBy("name", "asc") .execute(); let countQuery = db .selectFrom("issues") .select(["issues.status", db.fn.countAll().as("count")]) .where("issues.repo_id", "=", repo.id); if (labelIds.length > 0) { countQuery = countQuery.where((eb) => issuesLabelFilter(eb, labelIds), ); } const allCounts = await countQuery .groupBy("issues.status") .execute(); const counts: Record = Object.fromEntries( allCounts.map((r) => [r.status, Number(r.count)]), ); const { page: safePage, totalPages, offset, } = paginate(query.page, counts[status] ?? 0, ISSUES_PER_PAGE); let listQuery = db .selectFrom("issues") .leftJoin("users", "users.id", "issues.author_id") .select([ "issues.id", "issues.repo_id", "issues.author_id", "issues.number", "issues.title", "issues.body", "issues.status", "issues.created_at", "issues.updated_at", "issues.edited_at", "users.username as author_username", "users.avatar_version as author_avatar_version", ]) .where("issues.repo_id", "=", repo.id) .where("issues.status", "=", status); if (labelIds.length > 0) { listQuery = listQuery.where((eb) => issuesLabelFilter(eb, labelIds), ); } const issues = await listQuery .orderBy("issues.number", "desc") .limit(ISSUES_PER_PAGE) .offset(offset) .execute(); // Batch-fetch labels for displayed issues const issueIds = issues.map((i) => i.id); const issueLabelsRows = issueIds.length > 0 ? await db .selectFrom("issue_labels") .innerJoin( "labels", "labels.id", "issue_labels.label_id", ) .select([ "issue_labels.issue_id", "labels.id", "labels.name", "labels.color", ]) .where("issue_labels.issue_id", "in", issueIds) .execute() : []; const labelsByIssueId = new Map(); for (const row of issueLabelsRows) { const list = labelsByIssueId.get(row.issue_id) ?? []; list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "", }); labelsByIssueId.set(row.issue_id, list); } const labelsParam = labelIds.length > 0 ? `&labels=${labelIds.map(String).join(",")}` : ""; const pagination = { page: safePage, totalPages, pageUrlTemplate: `/${repo.name}/issues?status=${status}${labelsParam}&page={page}`, }; return html( , ); }, { query: t.Object({ status: t.Optional(t.String()), page: t.Optional(t.Numeric()), labels: t.Optional(t.Union([t.String(), t.Array(t.String())])), }), }, ) .get("/:repo/issues/new", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const labels = await db .selectFrom("labels") .selectAll() .where("repo_id", "=", repo.id) .orderBy("name", "asc") .execute(); return html( , ); }) .post( "/:repo/issues", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "issue-create", ISSUE_CREATE_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const { title, body: issueBody } = body; if (!title?.trim()) { const labels = await db .selectFrom("labels") .selectAll() .where("repo_id", "=", repo.id) .orderBy("name", "asc") .execute(); return html( , ); } const rawIds = user!.isAdmin || repo.allow_user_labels === 1 ? body.label_ids : undefined; const labelIds = rawIds ? (Array.isArray(rawIds) ? rawIds : [rawIds]) .map(Number) .filter(Boolean) : []; const now = new Date().toISOString(); const { number } = await db.transaction().execute(async (trx) => { const { issue_seq } = await trx .updateTable("repositories") .set({ issue_seq: sql`issue_seq + 1` }) .where("id", "=", repo.id) .returning("issue_seq") .executeTakeFirstOrThrow(); const inserted = await trx .insertInto("issues") .values({ repo_id: repo.id, author_id: user?.id, number: issue_seq, title: title.trim(), body: issueBody ?? "", status: "open", created_at: now, updated_at: now, }) .returning("id") .executeTakeFirstOrThrow(); if (labelIds.length > 0) { const validLabels = await trx .selectFrom("labels") .select("id") .where("repo_id", "=", repo.id) .where("id", "in", labelIds) .execute(); if (validLabels.length > 0) { await trx .insertInto("issue_labels") .values( validLabels.map((l) => ({ issue_id: inserted.id, label_id: l.id, })), ) .onConflict((oc) => oc.doNothing()) .execute(); } } return { number: issue_seq }; }); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${number}` }, }); }, { body: t.Object({ title: t.String({ maxLength: config.MAX_TITLE_BYTES }), body: t.Optional( t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), ), label_ids: t.Optional( t.Union([t.String(), t.Array(t.String())]), ), }), }, ) .get("/:repo/issues/:number", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .leftJoin("users", "users.id", "issues.author_id") .select([ "issues.id", "issues.repo_id", "issues.author_id", "issues.number", "issues.title", "issues.body", "issues.status", "issues.created_at", "issues.updated_at", "issues.edited_at", "users.username as author_username", "users.avatar_version as author_avatar_version", ]) .where("issues.repo_id", "=", repo.id) .where("issues.number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); const bodyHtml = renderMarkdown(issue.body); const comments = await db .selectFrom("issue_comments") .leftJoin("users", "users.id", "issue_comments.author_id") .select([ "issue_comments.id", "issue_comments.issue_id", "issue_comments.author_id", "issue_comments.body", "issue_comments.created_at", "issue_comments.edited_at", "users.username as author_username", "users.avatar_version as author_avatar_version", ]) .where("issue_comments.issue_id", "=", issue.id) .orderBy("issue_comments.created_at", "asc") .execute(); const commentsWithHtml = comments.map((c) => ({ ...c, bodyHtml: renderMarkdown(c.body), })); // Reactions on the issue itself const allReactions = await db .selectFrom("issue_reactions") .selectAll() .where("issue_id", "=", issue.id) .execute(); const reactions = buildReactionCounts(allReactions, null, user?.id); const commentReactions = new Map( comments.map((c) => [ c.id, buildReactionCounts(allReactions, c.id, user?.id), ]), ); const issueLabels = await db .selectFrom("issue_labels") .innerJoin("labels", "labels.id", "issue_labels.label_id") .select([ "labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at", ]) .where("issue_labels.issue_id", "=", issue.id) .execute(); const repoLabels = await db .selectFrom("labels") .selectAll() .where("repo_id", "=", repo.id) .orderBy("name", "asc") .execute(); return html( , ); }) .post( "/:repo/issues/:number/comments", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "comment", COMMENT_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id", "status"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); // Only admin can comment on closed issues if (issue.status === "closed" && !user?.isAdmin) { return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); } const { body: commentBody } = body; if (!commentBody?.trim()) { return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); } await db.transaction().execute(async (trx) => { const now = new Date().toISOString(); await trx .insertInto("issue_comments") .values({ issue_id: issue.id, author_id: user?.id, body: commentBody.trim(), created_at: now, }) .execute(); await trx .updateTable("issues") .set({ updated_at: now }) .where("id", "=", issue.id) .execute(); }); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { body: t.Object({ body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), }), }, ) .post( "/:repo/issues/:number/react", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "reaction", REACTION_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const { emoji, comment_id } = body; if (!ALLOWED_REACTIONS.has(emoji)) { return new Response("Invalid emoji", { status: 400 }); } const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); const commentId = comment_id ? parseInt(comment_id, 10) : null; // One reaction per user per target: toggle off if same emoji, replace if different await db.transaction().execute(async (trx) => { const existing = await trx .selectFrom("issue_reactions") .select(["id", "emoji"]) .where("issue_id", "=", issue.id) .where((eb) => commentId !== null ? eb("comment_id", "=", commentId) : eb("comment_id", "is", null), ) .where("user_id", "=", user!.id) .executeTakeFirst(); if (existing) { if (existing.emoji === emoji) { await trx .deleteFrom("issue_reactions") .where("id", "=", existing.id) .execute(); } else { await trx .updateTable("issue_reactions") .set({ emoji }) .where("id", "=", existing.id) .execute(); } } else { await trx .insertInto("issue_reactions") .values({ issue_id: issue.id, comment_id: commentId, user_id: user!.id, emoji, }) .execute(); } }); return new Response(null, { status: 303, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { body: t.Object({ emoji: t.String(), comment_id: t.Optional(t.String()), }), }, ) .post("/:repo/issues/:number/complete", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select("id") .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); await db .updateTable("issues") .set({ status: "completed", updated_at: new Date().toISOString() }) .where("id", "=", issue.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }) .post("/:repo/issues/:number/close", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select("id") .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); await db .updateTable("issues") .set({ status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`, updated_at: new Date().toISOString(), }) .where("id", "=", issue.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }) .post("/:repo/issues/:number/delete", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id", "author_id"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); if (issue.author_id !== user?.id && !user?.isAdmin) return new Response("Forbidden", { status: 403 }); await db.deleteFrom("issues").where("id", "=", issue.id).execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues` }, }); }) .post( "/:repo/issues/:number/edit", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "comment", COMMENT_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id", "author_id", "status"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); if (issue.author_id !== user?.id && !user?.isAdmin) return new Response("Forbidden", { status: 403 }); if (issue.status !== "open" && !user?.isAdmin) return new Response("Forbidden", { status: 403 }); await db .updateTable("issues") .set({ title: body.title.trim(), body: body.edit_body ?? "", edited_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .where("id", "=", issue.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { body: t.Object({ title: t.String({ maxLength: config.MAX_TITLE_BYTES }), edit_body: t.Optional( t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), ), }), }, ) .post( "/:repo/issues/:number/comments/:id/edit", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); const deny = requireAuth(user); if (deny) return deny; const limited = rateLimit( request, server, user?.id ?? null, "comment", COMMENT_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const denyComment = await authorizeCommentEdit( "issue", params.id, repo.id, user, ); if (denyComment) return denyComment; const issueNum = parseInt(params.number, 10); await db .updateTable("issue_comments") .set({ body: body.edit_body.trim(), edited_at: new Date().toISOString(), }) .where("id", "=", params.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { params: t.Object({ repo: t.String(), number: t.String(), id: t.Numeric(), }), body: t.Object({ edit_body: t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), }), }, ) .post( "/:repo/issues/:number/labels/add", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); if (!user) return new Response("Unauthorized", { status: 401 }); const limited = rateLimit( request, server, user.id, "label-write", LABEL_WRITE_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user.isAdmin); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id", "author_id"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); const canManage = user.isAdmin || (repo.allow_user_labels === 1 && user.id === issue.author_id); if (!canManage) return new Response("Forbidden", { status: 403 }); const label = await db .selectFrom("labels") .select(["id"]) .where("id", "=", body.label_id) .where("repo_id", "=", repo.id) .executeTakeFirst(); if (!label) { return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); } await db .insertInto("issue_labels") .values({ issue_id: issue.id, label_id: label.id }) .onConflict((oc) => oc.doNothing()) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { params: t.Object({ repo: t.String(), number: t.String() }), body: t.Object({ label_id: t.Numeric() }), }, ) .post( "/:repo/issues/:number/labels/remove", async ({ params, body, cookie, request, server }) => { const user = await resolveSession(cookie.session.value); if (!user) return new Response("Unauthorized", { status: 401 }); const limited = rateLimit( request, server, user.id, "label-write", LABEL_WRITE_MAX_PER_MIN, RATE_WINDOW_MIN_MS, ); if (limited) return limited; const repo = await getRepo(params.repo, user.isAdmin); if (!repo) return new Response("Not found", { status: 404 }); const issueNum = parseInt(params.number, 10); const issue = await db .selectFrom("issues") .select(["id", "author_id"]) .where("repo_id", "=", repo.id) .where("number", "=", issueNum) .executeTakeFirst(); if (!issue) return new Response("Not found", { status: 404 }); const canManage = user.isAdmin || (repo.allow_user_labels === 1 && user.id === issue.author_id); if (!canManage) return new Response("Forbidden", { status: 403 }); await db .deleteFrom("issue_labels") .where("issue_id", "=", issue.id) .where("label_id", "=", body.label_id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${issueNum}` }, }); }, { params: t.Object({ repo: t.String(), number: t.String() }), body: t.Object({ label_id: t.Numeric() }), }, );