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.tsxBlame1407 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
f1dc7c7Claude150 /* Mobile rules — added in the 720px sweep. Kept additive only. */
151 @media (max-width: 720px) {
152 .issues-toolbar { flex-direction: column; align-items: stretch; }
153 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
154 .issues-filter { min-height: 40px; padding: 10px 14px; }
155 .issues-row { padding: 12px 14px; gap: 10px; }
156 .issues-row-side { flex-wrap: wrap; gap: 8px; }
157 .issues-detail-hero { padding: 18px; }
158 .issues-detail-attr { font-size: 13px; }
159 .issues-composer-actions { gap: 8px; }
160 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
161 .issues-empty { padding: 40px 20px; }
162 }
163
f7ad7b8Claude164 /* Count chip + filter pills */
165 .issues-toolbar {
166 display: flex;
167 align-items: center;
168 justify-content: space-between;
169 gap: 12px;
170 flex-wrap: wrap;
171 margin: 0 0 16px;
172 }
173 .issues-filters {
174 display: inline-flex;
175 background: var(--bg-elevated);
176 border: 1px solid var(--border);
177 border-radius: 9999px;
178 padding: 4px;
179 gap: 2px;
180 }
181 .issues-filter {
182 display: inline-flex;
183 align-items: center;
184 gap: 6px;
185 padding: 6px 14px;
186 border-radius: 9999px;
187 font-size: 13px;
188 font-weight: 500;
189 color: var(--text-muted);
190 text-decoration: none;
191 transition: color 120ms ease, background 120ms ease;
192 line-height: 1.4;
193 }
194 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
195 .issues-filter.is-active {
196 background: rgba(140,109,255,0.14);
197 color: var(--text-strong);
198 }
199 .issues-filter-count {
200 font-variant-numeric: tabular-nums;
201 font-size: 11.5px;
202 color: var(--text-muted);
203 background: rgba(255,255,255,0.04);
204 padding: 1px 7px;
205 border-radius: 9999px;
206 }
207 .issues-filter.is-active .issues-filter-count {
208 background: rgba(140,109,255,0.18);
209 color: var(--text);
210 }
211
212 /* Issue list — modernised rows */
213 .issues-list {
214 list-style: none;
215 margin: 0;
216 padding: 0;
217 border: 1px solid var(--border);
218 border-radius: 12px;
219 overflow: hidden;
220 background: var(--bg-elevated);
221 }
222 .issues-row {
223 display: flex;
224 align-items: flex-start;
225 gap: 14px;
226 padding: 14px 18px;
227 border-bottom: 1px solid var(--border);
228 transition: background 120ms ease, transform 120ms ease;
229 }
230 .issues-row:last-child { border-bottom: none; }
231 .issues-row:hover { background: rgba(140,109,255,0.04); }
232 .issues-row-icon {
233 width: 18px;
234 height: 18px;
235 flex-shrink: 0;
236 margin-top: 3px;
237 display: inline-flex;
238 align-items: center;
239 justify-content: center;
240 }
241 .issues-row-icon.is-open { color: #34d399; }
242 .issues-row-icon.is-closed { color: #b69dff; }
243 .issues-row-main { flex: 1; min-width: 0; }
244 .issues-row-title {
245 font-family: var(--font-display);
246 font-size: 15.5px;
247 font-weight: 600;
248 line-height: 1.35;
249 letter-spacing: -0.012em;
250 margin: 0;
251 display: flex;
252 flex-wrap: wrap;
253 align-items: center;
254 gap: 8px;
255 }
256 .issues-row-title a {
257 color: var(--text-strong);
258 text-decoration: none;
259 transition: color 120ms ease;
260 }
261 .issues-row-title a:hover { color: var(--accent); }
262 .issues-row-meta {
263 margin-top: 5px;
264 font-size: 12.5px;
265 color: var(--text-muted);
266 line-height: 1.5;
267 }
268 .issues-row-meta strong { color: var(--text); font-weight: 600; }
269 .issues-row-side {
270 display: flex;
271 align-items: center;
272 gap: 12px;
273 color: var(--text-muted);
274 font-size: 12.5px;
275 flex-shrink: 0;
276 }
277 .issues-row-comments {
278 display: inline-flex;
279 align-items: center;
280 gap: 5px;
281 color: var(--text-muted);
282 text-decoration: none;
283 }
284 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
285
286 /* Label pills (rendered inline on titles) */
287 .issues-label {
288 display: inline-flex;
289 align-items: center;
290 padding: 2px 9px;
291 border-radius: 9999px;
292 font-size: 11.5px;
293 font-weight: 600;
294 line-height: 1.4;
295 background: rgba(140,109,255,0.10);
296 color: var(--text-strong);
297 border: 1px solid rgba(140,109,255,0.28);
298 letter-spacing: 0.005em;
299 }
300
301 /* Polished empty state */
302 .issues-empty {
303 margin: 0;
304 padding: 56px 32px;
305 background: var(--bg-elevated);
306 border: 1px solid var(--border);
307 border-radius: 16px;
308 text-align: center;
309 position: relative;
310 overflow: hidden;
311 }
312 .issues-empty::before {
313 content: '';
314 position: absolute;
315 top: 0; left: 0; right: 0;
316 height: 2px;
317 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
318 opacity: 0.55;
319 pointer-events: none;
320 }
321 .issues-empty-art {
322 width: 96px;
323 height: 96px;
324 margin: 0 auto 18px;
325 display: block;
326 opacity: 0.85;
327 }
328 .issues-empty-title {
329 font-family: var(--font-display);
330 font-size: 22px;
331 font-weight: 700;
332 letter-spacing: -0.018em;
333 color: var(--text-strong);
334 margin: 0 0 8px;
335 }
336 .issues-empty-sub {
337 font-size: 14.5px;
338 color: var(--text-muted);
339 line-height: 1.55;
340 margin: 0 auto 22px;
341 max-width: 460px;
342 }
343 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
344
345 /* ─── Detail page ─── */
346 .issues-detail-hero {
347 position: relative;
348 margin: 4px 0 20px;
349 padding: 22px 26px;
350 background: var(--bg-elevated);
351 border: 1px solid var(--border);
352 border-radius: 16px;
353 overflow: hidden;
354 }
355 .issues-detail-hero::before {
356 content: '';
357 position: absolute;
358 top: 0; left: 0; right: 0;
359 height: 2px;
360 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
361 opacity: 0.7;
362 pointer-events: none;
363 }
364 .issues-detail-title {
365 font-family: var(--font-display);
366 font-size: clamp(22px, 3vw, 30px);
367 font-weight: 700;
368 letter-spacing: -0.022em;
369 line-height: 1.18;
370 color: var(--text-strong);
371 margin: 0 0 12px;
372 }
373 .issues-detail-title .issues-detail-number {
374 color: var(--text-muted);
375 font-weight: 500;
376 margin-left: 8px;
377 }
378 .issues-detail-attr {
379 display: flex;
380 align-items: center;
381 gap: 12px;
382 flex-wrap: wrap;
383 font-size: 14px;
384 color: var(--text-muted);
385 }
386 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
387 .issues-state-pill {
388 display: inline-flex;
389 align-items: center;
390 gap: 6px;
391 padding: 5px 12px;
392 border-radius: 9999px;
393 font-size: 12.5px;
394 font-weight: 600;
395 line-height: 1.4;
396 letter-spacing: 0.005em;
397 }
398 .issues-state-pill.is-open {
399 background: rgba(52,211,153,0.12);
400 color: #34d399;
401 border: 1px solid rgba(52,211,153,0.35);
402 }
403 .issues-state-pill.is-closed {
404 background: rgba(182,157,255,0.12);
405 color: #b69dff;
406 border: 1px solid rgba(182,157,255,0.35);
407 }
408 .issues-detail-spacer { flex: 1; }
409 .issues-detail-labels {
410 margin-top: 12px;
411 display: flex;
412 gap: 6px;
413 flex-wrap: wrap;
414 }
415
416 /* Comment thread */
417 .issues-thread { margin-top: 18px; }
418 .issues-comment {
419 position: relative;
420 border: 1px solid var(--border);
421 border-radius: 12px;
422 overflow: hidden;
423 background: var(--bg-elevated);
424 margin-bottom: 14px;
425 transition: border-color 160ms ease, box-shadow 160ms ease;
426 }
427 .issues-comment:hover {
428 border-color: var(--border-strong, rgba(255,255,255,0.13));
429 }
430 .issues-comment-header {
431 background: var(--bg-secondary);
432 padding: 10px 16px;
433 border-bottom: 1px solid var(--border);
434 display: flex;
435 align-items: center;
436 gap: 10px;
437 font-size: 13px;
438 color: var(--text-muted);
439 }
440 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
441 .issues-comment-body { padding: 14px 18px; }
442 .issues-comment-author-pill {
443 display: inline-flex;
444 align-items: center;
445 padding: 1px 8px;
446 border-radius: 9999px;
447 background: rgba(140,109,255,0.10);
448 color: var(--accent);
449 font-size: 11px;
450 font-weight: 600;
451 letter-spacing: 0.02em;
452 text-transform: uppercase;
453 }
454
455 /* AI Review comment — distinct purple-accent treatment */
456 .issues-comment.is-ai {
457 border-color: rgba(140,109,255,0.45);
458 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
459 }
460 .issues-comment.is-ai::before {
461 content: '';
462 position: absolute;
463 top: 0; left: 0; right: 0;
464 height: 2px;
465 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
466 pointer-events: none;
467 }
468 .issues-comment.is-ai .issues-comment-header {
469 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
470 border-bottom-color: rgba(140,109,255,0.22);
471 }
472 .issues-ai-badge {
473 display: inline-flex;
474 align-items: center;
475 gap: 5px;
476 padding: 2px 9px;
477 border-radius: 9999px;
478 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
479 color: #fff;
480 font-size: 11px;
481 font-weight: 700;
482 letter-spacing: 0.04em;
483 text-transform: uppercase;
484 line-height: 1.4;
485 }
486 .issues-ai-badge::before {
487 content: '';
488 width: 6px;
489 height: 6px;
490 border-radius: 9999px;
491 background: #fff;
492 opacity: 0.92;
493 box-shadow: 0 0 6px rgba(255,255,255,0.7);
494 }
495
496 /* Composer */
497 .issues-composer {
498 margin-top: 22px;
499 border: 1px solid var(--border);
500 border-radius: 12px;
501 overflow: hidden;
502 background: var(--bg-elevated);
503 transition: border-color 160ms ease, box-shadow 160ms ease;
504 }
505 .issues-composer:focus-within {
506 border-color: rgba(140,109,255,0.55);
507 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
508 }
509 .issues-composer-header {
510 display: flex;
511 align-items: center;
512 justify-content: space-between;
513 gap: 8px;
514 padding: 10px 14px;
515 background: var(--bg-secondary);
516 border-bottom: 1px solid var(--border);
517 font-size: 12.5px;
518 color: var(--text-muted);
519 }
520 .issues-composer-tag {
521 font-weight: 600;
522 color: var(--text);
523 letter-spacing: -0.005em;
524 }
525 .issues-composer-hint a {
526 color: var(--text-muted);
527 text-decoration: none;
528 border-bottom: 1px dashed currentColor;
529 }
530 .issues-composer-hint a:hover { color: var(--accent); }
531 .issues-composer textarea {
532 display: block;
533 width: 100%;
534 border: 0;
535 background: transparent;
536 color: var(--text);
537 padding: 14px 16px;
538 font-family: var(--font-mono);
539 font-size: 13.5px;
540 line-height: 1.55;
541 resize: vertical;
542 outline: none;
543 min-height: 140px;
544 }
545 .issues-composer-actions {
546 display: flex;
547 align-items: center;
548 gap: 8px;
549 padding: 10px 14px;
550 border-top: 1px solid var(--border);
551 background: var(--bg-secondary);
552 flex-wrap: wrap;
553 }
554
555 /* Info banner inside detail */
556 .issues-info-banner {
557 margin: 0 0 14px;
558 padding: 10px 14px;
559 border-radius: 12px;
560 background: rgba(140,109,255,0.08);
561 border: 1px solid rgba(140,109,255,0.28);
562 color: var(--text);
563 font-size: 13.5px;
564 }
565`;
566
567// Pre-rendered <style> tag (constant, reused per request).
568const IssuesStyle = () => (
569 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
570);
571
572// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
573const IssuesEmptySvg = () => (
574 <svg
575 class="issues-empty-art"
576 viewBox="0 0 96 96"
577 fill="none"
578 xmlns="http://www.w3.org/2000/svg"
579 aria-hidden="true"
580 >
581 <defs>
582 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
583 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
584 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
585 </linearGradient>
586 </defs>
587 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
588 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
589 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
590 </svg>
591);
592
593// Detect AI Triage comments by the marker the triage helper writes.
594function isAiTriageComment(body: string | null | undefined): boolean {
595 if (!body) return false;
596 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
597}
598
79136bbClaude599// Helper to resolve repo from :owner/:repo params
600async function resolveRepo(ownerName: string, repoName: string) {
601 const [owner] = await db
602 .select()
603 .from(users)
604 .where(eq(users.username, ownerName))
605 .limit(1);
606 if (!owner) return null;
607
608 const [repo] = await db
609 .select()
610 .from(repositories)
611 .where(
612 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
613 )
614 .limit(1);
615 if (!repo) return null;
616
617 return { owner, repo };
618}
619
620// Issue list
04f6b7fClaude621issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude622 const { owner: ownerName, repo: repoName } = c.req.param();
623 const user = c.get("user");
624 const state = c.req.query("state") || "open";
6ea2109Claude625 // Bounded pagination — unbounded selects ran a full table scan + O(n)
626 // sort on every page load; with 10k+ issues the request would hang.
627 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
628 const page = Math.max(1, Number(c.req.query("page")) || 1);
629 const offset = (page - 1) * perPage;
79136bbClaude630
f1dc7c7Claude631 // ── Loading skeleton (flag-gated) ──
632 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
633 // user see the shape of the issue list before the DB count + select
634 // resolve. Behind a flag — we don't ship flashes.
635 if (c.req.query("skeleton") === "1") {
636 return c.html(
637 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
638 <IssuesStyle />
639 <RepoHeader owner={ownerName} repo={repoName} />
640 <IssueNav owner={ownerName} repo={repoName} active="issues" />
641 <style
642 dangerouslySetInnerHTML={{
643 __html: `
644 .issues-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: issuesSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
645 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
646 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
647 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
648 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
649 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
650 .issues-skel-row { height: 62px; border-radius: 10px; }
651 `,
652 }}
653 />
654 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
655 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
656 <div class="issues-skel-list" aria-hidden="true">
657 {Array.from({ length: 8 }).map(() => (
658 <div class="issues-skel issues-skel-row" />
659 ))}
660 </div>
661 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
662 Loading issues for {ownerName}/{repoName}…
663 </span>
664 </Layout>
665 );
666 }
667
79136bbClaude668 const resolved = await resolveRepo(ownerName, repoName);
669 if (!resolved) {
670 return c.html(
671 <Layout title="Not Found" user={user}>
bb0f894Claude672 <EmptyState title="Repository not found" />
79136bbClaude673 </Layout>,
674 404
675 );
676 }
677
678 const { repo } = resolved;
679
680 const issueList = await db
681 .select({
682 issue: issues,
683 author: { username: users.username },
684 })
685 .from(issues)
686 .innerJoin(users, eq(issues.authorId, users.id))
687 .where(
688 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
689 )
6ea2109Claude690 .orderBy(desc(issues.createdAt))
691 .limit(perPage)
692 .offset(offset);
79136bbClaude693
694 // Count open/closed
695 const [counts] = await db
696 .select({
697 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
698 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
699 })
700 .from(issues)
701 .where(eq(issues.repositoryId, repo.id));
702
703 return c.html(
704 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude705 <IssuesStyle />
79136bbClaude706 <RepoHeader owner={ownerName} repo={repoName} />
707 <IssueNav owner={ownerName} repo={repoName} active="issues" />
f7ad7b8Claude708 <section class="issues-hero">
709 <div class="issues-hero-bg" aria-hidden="true">
710 <div class="issues-hero-orb" />
711 </div>
712 <div class="issues-hero-inner">
713 <div class="issues-hero-text">
714 <div class="issues-hero-eyebrow">
715 Issue tracker \u00B7{" "}
716 <span class="issues-hero-repo">
717 {ownerName}/{repoName}
718 </span>
719 </div>
720 <h1 class="issues-hero-title">
721 Track <span class="gradient-text">what matters</span>.
722 </h1>
723 <p class="issues-hero-sub">
724 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
725 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
726 : `${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.`}
727 </p>
728 </div>
729 <div class="issues-hero-actions">
730 {user && (
731 <a
732 href={`/${ownerName}/${repoName}/issues/new`}
733 class="btn btn-primary"
734 >
735 + New issue
736 </a>
737 )}
738 <a href={`/${ownerName}/${repoName}`} class="btn">
739 Back to code
740 </a>
741 </div>
742 </div>
743 </section>
744
745 <div class="issues-toolbar">
746 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
747 <a
748 class={`issues-filter${state === "open" ? " is-active" : ""}`}
749 href={`/${ownerName}/${repoName}/issues?state=open`}
750 role="tab"
751 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude752 >
f7ad7b8Claude753 <span aria-hidden="true">{"\u25CB"}</span>
754 <span>Open</span>
755 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
756 </a>
757 <a
758 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
759 href={`/${ownerName}/${repoName}/issues?state=closed`}
760 role="tab"
761 aria-selected={state === "closed" ? "true" : "false"}
762 >
763 <span aria-hidden="true">{"\u2713"}</span>
764 <span>Closed</span>
765 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
766 </a>
767 </div>
768 </div>
769
79136bbClaude770 {issueList.length === 0 ? (
f7ad7b8Claude771 <div class="issues-empty">
772 <IssuesEmptySvg />
773 <h2 class="issues-empty-title">
774 {state === "closed"
775 ? "No closed issues yet"
776 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
777 ? "No issues \u2014 yet"
778 : "Nothing open right now"}
779 </h2>
780 <p class="issues-empty-sub">
781 {state === "closed"
782 ? "Closed issues will show up here once the team starts shipping fixes."
783 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
784 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
785 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
786 </p>
787 <div class="issues-empty-cta">
788 {user && state !== "closed" && (
789 <a
790 href={`/${ownerName}/${repoName}/issues/new`}
791 class="btn btn-primary"
792 >
793 + Open the first issue
794 </a>
795 )}
79136bbClaude796 {state === "closed" && (
f7ad7b8Claude797 <a
798 href={`/${ownerName}/${repoName}/issues?state=open`}
799 class="btn"
800 >
801 View open issues
802 </a>
79136bbClaude803 )}
f7ad7b8Claude804 </div>
805 </div>
79136bbClaude806 ) : (
f7ad7b8Claude807 <ul class="issues-list">
79136bbClaude808 {issueList.map(({ issue, author }) => (
f7ad7b8Claude809 <li class="issues-row">
79136bbClaude810 <div
f7ad7b8Claude811 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
812 aria-hidden="true"
813 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude814 >
815 {issue.state === "open" ? "\u25CB" : "\u2713"}
816 </div>
f7ad7b8Claude817 <div class="issues-row-main">
818 <h3 class="issues-row-title">
79136bbClaude819 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
820 {issue.title}
821 </a>
f7ad7b8Claude822 </h3>
823 <div class="issues-row-meta">
824 #{issue.number} opened by{" "}
825 <strong>{author.username}</strong>{" "}
79136bbClaude826 {formatRelative(issue.createdAt)}
827 </div>
828 </div>
f7ad7b8Claude829 </li>
79136bbClaude830 ))}
f7ad7b8Claude831 </ul>
79136bbClaude832 )}
833 </Layout>
834 );
835});
836
837// New issue form
838issueRoutes.get(
839 "/:owner/:repo/issues/new",
840 softAuth,
841 requireAuth,
04f6b7fClaude842 requireRepoAccess("write"),
79136bbClaude843 async (c) => {
844 const { owner: ownerName, repo: repoName } = c.req.param();
845 const user = c.get("user")!;
846 const error = c.req.query("error");
24cf2caClaude847 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude848
849 return c.html(
850 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude851 <IssuesStyle />
79136bbClaude852 <RepoHeader owner={ownerName} repo={repoName} />
853 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude854 <Container maxWidth={800}>
f7ad7b8Claude855 <section class="issues-hero" style="margin-top:4px">
856 <div class="issues-hero-bg" aria-hidden="true">
857 <div class="issues-hero-orb" />
858 </div>
859 <div class="issues-hero-inner">
860 <div class="issues-hero-text">
861 <div class="issues-hero-eyebrow">
862 New issue ·{" "}
863 <span class="issues-hero-repo">
864 {ownerName}/{repoName}
865 </span>
866 </div>
867 <h1 class="issues-hero-title">
868 File <span class="gradient-text">it cleanly</span>.
869 </h1>
870 <p class="issues-hero-sub">
871 AI Triage will read the body the moment you submit and post
872 suggested labels, priority, and possible duplicates within
873 seconds. You stay in control — nothing is applied.
874 </p>
875 </div>
876 </div>
877 </section>
79136bbClaude878 {error && (
bb0f894Claude879 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude880 )}
0316dbbClaude881 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
882 <FormGroup>
883 <Input
79136bbClaude884 type="text"
885 name="title"
886 required
887 placeholder="Title"
bb0f894Claude888 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]889 aria-label="Issue title"
79136bbClaude890 />
bb0f894Claude891 </FormGroup>
892 <FormGroup>
893 <TextArea
79136bbClaude894 name="body"
895 rows={12}
896 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude897 mono
79136bbClaude898 />
bb0f894Claude899 </FormGroup>
900 <Button type="submit" variant="primary">
79136bbClaude901 Submit new issue
bb0f894Claude902 </Button>
903 </Form>
904 </Container>
79136bbClaude905 </Layout>
906 );
907 }
908);
909
910// Create issue
911issueRoutes.post(
912 "/:owner/:repo/issues/new",
913 softAuth,
914 requireAuth,
04f6b7fClaude915 requireRepoAccess("write"),
79136bbClaude916 async (c) => {
917 const { owner: ownerName, repo: repoName } = c.req.param();
918 const user = c.get("user")!;
919 const body = await c.req.parseBody();
920 const title = String(body.title || "").trim();
921 const issueBody = String(body.body || "").trim();
922
923 if (!title) {
924 return c.redirect(
925 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
926 );
927 }
928
929 const resolved = await resolveRepo(ownerName, repoName);
930 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
931
932 const [issue] = await db
933 .insert(issues)
934 .values({
935 repositoryId: resolved.repo.id,
936 authorId: user.id,
937 title,
938 body: issueBody || null,
939 })
940 .returning();
941
942 // Update issue count
943 await db
944 .update(repositories)
945 .set({ issueCount: resolved.repo.issueCount + 1 })
946 .where(eq(repositories.id, resolved.repo.id));
947
a9ada5fClaude948 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
949 // suggested labels, priority, summary, and a possible-duplicate
950 // callout. Suggestions only — nothing applied automatically.
951 triggerIssueTriage({
952 ownerName,
953 repoName,
954 repositoryId: resolved.repo.id,
955 issueId: issue.id,
956 issueNumber: issue.number,
957 authorId: user.id,
958 title,
959 body: issueBody,
a28cedeClaude960 }).catch((err) => {
961 console.warn(
962 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
963 err instanceof Error ? err.message : err
964 );
965 });
a9ada5fClaude966
79136bbClaude967 return c.redirect(
968 `/${ownerName}/${repoName}/issues/${issue.number}`
969 );
970 }
971);
972
973// View single issue
04f6b7fClaude974issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude975 const { owner: ownerName, repo: repoName } = c.req.param();
976 const issueNum = parseInt(c.req.param("number"), 10);
977 const user = c.get("user");
978
979 const resolved = await resolveRepo(ownerName, repoName);
980 if (!resolved) {
981 return c.html(
982 <Layout title="Not Found" user={user}>
bb0f894Claude983 <EmptyState title="Not found" />
79136bbClaude984 </Layout>,
985 404
986 );
987 }
988
989 const [issue] = await db
990 .select()
991 .from(issues)
992 .where(
993 and(
994 eq(issues.repositoryId, resolved.repo.id),
995 eq(issues.number, issueNum)
996 )
997 )
998 .limit(1);
999
1000 if (!issue) {
1001 return c.html(
1002 <Layout title="Not Found" user={user}>
bb0f894Claude1003 <EmptyState title="Issue not found" />
79136bbClaude1004 </Layout>,
1005 404
1006 );
1007 }
1008
1009 const [author] = await db
1010 .select()
1011 .from(users)
1012 .where(eq(users.id, issue.authorId))
1013 .limit(1);
1014
1015 // Get comments
1016 const comments = await db
1017 .select({
1018 comment: issueComments,
1019 author: { username: users.username },
1020 })
1021 .from(issueComments)
1022 .innerJoin(users, eq(issueComments.authorId, users.id))
1023 .where(eq(issueComments.issueId, issue.id))
1024 .orderBy(asc(issueComments.createdAt));
1025
6fc53bdClaude1026 // Load reactions for the issue + each comment in parallel.
1027 const [issueReactions, ...commentReactions] = await Promise.all([
1028 summariseReactions("issue", issue.id, user?.id),
1029 ...comments.map((row) =>
1030 summariseReactions("issue_comment", row.comment.id, user?.id)
1031 ),
1032 ]);
1033
79136bbClaude1034 const canManage =
1035 user &&
1036 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1037 const info = c.req.query("info");
79136bbClaude1038
1039 return c.html(
1040 <Layout
1041 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1042 user={user}
1043 >
f7ad7b8Claude1044 <IssuesStyle />
79136bbClaude1045 <RepoHeader owner={ownerName} repo={repoName} />
1046 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude1047 <div
1048 id="live-comment-banner"
1049 class="alert"
1050 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1051 >
1052 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1053 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1054 reload to view
1055 </a>
1056 </div>
1057 <script
1058 dangerouslySetInnerHTML={{
1059 __html: liveCommentBannerScript({
1060 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1061 bannerElementId: "live-comment-banner",
1062 }),
1063 }}
1064 />
79136bbClaude1065 <div class="issue-detail">
58915a9Claude1066 {info && (
f7ad7b8Claude1067 <div class="issues-info-banner">
58915a9Claude1068 {decodeURIComponent(info)}
1069 </div>
1070 )}
f7ad7b8Claude1071
1072 <section class="issues-detail-hero">
1073 <h1 class="issues-detail-title">
1074 {issue.title}
1075 <span class="issues-detail-number">#{issue.number}</span>
1076 </h1>
1077 <div class="issues-detail-attr">
1078 <span
1079 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1080 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1081 >
f7ad7b8Claude1082 <span aria-hidden="true">
1083 {issue.state === "open" ? "\u25CB" : "\u2713"}
1084 </span>
1085 {issue.state === "open" ? "Open" : "Closed"}
1086 </span>
1087 <span>
1088 <strong>{author?.username || "unknown"}</strong> opened this
1089 issue {formatRelative(issue.createdAt)}
1090 </span>
1091 <span class="issues-detail-spacer" />
1092 {issue.state === "open" && user && user.id === resolved.owner.id && (
1093 <a
1094 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1095 class="btn btn-primary"
1096 style="font-size:13px;padding:6px 12px"
1097 title="Generate a draft pull request from this issue using Claude"
1098 >
1099 Build with AI
1100 </a>
1101 )}
1102 </div>
1103 </section>
1104
1105 <div class="issues-thread">
1106 {issue.body && (
1107 <article class="issues-comment">
1108 <header class="issues-comment-header">
1109 <strong>{author?.username || "unknown"}</strong>
1110 <span class="issues-comment-author-pill">Author</span>
1111 <span>commented {formatRelative(issue.createdAt)}</span>
1112 </header>
1113 <div class="issues-comment-body">
1114 <div
1115 class="markdown-body"
1116 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1117 />
1118 </div>
1119 </article>
fbf4aefClaude1120 )}
79136bbClaude1121
f7ad7b8Claude1122 {comments.map(({ comment, author: commentAuthor }) => {
1123 const isAi = isAiTriageComment(comment.body);
1124 return (
1125 <article class={`issues-comment${isAi ? " is-ai" : ""}`}>
1126 <header class="issues-comment-header">
1127 <strong>{commentAuthor.username}</strong>
1128 {isAi ? (
1129 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1130 AI Review
1131 </span>
1132 ) : null}
1133 <span>commented {formatRelative(comment.createdAt)}</span>
1134 </header>
1135 <div class="issues-comment-body">
1136 <div
1137 class="markdown-body"
1138 dangerouslySetInnerHTML={{
1139 __html: renderMarkdown(comment.body),
1140 }}
1141 />
1142 </div>
1143 </article>
1144 );
1145 })}
1146 </div>
79136bbClaude1147
1148 {user && (
f7ad7b8Claude1149 <form
1150 class="issues-composer"
1151 method="post"
1152 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1153 >
1154 <div class="issues-composer-header">
1155 <span class="issues-composer-tag">Add a comment</span>
1156 <span class="issues-composer-hint">
1157 <a
1158 href="https://docs.github.com/en/get-started/writing-on-github"
1159 target="_blank"
1160 rel="noopener noreferrer"
1161 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1162 >
1163 Markdown supported
1164 </a>
1165 </span>
1166 </div>
1167 <textarea
1168 name="body"
1169 rows={6}
1170 required
1171 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1172 />
1173 <div class="issues-composer-actions">
1174 <button type="submit" class="btn btn-primary">
1175 Comment
1176 </button>
1177 {canManage && (
1178 <button
1179 type="submit"
1180 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1181 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1182 >
1183 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1184 </button>
f7ad7b8Claude1185 )}
1186 {canManage && issue.state === "open" && (
1187 <button
1188 type="submit"
1189 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1190 formnovalidate
1191 class="btn"
1192 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1193 >
1194 Re-run AI triage
1195 </button>
1196 )}
1197 </div>
1198 </form>
79136bbClaude1199 )}
1200 </div>
1201 </Layout>
1202 );
1203});
1204
1205// Add comment
1206issueRoutes.post(
1207 "/:owner/:repo/issues/:number/comment",
1208 softAuth,
1209 requireAuth,
04f6b7fClaude1210 requireRepoAccess("write"),
79136bbClaude1211 async (c) => {
1212 const { owner: ownerName, repo: repoName } = c.req.param();
1213 const issueNum = parseInt(c.req.param("number"), 10);
1214 const user = c.get("user")!;
1215 const body = await c.req.parseBody();
1216 const commentBody = String(body.body || "").trim();
1217
1218 if (!commentBody) {
1219 return c.redirect(
1220 `/${ownerName}/${repoName}/issues/${issueNum}`
1221 );
1222 }
1223
1224 const resolved = await resolveRepo(ownerName, repoName);
1225 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1226
1227 const [issue] = await db
1228 .select()
1229 .from(issues)
1230 .where(
1231 and(
1232 eq(issues.repositoryId, resolved.repo.id),
1233 eq(issues.number, issueNum)
1234 )
1235 )
1236 .limit(1);
1237
1238 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1239
d4ac5c3Claude1240 const [inserted] = await db
1241 .insert(issueComments)
1242 .values({
1243 issueId: issue.id,
1244 authorId: user.id,
1245 body: commentBody,
1246 })
1247 .returning();
1248
1249 // Live update: nudge any browser tabs subscribed to this issue. Pure
1250 // fanout — never blocks the redirect, never throws into the request.
1251 if (inserted) {
1252 try {
1253 const { publish } = await import("../lib/sse");
1254 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1255 event: "issue-comment",
1256 data: {
1257 issueId: issue.id,
1258 commentId: inserted.id,
1259 authorId: user.id,
1260 authorUsername: user.username,
1261 },
1262 });
1263 } catch {
1264 /* SSE is best-effort */
1265 }
1266 }
79136bbClaude1267
1268 return c.redirect(
1269 `/${ownerName}/${repoName}/issues/${issueNum}`
1270 );
1271 }
1272);
1273
1274// Close issue
1275issueRoutes.post(
1276 "/:owner/:repo/issues/:number/close",
1277 softAuth,
1278 requireAuth,
04f6b7fClaude1279 requireRepoAccess("write"),
79136bbClaude1280 async (c) => {
1281 const { owner: ownerName, repo: repoName } = c.req.param();
1282 const issueNum = parseInt(c.req.param("number"), 10);
1283
1284 const resolved = await resolveRepo(ownerName, repoName);
1285 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1286
1287 await db
1288 .update(issues)
1289 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1290 .where(
1291 and(
1292 eq(issues.repositoryId, resolved.repo.id),
1293 eq(issues.number, issueNum)
1294 )
1295 );
1296
1297 return c.redirect(
1298 `/${ownerName}/${repoName}/issues/${issueNum}`
1299 );
1300 }
1301);
1302
1303// Reopen issue
1304issueRoutes.post(
1305 "/:owner/:repo/issues/:number/reopen",
1306 softAuth,
1307 requireAuth,
04f6b7fClaude1308 requireRepoAccess("write"),
79136bbClaude1309 async (c) => {
1310 const { owner: ownerName, repo: repoName } = c.req.param();
1311 const issueNum = parseInt(c.req.param("number"), 10);
1312
1313 const resolved = await resolveRepo(ownerName, repoName);
1314 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1315
1316 await db
1317 .update(issues)
1318 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1319 .where(
1320 and(
1321 eq(issues.repositoryId, resolved.repo.id),
1322 eq(issues.number, issueNum)
1323 )
1324 );
1325
1326 return c.redirect(
1327 `/${ownerName}/${repoName}/issues/${issueNum}`
1328 );
1329 }
1330);
1331
1332// Shared nav component with issues tab
1333const IssueNav = ({
1334 owner,
1335 repo,
1336 active,
1337}: {
1338 owner: string;
1339 repo: string;
1340 active: "code" | "commits" | "issues";
1341}) => (
bb0f894Claude1342 <TabNav
1343 tabs={[
1344 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1345 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1346 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1347 ]}
1348 />
79136bbClaude1349);
1350
58915a9Claude1351// Re-run AI triage on demand (e.g. after the issue body has been edited).
1352// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1353issueRoutes.post(
1354 "/:owner/:repo/issues/:number/ai-retriage",
1355 softAuth,
1356 requireAuth,
1357 requireRepoAccess("write"),
1358 async (c) => {
1359 const { owner: ownerName, repo: repoName } = c.req.param();
1360 const issueNum = parseInt(c.req.param("number"), 10);
1361 const user = c.get("user")!;
1362 const resolved = await resolveRepo(ownerName, repoName);
1363 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1364
1365 const [issue] = await db
1366 .select()
1367 .from(issues)
1368 .where(
1369 and(
1370 eq(issues.repositoryId, resolved.repo.id),
1371 eq(issues.number, issueNum)
1372 )
1373 )
1374 .limit(1);
1375 if (!issue) {
1376 return c.redirect(`/${ownerName}/${repoName}/issues`);
1377 }
1378
1379 triggerIssueTriage(
1380 {
1381 ownerName,
1382 repoName,
1383 repositoryId: resolved.repo.id,
1384 issueId: issue.id,
1385 issueNumber: issue.number,
1386 authorId: user.id,
1387 title: issue.title,
1388 body: issue.body || "",
1389 },
1390 { force: true }
a28cedeClaude1391 ).catch((err) => {
1392 console.warn(
1393 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1394 err instanceof Error ? err.message : err
1395 );
1396 });
58915a9Claude1397
1398 return c.redirect(
1399 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1400 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1401 )}`
1402 );
1403 }
1404);
1405
79136bbClaude1406export default issueRoutes;
1407export { IssueNav };