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.tsxBlame1821 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
8d1483cClaude654 /* ─── Bulk action UI ─── */
655 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
656 .bulk-bar {
657 display: none;
658 align-items: center;
659 gap: 10px;
660 padding: 10px 16px;
661 background: var(--bg-elevated);
662 border: 1px solid var(--border-strong);
663 border-radius: var(--r-md);
664 margin-bottom: 12px;
665 position: sticky;
666 top: calc(var(--header-h) + 8px);
667 z-index: 10;
668 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
669 }
670 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
671 .bulk-bar-btn {
672 padding: 6px 12px;
673 border-radius: var(--r-sm);
674 border: 1px solid var(--border);
675 background: var(--bg-surface);
676 color: var(--text);
677 font-size: 13px;
678 cursor: pointer;
679 transition: background 120ms;
680 }
681 .bulk-bar-btn:hover { background: var(--bg-hover); }
682 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
683 .bulk-bar-clear:hover { color: var(--text); }
684
f5b9ef5Claude685 /* ─── Sort controls (issue list) ─── */
686 .issues-sort-row {
687 display: flex;
688 align-items: center;
689 gap: 6px;
690 margin: 0 0 12px;
691 flex-wrap: wrap;
692 }
693 .issues-sort-label {
694 font-size: 12.5px;
695 color: var(--text-muted);
696 font-weight: 600;
697 margin-right: 2px;
698 }
699 .issues-sort-opt {
700 font-size: 12.5px;
701 color: var(--text-muted);
702 text-decoration: none;
703 padding: 3px 10px;
704 border-radius: 9999px;
705 border: 1px solid transparent;
706 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
707 }
708 .issues-sort-opt:hover {
709 background: var(--bg-hover);
710 color: var(--text);
711 }
712 .issues-sort-opt.is-active {
713 background: rgba(140,109,255,0.12);
714 color: var(--text-link);
715 border-color: rgba(140,109,255,0.35);
716 font-weight: 600;
717 }
f7ad7b8Claude718`;
719
720// Pre-rendered <style> tag (constant, reused per request).
721const IssuesStyle = () => (
722 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
723);
724
725// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
726const IssuesEmptySvg = () => (
727 <svg
728 class="issues-empty-art"
729 viewBox="0 0 96 96"
730 fill="none"
731 xmlns="http://www.w3.org/2000/svg"
732 aria-hidden="true"
733 >
734 <defs>
735 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
736 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
737 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
738 </linearGradient>
739 </defs>
740 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
741 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
742 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
743 </svg>
744);
745
746// Detect AI Triage comments by the marker the triage helper writes.
747function isAiTriageComment(body: string | null | undefined): boolean {
748 if (!body) return false;
749 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
750}
751
79136bbClaude752// Helper to resolve repo from :owner/:repo params
753async function resolveRepo(ownerName: string, repoName: string) {
754 const [owner] = await db
755 .select()
756 .from(users)
757 .where(eq(users.username, ownerName))
758 .limit(1);
759 if (!owner) return null;
760
761 const [repo] = await db
762 .select()
763 .from(repositories)
764 .where(
765 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
766 )
767 .limit(1);
768 if (!repo) return null;
769
770 return { owner, repo };
771}
772
8d1483cClaude773// Bulk issue operations (close / reopen multiple issues at once)
774issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
775 const { owner: ownerName, repo: repoName } = c.req.param();
776 const user = c.get("user")!;
777 const body = await c.req.parseBody();
778 const action = String(body.action || "");
779 const rawNumbers = body["numbers[]"];
780 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
781 .map(n => parseInt(String(n), 10))
782 .filter(n => !isNaN(n) && n > 0);
783
784 if (!numbers.length || !["close","reopen"].includes(action)) {
785 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
786 }
787
788 const resolved = await resolveRepo(ownerName, repoName);
789 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
790
791 const newState = action === "close" ? "closed" : "open";
792 await db.update(issues)
793 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
794 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
795
796 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
797 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
798});
799
79136bbClaude800// Issue list
04f6b7fClaude801issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude802 const { owner: ownerName, repo: repoName } = c.req.param();
803 const user = c.get("user");
804 const state = c.req.query("state") || "open";
7a28902Claude805 const searchQ = (c.req.query("q") || "").trim();
806 const labelFilter = (c.req.query("label") || "").trim();
f5b9ef5Claude807 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude808 // Bounded pagination — unbounded selects ran a full table scan + O(n)
809 // sort on every page load; with 10k+ issues the request would hang.
810 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
811 const page = Math.max(1, Number(c.req.query("page")) || 1);
812 const offset = (page - 1) * perPage;
79136bbClaude813
f1dc7c7Claude814 // ── Loading skeleton (flag-gated) ──
815 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
816 // user see the shape of the issue list before the DB count + select
817 // resolve. Behind a flag — we don't ship flashes.
818 if (c.req.query("skeleton") === "1") {
819 return c.html(
820 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
821 <IssuesStyle />
822 <RepoHeader owner={ownerName} repo={repoName} />
823 <IssueNav owner={ownerName} repo={repoName} active="issues" />
824 <style
825 dangerouslySetInnerHTML={{
826 __html: `
827 .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; }
828 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
829 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
830 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
831 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
832 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
833 .issues-skel-row { height: 62px; border-radius: 10px; }
834 `,
835 }}
836 />
837 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
838 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
839 <div class="issues-skel-list" aria-hidden="true">
840 {Array.from({ length: 8 }).map(() => (
841 <div class="issues-skel issues-skel-row" />
842 ))}
843 </div>
844 <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">
845 Loading issues for {ownerName}/{repoName}…
846 </span>
847 </Layout>
848 );
849 }
850
79136bbClaude851 const resolved = await resolveRepo(ownerName, repoName);
852 if (!resolved) {
853 return c.html(
854 <Layout title="Not Found" user={user}>
bb0f894Claude855 <EmptyState title="Repository not found" />
79136bbClaude856 </Layout>,
857 404
858 );
859 }
860
861 const { repo } = resolved;
862
7a28902Claude863 // If label filter is set, find issue IDs that have that label
864 let labelFilteredIds: string[] | null = null;
865 if (labelFilter) {
866 const [matchedLabel] = await db
867 .select({ id: labels.id })
868 .from(labels)
869 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
870 .limit(1);
871 if (matchedLabel) {
872 const rows = await db
873 .select({ issueId: issueLabels.issueId })
874 .from(issueLabels)
875 .where(eq(issueLabels.labelId, matchedLabel.id));
876 labelFilteredIds = rows.map((r) => r.issueId);
877 } else {
878 labelFilteredIds = []; // no matches
879 }
880 }
881
882 const baseWhere = and(
883 eq(issues.repositoryId, repo.id),
884 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
885 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
886 labelFilteredIds !== null && labelFilteredIds.length > 0
887 ? inArray(issues.id, labelFilteredIds)
888 : labelFilteredIds !== null && labelFilteredIds.length === 0
889 ? sql`false`
890 : undefined
891 );
892
79136bbClaude893 const issueList = await db
894 .select({
895 issue: issues,
896 author: { username: users.username },
897 })
898 .from(issues)
899 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude900 .where(baseWhere)
f5b9ef5Claude901 .orderBy(
902 sort === "oldest" ? asc(issues.createdAt)
903 : sort === "updated" ? desc(issues.updatedAt)
904 : desc(issues.createdAt) // newest (default)
905 )
6ea2109Claude906 .limit(perPage)
907 .offset(offset);
79136bbClaude908
909 // Count open/closed
910 const [counts] = await db
911 .select({
912 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
913 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
914 })
915 .from(issues)
916 .where(eq(issues.repositoryId, repo.id));
917
cb5a796Claude918 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
919 const pendingCountList = viewerIsOwnerOnList
920 ? await countPendingForRepo(repo.id)
921 : 0;
922
79136bbClaude923 return c.html(
924 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude925 <IssuesStyle />
79136bbClaude926 <RepoHeader owner={ownerName} repo={repoName} />
927 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude928 <PendingCommentsBanner
929 owner={ownerName}
930 repo={repoName}
931 count={pendingCountList}
932 />
f7ad7b8Claude933 <section class="issues-hero">
934 <div class="issues-hero-bg" aria-hidden="true">
935 <div class="issues-hero-orb" />
936 </div>
937 <div class="issues-hero-inner">
938 <div class="issues-hero-text">
939 <div class="issues-hero-eyebrow">
940 Issue tracker \u00B7{" "}
941 <span class="issues-hero-repo">
942 {ownerName}/{repoName}
943 </span>
944 </div>
945 <h1 class="issues-hero-title">
946 Track <span class="gradient-text">what matters</span>.
947 </h1>
948 <p class="issues-hero-sub">
949 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
950 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
951 : `${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.`}
952 </p>
953 </div>
954 <div class="issues-hero-actions">
955 {user && (
956 <a
957 href={`/${ownerName}/${repoName}/issues/new`}
958 class="btn btn-primary"
959 >
960 + New issue
961 </a>
962 )}
963 <a href={`/${ownerName}/${repoName}`} class="btn">
964 Back to code
965 </a>
966 </div>
967 </div>
968 </section>
969
970 <div class="issues-toolbar">
971 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
972 <a
973 class={`issues-filter${state === "open" ? " is-active" : ""}`}
974 href={`/${ownerName}/${repoName}/issues?state=open`}
975 role="tab"
976 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude977 >
f7ad7b8Claude978 <span aria-hidden="true">{"\u25CB"}</span>
979 <span>Open</span>
980 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
981 </a>
982 <a
983 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
984 href={`/${ownerName}/${repoName}/issues?state=closed`}
985 role="tab"
986 aria-selected={state === "closed" ? "true" : "false"}
987 >
988 <span aria-hidden="true">{"\u2713"}</span>
989 <span>Closed</span>
990 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
991 </a>
992 </div>
7a28902Claude993 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
994 <input type="hidden" name="state" value={state} />
995 <input
996 type="search"
997 name="q"
998 value={searchQ}
999 placeholder="Search issues\u2026"
1000 class="issues-search-input"
1001 aria-label="Search issues by title"
1002 />
1003 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1004 <button type="submit" class="issues-search-btn" aria-label="Search">
1005 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1006 <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" />
1007 </svg>
1008 </button>
1009 </form>
f7ad7b8Claude1010 </div>
7a28902Claude1011 {(searchQ || labelFilter) && (
1012 <div class="issues-filter-banner">
1013 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1014 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1015 {" \u00B7 "}
1016 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1017 </div>
1018 )}
f7ad7b8Claude1019
f5b9ef5Claude1020 <div class="issues-sort-row">
1021 <span class="issues-sort-label">Sort:</span>
1022 {(["newest", "oldest", "updated"] as const).map((s) => (
1023 <a
1024 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1025 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1026 >
1027 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1028 </a>
1029 ))}
1030 </div>
1031
79136bbClaude1032 {issueList.length === 0 ? (
f7ad7b8Claude1033 <div class="issues-empty">
1034 <IssuesEmptySvg />
1035 <h2 class="issues-empty-title">
1036 {state === "closed"
1037 ? "No closed issues yet"
1038 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1039 ? "No issues \u2014 yet"
1040 : "Nothing open right now"}
1041 </h2>
1042 <p class="issues-empty-sub">
1043 {state === "closed"
1044 ? "Closed issues will show up here once the team starts shipping fixes."
1045 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1046 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1047 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1048 </p>
1049 <div class="issues-empty-cta">
1050 {user && state !== "closed" && (
1051 <a
1052 href={`/${ownerName}/${repoName}/issues/new`}
1053 class="btn btn-primary"
1054 >
1055 + Open the first issue
1056 </a>
1057 )}
79136bbClaude1058 {state === "closed" && (
f7ad7b8Claude1059 <a
1060 href={`/${ownerName}/${repoName}/issues?state=open`}
1061 class="btn"
1062 >
1063 View open issues
1064 </a>
79136bbClaude1065 )}
f7ad7b8Claude1066 </div>
1067 </div>
79136bbClaude1068 ) : (
8d1483cClaude1069 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1070 <input type="hidden" name="action" id="bulk-action" value="" />
1071 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1072 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1073 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1074 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1075 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1076 </div>
1077 <ul class="issues-list">
1078 {issueList.map(({ issue, author }) => (
1079 <li class="issues-row">
1080 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1081 <div
1082 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1083 aria-hidden="true"
1084 title={issue.state === "open" ? "Open" : "Closed"}
1085 >
1086 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1087 </div>
8d1483cClaude1088 <div class="issues-row-main">
1089 <h3 class="issues-row-title">
1090 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1091 {issue.title}
1092 </a>
1093 </h3>
1094 <div class="issues-row-meta">
1095 #{issue.number} opened by{" "}
1096 <strong>{author.username}</strong>{" "}
1097 {formatRelative(issue.createdAt)}
1098 </div>
1099 </div>
1100 </li>
1101 ))}
1102 </ul>
1103 <script dangerouslySetInnerHTML={{ __html: `
1104function bulkSubmit(action){
1105 document.getElementById('bulk-action').value=action;
1106 document.getElementById('bulk-form').submit();
1107}
1108function bulkClear(){
1109 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1110 updateBulkBar();
1111}
1112function updateBulkBar(){
1113 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1114 var bar=document.getElementById('bulk-bar');
1115 var cnt=document.getElementById('bulk-bar-count');
1116 if(bar){bar.style.display=checked>0?'flex':'none';}
1117 if(cnt){cnt.textContent=checked+' selected';}
1118}
1119document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1120` }} />
1121 </form>
79136bbClaude1122 )}
1123 </Layout>
1124 );
1125});
1126
1127// New issue form
1128issueRoutes.get(
1129 "/:owner/:repo/issues/new",
1130 softAuth,
1131 requireAuth,
04f6b7fClaude1132 requireRepoAccess("write"),
79136bbClaude1133 async (c) => {
1134 const { owner: ownerName, repo: repoName } = c.req.param();
1135 const user = c.get("user")!;
1136 const error = c.req.query("error");
24cf2caClaude1137 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1138
1139 return c.html(
1140 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1141 <IssuesStyle />
79136bbClaude1142 <RepoHeader owner={ownerName} repo={repoName} />
1143 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1144 <Container maxWidth={800}>
f7ad7b8Claude1145 <section class="issues-hero" style="margin-top:4px">
1146 <div class="issues-hero-bg" aria-hidden="true">
1147 <div class="issues-hero-orb" />
1148 </div>
1149 <div class="issues-hero-inner">
1150 <div class="issues-hero-text">
1151 <div class="issues-hero-eyebrow">
1152 New issue ·{" "}
1153 <span class="issues-hero-repo">
1154 {ownerName}/{repoName}
1155 </span>
1156 </div>
1157 <h1 class="issues-hero-title">
1158 File <span class="gradient-text">it cleanly</span>.
1159 </h1>
1160 <p class="issues-hero-sub">
1161 AI Triage will read the body the moment you submit and post
1162 suggested labels, priority, and possible duplicates within
1163 seconds. You stay in control — nothing is applied.
1164 </p>
1165 </div>
1166 </div>
1167 </section>
79136bbClaude1168 {error && (
bb0f894Claude1169 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1170 )}
0316dbbClaude1171 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1172 <FormGroup>
1173 <Input
79136bbClaude1174 type="text"
1175 name="title"
1176 required
1177 placeholder="Title"
bb0f894Claude1178 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1179 aria-label="Issue title"
79136bbClaude1180 />
bb0f894Claude1181 </FormGroup>
1182 <FormGroup>
1183 <TextArea
79136bbClaude1184 name="body"
1185 rows={12}
1186 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1187 mono
79136bbClaude1188 />
bb0f894Claude1189 </FormGroup>
1190 <Button type="submit" variant="primary">
79136bbClaude1191 Submit new issue
bb0f894Claude1192 </Button>
1193 </Form>
1194 </Container>
79136bbClaude1195 </Layout>
1196 );
1197 }
1198);
1199
1200// Create issue
1201issueRoutes.post(
1202 "/:owner/:repo/issues/new",
1203 softAuth,
1204 requireAuth,
04f6b7fClaude1205 requireRepoAccess("write"),
79136bbClaude1206 async (c) => {
1207 const { owner: ownerName, repo: repoName } = c.req.param();
1208 const user = c.get("user")!;
1209 const body = await c.req.parseBody();
1210 const title = String(body.title || "").trim();
1211 const issueBody = String(body.body || "").trim();
1212
1213 if (!title) {
1214 return c.redirect(
1215 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1216 );
1217 }
1218
1219 const resolved = await resolveRepo(ownerName, repoName);
1220 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1221
1222 const [issue] = await db
1223 .insert(issues)
1224 .values({
1225 repositoryId: resolved.repo.id,
1226 authorId: user.id,
1227 title,
1228 body: issueBody || null,
1229 })
1230 .returning();
1231
1232 // Update issue count
1233 await db
1234 .update(repositories)
1235 .set({ issueCount: resolved.repo.issueCount + 1 })
1236 .where(eq(repositories.id, resolved.repo.id));
1237
a9ada5fClaude1238 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1239 // suggested labels, priority, summary, and a possible-duplicate
1240 // callout. Suggestions only — nothing applied automatically.
1241 triggerIssueTriage({
1242 ownerName,
1243 repoName,
1244 repositoryId: resolved.repo.id,
1245 issueId: issue.id,
1246 issueNumber: issue.number,
1247 authorId: user.id,
1248 title,
1249 body: issueBody,
a28cedeClaude1250 }).catch((err) => {
1251 console.warn(
1252 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1253 err instanceof Error ? err.message : err
1254 );
1255 });
a9ada5fClaude1256
79136bbClaude1257 return c.redirect(
1258 `/${ownerName}/${repoName}/issues/${issue.number}`
1259 );
1260 }
1261);
1262
1263// View single issue
04f6b7fClaude1264issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1265 const { owner: ownerName, repo: repoName } = c.req.param();
1266 const issueNum = parseInt(c.req.param("number"), 10);
1267 const user = c.get("user");
1268
1269 const resolved = await resolveRepo(ownerName, repoName);
1270 if (!resolved) {
1271 return c.html(
1272 <Layout title="Not Found" user={user}>
bb0f894Claude1273 <EmptyState title="Not found" />
79136bbClaude1274 </Layout>,
1275 404
1276 );
1277 }
1278
1279 const [issue] = await db
1280 .select()
1281 .from(issues)
1282 .where(
1283 and(
1284 eq(issues.repositoryId, resolved.repo.id),
1285 eq(issues.number, issueNum)
1286 )
1287 )
1288 .limit(1);
1289
1290 if (!issue) {
1291 return c.html(
1292 <Layout title="Not Found" user={user}>
bb0f894Claude1293 <EmptyState title="Issue not found" />
79136bbClaude1294 </Layout>,
1295 404
1296 );
1297 }
1298
1299 const [author] = await db
1300 .select()
1301 .from(users)
1302 .where(eq(users.id, issue.authorId))
1303 .limit(1);
1304
cb5a796Claude1305 // Get comments. We pull every row and filter by moderation_status +
1306 // viewer identity client-side (in the JSX below) so a moderator/owner
1307 // sees them all while non-author viewers only ever see 'approved'.
1308 const allComments = await db
79136bbClaude1309 .select({
1310 comment: issueComments,
cb5a796Claude1311 author: { id: users.id, username: users.username },
79136bbClaude1312 })
1313 .from(issueComments)
1314 .innerJoin(users, eq(issueComments.authorId, users.id))
1315 .where(eq(issueComments.issueId, issue.id))
1316 .orderBy(asc(issueComments.createdAt));
1317
cb5a796Claude1318 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1319 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1320 // Owner always sees everything (so they can spot conversations going
1321 // sideways even before they hit the queue page).
1322 if (viewerIsOwner) return true;
1323 if (comment.moderationStatus === "approved") return true;
1324 // The comment's own author sees their pending comment so they know
1325 // it landed (with an "Awaiting approval" badge in the render below).
1326 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1327 return true;
1328 }
1329 return false;
1330 });
1331
1332 // Pending banner — when the viewer is the repo owner, show a strip at
1333 // the top of the page nudging them to the moderation queue. Inline on
1334 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1335 const pendingCount = viewerIsOwner
1336 ? await countPendingForRepo(resolved.repo.id)
1337 : 0;
1338
6fc53bdClaude1339 // Load reactions for the issue + each comment in parallel.
1340 const [issueReactions, ...commentReactions] = await Promise.all([
1341 summariseReactions("issue", issue.id, user?.id),
1342 ...comments.map((row) =>
1343 summariseReactions("issue_comment", row.comment.id, user?.id)
1344 ),
1345 ]);
1346
79136bbClaude1347 const canManage =
1348 user &&
1349 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1350 const info = c.req.query("info");
79136bbClaude1351
240c477Claude1352 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1353 const issueRefPattern = `%#${issue.number}%`;
1354 const linkedPrs = await db
1355 .select({
1356 number: pullRequests.number,
1357 title: pullRequests.title,
1358 state: pullRequests.state,
1359 isDraft: pullRequests.isDraft,
1360 })
1361 .from(pullRequests)
1362 .where(
1363 and(
1364 eq(pullRequests.repositoryId, resolved.repo.id),
1365 or(
1366 ilike(pullRequests.title, issueRefPattern),
1367 ilike(pullRequests.body, issueRefPattern),
1368 )
1369 )
1370 )
1371 .orderBy(desc(pullRequests.createdAt))
1372 .limit(8);
1373
79136bbClaude1374 return c.html(
1375 <Layout
1376 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1377 user={user}
1378 >
f7ad7b8Claude1379 <IssuesStyle />
79136bbClaude1380 <RepoHeader owner={ownerName} repo={repoName} />
1381 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1382 <PendingCommentsBanner
1383 owner={ownerName}
1384 repo={repoName}
1385 count={pendingCount}
1386 />
b584e52Claude1387 <div
1388 id="live-comment-banner"
1389 class="alert"
1390 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1391 >
1392 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1393 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1394 reload to view
1395 </a>
1396 </div>
1397 <script
1398 dangerouslySetInnerHTML={{
1399 __html: liveCommentBannerScript({
1400 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1401 bannerElementId: "live-comment-banner",
1402 }),
1403 }}
1404 />
829a046Claude1405 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1406 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1407 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1408 <div class="issue-detail">
58915a9Claude1409 {info && (
f7ad7b8Claude1410 <div class="issues-info-banner">
58915a9Claude1411 {decodeURIComponent(info)}
1412 </div>
1413 )}
f7ad7b8Claude1414
1415 <section class="issues-detail-hero">
1416 <h1 class="issues-detail-title">
1417 {issue.title}
1418 <span class="issues-detail-number">#{issue.number}</span>
1419 </h1>
1420 <div class="issues-detail-attr">
1421 <span
1422 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1423 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1424 >
f7ad7b8Claude1425 <span aria-hidden="true">
1426 {issue.state === "open" ? "\u25CB" : "\u2713"}
1427 </span>
1428 {issue.state === "open" ? "Open" : "Closed"}
1429 </span>
1430 <span>
1431 <strong>{author?.username || "unknown"}</strong> opened this
1432 issue {formatRelative(issue.createdAt)}
1433 </span>
1434 <span class="issues-detail-spacer" />
1435 {issue.state === "open" && user && user.id === resolved.owner.id && (
1436 <a
1437 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1438 class="btn btn-primary"
1439 style="font-size:13px;padding:6px 12px"
1440 title="Generate a draft pull request from this issue using Claude"
1441 >
1442 Build with AI
1443 </a>
1444 )}
1445 </div>
1446 </section>
1447
1448 <div class="issues-thread">
1449 {issue.body && (
1450 <article class="issues-comment">
1451 <header class="issues-comment-header">
1452 <strong>{author?.username || "unknown"}</strong>
1453 <span class="issues-comment-author-pill">Author</span>
1454 <span>commented {formatRelative(issue.createdAt)}</span>
1455 </header>
1456 <div class="issues-comment-body">
1457 <div
1458 class="markdown-body"
1459 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1460 />
1461 </div>
1462 </article>
fbf4aefClaude1463 )}
79136bbClaude1464
f7ad7b8Claude1465 {comments.map(({ comment, author: commentAuthor }) => {
1466 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1467 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1468 return (
cb5a796Claude1469 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1470 <header class="issues-comment-header">
1471 <strong>{commentAuthor.username}</strong>
1472 {isAi ? (
1473 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1474 AI Review
1475 </span>
1476 ) : null}
cb5a796Claude1477 {isPending ? (
1478 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1479 Awaiting approval
1480 </span>
1481 ) : null}
f7ad7b8Claude1482 <span>commented {formatRelative(comment.createdAt)}</span>
1483 </header>
1484 <div class="issues-comment-body">
1485 <div
1486 class="markdown-body"
1487 dangerouslySetInnerHTML={{
1488 __html: renderMarkdown(comment.body),
1489 }}
1490 />
1491 </div>
1492 </article>
1493 );
1494 })}
1495 </div>
79136bbClaude1496
240c477Claude1497 {linkedPrs.length > 0 && (
1498 <div class="iss-linked-prs">
1499 <div class="iss-linked-prs-head">
1500 <span>Linked pull requests</span>
1501 <span>{linkedPrs.length}</span>
1502 </div>
1503 {linkedPrs.map((lpr) => {
1504 const prState = lpr.isDraft ? "draft" : lpr.state;
1505 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1506 return (
1507 <a
1508 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1509 class="iss-linked-pr-row"
1510 >
1511 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1512 <span class="iss-linked-pr-title">{lpr.title}</span>
1513 <span class="iss-linked-pr-num">#{lpr.number}</span>
1514 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1515 </a>
1516 );
1517 })}
1518 </div>
1519 )}
1520
79136bbClaude1521 {user && (
f7ad7b8Claude1522 <form
1523 class="issues-composer"
1524 method="post"
1525 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1526 >
1527 <div class="issues-composer-header">
1528 <span class="issues-composer-tag">Add a comment</span>
1529 <span class="issues-composer-hint">
1530 <a
1531 href="https://docs.github.com/en/get-started/writing-on-github"
1532 target="_blank"
1533 rel="noopener noreferrer"
1534 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1535 >
1536 Markdown supported
1537 </a>
1538 </span>
1539 </div>
1540 <textarea
1541 name="body"
1542 rows={6}
1543 required
6cd2f0eClaude1544 data-md-preview=""
f7ad7b8Claude1545 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1546 />
1547 <div class="issues-composer-actions">
1548 <button type="submit" class="btn btn-primary">
1549 Comment
1550 </button>
1551 {canManage && (
1552 <button
1553 type="submit"
1554 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1555 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1556 >
1557 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1558 </button>
f7ad7b8Claude1559 )}
1560 {canManage && issue.state === "open" && (
1561 <button
1562 type="submit"
1563 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1564 formnovalidate
1565 class="btn"
1566 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1567 >
1568 Re-run AI triage
1569 </button>
1570 )}
1571 </div>
1572 </form>
79136bbClaude1573 )}
1574 </div>
1575 </Layout>
1576 );
1577});
1578
cb5a796Claude1579// Add comment.
1580//
1581// Permission model: we accept comments from ANY authenticated user (read
1582// access required — public repos satisfy that, private repos still need a
1583// collaborator row). The moderation gate in `decideInitialStatus`
1584// determines whether the comment is published immediately or queued for
1585// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1586// "anti-impersonation" rationale.
79136bbClaude1587issueRoutes.post(
1588 "/:owner/:repo/issues/:number/comment",
1589 softAuth,
1590 requireAuth,
cb5a796Claude1591 requireRepoAccess("read"),
79136bbClaude1592 async (c) => {
1593 const { owner: ownerName, repo: repoName } = c.req.param();
1594 const issueNum = parseInt(c.req.param("number"), 10);
1595 const user = c.get("user")!;
1596 const body = await c.req.parseBody();
1597 const commentBody = String(body.body || "").trim();
1598
1599 if (!commentBody) {
1600 return c.redirect(
1601 `/${ownerName}/${repoName}/issues/${issueNum}`
1602 );
1603 }
1604
1605 const resolved = await resolveRepo(ownerName, repoName);
1606 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1607
1608 const [issue] = await db
1609 .select()
1610 .from(issues)
1611 .where(
1612 and(
1613 eq(issues.repositoryId, resolved.repo.id),
1614 eq(issues.number, issueNum)
1615 )
1616 )
1617 .limit(1);
1618
1619 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1620
cb5a796Claude1621 // Decide moderation status BEFORE insert so the row lands in the right
1622 // initial state. Collaborators, the issue author, and trusted users
1623 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1624 // else gets 'pending'.
1625 const decision = await decideInitialStatus({
1626 commenterUserId: user.id,
1627 repositoryId: resolved.repo.id,
1628 kind: "issue",
1629 threadId: issue.id,
1630 });
1631
d4ac5c3Claude1632 const [inserted] = await db
1633 .insert(issueComments)
1634 .values({
1635 issueId: issue.id,
1636 authorId: user.id,
1637 body: commentBody,
cb5a796Claude1638 moderationStatus: decision.status,
d4ac5c3Claude1639 })
1640 .returning();
1641
cb5a796Claude1642 // Live update only when visible. Don't leak pending/rejected via SSE.
1643 if (inserted && decision.status === "approved") {
d4ac5c3Claude1644 try {
1645 const { publish } = await import("../lib/sse");
1646 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1647 event: "issue-comment",
1648 data: {
1649 issueId: issue.id,
1650 commentId: inserted.id,
1651 authorId: user.id,
1652 authorUsername: user.username,
1653 },
1654 });
1655 } catch {
1656 /* SSE is best-effort */
1657 }
1658 }
79136bbClaude1659
cb5a796Claude1660 if (decision.status === "pending") {
1661 // Notify repo owner that a comment is awaiting review.
1662 void notifyOwnerOfPendingComment({
1663 repositoryId: resolved.repo.id,
1664 commenterUsername: user.username,
1665 kind: "issue",
1666 threadNumber: issueNum,
1667 ownerUsername: ownerName,
1668 repoName,
1669 });
1670 return c.redirect(
1671 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1672 );
1673 }
1674 if (decision.status === "rejected") {
1675 // Silent ban — the commenter is on the banned list. Don't reveal
1676 // the gate; just send them back to the page as if it posted.
1677 return c.redirect(
1678 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1679 );
1680 }
1681
79136bbClaude1682 return c.redirect(
1683 `/${ownerName}/${repoName}/issues/${issueNum}`
1684 );
1685 }
1686);
1687
1688// Close issue
1689issueRoutes.post(
1690 "/:owner/:repo/issues/:number/close",
1691 softAuth,
1692 requireAuth,
04f6b7fClaude1693 requireRepoAccess("write"),
79136bbClaude1694 async (c) => {
1695 const { owner: ownerName, repo: repoName } = c.req.param();
1696 const issueNum = parseInt(c.req.param("number"), 10);
1697
1698 const resolved = await resolveRepo(ownerName, repoName);
1699 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1700
1701 await db
1702 .update(issues)
1703 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1704 .where(
1705 and(
1706 eq(issues.repositoryId, resolved.repo.id),
1707 eq(issues.number, issueNum)
1708 )
1709 );
1710
1711 return c.redirect(
1712 `/${ownerName}/${repoName}/issues/${issueNum}`
1713 );
1714 }
1715);
1716
1717// Reopen issue
1718issueRoutes.post(
1719 "/:owner/:repo/issues/:number/reopen",
1720 softAuth,
1721 requireAuth,
04f6b7fClaude1722 requireRepoAccess("write"),
79136bbClaude1723 async (c) => {
1724 const { owner: ownerName, repo: repoName } = c.req.param();
1725 const issueNum = parseInt(c.req.param("number"), 10);
1726
1727 const resolved = await resolveRepo(ownerName, repoName);
1728 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1729
1730 await db
1731 .update(issues)
1732 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1733 .where(
1734 and(
1735 eq(issues.repositoryId, resolved.repo.id),
1736 eq(issues.number, issueNum)
1737 )
1738 );
1739
1740 return c.redirect(
1741 `/${ownerName}/${repoName}/issues/${issueNum}`
1742 );
1743 }
1744);
1745
1746// Shared nav component with issues tab
1747const IssueNav = ({
1748 owner,
1749 repo,
1750 active,
1751}: {
1752 owner: string;
1753 repo: string;
1754 active: "code" | "commits" | "issues";
1755}) => (
bb0f894Claude1756 <TabNav
1757 tabs={[
1758 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1759 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1760 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1761 ]}
1762 />
79136bbClaude1763);
1764
58915a9Claude1765// Re-run AI triage on demand (e.g. after the issue body has been edited).
1766// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1767issueRoutes.post(
1768 "/:owner/:repo/issues/:number/ai-retriage",
1769 softAuth,
1770 requireAuth,
1771 requireRepoAccess("write"),
1772 async (c) => {
1773 const { owner: ownerName, repo: repoName } = c.req.param();
1774 const issueNum = parseInt(c.req.param("number"), 10);
1775 const user = c.get("user")!;
1776 const resolved = await resolveRepo(ownerName, repoName);
1777 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1778
1779 const [issue] = await db
1780 .select()
1781 .from(issues)
1782 .where(
1783 and(
1784 eq(issues.repositoryId, resolved.repo.id),
1785 eq(issues.number, issueNum)
1786 )
1787 )
1788 .limit(1);
1789 if (!issue) {
1790 return c.redirect(`/${ownerName}/${repoName}/issues`);
1791 }
1792
1793 triggerIssueTriage(
1794 {
1795 ownerName,
1796 repoName,
1797 repositoryId: resolved.repo.id,
1798 issueId: issue.id,
1799 issueNumber: issue.number,
1800 authorId: user.id,
1801 title: issue.title,
1802 body: issue.body || "",
1803 },
1804 { force: true }
a28cedeClaude1805 ).catch((err) => {
1806 console.warn(
1807 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1808 err instanceof Error ? err.message : err
1809 );
1810 });
58915a9Claude1811
1812 return c.redirect(
1813 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1814 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1815 )}`
1816 );
1817 }
1818);
1819
79136bbClaude1820export default issueRoutes;
1821export { IssueNav };