Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

discussions.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

discussions.tsxBlame674 lines · 1 contributor
1e162a8Claude1/**
2 * Block E2 — Discussions: forum-style threaded conversations attached to a repo.
3 *
4 * Similar to GitHub Discussions: categorised, pinnable, answer-able threads
5 * that sit alongside issues but are conversational (Q&A, ideas, announcements).
6 *
7 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
8 * shell page or a redirect on any failure.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 discussions,
16 discussionComments,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader, RepoNav } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const CATEGORIES = [
27 "general",
28 "q-and-a",
29 "ideas",
30 "announcements",
31 "show-and-tell",
32] as const;
33
34export function isValidCategory(c: string): boolean {
35 return (CATEGORIES as readonly string[]).includes(c);
36}
37
38const discussionRoutes = new Hono<AuthEnv>();
39
40async function resolveRepo(ownerName: string, repoName: string) {
41 try {
42 const [owner] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, ownerName))
46 .limit(1);
47 if (!owner) return null;
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(
53 eq(repositories.ownerId, owner.id),
54 eq(repositories.name, repoName)
55 )
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60 } catch {
61 return null;
62 }
63}
64
65function notFound(user: any, label = "Not found") {
66 return (
67 <Layout title={label} user={user}>
68 <div class="empty-state">
69 <h2>{label}</h2>
70 </div>
71 </Layout>
72 );
73}
74
75// List
76discussionRoutes.get("/:owner/:repo/discussions", softAuth, async (c) => {
77 const { owner: ownerName, repo: repoName } = c.req.param();
78 const user = c.get("user");
79 const category = c.req.query("category") || "";
80
81 const resolved = await resolveRepo(ownerName, repoName);
82 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
83 const { repo } = resolved;
84
85 let rows: any[] = [];
86 try {
87 const whereClause =
88 category && isValidCategory(category)
89 ? and(
90 eq(discussions.repositoryId, repo.id),
91 eq(discussions.category, category)
92 )
93 : eq(discussions.repositoryId, repo.id);
94 rows = await db
95 .select({
96 d: discussions,
97 author: { username: users.username },
98 commentCount: sql<number>`(SELECT count(*) FROM discussion_comments WHERE discussion_id = ${discussions.id})`,
99 })
100 .from(discussions)
101 .innerJoin(users, eq(discussions.authorId, users.id))
102 .where(whereClause)
103 .orderBy(desc(discussions.pinned), desc(discussions.updatedAt));
104 } catch {
105 rows = [];
106 }
107
108 return c.html(
109 <Layout title={`Discussions — ${ownerName}/${repoName}`} user={user}>
110 <RepoHeader owner={ownerName} repo={repoName} />
111 <div class="repo-nav">
112 <a href={`/${ownerName}/${repoName}`}>Code</a>
113 <a href={`/${ownerName}/${repoName}/issues`}>Issues</a>
114 <a href={`/${ownerName}/${repoName}/pulls`}>Pull Requests</a>
115 <a href={`/${ownerName}/${repoName}/discussions`} class="active">
116 Discussions
117 </a>
118 </div>
119 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
120 <div style="display: flex; gap: 8px;">
121 <a
122 href={`/${ownerName}/${repoName}/discussions`}
123 class={!category ? "active" : ""}
124 style="padding: 4px 10px; border-radius: 6px;"
125 >
126 All
127 </a>
128 {CATEGORIES.map((cat) => (
129 <a
130 href={`/${ownerName}/${repoName}/discussions?category=${cat}`}
131 class={cat === category ? "active" : ""}
132 style="padding: 4px 10px; border-radius: 6px;"
133 >
134 {cat}
135 </a>
136 ))}
137 </div>
138 {user && (
139 <a
140 href={`/${ownerName}/${repoName}/discussions/new`}
141 class="btn btn-primary"
142 >
143 New discussion
144 </a>
145 )}
146 </div>
147 {rows.length === 0 ? (
148 <div class="empty-state">
149 <p>No discussions yet.</p>
150 </div>
151 ) : (
152 <table class="file-table">
153 <tbody>
154 {rows.map((r) => (
155 <tr>
156 <td style="width: 40px; color: var(--text-muted);">
157 #{r.d.number}
158 </td>
159 <td>
160 {r.d.pinned && <span class="badge">📌 Pinned</span>}{" "}
161 <a
162 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
163 >
164 <strong>{r.d.title}</strong>
165 </a>{" "}
166 <span class="badge">{r.d.category}</span>
167 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
168 by @{r.author.username}
169 {r.d.state === "closed" ? " · closed" : ""}
170 {r.d.locked ? " · locked" : ""}
171 </div>
172 </td>
173 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
174 💬 {r.commentCount}
175 </td>
176 </tr>
177 ))}
178 </tbody>
179 </table>
180 )}
181 </Layout>
182 );
183});
184
185// New discussion form
186discussionRoutes.get(
187 "/:owner/:repo/discussions/new",
188 requireAuth,
189 async (c) => {
190 const { owner: ownerName, repo: repoName } = c.req.param();
191 const user = c.get("user");
192 const resolved = await resolveRepo(ownerName, repoName);
193 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
194 return c.html(
195 <Layout title="New discussion" user={user}>
196 <RepoHeader owner={ownerName} repo={repoName} />
197 <h2 style="margin-top: 20px;">Start a discussion</h2>
198 <form
9e2c6dfClaude199 method="post"
1e162a8Claude200 action={`/${ownerName}/${repoName}/discussions`}
201 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
202 >
203 <input
204 type="text"
205 name="title"
206 placeholder="Title"
207 required
208 style="padding: 8px;"
209 />
210 <select name="category" style="padding: 8px;">
211 {CATEGORIES.map((c) => (
212 <option value={c}>{c}</option>
213 ))}
214 </select>
215 <textarea
216 name="body"
217 rows={10}
218 placeholder="Write your post (markdown supported)"
219 style="padding: 8px; font-family: inherit;"
220 ></textarea>
221 <button type="submit" class="btn btn-primary">
222 Start discussion
223 </button>
224 </form>
225 </Layout>
226 );
227 }
228);
229
230// Create
231discussionRoutes.post(
232 "/:owner/:repo/discussions",
233 requireAuth,
234 async (c) => {
235 const { owner: ownerName, repo: repoName } = c.req.param();
236 const user = c.get("user")!;
237 const resolved = await resolveRepo(ownerName, repoName);
238 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
239
240 const form = await c.req.formData();
241 const title = (form.get("title") as string || "").trim();
242 const body = (form.get("body") as string || "").trim();
243 const categoryRaw = (form.get("category") as string || "general").trim();
244 const category = isValidCategory(categoryRaw) ? categoryRaw : "general";
245
246 if (!title) {
247 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
248 }
249
250 try {
251 const [row] = await db
252 .insert(discussions)
253 .values({
254 repositoryId: resolved.repo.id,
255 authorId: user.id,
256 category,
257 title,
258 body,
259 })
260 .returning({ number: discussions.number });
261 return c.redirect(
262 `/${ownerName}/${repoName}/discussions/${row.number}`
263 );
264 } catch {
265 return c.redirect(`/${ownerName}/${repoName}/discussions`);
266 }
267 }
268);
269
270// Detail
271discussionRoutes.get(
272 "/:owner/:repo/discussions/:number",
273 softAuth,
274 async (c) => {
275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const user = c.get("user");
277 const numParam = Number(c.req.param("number"));
278 const resolved = await resolveRepo(ownerName, repoName);
279 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
280
281 let discussion: any = null;
282 let comments: any[] = [];
283 try {
284 const [row] = await db
285 .select({ d: discussions, author: { username: users.username } })
286 .from(discussions)
287 .innerJoin(users, eq(discussions.authorId, users.id))
288 .where(
289 and(
290 eq(discussions.repositoryId, resolved.repo.id),
291 eq(discussions.number, numParam)
292 )
293 )
294 .limit(1);
295 if (row) discussion = row;
296 if (discussion) {
297 comments = await db
298 .select({
299 c: discussionComments,
300 author: { username: users.username },
301 })
302 .from(discussionComments)
303 .innerJoin(users, eq(discussionComments.authorId, users.id))
304 .where(eq(discussionComments.discussionId, discussion.d.id))
305 .orderBy(discussionComments.createdAt);
306 }
307 } catch {
308 // leave nulls
309 }
310
311 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
312
313 const isOwner = user && user.id === resolved.repo.ownerId;
314 const isAuthor = user && user.id === discussion.d.authorId;
315 const canModerate = isOwner || isAuthor;
316
317 return c.html(
318 <Layout
319 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
320 user={user}
321 >
322 <RepoHeader owner={ownerName} repo={repoName} />
323 <div style="margin-top: 16px;">
324 <div style="display: flex; justify-content: space-between; align-items: center;">
325 <h1 style="margin: 0;">
326 {discussion.d.title}{" "}
327 <span style="color: var(--text-muted);">
328 #{discussion.d.number}
329 </span>
330 </h1>
331 <div style="display: flex; gap: 8px;">
332 <span class="badge">{discussion.d.category}</span>
333 {discussion.d.state === "closed" && (
334 <span class="badge">closed</span>
335 )}
336 {discussion.d.locked && <span class="badge">🔒 locked</span>}
337 {discussion.d.pinned && <span class="badge">📌 pinned</span>}
338 </div>
339 </div>
340 <div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">
341 Started by @{discussion.author.username}
342 </div>
343 </div>
344 <article class="comment" style="margin-top: 16px;">
345 <div
346 // biome-ignore lint: rendered server-side from trusted markdown
347 dangerouslySetInnerHTML={{
348 __html: renderMarkdown(discussion.d.body || ""),
349 }}
350 />
351 </article>
352 <h3 style="margin-top: 32px;">{comments.length} Comments</h3>
353 {comments.map((com) => {
354 const isAnswer = com.c.id === discussion.d.answerCommentId;
355 return (
356 <article
357 class="comment"
358 style={`margin-top: 12px; ${isAnswer ? "border: 2px solid var(--green); padding: 12px;" : ""}`}
359 >
360 <div style="display: flex; justify-content: space-between;">
361 <div style="font-size: 13px; color: var(--text-muted);">
362 @{com.author.username}
363 {isAnswer && " · ✅ Answer"}
364 </div>
365 {isOwner &&
366 discussion.d.category === "q-and-a" &&
367 !isAnswer && (
368 <form
9e2c6dfClaude369 method="post"
1e162a8Claude370 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
371 style="display: inline;"
372 >
373 <button type="submit" class="btn">
374 Mark as answer
375 </button>
376 </form>
377 )}
378 </div>
379 <div
380 style="margin-top: 8px;"
381 dangerouslySetInnerHTML={{
382 __html: renderMarkdown(com.c.body || ""),
383 }}
384 />
385 </article>
386 );
387 })}
388 {user && !discussion.d.locked && discussion.d.state === "open" && (
389 <form
9e2c6dfClaude390 method="post"
1e162a8Claude391 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
392 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
393 >
394 <textarea
395 name="body"
396 rows={5}
397 placeholder="Add a comment (markdown supported)"
398 required
399 style="padding: 8px; font-family: inherit;"
400 ></textarea>
401 <button type="submit" class="btn btn-primary">
402 Comment
403 </button>
404 </form>
405 )}
406 {user && (
407 <div style="margin-top: 24px; display: flex; gap: 8px;">
408 {canModerate && (
409 <form
9e2c6dfClaude410 method="post"
1e162a8Claude411 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
412 style="display: inline;"
413 >
414 <button type="submit" class="btn">
415 {discussion.d.state === "open" ? "Close" : "Reopen"}
416 </button>
417 </form>
418 )}
419 {isOwner && (
420 <>
421 <form
9e2c6dfClaude422 method="post"
1e162a8Claude423 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
424 style="display: inline;"
425 >
426 <button type="submit" class="btn">
427 {discussion.d.locked ? "Unlock" : "Lock"}
428 </button>
429 </form>
430 <form
9e2c6dfClaude431 method="post"
1e162a8Claude432 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
433 style="display: inline;"
434 >
435 <button type="submit" class="btn">
436 {discussion.d.pinned ? "Unpin" : "Pin"}
437 </button>
438 </form>
439 </>
440 )}
441 </div>
442 )}
443 </Layout>
444 );
445 }
446);
447
448// Add comment
449discussionRoutes.post(
450 "/:owner/:repo/discussions/:number/comment",
451 requireAuth,
452 async (c) => {
453 const { owner: ownerName, repo: repoName } = c.req.param();
454 const user = c.get("user")!;
455 const numParam = Number(c.req.param("number"));
456 const resolved = await resolveRepo(ownerName, repoName);
457 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
458
459 const form = await c.req.formData();
460 const body = (form.get("body") as string || "").trim();
461 const parent = (form.get("parent_comment_id") as string) || null;
462 if (!body) {
463 return c.redirect(
464 `/${ownerName}/${repoName}/discussions/${numParam}`
465 );
466 }
467
468 try {
469 const [row] = await db
470 .select()
471 .from(discussions)
472 .where(
473 and(
474 eq(discussions.repositoryId, resolved.repo.id),
475 eq(discussions.number, numParam)
476 )
477 )
478 .limit(1);
479 if (!row || row.locked || row.state === "closed") {
480 return c.redirect(
481 `/${ownerName}/${repoName}/discussions/${numParam}`
482 );
483 }
484 await db.insert(discussionComments).values({
485 discussionId: row.id,
486 authorId: user.id,
487 body,
488 parentCommentId: parent || null,
489 });
490 await db
491 .update(discussions)
492 .set({ updatedAt: new Date() })
493 .where(eq(discussions.id, row.id));
494 } catch {
495 // swallow
496 }
497 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
498 }
499);
500
501// Toggle lock (owner)
502discussionRoutes.post(
503 "/:owner/:repo/discussions/:number/lock",
504 requireAuth,
505 async (c) => {
506 const { owner: ownerName, repo: repoName } = c.req.param();
507 const user = c.get("user")!;
508 const numParam = Number(c.req.param("number"));
509 const resolved = await resolveRepo(ownerName, repoName);
510 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
511 if (user.id !== resolved.repo.ownerId) {
512 return c.redirect(
513 `/${ownerName}/${repoName}/discussions/${numParam}`
514 );
515 }
516 try {
517 const [row] = await db
518 .select()
519 .from(discussions)
520 .where(
521 and(
522 eq(discussions.repositoryId, resolved.repo.id),
523 eq(discussions.number, numParam)
524 )
525 )
526 .limit(1);
527 if (row) {
528 await db
529 .update(discussions)
530 .set({ locked: !row.locked })
531 .where(eq(discussions.id, row.id));
532 }
533 } catch {
534 // swallow
535 }
536 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
537 }
538);
539
540// Toggle pin (owner)
541discussionRoutes.post(
542 "/:owner/:repo/discussions/:number/pin",
543 requireAuth,
544 async (c) => {
545 const { owner: ownerName, repo: repoName } = c.req.param();
546 const user = c.get("user")!;
547 const numParam = Number(c.req.param("number"));
548 const resolved = await resolveRepo(ownerName, repoName);
549 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
550 if (user.id !== resolved.repo.ownerId) {
551 return c.redirect(
552 `/${ownerName}/${repoName}/discussions/${numParam}`
553 );
554 }
555 try {
556 const [row] = await db
557 .select()
558 .from(discussions)
559 .where(
560 and(
561 eq(discussions.repositoryId, resolved.repo.id),
562 eq(discussions.number, numParam)
563 )
564 )
565 .limit(1);
566 if (row) {
567 await db
568 .update(discussions)
569 .set({ pinned: !row.pinned })
570 .where(eq(discussions.id, row.id));
571 }
572 } catch {
573 // swallow
574 }
575 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
576 }
577);
578
579// Mark answer (owner on q-and-a)
580discussionRoutes.post(
581 "/:owner/:repo/discussions/:number/answer/:commentId",
582 requireAuth,
583 async (c) => {
584 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
585 const user = c.get("user")!;
586 const numParam = Number(c.req.param("number"));
587 const resolved = await resolveRepo(ownerName, repoName);
588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
589
590 try {
591 const [row] = await db
592 .select()
593 .from(discussions)
594 .where(
595 and(
596 eq(discussions.repositoryId, resolved.repo.id),
597 eq(discussions.number, numParam)
598 )
599 )
600 .limit(1);
601 if (!row) {
602 return c.redirect(`/${ownerName}/${repoName}/discussions`);
603 }
604 const isOwner = user.id === resolved.repo.ownerId;
605 const isAuthor = user.id === row.authorId;
606 if (!isOwner && !isAuthor) {
607 return c.redirect(
608 `/${ownerName}/${repoName}/discussions/${numParam}`
609 );
610 }
611 if (row.category !== "q-and-a") {
612 return c.text(
613 "Only q-and-a discussions can have answers",
614 400
615 );
616 }
617 await db
618 .update(discussions)
619 .set({ answerCommentId: commentId })
620 .where(eq(discussions.id, row.id));
621 await db
622 .update(discussionComments)
623 .set({ isAnswer: true })
624 .where(eq(discussionComments.id, commentId));
625 } catch {
626 // swallow
627 }
628 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
629 }
630);
631
632// Toggle close (owner or author)
633discussionRoutes.post(
634 "/:owner/:repo/discussions/:number/close",
635 requireAuth,
636 async (c) => {
637 const { owner: ownerName, repo: repoName } = c.req.param();
638 const user = c.get("user")!;
639 const numParam = Number(c.req.param("number"));
640 const resolved = await resolveRepo(ownerName, repoName);
641 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
642 try {
643 const [row] = await db
644 .select()
645 .from(discussions)
646 .where(
647 and(
648 eq(discussions.repositoryId, resolved.repo.id),
649 eq(discussions.number, numParam)
650 )
651 )
652 .limit(1);
653 if (!row) {
654 return c.redirect(`/${ownerName}/${repoName}/discussions`);
655 }
656 const isOwner = user.id === resolved.repo.ownerId;
657 const isAuthor = user.id === row.authorId;
658 if (!isOwner && !isAuthor) {
659 return c.redirect(
660 `/${ownerName}/${repoName}/discussions/${numParam}`
661 );
662 }
663 await db
664 .update(discussions)
665 .set({ state: row.state === "open" ? "closed" : "open" })
666 .where(eq(discussions.id, row.id));
667 } catch {
668 // swallow
669 }
670 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
671 }
672);
673
674export default discussionRoutes;