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.tsxBlame1677 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";
f7ad7b8Claude25import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude28import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude29import {
30 decideInitialStatus,
31 notifyOwnerOfPendingComment,
32 countPendingForRepo,
33} from "../lib/comment-moderation";
bb0f894Claude34import {
35 Flex,
36 Container,
37 PageHeader,
38 Form,
39 FormGroup,
40 Input,
41 TextArea,
42 Button,
43 LinkButton,
44 Badge,
45 EmptyState,
46 TabNav,
47 FilterTabs,
48 List,
49 ListItem,
50 Alert,
51 CommentBox,
52 CommentForm,
53 formatRelative,
54} from "../views/ui";
79136bbClaude55
56const issueRoutes = new Hono<AuthEnv>();
57
f7ad7b8Claude58// ---------------------------------------------------------------------------
59// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
60// other routes/shared views. All design tokens come from :root in layout.tsx.
61// ---------------------------------------------------------------------------
62const issuesStyles = `
63 /* Hero card — list page */
64 .issues-hero {
65 position: relative;
66 margin: 4px 0 24px;
67 padding: 28px 32px;
68 background: var(--bg-elevated);
69 border: 1px solid var(--border);
70 border-radius: 16px;
71 overflow: hidden;
72 }
73 .issues-hero::before {
74 content: '';
75 position: absolute;
76 top: 0; left: 0; right: 0;
77 height: 2px;
78 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
79 opacity: 0.7;
80 pointer-events: none;
81 }
82 .issues-hero-bg {
83 position: absolute;
84 inset: -30% -10% auto auto;
85 width: 360px;
86 height: 360px;
87 pointer-events: none;
88 z-index: 0;
89 }
90 .issues-hero-orb {
91 position: absolute;
92 inset: 0;
93 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
94 filter: blur(80px);
95 opacity: 0.7;
96 animation: issuesHeroOrb 14s ease-in-out infinite;
97 }
98 @keyframes issuesHeroOrb {
99 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
100 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
101 }
102 @media (prefers-reduced-motion: reduce) {
103 .issues-hero-orb { animation: none; }
104 }
105 .issues-hero-inner {
106 position: relative;
107 z-index: 1;
108 display: flex;
109 justify-content: space-between;
110 align-items: flex-end;
111 gap: 24px;
112 flex-wrap: wrap;
113 }
114 .issues-hero-text { flex: 1; min-width: 280px; }
115 .issues-hero-eyebrow {
116 font-size: 12.5px;
117 color: var(--text-muted);
118 margin-bottom: 8px;
119 letter-spacing: 0.04em;
120 text-transform: uppercase;
121 font-weight: 600;
122 }
123 .issues-hero-eyebrow .issues-hero-repo {
124 color: var(--accent);
125 text-transform: none;
126 letter-spacing: -0.005em;
127 font-weight: 600;
128 }
129 .issues-hero-title {
130 font-family: var(--font-display);
131 font-size: clamp(28px, 4vw, 40px);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 10px;
136 color: var(--text-strong);
137 }
138 .issues-hero-sub {
139 font-size: 15px;
140 color: var(--text-muted);
141 margin: 0;
142 line-height: 1.5;
143 max-width: 580px;
144 }
145 .issues-hero-actions {
146 display: flex;
147 gap: 8px;
148 flex-wrap: wrap;
149 }
150 @media (max-width: 720px) {
151 .issues-hero { padding: 24px 20px; }
152 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
153 .issues-hero-actions { width: 100%; }
154 .issues-hero-actions .btn { flex: 1; min-width: 0; }
155 }
156
f1dc7c7Claude157 /* Mobile rules — added in the 720px sweep. Kept additive only. */
158 @media (max-width: 720px) {
159 .issues-toolbar { flex-direction: column; align-items: stretch; }
160 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
161 .issues-filter { min-height: 40px; padding: 10px 14px; }
162 .issues-row { padding: 12px 14px; gap: 10px; }
163 .issues-row-side { flex-wrap: wrap; gap: 8px; }
164 .issues-detail-hero { padding: 18px; }
165 .issues-detail-attr { font-size: 13px; }
166 .issues-composer-actions { gap: 8px; }
167 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
168 .issues-empty { padding: 40px 20px; }
169 }
170
f7ad7b8Claude171 /* Count chip + filter pills */
172 .issues-toolbar {
173 display: flex;
174 align-items: center;
175 justify-content: space-between;
176 gap: 12px;
177 flex-wrap: wrap;
178 margin: 0 0 16px;
179 }
180 .issues-filters {
181 display: inline-flex;
182 background: var(--bg-elevated);
183 border: 1px solid var(--border);
184 border-radius: 9999px;
185 padding: 4px;
186 gap: 2px;
187 }
188 .issues-filter {
189 display: inline-flex;
190 align-items: center;
191 gap: 6px;
192 padding: 6px 14px;
193 border-radius: 9999px;
194 font-size: 13px;
195 font-weight: 500;
196 color: var(--text-muted);
197 text-decoration: none;
198 transition: color 120ms ease, background 120ms ease;
199 line-height: 1.4;
200 }
201 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
202 .issues-filter.is-active {
203 background: rgba(140,109,255,0.14);
204 color: var(--text-strong);
205 }
206 .issues-filter-count {
207 font-variant-numeric: tabular-nums;
208 font-size: 11.5px;
209 color: var(--text-muted);
210 background: rgba(255,255,255,0.04);
211 padding: 1px 7px;
212 border-radius: 9999px;
213 }
214 .issues-filter.is-active .issues-filter-count {
215 background: rgba(140,109,255,0.18);
216 color: var(--text);
217 }
218
7a28902Claude219 /* Issue search form */
220 .issues-search-form {
221 display: flex;
222 align-items: center;
223 gap: 0;
224 background: var(--bg-elevated);
225 border: 1px solid var(--border);
226 border-radius: 9999px;
227 overflow: hidden;
228 flex: 1;
229 max-width: 280px;
230 transition: border-color 120ms ease;
231 }
232 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
233 .issues-search-input {
234 flex: 1;
235 background: transparent;
236 color: var(--text);
237 border: none;
238 outline: none;
239 padding: 6px 14px;
240 font-size: 13px;
241 font-family: var(--font-sans, inherit);
242 min-width: 0;
243 }
244 .issues-search-input::placeholder { color: var(--text-muted); }
245 .issues-search-btn {
246 background: transparent;
247 border: none;
248 color: var(--text-muted);
249 padding: 6px 12px 6px 8px;
250 cursor: pointer;
251 display: flex;
252 align-items: center;
253 }
254 .issues-search-btn:hover { color: var(--text); }
255 .issues-filter-banner {
256 margin-bottom: 12px;
257 padding: 8px 14px;
258 background: rgba(140,109,255,0.06);
259 border: 1px solid rgba(140,109,255,0.2);
260 border-radius: 8px;
261 font-size: 13px;
262 color: var(--text-muted);
263 }
264 .issues-filter-clear {
265 color: var(--accent, #8c6dff);
266 text-decoration: underline;
267 cursor: pointer;
268 }
269 .issues-label-badge {
270 display: inline-block;
271 background: rgba(140,109,255,0.15);
272 color: #a78bfa;
273 border-radius: 9999px;
274 padding: 1px 8px;
275 font-size: 11.5px;
276 font-weight: 600;
277 }
278
f7ad7b8Claude279 /* Issue list — modernised rows */
280 .issues-list {
281 list-style: none;
282 margin: 0;
283 padding: 0;
284 border: 1px solid var(--border);
285 border-radius: 12px;
286 overflow: hidden;
287 background: var(--bg-elevated);
288 }
289 .issues-row {
290 display: flex;
291 align-items: flex-start;
292 gap: 14px;
293 padding: 14px 18px;
294 border-bottom: 1px solid var(--border);
295 transition: background 120ms ease, transform 120ms ease;
296 }
297 .issues-row:last-child { border-bottom: none; }
298 .issues-row:hover { background: rgba(140,109,255,0.04); }
299 .issues-row-icon {
300 width: 18px;
301 height: 18px;
302 flex-shrink: 0;
303 margin-top: 3px;
304 display: inline-flex;
305 align-items: center;
306 justify-content: center;
307 }
308 .issues-row-icon.is-open { color: #34d399; }
309 .issues-row-icon.is-closed { color: #b69dff; }
310 .issues-row-main { flex: 1; min-width: 0; }
311 .issues-row-title {
312 font-family: var(--font-display);
313 font-size: 15.5px;
314 font-weight: 600;
315 line-height: 1.35;
316 letter-spacing: -0.012em;
317 margin: 0;
318 display: flex;
319 flex-wrap: wrap;
320 align-items: center;
321 gap: 8px;
322 }
323 .issues-row-title a {
324 color: var(--text-strong);
325 text-decoration: none;
326 transition: color 120ms ease;
327 }
328 .issues-row-title a:hover { color: var(--accent); }
329 .issues-row-meta {
330 margin-top: 5px;
331 font-size: 12.5px;
332 color: var(--text-muted);
333 line-height: 1.5;
334 }
335 .issues-row-meta strong { color: var(--text); font-weight: 600; }
336 .issues-row-side {
337 display: flex;
338 align-items: center;
339 gap: 12px;
340 color: var(--text-muted);
341 font-size: 12.5px;
342 flex-shrink: 0;
343 }
344 .issues-row-comments {
345 display: inline-flex;
346 align-items: center;
347 gap: 5px;
348 color: var(--text-muted);
349 text-decoration: none;
350 }
351 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
352
353 /* Label pills (rendered inline on titles) */
354 .issues-label {
355 display: inline-flex;
356 align-items: center;
357 padding: 2px 9px;
358 border-radius: 9999px;
359 font-size: 11.5px;
360 font-weight: 600;
361 line-height: 1.4;
362 background: rgba(140,109,255,0.10);
363 color: var(--text-strong);
364 border: 1px solid rgba(140,109,255,0.28);
365 letter-spacing: 0.005em;
366 }
367
368 /* Polished empty state */
369 .issues-empty {
370 margin: 0;
371 padding: 56px 32px;
372 background: var(--bg-elevated);
373 border: 1px solid var(--border);
374 border-radius: 16px;
375 text-align: center;
376 position: relative;
377 overflow: hidden;
378 }
379 .issues-empty::before {
380 content: '';
381 position: absolute;
382 top: 0; left: 0; right: 0;
383 height: 2px;
384 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
385 opacity: 0.55;
386 pointer-events: none;
387 }
388 .issues-empty-art {
389 width: 96px;
390 height: 96px;
391 margin: 0 auto 18px;
392 display: block;
393 opacity: 0.85;
394 }
395 .issues-empty-title {
396 font-family: var(--font-display);
397 font-size: 22px;
398 font-weight: 700;
399 letter-spacing: -0.018em;
400 color: var(--text-strong);
401 margin: 0 0 8px;
402 }
403 .issues-empty-sub {
404 font-size: 14.5px;
405 color: var(--text-muted);
406 line-height: 1.55;
407 margin: 0 auto 22px;
408 max-width: 460px;
409 }
410 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
411
412 /* ─── Detail page ─── */
413 .issues-detail-hero {
414 position: relative;
415 margin: 4px 0 20px;
416 padding: 22px 26px;
417 background: var(--bg-elevated);
418 border: 1px solid var(--border);
419 border-radius: 16px;
420 overflow: hidden;
421 }
422 .issues-detail-hero::before {
423 content: '';
424 position: absolute;
425 top: 0; left: 0; right: 0;
426 height: 2px;
427 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
428 opacity: 0.7;
429 pointer-events: none;
430 }
431 .issues-detail-title {
432 font-family: var(--font-display);
433 font-size: clamp(22px, 3vw, 30px);
434 font-weight: 700;
435 letter-spacing: -0.022em;
436 line-height: 1.18;
437 color: var(--text-strong);
438 margin: 0 0 12px;
439 }
440 .issues-detail-title .issues-detail-number {
441 color: var(--text-muted);
442 font-weight: 500;
443 margin-left: 8px;
444 }
445 .issues-detail-attr {
446 display: flex;
447 align-items: center;
448 gap: 12px;
449 flex-wrap: wrap;
450 font-size: 14px;
451 color: var(--text-muted);
452 }
453 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
454 .issues-state-pill {
455 display: inline-flex;
456 align-items: center;
457 gap: 6px;
458 padding: 5px 12px;
459 border-radius: 9999px;
460 font-size: 12.5px;
461 font-weight: 600;
462 line-height: 1.4;
463 letter-spacing: 0.005em;
464 }
465 .issues-state-pill.is-open {
466 background: rgba(52,211,153,0.12);
467 color: #34d399;
468 border: 1px solid rgba(52,211,153,0.35);
469 }
470 .issues-state-pill.is-closed {
471 background: rgba(182,157,255,0.12);
472 color: #b69dff;
473 border: 1px solid rgba(182,157,255,0.35);
474 }
475 .issues-detail-spacer { flex: 1; }
476 .issues-detail-labels {
477 margin-top: 12px;
478 display: flex;
479 gap: 6px;
480 flex-wrap: wrap;
481 }
482
483 /* Comment thread */
484 .issues-thread { margin-top: 18px; }
485 .issues-comment {
486 position: relative;
487 border: 1px solid var(--border);
488 border-radius: 12px;
489 overflow: hidden;
490 background: var(--bg-elevated);
491 margin-bottom: 14px;
492 transition: border-color 160ms ease, box-shadow 160ms ease;
493 }
494 .issues-comment:hover {
495 border-color: var(--border-strong, rgba(255,255,255,0.13));
496 }
497 .issues-comment-header {
498 background: var(--bg-secondary);
499 padding: 10px 16px;
500 border-bottom: 1px solid var(--border);
501 display: flex;
502 align-items: center;
503 gap: 10px;
504 font-size: 13px;
505 color: var(--text-muted);
506 }
507 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
508 .issues-comment-body { padding: 14px 18px; }
509 .issues-comment-author-pill {
510 display: inline-flex;
511 align-items: center;
512 padding: 1px 8px;
513 border-radius: 9999px;
514 background: rgba(140,109,255,0.10);
515 color: var(--accent);
516 font-size: 11px;
517 font-weight: 600;
518 letter-spacing: 0.02em;
519 text-transform: uppercase;
520 }
521
522 /* AI Review comment — distinct purple-accent treatment */
523 .issues-comment.is-ai {
524 border-color: rgba(140,109,255,0.45);
525 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
526 }
527 .issues-comment.is-ai::before {
528 content: '';
529 position: absolute;
530 top: 0; left: 0; right: 0;
531 height: 2px;
532 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
533 pointer-events: none;
534 }
535 .issues-comment.is-ai .issues-comment-header {
536 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
537 border-bottom-color: rgba(140,109,255,0.22);
538 }
539 .issues-ai-badge {
540 display: inline-flex;
541 align-items: center;
542 gap: 5px;
543 padding: 2px 9px;
544 border-radius: 9999px;
545 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
546 color: #fff;
547 font-size: 11px;
548 font-weight: 700;
549 letter-spacing: 0.04em;
550 text-transform: uppercase;
551 line-height: 1.4;
552 }
553 .issues-ai-badge::before {
554 content: '';
555 width: 6px;
556 height: 6px;
557 border-radius: 9999px;
558 background: #fff;
559 opacity: 0.92;
560 box-shadow: 0 0 6px rgba(255,255,255,0.7);
561 }
562
563 /* Composer */
564 .issues-composer {
565 margin-top: 22px;
566 border: 1px solid var(--border);
567 border-radius: 12px;
568 overflow: hidden;
569 background: var(--bg-elevated);
570 transition: border-color 160ms ease, box-shadow 160ms ease;
571 }
572 .issues-composer:focus-within {
573 border-color: rgba(140,109,255,0.55);
574 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
575 }
576 .issues-composer-header {
577 display: flex;
578 align-items: center;
579 justify-content: space-between;
580 gap: 8px;
581 padding: 10px 14px;
582 background: var(--bg-secondary);
583 border-bottom: 1px solid var(--border);
584 font-size: 12.5px;
585 color: var(--text-muted);
586 }
587 .issues-composer-tag {
588 font-weight: 600;
589 color: var(--text);
590 letter-spacing: -0.005em;
591 }
592 .issues-composer-hint a {
593 color: var(--text-muted);
594 text-decoration: none;
595 border-bottom: 1px dashed currentColor;
596 }
597 .issues-composer-hint a:hover { color: var(--accent); }
598 .issues-composer textarea {
599 display: block;
600 width: 100%;
601 border: 0;
602 background: transparent;
603 color: var(--text);
604 padding: 14px 16px;
605 font-family: var(--font-mono);
606 font-size: 13.5px;
607 line-height: 1.55;
608 resize: vertical;
609 outline: none;
610 min-height: 140px;
611 }
612 .issues-composer-actions {
613 display: flex;
614 align-items: center;
615 gap: 8px;
616 padding: 10px 14px;
617 border-top: 1px solid var(--border);
618 background: var(--bg-secondary);
619 flex-wrap: wrap;
620 }
621
622 /* Info banner inside detail */
623 .issues-info-banner {
624 margin: 0 0 14px;
625 padding: 10px 14px;
626 border-radius: 12px;
627 background: rgba(140,109,255,0.08);
628 border: 1px solid rgba(140,109,255,0.28);
629 color: var(--text);
630 font-size: 13.5px;
631 }
240c477Claude632
633 /* ─── Linked PRs ─── */
634 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
635 .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; }
636 .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; }
637 .iss-linked-pr-row:last-child { border-bottom: none; }
638 .iss-linked-pr-row:hover { background: var(--bg-hover); }
639 .iss-linked-pr-icon.is-open { color: #34d399; }
640 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
641 .iss-linked-pr-icon.is-closed { color: #8b949e; }
642 .iss-linked-pr-icon.is-draft { color: #8b949e; }
643 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
644 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
645 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
646 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
647 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
648 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
649 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f7ad7b8Claude650`;
651
652// Pre-rendered <style> tag (constant, reused per request).
653const IssuesStyle = () => (
654 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
655);
656
657// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
658const IssuesEmptySvg = () => (
659 <svg
660 class="issues-empty-art"
661 viewBox="0 0 96 96"
662 fill="none"
663 xmlns="http://www.w3.org/2000/svg"
664 aria-hidden="true"
665 >
666 <defs>
667 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
668 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
669 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
670 </linearGradient>
671 </defs>
672 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
673 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
674 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
675 </svg>
676);
677
678// Detect AI Triage comments by the marker the triage helper writes.
679function isAiTriageComment(body: string | null | undefined): boolean {
680 if (!body) return false;
681 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
682}
683
79136bbClaude684// Helper to resolve repo from :owner/:repo params
685async function resolveRepo(ownerName: string, repoName: string) {
686 const [owner] = await db
687 .select()
688 .from(users)
689 .where(eq(users.username, ownerName))
690 .limit(1);
691 if (!owner) return null;
692
693 const [repo] = await db
694 .select()
695 .from(repositories)
696 .where(
697 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
698 )
699 .limit(1);
700 if (!repo) return null;
701
702 return { owner, repo };
703}
704
705// Issue list
04f6b7fClaude706issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude707 const { owner: ownerName, repo: repoName } = c.req.param();
708 const user = c.get("user");
709 const state = c.req.query("state") || "open";
7a28902Claude710 const searchQ = (c.req.query("q") || "").trim();
711 const labelFilter = (c.req.query("label") || "").trim();
6ea2109Claude712 // Bounded pagination — unbounded selects ran a full table scan + O(n)
713 // sort on every page load; with 10k+ issues the request would hang.
714 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
715 const page = Math.max(1, Number(c.req.query("page")) || 1);
716 const offset = (page - 1) * perPage;
79136bbClaude717
f1dc7c7Claude718 // ── Loading skeleton (flag-gated) ──
719 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
720 // user see the shape of the issue list before the DB count + select
721 // resolve. Behind a flag — we don't ship flashes.
722 if (c.req.query("skeleton") === "1") {
723 return c.html(
724 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
725 <IssuesStyle />
726 <RepoHeader owner={ownerName} repo={repoName} />
727 <IssueNav owner={ownerName} repo={repoName} active="issues" />
728 <style
729 dangerouslySetInnerHTML={{
730 __html: `
731 .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; }
732 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
733 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
734 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
735 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
736 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
737 .issues-skel-row { height: 62px; border-radius: 10px; }
738 `,
739 }}
740 />
741 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
742 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
743 <div class="issues-skel-list" aria-hidden="true">
744 {Array.from({ length: 8 }).map(() => (
745 <div class="issues-skel issues-skel-row" />
746 ))}
747 </div>
748 <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">
749 Loading issues for {ownerName}/{repoName}…
750 </span>
751 </Layout>
752 );
753 }
754
79136bbClaude755 const resolved = await resolveRepo(ownerName, repoName);
756 if (!resolved) {
757 return c.html(
758 <Layout title="Not Found" user={user}>
bb0f894Claude759 <EmptyState title="Repository not found" />
79136bbClaude760 </Layout>,
761 404
762 );
763 }
764
765 const { repo } = resolved;
766
7a28902Claude767 // If label filter is set, find issue IDs that have that label
768 let labelFilteredIds: string[] | null = null;
769 if (labelFilter) {
770 const [matchedLabel] = await db
771 .select({ id: labels.id })
772 .from(labels)
773 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
774 .limit(1);
775 if (matchedLabel) {
776 const rows = await db
777 .select({ issueId: issueLabels.issueId })
778 .from(issueLabels)
779 .where(eq(issueLabels.labelId, matchedLabel.id));
780 labelFilteredIds = rows.map((r) => r.issueId);
781 } else {
782 labelFilteredIds = []; // no matches
783 }
784 }
785
786 const baseWhere = and(
787 eq(issues.repositoryId, repo.id),
788 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
789 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
790 labelFilteredIds !== null && labelFilteredIds.length > 0
791 ? inArray(issues.id, labelFilteredIds)
792 : labelFilteredIds !== null && labelFilteredIds.length === 0
793 ? sql`false`
794 : undefined
795 );
796
79136bbClaude797 const issueList = await db
798 .select({
799 issue: issues,
800 author: { username: users.username },
801 })
802 .from(issues)
803 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude804 .where(baseWhere)
6ea2109Claude805 .orderBy(desc(issues.createdAt))
806 .limit(perPage)
807 .offset(offset);
79136bbClaude808
809 // Count open/closed
810 const [counts] = await db
811 .select({
812 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
813 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
814 })
815 .from(issues)
816 .where(eq(issues.repositoryId, repo.id));
817
cb5a796Claude818 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
819 const pendingCountList = viewerIsOwnerOnList
820 ? await countPendingForRepo(repo.id)
821 : 0;
822
79136bbClaude823 return c.html(
824 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude825 <IssuesStyle />
79136bbClaude826 <RepoHeader owner={ownerName} repo={repoName} />
827 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude828 <PendingCommentsBanner
829 owner={ownerName}
830 repo={repoName}
831 count={pendingCountList}
832 />
f7ad7b8Claude833 <section class="issues-hero">
834 <div class="issues-hero-bg" aria-hidden="true">
835 <div class="issues-hero-orb" />
836 </div>
837 <div class="issues-hero-inner">
838 <div class="issues-hero-text">
839 <div class="issues-hero-eyebrow">
840 Issue tracker \u00B7{" "}
841 <span class="issues-hero-repo">
842 {ownerName}/{repoName}
843 </span>
844 </div>
845 <h1 class="issues-hero-title">
846 Track <span class="gradient-text">what matters</span>.
847 </h1>
848 <p class="issues-hero-sub">
849 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
850 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
851 : `${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.`}
852 </p>
853 </div>
854 <div class="issues-hero-actions">
855 {user && (
856 <a
857 href={`/${ownerName}/${repoName}/issues/new`}
858 class="btn btn-primary"
859 >
860 + New issue
861 </a>
862 )}
863 <a href={`/${ownerName}/${repoName}`} class="btn">
864 Back to code
865 </a>
866 </div>
867 </div>
868 </section>
869
870 <div class="issues-toolbar">
871 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
872 <a
873 class={`issues-filter${state === "open" ? " is-active" : ""}`}
874 href={`/${ownerName}/${repoName}/issues?state=open`}
875 role="tab"
876 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude877 >
f7ad7b8Claude878 <span aria-hidden="true">{"\u25CB"}</span>
879 <span>Open</span>
880 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
881 </a>
882 <a
883 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
884 href={`/${ownerName}/${repoName}/issues?state=closed`}
885 role="tab"
886 aria-selected={state === "closed" ? "true" : "false"}
887 >
888 <span aria-hidden="true">{"\u2713"}</span>
889 <span>Closed</span>
890 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
891 </a>
892 </div>
7a28902Claude893 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
894 <input type="hidden" name="state" value={state} />
895 <input
896 type="search"
897 name="q"
898 value={searchQ}
899 placeholder="Search issues\u2026"
900 class="issues-search-input"
901 aria-label="Search issues by title"
902 />
903 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
904 <button type="submit" class="issues-search-btn" aria-label="Search">
905 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
906 <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" />
907 </svg>
908 </button>
909 </form>
f7ad7b8Claude910 </div>
7a28902Claude911 {(searchQ || labelFilter) && (
912 <div class="issues-filter-banner">
913 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
914 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
915 {" \u00B7 "}
916 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
917 </div>
918 )}
f7ad7b8Claude919
79136bbClaude920 {issueList.length === 0 ? (
f7ad7b8Claude921 <div class="issues-empty">
922 <IssuesEmptySvg />
923 <h2 class="issues-empty-title">
924 {state === "closed"
925 ? "No closed issues yet"
926 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
927 ? "No issues \u2014 yet"
928 : "Nothing open right now"}
929 </h2>
930 <p class="issues-empty-sub">
931 {state === "closed"
932 ? "Closed issues will show up here once the team starts shipping fixes."
933 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
934 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
935 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
936 </p>
937 <div class="issues-empty-cta">
938 {user && state !== "closed" && (
939 <a
940 href={`/${ownerName}/${repoName}/issues/new`}
941 class="btn btn-primary"
942 >
943 + Open the first issue
944 </a>
945 )}
79136bbClaude946 {state === "closed" && (
f7ad7b8Claude947 <a
948 href={`/${ownerName}/${repoName}/issues?state=open`}
949 class="btn"
950 >
951 View open issues
952 </a>
79136bbClaude953 )}
f7ad7b8Claude954 </div>
955 </div>
79136bbClaude956 ) : (
f7ad7b8Claude957 <ul class="issues-list">
79136bbClaude958 {issueList.map(({ issue, author }) => (
f7ad7b8Claude959 <li class="issues-row">
79136bbClaude960 <div
f7ad7b8Claude961 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
962 aria-hidden="true"
963 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude964 >
965 {issue.state === "open" ? "\u25CB" : "\u2713"}
966 </div>
f7ad7b8Claude967 <div class="issues-row-main">
968 <h3 class="issues-row-title">
79136bbClaude969 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
970 {issue.title}
971 </a>
f7ad7b8Claude972 </h3>
973 <div class="issues-row-meta">
974 #{issue.number} opened by{" "}
975 <strong>{author.username}</strong>{" "}
79136bbClaude976 {formatRelative(issue.createdAt)}
977 </div>
978 </div>
f7ad7b8Claude979 </li>
79136bbClaude980 ))}
f7ad7b8Claude981 </ul>
79136bbClaude982 )}
983 </Layout>
984 );
985});
986
987// New issue form
988issueRoutes.get(
989 "/:owner/:repo/issues/new",
990 softAuth,
991 requireAuth,
04f6b7fClaude992 requireRepoAccess("write"),
79136bbClaude993 async (c) => {
994 const { owner: ownerName, repo: repoName } = c.req.param();
995 const user = c.get("user")!;
996 const error = c.req.query("error");
24cf2caClaude997 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude998
999 return c.html(
1000 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1001 <IssuesStyle />
79136bbClaude1002 <RepoHeader owner={ownerName} repo={repoName} />
1003 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1004 <Container maxWidth={800}>
f7ad7b8Claude1005 <section class="issues-hero" style="margin-top:4px">
1006 <div class="issues-hero-bg" aria-hidden="true">
1007 <div class="issues-hero-orb" />
1008 </div>
1009 <div class="issues-hero-inner">
1010 <div class="issues-hero-text">
1011 <div class="issues-hero-eyebrow">
1012 New issue ·{" "}
1013 <span class="issues-hero-repo">
1014 {ownerName}/{repoName}
1015 </span>
1016 </div>
1017 <h1 class="issues-hero-title">
1018 File <span class="gradient-text">it cleanly</span>.
1019 </h1>
1020 <p class="issues-hero-sub">
1021 AI Triage will read the body the moment you submit and post
1022 suggested labels, priority, and possible duplicates within
1023 seconds. You stay in control — nothing is applied.
1024 </p>
1025 </div>
1026 </div>
1027 </section>
79136bbClaude1028 {error && (
bb0f894Claude1029 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1030 )}
0316dbbClaude1031 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1032 <FormGroup>
1033 <Input
79136bbClaude1034 type="text"
1035 name="title"
1036 required
1037 placeholder="Title"
bb0f894Claude1038 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1039 aria-label="Issue title"
79136bbClaude1040 />
bb0f894Claude1041 </FormGroup>
1042 <FormGroup>
1043 <TextArea
79136bbClaude1044 name="body"
1045 rows={12}
1046 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1047 mono
79136bbClaude1048 />
bb0f894Claude1049 </FormGroup>
1050 <Button type="submit" variant="primary">
79136bbClaude1051 Submit new issue
bb0f894Claude1052 </Button>
1053 </Form>
1054 </Container>
79136bbClaude1055 </Layout>
1056 );
1057 }
1058);
1059
1060// Create issue
1061issueRoutes.post(
1062 "/:owner/:repo/issues/new",
1063 softAuth,
1064 requireAuth,
04f6b7fClaude1065 requireRepoAccess("write"),
79136bbClaude1066 async (c) => {
1067 const { owner: ownerName, repo: repoName } = c.req.param();
1068 const user = c.get("user")!;
1069 const body = await c.req.parseBody();
1070 const title = String(body.title || "").trim();
1071 const issueBody = String(body.body || "").trim();
1072
1073 if (!title) {
1074 return c.redirect(
1075 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1076 );
1077 }
1078
1079 const resolved = await resolveRepo(ownerName, repoName);
1080 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1081
1082 const [issue] = await db
1083 .insert(issues)
1084 .values({
1085 repositoryId: resolved.repo.id,
1086 authorId: user.id,
1087 title,
1088 body: issueBody || null,
1089 })
1090 .returning();
1091
1092 // Update issue count
1093 await db
1094 .update(repositories)
1095 .set({ issueCount: resolved.repo.issueCount + 1 })
1096 .where(eq(repositories.id, resolved.repo.id));
1097
a9ada5fClaude1098 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1099 // suggested labels, priority, summary, and a possible-duplicate
1100 // callout. Suggestions only — nothing applied automatically.
1101 triggerIssueTriage({
1102 ownerName,
1103 repoName,
1104 repositoryId: resolved.repo.id,
1105 issueId: issue.id,
1106 issueNumber: issue.number,
1107 authorId: user.id,
1108 title,
1109 body: issueBody,
a28cedeClaude1110 }).catch((err) => {
1111 console.warn(
1112 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1113 err instanceof Error ? err.message : err
1114 );
1115 });
a9ada5fClaude1116
79136bbClaude1117 return c.redirect(
1118 `/${ownerName}/${repoName}/issues/${issue.number}`
1119 );
1120 }
1121);
1122
1123// View single issue
04f6b7fClaude1124issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1125 const { owner: ownerName, repo: repoName } = c.req.param();
1126 const issueNum = parseInt(c.req.param("number"), 10);
1127 const user = c.get("user");
1128
1129 const resolved = await resolveRepo(ownerName, repoName);
1130 if (!resolved) {
1131 return c.html(
1132 <Layout title="Not Found" user={user}>
bb0f894Claude1133 <EmptyState title="Not found" />
79136bbClaude1134 </Layout>,
1135 404
1136 );
1137 }
1138
1139 const [issue] = await db
1140 .select()
1141 .from(issues)
1142 .where(
1143 and(
1144 eq(issues.repositoryId, resolved.repo.id),
1145 eq(issues.number, issueNum)
1146 )
1147 )
1148 .limit(1);
1149
1150 if (!issue) {
1151 return c.html(
1152 <Layout title="Not Found" user={user}>
bb0f894Claude1153 <EmptyState title="Issue not found" />
79136bbClaude1154 </Layout>,
1155 404
1156 );
1157 }
1158
1159 const [author] = await db
1160 .select()
1161 .from(users)
1162 .where(eq(users.id, issue.authorId))
1163 .limit(1);
1164
cb5a796Claude1165 // Get comments. We pull every row and filter by moderation_status +
1166 // viewer identity client-side (in the JSX below) so a moderator/owner
1167 // sees them all while non-author viewers only ever see 'approved'.
1168 const allComments = await db
79136bbClaude1169 .select({
1170 comment: issueComments,
cb5a796Claude1171 author: { id: users.id, username: users.username },
79136bbClaude1172 })
1173 .from(issueComments)
1174 .innerJoin(users, eq(issueComments.authorId, users.id))
1175 .where(eq(issueComments.issueId, issue.id))
1176 .orderBy(asc(issueComments.createdAt));
1177
cb5a796Claude1178 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1179 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1180 // Owner always sees everything (so they can spot conversations going
1181 // sideways even before they hit the queue page).
1182 if (viewerIsOwner) return true;
1183 if (comment.moderationStatus === "approved") return true;
1184 // The comment's own author sees their pending comment so they know
1185 // it landed (with an "Awaiting approval" badge in the render below).
1186 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1187 return true;
1188 }
1189 return false;
1190 });
1191
1192 // Pending banner — when the viewer is the repo owner, show a strip at
1193 // the top of the page nudging them to the moderation queue. Inline on
1194 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1195 const pendingCount = viewerIsOwner
1196 ? await countPendingForRepo(resolved.repo.id)
1197 : 0;
1198
6fc53bdClaude1199 // Load reactions for the issue + each comment in parallel.
1200 const [issueReactions, ...commentReactions] = await Promise.all([
1201 summariseReactions("issue", issue.id, user?.id),
1202 ...comments.map((row) =>
1203 summariseReactions("issue_comment", row.comment.id, user?.id)
1204 ),
1205 ]);
1206
79136bbClaude1207 const canManage =
1208 user &&
1209 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1210 const info = c.req.query("info");
79136bbClaude1211
240c477Claude1212 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1213 const issueRefPattern = `%#${issue.number}%`;
1214 const linkedPrs = await db
1215 .select({
1216 number: pullRequests.number,
1217 title: pullRequests.title,
1218 state: pullRequests.state,
1219 isDraft: pullRequests.isDraft,
1220 })
1221 .from(pullRequests)
1222 .where(
1223 and(
1224 eq(pullRequests.repositoryId, resolved.repo.id),
1225 or(
1226 ilike(pullRequests.title, issueRefPattern),
1227 ilike(pullRequests.body, issueRefPattern),
1228 )
1229 )
1230 )
1231 .orderBy(desc(pullRequests.createdAt))
1232 .limit(8);
1233
79136bbClaude1234 return c.html(
1235 <Layout
1236 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1237 user={user}
1238 >
f7ad7b8Claude1239 <IssuesStyle />
79136bbClaude1240 <RepoHeader owner={ownerName} repo={repoName} />
1241 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1242 <PendingCommentsBanner
1243 owner={ownerName}
1244 repo={repoName}
1245 count={pendingCount}
1246 />
b584e52Claude1247 <div
1248 id="live-comment-banner"
1249 class="alert"
1250 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1251 >
1252 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1253 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1254 reload to view
1255 </a>
1256 </div>
1257 <script
1258 dangerouslySetInnerHTML={{
1259 __html: liveCommentBannerScript({
1260 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1261 bannerElementId: "live-comment-banner",
1262 }),
1263 }}
1264 />
79136bbClaude1265 <div class="issue-detail">
58915a9Claude1266 {info && (
f7ad7b8Claude1267 <div class="issues-info-banner">
58915a9Claude1268 {decodeURIComponent(info)}
1269 </div>
1270 )}
f7ad7b8Claude1271
1272 <section class="issues-detail-hero">
1273 <h1 class="issues-detail-title">
1274 {issue.title}
1275 <span class="issues-detail-number">#{issue.number}</span>
1276 </h1>
1277 <div class="issues-detail-attr">
1278 <span
1279 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1280 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1281 >
f7ad7b8Claude1282 <span aria-hidden="true">
1283 {issue.state === "open" ? "\u25CB" : "\u2713"}
1284 </span>
1285 {issue.state === "open" ? "Open" : "Closed"}
1286 </span>
1287 <span>
1288 <strong>{author?.username || "unknown"}</strong> opened this
1289 issue {formatRelative(issue.createdAt)}
1290 </span>
1291 <span class="issues-detail-spacer" />
1292 {issue.state === "open" && user && user.id === resolved.owner.id && (
1293 <a
1294 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1295 class="btn btn-primary"
1296 style="font-size:13px;padding:6px 12px"
1297 title="Generate a draft pull request from this issue using Claude"
1298 >
1299 Build with AI
1300 </a>
1301 )}
1302 </div>
1303 </section>
1304
1305 <div class="issues-thread">
1306 {issue.body && (
1307 <article class="issues-comment">
1308 <header class="issues-comment-header">
1309 <strong>{author?.username || "unknown"}</strong>
1310 <span class="issues-comment-author-pill">Author</span>
1311 <span>commented {formatRelative(issue.createdAt)}</span>
1312 </header>
1313 <div class="issues-comment-body">
1314 <div
1315 class="markdown-body"
1316 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1317 />
1318 </div>
1319 </article>
fbf4aefClaude1320 )}
79136bbClaude1321
f7ad7b8Claude1322 {comments.map(({ comment, author: commentAuthor }) => {
1323 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1324 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1325 return (
cb5a796Claude1326 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1327 <header class="issues-comment-header">
1328 <strong>{commentAuthor.username}</strong>
1329 {isAi ? (
1330 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1331 AI Review
1332 </span>
1333 ) : null}
cb5a796Claude1334 {isPending ? (
1335 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1336 Awaiting approval
1337 </span>
1338 ) : null}
f7ad7b8Claude1339 <span>commented {formatRelative(comment.createdAt)}</span>
1340 </header>
1341 <div class="issues-comment-body">
1342 <div
1343 class="markdown-body"
1344 dangerouslySetInnerHTML={{
1345 __html: renderMarkdown(comment.body),
1346 }}
1347 />
1348 </div>
1349 </article>
1350 );
1351 })}
1352 </div>
79136bbClaude1353
240c477Claude1354 {linkedPrs.length > 0 && (
1355 <div class="iss-linked-prs">
1356 <div class="iss-linked-prs-head">
1357 <span>Linked pull requests</span>
1358 <span>{linkedPrs.length}</span>
1359 </div>
1360 {linkedPrs.map((lpr) => {
1361 const prState = lpr.isDraft ? "draft" : lpr.state;
1362 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1363 return (
1364 <a
1365 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1366 class="iss-linked-pr-row"
1367 >
1368 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1369 <span class="iss-linked-pr-title">{lpr.title}</span>
1370 <span class="iss-linked-pr-num">#{lpr.number}</span>
1371 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1372 </a>
1373 );
1374 })}
1375 </div>
1376 )}
1377
79136bbClaude1378 {user && (
f7ad7b8Claude1379 <form
1380 class="issues-composer"
1381 method="post"
1382 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1383 >
1384 <div class="issues-composer-header">
1385 <span class="issues-composer-tag">Add a comment</span>
1386 <span class="issues-composer-hint">
1387 <a
1388 href="https://docs.github.com/en/get-started/writing-on-github"
1389 target="_blank"
1390 rel="noopener noreferrer"
1391 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1392 >
1393 Markdown supported
1394 </a>
1395 </span>
1396 </div>
1397 <textarea
1398 name="body"
1399 rows={6}
1400 required
1401 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1402 />
1403 <div class="issues-composer-actions">
1404 <button type="submit" class="btn btn-primary">
1405 Comment
1406 </button>
1407 {canManage && (
1408 <button
1409 type="submit"
1410 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1411 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1412 >
1413 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1414 </button>
f7ad7b8Claude1415 )}
1416 {canManage && issue.state === "open" && (
1417 <button
1418 type="submit"
1419 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1420 formnovalidate
1421 class="btn"
1422 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1423 >
1424 Re-run AI triage
1425 </button>
1426 )}
1427 </div>
1428 </form>
79136bbClaude1429 )}
1430 </div>
1431 </Layout>
1432 );
1433});
1434
cb5a796Claude1435// Add comment.
1436//
1437// Permission model: we accept comments from ANY authenticated user (read
1438// access required — public repos satisfy that, private repos still need a
1439// collaborator row). The moderation gate in `decideInitialStatus`
1440// determines whether the comment is published immediately or queued for
1441// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1442// "anti-impersonation" rationale.
79136bbClaude1443issueRoutes.post(
1444 "/:owner/:repo/issues/:number/comment",
1445 softAuth,
1446 requireAuth,
cb5a796Claude1447 requireRepoAccess("read"),
79136bbClaude1448 async (c) => {
1449 const { owner: ownerName, repo: repoName } = c.req.param();
1450 const issueNum = parseInt(c.req.param("number"), 10);
1451 const user = c.get("user")!;
1452 const body = await c.req.parseBody();
1453 const commentBody = String(body.body || "").trim();
1454
1455 if (!commentBody) {
1456 return c.redirect(
1457 `/${ownerName}/${repoName}/issues/${issueNum}`
1458 );
1459 }
1460
1461 const resolved = await resolveRepo(ownerName, repoName);
1462 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1463
1464 const [issue] = await db
1465 .select()
1466 .from(issues)
1467 .where(
1468 and(
1469 eq(issues.repositoryId, resolved.repo.id),
1470 eq(issues.number, issueNum)
1471 )
1472 )
1473 .limit(1);
1474
1475 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1476
cb5a796Claude1477 // Decide moderation status BEFORE insert so the row lands in the right
1478 // initial state. Collaborators, the issue author, and trusted users
1479 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1480 // else gets 'pending'.
1481 const decision = await decideInitialStatus({
1482 commenterUserId: user.id,
1483 repositoryId: resolved.repo.id,
1484 kind: "issue",
1485 threadId: issue.id,
1486 });
1487
d4ac5c3Claude1488 const [inserted] = await db
1489 .insert(issueComments)
1490 .values({
1491 issueId: issue.id,
1492 authorId: user.id,
1493 body: commentBody,
cb5a796Claude1494 moderationStatus: decision.status,
d4ac5c3Claude1495 })
1496 .returning();
1497
cb5a796Claude1498 // Live update only when visible. Don't leak pending/rejected via SSE.
1499 if (inserted && decision.status === "approved") {
d4ac5c3Claude1500 try {
1501 const { publish } = await import("../lib/sse");
1502 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1503 event: "issue-comment",
1504 data: {
1505 issueId: issue.id,
1506 commentId: inserted.id,
1507 authorId: user.id,
1508 authorUsername: user.username,
1509 },
1510 });
1511 } catch {
1512 /* SSE is best-effort */
1513 }
1514 }
79136bbClaude1515
cb5a796Claude1516 if (decision.status === "pending") {
1517 // Notify repo owner that a comment is awaiting review.
1518 void notifyOwnerOfPendingComment({
1519 repositoryId: resolved.repo.id,
1520 commenterUsername: user.username,
1521 kind: "issue",
1522 threadNumber: issueNum,
1523 ownerUsername: ownerName,
1524 repoName,
1525 });
1526 return c.redirect(
1527 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1528 );
1529 }
1530 if (decision.status === "rejected") {
1531 // Silent ban — the commenter is on the banned list. Don't reveal
1532 // the gate; just send them back to the page as if it posted.
1533 return c.redirect(
1534 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1535 );
1536 }
1537
79136bbClaude1538 return c.redirect(
1539 `/${ownerName}/${repoName}/issues/${issueNum}`
1540 );
1541 }
1542);
1543
1544// Close issue
1545issueRoutes.post(
1546 "/:owner/:repo/issues/:number/close",
1547 softAuth,
1548 requireAuth,
04f6b7fClaude1549 requireRepoAccess("write"),
79136bbClaude1550 async (c) => {
1551 const { owner: ownerName, repo: repoName } = c.req.param();
1552 const issueNum = parseInt(c.req.param("number"), 10);
1553
1554 const resolved = await resolveRepo(ownerName, repoName);
1555 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1556
1557 await db
1558 .update(issues)
1559 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1560 .where(
1561 and(
1562 eq(issues.repositoryId, resolved.repo.id),
1563 eq(issues.number, issueNum)
1564 )
1565 );
1566
1567 return c.redirect(
1568 `/${ownerName}/${repoName}/issues/${issueNum}`
1569 );
1570 }
1571);
1572
1573// Reopen issue
1574issueRoutes.post(
1575 "/:owner/:repo/issues/:number/reopen",
1576 softAuth,
1577 requireAuth,
04f6b7fClaude1578 requireRepoAccess("write"),
79136bbClaude1579 async (c) => {
1580 const { owner: ownerName, repo: repoName } = c.req.param();
1581 const issueNum = parseInt(c.req.param("number"), 10);
1582
1583 const resolved = await resolveRepo(ownerName, repoName);
1584 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1585
1586 await db
1587 .update(issues)
1588 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1589 .where(
1590 and(
1591 eq(issues.repositoryId, resolved.repo.id),
1592 eq(issues.number, issueNum)
1593 )
1594 );
1595
1596 return c.redirect(
1597 `/${ownerName}/${repoName}/issues/${issueNum}`
1598 );
1599 }
1600);
1601
1602// Shared nav component with issues tab
1603const IssueNav = ({
1604 owner,
1605 repo,
1606 active,
1607}: {
1608 owner: string;
1609 repo: string;
1610 active: "code" | "commits" | "issues";
1611}) => (
bb0f894Claude1612 <TabNav
1613 tabs={[
1614 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1615 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1616 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1617 ]}
1618 />
79136bbClaude1619);
1620
58915a9Claude1621// Re-run AI triage on demand (e.g. after the issue body has been edited).
1622// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1623issueRoutes.post(
1624 "/:owner/:repo/issues/:number/ai-retriage",
1625 softAuth,
1626 requireAuth,
1627 requireRepoAccess("write"),
1628 async (c) => {
1629 const { owner: ownerName, repo: repoName } = c.req.param();
1630 const issueNum = parseInt(c.req.param("number"), 10);
1631 const user = c.get("user")!;
1632 const resolved = await resolveRepo(ownerName, repoName);
1633 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1634
1635 const [issue] = await db
1636 .select()
1637 .from(issues)
1638 .where(
1639 and(
1640 eq(issues.repositoryId, resolved.repo.id),
1641 eq(issues.number, issueNum)
1642 )
1643 )
1644 .limit(1);
1645 if (!issue) {
1646 return c.redirect(`/${ownerName}/${repoName}/issues`);
1647 }
1648
1649 triggerIssueTriage(
1650 {
1651 ownerName,
1652 repoName,
1653 repositoryId: resolved.repo.id,
1654 issueId: issue.id,
1655 issueNumber: issue.number,
1656 authorId: user.id,
1657 title: issue.title,
1658 body: issue.body || "",
1659 },
1660 { force: true }
a28cedeClaude1661 ).catch((err) => {
1662 console.warn(
1663 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1664 err instanceof Error ? err.message : err
1665 );
1666 });
58915a9Claude1667
1668 return c.redirect(
1669 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1670 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1671 )}`
1672 );
1673 }
1674);
1675
79136bbClaude1676export default issueRoutes;
1677export { IssueNav };