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.tsxBlame1735 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
240c477Claude6import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
79136bbClaude7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
240c477Claude15 pullRequests,
79136bbClaude16} from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude19import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude20import { ReactionsBar } from "../views/reactions";
21import { summariseReactions } from "../lib/reactions";
24cf2caClaude22import { loadIssueTemplate } from "../lib/templates";
79136bbClaude23import { renderMarkdown } from "../lib/markdown";
b584e52Claude24import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude25import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude26import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude27import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude28import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude31import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude32import {
33 decideInitialStatus,
34 notifyOwnerOfPendingComment,
35 countPendingForRepo,
36} from "../lib/comment-moderation";
bb0f894Claude37import {
38 Flex,
39 Container,
40 PageHeader,
41 Form,
42 FormGroup,
43 Input,
44 TextArea,
45 Button,
46 LinkButton,
47 Badge,
48 EmptyState,
49 TabNav,
50 FilterTabs,
51 List,
52 ListItem,
53 Alert,
54 CommentBox,
55 CommentForm,
56 formatRelative,
57} from "../views/ui";
79136bbClaude58
59const issueRoutes = new Hono<AuthEnv>();
60
f7ad7b8Claude61// ---------------------------------------------------------------------------
62// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
63// other routes/shared views. All design tokens come from :root in layout.tsx.
64// ---------------------------------------------------------------------------
65const issuesStyles = `
66 /* Hero card — list page */
67 .issues-hero {
68 position: relative;
69 margin: 4px 0 24px;
70 padding: 28px 32px;
71 background: var(--bg-elevated);
72 border: 1px solid var(--border);
73 border-radius: 16px;
74 overflow: hidden;
75 }
76 .issues-hero::before {
77 content: '';
78 position: absolute;
79 top: 0; left: 0; right: 0;
80 height: 2px;
81 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
82 opacity: 0.7;
83 pointer-events: none;
84 }
85 .issues-hero-bg {
86 position: absolute;
87 inset: -30% -10% auto auto;
88 width: 360px;
89 height: 360px;
90 pointer-events: none;
91 z-index: 0;
92 }
93 .issues-hero-orb {
94 position: absolute;
95 inset: 0;
96 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
97 filter: blur(80px);
98 opacity: 0.7;
99 animation: issuesHeroOrb 14s ease-in-out infinite;
100 }
101 @keyframes issuesHeroOrb {
102 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
103 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
104 }
105 @media (prefers-reduced-motion: reduce) {
106 .issues-hero-orb { animation: none; }
107 }
108 .issues-hero-inner {
109 position: relative;
110 z-index: 1;
111 display: flex;
112 justify-content: space-between;
113 align-items: flex-end;
114 gap: 24px;
115 flex-wrap: wrap;
116 }
117 .issues-hero-text { flex: 1; min-width: 280px; }
118 .issues-hero-eyebrow {
119 font-size: 12.5px;
120 color: var(--text-muted);
121 margin-bottom: 8px;
122 letter-spacing: 0.04em;
123 text-transform: uppercase;
124 font-weight: 600;
125 }
126 .issues-hero-eyebrow .issues-hero-repo {
127 color: var(--accent);
128 text-transform: none;
129 letter-spacing: -0.005em;
130 font-weight: 600;
131 }
132 .issues-hero-title {
133 font-family: var(--font-display);
134 font-size: clamp(28px, 4vw, 40px);
135 font-weight: 800;
136 letter-spacing: -0.028em;
137 line-height: 1.05;
138 margin: 0 0 10px;
139 color: var(--text-strong);
140 }
141 .issues-hero-sub {
142 font-size: 15px;
143 color: var(--text-muted);
144 margin: 0;
145 line-height: 1.5;
146 max-width: 580px;
147 }
148 .issues-hero-actions {
149 display: flex;
150 gap: 8px;
151 flex-wrap: wrap;
152 }
153 @media (max-width: 720px) {
154 .issues-hero { padding: 24px 20px; }
155 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
156 .issues-hero-actions { width: 100%; }
157 .issues-hero-actions .btn { flex: 1; min-width: 0; }
158 }
159
f1dc7c7Claude160 /* Mobile rules — added in the 720px sweep. Kept additive only. */
161 @media (max-width: 720px) {
162 .issues-toolbar { flex-direction: column; align-items: stretch; }
163 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
164 .issues-filter { min-height: 40px; padding: 10px 14px; }
165 .issues-row { padding: 12px 14px; gap: 10px; }
166 .issues-row-side { flex-wrap: wrap; gap: 8px; }
167 .issues-detail-hero { padding: 18px; }
168 .issues-detail-attr { font-size: 13px; }
169 .issues-composer-actions { gap: 8px; }
170 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
171 .issues-empty { padding: 40px 20px; }
172 }
173
f7ad7b8Claude174 /* Count chip + filter pills */
175 .issues-toolbar {
176 display: flex;
177 align-items: center;
178 justify-content: space-between;
179 gap: 12px;
180 flex-wrap: wrap;
181 margin: 0 0 16px;
182 }
183 .issues-filters {
184 display: inline-flex;
185 background: var(--bg-elevated);
186 border: 1px solid var(--border);
187 border-radius: 9999px;
188 padding: 4px;
189 gap: 2px;
190 }
191 .issues-filter {
192 display: inline-flex;
193 align-items: center;
194 gap: 6px;
195 padding: 6px 14px;
196 border-radius: 9999px;
197 font-size: 13px;
198 font-weight: 500;
199 color: var(--text-muted);
200 text-decoration: none;
201 transition: color 120ms ease, background 120ms ease;
202 line-height: 1.4;
203 }
204 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
205 .issues-filter.is-active {
206 background: rgba(140,109,255,0.14);
207 color: var(--text-strong);
208 }
209 .issues-filter-count {
210 font-variant-numeric: tabular-nums;
211 font-size: 11.5px;
212 color: var(--text-muted);
213 background: rgba(255,255,255,0.04);
214 padding: 1px 7px;
215 border-radius: 9999px;
216 }
217 .issues-filter.is-active .issues-filter-count {
218 background: rgba(140,109,255,0.18);
219 color: var(--text);
220 }
221
7a28902Claude222 /* Issue search form */
223 .issues-search-form {
224 display: flex;
225 align-items: center;
226 gap: 0;
227 background: var(--bg-elevated);
228 border: 1px solid var(--border);
229 border-radius: 9999px;
230 overflow: hidden;
231 flex: 1;
232 max-width: 280px;
233 transition: border-color 120ms ease;
234 }
235 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
236 .issues-search-input {
237 flex: 1;
238 background: transparent;
239 color: var(--text);
240 border: none;
241 outline: none;
242 padding: 6px 14px;
243 font-size: 13px;
244 font-family: var(--font-sans, inherit);
245 min-width: 0;
246 }
247 .issues-search-input::placeholder { color: var(--text-muted); }
248 .issues-search-btn {
249 background: transparent;
250 border: none;
251 color: var(--text-muted);
252 padding: 6px 12px 6px 8px;
253 cursor: pointer;
254 display: flex;
255 align-items: center;
256 }
257 .issues-search-btn:hover { color: var(--text); }
258 .issues-filter-banner {
259 margin-bottom: 12px;
260 padding: 8px 14px;
261 background: rgba(140,109,255,0.06);
262 border: 1px solid rgba(140,109,255,0.2);
263 border-radius: 8px;
264 font-size: 13px;
265 color: var(--text-muted);
266 }
267 .issues-filter-clear {
268 color: var(--accent, #8c6dff);
269 text-decoration: underline;
270 cursor: pointer;
271 }
272 .issues-label-badge {
273 display: inline-block;
274 background: rgba(140,109,255,0.15);
275 color: #a78bfa;
276 border-radius: 9999px;
277 padding: 1px 8px;
278 font-size: 11.5px;
279 font-weight: 600;
280 }
281
f7ad7b8Claude282 /* Issue list — modernised rows */
283 .issues-list {
284 list-style: none;
285 margin: 0;
286 padding: 0;
287 border: 1px solid var(--border);
288 border-radius: 12px;
289 overflow: hidden;
290 background: var(--bg-elevated);
291 }
292 .issues-row {
293 display: flex;
294 align-items: flex-start;
295 gap: 14px;
296 padding: 14px 18px;
297 border-bottom: 1px solid var(--border);
298 transition: background 120ms ease, transform 120ms ease;
299 }
300 .issues-row:last-child { border-bottom: none; }
301 .issues-row:hover { background: rgba(140,109,255,0.04); }
302 .issues-row-icon {
303 width: 18px;
304 height: 18px;
305 flex-shrink: 0;
306 margin-top: 3px;
307 display: inline-flex;
308 align-items: center;
309 justify-content: center;
310 }
311 .issues-row-icon.is-open { color: #34d399; }
312 .issues-row-icon.is-closed { color: #b69dff; }
313 .issues-row-main { flex: 1; min-width: 0; }
314 .issues-row-title {
315 font-family: var(--font-display);
316 font-size: 15.5px;
317 font-weight: 600;
318 line-height: 1.35;
319 letter-spacing: -0.012em;
320 margin: 0;
321 display: flex;
322 flex-wrap: wrap;
323 align-items: center;
324 gap: 8px;
325 }
326 .issues-row-title a {
327 color: var(--text-strong);
328 text-decoration: none;
329 transition: color 120ms ease;
330 }
331 .issues-row-title a:hover { color: var(--accent); }
332 .issues-row-meta {
333 margin-top: 5px;
334 font-size: 12.5px;
335 color: var(--text-muted);
336 line-height: 1.5;
337 }
338 .issues-row-meta strong { color: var(--text); font-weight: 600; }
339 .issues-row-side {
340 display: flex;
341 align-items: center;
342 gap: 12px;
343 color: var(--text-muted);
344 font-size: 12.5px;
345 flex-shrink: 0;
346 }
347 .issues-row-comments {
348 display: inline-flex;
349 align-items: center;
350 gap: 5px;
351 color: var(--text-muted);
352 text-decoration: none;
353 }
354 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
355
356 /* Label pills (rendered inline on titles) */
357 .issues-label {
358 display: inline-flex;
359 align-items: center;
360 padding: 2px 9px;
361 border-radius: 9999px;
362 font-size: 11.5px;
363 font-weight: 600;
364 line-height: 1.4;
365 background: rgba(140,109,255,0.10);
366 color: var(--text-strong);
367 border: 1px solid rgba(140,109,255,0.28);
368 letter-spacing: 0.005em;
369 }
370
371 /* Polished empty state */
372 .issues-empty {
373 margin: 0;
374 padding: 56px 32px;
375 background: var(--bg-elevated);
376 border: 1px solid var(--border);
377 border-radius: 16px;
378 text-align: center;
379 position: relative;
380 overflow: hidden;
381 }
382 .issues-empty::before {
383 content: '';
384 position: absolute;
385 top: 0; left: 0; right: 0;
386 height: 2px;
387 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
388 opacity: 0.55;
389 pointer-events: none;
390 }
391 .issues-empty-art {
392 width: 96px;
393 height: 96px;
394 margin: 0 auto 18px;
395 display: block;
396 opacity: 0.85;
397 }
398 .issues-empty-title {
399 font-family: var(--font-display);
400 font-size: 22px;
401 font-weight: 700;
402 letter-spacing: -0.018em;
403 color: var(--text-strong);
404 margin: 0 0 8px;
405 }
406 .issues-empty-sub {
407 font-size: 14.5px;
408 color: var(--text-muted);
409 line-height: 1.55;
410 margin: 0 auto 22px;
411 max-width: 460px;
412 }
413 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
414
415 /* ─── Detail page ─── */
416 .issues-detail-hero {
417 position: relative;
418 margin: 4px 0 20px;
419 padding: 22px 26px;
420 background: var(--bg-elevated);
421 border: 1px solid var(--border);
422 border-radius: 16px;
423 overflow: hidden;
424 }
425 .issues-detail-hero::before {
426 content: '';
427 position: absolute;
428 top: 0; left: 0; right: 0;
429 height: 2px;
430 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
431 opacity: 0.7;
432 pointer-events: none;
433 }
434 .issues-detail-title {
435 font-family: var(--font-display);
436 font-size: clamp(22px, 3vw, 30px);
437 font-weight: 700;
438 letter-spacing: -0.022em;
439 line-height: 1.18;
440 color: var(--text-strong);
441 margin: 0 0 12px;
442 }
443 .issues-detail-title .issues-detail-number {
444 color: var(--text-muted);
445 font-weight: 500;
446 margin-left: 8px;
447 }
448 .issues-detail-attr {
449 display: flex;
450 align-items: center;
451 gap: 12px;
452 flex-wrap: wrap;
453 font-size: 14px;
454 color: var(--text-muted);
455 }
456 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
457 .issues-state-pill {
458 display: inline-flex;
459 align-items: center;
460 gap: 6px;
461 padding: 5px 12px;
462 border-radius: 9999px;
463 font-size: 12.5px;
464 font-weight: 600;
465 line-height: 1.4;
466 letter-spacing: 0.005em;
467 }
468 .issues-state-pill.is-open {
469 background: rgba(52,211,153,0.12);
470 color: #34d399;
471 border: 1px solid rgba(52,211,153,0.35);
472 }
473 .issues-state-pill.is-closed {
474 background: rgba(182,157,255,0.12);
475 color: #b69dff;
476 border: 1px solid rgba(182,157,255,0.35);
477 }
478 .issues-detail-spacer { flex: 1; }
479 .issues-detail-labels {
480 margin-top: 12px;
481 display: flex;
482 gap: 6px;
483 flex-wrap: wrap;
484 }
485
486 /* Comment thread */
487 .issues-thread { margin-top: 18px; }
488 .issues-comment {
489 position: relative;
490 border: 1px solid var(--border);
491 border-radius: 12px;
492 overflow: hidden;
493 background: var(--bg-elevated);
494 margin-bottom: 14px;
495 transition: border-color 160ms ease, box-shadow 160ms ease;
496 }
497 .issues-comment:hover {
498 border-color: var(--border-strong, rgba(255,255,255,0.13));
499 }
500 .issues-comment-header {
501 background: var(--bg-secondary);
502 padding: 10px 16px;
503 border-bottom: 1px solid var(--border);
504 display: flex;
505 align-items: center;
506 gap: 10px;
507 font-size: 13px;
508 color: var(--text-muted);
509 }
510 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
511 .issues-comment-body { padding: 14px 18px; }
512 .issues-comment-author-pill {
513 display: inline-flex;
514 align-items: center;
515 padding: 1px 8px;
516 border-radius: 9999px;
517 background: rgba(140,109,255,0.10);
518 color: var(--accent);
519 font-size: 11px;
520 font-weight: 600;
521 letter-spacing: 0.02em;
522 text-transform: uppercase;
523 }
524
525 /* AI Review comment — distinct purple-accent treatment */
526 .issues-comment.is-ai {
527 border-color: rgba(140,109,255,0.45);
528 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
529 }
530 .issues-comment.is-ai::before {
531 content: '';
532 position: absolute;
533 top: 0; left: 0; right: 0;
534 height: 2px;
535 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
536 pointer-events: none;
537 }
538 .issues-comment.is-ai .issues-comment-header {
539 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
540 border-bottom-color: rgba(140,109,255,0.22);
541 }
542 .issues-ai-badge {
543 display: inline-flex;
544 align-items: center;
545 gap: 5px;
546 padding: 2px 9px;
547 border-radius: 9999px;
548 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
549 color: #fff;
550 font-size: 11px;
551 font-weight: 700;
552 letter-spacing: 0.04em;
553 text-transform: uppercase;
554 line-height: 1.4;
555 }
556 .issues-ai-badge::before {
557 content: '';
558 width: 6px;
559 height: 6px;
560 border-radius: 9999px;
561 background: #fff;
562 opacity: 0.92;
563 box-shadow: 0 0 6px rgba(255,255,255,0.7);
564 }
565
566 /* Composer */
567 .issues-composer {
568 margin-top: 22px;
569 border: 1px solid var(--border);
570 border-radius: 12px;
571 overflow: hidden;
572 background: var(--bg-elevated);
573 transition: border-color 160ms ease, box-shadow 160ms ease;
574 }
575 .issues-composer:focus-within {
576 border-color: rgba(140,109,255,0.55);
577 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
578 }
579 .issues-composer-header {
580 display: flex;
581 align-items: center;
582 justify-content: space-between;
583 gap: 8px;
584 padding: 10px 14px;
585 background: var(--bg-secondary);
586 border-bottom: 1px solid var(--border);
587 font-size: 12.5px;
588 color: var(--text-muted);
589 }
590 .issues-composer-tag {
591 font-weight: 600;
592 color: var(--text);
593 letter-spacing: -0.005em;
594 }
595 .issues-composer-hint a {
596 color: var(--text-muted);
597 text-decoration: none;
598 border-bottom: 1px dashed currentColor;
599 }
600 .issues-composer-hint a:hover { color: var(--accent); }
601 .issues-composer textarea {
602 display: block;
603 width: 100%;
604 border: 0;
605 background: transparent;
606 color: var(--text);
607 padding: 14px 16px;
608 font-family: var(--font-mono);
609 font-size: 13.5px;
610 line-height: 1.55;
611 resize: vertical;
612 outline: none;
613 min-height: 140px;
614 }
615 .issues-composer-actions {
616 display: flex;
617 align-items: center;
618 gap: 8px;
619 padding: 10px 14px;
620 border-top: 1px solid var(--border);
621 background: var(--bg-secondary);
622 flex-wrap: wrap;
623 }
624
625 /* Info banner inside detail */
626 .issues-info-banner {
627 margin: 0 0 14px;
628 padding: 10px 14px;
629 border-radius: 12px;
630 background: rgba(140,109,255,0.08);
631 border: 1px solid rgba(140,109,255,0.28);
632 color: var(--text);
633 font-size: 13.5px;
634 }
240c477Claude635
636 /* ─── Linked PRs ─── */
637 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
638 .iss-linked-prs-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; background: var(--bg-elevated); border-bottom: 1px solid var(--border); font-size: 13px; font-weight: 600; }
639 .iss-linked-pr-row { display: flex; align-items: center; gap: 10px; padding: 9px 16px; border-bottom: 1px solid var(--border); font-size: 13px; text-decoration: none; color: inherit; }
640 .iss-linked-pr-row:last-child { border-bottom: none; }
641 .iss-linked-pr-row:hover { background: var(--bg-hover); }
642 .iss-linked-pr-icon.is-open { color: #34d399; }
643 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
644 .iss-linked-pr-icon.is-closed { color: #8b949e; }
645 .iss-linked-pr-icon.is-draft { color: #8b949e; }
646 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
647 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
648 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
649 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
650 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
651 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
652 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude653
654 /* ─── Sort controls (issue list) ─── */
655 .issues-sort-row {
656 display: flex;
657 align-items: center;
658 gap: 6px;
659 margin: 0 0 12px;
660 flex-wrap: wrap;
661 }
662 .issues-sort-label {
663 font-size: 12.5px;
664 color: var(--text-muted);
665 font-weight: 600;
666 margin-right: 2px;
667 }
668 .issues-sort-opt {
669 font-size: 12.5px;
670 color: var(--text-muted);
671 text-decoration: none;
672 padding: 3px 10px;
673 border-radius: 9999px;
674 border: 1px solid transparent;
675 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
676 }
677 .issues-sort-opt:hover {
678 background: var(--bg-hover);
679 color: var(--text);
680 }
681 .issues-sort-opt.is-active {
682 background: rgba(140,109,255,0.12);
683 color: var(--text-link);
684 border-color: rgba(140,109,255,0.35);
685 font-weight: 600;
686 }
f7ad7b8Claude687`;
688
689// Pre-rendered <style> tag (constant, reused per request).
690const IssuesStyle = () => (
691 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
692);
693
694// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
695const IssuesEmptySvg = () => (
696 <svg
697 class="issues-empty-art"
698 viewBox="0 0 96 96"
699 fill="none"
700 xmlns="http://www.w3.org/2000/svg"
701 aria-hidden="true"
702 >
703 <defs>
704 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
705 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
706 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
707 </linearGradient>
708 </defs>
709 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
710 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
711 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
712 </svg>
713);
714
715// Detect AI Triage comments by the marker the triage helper writes.
716function isAiTriageComment(body: string | null | undefined): boolean {
717 if (!body) return false;
718 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
719}
720
79136bbClaude721// Helper to resolve repo from :owner/:repo params
722async function resolveRepo(ownerName: string, repoName: string) {
723 const [owner] = await db
724 .select()
725 .from(users)
726 .where(eq(users.username, ownerName))
727 .limit(1);
728 if (!owner) return null;
729
730 const [repo] = await db
731 .select()
732 .from(repositories)
733 .where(
734 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
735 )
736 .limit(1);
737 if (!repo) return null;
738
739 return { owner, repo };
740}
741
742// Issue list
04f6b7fClaude743issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude744 const { owner: ownerName, repo: repoName } = c.req.param();
745 const user = c.get("user");
746 const state = c.req.query("state") || "open";
7a28902Claude747 const searchQ = (c.req.query("q") || "").trim();
748 const labelFilter = (c.req.query("label") || "").trim();
f5b9ef5Claude749 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude750 // Bounded pagination — unbounded selects ran a full table scan + O(n)
751 // sort on every page load; with 10k+ issues the request would hang.
752 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
753 const page = Math.max(1, Number(c.req.query("page")) || 1);
754 const offset = (page - 1) * perPage;
79136bbClaude755
f1dc7c7Claude756 // ── Loading skeleton (flag-gated) ──
757 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
758 // user see the shape of the issue list before the DB count + select
759 // resolve. Behind a flag — we don't ship flashes.
760 if (c.req.query("skeleton") === "1") {
761 return c.html(
762 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
763 <IssuesStyle />
764 <RepoHeader owner={ownerName} repo={repoName} />
765 <IssueNav owner={ownerName} repo={repoName} active="issues" />
766 <style
767 dangerouslySetInnerHTML={{
768 __html: `
769 .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; }
770 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
771 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
772 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
773 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
774 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
775 .issues-skel-row { height: 62px; border-radius: 10px; }
776 `,
777 }}
778 />
779 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
780 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
781 <div class="issues-skel-list" aria-hidden="true">
782 {Array.from({ length: 8 }).map(() => (
783 <div class="issues-skel issues-skel-row" />
784 ))}
785 </div>
786 <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">
787 Loading issues for {ownerName}/{repoName}…
788 </span>
789 </Layout>
790 );
791 }
792
79136bbClaude793 const resolved = await resolveRepo(ownerName, repoName);
794 if (!resolved) {
795 return c.html(
796 <Layout title="Not Found" user={user}>
bb0f894Claude797 <EmptyState title="Repository not found" />
79136bbClaude798 </Layout>,
799 404
800 );
801 }
802
803 const { repo } = resolved;
804
7a28902Claude805 // If label filter is set, find issue IDs that have that label
806 let labelFilteredIds: string[] | null = null;
807 if (labelFilter) {
808 const [matchedLabel] = await db
809 .select({ id: labels.id })
810 .from(labels)
811 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
812 .limit(1);
813 if (matchedLabel) {
814 const rows = await db
815 .select({ issueId: issueLabels.issueId })
816 .from(issueLabels)
817 .where(eq(issueLabels.labelId, matchedLabel.id));
818 labelFilteredIds = rows.map((r) => r.issueId);
819 } else {
820 labelFilteredIds = []; // no matches
821 }
822 }
823
824 const baseWhere = and(
825 eq(issues.repositoryId, repo.id),
826 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
827 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
828 labelFilteredIds !== null && labelFilteredIds.length > 0
829 ? inArray(issues.id, labelFilteredIds)
830 : labelFilteredIds !== null && labelFilteredIds.length === 0
831 ? sql`false`
832 : undefined
833 );
834
79136bbClaude835 const issueList = await db
836 .select({
837 issue: issues,
838 author: { username: users.username },
839 })
840 .from(issues)
841 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude842 .where(baseWhere)
f5b9ef5Claude843 .orderBy(
844 sort === "oldest" ? asc(issues.createdAt)
845 : sort === "updated" ? desc(issues.updatedAt)
846 : desc(issues.createdAt) // newest (default)
847 )
6ea2109Claude848 .limit(perPage)
849 .offset(offset);
79136bbClaude850
851 // Count open/closed
852 const [counts] = await db
853 .select({
854 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
855 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
856 })
857 .from(issues)
858 .where(eq(issues.repositoryId, repo.id));
859
cb5a796Claude860 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
861 const pendingCountList = viewerIsOwnerOnList
862 ? await countPendingForRepo(repo.id)
863 : 0;
864
79136bbClaude865 return c.html(
866 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude867 <IssuesStyle />
79136bbClaude868 <RepoHeader owner={ownerName} repo={repoName} />
869 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude870 <PendingCommentsBanner
871 owner={ownerName}
872 repo={repoName}
873 count={pendingCountList}
874 />
f7ad7b8Claude875 <section class="issues-hero">
876 <div class="issues-hero-bg" aria-hidden="true">
877 <div class="issues-hero-orb" />
878 </div>
879 <div class="issues-hero-inner">
880 <div class="issues-hero-text">
881 <div class="issues-hero-eyebrow">
882 Issue tracker \u00B7{" "}
883 <span class="issues-hero-repo">
884 {ownerName}/{repoName}
885 </span>
886 </div>
887 <h1 class="issues-hero-title">
888 Track <span class="gradient-text">what matters</span>.
889 </h1>
890 <p class="issues-hero-sub">
891 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
892 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
893 : `${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.`}
894 </p>
895 </div>
896 <div class="issues-hero-actions">
897 {user && (
898 <a
899 href={`/${ownerName}/${repoName}/issues/new`}
900 class="btn btn-primary"
901 >
902 + New issue
903 </a>
904 )}
905 <a href={`/${ownerName}/${repoName}`} class="btn">
906 Back to code
907 </a>
908 </div>
909 </div>
910 </section>
911
912 <div class="issues-toolbar">
913 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
914 <a
915 class={`issues-filter${state === "open" ? " is-active" : ""}`}
916 href={`/${ownerName}/${repoName}/issues?state=open`}
917 role="tab"
918 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude919 >
f7ad7b8Claude920 <span aria-hidden="true">{"\u25CB"}</span>
921 <span>Open</span>
922 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
923 </a>
924 <a
925 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
926 href={`/${ownerName}/${repoName}/issues?state=closed`}
927 role="tab"
928 aria-selected={state === "closed" ? "true" : "false"}
929 >
930 <span aria-hidden="true">{"\u2713"}</span>
931 <span>Closed</span>
932 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
933 </a>
934 </div>
7a28902Claude935 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
936 <input type="hidden" name="state" value={state} />
937 <input
938 type="search"
939 name="q"
940 value={searchQ}
941 placeholder="Search issues\u2026"
942 class="issues-search-input"
943 aria-label="Search issues by title"
944 />
945 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
946 <button type="submit" class="issues-search-btn" aria-label="Search">
947 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
948 <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z" />
949 </svg>
950 </button>
951 </form>
f7ad7b8Claude952 </div>
7a28902Claude953 {(searchQ || labelFilter) && (
954 <div class="issues-filter-banner">
955 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
956 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
957 {" \u00B7 "}
958 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
959 </div>
960 )}
f7ad7b8Claude961
f5b9ef5Claude962 <div class="issues-sort-row">
963 <span class="issues-sort-label">Sort:</span>
964 {(["newest", "oldest", "updated"] as const).map((s) => (
965 <a
966 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
967 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
968 >
969 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
970 </a>
971 ))}
972 </div>
973
79136bbClaude974 {issueList.length === 0 ? (
f7ad7b8Claude975 <div class="issues-empty">
976 <IssuesEmptySvg />
977 <h2 class="issues-empty-title">
978 {state === "closed"
979 ? "No closed issues yet"
980 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
981 ? "No issues \u2014 yet"
982 : "Nothing open right now"}
983 </h2>
984 <p class="issues-empty-sub">
985 {state === "closed"
986 ? "Closed issues will show up here once the team starts shipping fixes."
987 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
988 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
989 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
990 </p>
991 <div class="issues-empty-cta">
992 {user && state !== "closed" && (
993 <a
994 href={`/${ownerName}/${repoName}/issues/new`}
995 class="btn btn-primary"
996 >
997 + Open the first issue
998 </a>
999 )}
79136bbClaude1000 {state === "closed" && (
f7ad7b8Claude1001 <a
1002 href={`/${ownerName}/${repoName}/issues?state=open`}
1003 class="btn"
1004 >
1005 View open issues
1006 </a>
79136bbClaude1007 )}
f7ad7b8Claude1008 </div>
1009 </div>
79136bbClaude1010 ) : (
f7ad7b8Claude1011 <ul class="issues-list">
79136bbClaude1012 {issueList.map(({ issue, author }) => (
f7ad7b8Claude1013 <li class="issues-row">
79136bbClaude1014 <div
f7ad7b8Claude1015 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1016 aria-hidden="true"
1017 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude1018 >
1019 {issue.state === "open" ? "\u25CB" : "\u2713"}
1020 </div>
f7ad7b8Claude1021 <div class="issues-row-main">
1022 <h3 class="issues-row-title">
79136bbClaude1023 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1024 {issue.title}
1025 </a>
f7ad7b8Claude1026 </h3>
1027 <div class="issues-row-meta">
1028 #{issue.number} opened by{" "}
1029 <strong>{author.username}</strong>{" "}
79136bbClaude1030 {formatRelative(issue.createdAt)}
1031 </div>
1032 </div>
f7ad7b8Claude1033 </li>
79136bbClaude1034 ))}
f7ad7b8Claude1035 </ul>
79136bbClaude1036 )}
1037 </Layout>
1038 );
1039});
1040
1041// New issue form
1042issueRoutes.get(
1043 "/:owner/:repo/issues/new",
1044 softAuth,
1045 requireAuth,
04f6b7fClaude1046 requireRepoAccess("write"),
79136bbClaude1047 async (c) => {
1048 const { owner: ownerName, repo: repoName } = c.req.param();
1049 const user = c.get("user")!;
1050 const error = c.req.query("error");
24cf2caClaude1051 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1052
1053 return c.html(
1054 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1055 <IssuesStyle />
79136bbClaude1056 <RepoHeader owner={ownerName} repo={repoName} />
1057 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1058 <Container maxWidth={800}>
f7ad7b8Claude1059 <section class="issues-hero" style="margin-top:4px">
1060 <div class="issues-hero-bg" aria-hidden="true">
1061 <div class="issues-hero-orb" />
1062 </div>
1063 <div class="issues-hero-inner">
1064 <div class="issues-hero-text">
1065 <div class="issues-hero-eyebrow">
1066 New issue ·{" "}
1067 <span class="issues-hero-repo">
1068 {ownerName}/{repoName}
1069 </span>
1070 </div>
1071 <h1 class="issues-hero-title">
1072 File <span class="gradient-text">it cleanly</span>.
1073 </h1>
1074 <p class="issues-hero-sub">
1075 AI Triage will read the body the moment you submit and post
1076 suggested labels, priority, and possible duplicates within
1077 seconds. You stay in control — nothing is applied.
1078 </p>
1079 </div>
1080 </div>
1081 </section>
79136bbClaude1082 {error && (
bb0f894Claude1083 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1084 )}
0316dbbClaude1085 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1086 <FormGroup>
1087 <Input
79136bbClaude1088 type="text"
1089 name="title"
1090 required
1091 placeholder="Title"
bb0f894Claude1092 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1093 aria-label="Issue title"
79136bbClaude1094 />
bb0f894Claude1095 </FormGroup>
1096 <FormGroup>
1097 <TextArea
79136bbClaude1098 name="body"
1099 rows={12}
1100 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1101 mono
79136bbClaude1102 />
bb0f894Claude1103 </FormGroup>
1104 <Button type="submit" variant="primary">
79136bbClaude1105 Submit new issue
bb0f894Claude1106 </Button>
1107 </Form>
1108 </Container>
79136bbClaude1109 </Layout>
1110 );
1111 }
1112);
1113
1114// Create issue
1115issueRoutes.post(
1116 "/:owner/:repo/issues/new",
1117 softAuth,
1118 requireAuth,
04f6b7fClaude1119 requireRepoAccess("write"),
79136bbClaude1120 async (c) => {
1121 const { owner: ownerName, repo: repoName } = c.req.param();
1122 const user = c.get("user")!;
1123 const body = await c.req.parseBody();
1124 const title = String(body.title || "").trim();
1125 const issueBody = String(body.body || "").trim();
1126
1127 if (!title) {
1128 return c.redirect(
1129 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1130 );
1131 }
1132
1133 const resolved = await resolveRepo(ownerName, repoName);
1134 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1135
1136 const [issue] = await db
1137 .insert(issues)
1138 .values({
1139 repositoryId: resolved.repo.id,
1140 authorId: user.id,
1141 title,
1142 body: issueBody || null,
1143 })
1144 .returning();
1145
1146 // Update issue count
1147 await db
1148 .update(repositories)
1149 .set({ issueCount: resolved.repo.issueCount + 1 })
1150 .where(eq(repositories.id, resolved.repo.id));
1151
a9ada5fClaude1152 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1153 // suggested labels, priority, summary, and a possible-duplicate
1154 // callout. Suggestions only — nothing applied automatically.
1155 triggerIssueTriage({
1156 ownerName,
1157 repoName,
1158 repositoryId: resolved.repo.id,
1159 issueId: issue.id,
1160 issueNumber: issue.number,
1161 authorId: user.id,
1162 title,
1163 body: issueBody,
a28cedeClaude1164 }).catch((err) => {
1165 console.warn(
1166 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1167 err instanceof Error ? err.message : err
1168 );
1169 });
a9ada5fClaude1170
79136bbClaude1171 return c.redirect(
1172 `/${ownerName}/${repoName}/issues/${issue.number}`
1173 );
1174 }
1175);
1176
1177// View single issue
04f6b7fClaude1178issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1179 const { owner: ownerName, repo: repoName } = c.req.param();
1180 const issueNum = parseInt(c.req.param("number"), 10);
1181 const user = c.get("user");
1182
1183 const resolved = await resolveRepo(ownerName, repoName);
1184 if (!resolved) {
1185 return c.html(
1186 <Layout title="Not Found" user={user}>
bb0f894Claude1187 <EmptyState title="Not found" />
79136bbClaude1188 </Layout>,
1189 404
1190 );
1191 }
1192
1193 const [issue] = await db
1194 .select()
1195 .from(issues)
1196 .where(
1197 and(
1198 eq(issues.repositoryId, resolved.repo.id),
1199 eq(issues.number, issueNum)
1200 )
1201 )
1202 .limit(1);
1203
1204 if (!issue) {
1205 return c.html(
1206 <Layout title="Not Found" user={user}>
bb0f894Claude1207 <EmptyState title="Issue not found" />
79136bbClaude1208 </Layout>,
1209 404
1210 );
1211 }
1212
1213 const [author] = await db
1214 .select()
1215 .from(users)
1216 .where(eq(users.id, issue.authorId))
1217 .limit(1);
1218
cb5a796Claude1219 // Get comments. We pull every row and filter by moderation_status +
1220 // viewer identity client-side (in the JSX below) so a moderator/owner
1221 // sees them all while non-author viewers only ever see 'approved'.
1222 const allComments = await db
79136bbClaude1223 .select({
1224 comment: issueComments,
cb5a796Claude1225 author: { id: users.id, username: users.username },
79136bbClaude1226 })
1227 .from(issueComments)
1228 .innerJoin(users, eq(issueComments.authorId, users.id))
1229 .where(eq(issueComments.issueId, issue.id))
1230 .orderBy(asc(issueComments.createdAt));
1231
cb5a796Claude1232 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1233 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1234 // Owner always sees everything (so they can spot conversations going
1235 // sideways even before they hit the queue page).
1236 if (viewerIsOwner) return true;
1237 if (comment.moderationStatus === "approved") return true;
1238 // The comment's own author sees their pending comment so they know
1239 // it landed (with an "Awaiting approval" badge in the render below).
1240 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1241 return true;
1242 }
1243 return false;
1244 });
1245
1246 // Pending banner — when the viewer is the repo owner, show a strip at
1247 // the top of the page nudging them to the moderation queue. Inline on
1248 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1249 const pendingCount = viewerIsOwner
1250 ? await countPendingForRepo(resolved.repo.id)
1251 : 0;
1252
6fc53bdClaude1253 // Load reactions for the issue + each comment in parallel.
1254 const [issueReactions, ...commentReactions] = await Promise.all([
1255 summariseReactions("issue", issue.id, user?.id),
1256 ...comments.map((row) =>
1257 summariseReactions("issue_comment", row.comment.id, user?.id)
1258 ),
1259 ]);
1260
79136bbClaude1261 const canManage =
1262 user &&
1263 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1264 const info = c.req.query("info");
79136bbClaude1265
240c477Claude1266 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1267 const issueRefPattern = `%#${issue.number}%`;
1268 const linkedPrs = await db
1269 .select({
1270 number: pullRequests.number,
1271 title: pullRequests.title,
1272 state: pullRequests.state,
1273 isDraft: pullRequests.isDraft,
1274 })
1275 .from(pullRequests)
1276 .where(
1277 and(
1278 eq(pullRequests.repositoryId, resolved.repo.id),
1279 or(
1280 ilike(pullRequests.title, issueRefPattern),
1281 ilike(pullRequests.body, issueRefPattern),
1282 )
1283 )
1284 )
1285 .orderBy(desc(pullRequests.createdAt))
1286 .limit(8);
1287
79136bbClaude1288 return c.html(
1289 <Layout
1290 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1291 user={user}
1292 >
f7ad7b8Claude1293 <IssuesStyle />
79136bbClaude1294 <RepoHeader owner={ownerName} repo={repoName} />
1295 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1296 <PendingCommentsBanner
1297 owner={ownerName}
1298 repo={repoName}
1299 count={pendingCount}
1300 />
b584e52Claude1301 <div
1302 id="live-comment-banner"
1303 class="alert"
1304 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1305 >
1306 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1307 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1308 reload to view
1309 </a>
1310 </div>
1311 <script
1312 dangerouslySetInnerHTML={{
1313 __html: liveCommentBannerScript({
1314 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1315 bannerElementId: "live-comment-banner",
1316 }),
1317 }}
1318 />
829a046Claude1319 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1320 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1321 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1322 <div class="issue-detail">
58915a9Claude1323 {info && (
f7ad7b8Claude1324 <div class="issues-info-banner">
58915a9Claude1325 {decodeURIComponent(info)}
1326 </div>
1327 )}
f7ad7b8Claude1328
1329 <section class="issues-detail-hero">
1330 <h1 class="issues-detail-title">
1331 {issue.title}
1332 <span class="issues-detail-number">#{issue.number}</span>
1333 </h1>
1334 <div class="issues-detail-attr">
1335 <span
1336 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1337 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1338 >
f7ad7b8Claude1339 <span aria-hidden="true">
1340 {issue.state === "open" ? "\u25CB" : "\u2713"}
1341 </span>
1342 {issue.state === "open" ? "Open" : "Closed"}
1343 </span>
1344 <span>
1345 <strong>{author?.username || "unknown"}</strong> opened this
1346 issue {formatRelative(issue.createdAt)}
1347 </span>
1348 <span class="issues-detail-spacer" />
1349 {issue.state === "open" && user && user.id === resolved.owner.id && (
1350 <a
1351 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1352 class="btn btn-primary"
1353 style="font-size:13px;padding:6px 12px"
1354 title="Generate a draft pull request from this issue using Claude"
1355 >
1356 Build with AI
1357 </a>
1358 )}
1359 </div>
1360 </section>
1361
1362 <div class="issues-thread">
1363 {issue.body && (
1364 <article class="issues-comment">
1365 <header class="issues-comment-header">
1366 <strong>{author?.username || "unknown"}</strong>
1367 <span class="issues-comment-author-pill">Author</span>
1368 <span>commented {formatRelative(issue.createdAt)}</span>
1369 </header>
1370 <div class="issues-comment-body">
1371 <div
1372 class="markdown-body"
1373 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1374 />
1375 </div>
1376 </article>
fbf4aefClaude1377 )}
79136bbClaude1378
f7ad7b8Claude1379 {comments.map(({ comment, author: commentAuthor }) => {
1380 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1381 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1382 return (
cb5a796Claude1383 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1384 <header class="issues-comment-header">
1385 <strong>{commentAuthor.username}</strong>
1386 {isAi ? (
1387 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1388 AI Review
1389 </span>
1390 ) : null}
cb5a796Claude1391 {isPending ? (
1392 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1393 Awaiting approval
1394 </span>
1395 ) : null}
f7ad7b8Claude1396 <span>commented {formatRelative(comment.createdAt)}</span>
1397 </header>
1398 <div class="issues-comment-body">
1399 <div
1400 class="markdown-body"
1401 dangerouslySetInnerHTML={{
1402 __html: renderMarkdown(comment.body),
1403 }}
1404 />
1405 </div>
1406 </article>
1407 );
1408 })}
1409 </div>
79136bbClaude1410
240c477Claude1411 {linkedPrs.length > 0 && (
1412 <div class="iss-linked-prs">
1413 <div class="iss-linked-prs-head">
1414 <span>Linked pull requests</span>
1415 <span>{linkedPrs.length}</span>
1416 </div>
1417 {linkedPrs.map((lpr) => {
1418 const prState = lpr.isDraft ? "draft" : lpr.state;
1419 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1420 return (
1421 <a
1422 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1423 class="iss-linked-pr-row"
1424 >
1425 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1426 <span class="iss-linked-pr-title">{lpr.title}</span>
1427 <span class="iss-linked-pr-num">#{lpr.number}</span>
1428 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1429 </a>
1430 );
1431 })}
1432 </div>
1433 )}
1434
79136bbClaude1435 {user && (
f7ad7b8Claude1436 <form
1437 class="issues-composer"
1438 method="post"
1439 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1440 >
1441 <div class="issues-composer-header">
1442 <span class="issues-composer-tag">Add a comment</span>
1443 <span class="issues-composer-hint">
1444 <a
1445 href="https://docs.github.com/en/get-started/writing-on-github"
1446 target="_blank"
1447 rel="noopener noreferrer"
1448 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1449 >
1450 Markdown supported
1451 </a>
1452 </span>
1453 </div>
1454 <textarea
1455 name="body"
1456 rows={6}
1457 required
6cd2f0eClaude1458 data-md-preview=""
f7ad7b8Claude1459 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1460 />
1461 <div class="issues-composer-actions">
1462 <button type="submit" class="btn btn-primary">
1463 Comment
1464 </button>
1465 {canManage && (
1466 <button
1467 type="submit"
1468 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1469 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1470 >
1471 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1472 </button>
f7ad7b8Claude1473 )}
1474 {canManage && issue.state === "open" && (
1475 <button
1476 type="submit"
1477 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1478 formnovalidate
1479 class="btn"
1480 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1481 >
1482 Re-run AI triage
1483 </button>
1484 )}
1485 </div>
1486 </form>
79136bbClaude1487 )}
1488 </div>
1489 </Layout>
1490 );
1491});
1492
cb5a796Claude1493// Add comment.
1494//
1495// Permission model: we accept comments from ANY authenticated user (read
1496// access required — public repos satisfy that, private repos still need a
1497// collaborator row). The moderation gate in `decideInitialStatus`
1498// determines whether the comment is published immediately or queued for
1499// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1500// "anti-impersonation" rationale.
79136bbClaude1501issueRoutes.post(
1502 "/:owner/:repo/issues/:number/comment",
1503 softAuth,
1504 requireAuth,
cb5a796Claude1505 requireRepoAccess("read"),
79136bbClaude1506 async (c) => {
1507 const { owner: ownerName, repo: repoName } = c.req.param();
1508 const issueNum = parseInt(c.req.param("number"), 10);
1509 const user = c.get("user")!;
1510 const body = await c.req.parseBody();
1511 const commentBody = String(body.body || "").trim();
1512
1513 if (!commentBody) {
1514 return c.redirect(
1515 `/${ownerName}/${repoName}/issues/${issueNum}`
1516 );
1517 }
1518
1519 const resolved = await resolveRepo(ownerName, repoName);
1520 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1521
1522 const [issue] = await db
1523 .select()
1524 .from(issues)
1525 .where(
1526 and(
1527 eq(issues.repositoryId, resolved.repo.id),
1528 eq(issues.number, issueNum)
1529 )
1530 )
1531 .limit(1);
1532
1533 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1534
cb5a796Claude1535 // Decide moderation status BEFORE insert so the row lands in the right
1536 // initial state. Collaborators, the issue author, and trusted users
1537 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1538 // else gets 'pending'.
1539 const decision = await decideInitialStatus({
1540 commenterUserId: user.id,
1541 repositoryId: resolved.repo.id,
1542 kind: "issue",
1543 threadId: issue.id,
1544 });
1545
d4ac5c3Claude1546 const [inserted] = await db
1547 .insert(issueComments)
1548 .values({
1549 issueId: issue.id,
1550 authorId: user.id,
1551 body: commentBody,
cb5a796Claude1552 moderationStatus: decision.status,
d4ac5c3Claude1553 })
1554 .returning();
1555
cb5a796Claude1556 // Live update only when visible. Don't leak pending/rejected via SSE.
1557 if (inserted && decision.status === "approved") {
d4ac5c3Claude1558 try {
1559 const { publish } = await import("../lib/sse");
1560 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1561 event: "issue-comment",
1562 data: {
1563 issueId: issue.id,
1564 commentId: inserted.id,
1565 authorId: user.id,
1566 authorUsername: user.username,
1567 },
1568 });
1569 } catch {
1570 /* SSE is best-effort */
1571 }
1572 }
79136bbClaude1573
cb5a796Claude1574 if (decision.status === "pending") {
1575 // Notify repo owner that a comment is awaiting review.
1576 void notifyOwnerOfPendingComment({
1577 repositoryId: resolved.repo.id,
1578 commenterUsername: user.username,
1579 kind: "issue",
1580 threadNumber: issueNum,
1581 ownerUsername: ownerName,
1582 repoName,
1583 });
1584 return c.redirect(
1585 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1586 );
1587 }
1588 if (decision.status === "rejected") {
1589 // Silent ban — the commenter is on the banned list. Don't reveal
1590 // the gate; just send them back to the page as if it posted.
1591 return c.redirect(
1592 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1593 );
1594 }
1595
79136bbClaude1596 return c.redirect(
1597 `/${ownerName}/${repoName}/issues/${issueNum}`
1598 );
1599 }
1600);
1601
1602// Close issue
1603issueRoutes.post(
1604 "/:owner/:repo/issues/:number/close",
1605 softAuth,
1606 requireAuth,
04f6b7fClaude1607 requireRepoAccess("write"),
79136bbClaude1608 async (c) => {
1609 const { owner: ownerName, repo: repoName } = c.req.param();
1610 const issueNum = parseInt(c.req.param("number"), 10);
1611
1612 const resolved = await resolveRepo(ownerName, repoName);
1613 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1614
1615 await db
1616 .update(issues)
1617 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1618 .where(
1619 and(
1620 eq(issues.repositoryId, resolved.repo.id),
1621 eq(issues.number, issueNum)
1622 )
1623 );
1624
1625 return c.redirect(
1626 `/${ownerName}/${repoName}/issues/${issueNum}`
1627 );
1628 }
1629);
1630
1631// Reopen issue
1632issueRoutes.post(
1633 "/:owner/:repo/issues/:number/reopen",
1634 softAuth,
1635 requireAuth,
04f6b7fClaude1636 requireRepoAccess("write"),
79136bbClaude1637 async (c) => {
1638 const { owner: ownerName, repo: repoName } = c.req.param();
1639 const issueNum = parseInt(c.req.param("number"), 10);
1640
1641 const resolved = await resolveRepo(ownerName, repoName);
1642 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1643
1644 await db
1645 .update(issues)
1646 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1647 .where(
1648 and(
1649 eq(issues.repositoryId, resolved.repo.id),
1650 eq(issues.number, issueNum)
1651 )
1652 );
1653
1654 return c.redirect(
1655 `/${ownerName}/${repoName}/issues/${issueNum}`
1656 );
1657 }
1658);
1659
1660// Shared nav component with issues tab
1661const IssueNav = ({
1662 owner,
1663 repo,
1664 active,
1665}: {
1666 owner: string;
1667 repo: string;
1668 active: "code" | "commits" | "issues";
1669}) => (
bb0f894Claude1670 <TabNav
1671 tabs={[
1672 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1673 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1674 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1675 ]}
1676 />
79136bbClaude1677);
1678
58915a9Claude1679// Re-run AI triage on demand (e.g. after the issue body has been edited).
1680// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1681issueRoutes.post(
1682 "/:owner/:repo/issues/:number/ai-retriage",
1683 softAuth,
1684 requireAuth,
1685 requireRepoAccess("write"),
1686 async (c) => {
1687 const { owner: ownerName, repo: repoName } = c.req.param();
1688 const issueNum = parseInt(c.req.param("number"), 10);
1689 const user = c.get("user")!;
1690 const resolved = await resolveRepo(ownerName, repoName);
1691 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1692
1693 const [issue] = await db
1694 .select()
1695 .from(issues)
1696 .where(
1697 and(
1698 eq(issues.repositoryId, resolved.repo.id),
1699 eq(issues.number, issueNum)
1700 )
1701 )
1702 .limit(1);
1703 if (!issue) {
1704 return c.redirect(`/${ownerName}/${repoName}/issues`);
1705 }
1706
1707 triggerIssueTriage(
1708 {
1709 ownerName,
1710 repoName,
1711 repositoryId: resolved.repo.id,
1712 issueId: issue.id,
1713 issueNumber: issue.number,
1714 authorId: user.id,
1715 title: issue.title,
1716 body: issue.body || "",
1717 },
1718 { force: true }
a28cedeClaude1719 ).catch((err) => {
1720 console.warn(
1721 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1722 err instanceof Error ? err.message : err
1723 );
1724 });
58915a9Claude1725
1726 return c.redirect(
1727 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1728 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1729 )}`
1730 );
1731 }
1732);
1733
79136bbClaude1734export default issueRoutes;
1735export { IssueNav };