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.tsxBlame675 lines · 2 contributors
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
2228c49copilot-swe-agent[bot]208 aria-label="Discussion title"
1e162a8Claude209 style="padding: 8px;"
210 />
211 <select name="category" style="padding: 8px;">
212 {CATEGORIES.map((c) => (
213 <option value={c}>{c}</option>
214 ))}
215 </select>
216 <textarea
217 name="body"
218 rows={10}
219 placeholder="Write your post (markdown supported)"
220 style="padding: 8px; font-family: inherit;"
221 ></textarea>
222 <button type="submit" class="btn btn-primary">
223 Start discussion
224 </button>
225 </form>
226 </Layout>
227 );
228 }
229);
230
231// Create
232discussionRoutes.post(
233 "/:owner/:repo/discussions",
234 requireAuth,
235 async (c) => {
236 const { owner: ownerName, repo: repoName } = c.req.param();
237 const user = c.get("user")!;
238 const resolved = await resolveRepo(ownerName, repoName);
239 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
240
241 const form = await c.req.formData();
242 const title = (form.get("title") as string || "").trim();
243 const body = (form.get("body") as string || "").trim();
244 const categoryRaw = (form.get("category") as string || "general").trim();
245 const category = isValidCategory(categoryRaw) ? categoryRaw : "general";
246
247 if (!title) {
248 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
249 }
250
251 try {
252 const [row] = await db
253 .insert(discussions)
254 .values({
255 repositoryId: resolved.repo.id,
256 authorId: user.id,
257 category,
258 title,
259 body,
260 })
261 .returning({ number: discussions.number });
262 return c.redirect(
263 `/${ownerName}/${repoName}/discussions/${row.number}`
264 );
265 } catch {
266 return c.redirect(`/${ownerName}/${repoName}/discussions`);
267 }
268 }
269);
270
271// Detail
272discussionRoutes.get(
273 "/:owner/:repo/discussions/:number",
274 softAuth,
275 async (c) => {
276 const { owner: ownerName, repo: repoName } = c.req.param();
277 const user = c.get("user");
278 const numParam = Number(c.req.param("number"));
279 const resolved = await resolveRepo(ownerName, repoName);
280 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
281
282 let discussion: any = null;
283 let comments: any[] = [];
284 try {
285 const [row] = await db
286 .select({ d: discussions, author: { username: users.username } })
287 .from(discussions)
288 .innerJoin(users, eq(discussions.authorId, users.id))
289 .where(
290 and(
291 eq(discussions.repositoryId, resolved.repo.id),
292 eq(discussions.number, numParam)
293 )
294 )
295 .limit(1);
296 if (row) discussion = row;
297 if (discussion) {
298 comments = await db
299 .select({
300 c: discussionComments,
301 author: { username: users.username },
302 })
303 .from(discussionComments)
304 .innerJoin(users, eq(discussionComments.authorId, users.id))
305 .where(eq(discussionComments.discussionId, discussion.d.id))
306 .orderBy(discussionComments.createdAt);
307 }
308 } catch {
309 // leave nulls
310 }
311
312 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
313
314 const isOwner = user && user.id === resolved.repo.ownerId;
315 const isAuthor = user && user.id === discussion.d.authorId;
316 const canModerate = isOwner || isAuthor;
317
318 return c.html(
319 <Layout
320 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
321 user={user}
322 >
323 <RepoHeader owner={ownerName} repo={repoName} />
324 <div style="margin-top: 16px;">
325 <div style="display: flex; justify-content: space-between; align-items: center;">
326 <h1 style="margin: 0;">
327 {discussion.d.title}{" "}
328 <span style="color: var(--text-muted);">
329 #{discussion.d.number}
330 </span>
331 </h1>
332 <div style="display: flex; gap: 8px;">
333 <span class="badge">{discussion.d.category}</span>
334 {discussion.d.state === "closed" && (
335 <span class="badge">closed</span>
336 )}
337 {discussion.d.locked && <span class="badge">🔒 locked</span>}
338 {discussion.d.pinned && <span class="badge">📌 pinned</span>}
339 </div>
340 </div>
341 <div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">
342 Started by @{discussion.author.username}
343 </div>
344 </div>
345 <article class="comment" style="margin-top: 16px;">
346 <div
347 // biome-ignore lint: rendered server-side from trusted markdown
348 dangerouslySetInnerHTML={{
349 __html: renderMarkdown(discussion.d.body || ""),
350 }}
351 />
352 </article>
2228c49copilot-swe-agent[bot]353 <h2 style="margin-top: 32px;">{comments.length} Comments</h2>
1e162a8Claude354 {comments.map((com) => {
355 const isAnswer = com.c.id === discussion.d.answerCommentId;
356 return (
357 <article
358 class="comment"
359 style={`margin-top: 12px; ${isAnswer ? "border: 2px solid var(--green); padding: 12px;" : ""}`}
360 >
361 <div style="display: flex; justify-content: space-between;">
362 <div style="font-size: 13px; color: var(--text-muted);">
363 @{com.author.username}
364 {isAnswer && " · ✅ Answer"}
365 </div>
366 {isOwner &&
367 discussion.d.category === "q-and-a" &&
368 !isAnswer && (
369 <form
9e2c6dfClaude370 method="post"
1e162a8Claude371 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
372 style="display: inline;"
373 >
374 <button type="submit" class="btn">
375 Mark as answer
376 </button>
377 </form>
378 )}
379 </div>
380 <div
381 style="margin-top: 8px;"
382 dangerouslySetInnerHTML={{
383 __html: renderMarkdown(com.c.body || ""),
384 }}
385 />
386 </article>
387 );
388 })}
389 {user && !discussion.d.locked && discussion.d.state === "open" && (
390 <form
9e2c6dfClaude391 method="post"
1e162a8Claude392 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
393 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
394 >
395 <textarea
396 name="body"
397 rows={5}
398 placeholder="Add a comment (markdown supported)"
399 required
400 style="padding: 8px; font-family: inherit;"
401 ></textarea>
402 <button type="submit" class="btn btn-primary">
403 Comment
404 </button>
405 </form>
406 )}
407 {user && (
408 <div style="margin-top: 24px; display: flex; gap: 8px;">
409 {canModerate && (
410 <form
9e2c6dfClaude411 method="post"
1e162a8Claude412 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
413 style="display: inline;"
414 >
415 <button type="submit" class="btn">
416 {discussion.d.state === "open" ? "Close" : "Reopen"}
417 </button>
418 </form>
419 )}
420 {isOwner && (
421 <>
422 <form
9e2c6dfClaude423 method="post"
1e162a8Claude424 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
425 style="display: inline;"
426 >
427 <button type="submit" class="btn">
428 {discussion.d.locked ? "Unlock" : "Lock"}
429 </button>
430 </form>
431 <form
9e2c6dfClaude432 method="post"
1e162a8Claude433 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
434 style="display: inline;"
435 >
436 <button type="submit" class="btn">
437 {discussion.d.pinned ? "Unpin" : "Pin"}
438 </button>
439 </form>
440 </>
441 )}
442 </div>
443 )}
444 </Layout>
445 );
446 }
447);
448
449// Add comment
450discussionRoutes.post(
451 "/:owner/:repo/discussions/:number/comment",
452 requireAuth,
453 async (c) => {
454 const { owner: ownerName, repo: repoName } = c.req.param();
455 const user = c.get("user")!;
456 const numParam = Number(c.req.param("number"));
457 const resolved = await resolveRepo(ownerName, repoName);
458 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
459
460 const form = await c.req.formData();
461 const body = (form.get("body") as string || "").trim();
462 const parent = (form.get("parent_comment_id") as string) || null;
463 if (!body) {
464 return c.redirect(
465 `/${ownerName}/${repoName}/discussions/${numParam}`
466 );
467 }
468
469 try {
470 const [row] = await db
471 .select()
472 .from(discussions)
473 .where(
474 and(
475 eq(discussions.repositoryId, resolved.repo.id),
476 eq(discussions.number, numParam)
477 )
478 )
479 .limit(1);
480 if (!row || row.locked || row.state === "closed") {
481 return c.redirect(
482 `/${ownerName}/${repoName}/discussions/${numParam}`
483 );
484 }
485 await db.insert(discussionComments).values({
486 discussionId: row.id,
487 authorId: user.id,
488 body,
489 parentCommentId: parent || null,
490 });
491 await db
492 .update(discussions)
493 .set({ updatedAt: new Date() })
494 .where(eq(discussions.id, row.id));
495 } catch {
496 // swallow
497 }
498 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
499 }
500);
501
502// Toggle lock (owner)
503discussionRoutes.post(
504 "/:owner/:repo/discussions/:number/lock",
505 requireAuth,
506 async (c) => {
507 const { owner: ownerName, repo: repoName } = c.req.param();
508 const user = c.get("user")!;
509 const numParam = Number(c.req.param("number"));
510 const resolved = await resolveRepo(ownerName, repoName);
511 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
512 if (user.id !== resolved.repo.ownerId) {
513 return c.redirect(
514 `/${ownerName}/${repoName}/discussions/${numParam}`
515 );
516 }
517 try {
518 const [row] = await db
519 .select()
520 .from(discussions)
521 .where(
522 and(
523 eq(discussions.repositoryId, resolved.repo.id),
524 eq(discussions.number, numParam)
525 )
526 )
527 .limit(1);
528 if (row) {
529 await db
530 .update(discussions)
531 .set({ locked: !row.locked })
532 .where(eq(discussions.id, row.id));
533 }
534 } catch {
535 // swallow
536 }
537 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
538 }
539);
540
541// Toggle pin (owner)
542discussionRoutes.post(
543 "/:owner/:repo/discussions/:number/pin",
544 requireAuth,
545 async (c) => {
546 const { owner: ownerName, repo: repoName } = c.req.param();
547 const user = c.get("user")!;
548 const numParam = Number(c.req.param("number"));
549 const resolved = await resolveRepo(ownerName, repoName);
550 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
551 if (user.id !== resolved.repo.ownerId) {
552 return c.redirect(
553 `/${ownerName}/${repoName}/discussions/${numParam}`
554 );
555 }
556 try {
557 const [row] = await db
558 .select()
559 .from(discussions)
560 .where(
561 and(
562 eq(discussions.repositoryId, resolved.repo.id),
563 eq(discussions.number, numParam)
564 )
565 )
566 .limit(1);
567 if (row) {
568 await db
569 .update(discussions)
570 .set({ pinned: !row.pinned })
571 .where(eq(discussions.id, row.id));
572 }
573 } catch {
574 // swallow
575 }
576 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
577 }
578);
579
580// Mark answer (owner on q-and-a)
581discussionRoutes.post(
582 "/:owner/:repo/discussions/:number/answer/:commentId",
583 requireAuth,
584 async (c) => {
585 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
586 const user = c.get("user")!;
587 const numParam = Number(c.req.param("number"));
588 const resolved = await resolveRepo(ownerName, repoName);
589 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
590
591 try {
592 const [row] = await db
593 .select()
594 .from(discussions)
595 .where(
596 and(
597 eq(discussions.repositoryId, resolved.repo.id),
598 eq(discussions.number, numParam)
599 )
600 )
601 .limit(1);
602 if (!row) {
603 return c.redirect(`/${ownerName}/${repoName}/discussions`);
604 }
605 const isOwner = user.id === resolved.repo.ownerId;
606 const isAuthor = user.id === row.authorId;
607 if (!isOwner && !isAuthor) {
608 return c.redirect(
609 `/${ownerName}/${repoName}/discussions/${numParam}`
610 );
611 }
612 if (row.category !== "q-and-a") {
613 return c.text(
614 "Only q-and-a discussions can have answers",
615 400
616 );
617 }
618 await db
619 .update(discussions)
620 .set({ answerCommentId: commentId })
621 .where(eq(discussions.id, row.id));
622 await db
623 .update(discussionComments)
624 .set({ isAnswer: true })
625 .where(eq(discussionComments.id, commentId));
626 } catch {
627 // swallow
628 }
629 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
630 }
631);
632
633// Toggle close (owner or author)
634discussionRoutes.post(
635 "/:owner/:repo/discussions/:number/close",
636 requireAuth,
637 async (c) => {
638 const { owner: ownerName, repo: repoName } = c.req.param();
639 const user = c.get("user")!;
640 const numParam = Number(c.req.param("number"));
641 const resolved = await resolveRepo(ownerName, repoName);
642 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
643 try {
644 const [row] = await db
645 .select()
646 .from(discussions)
647 .where(
648 and(
649 eq(discussions.repositoryId, resolved.repo.id),
650 eq(discussions.number, numParam)
651 )
652 )
653 .limit(1);
654 if (!row) {
655 return c.redirect(`/${ownerName}/${repoName}/discussions`);
656 }
657 const isOwner = user.id === resolved.repo.ownerId;
658 const isAuthor = user.id === row.authorId;
659 if (!isOwner && !isAuthor) {
660 return c.redirect(
661 `/${ownerName}/${repoName}/discussions/${numParam}`
662 );
663 }
664 await db
665 .update(discussions)
666 .set({ state: row.state === "open" ? "closed" : "open" })
667 .where(eq(discussions.id, row.id));
668 } catch {
669 // swallow
670 }
671 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
672 }
673);
674
675export default discussionRoutes;