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.tsxBlame1577 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 *
c645a86Claude7 * Categories are stored in `discussion_categories` (migration 0077) and seeded
8 * lazily on first discussion creation:
9 * - General 💬 (not answerable)
10 * - Q&A ❓ (answerable — surfaces "Mark as answer")
11 * - Announcements 📢 (not answerable)
12 * - Ideas 💡 (not answerable)
13 *
f0b5874Claude14 * 2026 polish: gradient-hairline hero + radial orb + thread cards with
15 * author chip + reply count + recent timestamp + category chip. Every class
16 * prefixed `.disc-` so this surface doesn't bleed into the rest of the repo
17 * polish. All data fetches, queries, and POST handlers preserved exactly.
18 *
1e162a8Claude19 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
20 * shell page or a redirect on any failure.
21 */
22
23import { Hono } from "hono";
c645a86Claude24import { and, eq, desc, sql, asc } from "drizzle-orm";
1e162a8Claude25import { db } from "../db";
26import {
27 discussions,
c645a86Claude28 discussionCategories,
1e162a8Claude29 discussionComments,
30 repositories,
31 users,
32} from "../db/schema";
33import { Layout } from "../views/layout";
34import { RepoHeader, RepoNav } from "../views/components";
35import { renderMarkdown } from "../lib/markdown";
36import { softAuth, requireAuth } from "../middleware/auth";
37import type { AuthEnv } from "../middleware/auth";
38
c645a86Claude39// ---------------------------------------------------------------------------
40// Legacy category validation helper — kept for test compatibility.
41// The new system validates against the discussion_categories DB table;
42// this covers the old text-enum values so existing tests don't break.
43// ---------------------------------------------------------------------------
44const LEGACY_CATEGORIES = [
1e162a8Claude45 "general",
46 "q-and-a",
47 "ideas",
48 "announcements",
49 "show-and-tell",
50] as const;
51
c645a86Claude52/** @deprecated Use DB-backed discussion_categories instead. Kept for test compat. */
1e162a8Claude53export function isValidCategory(c: string): boolean {
c645a86Claude54 return (LEGACY_CATEGORIES as readonly string[]).includes(c);
55}
56
57// ---------------------------------------------------------------------------
58// Default categories seeded on first discussion creation per repo.
59// ---------------------------------------------------------------------------
60const DEFAULT_CATEGORIES: Array<{
61 name: string;
62 emoji: string;
63 description: string;
64 isAnswerable: boolean;
65}> = [
66 {
67 name: "General",
68 emoji: "💬",
69 description: "General discussion — anything that doesn't fit elsewhere.",
70 isAnswerable: false,
71 },
72 {
73 name: "Q&A",
74 emoji: "❓",
75 description: "Ask questions and get answers from the community.",
76 isAnswerable: true,
77 },
78 {
79 name: "Announcements",
80 emoji: "📢",
81 description: "Important updates and announcements from maintainers.",
82 isAnswerable: false,
83 },
84 {
85 name: "Ideas",
86 emoji: "💡",
87 description: "Feature requests, proposals, and brainstorming.",
88 isAnswerable: false,
89 },
90];
91
92/**
93 * Ensure the 4 default categories exist for a repository.
94 * Idempotent — checks count before inserting to avoid duplicates.
95 * Called lazily on first discussion creation so no migration data seeding
96 * is needed; existing repos get their categories on first use.
97 */
98async function ensureDefaultCategories(repositoryId: string): Promise<void> {
99 try {
100 const existing = await db
101 .select({ id: discussionCategories.id })
102 .from(discussionCategories)
103 .where(eq(discussionCategories.repositoryId, repositoryId))
104 .limit(1);
105 if (existing.length > 0) return; // already seeded
106 await db.insert(discussionCategories).values(
107 DEFAULT_CATEGORIES.map((cat) => ({
108 repositoryId,
109 name: cat.name,
110 emoji: cat.emoji,
111 description: cat.description,
112 isAnswerable: cat.isAnswerable,
113 }))
114 );
115 } catch {
116 // Silently ignore — categories are cosmetic; a missing row is not fatal.
117 }
1e162a8Claude118}
119
120const discussionRoutes = new Hono<AuthEnv>();
121
f0b5874Claude122/* ─────────────────────────────────────────────────────────────────────────
123 * Scoped CSS — every class prefixed `.disc-` so this surface can't bleed
124 * into the wider repo polish. Mirrors the gradient-hairline hero + radial
125 * orb + card patterns from `insights.tsx` and `error-page.tsx`.
126 * ───────────────────────────────────────────────────────────────────── */
127const styles = `
eed4684Claude128 .disc-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
f0b5874Claude129
130 .disc-hero {
131 position: relative;
132 margin-bottom: var(--space-5);
133 padding: var(--space-5) var(--space-6);
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 16px;
137 overflow: hidden;
138 }
139 .disc-hero::before {
140 content: '';
141 position: absolute;
142 top: 0; left: 0; right: 0;
143 height: 2px;
6fd5915Claude144 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f0b5874Claude145 opacity: 0.75;
146 pointer-events: none;
147 }
148 .disc-hero-orb {
149 position: absolute;
150 inset: -30% -15% auto auto;
151 width: 460px; height: 460px;
6fd5915Claude152 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
f0b5874Claude153 filter: blur(80px);
154 opacity: 0.75;
155 pointer-events: none;
156 z-index: 0;
157 }
158 .disc-hero-inner {
159 position: relative;
160 z-index: 1;
161 display: flex;
162 align-items: flex-start;
163 justify-content: space-between;
164 gap: var(--space-4);
165 flex-wrap: wrap;
166 }
167 .disc-hero-text { max-width: 720px; }
168 .disc-eyebrow {
169 display: inline-flex;
170 align-items: center;
171 gap: 8px;
172 text-transform: uppercase;
173 font-family: var(--font-mono);
174 font-size: 11px;
175 letter-spacing: 0.18em;
176 color: var(--text-muted);
177 font-weight: 600;
178 margin-bottom: 14px;
179 }
180 .disc-eyebrow-dot {
181 width: 8px; height: 8px;
182 border-radius: 9999px;
6fd5915Claude183 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
184 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
f0b5874Claude185 }
186 .disc-title {
187 font-family: var(--font-display);
188 font-size: clamp(28px, 4vw, 40px);
189 font-weight: 800;
190 letter-spacing: -0.028em;
191 line-height: 1.05;
192 margin: 0 0 var(--space-2);
193 color: var(--text-strong);
194 }
195 .disc-title-grad {
6fd5915Claude196 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
f0b5874Claude197 -webkit-background-clip: text;
198 background-clip: text;
199 -webkit-text-fill-color: transparent;
200 color: transparent;
201 }
202 .disc-sub {
203 font-size: 15px;
204 color: var(--text-muted);
205 margin: 0;
206 line-height: 1.55;
207 }
208
209 /* Primary CTA — gradient pill that matches the hero stripe. */
210 .disc-cta {
211 display: inline-flex;
212 align-items: center;
213 justify-content: center;
214 padding: 10px 18px;
215 border-radius: 10px;
216 font-size: 14px;
217 font-weight: 600;
218 text-decoration: none;
219 border: 1px solid transparent;
6fd5915Claude220 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f0b5874Claude221 color: #fff;
6fd5915Claude222 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
f0b5874Claude223 transition: transform 120ms ease, box-shadow 120ms ease;
224 white-space: nowrap;
225 }
226 .disc-cta:hover {
227 transform: translateY(-1px);
6fd5915Claude228 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
f0b5874Claude229 color: #fff;
230 text-decoration: none;
231 }
232
233 /* Category filter chips */
234 .disc-filters {
235 display: flex;
236 flex-wrap: wrap;
237 gap: 8px;
238 margin: 0 0 var(--space-4);
239 }
240 .disc-chip {
241 display: inline-flex;
242 align-items: center;
243 gap: 6px;
244 padding: 6px 12px;
245 border-radius: 9999px;
246 border: 1px solid var(--border);
247 background: var(--bg-elevated);
248 color: var(--text-muted);
249 font-size: 12.5px;
250 font-weight: 600;
251 text-decoration: none;
252 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
253 }
6fd5915Claude254 .disc-chip:hover { border-color: rgba(91,110,232,0.45); color: var(--text-strong); text-decoration: none; }
f0b5874Claude255 .disc-chip.is-active {
256 color: #fff;
6fd5915Claude257 background: linear-gradient(135deg, rgba(91,110,232,0.85), rgba(95,143,160,0.85));
258 border-color: rgba(91,110,232,0.55);
f0b5874Claude259 }
260
261 /* Thread card list */
262 .disc-list {
263 display: flex;
264 flex-direction: column;
265 gap: var(--space-2);
266 margin-bottom: var(--space-5);
267 }
268 .disc-card {
269 position: relative;
270 background: var(--bg-elevated);
271 border: 1px solid var(--border);
272 border-radius: 12px;
273 padding: var(--space-3) var(--space-4);
274 transition: border-color 120ms ease, transform 120ms ease;
275 }
276 .disc-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
6fd5915Claude277 .disc-card.is-pinned { border-color: rgba(91,110,232,0.32); }
f0b5874Claude278
279 .disc-card-row {
280 display: flex;
281 gap: 14px;
282 align-items: flex-start;
283 }
284 .disc-avatar {
285 flex: none;
286 width: 36px; height: 36px;
287 border-radius: 9999px;
6fd5915Claude288 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
f0b5874Claude289 color: #fff;
290 display: flex;
291 align-items: center;
292 justify-content: center;
293 font-family: var(--font-display);
294 font-size: 14px;
295 font-weight: 700;
296 text-transform: uppercase;
297 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
298 }
299 .disc-card-main { flex: 1; min-width: 0; }
300 .disc-card-title {
301 font-family: var(--font-display);
302 font-size: 15px;
303 font-weight: 700;
304 color: var(--text-strong);
305 letter-spacing: -0.005em;
306 line-height: 1.3;
307 margin: 0 0 4px;
308 word-break: break-word;
309 }
310 .disc-card-title a { color: inherit; text-decoration: none; }
311 .disc-card-title a:hover { color: var(--accent); }
312 .disc-card-sub {
313 font-size: 12.5px;
314 color: var(--text-muted);
315 line-height: 1.5;
316 margin: 0;
317 display: flex;
318 flex-wrap: wrap;
319 gap: 6px 10px;
320 align-items: center;
321 }
322 .disc-card-sub a { color: var(--text); text-decoration: none; }
323 .disc-card-sub a:hover { color: var(--text-strong); }
324 .disc-num {
325 font-family: var(--font-mono);
326 font-size: 12px;
327 color: var(--text-muted);
328 }
329
330 .disc-tag {
331 display: inline-flex;
332 align-items: center;
333 gap: 4px;
334 padding: 1px 8px;
335 border-radius: 9999px;
336 font-size: 10.5px;
337 font-weight: 700;
338 letter-spacing: 0.06em;
339 text-transform: uppercase;
6fd5915Claude340 background: rgba(91,110,232,0.10);
341 color: #5b6ee8;
342 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
f0b5874Claude343 }
344 .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); }
345 .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); }
346 .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); }
347
348 .disc-card-meta {
349 flex: none;
350 text-align: right;
351 font-size: 12px;
352 color: var(--text-muted);
353 font-variant-numeric: tabular-nums;
354 display: flex;
355 flex-direction: column;
356 align-items: flex-end;
357 gap: 4px;
358 min-width: 80px;
359 }
360 .disc-card-meta .replies {
361 font-family: var(--font-display);
362 font-size: 16px;
363 font-weight: 700;
364 color: var(--text-strong);
365 line-height: 1;
366 }
367 .disc-card-meta .replies-label {
368 font-size: 10.5px;
369 text-transform: uppercase;
370 letter-spacing: 0.12em;
371 font-weight: 700;
372 }
373
374 /* Empty state — dashed orb card */
375 .disc-empty {
376 position: relative;
377 overflow: hidden;
378 text-align: center;
379 padding: var(--space-6) var(--space-4);
380 border: 1px dashed var(--border-strong, var(--border));
381 border-radius: 16px;
382 background: rgba(255,255,255,0.012);
383 color: var(--text-muted);
384 margin-bottom: var(--space-5);
385 }
386 .disc-empty::before {
387 content: '';
388 position: absolute;
389 inset: -40% -20% auto auto;
390 width: 320px; height: 320px;
6fd5915Claude391 background: radial-gradient(circle, rgba(91,110,232,0.14), rgba(95,143,160,0.06) 45%, transparent 70%);
f0b5874Claude392 filter: blur(60px);
393 pointer-events: none;
394 }
395 .disc-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
396 .disc-empty strong {
397 display: block;
398 font-family: var(--font-display);
399 font-size: 18px;
400 font-weight: 700;
401 color: var(--text-strong);
402 margin: 0;
403 }
404 .disc-empty p { font-size: 13px; margin: 0; max-width: 420px; }
405
406 /* New-discussion form polish (preserves form action + field names) */
407 .disc-form-card {
408 background: var(--bg-elevated);
409 border: 1px solid var(--border);
410 border-radius: 14px;
411 padding: var(--space-4) var(--space-5);
412 margin-top: var(--space-4);
413 }
414 .disc-form { display: flex; flex-direction: column; gap: 12px; }
415 .disc-input,
416 .disc-textarea,
417 .disc-select {
418 width: 100%;
419 padding: 10px 12px;
420 font-size: 14px;
421 color: var(--text);
422 background: var(--bg);
423 border: 1px solid var(--border-strong);
424 border-radius: 10px;
425 outline: none;
426 font-family: inherit;
427 box-sizing: border-box;
428 transition: border-color 120ms ease, box-shadow 120ms ease;
429 }
430 .disc-textarea { font-family: var(--font-mono); font-size: 13px; line-height: 1.55; }
431 .disc-input:focus,
432 .disc-textarea:focus,
433 .disc-select:focus {
6fd5915Claude434 border-color: var(--border-focus, rgba(91,110,232,0.55));
435 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
f0b5874Claude436 }
c645a86Claude437
438 /* Category cards on the form */
439 .disc-cat-grid {
440 display: grid;
441 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
442 gap: 10px;
443 }
444 .disc-cat-option {
445 position: relative;
446 }
447 .disc-cat-option input[type="radio"] {
448 position: absolute;
449 opacity: 0;
450 width: 0; height: 0;
451 }
452 .disc-cat-label {
453 display: flex;
454 align-items: center;
455 gap: 10px;
456 padding: 10px 14px;
457 border: 1px solid var(--border);
458 border-radius: 10px;
459 background: var(--bg);
460 cursor: pointer;
461 transition: border-color 120ms ease, background 120ms ease;
462 font-size: 13px;
463 font-weight: 600;
464 color: var(--text);
465 }
6fd5915Claude466 .disc-cat-label:hover { border-color: rgba(91,110,232,0.45); }
c645a86Claude467 .disc-cat-option input[type="radio"]:checked + .disc-cat-label {
6fd5915Claude468 border-color: rgba(91,110,232,0.6);
469 background: rgba(91,110,232,0.08);
c645a86Claude470 color: var(--text-strong);
471 }
472 .disc-cat-emoji { font-size: 18px; line-height: 1; }
473 .disc-cat-desc { font-size: 11px; color: var(--text-muted); font-weight: 400; margin-top: 1px; }
474
475 /* Answer highlight */
476 .disc-answer {
477 border: 1.5px solid rgba(52,211,153,0.55) !important;
478 box-shadow: 0 0 0 3px rgba(52,211,153,0.10);
479 }
480 .disc-answer-badge {
481 display: inline-flex;
482 align-items: center;
483 gap: 5px;
484 padding: 2px 9px;
485 border-radius: 9999px;
486 background: rgba(52,211,153,0.12);
487 color: #34d399;
488 font-size: 11px;
489 font-weight: 700;
490 border: 1px solid rgba(52,211,153,0.35);
491 }
492
493 /* Thread detail comment cards */
494 .disc-thread { margin-top: 18px; }
495 .disc-comment {
496 border: 1px solid var(--border);
497 border-radius: 12px;
498 overflow: hidden;
499 background: var(--bg-elevated);
500 margin-bottom: 14px;
501 }
502 .disc-comment-header {
503 background: var(--bg-secondary);
504 padding: 10px 16px;
505 border-bottom: 1px solid var(--border);
506 display: flex;
507 align-items: center;
508 gap: 10px;
509 font-size: 13px;
510 color: var(--text-muted);
511 flex-wrap: wrap;
512 }
513 .disc-comment-header strong { color: var(--text-strong); font-weight: 600; }
514 .disc-comment-body { padding: 14px 18px; }
f0b5874Claude515`;
516
517function relTime(d: Date | string | null | undefined): string {
518 if (!d) return "";
c645a86Claude519 const t = typeof d === "string" ? new Date(d).getTime() : (d as Date).getTime();
f0b5874Claude520 if (!Number.isFinite(t)) return "";
521 const diff = (Date.now() - t) / 1000;
522 if (diff < 60) return "just now";
523 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
524 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
525 if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
526 if (diff < 86400 * 30) return `${Math.floor(diff / (86400 * 7))}w ago`;
527 return new Date(t).toLocaleDateString();
528}
529
530function initials(name: string): string {
531 if (!name) return "?";
532 return name.slice(0, 2);
533}
534
1e162a8Claude535async function resolveRepo(ownerName: string, repoName: string) {
536 try {
537 const [owner] = await db
538 .select()
539 .from(users)
540 .where(eq(users.username, ownerName))
541 .limit(1);
542 if (!owner) return null;
543 const [repo] = await db
544 .select()
545 .from(repositories)
546 .where(
547 and(
548 eq(repositories.ownerId, owner.id),
549 eq(repositories.name, repoName)
550 )
551 )
552 .limit(1);
553 if (!repo) return null;
554 return { owner, repo };
555 } catch {
556 return null;
557 }
558}
559
c645a86Claude560function notFound(user: ReturnType<typeof Object>, label = "Not found") {
1e162a8Claude561 return (
c645a86Claude562 <Layout title={label} user={user as any}>
1e162a8Claude563 <div class="empty-state">
564 <h2>{label}</h2>
565 </div>
566 </Layout>
567 );
568}
569
c645a86Claude570// ---------------------------------------------------------------------------
571// GET /:owner/:repo/discussions — list discussions, filter by category
572// ---------------------------------------------------------------------------
1e162a8Claude573discussionRoutes.get("/:owner/:repo/discussions", softAuth, async (c) => {
574 const { owner: ownerName, repo: repoName } = c.req.param();
575 const user = c.get("user");
c645a86Claude576 const categoryParam = c.req.query("category") || "";
1e162a8Claude577
578 const resolved = await resolveRepo(ownerName, repoName);
579 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
580 const { repo } = resolved;
581
c645a86Claude582 // Load categories for this repo (may be empty if never seeded yet)
583 let categories: Array<{ id: number; name: string; emoji: string; description: string | null; isAnswerable: boolean }> = [];
1e162a8Claude584 try {
c645a86Claude585 categories = await db
586 .select()
587 .from(discussionCategories)
588 .where(eq(discussionCategories.repositoryId, repo.id))
589 .orderBy(asc(discussionCategories.id));
590 } catch {
591 categories = [];
592 }
593
594 // Find the active category object by name (URL param is the category name)
595 const activeCategory = categories.find(
596 (cat) => cat.name.toLowerCase() === categoryParam.toLowerCase()
597 ) ?? null;
598
599 let rows: Array<{
600 d: typeof discussions.$inferSelect;
601 author: { username: string };
602 commentCount: number;
603 catName: string | null;
604 catEmoji: string | null;
605 }> = [];
606
607 try {
608 const whereClause = activeCategory
609 ? and(
610 eq(discussions.repositoryId, repo.id),
611 eq(discussions.category, activeCategory.name)
612 )
613 : eq(discussions.repositoryId, repo.id);
614
615 const rawRows = await db
1e162a8Claude616 .select({
617 d: discussions,
618 author: { username: users.username },
c645a86Claude619 commentCount: sql<number>`(SELECT count(*)::int FROM discussion_comments WHERE discussion_id = ${discussions.id})`,
1e162a8Claude620 })
621 .from(discussions)
622 .innerJoin(users, eq(discussions.authorId, users.id))
623 .where(whereClause)
624 .orderBy(desc(discussions.pinned), desc(discussions.updatedAt));
c645a86Claude625
626 rows = rawRows.map((r) => {
627 const cat = categories.find((c) => c.name === r.d.category) ?? null;
628 return {
629 d: r.d,
630 author: r.author,
631 commentCount: Number(r.commentCount || 0),
632 catName: cat ? cat.name : r.d.category,
633 catEmoji: cat ? cat.emoji : "💬",
634 };
635 });
1e162a8Claude636 } catch {
637 rows = [];
638 }
639
640 return c.html(
641 <Layout title={`Discussions — ${ownerName}/${repoName}`} user={user}>
642 <RepoHeader owner={ownerName} repo={repoName} />
c645a86Claude643 <RepoNav owner={ownerName} repo={repoName} active="discussions" />
f0b5874Claude644
645 <div class="disc-wrap">
646 <section class="disc-hero">
647 <div class="disc-hero-orb" aria-hidden="true" />
648 <div class="disc-hero-inner">
649 <div class="disc-hero-text">
650 <div class="disc-eyebrow">
651 <span class="disc-eyebrow-dot" aria-hidden="true" />
652 Discussions · {ownerName}/{repoName}
653 </div>
654 <h2 class="disc-title">
655 <span class="disc-title-grad">Talk it out.</span>
656 </h2>
657 <p class="disc-sub">
658 Q&amp;A, ideas, announcements, and show-and-tell — the
659 conversational space alongside issues and PRs.
660 </p>
661 </div>
662 {user && (
663 <a
664 href={`/${ownerName}/${repoName}/discussions/new`}
665 class="disc-cta"
666 >
667 + New discussion
668 </a>
669 )}
670 </div>
671 </section>
672
c645a86Claude673 {categories.length > 0 && (
674 <div class="disc-filters" aria-label="Filter by category">
1e162a8Claude675 <a
c645a86Claude676 href={`/${ownerName}/${repoName}/discussions`}
677 class={"disc-chip" + (!activeCategory ? " is-active" : "")}
1e162a8Claude678 >
c645a86Claude679 All
1e162a8Claude680 </a>
c645a86Claude681 {categories.map((cat) => (
682 <a
683 href={`/${ownerName}/${repoName}/discussions?category=${encodeURIComponent(cat.name)}`}
684 class={"disc-chip" + (cat.id === activeCategory?.id ? " is-active" : "")}
685 >
686 {cat.emoji} {cat.name}
687 </a>
688 ))}
689 </div>
690 )}
f0b5874Claude691
692 {rows.length === 0 ? (
693 <div class="disc-empty">
694 <div class="disc-empty-inner">
695 <strong>No discussions yet</strong>
696 <p>
697 Start a thread to ask a question, float an idea, or share an
698 announcement with the community.
699 </p>
700 {user && (
701 <a
702 href={`/${ownerName}/${repoName}/discussions/new`}
703 class="disc-cta"
704 style="margin-top:6px"
705 >
706 + Start a discussion
707 </a>
708 )}
709 </div>
710 </div>
711 ) : (
712 <div class="disc-list">
713 {rows.map((r) => {
c645a86Claude714 const replies = r.commentCount;
f0b5874Claude715 const updated = r.d.updatedAt || r.d.createdAt;
716 return (
717 <article
718 class={"disc-card" + (r.d.pinned ? " is-pinned" : "")}
719 >
720 <div class="disc-card-row">
721 <div
722 class="disc-avatar"
723 aria-label={`@${r.author.username}`}
724 >
725 {initials(r.author.username)}
726 </div>
727 <div class="disc-card-main">
728 <h3 class="disc-card-title">
729 <a
730 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
731 >
732 {r.d.title}
733 </a>
734 </h3>
735 <div class="disc-card-sub">
736 <span class="disc-num">#{r.d.number}</span>
737 <span>by @{r.author.username}</span>
738 {updated && <span>· active {relTime(updated)}</span>}
c645a86Claude739 {r.catName && (
740 <span class="disc-tag">
741 {r.catEmoji} {r.catName}
742 </span>
743 )}
f0b5874Claude744 {r.d.pinned && (
c645a86Claude745 <span class="disc-tag is-pinned">📌 pinned</span>
f0b5874Claude746 )}
747 {r.d.state === "closed" && (
748 <span class="disc-tag is-closed">closed</span>
749 )}
750 {r.d.locked && (
c645a86Claude751 <span class="disc-tag is-locked">🔒 locked</span>
f0b5874Claude752 )}
753 </div>
754 </div>
755 <div class="disc-card-meta">
756 <span class="replies">{replies}</span>
757 <span class="replies-label">
758 {replies === 1 ? "reply" : "replies"}
759 </span>
760 </div>
761 </div>
762 </article>
763 );
764 })}
765 </div>
1e162a8Claude766 )}
767 </div>
f0b5874Claude768 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude769 </Layout>
770 );
771});
772
c645a86Claude773// ---------------------------------------------------------------------------
774// GET /:owner/:repo/discussions/new — new discussion form (pick category)
775// ---------------------------------------------------------------------------
1e162a8Claude776discussionRoutes.get(
777 "/:owner/:repo/discussions/new",
778 requireAuth,
779 async (c) => {
780 const { owner: ownerName, repo: repoName } = c.req.param();
781 const user = c.get("user");
782 const resolved = await resolveRepo(ownerName, repoName);
783 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
c645a86Claude784
785 // Ensure default categories exist (lazy seed)
786 await ensureDefaultCategories(resolved.repo.id);
787
788 let categories: Array<{ id: number; name: string; emoji: string; description: string | null; isAnswerable: boolean }> = [];
789 try {
790 categories = await db
791 .select()
792 .from(discussionCategories)
793 .where(eq(discussionCategories.repositoryId, resolved.repo.id))
794 .orderBy(asc(discussionCategories.id));
795 } catch {
796 categories = [];
797 }
798
1e162a8Claude799 return c.html(
800 <Layout title="New discussion" user={user}>
801 <RepoHeader owner={ownerName} repo={repoName} />
c645a86Claude802 <RepoNav owner={ownerName} repo={repoName} active="discussions" />
f0b5874Claude803 <div class="disc-wrap">
804 <section class="disc-hero">
805 <div class="disc-hero-orb" aria-hidden="true" />
806 <div class="disc-hero-inner">
807 <div class="disc-hero-text">
808 <div class="disc-eyebrow">
809 <span class="disc-eyebrow-dot" aria-hidden="true" />
810 New discussion · {ownerName}/{repoName}
811 </div>
812 <h2 class="disc-title">
813 <span class="disc-title-grad">Start a thread.</span>
814 </h2>
815 <p class="disc-sub">
816 Pick a category, add a clear title, and tell folks what's on
817 your mind. Markdown supported.
818 </p>
819 </div>
820 </div>
821 </section>
822
823 <div class="disc-form-card">
824 <form
825 method="post"
826 action={`/${ownerName}/${repoName}/discussions`}
827 class="disc-form"
828 >
829 <input
830 type="text"
831 name="title"
832 placeholder="Title"
833 required
834 aria-label="Discussion title"
835 class="disc-input"
836 />
c645a86Claude837
838 {categories.length > 0 ? (
839 <fieldset style="border:none;padding:0;margin:0">
840 <legend style="font-size:13px;font-weight:600;color:var(--text-muted);margin-bottom:10px">
841 Category
842 </legend>
843 <div class="disc-cat-grid">
844 {categories.map((cat, i) => (
845 <div class="disc-cat-option">
846 <input
847 type="radio"
848 name="category"
849 id={`cat-${cat.id}`}
850 value={cat.name}
851 checked={i === 0}
852 />
853 <label for={`cat-${cat.id}`} class="disc-cat-label">
854 <span class="disc-cat-emoji" aria-hidden="true">{cat.emoji}</span>
855 <span>
856 <span style="display:block">{cat.name}</span>
857 {cat.description && (
858 <span class="disc-cat-desc">{cat.description}</span>
859 )}
860 </span>
861 </label>
862 </div>
863 ))}
864 </div>
865 </fieldset>
866 ) : (
867 <select name="category" class="disc-select">
868 <option value="General">💬 General</option>
869 <option value="Q&A">❓ Q&amp;A</option>
870 <option value="Announcements">📢 Announcements</option>
871 <option value="Ideas">💡 Ideas</option>
872 </select>
873 )}
874
f0b5874Claude875 <textarea
876 name="body"
877 rows={10}
878 placeholder="Write your post (markdown supported)"
879 class="disc-textarea"
880 ></textarea>
881 <div>
882 <button type="submit" class="disc-cta">
883 Start discussion
884 </button>
885 </div>
886 </form>
887 </div>
888 </div>
889 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude890 </Layout>
891 );
892 }
893);
894
c645a86Claude895// ---------------------------------------------------------------------------
896// POST /:owner/:repo/discussions — create discussion
897// ---------------------------------------------------------------------------
1e162a8Claude898discussionRoutes.post(
899 "/:owner/:repo/discussions",
900 requireAuth,
901 async (c) => {
902 const { owner: ownerName, repo: repoName } = c.req.param();
903 const user = c.get("user")!;
904 const resolved = await resolveRepo(ownerName, repoName);
905 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
906
c645a86Claude907 // Ensure default categories are seeded before we create the first discussion
908 await ensureDefaultCategories(resolved.repo.id);
909
1e162a8Claude910 const form = await c.req.formData();
911 const title = (form.get("title") as string || "").trim();
912 const body = (form.get("body") as string || "").trim();
c645a86Claude913 const categoryRaw = (form.get("category") as string || "General").trim();
1e162a8Claude914
915 if (!title) {
916 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
917 }
918
c645a86Claude919 // Validate category against the DB — fall back to "General"
920 let category = "General";
921 try {
922 const [cat] = await db
923 .select({ name: discussionCategories.name })
924 .from(discussionCategories)
925 .where(
926 and(
927 eq(discussionCategories.repositoryId, resolved.repo.id),
928 eq(discussionCategories.name, categoryRaw)
929 )
930 )
931 .limit(1);
932 if (cat) category = cat.name;
933 } catch {
934 category = "General";
935 }
936
1e162a8Claude937 try {
938 const [row] = await db
939 .insert(discussions)
940 .values({
941 repositoryId: resolved.repo.id,
942 authorId: user.id,
943 category,
944 title,
945 body,
946 })
947 .returning({ number: discussions.number });
948 return c.redirect(
949 `/${ownerName}/${repoName}/discussions/${row.number}`
950 );
951 } catch {
952 return c.redirect(`/${ownerName}/${repoName}/discussions`);
953 }
954 }
955);
956
c645a86Claude957// ---------------------------------------------------------------------------
958// GET /:owner/:repo/discussions/:id — view discussion thread with replies
959// ---------------------------------------------------------------------------
1e162a8Claude960discussionRoutes.get(
961 "/:owner/:repo/discussions/:number",
962 softAuth,
963 async (c) => {
964 const { owner: ownerName, repo: repoName } = c.req.param();
965 const user = c.get("user");
966 const numParam = Number(c.req.param("number"));
967 const resolved = await resolveRepo(ownerName, repoName);
968 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
969
c645a86Claude970 let discussion: { d: typeof discussions.$inferSelect; author: { username: string } } | null = null;
971 let comments: Array<{ c: typeof discussionComments.$inferSelect; author: { username: string } }> = [];
972 let category: { id: number; name: string; emoji: string; isAnswerable: boolean } | null = null;
973
1e162a8Claude974 try {
975 const [row] = await db
976 .select({ d: discussions, author: { username: users.username } })
977 .from(discussions)
978 .innerJoin(users, eq(discussions.authorId, users.id))
979 .where(
980 and(
981 eq(discussions.repositoryId, resolved.repo.id),
982 eq(discussions.number, numParam)
983 )
984 )
985 .limit(1);
986 if (row) discussion = row;
c645a86Claude987
1e162a8Claude988 if (discussion) {
989 comments = await db
990 .select({
991 c: discussionComments,
992 author: { username: users.username },
993 })
994 .from(discussionComments)
995 .innerJoin(users, eq(discussionComments.authorId, users.id))
996 .where(eq(discussionComments.discussionId, discussion.d.id))
c645a86Claude997 .orderBy(asc(discussionComments.createdAt));
998
999 // Look up the full category record so we know if it's answerable
1000 const [catRow] = await db
1001 .select()
1002 .from(discussionCategories)
1003 .where(
1004 and(
1005 eq(discussionCategories.repositoryId, resolved.repo.id),
1006 eq(discussionCategories.name, discussion.d.category)
1007 )
1008 )
1009 .limit(1);
1010 if (catRow) category = catRow;
1e162a8Claude1011 }
1012 } catch {
1013 // leave nulls
1014 }
1015
1016 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
1017
c645a86Claude1018 const isOwner = !!(user && user.id === resolved.repo.ownerId);
1019 const isAuthor = !!(user && user.id === discussion.d.authorId);
1e162a8Claude1020 const canModerate = isOwner || isAuthor;
1021
c645a86Claude1022 // A discussion is answerable if its category is marked is_answerable
1023 // OR if the legacy category string is "q-and-a" (backwards-compat).
1024 const isAnswerable =
1025 (category?.isAnswerable ?? false) ||
1026 discussion.d.category === "q-and-a" ||
1027 discussion.d.category === "Q&A";
1028
1e162a8Claude1029 return c.html(
1030 <Layout
1031 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
1032 user={user}
1033 >
1034 <RepoHeader owner={ownerName} repo={repoName} />
c645a86Claude1035 <RepoNav owner={ownerName} repo={repoName} active="discussions" />
1036
1037 <div class="disc-wrap">
1038 {/* ── Discussion header ── */}
1039 <section class="disc-hero" style="margin-bottom:var(--space-4)">
1040 <div class="disc-hero-orb" aria-hidden="true" />
1041 <div class="disc-hero-inner">
1042 <div class="disc-hero-text" style="max-width:100%">
1043 <div class="disc-eyebrow">
1044 <span class="disc-eyebrow-dot" aria-hidden="true" />
1045 {ownerName}/{repoName} · Discussions
1e162a8Claude1046 </div>
c645a86Claude1047 <h1 class="disc-title" style="font-size:clamp(20px,3vw,30px)">
1048 {discussion.d.title}
1049 <span style="color:var(--text-muted);font-weight:500;font-size:0.65em;margin-left:8px">
1050 #{discussion.d.number}
1051 </span>
1052 </h1>
1053 <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:8px">
1054 <span style="font-size:13px;color:var(--text-muted)">
1055 Started by <strong style="color:var(--text)">@{discussion.author.username}</strong>
1056 {" · "}{relTime(discussion.d.createdAt)}
1057 </span>
1058 {category && (
1059 <span class="disc-tag">
1060 {category.emoji} {category.name}
1061 </span>
1062 )}
1063 {discussion.d.state === "closed" && (
1064 <span class="disc-tag is-closed">closed</span>
1e162a8Claude1065 )}
c645a86Claude1066 {discussion.d.locked && (
1067 <span class="disc-tag is-locked">🔒 locked</span>
1068 )}
1069 {discussion.d.pinned && (
1070 <span class="disc-tag is-pinned">📌 pinned</span>
1071 )}
1072 </div>
1e162a8Claude1073 </div>
c645a86Claude1074 </div>
1075 </section>
1076
1077 {/* ── Original post ── */}
1078 <div class="disc-thread">
1079 {discussion.d.body && (
1080 <article class="disc-comment">
1081 <header class="disc-comment-header">
1082 <strong>@{discussion.author.username}</strong>
6fd5915Claude1083 <span style="background:rgba(91,110,232,0.10);color:var(--accent);font-size:11px;font-weight:700;padding:1px 8px;border-radius:9999px;text-transform:uppercase;letter-spacing:0.02em">
c645a86Claude1084 Author
1085 </span>
1086 <span>{relTime(discussion.d.createdAt)}</span>
1087 </header>
1088 <div
1089 class="disc-comment-body markdown-body"
1090 dangerouslySetInnerHTML={{
1091 __html: renderMarkdown(discussion.d.body || ""),
1092 }}
1093 />
1094 </article>
1095 )}
1096
1097 {/* ── Replies ── */}
1098 {comments.length > 0 && (
1099 <h3 style="font-size:15px;font-weight:700;color:var(--text-muted);margin:24px 0 12px">
1100 {comments.length} {comments.length === 1 ? "reply" : "replies"}
1101 </h3>
1102 )}
1103
1104 {comments.map((com) => {
1105 const isAnswer = com.c.id === discussion!.d.answerCommentId;
1106 return (
1107 <article
1108 class={"disc-comment" + (isAnswer ? " disc-answer" : "")}
1109 id={`comment-${com.c.id}`}
1110 >
1111 <header class="disc-comment-header">
1112 <strong>@{com.author.username}</strong>
1113 {isAnswer && (
1114 <span class="disc-answer-badge">
1115 ✅ Answer
1116 </span>
1117 )}
1118 <span>{relTime(com.c.createdAt)}</span>
1119 {/* Mark-as-answer — only for answerable categories, only for owner/author, only when not already answered */}
1120 {isAnswerable &&
1121 canModerate &&
1122 !isAnswer &&
1123 discussion!.d.state === "open" && (
1124 <form
1125 method="post"
1126 action={`/${ownerName}/${repoName}/discussions/${discussion!.d.number}/comments/${com.c.id}/answer`}
1127 style="display:inline;margin-left:auto"
1128 >
1129 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px">
1130 Mark as answer
1131 </button>
1132 </form>
1133 )}
1134 </header>
1135 <div
1136 class="disc-comment-body markdown-body"
1137 dangerouslySetInnerHTML={{
1138 __html: renderMarkdown(com.c.body || ""),
1139 }}
1140 />
1141 </article>
1142 );
1143 })}
1144
1145 {/* ── Reply composer ── */}
1146 {user && !discussion.d.locked && discussion.d.state === "open" && (
1e162a8Claude1147 <form
9e2c6dfClaude1148 method="post"
c645a86Claude1149 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comments`}
1150 style="margin-top:24px;display:flex;flex-direction:column;gap:8px"
1e162a8Claude1151 >
c645a86Claude1152 <textarea
1153 name="body"
1154 rows={5}
1155 placeholder="Add a reply (markdown supported)"
1156 required
1157 class="disc-textarea"
1158 style="border-radius:10px"
1159 ></textarea>
1160 <button type="submit" class="disc-cta" style="align-self:flex-start">
1161 Comment
1e162a8Claude1162 </button>
1163 </form>
1164 )}
c645a86Claude1165
1166 {/* ── Moderation actions ── */}
1167 {user && (
1168 <div style="margin-top:24px;display:flex;gap:8px;flex-wrap:wrap">
1169 {canModerate && (
1170 <form
1171 method="post"
1172 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
1173 style="display:inline"
1174 >
1175 <button type="submit" class="btn">
1176 {discussion.d.state === "open" ? "Close" : "Reopen"}
1177 </button>
1178 </form>
1179 )}
1180 {isOwner && (
1181 <>
1182 <form
1183 method="post"
1184 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
1185 style="display:inline"
1186 >
1187 <button type="submit" class="btn">
1188 {discussion.d.locked ? "Unlock" : "Lock"}
1189 </button>
1190 </form>
1191 <form
1192 method="post"
1193 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
1194 style="display:inline"
1195 >
1196 <button type="submit" class="btn">
1197 {discussion.d.pinned ? "Unpin" : "Pin"}
1198 </button>
1199 </form>
1200 </>
1201 )}
1202 </div>
1e162a8Claude1203 )}
1204 </div>
c645a86Claude1205 </div>
1206 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude1207 </Layout>
1208 );
1209 }
1210);
1211
c645a86Claude1212// ---------------------------------------------------------------------------
1213// POST /:owner/:repo/discussions/:id/comments — post reply
1214// ---------------------------------------------------------------------------
1e162a8Claude1215discussionRoutes.post(
c645a86Claude1216 "/:owner/:repo/discussions/:number/comments",
1e162a8Claude1217 requireAuth,
1218 async (c) => {
1219 const { owner: ownerName, repo: repoName } = c.req.param();
1220 const user = c.get("user")!;
1221 const numParam = Number(c.req.param("number"));
1222 const resolved = await resolveRepo(ownerName, repoName);
1223 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1224
1225 const form = await c.req.formData();
1226 const body = (form.get("body") as string || "").trim();
c645a86Claude1227 const parentCommentId = (form.get("parent_comment_id") as string) || null;
1e162a8Claude1228 if (!body) {
1229 return c.redirect(
1230 `/${ownerName}/${repoName}/discussions/${numParam}`
1231 );
1232 }
1233
1234 try {
1235 const [row] = await db
1236 .select()
1237 .from(discussions)
1238 .where(
1239 and(
1240 eq(discussions.repositoryId, resolved.repo.id),
1241 eq(discussions.number, numParam)
1242 )
1243 )
1244 .limit(1);
1245 if (!row || row.locked || row.state === "closed") {
1246 return c.redirect(
1247 `/${ownerName}/${repoName}/discussions/${numParam}`
1248 );
1249 }
1250 await db.insert(discussionComments).values({
1251 discussionId: row.id,
1252 authorId: user.id,
1253 body,
c645a86Claude1254 parentCommentId: parentCommentId || null,
1e162a8Claude1255 });
1256 await db
1257 .update(discussions)
1258 .set({ updatedAt: new Date() })
1259 .where(eq(discussions.id, row.id));
1260 } catch {
1261 // swallow
1262 }
1263 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1264 }
1265);
1266
c645a86Claude1267// ---------------------------------------------------------------------------
1268// POST /:owner/:repo/discussions/:id/comments/:commentId/answer
1269// Mark as answer (author or repo owner only)
1270// ---------------------------------------------------------------------------
1e162a8Claude1271discussionRoutes.post(
c645a86Claude1272 "/:owner/:repo/discussions/:number/comments/:commentId/answer",
1e162a8Claude1273 requireAuth,
1274 async (c) => {
c645a86Claude1275 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
1e162a8Claude1276 const user = c.get("user")!;
1277 const numParam = Number(c.req.param("number"));
1278 const resolved = await resolveRepo(ownerName, repoName);
1279 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
c645a86Claude1280
1e162a8Claude1281 try {
1282 const [row] = await db
1283 .select()
1284 .from(discussions)
1285 .where(
1286 and(
1287 eq(discussions.repositoryId, resolved.repo.id),
1288 eq(discussions.number, numParam)
1289 )
1290 )
1291 .limit(1);
c645a86Claude1292 if (!row) {
1293 return c.redirect(`/${ownerName}/${repoName}/discussions`);
1e162a8Claude1294 }
c645a86Claude1295 const isOwner = user.id === resolved.repo.ownerId;
1296 const isAuthor = user.id === row.authorId;
1297 if (!isOwner && !isAuthor) {
1298 return c.redirect(
1299 `/${ownerName}/${repoName}/discussions/${numParam}`
1300 );
1301 }
1302
1303 // Verify the category is answerable
1304 const [cat] = await db
1305 .select({ isAnswerable: discussionCategories.isAnswerable })
1306 .from(discussionCategories)
1307 .where(
1308 and(
1309 eq(discussionCategories.repositoryId, resolved.repo.id),
1310 eq(discussionCategories.name, row.category)
1311 )
1312 )
1313 .limit(1);
1314
1315 const answerable =
1316 (cat?.isAnswerable ?? false) ||
1317 row.category === "q-and-a" ||
1318 row.category === "Q&A";
1319
1320 if (!answerable) {
1321 return c.text("Only answerable discussions can have a marked answer", 400);
1322 }
1323
1324 // Clear any previous answer flag, then set the new one
1325 await db
1326 .update(discussionComments)
1327 .set({ isAnswer: false })
1328 .where(eq(discussionComments.discussionId, row.id));
1329
1330 await db
1331 .update(discussions)
1332 .set({ answerCommentId: commentId })
1333 .where(eq(discussions.id, row.id));
1334
1335 await db
1336 .update(discussionComments)
1337 .set({ isAnswer: true })
1338 .where(eq(discussionComments.id, commentId));
1e162a8Claude1339 } catch {
1340 // swallow
1341 }
1342 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1343 }
1344);
1345
c645a86Claude1346// ---------------------------------------------------------------------------
1347// Legacy comment route (kept for backwards compat — old forms post here)
1348// ---------------------------------------------------------------------------
1e162a8Claude1349discussionRoutes.post(
c645a86Claude1350 "/:owner/:repo/discussions/:number/comment",
1e162a8Claude1351 requireAuth,
1352 async (c) => {
1353 const { owner: ownerName, repo: repoName } = c.req.param();
1354 const user = c.get("user")!;
1355 const numParam = Number(c.req.param("number"));
1356 const resolved = await resolveRepo(ownerName, repoName);
1357 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
c645a86Claude1358
1359 const form = await c.req.formData();
1360 const body = (form.get("body") as string || "").trim();
1361 const parentCommentId = (form.get("parent_comment_id") as string) || null;
1362 if (!body) {
1e162a8Claude1363 return c.redirect(
1364 `/${ownerName}/${repoName}/discussions/${numParam}`
1365 );
1366 }
c645a86Claude1367
1e162a8Claude1368 try {
1369 const [row] = await db
1370 .select()
1371 .from(discussions)
1372 .where(
1373 and(
1374 eq(discussions.repositoryId, resolved.repo.id),
1375 eq(discussions.number, numParam)
1376 )
1377 )
1378 .limit(1);
c645a86Claude1379 if (!row || row.locked || row.state === "closed") {
1380 return c.redirect(
1381 `/${ownerName}/${repoName}/discussions/${numParam}`
1382 );
1e162a8Claude1383 }
c645a86Claude1384 await db.insert(discussionComments).values({
1385 discussionId: row.id,
1386 authorId: user.id,
1387 body,
1388 parentCommentId: parentCommentId || null,
1389 });
1390 await db
1391 .update(discussions)
1392 .set({ updatedAt: new Date() })
1393 .where(eq(discussions.id, row.id));
1e162a8Claude1394 } catch {
1395 // swallow
1396 }
1397 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1398 }
1399);
1400
c645a86Claude1401// ---------------------------------------------------------------------------
1402// Legacy answer route (kept for backwards compat)
1403// ---------------------------------------------------------------------------
1e162a8Claude1404discussionRoutes.post(
1405 "/:owner/:repo/discussions/:number/answer/:commentId",
1406 requireAuth,
1407 async (c) => {
1408 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
1409 const user = c.get("user")!;
1410 const numParam = Number(c.req.param("number"));
1411 const resolved = await resolveRepo(ownerName, repoName);
1412 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1413
1414 try {
1415 const [row] = await db
1416 .select()
1417 .from(discussions)
1418 .where(
1419 and(
1420 eq(discussions.repositoryId, resolved.repo.id),
1421 eq(discussions.number, numParam)
1422 )
1423 )
1424 .limit(1);
1425 if (!row) {
1426 return c.redirect(`/${ownerName}/${repoName}/discussions`);
1427 }
1428 const isOwner = user.id === resolved.repo.ownerId;
1429 const isAuthor = user.id === row.authorId;
1430 if (!isOwner && !isAuthor) {
1431 return c.redirect(
1432 `/${ownerName}/${repoName}/discussions/${numParam}`
1433 );
1434 }
c645a86Claude1435
1e162a8Claude1436 await db
1437 .update(discussions)
1438 .set({ answerCommentId: commentId })
1439 .where(eq(discussions.id, row.id));
1440 await db
1441 .update(discussionComments)
1442 .set({ isAnswer: true })
1443 .where(eq(discussionComments.id, commentId));
1444 } catch {
1445 // swallow
1446 }
1447 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1448 }
1449);
1450
c645a86Claude1451// ---------------------------------------------------------------------------
1452// Toggle lock (owner)
1453// ---------------------------------------------------------------------------
1454discussionRoutes.post(
1455 "/:owner/:repo/discussions/:number/lock",
1456 requireAuth,
1457 async (c) => {
1458 const { owner: ownerName, repo: repoName } = c.req.param();
1459 const user = c.get("user")!;
1460 const numParam = Number(c.req.param("number"));
1461 const resolved = await resolveRepo(ownerName, repoName);
1462 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1463 if (user.id !== resolved.repo.ownerId) {
1464 return c.redirect(
1465 `/${ownerName}/${repoName}/discussions/${numParam}`
1466 );
1467 }
1468 try {
1469 const [row] = await db
1470 .select()
1471 .from(discussions)
1472 .where(
1473 and(
1474 eq(discussions.repositoryId, resolved.repo.id),
1475 eq(discussions.number, numParam)
1476 )
1477 )
1478 .limit(1);
1479 if (row) {
1480 await db
1481 .update(discussions)
1482 .set({ locked: !row.locked })
1483 .where(eq(discussions.id, row.id));
1484 }
1485 } catch {
1486 // swallow
1487 }
1488 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1489 }
1490);
1491
1492// ---------------------------------------------------------------------------
1493// Toggle pin (owner)
1494// ---------------------------------------------------------------------------
1495discussionRoutes.post(
1496 "/:owner/:repo/discussions/:number/pin",
1497 requireAuth,
1498 async (c) => {
1499 const { owner: ownerName, repo: repoName } = c.req.param();
1500 const user = c.get("user")!;
1501 const numParam = Number(c.req.param("number"));
1502 const resolved = await resolveRepo(ownerName, repoName);
1503 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1504 if (user.id !== resolved.repo.ownerId) {
1505 return c.redirect(
1506 `/${ownerName}/${repoName}/discussions/${numParam}`
1507 );
1508 }
1509 try {
1510 const [row] = await db
1511 .select()
1512 .from(discussions)
1513 .where(
1514 and(
1515 eq(discussions.repositoryId, resolved.repo.id),
1516 eq(discussions.number, numParam)
1517 )
1518 )
1519 .limit(1);
1520 if (row) {
1521 await db
1522 .update(discussions)
1523 .set({ pinned: !row.pinned })
1524 .where(eq(discussions.id, row.id));
1525 }
1526 } catch {
1527 // swallow
1528 }
1529 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1530 }
1531);
1532
1533// ---------------------------------------------------------------------------
1e162a8Claude1534// Toggle close (owner or author)
c645a86Claude1535// ---------------------------------------------------------------------------
1e162a8Claude1536discussionRoutes.post(
1537 "/:owner/:repo/discussions/:number/close",
1538 requireAuth,
1539 async (c) => {
1540 const { owner: ownerName, repo: repoName } = c.req.param();
1541 const user = c.get("user")!;
1542 const numParam = Number(c.req.param("number"));
1543 const resolved = await resolveRepo(ownerName, repoName);
1544 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1545 try {
1546 const [row] = await db
1547 .select()
1548 .from(discussions)
1549 .where(
1550 and(
1551 eq(discussions.repositoryId, resolved.repo.id),
1552 eq(discussions.number, numParam)
1553 )
1554 )
1555 .limit(1);
1556 if (!row) {
1557 return c.redirect(`/${ownerName}/${repoName}/discussions`);
1558 }
1559 const isOwner = user.id === resolved.repo.ownerId;
1560 const isAuthor = user.id === row.authorId;
1561 if (!isOwner && !isAuthor) {
1562 return c.redirect(
1563 `/${ownerName}/${repoName}/discussions/${numParam}`
1564 );
1565 }
1566 await db
1567 .update(discussions)
1568 .set({ state: row.state === "open" ? "closed" : "open" })
1569 .where(eq(discussions.id, row.id));
1570 } catch {
1571 // swallow
1572 }
1573 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
1574 }
1575);
1576
1577export default discussionRoutes;