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