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.tsxBlame1098 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 *
f0b5874Claude7 * 2026 polish: gradient-hairline hero + radial orb + thread cards with
8 * author chip + reply count + recent timestamp + category chip. Every class
9 * prefixed `.disc-` so this surface doesn't bleed into the rest of the repo
10 * polish. All data fetches, queries, and POST handlers preserved exactly.
11 *
1e162a8Claude12 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
13 * shell page or a redirect on any failure.
14 */
15
16import { Hono } from "hono";
17import { and, eq, desc, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 discussions,
21 discussionComments,
22 repositories,
23 users,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { renderMarkdown } from "../lib/markdown";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30
31const CATEGORIES = [
32 "general",
33 "q-and-a",
34 "ideas",
35 "announcements",
36 "show-and-tell",
37] as const;
38
39export function isValidCategory(c: string): boolean {
40 return (CATEGORIES as readonly string[]).includes(c);
41}
42
43const discussionRoutes = new Hono<AuthEnv>();
44
f0b5874Claude45/* ─────────────────────────────────────────────────────────────────────────
46 * Scoped CSS — every class prefixed `.disc-` so this surface can't bleed
47 * into the wider repo polish. Mirrors the gradient-hairline hero + radial
48 * orb + card patterns from `insights.tsx` and `error-page.tsx`.
49 * ───────────────────────────────────────────────────────────────────── */
50const styles = `
51 .disc-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
52
53 .disc-hero {
54 position: relative;
55 margin-bottom: var(--space-5);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .disc-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
68 opacity: 0.75;
69 pointer-events: none;
70 }
71 .disc-hero-orb {
72 position: absolute;
73 inset: -30% -15% auto auto;
74 width: 460px; height: 460px;
75 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
76 filter: blur(80px);
77 opacity: 0.75;
78 pointer-events: none;
79 z-index: 0;
80 }
81 .disc-hero-inner {
82 position: relative;
83 z-index: 1;
84 display: flex;
85 align-items: flex-start;
86 justify-content: space-between;
87 gap: var(--space-4);
88 flex-wrap: wrap;
89 }
90 .disc-hero-text { max-width: 720px; }
91 .disc-eyebrow {
92 display: inline-flex;
93 align-items: center;
94 gap: 8px;
95 text-transform: uppercase;
96 font-family: var(--font-mono);
97 font-size: 11px;
98 letter-spacing: 0.18em;
99 color: var(--text-muted);
100 font-weight: 600;
101 margin-bottom: 14px;
102 }
103 .disc-eyebrow-dot {
104 width: 8px; height: 8px;
105 border-radius: 9999px;
106 background: linear-gradient(135deg, #8c6dff, #36c5d6);
107 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
108 }
109 .disc-title {
110 font-family: var(--font-display);
111 font-size: clamp(28px, 4vw, 40px);
112 font-weight: 800;
113 letter-spacing: -0.028em;
114 line-height: 1.05;
115 margin: 0 0 var(--space-2);
116 color: var(--text-strong);
117 }
118 .disc-title-grad {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .disc-sub {
126 font-size: 15px;
127 color: var(--text-muted);
128 margin: 0;
129 line-height: 1.55;
130 }
131
132 /* Primary CTA — gradient pill that matches the hero stripe. */
133 .disc-cta {
134 display: inline-flex;
135 align-items: center;
136 justify-content: center;
137 padding: 10px 18px;
138 border-radius: 10px;
139 font-size: 14px;
140 font-weight: 600;
141 text-decoration: none;
142 border: 1px solid transparent;
143 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
144 color: #fff;
145 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
146 transition: transform 120ms ease, box-shadow 120ms ease;
147 white-space: nowrap;
148 }
149 .disc-cta:hover {
150 transform: translateY(-1px);
151 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
152 color: #fff;
153 text-decoration: none;
154 }
155
156 /* Category filter chips */
157 .disc-filters {
158 display: flex;
159 flex-wrap: wrap;
160 gap: 8px;
161 margin: 0 0 var(--space-4);
162 }
163 .disc-chip {
164 display: inline-flex;
165 align-items: center;
166 gap: 6px;
167 padding: 6px 12px;
168 border-radius: 9999px;
169 border: 1px solid var(--border);
170 background: var(--bg-elevated);
171 color: var(--text-muted);
172 font-size: 12.5px;
173 font-weight: 600;
174 text-decoration: none;
175 text-transform: lowercase;
176 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
177 }
178 .disc-chip:hover { border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
179 .disc-chip.is-active {
180 color: #fff;
181 background: linear-gradient(135deg, rgba(140,109,255,0.85), rgba(54,197,214,0.85));
182 border-color: rgba(140,109,255,0.55);
183 }
184
185 /* Thread card list */
186 .disc-list {
187 display: flex;
188 flex-direction: column;
189 gap: var(--space-2);
190 margin-bottom: var(--space-5);
191 }
192 .disc-card {
193 position: relative;
194 background: var(--bg-elevated);
195 border: 1px solid var(--border);
196 border-radius: 12px;
197 padding: var(--space-3) var(--space-4);
198 transition: border-color 120ms ease, transform 120ms ease;
199 }
200 .disc-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
201 .disc-card.is-pinned { border-color: rgba(140,109,255,0.32); }
202
203 .disc-card-row {
204 display: flex;
205 gap: 14px;
206 align-items: flex-start;
207 }
208 .disc-avatar {
209 flex: none;
210 width: 36px; height: 36px;
211 border-radius: 9999px;
212 background: linear-gradient(135deg, #8c6dff, #36c5d6);
213 color: #fff;
214 display: flex;
215 align-items: center;
216 justify-content: center;
217 font-family: var(--font-display);
218 font-size: 14px;
219 font-weight: 700;
220 text-transform: uppercase;
221 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
222 }
223 .disc-card-main { flex: 1; min-width: 0; }
224 .disc-card-title {
225 font-family: var(--font-display);
226 font-size: 15px;
227 font-weight: 700;
228 color: var(--text-strong);
229 letter-spacing: -0.005em;
230 line-height: 1.3;
231 margin: 0 0 4px;
232 word-break: break-word;
233 }
234 .disc-card-title a { color: inherit; text-decoration: none; }
235 .disc-card-title a:hover { color: var(--accent); }
236 .disc-card-sub {
237 font-size: 12.5px;
238 color: var(--text-muted);
239 line-height: 1.5;
240 margin: 0;
241 display: flex;
242 flex-wrap: wrap;
243 gap: 6px 10px;
244 align-items: center;
245 }
246 .disc-card-sub a { color: var(--text); text-decoration: none; }
247 .disc-card-sub a:hover { color: var(--text-strong); }
248 .disc-num {
249 font-family: var(--font-mono);
250 font-size: 12px;
251 color: var(--text-muted);
252 }
253
254 .disc-tag {
255 display: inline-flex;
256 align-items: center;
257 gap: 4px;
258 padding: 1px 8px;
259 border-radius: 9999px;
260 font-size: 10.5px;
261 font-weight: 700;
262 letter-spacing: 0.06em;
263 text-transform: uppercase;
264 background: rgba(140,109,255,0.10);
265 color: #b69dff;
266 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
267 }
268 .disc-tag.is-pinned { background: rgba(252,211,77,0.10); color: #fcd34d; box-shadow: inset 0 0 0 1px rgba(252,211,77,0.32); }
269 .disc-tag.is-closed { background: rgba(248,113,113,0.10); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
270 .disc-tag.is-locked { background: rgba(148,163,184,0.10); color: #cbd5e1; box-shadow: inset 0 0 0 1px rgba(148,163,184,0.32); }
271
272 .disc-card-meta {
273 flex: none;
274 text-align: right;
275 font-size: 12px;
276 color: var(--text-muted);
277 font-variant-numeric: tabular-nums;
278 display: flex;
279 flex-direction: column;
280 align-items: flex-end;
281 gap: 4px;
282 min-width: 80px;
283 }
284 .disc-card-meta .replies {
285 font-family: var(--font-display);
286 font-size: 16px;
287 font-weight: 700;
288 color: var(--text-strong);
289 line-height: 1;
290 }
291 .disc-card-meta .replies-label {
292 font-size: 10.5px;
293 text-transform: uppercase;
294 letter-spacing: 0.12em;
295 font-weight: 700;
296 }
297
298 /* Empty state — dashed orb card */
299 .disc-empty {
300 position: relative;
301 overflow: hidden;
302 text-align: center;
303 padding: var(--space-6) var(--space-4);
304 border: 1px dashed var(--border-strong, var(--border));
305 border-radius: 16px;
306 background: rgba(255,255,255,0.012);
307 color: var(--text-muted);
308 margin-bottom: var(--space-5);
309 }
310 .disc-empty::before {
311 content: '';
312 position: absolute;
313 inset: -40% -20% auto auto;
314 width: 320px; height: 320px;
315 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
316 filter: blur(60px);
317 pointer-events: none;
318 }
319 .disc-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
320 .disc-empty strong {
321 display: block;
322 font-family: var(--font-display);
323 font-size: 18px;
324 font-weight: 700;
325 color: var(--text-strong);
326 margin: 0;
327 }
328 .disc-empty p { font-size: 13px; margin: 0; max-width: 420px; }
329
330 /* New-discussion form polish (preserves form action + field names) */
331 .disc-form-card {
332 background: var(--bg-elevated);
333 border: 1px solid var(--border);
334 border-radius: 14px;
335 padding: var(--space-4) var(--space-5);
336 margin-top: var(--space-4);
337 }
338 .disc-form { display: flex; flex-direction: column; gap: 12px; }
339 .disc-input,
340 .disc-textarea,
341 .disc-select {
342 width: 100%;
343 padding: 10px 12px;
344 font-size: 14px;
345 color: var(--text);
346 background: var(--bg);
347 border: 1px solid var(--border-strong);
348 border-radius: 10px;
349 outline: none;
350 font-family: inherit;
351 box-sizing: border-box;
352 transition: border-color 120ms ease, box-shadow 120ms ease;
353 }
354 .disc-textarea { font-family: var(--font-mono); font-size: 13px; line-height: 1.55; }
355 .disc-input:focus,
356 .disc-textarea:focus,
357 .disc-select:focus {
358 border-color: var(--border-focus, rgba(140,109,255,0.55));
359 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
360 }
361`;
362
363function relTime(d: Date | string | null | undefined): string {
364 if (!d) return "";
365 const t = typeof d === "string" ? new Date(d).getTime() : d.getTime();
366 if (!Number.isFinite(t)) return "";
367 const diff = (Date.now() - t) / 1000;
368 if (diff < 60) return "just now";
369 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
370 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
371 if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
372 if (diff < 86400 * 30) return `${Math.floor(diff / (86400 * 7))}w ago`;
373 return new Date(t).toLocaleDateString();
374}
375
376function initials(name: string): string {
377 if (!name) return "?";
378 return name.slice(0, 2);
379}
380
1e162a8Claude381async function resolveRepo(ownerName: string, repoName: string) {
382 try {
383 const [owner] = await db
384 .select()
385 .from(users)
386 .where(eq(users.username, ownerName))
387 .limit(1);
388 if (!owner) return null;
389 const [repo] = await db
390 .select()
391 .from(repositories)
392 .where(
393 and(
394 eq(repositories.ownerId, owner.id),
395 eq(repositories.name, repoName)
396 )
397 )
398 .limit(1);
399 if (!repo) return null;
400 return { owner, repo };
401 } catch {
402 return null;
403 }
404}
405
406function notFound(user: any, label = "Not found") {
407 return (
408 <Layout title={label} user={user}>
409 <div class="empty-state">
410 <h2>{label}</h2>
411 </div>
412 </Layout>
413 );
414}
415
416// List
417discussionRoutes.get("/:owner/:repo/discussions", softAuth, async (c) => {
418 const { owner: ownerName, repo: repoName } = c.req.param();
419 const user = c.get("user");
420 const category = c.req.query("category") || "";
421
422 const resolved = await resolveRepo(ownerName, repoName);
423 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
424 const { repo } = resolved;
425
426 let rows: any[] = [];
427 try {
428 const whereClause =
429 category && isValidCategory(category)
430 ? and(
431 eq(discussions.repositoryId, repo.id),
432 eq(discussions.category, category)
433 )
434 : eq(discussions.repositoryId, repo.id);
435 rows = await db
436 .select({
437 d: discussions,
438 author: { username: users.username },
439 commentCount: sql<number>`(SELECT count(*) FROM discussion_comments WHERE discussion_id = ${discussions.id})`,
440 })
441 .from(discussions)
442 .innerJoin(users, eq(discussions.authorId, users.id))
443 .where(whereClause)
444 .orderBy(desc(discussions.pinned), desc(discussions.updatedAt));
445 } catch {
446 rows = [];
447 }
448
449 return c.html(
450 <Layout title={`Discussions — ${ownerName}/${repoName}`} user={user}>
451 <RepoHeader owner={ownerName} repo={repoName} />
452 <div class="repo-nav">
453 <a href={`/${ownerName}/${repoName}`}>Code</a>
454 <a href={`/${ownerName}/${repoName}/issues`}>Issues</a>
455 <a href={`/${ownerName}/${repoName}/pulls`}>Pull Requests</a>
456 <a href={`/${ownerName}/${repoName}/discussions`} class="active">
457 Discussions
458 </a>
459 </div>
f0b5874Claude460
461 <div class="disc-wrap">
462 <section class="disc-hero">
463 <div class="disc-hero-orb" aria-hidden="true" />
464 <div class="disc-hero-inner">
465 <div class="disc-hero-text">
466 <div class="disc-eyebrow">
467 <span class="disc-eyebrow-dot" aria-hidden="true" />
468 Discussions · {ownerName}/{repoName}
469 </div>
470 <h2 class="disc-title">
471 <span class="disc-title-grad">Talk it out.</span>
472 </h2>
473 <p class="disc-sub">
474 Q&amp;A, ideas, announcements, and show-and-tell — the
475 conversational space alongside issues and PRs.
476 </p>
477 </div>
478 {user && (
479 <a
480 href={`/${ownerName}/${repoName}/discussions/new`}
481 class="disc-cta"
482 >
483 + New discussion
484 </a>
485 )}
486 </div>
487 </section>
488
489 <div class="disc-filters" aria-label="Filter by category">
1e162a8Claude490 <a
491 href={`/${ownerName}/${repoName}/discussions`}
f0b5874Claude492 class={"disc-chip" + (!category ? " is-active" : "")}
1e162a8Claude493 >
f0b5874Claude494 all
1e162a8Claude495 </a>
496 {CATEGORIES.map((cat) => (
497 <a
498 href={`/${ownerName}/${repoName}/discussions?category=${cat}`}
f0b5874Claude499 class={"disc-chip" + (cat === category ? " is-active" : "")}
1e162a8Claude500 >
501 {cat}
502 </a>
503 ))}
504 </div>
f0b5874Claude505
506 {rows.length === 0 ? (
507 <div class="disc-empty">
508 <div class="disc-empty-inner">
509 <strong>No discussions yet</strong>
510 <p>
511 Start a thread to ask a question, float an idea, or share an
512 announcement with the community.
513 </p>
514 {user && (
515 <a
516 href={`/${ownerName}/${repoName}/discussions/new`}
517 class="disc-cta"
518 style="margin-top:6px"
519 >
520 + Start a discussion
521 </a>
522 )}
523 </div>
524 </div>
525 ) : (
526 <div class="disc-list">
527 {rows.map((r) => {
528 const replies = Number(r.commentCount || 0);
529 const updated = r.d.updatedAt || r.d.createdAt;
530 return (
531 <article
532 class={"disc-card" + (r.d.pinned ? " is-pinned" : "")}
533 >
534 <div class="disc-card-row">
535 <div
536 class="disc-avatar"
537 aria-label={`@${r.author.username}`}
538 >
539 {initials(r.author.username)}
540 </div>
541 <div class="disc-card-main">
542 <h3 class="disc-card-title">
543 <a
544 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
545 >
546 {r.d.title}
547 </a>
548 </h3>
549 <div class="disc-card-sub">
550 <span class="disc-num">#{r.d.number}</span>
551 <span>by @{r.author.username}</span>
552 {updated && <span>· active {relTime(updated)}</span>}
553 <span class="disc-tag">{r.d.category}</span>
554 {r.d.pinned && (
555 <span class="disc-tag is-pinned">pinned</span>
556 )}
557 {r.d.state === "closed" && (
558 <span class="disc-tag is-closed">closed</span>
559 )}
560 {r.d.locked && (
561 <span class="disc-tag is-locked">locked</span>
562 )}
563 </div>
564 </div>
565 <div class="disc-card-meta">
566 <span class="replies">{replies}</span>
567 <span class="replies-label">
568 {replies === 1 ? "reply" : "replies"}
569 </span>
570 </div>
571 </div>
572 </article>
573 );
574 })}
575 </div>
1e162a8Claude576 )}
577 </div>
f0b5874Claude578 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude579 </Layout>
580 );
581});
582
583// New discussion form
584discussionRoutes.get(
585 "/:owner/:repo/discussions/new",
586 requireAuth,
587 async (c) => {
588 const { owner: ownerName, repo: repoName } = c.req.param();
589 const user = c.get("user");
590 const resolved = await resolveRepo(ownerName, repoName);
591 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
592 return c.html(
593 <Layout title="New discussion" user={user}>
594 <RepoHeader owner={ownerName} repo={repoName} />
f0b5874Claude595 <div class="disc-wrap">
596 <section class="disc-hero">
597 <div class="disc-hero-orb" aria-hidden="true" />
598 <div class="disc-hero-inner">
599 <div class="disc-hero-text">
600 <div class="disc-eyebrow">
601 <span class="disc-eyebrow-dot" aria-hidden="true" />
602 New discussion · {ownerName}/{repoName}
603 </div>
604 <h2 class="disc-title">
605 <span class="disc-title-grad">Start a thread.</span>
606 </h2>
607 <p class="disc-sub">
608 Pick a category, add a clear title, and tell folks what's on
609 your mind. Markdown supported.
610 </p>
611 </div>
612 </div>
613 </section>
614
615 <div class="disc-form-card">
616 <form
617 method="post"
618 action={`/${ownerName}/${repoName}/discussions`}
619 class="disc-form"
620 >
621 <input
622 type="text"
623 name="title"
624 placeholder="Title"
625 required
626 aria-label="Discussion title"
627 class="disc-input"
628 />
629 <select name="category" class="disc-select">
630 {CATEGORIES.map((c) => (
631 <option value={c}>{c}</option>
632 ))}
633 </select>
634 <textarea
635 name="body"
636 rows={10}
637 placeholder="Write your post (markdown supported)"
638 class="disc-textarea"
639 ></textarea>
640 <div>
641 <button type="submit" class="disc-cta">
642 Start discussion
643 </button>
644 </div>
645 </form>
646 </div>
647 </div>
648 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude649 </Layout>
650 );
651 }
652);
653
654// Create
655discussionRoutes.post(
656 "/:owner/:repo/discussions",
657 requireAuth,
658 async (c) => {
659 const { owner: ownerName, repo: repoName } = c.req.param();
660 const user = c.get("user")!;
661 const resolved = await resolveRepo(ownerName, repoName);
662 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
663
664 const form = await c.req.formData();
665 const title = (form.get("title") as string || "").trim();
666 const body = (form.get("body") as string || "").trim();
667 const categoryRaw = (form.get("category") as string || "general").trim();
668 const category = isValidCategory(categoryRaw) ? categoryRaw : "general";
669
670 if (!title) {
671 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
672 }
673
674 try {
675 const [row] = await db
676 .insert(discussions)
677 .values({
678 repositoryId: resolved.repo.id,
679 authorId: user.id,
680 category,
681 title,
682 body,
683 })
684 .returning({ number: discussions.number });
685 return c.redirect(
686 `/${ownerName}/${repoName}/discussions/${row.number}`
687 );
688 } catch {
689 return c.redirect(`/${ownerName}/${repoName}/discussions`);
690 }
691 }
692);
693
694// Detail
695discussionRoutes.get(
696 "/:owner/:repo/discussions/:number",
697 softAuth,
698 async (c) => {
699 const { owner: ownerName, repo: repoName } = c.req.param();
700 const user = c.get("user");
701 const numParam = Number(c.req.param("number"));
702 const resolved = await resolveRepo(ownerName, repoName);
703 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
704
705 let discussion: any = null;
706 let comments: any[] = [];
707 try {
708 const [row] = await db
709 .select({ d: discussions, author: { username: users.username } })
710 .from(discussions)
711 .innerJoin(users, eq(discussions.authorId, users.id))
712 .where(
713 and(
714 eq(discussions.repositoryId, resolved.repo.id),
715 eq(discussions.number, numParam)
716 )
717 )
718 .limit(1);
719 if (row) discussion = row;
720 if (discussion) {
721 comments = await db
722 .select({
723 c: discussionComments,
724 author: { username: users.username },
725 })
726 .from(discussionComments)
727 .innerJoin(users, eq(discussionComments.authorId, users.id))
728 .where(eq(discussionComments.discussionId, discussion.d.id))
729 .orderBy(discussionComments.createdAt);
730 }
731 } catch {
732 // leave nulls
733 }
734
735 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
736
737 const isOwner = user && user.id === resolved.repo.ownerId;
738 const isAuthor = user && user.id === discussion.d.authorId;
739 const canModerate = isOwner || isAuthor;
740
741 return c.html(
742 <Layout
743 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
744 user={user}
745 >
746 <RepoHeader owner={ownerName} repo={repoName} />
747 <div style="margin-top: 16px;">
748 <div style="display: flex; justify-content: space-between; align-items: center;">
749 <h1 style="margin: 0;">
750 {discussion.d.title}{" "}
751 <span style="color: var(--text-muted);">
752 #{discussion.d.number}
753 </span>
754 </h1>
755 <div style="display: flex; gap: 8px;">
756 <span class="badge">{discussion.d.category}</span>
757 {discussion.d.state === "closed" && (
758 <span class="badge">closed</span>
759 )}
760 {discussion.d.locked && <span class="badge">🔒 locked</span>}
761 {discussion.d.pinned && <span class="badge">📌 pinned</span>}
762 </div>
763 </div>
764 <div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">
765 Started by @{discussion.author.username}
766 </div>
767 </div>
768 <article class="comment" style="margin-top: 16px;">
769 <div
770 // biome-ignore lint: rendered server-side from trusted markdown
771 dangerouslySetInnerHTML={{
772 __html: renderMarkdown(discussion.d.body || ""),
773 }}
774 />
775 </article>
2228c49copilot-swe-agent[bot]776 <h2 style="margin-top: 32px;">{comments.length} Comments</h2>
1e162a8Claude777 {comments.map((com) => {
778 const isAnswer = com.c.id === discussion.d.answerCommentId;
779 return (
780 <article
781 class="comment"
782 style={`margin-top: 12px; ${isAnswer ? "border: 2px solid var(--green); padding: 12px;" : ""}`}
783 >
784 <div style="display: flex; justify-content: space-between;">
785 <div style="font-size: 13px; color: var(--text-muted);">
786 @{com.author.username}
787 {isAnswer && " · ✅ Answer"}
788 </div>
789 {isOwner &&
790 discussion.d.category === "q-and-a" &&
791 !isAnswer && (
792 <form
9e2c6dfClaude793 method="post"
1e162a8Claude794 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
795 style="display: inline;"
796 >
797 <button type="submit" class="btn">
798 Mark as answer
799 </button>
800 </form>
801 )}
802 </div>
803 <div
804 style="margin-top: 8px;"
805 dangerouslySetInnerHTML={{
806 __html: renderMarkdown(com.c.body || ""),
807 }}
808 />
809 </article>
810 );
811 })}
812 {user && !discussion.d.locked && discussion.d.state === "open" && (
813 <form
9e2c6dfClaude814 method="post"
1e162a8Claude815 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
816 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
817 >
818 <textarea
819 name="body"
820 rows={5}
821 placeholder="Add a comment (markdown supported)"
822 required
823 style="padding: 8px; font-family: inherit;"
824 ></textarea>
825 <button type="submit" class="btn btn-primary">
826 Comment
827 </button>
828 </form>
829 )}
830 {user && (
831 <div style="margin-top: 24px; display: flex; gap: 8px;">
832 {canModerate && (
833 <form
9e2c6dfClaude834 method="post"
1e162a8Claude835 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
836 style="display: inline;"
837 >
838 <button type="submit" class="btn">
839 {discussion.d.state === "open" ? "Close" : "Reopen"}
840 </button>
841 </form>
842 )}
843 {isOwner && (
844 <>
845 <form
9e2c6dfClaude846 method="post"
1e162a8Claude847 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
848 style="display: inline;"
849 >
850 <button type="submit" class="btn">
851 {discussion.d.locked ? "Unlock" : "Lock"}
852 </button>
853 </form>
854 <form
9e2c6dfClaude855 method="post"
1e162a8Claude856 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
857 style="display: inline;"
858 >
859 <button type="submit" class="btn">
860 {discussion.d.pinned ? "Unpin" : "Pin"}
861 </button>
862 </form>
863 </>
864 )}
865 </div>
866 )}
867 </Layout>
868 );
869 }
870);
871
872// Add comment
873discussionRoutes.post(
874 "/:owner/:repo/discussions/:number/comment",
875 requireAuth,
876 async (c) => {
877 const { owner: ownerName, repo: repoName } = c.req.param();
878 const user = c.get("user")!;
879 const numParam = Number(c.req.param("number"));
880 const resolved = await resolveRepo(ownerName, repoName);
881 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
882
883 const form = await c.req.formData();
884 const body = (form.get("body") as string || "").trim();
885 const parent = (form.get("parent_comment_id") as string) || null;
886 if (!body) {
887 return c.redirect(
888 `/${ownerName}/${repoName}/discussions/${numParam}`
889 );
890 }
891
892 try {
893 const [row] = await db
894 .select()
895 .from(discussions)
896 .where(
897 and(
898 eq(discussions.repositoryId, resolved.repo.id),
899 eq(discussions.number, numParam)
900 )
901 )
902 .limit(1);
903 if (!row || row.locked || row.state === "closed") {
904 return c.redirect(
905 `/${ownerName}/${repoName}/discussions/${numParam}`
906 );
907 }
908 await db.insert(discussionComments).values({
909 discussionId: row.id,
910 authorId: user.id,
911 body,
912 parentCommentId: parent || null,
913 });
914 await db
915 .update(discussions)
916 .set({ updatedAt: new Date() })
917 .where(eq(discussions.id, row.id));
918 } catch {
919 // swallow
920 }
921 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
922 }
923);
924
925// Toggle lock (owner)
926discussionRoutes.post(
927 "/:owner/:repo/discussions/:number/lock",
928 requireAuth,
929 async (c) => {
930 const { owner: ownerName, repo: repoName } = c.req.param();
931 const user = c.get("user")!;
932 const numParam = Number(c.req.param("number"));
933 const resolved = await resolveRepo(ownerName, repoName);
934 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
935 if (user.id !== resolved.repo.ownerId) {
936 return c.redirect(
937 `/${ownerName}/${repoName}/discussions/${numParam}`
938 );
939 }
940 try {
941 const [row] = await db
942 .select()
943 .from(discussions)
944 .where(
945 and(
946 eq(discussions.repositoryId, resolved.repo.id),
947 eq(discussions.number, numParam)
948 )
949 )
950 .limit(1);
951 if (row) {
952 await db
953 .update(discussions)
954 .set({ locked: !row.locked })
955 .where(eq(discussions.id, row.id));
956 }
957 } catch {
958 // swallow
959 }
960 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
961 }
962);
963
964// Toggle pin (owner)
965discussionRoutes.post(
966 "/:owner/:repo/discussions/:number/pin",
967 requireAuth,
968 async (c) => {
969 const { owner: ownerName, repo: repoName } = c.req.param();
970 const user = c.get("user")!;
971 const numParam = Number(c.req.param("number"));
972 const resolved = await resolveRepo(ownerName, repoName);
973 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
974 if (user.id !== resolved.repo.ownerId) {
975 return c.redirect(
976 `/${ownerName}/${repoName}/discussions/${numParam}`
977 );
978 }
979 try {
980 const [row] = await db
981 .select()
982 .from(discussions)
983 .where(
984 and(
985 eq(discussions.repositoryId, resolved.repo.id),
986 eq(discussions.number, numParam)
987 )
988 )
989 .limit(1);
990 if (row) {
991 await db
992 .update(discussions)
993 .set({ pinned: !row.pinned })
994 .where(eq(discussions.id, row.id));
995 }
996 } catch {
997 // swallow
998 }
999 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1000 }
1001);
1002
1003// Mark answer (owner on q-and-a)
1004discussionRoutes.post(
1005 "/:owner/:repo/discussions/:number/answer/:commentId",
1006 requireAuth,
1007 async (c) => {
1008 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
1009 const user = c.get("user")!;
1010 const numParam = Number(c.req.param("number"));
1011 const resolved = await resolveRepo(ownerName, repoName);
1012 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1013
1014 try {
1015 const [row] = await db
1016 .select()
1017 .from(discussions)
1018 .where(
1019 and(
1020 eq(discussions.repositoryId, resolved.repo.id),
1021 eq(discussions.number, numParam)
1022 )
1023 )
1024 .limit(1);
1025 if (!row) {
1026 return c.redirect(`/${ownerName}/${repoName}/discussions`);
1027 }
1028 const isOwner = user.id === resolved.repo.ownerId;
1029 const isAuthor = user.id === row.authorId;
1030 if (!isOwner && !isAuthor) {
1031 return c.redirect(
1032 `/${ownerName}/${repoName}/discussions/${numParam}`
1033 );
1034 }
1035 if (row.category !== "q-and-a") {
1036 return c.text(
1037 "Only q-and-a discussions can have answers",
1038 400
1039 );
1040 }
1041 await db
1042 .update(discussions)
1043 .set({ answerCommentId: commentId })
1044 .where(eq(discussions.id, row.id));
1045 await db
1046 .update(discussionComments)
1047 .set({ isAnswer: true })
1048 .where(eq(discussionComments.id, commentId));
1049 } catch {
1050 // swallow
1051 }
1052 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1053 }
1054);
1055
1056// Toggle close (owner or author)
1057discussionRoutes.post(
1058 "/:owner/:repo/discussions/:number/close",
1059 requireAuth,
1060 async (c) => {
1061 const { owner: ownerName, repo: repoName } = c.req.param();
1062 const user = c.get("user")!;
1063 const numParam = Number(c.req.param("number"));
1064 const resolved = await resolveRepo(ownerName, repoName);
1065 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1066 try {
1067 const [row] = await db
1068 .select()
1069 .from(discussions)
1070 .where(
1071 and(
1072 eq(discussions.repositoryId, resolved.repo.id),
1073 eq(discussions.number, numParam)
1074 )
1075 )
1076 .limit(1);
1077 if (!row) {
1078 return c.redirect(`/${ownerName}/${repoName}/discussions`);
1079 }
1080 const isOwner = user.id === resolved.repo.ownerId;
1081 const isAuthor = user.id === row.authorId;
1082 if (!isOwner && !isAuthor) {
1083 return c.redirect(
1084 `/${ownerName}/${repoName}/discussions/${numParam}`
1085 );
1086 }
1087 await db
1088 .update(discussions)
1089 .set({ state: row.state === "open" ? "closed" : "open" })
1090 .where(eq(discussions.id, row.id));
1091 } catch {
1092 // swallow
1093 }
1094 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1095 }
1096);
1097
1098export default discussionRoutes;