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.tsxBlame1612 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
7a28902Claude6import { eq, and, desc, asc, sql, ilike, inArray } from "drizzle-orm";
79136bbClaude7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude18import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude19import { ReactionsBar } from "../views/reactions";
20import { summariseReactions } from "../lib/reactions";
24cf2caClaude21import { loadIssueTemplate } from "../lib/templates";
79136bbClaude22import { renderMarkdown } from "../lib/markdown";
b584e52Claude23import { liveCommentBannerScript } from "../lib/sse-client";
f7ad7b8Claude24import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude27import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude28import {
29 decideInitialStatus,
30 notifyOwnerOfPendingComment,
31 countPendingForRepo,
32} from "../lib/comment-moderation";
bb0f894Claude33import {
34 Flex,
35 Container,
36 PageHeader,
37 Form,
38 FormGroup,
39 Input,
40 TextArea,
41 Button,
42 LinkButton,
43 Badge,
44 EmptyState,
45 TabNav,
46 FilterTabs,
47 List,
48 ListItem,
49 Alert,
50 CommentBox,
51 CommentForm,
52 formatRelative,
53} from "../views/ui";
79136bbClaude54
55const issueRoutes = new Hono<AuthEnv>();
56
f7ad7b8Claude57// ---------------------------------------------------------------------------
58// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
59// other routes/shared views. All design tokens come from :root in layout.tsx.
60// ---------------------------------------------------------------------------
61const issuesStyles = `
62 /* Hero card — list page */
63 .issues-hero {
64 position: relative;
65 margin: 4px 0 24px;
66 padding: 28px 32px;
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 }
72 .issues-hero::before {
73 content: '';
74 position: absolute;
75 top: 0; left: 0; right: 0;
76 height: 2px;
77 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
78 opacity: 0.7;
79 pointer-events: none;
80 }
81 .issues-hero-bg {
82 position: absolute;
83 inset: -30% -10% auto auto;
84 width: 360px;
85 height: 360px;
86 pointer-events: none;
87 z-index: 0;
88 }
89 .issues-hero-orb {
90 position: absolute;
91 inset: 0;
92 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
93 filter: blur(80px);
94 opacity: 0.7;
95 animation: issuesHeroOrb 14s ease-in-out infinite;
96 }
97 @keyframes issuesHeroOrb {
98 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
99 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
100 }
101 @media (prefers-reduced-motion: reduce) {
102 .issues-hero-orb { animation: none; }
103 }
104 .issues-hero-inner {
105 position: relative;
106 z-index: 1;
107 display: flex;
108 justify-content: space-between;
109 align-items: flex-end;
110 gap: 24px;
111 flex-wrap: wrap;
112 }
113 .issues-hero-text { flex: 1; min-width: 280px; }
114 .issues-hero-eyebrow {
115 font-size: 12.5px;
116 color: var(--text-muted);
117 margin-bottom: 8px;
118 letter-spacing: 0.04em;
119 text-transform: uppercase;
120 font-weight: 600;
121 }
122 .issues-hero-eyebrow .issues-hero-repo {
123 color: var(--accent);
124 text-transform: none;
125 letter-spacing: -0.005em;
126 font-weight: 600;
127 }
128 .issues-hero-title {
129 font-family: var(--font-display);
130 font-size: clamp(28px, 4vw, 40px);
131 font-weight: 800;
132 letter-spacing: -0.028em;
133 line-height: 1.05;
134 margin: 0 0 10px;
135 color: var(--text-strong);
136 }
137 .issues-hero-sub {
138 font-size: 15px;
139 color: var(--text-muted);
140 margin: 0;
141 line-height: 1.5;
142 max-width: 580px;
143 }
144 .issues-hero-actions {
145 display: flex;
146 gap: 8px;
147 flex-wrap: wrap;
148 }
149 @media (max-width: 720px) {
150 .issues-hero { padding: 24px 20px; }
151 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
152 .issues-hero-actions { width: 100%; }
153 .issues-hero-actions .btn { flex: 1; min-width: 0; }
154 }
155
f1dc7c7Claude156 /* Mobile rules — added in the 720px sweep. Kept additive only. */
157 @media (max-width: 720px) {
158 .issues-toolbar { flex-direction: column; align-items: stretch; }
159 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
160 .issues-filter { min-height: 40px; padding: 10px 14px; }
161 .issues-row { padding: 12px 14px; gap: 10px; }
162 .issues-row-side { flex-wrap: wrap; gap: 8px; }
163 .issues-detail-hero { padding: 18px; }
164 .issues-detail-attr { font-size: 13px; }
165 .issues-composer-actions { gap: 8px; }
166 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
167 .issues-empty { padding: 40px 20px; }
168 }
169
f7ad7b8Claude170 /* Count chip + filter pills */
171 .issues-toolbar {
172 display: flex;
173 align-items: center;
174 justify-content: space-between;
175 gap: 12px;
176 flex-wrap: wrap;
177 margin: 0 0 16px;
178 }
179 .issues-filters {
180 display: inline-flex;
181 background: var(--bg-elevated);
182 border: 1px solid var(--border);
183 border-radius: 9999px;
184 padding: 4px;
185 gap: 2px;
186 }
187 .issues-filter {
188 display: inline-flex;
189 align-items: center;
190 gap: 6px;
191 padding: 6px 14px;
192 border-radius: 9999px;
193 font-size: 13px;
194 font-weight: 500;
195 color: var(--text-muted);
196 text-decoration: none;
197 transition: color 120ms ease, background 120ms ease;
198 line-height: 1.4;
199 }
200 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
201 .issues-filter.is-active {
202 background: rgba(140,109,255,0.14);
203 color: var(--text-strong);
204 }
205 .issues-filter-count {
206 font-variant-numeric: tabular-nums;
207 font-size: 11.5px;
208 color: var(--text-muted);
209 background: rgba(255,255,255,0.04);
210 padding: 1px 7px;
211 border-radius: 9999px;
212 }
213 .issues-filter.is-active .issues-filter-count {
214 background: rgba(140,109,255,0.18);
215 color: var(--text);
216 }
217
7a28902Claude218 /* Issue search form */
219 .issues-search-form {
220 display: flex;
221 align-items: center;
222 gap: 0;
223 background: var(--bg-elevated);
224 border: 1px solid var(--border);
225 border-radius: 9999px;
226 overflow: hidden;
227 flex: 1;
228 max-width: 280px;
229 transition: border-color 120ms ease;
230 }
231 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
232 .issues-search-input {
233 flex: 1;
234 background: transparent;
235 color: var(--text);
236 border: none;
237 outline: none;
238 padding: 6px 14px;
239 font-size: 13px;
240 font-family: var(--font-sans, inherit);
241 min-width: 0;
242 }
243 .issues-search-input::placeholder { color: var(--text-muted); }
244 .issues-search-btn {
245 background: transparent;
246 border: none;
247 color: var(--text-muted);
248 padding: 6px 12px 6px 8px;
249 cursor: pointer;
250 display: flex;
251 align-items: center;
252 }
253 .issues-search-btn:hover { color: var(--text); }
254 .issues-filter-banner {
255 margin-bottom: 12px;
256 padding: 8px 14px;
257 background: rgba(140,109,255,0.06);
258 border: 1px solid rgba(140,109,255,0.2);
259 border-radius: 8px;
260 font-size: 13px;
261 color: var(--text-muted);
262 }
263 .issues-filter-clear {
264 color: var(--accent, #8c6dff);
265 text-decoration: underline;
266 cursor: pointer;
267 }
268 .issues-label-badge {
269 display: inline-block;
270 background: rgba(140,109,255,0.15);
271 color: #a78bfa;
272 border-radius: 9999px;
273 padding: 1px 8px;
274 font-size: 11.5px;
275 font-weight: 600;
276 }
277
f7ad7b8Claude278 /* Issue list — modernised rows */
279 .issues-list {
280 list-style: none;
281 margin: 0;
282 padding: 0;
283 border: 1px solid var(--border);
284 border-radius: 12px;
285 overflow: hidden;
286 background: var(--bg-elevated);
287 }
288 .issues-row {
289 display: flex;
290 align-items: flex-start;
291 gap: 14px;
292 padding: 14px 18px;
293 border-bottom: 1px solid var(--border);
294 transition: background 120ms ease, transform 120ms ease;
295 }
296 .issues-row:last-child { border-bottom: none; }
297 .issues-row:hover { background: rgba(140,109,255,0.04); }
298 .issues-row-icon {
299 width: 18px;
300 height: 18px;
301 flex-shrink: 0;
302 margin-top: 3px;
303 display: inline-flex;
304 align-items: center;
305 justify-content: center;
306 }
307 .issues-row-icon.is-open { color: #34d399; }
308 .issues-row-icon.is-closed { color: #b69dff; }
309 .issues-row-main { flex: 1; min-width: 0; }
310 .issues-row-title {
311 font-family: var(--font-display);
312 font-size: 15.5px;
313 font-weight: 600;
314 line-height: 1.35;
315 letter-spacing: -0.012em;
316 margin: 0;
317 display: flex;
318 flex-wrap: wrap;
319 align-items: center;
320 gap: 8px;
321 }
322 .issues-row-title a {
323 color: var(--text-strong);
324 text-decoration: none;
325 transition: color 120ms ease;
326 }
327 .issues-row-title a:hover { color: var(--accent); }
328 .issues-row-meta {
329 margin-top: 5px;
330 font-size: 12.5px;
331 color: var(--text-muted);
332 line-height: 1.5;
333 }
334 .issues-row-meta strong { color: var(--text); font-weight: 600; }
335 .issues-row-side {
336 display: flex;
337 align-items: center;
338 gap: 12px;
339 color: var(--text-muted);
340 font-size: 12.5px;
341 flex-shrink: 0;
342 }
343 .issues-row-comments {
344 display: inline-flex;
345 align-items: center;
346 gap: 5px;
347 color: var(--text-muted);
348 text-decoration: none;
349 }
350 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
351
352 /* Label pills (rendered inline on titles) */
353 .issues-label {
354 display: inline-flex;
355 align-items: center;
356 padding: 2px 9px;
357 border-radius: 9999px;
358 font-size: 11.5px;
359 font-weight: 600;
360 line-height: 1.4;
361 background: rgba(140,109,255,0.10);
362 color: var(--text-strong);
363 border: 1px solid rgba(140,109,255,0.28);
364 letter-spacing: 0.005em;
365 }
366
367 /* Polished empty state */
368 .issues-empty {
369 margin: 0;
370 padding: 56px 32px;
371 background: var(--bg-elevated);
372 border: 1px solid var(--border);
373 border-radius: 16px;
374 text-align: center;
375 position: relative;
376 overflow: hidden;
377 }
378 .issues-empty::before {
379 content: '';
380 position: absolute;
381 top: 0; left: 0; right: 0;
382 height: 2px;
383 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
384 opacity: 0.55;
385 pointer-events: none;
386 }
387 .issues-empty-art {
388 width: 96px;
389 height: 96px;
390 margin: 0 auto 18px;
391 display: block;
392 opacity: 0.85;
393 }
394 .issues-empty-title {
395 font-family: var(--font-display);
396 font-size: 22px;
397 font-weight: 700;
398 letter-spacing: -0.018em;
399 color: var(--text-strong);
400 margin: 0 0 8px;
401 }
402 .issues-empty-sub {
403 font-size: 14.5px;
404 color: var(--text-muted);
405 line-height: 1.55;
406 margin: 0 auto 22px;
407 max-width: 460px;
408 }
409 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
410
411 /* ─── Detail page ─── */
412 .issues-detail-hero {
413 position: relative;
414 margin: 4px 0 20px;
415 padding: 22px 26px;
416 background: var(--bg-elevated);
417 border: 1px solid var(--border);
418 border-radius: 16px;
419 overflow: hidden;
420 }
421 .issues-detail-hero::before {
422 content: '';
423 position: absolute;
424 top: 0; left: 0; right: 0;
425 height: 2px;
426 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
427 opacity: 0.7;
428 pointer-events: none;
429 }
430 .issues-detail-title {
431 font-family: var(--font-display);
432 font-size: clamp(22px, 3vw, 30px);
433 font-weight: 700;
434 letter-spacing: -0.022em;
435 line-height: 1.18;
436 color: var(--text-strong);
437 margin: 0 0 12px;
438 }
439 .issues-detail-title .issues-detail-number {
440 color: var(--text-muted);
441 font-weight: 500;
442 margin-left: 8px;
443 }
444 .issues-detail-attr {
445 display: flex;
446 align-items: center;
447 gap: 12px;
448 flex-wrap: wrap;
449 font-size: 14px;
450 color: var(--text-muted);
451 }
452 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
453 .issues-state-pill {
454 display: inline-flex;
455 align-items: center;
456 gap: 6px;
457 padding: 5px 12px;
458 border-radius: 9999px;
459 font-size: 12.5px;
460 font-weight: 600;
461 line-height: 1.4;
462 letter-spacing: 0.005em;
463 }
464 .issues-state-pill.is-open {
465 background: rgba(52,211,153,0.12);
466 color: #34d399;
467 border: 1px solid rgba(52,211,153,0.35);
468 }
469 .issues-state-pill.is-closed {
470 background: rgba(182,157,255,0.12);
471 color: #b69dff;
472 border: 1px solid rgba(182,157,255,0.35);
473 }
474 .issues-detail-spacer { flex: 1; }
475 .issues-detail-labels {
476 margin-top: 12px;
477 display: flex;
478 gap: 6px;
479 flex-wrap: wrap;
480 }
481
482 /* Comment thread */
483 .issues-thread { margin-top: 18px; }
484 .issues-comment {
485 position: relative;
486 border: 1px solid var(--border);
487 border-radius: 12px;
488 overflow: hidden;
489 background: var(--bg-elevated);
490 margin-bottom: 14px;
491 transition: border-color 160ms ease, box-shadow 160ms ease;
492 }
493 .issues-comment:hover {
494 border-color: var(--border-strong, rgba(255,255,255,0.13));
495 }
496 .issues-comment-header {
497 background: var(--bg-secondary);
498 padding: 10px 16px;
499 border-bottom: 1px solid var(--border);
500 display: flex;
501 align-items: center;
502 gap: 10px;
503 font-size: 13px;
504 color: var(--text-muted);
505 }
506 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
507 .issues-comment-body { padding: 14px 18px; }
508 .issues-comment-author-pill {
509 display: inline-flex;
510 align-items: center;
511 padding: 1px 8px;
512 border-radius: 9999px;
513 background: rgba(140,109,255,0.10);
514 color: var(--accent);
515 font-size: 11px;
516 font-weight: 600;
517 letter-spacing: 0.02em;
518 text-transform: uppercase;
519 }
520
521 /* AI Review comment — distinct purple-accent treatment */
522 .issues-comment.is-ai {
523 border-color: rgba(140,109,255,0.45);
524 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
525 }
526 .issues-comment.is-ai::before {
527 content: '';
528 position: absolute;
529 top: 0; left: 0; right: 0;
530 height: 2px;
531 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
532 pointer-events: none;
533 }
534 .issues-comment.is-ai .issues-comment-header {
535 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
536 border-bottom-color: rgba(140,109,255,0.22);
537 }
538 .issues-ai-badge {
539 display: inline-flex;
540 align-items: center;
541 gap: 5px;
542 padding: 2px 9px;
543 border-radius: 9999px;
544 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
545 color: #fff;
546 font-size: 11px;
547 font-weight: 700;
548 letter-spacing: 0.04em;
549 text-transform: uppercase;
550 line-height: 1.4;
551 }
552 .issues-ai-badge::before {
553 content: '';
554 width: 6px;
555 height: 6px;
556 border-radius: 9999px;
557 background: #fff;
558 opacity: 0.92;
559 box-shadow: 0 0 6px rgba(255,255,255,0.7);
560 }
561
562 /* Composer */
563 .issues-composer {
564 margin-top: 22px;
565 border: 1px solid var(--border);
566 border-radius: 12px;
567 overflow: hidden;
568 background: var(--bg-elevated);
569 transition: border-color 160ms ease, box-shadow 160ms ease;
570 }
571 .issues-composer:focus-within {
572 border-color: rgba(140,109,255,0.55);
573 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
574 }
575 .issues-composer-header {
576 display: flex;
577 align-items: center;
578 justify-content: space-between;
579 gap: 8px;
580 padding: 10px 14px;
581 background: var(--bg-secondary);
582 border-bottom: 1px solid var(--border);
583 font-size: 12.5px;
584 color: var(--text-muted);
585 }
586 .issues-composer-tag {
587 font-weight: 600;
588 color: var(--text);
589 letter-spacing: -0.005em;
590 }
591 .issues-composer-hint a {
592 color: var(--text-muted);
593 text-decoration: none;
594 border-bottom: 1px dashed currentColor;
595 }
596 .issues-composer-hint a:hover { color: var(--accent); }
597 .issues-composer textarea {
598 display: block;
599 width: 100%;
600 border: 0;
601 background: transparent;
602 color: var(--text);
603 padding: 14px 16px;
604 font-family: var(--font-mono);
605 font-size: 13.5px;
606 line-height: 1.55;
607 resize: vertical;
608 outline: none;
609 min-height: 140px;
610 }
611 .issues-composer-actions {
612 display: flex;
613 align-items: center;
614 gap: 8px;
615 padding: 10px 14px;
616 border-top: 1px solid var(--border);
617 background: var(--bg-secondary);
618 flex-wrap: wrap;
619 }
620
621 /* Info banner inside detail */
622 .issues-info-banner {
623 margin: 0 0 14px;
624 padding: 10px 14px;
625 border-radius: 12px;
626 background: rgba(140,109,255,0.08);
627 border: 1px solid rgba(140,109,255,0.28);
628 color: var(--text);
629 font-size: 13.5px;
630 }
631`;
632
633// Pre-rendered <style> tag (constant, reused per request).
634const IssuesStyle = () => (
635 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
636);
637
638// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
639const IssuesEmptySvg = () => (
640 <svg
641 class="issues-empty-art"
642 viewBox="0 0 96 96"
643 fill="none"
644 xmlns="http://www.w3.org/2000/svg"
645 aria-hidden="true"
646 >
647 <defs>
648 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
649 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
650 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
651 </linearGradient>
652 </defs>
653 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
654 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
655 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
656 </svg>
657);
658
659// Detect AI Triage comments by the marker the triage helper writes.
660function isAiTriageComment(body: string | null | undefined): boolean {
661 if (!body) return false;
662 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
663}
664
79136bbClaude665// Helper to resolve repo from :owner/:repo params
666async function resolveRepo(ownerName: string, repoName: string) {
667 const [owner] = await db
668 .select()
669 .from(users)
670 .where(eq(users.username, ownerName))
671 .limit(1);
672 if (!owner) return null;
673
674 const [repo] = await db
675 .select()
676 .from(repositories)
677 .where(
678 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
679 )
680 .limit(1);
681 if (!repo) return null;
682
683 return { owner, repo };
684}
685
686// Issue list
04f6b7fClaude687issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude688 const { owner: ownerName, repo: repoName } = c.req.param();
689 const user = c.get("user");
690 const state = c.req.query("state") || "open";
7a28902Claude691 const searchQ = (c.req.query("q") || "").trim();
692 const labelFilter = (c.req.query("label") || "").trim();
6ea2109Claude693 // Bounded pagination — unbounded selects ran a full table scan + O(n)
694 // sort on every page load; with 10k+ issues the request would hang.
695 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
696 const page = Math.max(1, Number(c.req.query("page")) || 1);
697 const offset = (page - 1) * perPage;
79136bbClaude698
f1dc7c7Claude699 // ── Loading skeleton (flag-gated) ──
700 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
701 // user see the shape of the issue list before the DB count + select
702 // resolve. Behind a flag — we don't ship flashes.
703 if (c.req.query("skeleton") === "1") {
704 return c.html(
705 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
706 <IssuesStyle />
707 <RepoHeader owner={ownerName} repo={repoName} />
708 <IssueNav owner={ownerName} repo={repoName} active="issues" />
709 <style
710 dangerouslySetInnerHTML={{
711 __html: `
712 .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; }
713 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
714 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
715 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
716 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
717 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
718 .issues-skel-row { height: 62px; border-radius: 10px; }
719 `,
720 }}
721 />
722 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
723 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
724 <div class="issues-skel-list" aria-hidden="true">
725 {Array.from({ length: 8 }).map(() => (
726 <div class="issues-skel issues-skel-row" />
727 ))}
728 </div>
729 <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">
730 Loading issues for {ownerName}/{repoName}…
731 </span>
732 </Layout>
733 );
734 }
735
79136bbClaude736 const resolved = await resolveRepo(ownerName, repoName);
737 if (!resolved) {
738 return c.html(
739 <Layout title="Not Found" user={user}>
bb0f894Claude740 <EmptyState title="Repository not found" />
79136bbClaude741 </Layout>,
742 404
743 );
744 }
745
746 const { repo } = resolved;
747
7a28902Claude748 // If label filter is set, find issue IDs that have that label
749 let labelFilteredIds: string[] | null = null;
750 if (labelFilter) {
751 const [matchedLabel] = await db
752 .select({ id: labels.id })
753 .from(labels)
754 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
755 .limit(1);
756 if (matchedLabel) {
757 const rows = await db
758 .select({ issueId: issueLabels.issueId })
759 .from(issueLabels)
760 .where(eq(issueLabels.labelId, matchedLabel.id));
761 labelFilteredIds = rows.map((r) => r.issueId);
762 } else {
763 labelFilteredIds = []; // no matches
764 }
765 }
766
767 const baseWhere = and(
768 eq(issues.repositoryId, repo.id),
769 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
770 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
771 labelFilteredIds !== null && labelFilteredIds.length > 0
772 ? inArray(issues.id, labelFilteredIds)
773 : labelFilteredIds !== null && labelFilteredIds.length === 0
774 ? sql`false`
775 : undefined
776 );
777
79136bbClaude778 const issueList = await db
779 .select({
780 issue: issues,
781 author: { username: users.username },
782 })
783 .from(issues)
784 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude785 .where(baseWhere)
6ea2109Claude786 .orderBy(desc(issues.createdAt))
787 .limit(perPage)
788 .offset(offset);
79136bbClaude789
790 // Count open/closed
791 const [counts] = await db
792 .select({
793 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
794 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
795 })
796 .from(issues)
797 .where(eq(issues.repositoryId, repo.id));
798
cb5a796Claude799 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
800 const pendingCountList = viewerIsOwnerOnList
801 ? await countPendingForRepo(repo.id)
802 : 0;
803
79136bbClaude804 return c.html(
805 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude806 <IssuesStyle />
79136bbClaude807 <RepoHeader owner={ownerName} repo={repoName} />
808 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude809 <PendingCommentsBanner
810 owner={ownerName}
811 repo={repoName}
812 count={pendingCountList}
813 />
f7ad7b8Claude814 <section class="issues-hero">
815 <div class="issues-hero-bg" aria-hidden="true">
816 <div class="issues-hero-orb" />
817 </div>
818 <div class="issues-hero-inner">
819 <div class="issues-hero-text">
820 <div class="issues-hero-eyebrow">
821 Issue tracker \u00B7{" "}
822 <span class="issues-hero-repo">
823 {ownerName}/{repoName}
824 </span>
825 </div>
826 <h1 class="issues-hero-title">
827 Track <span class="gradient-text">what matters</span>.
828 </h1>
829 <p class="issues-hero-sub">
830 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
831 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
832 : `${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.`}
833 </p>
834 </div>
835 <div class="issues-hero-actions">
836 {user && (
837 <a
838 href={`/${ownerName}/${repoName}/issues/new`}
839 class="btn btn-primary"
840 >
841 + New issue
842 </a>
843 )}
844 <a href={`/${ownerName}/${repoName}`} class="btn">
845 Back to code
846 </a>
847 </div>
848 </div>
849 </section>
850
851 <div class="issues-toolbar">
852 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
853 <a
854 class={`issues-filter${state === "open" ? " is-active" : ""}`}
855 href={`/${ownerName}/${repoName}/issues?state=open`}
856 role="tab"
857 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude858 >
f7ad7b8Claude859 <span aria-hidden="true">{"\u25CB"}</span>
860 <span>Open</span>
861 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
862 </a>
863 <a
864 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
865 href={`/${ownerName}/${repoName}/issues?state=closed`}
866 role="tab"
867 aria-selected={state === "closed" ? "true" : "false"}
868 >
869 <span aria-hidden="true">{"\u2713"}</span>
870 <span>Closed</span>
871 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
872 </a>
873 </div>
7a28902Claude874 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
875 <input type="hidden" name="state" value={state} />
876 <input
877 type="search"
878 name="q"
879 value={searchQ}
880 placeholder="Search issues\u2026"
881 class="issues-search-input"
882 aria-label="Search issues by title"
883 />
884 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
885 <button type="submit" class="issues-search-btn" aria-label="Search">
886 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
887 <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" />
888 </svg>
889 </button>
890 </form>
f7ad7b8Claude891 </div>
7a28902Claude892 {(searchQ || labelFilter) && (
893 <div class="issues-filter-banner">
894 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
895 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
896 {" \u00B7 "}
897 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
898 </div>
899 )}
f7ad7b8Claude900
79136bbClaude901 {issueList.length === 0 ? (
f7ad7b8Claude902 <div class="issues-empty">
903 <IssuesEmptySvg />
904 <h2 class="issues-empty-title">
905 {state === "closed"
906 ? "No closed issues yet"
907 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
908 ? "No issues \u2014 yet"
909 : "Nothing open right now"}
910 </h2>
911 <p class="issues-empty-sub">
912 {state === "closed"
913 ? "Closed issues will show up here once the team starts shipping fixes."
914 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
915 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
916 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
917 </p>
918 <div class="issues-empty-cta">
919 {user && state !== "closed" && (
920 <a
921 href={`/${ownerName}/${repoName}/issues/new`}
922 class="btn btn-primary"
923 >
924 + Open the first issue
925 </a>
926 )}
79136bbClaude927 {state === "closed" && (
f7ad7b8Claude928 <a
929 href={`/${ownerName}/${repoName}/issues?state=open`}
930 class="btn"
931 >
932 View open issues
933 </a>
79136bbClaude934 )}
f7ad7b8Claude935 </div>
936 </div>
79136bbClaude937 ) : (
f7ad7b8Claude938 <ul class="issues-list">
79136bbClaude939 {issueList.map(({ issue, author }) => (
f7ad7b8Claude940 <li class="issues-row">
79136bbClaude941 <div
f7ad7b8Claude942 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
943 aria-hidden="true"
944 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude945 >
946 {issue.state === "open" ? "\u25CB" : "\u2713"}
947 </div>
f7ad7b8Claude948 <div class="issues-row-main">
949 <h3 class="issues-row-title">
79136bbClaude950 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
951 {issue.title}
952 </a>
f7ad7b8Claude953 </h3>
954 <div class="issues-row-meta">
955 #{issue.number} opened by{" "}
956 <strong>{author.username}</strong>{" "}
79136bbClaude957 {formatRelative(issue.createdAt)}
958 </div>
959 </div>
f7ad7b8Claude960 </li>
79136bbClaude961 ))}
f7ad7b8Claude962 </ul>
79136bbClaude963 )}
964 </Layout>
965 );
966});
967
968// New issue form
969issueRoutes.get(
970 "/:owner/:repo/issues/new",
971 softAuth,
972 requireAuth,
04f6b7fClaude973 requireRepoAccess("write"),
79136bbClaude974 async (c) => {
975 const { owner: ownerName, repo: repoName } = c.req.param();
976 const user = c.get("user")!;
977 const error = c.req.query("error");
24cf2caClaude978 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude979
980 return c.html(
981 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude982 <IssuesStyle />
79136bbClaude983 <RepoHeader owner={ownerName} repo={repoName} />
984 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude985 <Container maxWidth={800}>
f7ad7b8Claude986 <section class="issues-hero" style="margin-top:4px">
987 <div class="issues-hero-bg" aria-hidden="true">
988 <div class="issues-hero-orb" />
989 </div>
990 <div class="issues-hero-inner">
991 <div class="issues-hero-text">
992 <div class="issues-hero-eyebrow">
993 New issue ·{" "}
994 <span class="issues-hero-repo">
995 {ownerName}/{repoName}
996 </span>
997 </div>
998 <h1 class="issues-hero-title">
999 File <span class="gradient-text">it cleanly</span>.
1000 </h1>
1001 <p class="issues-hero-sub">
1002 AI Triage will read the body the moment you submit and post
1003 suggested labels, priority, and possible duplicates within
1004 seconds. You stay in control — nothing is applied.
1005 </p>
1006 </div>
1007 </div>
1008 </section>
79136bbClaude1009 {error && (
bb0f894Claude1010 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1011 )}
0316dbbClaude1012 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1013 <FormGroup>
1014 <Input
79136bbClaude1015 type="text"
1016 name="title"
1017 required
1018 placeholder="Title"
bb0f894Claude1019 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1020 aria-label="Issue title"
79136bbClaude1021 />
bb0f894Claude1022 </FormGroup>
1023 <FormGroup>
1024 <TextArea
79136bbClaude1025 name="body"
1026 rows={12}
1027 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1028 mono
79136bbClaude1029 />
bb0f894Claude1030 </FormGroup>
1031 <Button type="submit" variant="primary">
79136bbClaude1032 Submit new issue
bb0f894Claude1033 </Button>
1034 </Form>
1035 </Container>
79136bbClaude1036 </Layout>
1037 );
1038 }
1039);
1040
1041// Create issue
1042issueRoutes.post(
1043 "/:owner/:repo/issues/new",
1044 softAuth,
1045 requireAuth,
04f6b7fClaude1046 requireRepoAccess("write"),
79136bbClaude1047 async (c) => {
1048 const { owner: ownerName, repo: repoName } = c.req.param();
1049 const user = c.get("user")!;
1050 const body = await c.req.parseBody();
1051 const title = String(body.title || "").trim();
1052 const issueBody = String(body.body || "").trim();
1053
1054 if (!title) {
1055 return c.redirect(
1056 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1057 );
1058 }
1059
1060 const resolved = await resolveRepo(ownerName, repoName);
1061 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1062
1063 const [issue] = await db
1064 .insert(issues)
1065 .values({
1066 repositoryId: resolved.repo.id,
1067 authorId: user.id,
1068 title,
1069 body: issueBody || null,
1070 })
1071 .returning();
1072
1073 // Update issue count
1074 await db
1075 .update(repositories)
1076 .set({ issueCount: resolved.repo.issueCount + 1 })
1077 .where(eq(repositories.id, resolved.repo.id));
1078
a9ada5fClaude1079 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1080 // suggested labels, priority, summary, and a possible-duplicate
1081 // callout. Suggestions only — nothing applied automatically.
1082 triggerIssueTriage({
1083 ownerName,
1084 repoName,
1085 repositoryId: resolved.repo.id,
1086 issueId: issue.id,
1087 issueNumber: issue.number,
1088 authorId: user.id,
1089 title,
1090 body: issueBody,
a28cedeClaude1091 }).catch((err) => {
1092 console.warn(
1093 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1094 err instanceof Error ? err.message : err
1095 );
1096 });
a9ada5fClaude1097
79136bbClaude1098 return c.redirect(
1099 `/${ownerName}/${repoName}/issues/${issue.number}`
1100 );
1101 }
1102);
1103
1104// View single issue
04f6b7fClaude1105issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1106 const { owner: ownerName, repo: repoName } = c.req.param();
1107 const issueNum = parseInt(c.req.param("number"), 10);
1108 const user = c.get("user");
1109
1110 const resolved = await resolveRepo(ownerName, repoName);
1111 if (!resolved) {
1112 return c.html(
1113 <Layout title="Not Found" user={user}>
bb0f894Claude1114 <EmptyState title="Not found" />
79136bbClaude1115 </Layout>,
1116 404
1117 );
1118 }
1119
1120 const [issue] = await db
1121 .select()
1122 .from(issues)
1123 .where(
1124 and(
1125 eq(issues.repositoryId, resolved.repo.id),
1126 eq(issues.number, issueNum)
1127 )
1128 )
1129 .limit(1);
1130
1131 if (!issue) {
1132 return c.html(
1133 <Layout title="Not Found" user={user}>
bb0f894Claude1134 <EmptyState title="Issue not found" />
79136bbClaude1135 </Layout>,
1136 404
1137 );
1138 }
1139
1140 const [author] = await db
1141 .select()
1142 .from(users)
1143 .where(eq(users.id, issue.authorId))
1144 .limit(1);
1145
cb5a796Claude1146 // Get comments. We pull every row and filter by moderation_status +
1147 // viewer identity client-side (in the JSX below) so a moderator/owner
1148 // sees them all while non-author viewers only ever see 'approved'.
1149 const allComments = await db
79136bbClaude1150 .select({
1151 comment: issueComments,
cb5a796Claude1152 author: { id: users.id, username: users.username },
79136bbClaude1153 })
1154 .from(issueComments)
1155 .innerJoin(users, eq(issueComments.authorId, users.id))
1156 .where(eq(issueComments.issueId, issue.id))
1157 .orderBy(asc(issueComments.createdAt));
1158
cb5a796Claude1159 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1160 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1161 // Owner always sees everything (so they can spot conversations going
1162 // sideways even before they hit the queue page).
1163 if (viewerIsOwner) return true;
1164 if (comment.moderationStatus === "approved") return true;
1165 // The comment's own author sees their pending comment so they know
1166 // it landed (with an "Awaiting approval" badge in the render below).
1167 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1168 return true;
1169 }
1170 return false;
1171 });
1172
1173 // Pending banner — when the viewer is the repo owner, show a strip at
1174 // the top of the page nudging them to the moderation queue. Inline on
1175 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1176 const pendingCount = viewerIsOwner
1177 ? await countPendingForRepo(resolved.repo.id)
1178 : 0;
1179
6fc53bdClaude1180 // Load reactions for the issue + each comment in parallel.
1181 const [issueReactions, ...commentReactions] = await Promise.all([
1182 summariseReactions("issue", issue.id, user?.id),
1183 ...comments.map((row) =>
1184 summariseReactions("issue_comment", row.comment.id, user?.id)
1185 ),
1186 ]);
1187
79136bbClaude1188 const canManage =
1189 user &&
1190 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1191 const info = c.req.query("info");
79136bbClaude1192
1193 return c.html(
1194 <Layout
1195 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1196 user={user}
1197 >
f7ad7b8Claude1198 <IssuesStyle />
79136bbClaude1199 <RepoHeader owner={ownerName} repo={repoName} />
1200 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1201 <PendingCommentsBanner
1202 owner={ownerName}
1203 repo={repoName}
1204 count={pendingCount}
1205 />
b584e52Claude1206 <div
1207 id="live-comment-banner"
1208 class="alert"
1209 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1210 >
1211 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1212 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1213 reload to view
1214 </a>
1215 </div>
1216 <script
1217 dangerouslySetInnerHTML={{
1218 __html: liveCommentBannerScript({
1219 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1220 bannerElementId: "live-comment-banner",
1221 }),
1222 }}
1223 />
79136bbClaude1224 <div class="issue-detail">
58915a9Claude1225 {info && (
f7ad7b8Claude1226 <div class="issues-info-banner">
58915a9Claude1227 {decodeURIComponent(info)}
1228 </div>
1229 )}
f7ad7b8Claude1230
1231 <section class="issues-detail-hero">
1232 <h1 class="issues-detail-title">
1233 {issue.title}
1234 <span class="issues-detail-number">#{issue.number}</span>
1235 </h1>
1236 <div class="issues-detail-attr">
1237 <span
1238 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1239 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1240 >
f7ad7b8Claude1241 <span aria-hidden="true">
1242 {issue.state === "open" ? "\u25CB" : "\u2713"}
1243 </span>
1244 {issue.state === "open" ? "Open" : "Closed"}
1245 </span>
1246 <span>
1247 <strong>{author?.username || "unknown"}</strong> opened this
1248 issue {formatRelative(issue.createdAt)}
1249 </span>
1250 <span class="issues-detail-spacer" />
1251 {issue.state === "open" && user && user.id === resolved.owner.id && (
1252 <a
1253 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1254 class="btn btn-primary"
1255 style="font-size:13px;padding:6px 12px"
1256 title="Generate a draft pull request from this issue using Claude"
1257 >
1258 Build with AI
1259 </a>
1260 )}
1261 </div>
1262 </section>
1263
1264 <div class="issues-thread">
1265 {issue.body && (
1266 <article class="issues-comment">
1267 <header class="issues-comment-header">
1268 <strong>{author?.username || "unknown"}</strong>
1269 <span class="issues-comment-author-pill">Author</span>
1270 <span>commented {formatRelative(issue.createdAt)}</span>
1271 </header>
1272 <div class="issues-comment-body">
1273 <div
1274 class="markdown-body"
1275 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1276 />
1277 </div>
1278 </article>
fbf4aefClaude1279 )}
79136bbClaude1280
f7ad7b8Claude1281 {comments.map(({ comment, author: commentAuthor }) => {
1282 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1283 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1284 return (
cb5a796Claude1285 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1286 <header class="issues-comment-header">
1287 <strong>{commentAuthor.username}</strong>
1288 {isAi ? (
1289 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1290 AI Review
1291 </span>
1292 ) : null}
cb5a796Claude1293 {isPending ? (
1294 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1295 Awaiting approval
1296 </span>
1297 ) : null}
f7ad7b8Claude1298 <span>commented {formatRelative(comment.createdAt)}</span>
1299 </header>
1300 <div class="issues-comment-body">
1301 <div
1302 class="markdown-body"
1303 dangerouslySetInnerHTML={{
1304 __html: renderMarkdown(comment.body),
1305 }}
1306 />
1307 </div>
1308 </article>
1309 );
1310 })}
1311 </div>
79136bbClaude1312
1313 {user && (
f7ad7b8Claude1314 <form
1315 class="issues-composer"
1316 method="post"
1317 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1318 >
1319 <div class="issues-composer-header">
1320 <span class="issues-composer-tag">Add a comment</span>
1321 <span class="issues-composer-hint">
1322 <a
1323 href="https://docs.github.com/en/get-started/writing-on-github"
1324 target="_blank"
1325 rel="noopener noreferrer"
1326 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1327 >
1328 Markdown supported
1329 </a>
1330 </span>
1331 </div>
1332 <textarea
1333 name="body"
1334 rows={6}
1335 required
1336 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1337 />
1338 <div class="issues-composer-actions">
1339 <button type="submit" class="btn btn-primary">
1340 Comment
1341 </button>
1342 {canManage && (
1343 <button
1344 type="submit"
1345 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1346 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1347 >
1348 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1349 </button>
f7ad7b8Claude1350 )}
1351 {canManage && issue.state === "open" && (
1352 <button
1353 type="submit"
1354 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1355 formnovalidate
1356 class="btn"
1357 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1358 >
1359 Re-run AI triage
1360 </button>
1361 )}
1362 </div>
1363 </form>
79136bbClaude1364 )}
1365 </div>
1366 </Layout>
1367 );
1368});
1369
cb5a796Claude1370// Add comment.
1371//
1372// Permission model: we accept comments from ANY authenticated user (read
1373// access required — public repos satisfy that, private repos still need a
1374// collaborator row). The moderation gate in `decideInitialStatus`
1375// determines whether the comment is published immediately or queued for
1376// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1377// "anti-impersonation" rationale.
79136bbClaude1378issueRoutes.post(
1379 "/:owner/:repo/issues/:number/comment",
1380 softAuth,
1381 requireAuth,
cb5a796Claude1382 requireRepoAccess("read"),
79136bbClaude1383 async (c) => {
1384 const { owner: ownerName, repo: repoName } = c.req.param();
1385 const issueNum = parseInt(c.req.param("number"), 10);
1386 const user = c.get("user")!;
1387 const body = await c.req.parseBody();
1388 const commentBody = String(body.body || "").trim();
1389
1390 if (!commentBody) {
1391 return c.redirect(
1392 `/${ownerName}/${repoName}/issues/${issueNum}`
1393 );
1394 }
1395
1396 const resolved = await resolveRepo(ownerName, repoName);
1397 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1398
1399 const [issue] = await db
1400 .select()
1401 .from(issues)
1402 .where(
1403 and(
1404 eq(issues.repositoryId, resolved.repo.id),
1405 eq(issues.number, issueNum)
1406 )
1407 )
1408 .limit(1);
1409
1410 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1411
cb5a796Claude1412 // Decide moderation status BEFORE insert so the row lands in the right
1413 // initial state. Collaborators, the issue author, and trusted users
1414 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1415 // else gets 'pending'.
1416 const decision = await decideInitialStatus({
1417 commenterUserId: user.id,
1418 repositoryId: resolved.repo.id,
1419 kind: "issue",
1420 threadId: issue.id,
1421 });
1422
d4ac5c3Claude1423 const [inserted] = await db
1424 .insert(issueComments)
1425 .values({
1426 issueId: issue.id,
1427 authorId: user.id,
1428 body: commentBody,
cb5a796Claude1429 moderationStatus: decision.status,
d4ac5c3Claude1430 })
1431 .returning();
1432
cb5a796Claude1433 // Live update only when visible. Don't leak pending/rejected via SSE.
1434 if (inserted && decision.status === "approved") {
d4ac5c3Claude1435 try {
1436 const { publish } = await import("../lib/sse");
1437 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1438 event: "issue-comment",
1439 data: {
1440 issueId: issue.id,
1441 commentId: inserted.id,
1442 authorId: user.id,
1443 authorUsername: user.username,
1444 },
1445 });
1446 } catch {
1447 /* SSE is best-effort */
1448 }
1449 }
79136bbClaude1450
cb5a796Claude1451 if (decision.status === "pending") {
1452 // Notify repo owner that a comment is awaiting review.
1453 void notifyOwnerOfPendingComment({
1454 repositoryId: resolved.repo.id,
1455 commenterUsername: user.username,
1456 kind: "issue",
1457 threadNumber: issueNum,
1458 ownerUsername: ownerName,
1459 repoName,
1460 });
1461 return c.redirect(
1462 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1463 );
1464 }
1465 if (decision.status === "rejected") {
1466 // Silent ban — the commenter is on the banned list. Don't reveal
1467 // the gate; just send them back to the page as if it posted.
1468 return c.redirect(
1469 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1470 );
1471 }
1472
79136bbClaude1473 return c.redirect(
1474 `/${ownerName}/${repoName}/issues/${issueNum}`
1475 );
1476 }
1477);
1478
1479// Close issue
1480issueRoutes.post(
1481 "/:owner/:repo/issues/:number/close",
1482 softAuth,
1483 requireAuth,
04f6b7fClaude1484 requireRepoAccess("write"),
79136bbClaude1485 async (c) => {
1486 const { owner: ownerName, repo: repoName } = c.req.param();
1487 const issueNum = parseInt(c.req.param("number"), 10);
1488
1489 const resolved = await resolveRepo(ownerName, repoName);
1490 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1491
1492 await db
1493 .update(issues)
1494 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1495 .where(
1496 and(
1497 eq(issues.repositoryId, resolved.repo.id),
1498 eq(issues.number, issueNum)
1499 )
1500 );
1501
1502 return c.redirect(
1503 `/${ownerName}/${repoName}/issues/${issueNum}`
1504 );
1505 }
1506);
1507
1508// Reopen issue
1509issueRoutes.post(
1510 "/:owner/:repo/issues/:number/reopen",
1511 softAuth,
1512 requireAuth,
04f6b7fClaude1513 requireRepoAccess("write"),
79136bbClaude1514 async (c) => {
1515 const { owner: ownerName, repo: repoName } = c.req.param();
1516 const issueNum = parseInt(c.req.param("number"), 10);
1517
1518 const resolved = await resolveRepo(ownerName, repoName);
1519 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1520
1521 await db
1522 .update(issues)
1523 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1524 .where(
1525 and(
1526 eq(issues.repositoryId, resolved.repo.id),
1527 eq(issues.number, issueNum)
1528 )
1529 );
1530
1531 return c.redirect(
1532 `/${ownerName}/${repoName}/issues/${issueNum}`
1533 );
1534 }
1535);
1536
1537// Shared nav component with issues tab
1538const IssueNav = ({
1539 owner,
1540 repo,
1541 active,
1542}: {
1543 owner: string;
1544 repo: string;
1545 active: "code" | "commits" | "issues";
1546}) => (
bb0f894Claude1547 <TabNav
1548 tabs={[
1549 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1550 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1551 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1552 ]}
1553 />
79136bbClaude1554);
1555
58915a9Claude1556// Re-run AI triage on demand (e.g. after the issue body has been edited).
1557// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1558issueRoutes.post(
1559 "/:owner/:repo/issues/:number/ai-retriage",
1560 softAuth,
1561 requireAuth,
1562 requireRepoAccess("write"),
1563 async (c) => {
1564 const { owner: ownerName, repo: repoName } = c.req.param();
1565 const issueNum = parseInt(c.req.param("number"), 10);
1566 const user = c.get("user")!;
1567 const resolved = await resolveRepo(ownerName, repoName);
1568 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1569
1570 const [issue] = await db
1571 .select()
1572 .from(issues)
1573 .where(
1574 and(
1575 eq(issues.repositoryId, resolved.repo.id),
1576 eq(issues.number, issueNum)
1577 )
1578 )
1579 .limit(1);
1580 if (!issue) {
1581 return c.redirect(`/${ownerName}/${repoName}/issues`);
1582 }
1583
1584 triggerIssueTriage(
1585 {
1586 ownerName,
1587 repoName,
1588 repositoryId: resolved.repo.id,
1589 issueId: issue.id,
1590 issueNumber: issue.number,
1591 authorId: user.id,
1592 title: issue.title,
1593 body: issue.body || "",
1594 },
1595 { force: true }
a28cedeClaude1596 ).catch((err) => {
1597 console.warn(
1598 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1599 err instanceof Error ? err.message : err
1600 );
1601 });
58915a9Claude1602
1603 return c.redirect(
1604 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1605 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1606 )}`
1607 );
1608 }
1609);
1610
79136bbClaude1611export default issueRoutes;
1612export { IssueNav };