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.tsxBlame1356 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";
6ea2109Claude611 // Bounded pagination — unbounded selects ran a full table scan + O(n)
612 // sort on every page load; with 10k+ issues the request would hang.
613 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
614 const page = Math.max(1, Number(c.req.query("page")) || 1);
615 const offset = (page - 1) * perPage;
79136bbClaude616
617 const resolved = await resolveRepo(ownerName, repoName);
618 if (!resolved) {
619 return c.html(
620 <Layout title="Not Found" user={user}>
bb0f894Claude621 <EmptyState title="Repository not found" />
79136bbClaude622 </Layout>,
623 404
624 );
625 }
626
627 const { repo } = resolved;
628
629 const issueList = await db
630 .select({
631 issue: issues,
632 author: { username: users.username },
633 })
634 .from(issues)
635 .innerJoin(users, eq(issues.authorId, users.id))
636 .where(
637 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
638 )
6ea2109Claude639 .orderBy(desc(issues.createdAt))
640 .limit(perPage)
641 .offset(offset);
79136bbClaude642
643 // Count open/closed
644 const [counts] = await db
645 .select({
646 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
647 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
648 })
649 .from(issues)
650 .where(eq(issues.repositoryId, repo.id));
651
652 return c.html(
653 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude654 <IssuesStyle />
79136bbClaude655 <RepoHeader owner={ownerName} repo={repoName} />
656 <IssueNav owner={ownerName} repo={repoName} active="issues" />
f7ad7b8Claude657 <section class="issues-hero">
658 <div class="issues-hero-bg" aria-hidden="true">
659 <div class="issues-hero-orb" />
660 </div>
661 <div class="issues-hero-inner">
662 <div class="issues-hero-text">
663 <div class="issues-hero-eyebrow">
664 Issue tracker \u00B7{" "}
665 <span class="issues-hero-repo">
666 {ownerName}/{repoName}
667 </span>
668 </div>
669 <h1 class="issues-hero-title">
670 Track <span class="gradient-text">what matters</span>.
671 </h1>
672 <p class="issues-hero-sub">
673 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
674 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
675 : `${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.`}
676 </p>
677 </div>
678 <div class="issues-hero-actions">
679 {user && (
680 <a
681 href={`/${ownerName}/${repoName}/issues/new`}
682 class="btn btn-primary"
683 >
684 + New issue
685 </a>
686 )}
687 <a href={`/${ownerName}/${repoName}`} class="btn">
688 Back to code
689 </a>
690 </div>
691 </div>
692 </section>
693
694 <div class="issues-toolbar">
695 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
696 <a
697 class={`issues-filter${state === "open" ? " is-active" : ""}`}
698 href={`/${ownerName}/${repoName}/issues?state=open`}
699 role="tab"
700 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude701 >
f7ad7b8Claude702 <span aria-hidden="true">{"\u25CB"}</span>
703 <span>Open</span>
704 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
705 </a>
706 <a
707 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
708 href={`/${ownerName}/${repoName}/issues?state=closed`}
709 role="tab"
710 aria-selected={state === "closed" ? "true" : "false"}
711 >
712 <span aria-hidden="true">{"\u2713"}</span>
713 <span>Closed</span>
714 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
715 </a>
716 </div>
717 </div>
718
79136bbClaude719 {issueList.length === 0 ? (
f7ad7b8Claude720 <div class="issues-empty">
721 <IssuesEmptySvg />
722 <h2 class="issues-empty-title">
723 {state === "closed"
724 ? "No closed issues yet"
725 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
726 ? "No issues \u2014 yet"
727 : "Nothing open right now"}
728 </h2>
729 <p class="issues-empty-sub">
730 {state === "closed"
731 ? "Closed issues will show up here once the team starts shipping fixes."
732 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
733 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
734 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
735 </p>
736 <div class="issues-empty-cta">
737 {user && state !== "closed" && (
738 <a
739 href={`/${ownerName}/${repoName}/issues/new`}
740 class="btn btn-primary"
741 >
742 + Open the first issue
743 </a>
744 )}
79136bbClaude745 {state === "closed" && (
f7ad7b8Claude746 <a
747 href={`/${ownerName}/${repoName}/issues?state=open`}
748 class="btn"
749 >
750 View open issues
751 </a>
79136bbClaude752 )}
f7ad7b8Claude753 </div>
754 </div>
79136bbClaude755 ) : (
f7ad7b8Claude756 <ul class="issues-list">
79136bbClaude757 {issueList.map(({ issue, author }) => (
f7ad7b8Claude758 <li class="issues-row">
79136bbClaude759 <div
f7ad7b8Claude760 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
761 aria-hidden="true"
762 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude763 >
764 {issue.state === "open" ? "\u25CB" : "\u2713"}
765 </div>
f7ad7b8Claude766 <div class="issues-row-main">
767 <h3 class="issues-row-title">
79136bbClaude768 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
769 {issue.title}
770 </a>
f7ad7b8Claude771 </h3>
772 <div class="issues-row-meta">
773 #{issue.number} opened by{" "}
774 <strong>{author.username}</strong>{" "}
79136bbClaude775 {formatRelative(issue.createdAt)}
776 </div>
777 </div>
f7ad7b8Claude778 </li>
79136bbClaude779 ))}
f7ad7b8Claude780 </ul>
79136bbClaude781 )}
782 </Layout>
783 );
784});
785
786// New issue form
787issueRoutes.get(
788 "/:owner/:repo/issues/new",
789 softAuth,
790 requireAuth,
04f6b7fClaude791 requireRepoAccess("write"),
79136bbClaude792 async (c) => {
793 const { owner: ownerName, repo: repoName } = c.req.param();
794 const user = c.get("user")!;
795 const error = c.req.query("error");
24cf2caClaude796 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude797
798 return c.html(
799 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude800 <IssuesStyle />
79136bbClaude801 <RepoHeader owner={ownerName} repo={repoName} />
802 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude803 <Container maxWidth={800}>
f7ad7b8Claude804 <section class="issues-hero" style="margin-top:4px">
805 <div class="issues-hero-bg" aria-hidden="true">
806 <div class="issues-hero-orb" />
807 </div>
808 <div class="issues-hero-inner">
809 <div class="issues-hero-text">
810 <div class="issues-hero-eyebrow">
811 New issue ·{" "}
812 <span class="issues-hero-repo">
813 {ownerName}/{repoName}
814 </span>
815 </div>
816 <h1 class="issues-hero-title">
817 File <span class="gradient-text">it cleanly</span>.
818 </h1>
819 <p class="issues-hero-sub">
820 AI Triage will read the body the moment you submit and post
821 suggested labels, priority, and possible duplicates within
822 seconds. You stay in control — nothing is applied.
823 </p>
824 </div>
825 </div>
826 </section>
79136bbClaude827 {error && (
bb0f894Claude828 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude829 )}
0316dbbClaude830 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
831 <FormGroup>
832 <Input
79136bbClaude833 type="text"
834 name="title"
835 required
836 placeholder="Title"
bb0f894Claude837 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]838 aria-label="Issue title"
79136bbClaude839 />
bb0f894Claude840 </FormGroup>
841 <FormGroup>
842 <TextArea
79136bbClaude843 name="body"
844 rows={12}
845 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude846 mono
79136bbClaude847 />
bb0f894Claude848 </FormGroup>
849 <Button type="submit" variant="primary">
79136bbClaude850 Submit new issue
bb0f894Claude851 </Button>
852 </Form>
853 </Container>
79136bbClaude854 </Layout>
855 );
856 }
857);
858
859// Create issue
860issueRoutes.post(
861 "/:owner/:repo/issues/new",
862 softAuth,
863 requireAuth,
04f6b7fClaude864 requireRepoAccess("write"),
79136bbClaude865 async (c) => {
866 const { owner: ownerName, repo: repoName } = c.req.param();
867 const user = c.get("user")!;
868 const body = await c.req.parseBody();
869 const title = String(body.title || "").trim();
870 const issueBody = String(body.body || "").trim();
871
872 if (!title) {
873 return c.redirect(
874 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
875 );
876 }
877
878 const resolved = await resolveRepo(ownerName, repoName);
879 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
880
881 const [issue] = await db
882 .insert(issues)
883 .values({
884 repositoryId: resolved.repo.id,
885 authorId: user.id,
886 title,
887 body: issueBody || null,
888 })
889 .returning();
890
891 // Update issue count
892 await db
893 .update(repositories)
894 .set({ issueCount: resolved.repo.issueCount + 1 })
895 .where(eq(repositories.id, resolved.repo.id));
896
a9ada5fClaude897 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
898 // suggested labels, priority, summary, and a possible-duplicate
899 // callout. Suggestions only — nothing applied automatically.
900 triggerIssueTriage({
901 ownerName,
902 repoName,
903 repositoryId: resolved.repo.id,
904 issueId: issue.id,
905 issueNumber: issue.number,
906 authorId: user.id,
907 title,
908 body: issueBody,
a28cedeClaude909 }).catch((err) => {
910 console.warn(
911 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
912 err instanceof Error ? err.message : err
913 );
914 });
a9ada5fClaude915
79136bbClaude916 return c.redirect(
917 `/${ownerName}/${repoName}/issues/${issue.number}`
918 );
919 }
920);
921
922// View single issue
04f6b7fClaude923issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude924 const { owner: ownerName, repo: repoName } = c.req.param();
925 const issueNum = parseInt(c.req.param("number"), 10);
926 const user = c.get("user");
927
928 const resolved = await resolveRepo(ownerName, repoName);
929 if (!resolved) {
930 return c.html(
931 <Layout title="Not Found" user={user}>
bb0f894Claude932 <EmptyState title="Not found" />
79136bbClaude933 </Layout>,
934 404
935 );
936 }
937
938 const [issue] = await db
939 .select()
940 .from(issues)
941 .where(
942 and(
943 eq(issues.repositoryId, resolved.repo.id),
944 eq(issues.number, issueNum)
945 )
946 )
947 .limit(1);
948
949 if (!issue) {
950 return c.html(
951 <Layout title="Not Found" user={user}>
bb0f894Claude952 <EmptyState title="Issue not found" />
79136bbClaude953 </Layout>,
954 404
955 );
956 }
957
958 const [author] = await db
959 .select()
960 .from(users)
961 .where(eq(users.id, issue.authorId))
962 .limit(1);
963
964 // Get comments
965 const comments = await db
966 .select({
967 comment: issueComments,
968 author: { username: users.username },
969 })
970 .from(issueComments)
971 .innerJoin(users, eq(issueComments.authorId, users.id))
972 .where(eq(issueComments.issueId, issue.id))
973 .orderBy(asc(issueComments.createdAt));
974
6fc53bdClaude975 // Load reactions for the issue + each comment in parallel.
976 const [issueReactions, ...commentReactions] = await Promise.all([
977 summariseReactions("issue", issue.id, user?.id),
978 ...comments.map((row) =>
979 summariseReactions("issue_comment", row.comment.id, user?.id)
980 ),
981 ]);
982
79136bbClaude983 const canManage =
984 user &&
985 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude986 const info = c.req.query("info");
79136bbClaude987
988 return c.html(
989 <Layout
990 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
991 user={user}
992 >
f7ad7b8Claude993 <IssuesStyle />
79136bbClaude994 <RepoHeader owner={ownerName} repo={repoName} />
995 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude996 <div
997 id="live-comment-banner"
998 class="alert"
999 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1000 >
1001 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1002 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1003 reload to view
1004 </a>
1005 </div>
1006 <script
1007 dangerouslySetInnerHTML={{
1008 __html: liveCommentBannerScript({
1009 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1010 bannerElementId: "live-comment-banner",
1011 }),
1012 }}
1013 />
79136bbClaude1014 <div class="issue-detail">
58915a9Claude1015 {info && (
f7ad7b8Claude1016 <div class="issues-info-banner">
58915a9Claude1017 {decodeURIComponent(info)}
1018 </div>
1019 )}
f7ad7b8Claude1020
1021 <section class="issues-detail-hero">
1022 <h1 class="issues-detail-title">
1023 {issue.title}
1024 <span class="issues-detail-number">#{issue.number}</span>
1025 </h1>
1026 <div class="issues-detail-attr">
1027 <span
1028 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1029 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1030 >
f7ad7b8Claude1031 <span aria-hidden="true">
1032 {issue.state === "open" ? "\u25CB" : "\u2713"}
1033 </span>
1034 {issue.state === "open" ? "Open" : "Closed"}
1035 </span>
1036 <span>
1037 <strong>{author?.username || "unknown"}</strong> opened this
1038 issue {formatRelative(issue.createdAt)}
1039 </span>
1040 <span class="issues-detail-spacer" />
1041 {issue.state === "open" && user && user.id === resolved.owner.id && (
1042 <a
1043 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1044 class="btn btn-primary"
1045 style="font-size:13px;padding:6px 12px"
1046 title="Generate a draft pull request from this issue using Claude"
1047 >
1048 Build with AI
1049 </a>
1050 )}
1051 </div>
1052 </section>
1053
1054 <div class="issues-thread">
1055 {issue.body && (
1056 <article class="issues-comment">
1057 <header class="issues-comment-header">
1058 <strong>{author?.username || "unknown"}</strong>
1059 <span class="issues-comment-author-pill">Author</span>
1060 <span>commented {formatRelative(issue.createdAt)}</span>
1061 </header>
1062 <div class="issues-comment-body">
1063 <div
1064 class="markdown-body"
1065 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1066 />
1067 </div>
1068 </article>
fbf4aefClaude1069 )}
79136bbClaude1070
f7ad7b8Claude1071 {comments.map(({ comment, author: commentAuthor }) => {
1072 const isAi = isAiTriageComment(comment.body);
1073 return (
1074 <article class={`issues-comment${isAi ? " is-ai" : ""}`}>
1075 <header class="issues-comment-header">
1076 <strong>{commentAuthor.username}</strong>
1077 {isAi ? (
1078 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1079 AI Review
1080 </span>
1081 ) : null}
1082 <span>commented {formatRelative(comment.createdAt)}</span>
1083 </header>
1084 <div class="issues-comment-body">
1085 <div
1086 class="markdown-body"
1087 dangerouslySetInnerHTML={{
1088 __html: renderMarkdown(comment.body),
1089 }}
1090 />
1091 </div>
1092 </article>
1093 );
1094 })}
1095 </div>
79136bbClaude1096
1097 {user && (
f7ad7b8Claude1098 <form
1099 class="issues-composer"
1100 method="post"
1101 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1102 >
1103 <div class="issues-composer-header">
1104 <span class="issues-composer-tag">Add a comment</span>
1105 <span class="issues-composer-hint">
1106 <a
1107 href="https://docs.github.com/en/get-started/writing-on-github"
1108 target="_blank"
1109 rel="noopener noreferrer"
1110 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1111 >
1112 Markdown supported
1113 </a>
1114 </span>
1115 </div>
1116 <textarea
1117 name="body"
1118 rows={6}
1119 required
1120 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1121 />
1122 <div class="issues-composer-actions">
1123 <button type="submit" class="btn btn-primary">
1124 Comment
1125 </button>
1126 {canManage && (
1127 <button
1128 type="submit"
1129 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1130 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1131 >
1132 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1133 </button>
f7ad7b8Claude1134 )}
1135 {canManage && issue.state === "open" && (
1136 <button
1137 type="submit"
1138 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1139 formnovalidate
1140 class="btn"
1141 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1142 >
1143 Re-run AI triage
1144 </button>
1145 )}
1146 </div>
1147 </form>
79136bbClaude1148 )}
1149 </div>
1150 </Layout>
1151 );
1152});
1153
1154// Add comment
1155issueRoutes.post(
1156 "/:owner/:repo/issues/:number/comment",
1157 softAuth,
1158 requireAuth,
04f6b7fClaude1159 requireRepoAccess("write"),
79136bbClaude1160 async (c) => {
1161 const { owner: ownerName, repo: repoName } = c.req.param();
1162 const issueNum = parseInt(c.req.param("number"), 10);
1163 const user = c.get("user")!;
1164 const body = await c.req.parseBody();
1165 const commentBody = String(body.body || "").trim();
1166
1167 if (!commentBody) {
1168 return c.redirect(
1169 `/${ownerName}/${repoName}/issues/${issueNum}`
1170 );
1171 }
1172
1173 const resolved = await resolveRepo(ownerName, repoName);
1174 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1175
1176 const [issue] = await db
1177 .select()
1178 .from(issues)
1179 .where(
1180 and(
1181 eq(issues.repositoryId, resolved.repo.id),
1182 eq(issues.number, issueNum)
1183 )
1184 )
1185 .limit(1);
1186
1187 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1188
d4ac5c3Claude1189 const [inserted] = await db
1190 .insert(issueComments)
1191 .values({
1192 issueId: issue.id,
1193 authorId: user.id,
1194 body: commentBody,
1195 })
1196 .returning();
1197
1198 // Live update: nudge any browser tabs subscribed to this issue. Pure
1199 // fanout — never blocks the redirect, never throws into the request.
1200 if (inserted) {
1201 try {
1202 const { publish } = await import("../lib/sse");
1203 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1204 event: "issue-comment",
1205 data: {
1206 issueId: issue.id,
1207 commentId: inserted.id,
1208 authorId: user.id,
1209 authorUsername: user.username,
1210 },
1211 });
1212 } catch {
1213 /* SSE is best-effort */
1214 }
1215 }
79136bbClaude1216
1217 return c.redirect(
1218 `/${ownerName}/${repoName}/issues/${issueNum}`
1219 );
1220 }
1221);
1222
1223// Close issue
1224issueRoutes.post(
1225 "/:owner/:repo/issues/:number/close",
1226 softAuth,
1227 requireAuth,
04f6b7fClaude1228 requireRepoAccess("write"),
79136bbClaude1229 async (c) => {
1230 const { owner: ownerName, repo: repoName } = c.req.param();
1231 const issueNum = parseInt(c.req.param("number"), 10);
1232
1233 const resolved = await resolveRepo(ownerName, repoName);
1234 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1235
1236 await db
1237 .update(issues)
1238 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1239 .where(
1240 and(
1241 eq(issues.repositoryId, resolved.repo.id),
1242 eq(issues.number, issueNum)
1243 )
1244 );
1245
1246 return c.redirect(
1247 `/${ownerName}/${repoName}/issues/${issueNum}`
1248 );
1249 }
1250);
1251
1252// Reopen issue
1253issueRoutes.post(
1254 "/:owner/:repo/issues/:number/reopen",
1255 softAuth,
1256 requireAuth,
04f6b7fClaude1257 requireRepoAccess("write"),
79136bbClaude1258 async (c) => {
1259 const { owner: ownerName, repo: repoName } = c.req.param();
1260 const issueNum = parseInt(c.req.param("number"), 10);
1261
1262 const resolved = await resolveRepo(ownerName, repoName);
1263 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1264
1265 await db
1266 .update(issues)
1267 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1268 .where(
1269 and(
1270 eq(issues.repositoryId, resolved.repo.id),
1271 eq(issues.number, issueNum)
1272 )
1273 );
1274
1275 return c.redirect(
1276 `/${ownerName}/${repoName}/issues/${issueNum}`
1277 );
1278 }
1279);
1280
1281// Shared nav component with issues tab
1282const IssueNav = ({
1283 owner,
1284 repo,
1285 active,
1286}: {
1287 owner: string;
1288 repo: string;
1289 active: "code" | "commits" | "issues";
1290}) => (
bb0f894Claude1291 <TabNav
1292 tabs={[
1293 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1294 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1295 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1296 ]}
1297 />
79136bbClaude1298);
1299
58915a9Claude1300// Re-run AI triage on demand (e.g. after the issue body has been edited).
1301// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1302issueRoutes.post(
1303 "/:owner/:repo/issues/:number/ai-retriage",
1304 softAuth,
1305 requireAuth,
1306 requireRepoAccess("write"),
1307 async (c) => {
1308 const { owner: ownerName, repo: repoName } = c.req.param();
1309 const issueNum = parseInt(c.req.param("number"), 10);
1310 const user = c.get("user")!;
1311 const resolved = await resolveRepo(ownerName, repoName);
1312 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1313
1314 const [issue] = await db
1315 .select()
1316 .from(issues)
1317 .where(
1318 and(
1319 eq(issues.repositoryId, resolved.repo.id),
1320 eq(issues.number, issueNum)
1321 )
1322 )
1323 .limit(1);
1324 if (!issue) {
1325 return c.redirect(`/${ownerName}/${repoName}/issues`);
1326 }
1327
1328 triggerIssueTriage(
1329 {
1330 ownerName,
1331 repoName,
1332 repositoryId: resolved.repo.id,
1333 issueId: issue.id,
1334 issueNumber: issue.number,
1335 authorId: user.id,
1336 title: issue.title,
1337 body: issue.body || "",
1338 },
1339 { force: true }
a28cedeClaude1340 ).catch((err) => {
1341 console.warn(
1342 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1343 err instanceof Error ? err.message : err
1344 );
1345 });
58915a9Claude1346
1347 return c.redirect(
1348 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1349 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1350 )}`
1351 );
1352 }
1353);
1354
79136bbClaude1355export default issueRoutes;
1356export { IssueNav };