import { Elysia, t } from "elysia"; import { sql } from "kysely"; import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts"; import { db, getRepo, type LabelRow } from "../db/index.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); const page = Math.max(1, query.page ?? 1); // 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) => !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(({ exists, selectFrom }) => exists( selectFrom("issue_labels") .select("issue_labels.issue_id") .whereRef( "issue_labels.issue_id", "=", "issues.id", ) .where("issue_labels.label_id", "in", labelIds), ), ); } const allCounts = await countQuery.groupBy("issues.status").execute(); const counts: Record = Object.fromEntries( allCounts.map((r) => [r.status, Number(r.count)]), ); const totalPages = Math.max( 1, Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE), ); const safePage = Math.min(page, totalPages); const offset = (safePage - 1) * 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(({ exists, selectFrom }) => exists( selectFrom("issue_labels") .select("issue_labels.issue_id") .whereRef( "issue_labels.issue_id", "=", "issues.id", ) .where("issue_labels.label_id", "in", 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 }); return html( , ); }) .post( "/:repo/issues", async ({ params, body, 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 { title, body: issueBody } = body; if (!title?.trim()) { return html( , ); } 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(); 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, }) .execute(); return { number: issue_seq }; }); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/issues/${number}` }, }); }, { body: t.Object({ title: t.String(), body: t.Optional(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 }) => { 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", "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() }), }, ) .post( "/:repo/issues/:number/react", async ({ params, body, 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 { 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 }) => { 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", "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(), edit_body: t.Optional(t.String()), }), }, ) .post( "/:repo/issues/:number/comments/:id/edit", async ({ params, body, 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 comment = await db .selectFrom("issue_comments") .select(["id", "author_id", "issue_id"]) .where("id", "=", params.id) .executeTakeFirst(); if (!comment) return new Response("Not found", { status: 404 }); if (comment.author_id !== user?.id && !user?.isAdmin) return new Response("Forbidden", { status: 403 }); const parentIssue = await db .selectFrom("issues") .select("status") .where("id", "=", comment.issue_id) .executeTakeFirst(); if (parentIssue?.status !== "open" && !user?.isAdmin) return new Response("Forbidden", { status: 403 }); const issueNum = parseInt(params.number, 10); await db .updateTable("issue_comments") .set({ body: body.edit_body.trim(), edited_at: new Date().toISOString(), }) .where("id", "=", comment.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() }), }, ) .post( "/:repo/issues/:number/labels/add", async ({ params, body, 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 }); 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 }) => { 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 .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() }), }, );