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