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.tsxBlame1684 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); }
f7ad7b8Claude653`;
654
655// Pre-rendered <style> tag (constant, reused per request).
656const IssuesStyle = () => (
657 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
658);
659
660// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
661const IssuesEmptySvg = () => (
662 <svg
663 class="issues-empty-art"
664 viewBox="0 0 96 96"
665 fill="none"
666 xmlns="http://www.w3.org/2000/svg"
667 aria-hidden="true"
668 >
669 <defs>
670 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
671 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
672 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
673 </linearGradient>
674 </defs>
675 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
676 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
677 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
678 </svg>
679);
680
681// Detect AI Triage comments by the marker the triage helper writes.
682function isAiTriageComment(body: string | null | undefined): boolean {
683 if (!body) return false;
684 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
685}
686
79136bbClaude687// Helper to resolve repo from :owner/:repo params
688async function resolveRepo(ownerName: string, repoName: string) {
689 const [owner] = await db
690 .select()
691 .from(users)
692 .where(eq(users.username, ownerName))
693 .limit(1);
694 if (!owner) return null;
695
696 const [repo] = await db
697 .select()
698 .from(repositories)
699 .where(
700 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
701 )
702 .limit(1);
703 if (!repo) return null;
704
705 return { owner, repo };
706}
707
708// Issue list
04f6b7fClaude709issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude710 const { owner: ownerName, repo: repoName } = c.req.param();
711 const user = c.get("user");
712 const state = c.req.query("state") || "open";
7a28902Claude713 const searchQ = (c.req.query("q") || "").trim();
714 const labelFilter = (c.req.query("label") || "").trim();
6ea2109Claude715 // Bounded pagination — unbounded selects ran a full table scan + O(n)
716 // sort on every page load; with 10k+ issues the request would hang.
717 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
718 const page = Math.max(1, Number(c.req.query("page")) || 1);
719 const offset = (page - 1) * perPage;
79136bbClaude720
f1dc7c7Claude721 // ── Loading skeleton (flag-gated) ──
722 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
723 // user see the shape of the issue list before the DB count + select
724 // resolve. Behind a flag — we don't ship flashes.
725 if (c.req.query("skeleton") === "1") {
726 return c.html(
727 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
728 <IssuesStyle />
729 <RepoHeader owner={ownerName} repo={repoName} />
730 <IssueNav owner={ownerName} repo={repoName} active="issues" />
731 <style
732 dangerouslySetInnerHTML={{
733 __html: `
734 .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; }
735 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
736 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
737 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
738 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
739 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
740 .issues-skel-row { height: 62px; border-radius: 10px; }
741 `,
742 }}
743 />
744 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
745 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
746 <div class="issues-skel-list" aria-hidden="true">
747 {Array.from({ length: 8 }).map(() => (
748 <div class="issues-skel issues-skel-row" />
749 ))}
750 </div>
751 <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">
752 Loading issues for {ownerName}/{repoName}…
753 </span>
754 </Layout>
755 );
756 }
757
79136bbClaude758 const resolved = await resolveRepo(ownerName, repoName);
759 if (!resolved) {
760 return c.html(
761 <Layout title="Not Found" user={user}>
bb0f894Claude762 <EmptyState title="Repository not found" />
79136bbClaude763 </Layout>,
764 404
765 );
766 }
767
768 const { repo } = resolved;
769
7a28902Claude770 // If label filter is set, find issue IDs that have that label
771 let labelFilteredIds: string[] | null = null;
772 if (labelFilter) {
773 const [matchedLabel] = await db
774 .select({ id: labels.id })
775 .from(labels)
776 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
777 .limit(1);
778 if (matchedLabel) {
779 const rows = await db
780 .select({ issueId: issueLabels.issueId })
781 .from(issueLabels)
782 .where(eq(issueLabels.labelId, matchedLabel.id));
783 labelFilteredIds = rows.map((r) => r.issueId);
784 } else {
785 labelFilteredIds = []; // no matches
786 }
787 }
788
789 const baseWhere = and(
790 eq(issues.repositoryId, repo.id),
791 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
792 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
793 labelFilteredIds !== null && labelFilteredIds.length > 0
794 ? inArray(issues.id, labelFilteredIds)
795 : labelFilteredIds !== null && labelFilteredIds.length === 0
796 ? sql`false`
797 : undefined
798 );
799
79136bbClaude800 const issueList = await db
801 .select({
802 issue: issues,
803 author: { username: users.username },
804 })
805 .from(issues)
806 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude807 .where(baseWhere)
6ea2109Claude808 .orderBy(desc(issues.createdAt))
809 .limit(perPage)
810 .offset(offset);
79136bbClaude811
812 // Count open/closed
813 const [counts] = await db
814 .select({
815 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
816 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
817 })
818 .from(issues)
819 .where(eq(issues.repositoryId, repo.id));
820
cb5a796Claude821 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
822 const pendingCountList = viewerIsOwnerOnList
823 ? await countPendingForRepo(repo.id)
824 : 0;
825
79136bbClaude826 return c.html(
827 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude828 <IssuesStyle />
79136bbClaude829 <RepoHeader owner={ownerName} repo={repoName} />
830 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude831 <PendingCommentsBanner
832 owner={ownerName}
833 repo={repoName}
834 count={pendingCountList}
835 />
f7ad7b8Claude836 <section class="issues-hero">
837 <div class="issues-hero-bg" aria-hidden="true">
838 <div class="issues-hero-orb" />
839 </div>
840 <div class="issues-hero-inner">
841 <div class="issues-hero-text">
842 <div class="issues-hero-eyebrow">
843 Issue tracker \u00B7{" "}
844 <span class="issues-hero-repo">
845 {ownerName}/{repoName}
846 </span>
847 </div>
848 <h1 class="issues-hero-title">
849 Track <span class="gradient-text">what matters</span>.
850 </h1>
851 <p class="issues-hero-sub">
852 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
853 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
854 : `${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.`}
855 </p>
856 </div>
857 <div class="issues-hero-actions">
858 {user && (
859 <a
860 href={`/${ownerName}/${repoName}/issues/new`}
861 class="btn btn-primary"
862 >
863 + New issue
864 </a>
865 )}
866 <a href={`/${ownerName}/${repoName}`} class="btn">
867 Back to code
868 </a>
869 </div>
870 </div>
871 </section>
872
873 <div class="issues-toolbar">
874 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
875 <a
876 class={`issues-filter${state === "open" ? " is-active" : ""}`}
877 href={`/${ownerName}/${repoName}/issues?state=open`}
878 role="tab"
879 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude880 >
f7ad7b8Claude881 <span aria-hidden="true">{"\u25CB"}</span>
882 <span>Open</span>
883 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
884 </a>
885 <a
886 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
887 href={`/${ownerName}/${repoName}/issues?state=closed`}
888 role="tab"
889 aria-selected={state === "closed" ? "true" : "false"}
890 >
891 <span aria-hidden="true">{"\u2713"}</span>
892 <span>Closed</span>
893 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
894 </a>
895 </div>
7a28902Claude896 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
897 <input type="hidden" name="state" value={state} />
898 <input
899 type="search"
900 name="q"
901 value={searchQ}
902 placeholder="Search issues\u2026"
903 class="issues-search-input"
904 aria-label="Search issues by title"
905 />
906 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
907 <button type="submit" class="issues-search-btn" aria-label="Search">
908 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
909 <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" />
910 </svg>
911 </button>
912 </form>
f7ad7b8Claude913 </div>
7a28902Claude914 {(searchQ || labelFilter) && (
915 <div class="issues-filter-banner">
916 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
917 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
918 {" \u00B7 "}
919 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
920 </div>
921 )}
f7ad7b8Claude922
79136bbClaude923 {issueList.length === 0 ? (
f7ad7b8Claude924 <div class="issues-empty">
925 <IssuesEmptySvg />
926 <h2 class="issues-empty-title">
927 {state === "closed"
928 ? "No closed issues yet"
929 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
930 ? "No issues \u2014 yet"
931 : "Nothing open right now"}
932 </h2>
933 <p class="issues-empty-sub">
934 {state === "closed"
935 ? "Closed issues will show up here once the team starts shipping fixes."
936 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
937 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
938 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
939 </p>
940 <div class="issues-empty-cta">
941 {user && state !== "closed" && (
942 <a
943 href={`/${ownerName}/${repoName}/issues/new`}
944 class="btn btn-primary"
945 >
946 + Open the first issue
947 </a>
948 )}
79136bbClaude949 {state === "closed" && (
f7ad7b8Claude950 <a
951 href={`/${ownerName}/${repoName}/issues?state=open`}
952 class="btn"
953 >
954 View open issues
955 </a>
79136bbClaude956 )}
f7ad7b8Claude957 </div>
958 </div>
79136bbClaude959 ) : (
f7ad7b8Claude960 <ul class="issues-list">
79136bbClaude961 {issueList.map(({ issue, author }) => (
f7ad7b8Claude962 <li class="issues-row">
79136bbClaude963 <div
f7ad7b8Claude964 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
965 aria-hidden="true"
966 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude967 >
968 {issue.state === "open" ? "\u25CB" : "\u2713"}
969 </div>
f7ad7b8Claude970 <div class="issues-row-main">
971 <h3 class="issues-row-title">
79136bbClaude972 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
973 {issue.title}
974 </a>
f7ad7b8Claude975 </h3>
976 <div class="issues-row-meta">
977 #{issue.number} opened by{" "}
978 <strong>{author.username}</strong>{" "}
79136bbClaude979 {formatRelative(issue.createdAt)}
980 </div>
981 </div>
f7ad7b8Claude982 </li>
79136bbClaude983 ))}
f7ad7b8Claude984 </ul>
79136bbClaude985 )}
986 </Layout>
987 );
988});
989
990// New issue form
991issueRoutes.get(
992 "/:owner/:repo/issues/new",
993 softAuth,
994 requireAuth,
04f6b7fClaude995 requireRepoAccess("write"),
79136bbClaude996 async (c) => {
997 const { owner: ownerName, repo: repoName } = c.req.param();
998 const user = c.get("user")!;
999 const error = c.req.query("error");
24cf2caClaude1000 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1001
1002 return c.html(
1003 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1004 <IssuesStyle />
79136bbClaude1005 <RepoHeader owner={ownerName} repo={repoName} />
1006 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1007 <Container maxWidth={800}>
f7ad7b8Claude1008 <section class="issues-hero" style="margin-top:4px">
1009 <div class="issues-hero-bg" aria-hidden="true">
1010 <div class="issues-hero-orb" />
1011 </div>
1012 <div class="issues-hero-inner">
1013 <div class="issues-hero-text">
1014 <div class="issues-hero-eyebrow">
1015 New issue ·{" "}
1016 <span class="issues-hero-repo">
1017 {ownerName}/{repoName}
1018 </span>
1019 </div>
1020 <h1 class="issues-hero-title">
1021 File <span class="gradient-text">it cleanly</span>.
1022 </h1>
1023 <p class="issues-hero-sub">
1024 AI Triage will read the body the moment you submit and post
1025 suggested labels, priority, and possible duplicates within
1026 seconds. You stay in control — nothing is applied.
1027 </p>
1028 </div>
1029 </div>
1030 </section>
79136bbClaude1031 {error && (
bb0f894Claude1032 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1033 )}
0316dbbClaude1034 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1035 <FormGroup>
1036 <Input
79136bbClaude1037 type="text"
1038 name="title"
1039 required
1040 placeholder="Title"
bb0f894Claude1041 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1042 aria-label="Issue title"
79136bbClaude1043 />
bb0f894Claude1044 </FormGroup>
1045 <FormGroup>
1046 <TextArea
79136bbClaude1047 name="body"
1048 rows={12}
1049 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1050 mono
79136bbClaude1051 />
bb0f894Claude1052 </FormGroup>
1053 <Button type="submit" variant="primary">
79136bbClaude1054 Submit new issue
bb0f894Claude1055 </Button>
1056 </Form>
1057 </Container>
79136bbClaude1058 </Layout>
1059 );
1060 }
1061);
1062
1063// Create issue
1064issueRoutes.post(
1065 "/:owner/:repo/issues/new",
1066 softAuth,
1067 requireAuth,
04f6b7fClaude1068 requireRepoAccess("write"),
79136bbClaude1069 async (c) => {
1070 const { owner: ownerName, repo: repoName } = c.req.param();
1071 const user = c.get("user")!;
1072 const body = await c.req.parseBody();
1073 const title = String(body.title || "").trim();
1074 const issueBody = String(body.body || "").trim();
1075
1076 if (!title) {
1077 return c.redirect(
1078 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1079 );
1080 }
1081
1082 const resolved = await resolveRepo(ownerName, repoName);
1083 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1084
1085 const [issue] = await db
1086 .insert(issues)
1087 .values({
1088 repositoryId: resolved.repo.id,
1089 authorId: user.id,
1090 title,
1091 body: issueBody || null,
1092 })
1093 .returning();
1094
1095 // Update issue count
1096 await db
1097 .update(repositories)
1098 .set({ issueCount: resolved.repo.issueCount + 1 })
1099 .where(eq(repositories.id, resolved.repo.id));
1100
a9ada5fClaude1101 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1102 // suggested labels, priority, summary, and a possible-duplicate
1103 // callout. Suggestions only — nothing applied automatically.
1104 triggerIssueTriage({
1105 ownerName,
1106 repoName,
1107 repositoryId: resolved.repo.id,
1108 issueId: issue.id,
1109 issueNumber: issue.number,
1110 authorId: user.id,
1111 title,
1112 body: issueBody,
a28cedeClaude1113 }).catch((err) => {
1114 console.warn(
1115 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1116 err instanceof Error ? err.message : err
1117 );
1118 });
a9ada5fClaude1119
79136bbClaude1120 return c.redirect(
1121 `/${ownerName}/${repoName}/issues/${issue.number}`
1122 );
1123 }
1124);
1125
1126// View single issue
04f6b7fClaude1127issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1128 const { owner: ownerName, repo: repoName } = c.req.param();
1129 const issueNum = parseInt(c.req.param("number"), 10);
1130 const user = c.get("user");
1131
1132 const resolved = await resolveRepo(ownerName, repoName);
1133 if (!resolved) {
1134 return c.html(
1135 <Layout title="Not Found" user={user}>
bb0f894Claude1136 <EmptyState title="Not found" />
79136bbClaude1137 </Layout>,
1138 404
1139 );
1140 }
1141
1142 const [issue] = await db
1143 .select()
1144 .from(issues)
1145 .where(
1146 and(
1147 eq(issues.repositoryId, resolved.repo.id),
1148 eq(issues.number, issueNum)
1149 )
1150 )
1151 .limit(1);
1152
1153 if (!issue) {
1154 return c.html(
1155 <Layout title="Not Found" user={user}>
bb0f894Claude1156 <EmptyState title="Issue not found" />
79136bbClaude1157 </Layout>,
1158 404
1159 );
1160 }
1161
1162 const [author] = await db
1163 .select()
1164 .from(users)
1165 .where(eq(users.id, issue.authorId))
1166 .limit(1);
1167
cb5a796Claude1168 // Get comments. We pull every row and filter by moderation_status +
1169 // viewer identity client-side (in the JSX below) so a moderator/owner
1170 // sees them all while non-author viewers only ever see 'approved'.
1171 const allComments = await db
79136bbClaude1172 .select({
1173 comment: issueComments,
cb5a796Claude1174 author: { id: users.id, username: users.username },
79136bbClaude1175 })
1176 .from(issueComments)
1177 .innerJoin(users, eq(issueComments.authorId, users.id))
1178 .where(eq(issueComments.issueId, issue.id))
1179 .orderBy(asc(issueComments.createdAt));
1180
cb5a796Claude1181 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1182 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1183 // Owner always sees everything (so they can spot conversations going
1184 // sideways even before they hit the queue page).
1185 if (viewerIsOwner) return true;
1186 if (comment.moderationStatus === "approved") return true;
1187 // The comment's own author sees their pending comment so they know
1188 // it landed (with an "Awaiting approval" badge in the render below).
1189 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1190 return true;
1191 }
1192 return false;
1193 });
1194
1195 // Pending banner — when the viewer is the repo owner, show a strip at
1196 // the top of the page nudging them to the moderation queue. Inline on
1197 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1198 const pendingCount = viewerIsOwner
1199 ? await countPendingForRepo(resolved.repo.id)
1200 : 0;
1201
6fc53bdClaude1202 // Load reactions for the issue + each comment in parallel.
1203 const [issueReactions, ...commentReactions] = await Promise.all([
1204 summariseReactions("issue", issue.id, user?.id),
1205 ...comments.map((row) =>
1206 summariseReactions("issue_comment", row.comment.id, user?.id)
1207 ),
1208 ]);
1209
79136bbClaude1210 const canManage =
1211 user &&
1212 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1213 const info = c.req.query("info");
79136bbClaude1214
240c477Claude1215 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1216 const issueRefPattern = `%#${issue.number}%`;
1217 const linkedPrs = await db
1218 .select({
1219 number: pullRequests.number,
1220 title: pullRequests.title,
1221 state: pullRequests.state,
1222 isDraft: pullRequests.isDraft,
1223 })
1224 .from(pullRequests)
1225 .where(
1226 and(
1227 eq(pullRequests.repositoryId, resolved.repo.id),
1228 or(
1229 ilike(pullRequests.title, issueRefPattern),
1230 ilike(pullRequests.body, issueRefPattern),
1231 )
1232 )
1233 )
1234 .orderBy(desc(pullRequests.createdAt))
1235 .limit(8);
1236
79136bbClaude1237 return c.html(
1238 <Layout
1239 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1240 user={user}
1241 >
f7ad7b8Claude1242 <IssuesStyle />
79136bbClaude1243 <RepoHeader owner={ownerName} repo={repoName} />
1244 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1245 <PendingCommentsBanner
1246 owner={ownerName}
1247 repo={repoName}
1248 count={pendingCount}
1249 />
b584e52Claude1250 <div
1251 id="live-comment-banner"
1252 class="alert"
1253 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1254 >
1255 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1256 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1257 reload to view
1258 </a>
1259 </div>
1260 <script
1261 dangerouslySetInnerHTML={{
1262 __html: liveCommentBannerScript({
1263 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1264 bannerElementId: "live-comment-banner",
1265 }),
1266 }}
1267 />
829a046Claude1268 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1269 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1270 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1271 <div class="issue-detail">
58915a9Claude1272 {info && (
f7ad7b8Claude1273 <div class="issues-info-banner">
58915a9Claude1274 {decodeURIComponent(info)}
1275 </div>
1276 )}
f7ad7b8Claude1277
1278 <section class="issues-detail-hero">
1279 <h1 class="issues-detail-title">
1280 {issue.title}
1281 <span class="issues-detail-number">#{issue.number}</span>
1282 </h1>
1283 <div class="issues-detail-attr">
1284 <span
1285 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1286 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1287 >
f7ad7b8Claude1288 <span aria-hidden="true">
1289 {issue.state === "open" ? "\u25CB" : "\u2713"}
1290 </span>
1291 {issue.state === "open" ? "Open" : "Closed"}
1292 </span>
1293 <span>
1294 <strong>{author?.username || "unknown"}</strong> opened this
1295 issue {formatRelative(issue.createdAt)}
1296 </span>
1297 <span class="issues-detail-spacer" />
1298 {issue.state === "open" && user && user.id === resolved.owner.id && (
1299 <a
1300 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1301 class="btn btn-primary"
1302 style="font-size:13px;padding:6px 12px"
1303 title="Generate a draft pull request from this issue using Claude"
1304 >
1305 Build with AI
1306 </a>
1307 )}
1308 </div>
1309 </section>
1310
1311 <div class="issues-thread">
1312 {issue.body && (
1313 <article class="issues-comment">
1314 <header class="issues-comment-header">
1315 <strong>{author?.username || "unknown"}</strong>
1316 <span class="issues-comment-author-pill">Author</span>
1317 <span>commented {formatRelative(issue.createdAt)}</span>
1318 </header>
1319 <div class="issues-comment-body">
1320 <div
1321 class="markdown-body"
1322 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1323 />
1324 </div>
1325 </article>
fbf4aefClaude1326 )}
79136bbClaude1327
f7ad7b8Claude1328 {comments.map(({ comment, author: commentAuthor }) => {
1329 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1330 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1331 return (
cb5a796Claude1332 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1333 <header class="issues-comment-header">
1334 <strong>{commentAuthor.username}</strong>
1335 {isAi ? (
1336 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1337 AI Review
1338 </span>
1339 ) : null}
cb5a796Claude1340 {isPending ? (
1341 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1342 Awaiting approval
1343 </span>
1344 ) : null}
f7ad7b8Claude1345 <span>commented {formatRelative(comment.createdAt)}</span>
1346 </header>
1347 <div class="issues-comment-body">
1348 <div
1349 class="markdown-body"
1350 dangerouslySetInnerHTML={{
1351 __html: renderMarkdown(comment.body),
1352 }}
1353 />
1354 </div>
1355 </article>
1356 );
1357 })}
1358 </div>
79136bbClaude1359
240c477Claude1360 {linkedPrs.length > 0 && (
1361 <div class="iss-linked-prs">
1362 <div class="iss-linked-prs-head">
1363 <span>Linked pull requests</span>
1364 <span>{linkedPrs.length}</span>
1365 </div>
1366 {linkedPrs.map((lpr) => {
1367 const prState = lpr.isDraft ? "draft" : lpr.state;
1368 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1369 return (
1370 <a
1371 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1372 class="iss-linked-pr-row"
1373 >
1374 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1375 <span class="iss-linked-pr-title">{lpr.title}</span>
1376 <span class="iss-linked-pr-num">#{lpr.number}</span>
1377 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1378 </a>
1379 );
1380 })}
1381 </div>
1382 )}
1383
79136bbClaude1384 {user && (
f7ad7b8Claude1385 <form
1386 class="issues-composer"
1387 method="post"
1388 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1389 >
1390 <div class="issues-composer-header">
1391 <span class="issues-composer-tag">Add a comment</span>
1392 <span class="issues-composer-hint">
1393 <a
1394 href="https://docs.github.com/en/get-started/writing-on-github"
1395 target="_blank"
1396 rel="noopener noreferrer"
1397 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1398 >
1399 Markdown supported
1400 </a>
1401 </span>
1402 </div>
1403 <textarea
1404 name="body"
1405 rows={6}
1406 required
6cd2f0eClaude1407 data-md-preview=""
f7ad7b8Claude1408 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1409 />
1410 <div class="issues-composer-actions">
1411 <button type="submit" class="btn btn-primary">
1412 Comment
1413 </button>
1414 {canManage && (
1415 <button
1416 type="submit"
1417 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1418 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1419 >
1420 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1421 </button>
f7ad7b8Claude1422 )}
1423 {canManage && issue.state === "open" && (
1424 <button
1425 type="submit"
1426 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1427 formnovalidate
1428 class="btn"
1429 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1430 >
1431 Re-run AI triage
1432 </button>
1433 )}
1434 </div>
1435 </form>
79136bbClaude1436 )}
1437 </div>
1438 </Layout>
1439 );
1440});
1441
cb5a796Claude1442// Add comment.
1443//
1444// Permission model: we accept comments from ANY authenticated user (read
1445// access required — public repos satisfy that, private repos still need a
1446// collaborator row). The moderation gate in `decideInitialStatus`
1447// determines whether the comment is published immediately or queued for
1448// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1449// "anti-impersonation" rationale.
79136bbClaude1450issueRoutes.post(
1451 "/:owner/:repo/issues/:number/comment",
1452 softAuth,
1453 requireAuth,
cb5a796Claude1454 requireRepoAccess("read"),
79136bbClaude1455 async (c) => {
1456 const { owner: ownerName, repo: repoName } = c.req.param();
1457 const issueNum = parseInt(c.req.param("number"), 10);
1458 const user = c.get("user")!;
1459 const body = await c.req.parseBody();
1460 const commentBody = String(body.body || "").trim();
1461
1462 if (!commentBody) {
1463 return c.redirect(
1464 `/${ownerName}/${repoName}/issues/${issueNum}`
1465 );
1466 }
1467
1468 const resolved = await resolveRepo(ownerName, repoName);
1469 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1470
1471 const [issue] = await db
1472 .select()
1473 .from(issues)
1474 .where(
1475 and(
1476 eq(issues.repositoryId, resolved.repo.id),
1477 eq(issues.number, issueNum)
1478 )
1479 )
1480 .limit(1);
1481
1482 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1483
cb5a796Claude1484 // Decide moderation status BEFORE insert so the row lands in the right
1485 // initial state. Collaborators, the issue author, and trusted users
1486 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1487 // else gets 'pending'.
1488 const decision = await decideInitialStatus({
1489 commenterUserId: user.id,
1490 repositoryId: resolved.repo.id,
1491 kind: "issue",
1492 threadId: issue.id,
1493 });
1494
d4ac5c3Claude1495 const [inserted] = await db
1496 .insert(issueComments)
1497 .values({
1498 issueId: issue.id,
1499 authorId: user.id,
1500 body: commentBody,
cb5a796Claude1501 moderationStatus: decision.status,
d4ac5c3Claude1502 })
1503 .returning();
1504
cb5a796Claude1505 // Live update only when visible. Don't leak pending/rejected via SSE.
1506 if (inserted && decision.status === "approved") {
d4ac5c3Claude1507 try {
1508 const { publish } = await import("../lib/sse");
1509 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1510 event: "issue-comment",
1511 data: {
1512 issueId: issue.id,
1513 commentId: inserted.id,
1514 authorId: user.id,
1515 authorUsername: user.username,
1516 },
1517 });
1518 } catch {
1519 /* SSE is best-effort */
1520 }
1521 }
79136bbClaude1522
cb5a796Claude1523 if (decision.status === "pending") {
1524 // Notify repo owner that a comment is awaiting review.
1525 void notifyOwnerOfPendingComment({
1526 repositoryId: resolved.repo.id,
1527 commenterUsername: user.username,
1528 kind: "issue",
1529 threadNumber: issueNum,
1530 ownerUsername: ownerName,
1531 repoName,
1532 });
1533 return c.redirect(
1534 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1535 );
1536 }
1537 if (decision.status === "rejected") {
1538 // Silent ban — the commenter is on the banned list. Don't reveal
1539 // the gate; just send them back to the page as if it posted.
1540 return c.redirect(
1541 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1542 );
1543 }
1544
79136bbClaude1545 return c.redirect(
1546 `/${ownerName}/${repoName}/issues/${issueNum}`
1547 );
1548 }
1549);
1550
1551// Close issue
1552issueRoutes.post(
1553 "/:owner/:repo/issues/:number/close",
1554 softAuth,
1555 requireAuth,
04f6b7fClaude1556 requireRepoAccess("write"),
79136bbClaude1557 async (c) => {
1558 const { owner: ownerName, repo: repoName } = c.req.param();
1559 const issueNum = parseInt(c.req.param("number"), 10);
1560
1561 const resolved = await resolveRepo(ownerName, repoName);
1562 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1563
1564 await db
1565 .update(issues)
1566 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1567 .where(
1568 and(
1569 eq(issues.repositoryId, resolved.repo.id),
1570 eq(issues.number, issueNum)
1571 )
1572 );
1573
1574 return c.redirect(
1575 `/${ownerName}/${repoName}/issues/${issueNum}`
1576 );
1577 }
1578);
1579
1580// Reopen issue
1581issueRoutes.post(
1582 "/:owner/:repo/issues/:number/reopen",
1583 softAuth,
1584 requireAuth,
04f6b7fClaude1585 requireRepoAccess("write"),
79136bbClaude1586 async (c) => {
1587 const { owner: ownerName, repo: repoName } = c.req.param();
1588 const issueNum = parseInt(c.req.param("number"), 10);
1589
1590 const resolved = await resolveRepo(ownerName, repoName);
1591 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1592
1593 await db
1594 .update(issues)
1595 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1596 .where(
1597 and(
1598 eq(issues.repositoryId, resolved.repo.id),
1599 eq(issues.number, issueNum)
1600 )
1601 );
1602
1603 return c.redirect(
1604 `/${ownerName}/${repoName}/issues/${issueNum}`
1605 );
1606 }
1607);
1608
1609// Shared nav component with issues tab
1610const IssueNav = ({
1611 owner,
1612 repo,
1613 active,
1614}: {
1615 owner: string;
1616 repo: string;
1617 active: "code" | "commits" | "issues";
1618}) => (
bb0f894Claude1619 <TabNav
1620 tabs={[
1621 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1622 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1623 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1624 ]}
1625 />
79136bbClaude1626);
1627
58915a9Claude1628// Re-run AI triage on demand (e.g. after the issue body has been edited).
1629// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1630issueRoutes.post(
1631 "/:owner/:repo/issues/:number/ai-retriage",
1632 softAuth,
1633 requireAuth,
1634 requireRepoAccess("write"),
1635 async (c) => {
1636 const { owner: ownerName, repo: repoName } = c.req.param();
1637 const issueNum = parseInt(c.req.param("number"), 10);
1638 const user = c.get("user")!;
1639 const resolved = await resolveRepo(ownerName, repoName);
1640 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1641
1642 const [issue] = await db
1643 .select()
1644 .from(issues)
1645 .where(
1646 and(
1647 eq(issues.repositoryId, resolved.repo.id),
1648 eq(issues.number, issueNum)
1649 )
1650 )
1651 .limit(1);
1652 if (!issue) {
1653 return c.redirect(`/${ownerName}/${repoName}/issues`);
1654 }
1655
1656 triggerIssueTriage(
1657 {
1658 ownerName,
1659 repoName,
1660 repositoryId: resolved.repo.id,
1661 issueId: issue.id,
1662 issueNumber: issue.number,
1663 authorId: user.id,
1664 title: issue.title,
1665 body: issue.body || "",
1666 },
1667 { force: true }
a28cedeClaude1668 ).catch((err) => {
1669 console.warn(
1670 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1671 err instanceof Error ? err.message : err
1672 );
1673 });
58915a9Claude1674
1675 return c.redirect(
1676 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1677 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1678 )}`
1679 );
1680 }
1681);
1682
79136bbClaude1683export default issueRoutes;
1684export { IssueNav };