Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

issues.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.

issues.tsxBlame1349 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
6fc53bdClaude18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
24cf2caClaude20import { loadIssueTemplate } from "../lib/templates";
79136bbClaude21import { renderMarkdown } from "../lib/markdown";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
f7ad7b8Claude23import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude26import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude27import {
28 Flex,
29 Container,
30 PageHeader,
31 Form,
32 FormGroup,
33 Input,
34 TextArea,
35 Button,
36 LinkButton,
37 Badge,
38 EmptyState,
39 TabNav,
40 FilterTabs,
41 List,
42 ListItem,
43 Alert,
44 CommentBox,
45 CommentForm,
46 formatRelative,
47} from "../views/ui";
79136bbClaude48
49const issueRoutes = new Hono<AuthEnv>();
50
f7ad7b8Claude51// ---------------------------------------------------------------------------
52// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
53// other routes/shared views. All design tokens come from :root in layout.tsx.
54// ---------------------------------------------------------------------------
55const issuesStyles = `
56 /* Hero card — list page */
57 .issues-hero {
58 position: relative;
59 margin: 4px 0 24px;
60 padding: 28px 32px;
61 background: var(--bg-elevated);
62 border: 1px solid var(--border);
63 border-radius: 16px;
64 overflow: hidden;
65 }
66 .issues-hero::before {
67 content: '';
68 position: absolute;
69 top: 0; left: 0; right: 0;
70 height: 2px;
71 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
72 opacity: 0.7;
73 pointer-events: none;
74 }
75 .issues-hero-bg {
76 position: absolute;
77 inset: -30% -10% auto auto;
78 width: 360px;
79 height: 360px;
80 pointer-events: none;
81 z-index: 0;
82 }
83 .issues-hero-orb {
84 position: absolute;
85 inset: 0;
86 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
87 filter: blur(80px);
88 opacity: 0.7;
89 animation: issuesHeroOrb 14s ease-in-out infinite;
90 }
91 @keyframes issuesHeroOrb {
92 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
93 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
94 }
95 @media (prefers-reduced-motion: reduce) {
96 .issues-hero-orb { animation: none; }
97 }
98 .issues-hero-inner {
99 position: relative;
100 z-index: 1;
101 display: flex;
102 justify-content: space-between;
103 align-items: flex-end;
104 gap: 24px;
105 flex-wrap: wrap;
106 }
107 .issues-hero-text { flex: 1; min-width: 280px; }
108 .issues-hero-eyebrow {
109 font-size: 12.5px;
110 color: var(--text-muted);
111 margin-bottom: 8px;
112 letter-spacing: 0.04em;
113 text-transform: uppercase;
114 font-weight: 600;
115 }
116 .issues-hero-eyebrow .issues-hero-repo {
117 color: var(--accent);
118 text-transform: none;
119 letter-spacing: -0.005em;
120 font-weight: 600;
121 }
122 .issues-hero-title {
123 font-family: var(--font-display);
124 font-size: clamp(28px, 4vw, 40px);
125 font-weight: 800;
126 letter-spacing: -0.028em;
127 line-height: 1.05;
128 margin: 0 0 10px;
129 color: var(--text-strong);
130 }
131 .issues-hero-sub {
132 font-size: 15px;
133 color: var(--text-muted);
134 margin: 0;
135 line-height: 1.5;
136 max-width: 580px;
137 }
138 .issues-hero-actions {
139 display: flex;
140 gap: 8px;
141 flex-wrap: wrap;
142 }
143 @media (max-width: 720px) {
144 .issues-hero { padding: 24px 20px; }
145 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
146 .issues-hero-actions { width: 100%; }
147 .issues-hero-actions .btn { flex: 1; min-width: 0; }
148 }
149
150 /* Count chip + filter pills */
151 .issues-toolbar {
152 display: flex;
153 align-items: center;
154 justify-content: space-between;
155 gap: 12px;
156 flex-wrap: wrap;
157 margin: 0 0 16px;
158 }
159 .issues-filters {
160 display: inline-flex;
161 background: var(--bg-elevated);
162 border: 1px solid var(--border);
163 border-radius: 9999px;
164 padding: 4px;
165 gap: 2px;
166 }
167 .issues-filter {
168 display: inline-flex;
169 align-items: center;
170 gap: 6px;
171 padding: 6px 14px;
172 border-radius: 9999px;
173 font-size: 13px;
174 font-weight: 500;
175 color: var(--text-muted);
176 text-decoration: none;
177 transition: color 120ms ease, background 120ms ease;
178 line-height: 1.4;
179 }
180 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
181 .issues-filter.is-active {
182 background: rgba(140,109,255,0.14);
183 color: var(--text-strong);
184 }
185 .issues-filter-count {
186 font-variant-numeric: tabular-nums;
187 font-size: 11.5px;
188 color: var(--text-muted);
189 background: rgba(255,255,255,0.04);
190 padding: 1px 7px;
191 border-radius: 9999px;
192 }
193 .issues-filter.is-active .issues-filter-count {
194 background: rgba(140,109,255,0.18);
195 color: var(--text);
196 }
197
198 /* Issue list — modernised rows */
199 .issues-list {
200 list-style: none;
201 margin: 0;
202 padding: 0;
203 border: 1px solid var(--border);
204 border-radius: 12px;
205 overflow: hidden;
206 background: var(--bg-elevated);
207 }
208 .issues-row {
209 display: flex;
210 align-items: flex-start;
211 gap: 14px;
212 padding: 14px 18px;
213 border-bottom: 1px solid var(--border);
214 transition: background 120ms ease, transform 120ms ease;
215 }
216 .issues-row:last-child { border-bottom: none; }
217 .issues-row:hover { background: rgba(140,109,255,0.04); }
218 .issues-row-icon {
219 width: 18px;
220 height: 18px;
221 flex-shrink: 0;
222 margin-top: 3px;
223 display: inline-flex;
224 align-items: center;
225 justify-content: center;
226 }
227 .issues-row-icon.is-open { color: #34d399; }
228 .issues-row-icon.is-closed { color: #b69dff; }
229 .issues-row-main { flex: 1; min-width: 0; }
230 .issues-row-title {
231 font-family: var(--font-display);
232 font-size: 15.5px;
233 font-weight: 600;
234 line-height: 1.35;
235 letter-spacing: -0.012em;
236 margin: 0;
237 display: flex;
238 flex-wrap: wrap;
239 align-items: center;
240 gap: 8px;
241 }
242 .issues-row-title a {
243 color: var(--text-strong);
244 text-decoration: none;
245 transition: color 120ms ease;
246 }
247 .issues-row-title a:hover { color: var(--accent); }
248 .issues-row-meta {
249 margin-top: 5px;
250 font-size: 12.5px;
251 color: var(--text-muted);
252 line-height: 1.5;
253 }
254 .issues-row-meta strong { color: var(--text); font-weight: 600; }
255 .issues-row-side {
256 display: flex;
257 align-items: center;
258 gap: 12px;
259 color: var(--text-muted);
260 font-size: 12.5px;
261 flex-shrink: 0;
262 }
263 .issues-row-comments {
264 display: inline-flex;
265 align-items: center;
266 gap: 5px;
267 color: var(--text-muted);
268 text-decoration: none;
269 }
270 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
271
272 /* Label pills (rendered inline on titles) */
273 .issues-label {
274 display: inline-flex;
275 align-items: center;
276 padding: 2px 9px;
277 border-radius: 9999px;
278 font-size: 11.5px;
279 font-weight: 600;
280 line-height: 1.4;
281 background: rgba(140,109,255,0.10);
282 color: var(--text-strong);
283 border: 1px solid rgba(140,109,255,0.28);
284 letter-spacing: 0.005em;
285 }
286
287 /* Polished empty state */
288 .issues-empty {
289 margin: 0;
290 padding: 56px 32px;
291 background: var(--bg-elevated);
292 border: 1px solid var(--border);
293 border-radius: 16px;
294 text-align: center;
295 position: relative;
296 overflow: hidden;
297 }
298 .issues-empty::before {
299 content: '';
300 position: absolute;
301 top: 0; left: 0; right: 0;
302 height: 2px;
303 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
304 opacity: 0.55;
305 pointer-events: none;
306 }
307 .issues-empty-art {
308 width: 96px;
309 height: 96px;
310 margin: 0 auto 18px;
311 display: block;
312 opacity: 0.85;
313 }
314 .issues-empty-title {
315 font-family: var(--font-display);
316 font-size: 22px;
317 font-weight: 700;
318 letter-spacing: -0.018em;
319 color: var(--text-strong);
320 margin: 0 0 8px;
321 }
322 .issues-empty-sub {
323 font-size: 14.5px;
324 color: var(--text-muted);
325 line-height: 1.55;
326 margin: 0 auto 22px;
327 max-width: 460px;
328 }
329 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
330
331 /* ─── Detail page ─── */
332 .issues-detail-hero {
333 position: relative;
334 margin: 4px 0 20px;
335 padding: 22px 26px;
336 background: var(--bg-elevated);
337 border: 1px solid var(--border);
338 border-radius: 16px;
339 overflow: hidden;
340 }
341 .issues-detail-hero::before {
342 content: '';
343 position: absolute;
344 top: 0; left: 0; right: 0;
345 height: 2px;
346 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
347 opacity: 0.7;
348 pointer-events: none;
349 }
350 .issues-detail-title {
351 font-family: var(--font-display);
352 font-size: clamp(22px, 3vw, 30px);
353 font-weight: 700;
354 letter-spacing: -0.022em;
355 line-height: 1.18;
356 color: var(--text-strong);
357 margin: 0 0 12px;
358 }
359 .issues-detail-title .issues-detail-number {
360 color: var(--text-muted);
361 font-weight: 500;
362 margin-left: 8px;
363 }
364 .issues-detail-attr {
365 display: flex;
366 align-items: center;
367 gap: 12px;
368 flex-wrap: wrap;
369 font-size: 14px;
370 color: var(--text-muted);
371 }
372 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
373 .issues-state-pill {
374 display: inline-flex;
375 align-items: center;
376 gap: 6px;
377 padding: 5px 12px;
378 border-radius: 9999px;
379 font-size: 12.5px;
380 font-weight: 600;
381 line-height: 1.4;
382 letter-spacing: 0.005em;
383 }
384 .issues-state-pill.is-open {
385 background: rgba(52,211,153,0.12);
386 color: #34d399;
387 border: 1px solid rgba(52,211,153,0.35);
388 }
389 .issues-state-pill.is-closed {
390 background: rgba(182,157,255,0.12);
391 color: #b69dff;
392 border: 1px solid rgba(182,157,255,0.35);
393 }
394 .issues-detail-spacer { flex: 1; }
395 .issues-detail-labels {
396 margin-top: 12px;
397 display: flex;
398 gap: 6px;
399 flex-wrap: wrap;
400 }
401
402 /* Comment thread */
403 .issues-thread { margin-top: 18px; }
404 .issues-comment {
405 position: relative;
406 border: 1px solid var(--border);
407 border-radius: 12px;
408 overflow: hidden;
409 background: var(--bg-elevated);
410 margin-bottom: 14px;
411 transition: border-color 160ms ease, box-shadow 160ms ease;
412 }
413 .issues-comment:hover {
414 border-color: var(--border-strong, rgba(255,255,255,0.13));
415 }
416 .issues-comment-header {
417 background: var(--bg-secondary);
418 padding: 10px 16px;
419 border-bottom: 1px solid var(--border);
420 display: flex;
421 align-items: center;
422 gap: 10px;
423 font-size: 13px;
424 color: var(--text-muted);
425 }
426 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
427 .issues-comment-body { padding: 14px 18px; }
428 .issues-comment-author-pill {
429 display: inline-flex;
430 align-items: center;
431 padding: 1px 8px;
432 border-radius: 9999px;
433 background: rgba(140,109,255,0.10);
434 color: var(--accent);
435 font-size: 11px;
436 font-weight: 600;
437 letter-spacing: 0.02em;
438 text-transform: uppercase;
439 }
440
441 /* AI Review comment — distinct purple-accent treatment */
442 .issues-comment.is-ai {
443 border-color: rgba(140,109,255,0.45);
444 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
445 }
446 .issues-comment.is-ai::before {
447 content: '';
448 position: absolute;
449 top: 0; left: 0; right: 0;
450 height: 2px;
451 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
452 pointer-events: none;
453 }
454 .issues-comment.is-ai .issues-comment-header {
455 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
456 border-bottom-color: rgba(140,109,255,0.22);
457 }
458 .issues-ai-badge {
459 display: inline-flex;
460 align-items: center;
461 gap: 5px;
462 padding: 2px 9px;
463 border-radius: 9999px;
464 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
465 color: #fff;
466 font-size: 11px;
467 font-weight: 700;
468 letter-spacing: 0.04em;
469 text-transform: uppercase;
470 line-height: 1.4;
471 }
472 .issues-ai-badge::before {
473 content: '';
474 width: 6px;
475 height: 6px;
476 border-radius: 9999px;
477 background: #fff;
478 opacity: 0.92;
479 box-shadow: 0 0 6px rgba(255,255,255,0.7);
480 }
481
482 /* Composer */
483 .issues-composer {
484 margin-top: 22px;
485 border: 1px solid var(--border);
486 border-radius: 12px;
487 overflow: hidden;
488 background: var(--bg-elevated);
489 transition: border-color 160ms ease, box-shadow 160ms ease;
490 }
491 .issues-composer:focus-within {
492 border-color: rgba(140,109,255,0.55);
493 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
494 }
495 .issues-composer-header {
496 display: flex;
497 align-items: center;
498 justify-content: space-between;
499 gap: 8px;
500 padding: 10px 14px;
501 background: var(--bg-secondary);
502 border-bottom: 1px solid var(--border);
503 font-size: 12.5px;
504 color: var(--text-muted);
505 }
506 .issues-composer-tag {
507 font-weight: 600;
508 color: var(--text);
509 letter-spacing: -0.005em;
510 }
511 .issues-composer-hint a {
512 color: var(--text-muted);
513 text-decoration: none;
514 border-bottom: 1px dashed currentColor;
515 }
516 .issues-composer-hint a:hover { color: var(--accent); }
517 .issues-composer textarea {
518 display: block;
519 width: 100%;
520 border: 0;
521 background: transparent;
522 color: var(--text);
523 padding: 14px 16px;
524 font-family: var(--font-mono);
525 font-size: 13.5px;
526 line-height: 1.55;
527 resize: vertical;
528 outline: none;
529 min-height: 140px;
530 }
531 .issues-composer-actions {
532 display: flex;
533 align-items: center;
534 gap: 8px;
535 padding: 10px 14px;
536 border-top: 1px solid var(--border);
537 background: var(--bg-secondary);
538 flex-wrap: wrap;
539 }
540
541 /* Info banner inside detail */
542 .issues-info-banner {
543 margin: 0 0 14px;
544 padding: 10px 14px;
545 border-radius: 12px;
546 background: rgba(140,109,255,0.08);
547 border: 1px solid rgba(140,109,255,0.28);
548 color: var(--text);
549 font-size: 13.5px;
550 }
551`;
552
553// Pre-rendered <style> tag (constant, reused per request).
554const IssuesStyle = () => (
555 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
556);
557
558// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
559const IssuesEmptySvg = () => (
560 <svg
561 class="issues-empty-art"
562 viewBox="0 0 96 96"
563 fill="none"
564 xmlns="http://www.w3.org/2000/svg"
565 aria-hidden="true"
566 >
567 <defs>
568 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
569 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
570 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
571 </linearGradient>
572 </defs>
573 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
574 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
575 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
576 </svg>
577);
578
579// Detect AI Triage comments by the marker the triage helper writes.
580function isAiTriageComment(body: string | null | undefined): boolean {
581 if (!body) return false;
582 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
583}
584
79136bbClaude585// Helper to resolve repo from :owner/:repo params
586async function resolveRepo(ownerName: string, repoName: string) {
587 const [owner] = await db
588 .select()
589 .from(users)
590 .where(eq(users.username, ownerName))
591 .limit(1);
592 if (!owner) return null;
593
594 const [repo] = await db
595 .select()
596 .from(repositories)
597 .where(
598 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
599 )
600 .limit(1);
601 if (!repo) return null;
602
603 return { owner, repo };
604}
605
606// Issue list
04f6b7fClaude607issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude608 const { owner: ownerName, repo: repoName } = c.req.param();
609 const user = c.get("user");
610 const state = c.req.query("state") || "open";
611
612 const resolved = await resolveRepo(ownerName, repoName);
613 if (!resolved) {
614 return c.html(
615 <Layout title="Not Found" user={user}>
bb0f894Claude616 <EmptyState title="Repository not found" />
79136bbClaude617 </Layout>,
618 404
619 );
620 }
621
622 const { repo } = resolved;
623
624 const issueList = await db
625 .select({
626 issue: issues,
627 author: { username: users.username },
628 })
629 .from(issues)
630 .innerJoin(users, eq(issues.authorId, users.id))
631 .where(
632 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
633 )
634 .orderBy(desc(issues.createdAt));
635
636 // Count open/closed
637 const [counts] = await db
638 .select({
639 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
640 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
641 })
642 .from(issues)
643 .where(eq(issues.repositoryId, repo.id));
644
645 return c.html(
646 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude647 <IssuesStyle />
79136bbClaude648 <RepoHeader owner={ownerName} repo={repoName} />
649 <IssueNav owner={ownerName} repo={repoName} active="issues" />
f7ad7b8Claude650 <section class="issues-hero">
651 <div class="issues-hero-bg" aria-hidden="true">
652 <div class="issues-hero-orb" />
653 </div>
654 <div class="issues-hero-inner">
655 <div class="issues-hero-text">
656 <div class="issues-hero-eyebrow">
657 Issue tracker \u00B7{" "}
658 <span class="issues-hero-repo">
659 {ownerName}/{repoName}
660 </span>
661 </div>
662 <h1 class="issues-hero-title">
663 Track <span class="gradient-text">what matters</span>.
664 </h1>
665 <p class="issues-hero-sub">
666 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
667 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
668 : `${Number(counts?.open ?? 0)} open \u00B7 ${Number(counts?.closed ?? 0)} closed. AI Triage suggests labels, priority, and possible duplicates the moment an issue is filed.`}
669 </p>
670 </div>
671 <div class="issues-hero-actions">
672 {user && (
673 <a
674 href={`/${ownerName}/${repoName}/issues/new`}
675 class="btn btn-primary"
676 >
677 + New issue
678 </a>
679 )}
680 <a href={`/${ownerName}/${repoName}`} class="btn">
681 Back to code
682 </a>
683 </div>
684 </div>
685 </section>
686
687 <div class="issues-toolbar">
688 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
689 <a
690 class={`issues-filter${state === "open" ? " is-active" : ""}`}
691 href={`/${ownerName}/${repoName}/issues?state=open`}
692 role="tab"
693 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude694 >
f7ad7b8Claude695 <span aria-hidden="true">{"\u25CB"}</span>
696 <span>Open</span>
697 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
698 </a>
699 <a
700 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
701 href={`/${ownerName}/${repoName}/issues?state=closed`}
702 role="tab"
703 aria-selected={state === "closed" ? "true" : "false"}
704 >
705 <span aria-hidden="true">{"\u2713"}</span>
706 <span>Closed</span>
707 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
708 </a>
709 </div>
710 </div>
711
79136bbClaude712 {issueList.length === 0 ? (
f7ad7b8Claude713 <div class="issues-empty">
714 <IssuesEmptySvg />
715 <h2 class="issues-empty-title">
716 {state === "closed"
717 ? "No closed issues yet"
718 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
719 ? "No issues \u2014 yet"
720 : "Nothing open right now"}
721 </h2>
722 <p class="issues-empty-sub">
723 {state === "closed"
724 ? "Closed issues will show up here once the team starts shipping fixes."
725 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
726 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
727 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
728 </p>
729 <div class="issues-empty-cta">
730 {user && state !== "closed" && (
731 <a
732 href={`/${ownerName}/${repoName}/issues/new`}
733 class="btn btn-primary"
734 >
735 + Open the first issue
736 </a>
737 )}
79136bbClaude738 {state === "closed" && (
f7ad7b8Claude739 <a
740 href={`/${ownerName}/${repoName}/issues?state=open`}
741 class="btn"
742 >
743 View open issues
744 </a>
79136bbClaude745 )}
f7ad7b8Claude746 </div>
747 </div>
79136bbClaude748 ) : (
f7ad7b8Claude749 <ul class="issues-list">
79136bbClaude750 {issueList.map(({ issue, author }) => (
f7ad7b8Claude751 <li class="issues-row">
79136bbClaude752 <div
f7ad7b8Claude753 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
754 aria-hidden="true"
755 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude756 >
757 {issue.state === "open" ? "\u25CB" : "\u2713"}
758 </div>
f7ad7b8Claude759 <div class="issues-row-main">
760 <h3 class="issues-row-title">
79136bbClaude761 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
762 {issue.title}
763 </a>
f7ad7b8Claude764 </h3>
765 <div class="issues-row-meta">
766 #{issue.number} opened by{" "}
767 <strong>{author.username}</strong>{" "}
79136bbClaude768 {formatRelative(issue.createdAt)}
769 </div>
770 </div>
f7ad7b8Claude771 </li>
79136bbClaude772 ))}
f7ad7b8Claude773 </ul>
79136bbClaude774 )}
775 </Layout>
776 );
777});
778
779// New issue form
780issueRoutes.get(
781 "/:owner/:repo/issues/new",
782 softAuth,
783 requireAuth,
04f6b7fClaude784 requireRepoAccess("write"),
79136bbClaude785 async (c) => {
786 const { owner: ownerName, repo: repoName } = c.req.param();
787 const user = c.get("user")!;
788 const error = c.req.query("error");
24cf2caClaude789 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude790
791 return c.html(
792 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude793 <IssuesStyle />
79136bbClaude794 <RepoHeader owner={ownerName} repo={repoName} />
795 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude796 <Container maxWidth={800}>
f7ad7b8Claude797 <section class="issues-hero" style="margin-top:4px">
798 <div class="issues-hero-bg" aria-hidden="true">
799 <div class="issues-hero-orb" />
800 </div>
801 <div class="issues-hero-inner">
802 <div class="issues-hero-text">
803 <div class="issues-hero-eyebrow">
804 New issue ·{" "}
805 <span class="issues-hero-repo">
806 {ownerName}/{repoName}
807 </span>
808 </div>
809 <h1 class="issues-hero-title">
810 File <span class="gradient-text">it cleanly</span>.
811 </h1>
812 <p class="issues-hero-sub">
813 AI Triage will read the body the moment you submit and post
814 suggested labels, priority, and possible duplicates within
815 seconds. You stay in control — nothing is applied.
816 </p>
817 </div>
818 </div>
819 </section>
79136bbClaude820 {error && (
bb0f894Claude821 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude822 )}
0316dbbClaude823 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
824 <FormGroup>
825 <Input
79136bbClaude826 type="text"
827 name="title"
828 required
829 placeholder="Title"
bb0f894Claude830 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]831 aria-label="Issue title"
79136bbClaude832 />
bb0f894Claude833 </FormGroup>
834 <FormGroup>
835 <TextArea
79136bbClaude836 name="body"
837 rows={12}
838 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude839 mono
79136bbClaude840 />
bb0f894Claude841 </FormGroup>
842 <Button type="submit" variant="primary">
79136bbClaude843 Submit new issue
bb0f894Claude844 </Button>
845 </Form>
846 </Container>
79136bbClaude847 </Layout>
848 );
849 }
850);
851
852// Create issue
853issueRoutes.post(
854 "/:owner/:repo/issues/new",
855 softAuth,
856 requireAuth,
04f6b7fClaude857 requireRepoAccess("write"),
79136bbClaude858 async (c) => {
859 const { owner: ownerName, repo: repoName } = c.req.param();
860 const user = c.get("user")!;
861 const body = await c.req.parseBody();
862 const title = String(body.title || "").trim();
863 const issueBody = String(body.body || "").trim();
864
865 if (!title) {
866 return c.redirect(
867 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
868 );
869 }
870
871 const resolved = await resolveRepo(ownerName, repoName);
872 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
873
874 const [issue] = await db
875 .insert(issues)
876 .values({
877 repositoryId: resolved.repo.id,
878 authorId: user.id,
879 title,
880 body: issueBody || null,
881 })
882 .returning();
883
884 // Update issue count
885 await db
886 .update(repositories)
887 .set({ issueCount: resolved.repo.issueCount + 1 })
888 .where(eq(repositories.id, resolved.repo.id));
889
a9ada5fClaude890 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
891 // suggested labels, priority, summary, and a possible-duplicate
892 // callout. Suggestions only — nothing applied automatically.
893 triggerIssueTriage({
894 ownerName,
895 repoName,
896 repositoryId: resolved.repo.id,
897 issueId: issue.id,
898 issueNumber: issue.number,
899 authorId: user.id,
900 title,
901 body: issueBody,
a28cedeClaude902 }).catch((err) => {
903 console.warn(
904 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
905 err instanceof Error ? err.message : err
906 );
907 });
a9ada5fClaude908
79136bbClaude909 return c.redirect(
910 `/${ownerName}/${repoName}/issues/${issue.number}`
911 );
912 }
913);
914
915// View single issue
04f6b7fClaude916issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude917 const { owner: ownerName, repo: repoName } = c.req.param();
918 const issueNum = parseInt(c.req.param("number"), 10);
919 const user = c.get("user");
920
921 const resolved = await resolveRepo(ownerName, repoName);
922 if (!resolved) {
923 return c.html(
924 <Layout title="Not Found" user={user}>
bb0f894Claude925 <EmptyState title="Not found" />
79136bbClaude926 </Layout>,
927 404
928 );
929 }
930
931 const [issue] = await db
932 .select()
933 .from(issues)
934 .where(
935 and(
936 eq(issues.repositoryId, resolved.repo.id),
937 eq(issues.number, issueNum)
938 )
939 )
940 .limit(1);
941
942 if (!issue) {
943 return c.html(
944 <Layout title="Not Found" user={user}>
bb0f894Claude945 <EmptyState title="Issue not found" />
79136bbClaude946 </Layout>,
947 404
948 );
949 }
950
951 const [author] = await db
952 .select()
953 .from(users)
954 .where(eq(users.id, issue.authorId))
955 .limit(1);
956
957 // Get comments
958 const comments = await db
959 .select({
960 comment: issueComments,
961 author: { username: users.username },
962 })
963 .from(issueComments)
964 .innerJoin(users, eq(issueComments.authorId, users.id))
965 .where(eq(issueComments.issueId, issue.id))
966 .orderBy(asc(issueComments.createdAt));
967
6fc53bdClaude968 // Load reactions for the issue + each comment in parallel.
969 const [issueReactions, ...commentReactions] = await Promise.all([
970 summariseReactions("issue", issue.id, user?.id),
971 ...comments.map((row) =>
972 summariseReactions("issue_comment", row.comment.id, user?.id)
973 ),
974 ]);
975
79136bbClaude976 const canManage =
977 user &&
978 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude979 const info = c.req.query("info");
79136bbClaude980
981 return c.html(
982 <Layout
983 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
984 user={user}
985 >
f7ad7b8Claude986 <IssuesStyle />
79136bbClaude987 <RepoHeader owner={ownerName} repo={repoName} />
988 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude989 <div
990 id="live-comment-banner"
991 class="alert"
992 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
993 >
994 <strong class="js-live-count">0</strong> new comment(s) —{" "}
995 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
996 reload to view
997 </a>
998 </div>
999 <script
1000 dangerouslySetInnerHTML={{
1001 __html: liveCommentBannerScript({
1002 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1003 bannerElementId: "live-comment-banner",
1004 }),
1005 }}
1006 />
79136bbClaude1007 <div class="issue-detail">
58915a9Claude1008 {info && (
f7ad7b8Claude1009 <div class="issues-info-banner">
58915a9Claude1010 {decodeURIComponent(info)}
1011 </div>
1012 )}
f7ad7b8Claude1013
1014 <section class="issues-detail-hero">
1015 <h1 class="issues-detail-title">
1016 {issue.title}
1017 <span class="issues-detail-number">#{issue.number}</span>
1018 </h1>
1019 <div class="issues-detail-attr">
1020 <span
1021 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1022 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1023 >
f7ad7b8Claude1024 <span aria-hidden="true">
1025 {issue.state === "open" ? "\u25CB" : "\u2713"}
1026 </span>
1027 {issue.state === "open" ? "Open" : "Closed"}
1028 </span>
1029 <span>
1030 <strong>{author?.username || "unknown"}</strong> opened this
1031 issue {formatRelative(issue.createdAt)}
1032 </span>
1033 <span class="issues-detail-spacer" />
1034 {issue.state === "open" && user && user.id === resolved.owner.id && (
1035 <a
1036 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1037 class="btn btn-primary"
1038 style="font-size:13px;padding:6px 12px"
1039 title="Generate a draft pull request from this issue using Claude"
1040 >
1041 Build with AI
1042 </a>
1043 )}
1044 </div>
1045 </section>
1046
1047 <div class="issues-thread">
1048 {issue.body && (
1049 <article class="issues-comment">
1050 <header class="issues-comment-header">
1051 <strong>{author?.username || "unknown"}</strong>
1052 <span class="issues-comment-author-pill">Author</span>
1053 <span>commented {formatRelative(issue.createdAt)}</span>
1054 </header>
1055 <div class="issues-comment-body">
1056 <div
1057 class="markdown-body"
1058 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1059 />
1060 </div>
1061 </article>
fbf4aefClaude1062 )}
79136bbClaude1063
f7ad7b8Claude1064 {comments.map(({ comment, author: commentAuthor }) => {
1065 const isAi = isAiTriageComment(comment.body);
1066 return (
1067 <article class={`issues-comment${isAi ? " is-ai" : ""}`}>
1068 <header class="issues-comment-header">
1069 <strong>{commentAuthor.username}</strong>
1070 {isAi ? (
1071 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1072 AI Review
1073 </span>
1074 ) : null}
1075 <span>commented {formatRelative(comment.createdAt)}</span>
1076 </header>
1077 <div class="issues-comment-body">
1078 <div
1079 class="markdown-body"
1080 dangerouslySetInnerHTML={{
1081 __html: renderMarkdown(comment.body),
1082 }}
1083 />
1084 </div>
1085 </article>
1086 );
1087 })}
1088 </div>
79136bbClaude1089
1090 {user && (
f7ad7b8Claude1091 <form
1092 class="issues-composer"
1093 method="post"
1094 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1095 >
1096 <div class="issues-composer-header">
1097 <span class="issues-composer-tag">Add a comment</span>
1098 <span class="issues-composer-hint">
1099 <a
1100 href="https://docs.github.com/en/get-started/writing-on-github"
1101 target="_blank"
1102 rel="noopener noreferrer"
1103 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1104 >
1105 Markdown supported
1106 </a>
1107 </span>
1108 </div>
1109 <textarea
1110 name="body"
1111 rows={6}
1112 required
1113 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1114 />
1115 <div class="issues-composer-actions">
1116 <button type="submit" class="btn btn-primary">
1117 Comment
1118 </button>
1119 {canManage && (
1120 <button
1121 type="submit"
1122 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1123 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1124 >
1125 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1126 </button>
f7ad7b8Claude1127 )}
1128 {canManage && issue.state === "open" && (
1129 <button
1130 type="submit"
1131 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1132 formnovalidate
1133 class="btn"
1134 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1135 >
1136 Re-run AI triage
1137 </button>
1138 )}
1139 </div>
1140 </form>
79136bbClaude1141 )}
1142 </div>
1143 </Layout>
1144 );
1145});
1146
1147// Add comment
1148issueRoutes.post(
1149 "/:owner/:repo/issues/:number/comment",
1150 softAuth,
1151 requireAuth,
04f6b7fClaude1152 requireRepoAccess("write"),
79136bbClaude1153 async (c) => {
1154 const { owner: ownerName, repo: repoName } = c.req.param();
1155 const issueNum = parseInt(c.req.param("number"), 10);
1156 const user = c.get("user")!;
1157 const body = await c.req.parseBody();
1158 const commentBody = String(body.body || "").trim();
1159
1160 if (!commentBody) {
1161 return c.redirect(
1162 `/${ownerName}/${repoName}/issues/${issueNum}`
1163 );
1164 }
1165
1166 const resolved = await resolveRepo(ownerName, repoName);
1167 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1168
1169 const [issue] = await db
1170 .select()
1171 .from(issues)
1172 .where(
1173 and(
1174 eq(issues.repositoryId, resolved.repo.id),
1175 eq(issues.number, issueNum)
1176 )
1177 )
1178 .limit(1);
1179
1180 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1181
d4ac5c3Claude1182 const [inserted] = await db
1183 .insert(issueComments)
1184 .values({
1185 issueId: issue.id,
1186 authorId: user.id,
1187 body: commentBody,
1188 })
1189 .returning();
1190
1191 // Live update: nudge any browser tabs subscribed to this issue. Pure
1192 // fanout — never blocks the redirect, never throws into the request.
1193 if (inserted) {
1194 try {
1195 const { publish } = await import("../lib/sse");
1196 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1197 event: "issue-comment",
1198 data: {
1199 issueId: issue.id,
1200 commentId: inserted.id,
1201 authorId: user.id,
1202 authorUsername: user.username,
1203 },
1204 });
1205 } catch {
1206 /* SSE is best-effort */
1207 }
1208 }
79136bbClaude1209
1210 return c.redirect(
1211 `/${ownerName}/${repoName}/issues/${issueNum}`
1212 );
1213 }
1214);
1215
1216// Close issue
1217issueRoutes.post(
1218 "/:owner/:repo/issues/:number/close",
1219 softAuth,
1220 requireAuth,
04f6b7fClaude1221 requireRepoAccess("write"),
79136bbClaude1222 async (c) => {
1223 const { owner: ownerName, repo: repoName } = c.req.param();
1224 const issueNum = parseInt(c.req.param("number"), 10);
1225
1226 const resolved = await resolveRepo(ownerName, repoName);
1227 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1228
1229 await db
1230 .update(issues)
1231 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1232 .where(
1233 and(
1234 eq(issues.repositoryId, resolved.repo.id),
1235 eq(issues.number, issueNum)
1236 )
1237 );
1238
1239 return c.redirect(
1240 `/${ownerName}/${repoName}/issues/${issueNum}`
1241 );
1242 }
1243);
1244
1245// Reopen issue
1246issueRoutes.post(
1247 "/:owner/:repo/issues/:number/reopen",
1248 softAuth,
1249 requireAuth,
04f6b7fClaude1250 requireRepoAccess("write"),
79136bbClaude1251 async (c) => {
1252 const { owner: ownerName, repo: repoName } = c.req.param();
1253 const issueNum = parseInt(c.req.param("number"), 10);
1254
1255 const resolved = await resolveRepo(ownerName, repoName);
1256 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1257
1258 await db
1259 .update(issues)
1260 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1261 .where(
1262 and(
1263 eq(issues.repositoryId, resolved.repo.id),
1264 eq(issues.number, issueNum)
1265 )
1266 );
1267
1268 return c.redirect(
1269 `/${ownerName}/${repoName}/issues/${issueNum}`
1270 );
1271 }
1272);
1273
1274// Shared nav component with issues tab
1275const IssueNav = ({
1276 owner,
1277 repo,
1278 active,
1279}: {
1280 owner: string;
1281 repo: string;
1282 active: "code" | "commits" | "issues";
1283}) => (
bb0f894Claude1284 <TabNav
1285 tabs={[
1286 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1287 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1288 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1289 ]}
1290 />
79136bbClaude1291);
1292
58915a9Claude1293// Re-run AI triage on demand (e.g. after the issue body has been edited).
1294// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1295issueRoutes.post(
1296 "/:owner/:repo/issues/:number/ai-retriage",
1297 softAuth,
1298 requireAuth,
1299 requireRepoAccess("write"),
1300 async (c) => {
1301 const { owner: ownerName, repo: repoName } = c.req.param();
1302 const issueNum = parseInt(c.req.param("number"), 10);
1303 const user = c.get("user")!;
1304 const resolved = await resolveRepo(ownerName, repoName);
1305 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1306
1307 const [issue] = await db
1308 .select()
1309 .from(issues)
1310 .where(
1311 and(
1312 eq(issues.repositoryId, resolved.repo.id),
1313 eq(issues.number, issueNum)
1314 )
1315 )
1316 .limit(1);
1317 if (!issue) {
1318 return c.redirect(`/${ownerName}/${repoName}/issues`);
1319 }
1320
1321 triggerIssueTriage(
1322 {
1323 ownerName,
1324 repoName,
1325 repositoryId: resolved.repo.id,
1326 issueId: issue.id,
1327 issueNumber: issue.number,
1328 authorId: user.id,
1329 title: issue.title,
1330 body: issue.body || "",
1331 },
1332 { force: true }
a28cedeClaude1333 ).catch((err) => {
1334 console.warn(
1335 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1336 err instanceof Error ? err.message : err
1337 );
1338 });
58915a9Claude1339
1340 return c.redirect(
1341 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1342 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1343 )}`
1344 );
1345 }
1346);
1347
79136bbClaude1348export default issueRoutes;
1349export { IssueNav };