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.tsxBlame2294 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,
85c4e13Claude16 milestones,
79136bbClaude17} from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude20import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude21import { ReactionsBar } from "../views/reactions";
22import { summariseReactions } from "../lib/reactions";
24cf2caClaude23import { loadIssueTemplate } from "../lib/templates";
79136bbClaude24import { renderMarkdown } from "../lib/markdown";
b584e52Claude25import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude26import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude27import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude28import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude29import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude32import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude33import {
34 decideInitialStatus,
35 notifyOwnerOfPendingComment,
36 countPendingForRepo,
37} from "../lib/comment-moderation";
bb0f894Claude38import {
39 Flex,
40 Container,
41 PageHeader,
42 Form,
43 FormGroup,
44 Input,
45 TextArea,
46 Button,
47 LinkButton,
48 Badge,
49 EmptyState,
50 TabNav,
51 FilterTabs,
52 List,
53 ListItem,
54 Alert,
55 CommentBox,
56 CommentForm,
57 formatRelative,
58} from "../views/ui";
c922868Claude59import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude60import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude61import { isAiAvailable } from "../lib/ai-client";
79136bbClaude62
63const issueRoutes = new Hono<AuthEnv>();
64
f7ad7b8Claude65// ---------------------------------------------------------------------------
66// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
67// other routes/shared views. All design tokens come from :root in layout.tsx.
68// ---------------------------------------------------------------------------
69const issuesStyles = `
70 /* Hero card — list page */
71 .issues-hero {
72 position: relative;
73 margin: 4px 0 24px;
74 padding: 28px 32px;
75 background: var(--bg-elevated);
76 border: 1px solid var(--border);
77 border-radius: 16px;
78 overflow: hidden;
79 }
80 .issues-hero::before {
81 content: '';
82 position: absolute;
83 top: 0; left: 0; right: 0;
84 height: 2px;
6fd5915Claude85 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude86 opacity: 0.7;
87 pointer-events: none;
88 }
89 .issues-hero-bg {
90 position: absolute;
91 inset: -30% -10% auto auto;
92 width: 360px;
93 height: 360px;
94 pointer-events: none;
95 z-index: 0;
96 }
97 .issues-hero-orb {
98 position: absolute;
99 inset: 0;
6fd5915Claude100 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
f7ad7b8Claude101 filter: blur(80px);
102 opacity: 0.7;
103 animation: issuesHeroOrb 14s ease-in-out infinite;
104 }
105 @keyframes issuesHeroOrb {
106 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
107 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
108 }
109 @media (prefers-reduced-motion: reduce) {
110 .issues-hero-orb { animation: none; }
111 }
112 .issues-hero-inner {
113 position: relative;
114 z-index: 1;
115 display: flex;
116 justify-content: space-between;
117 align-items: flex-end;
118 gap: 24px;
119 flex-wrap: wrap;
120 }
121 .issues-hero-text { flex: 1; min-width: 280px; }
122 .issues-hero-eyebrow {
123 font-size: 12.5px;
124 color: var(--text-muted);
125 margin-bottom: 8px;
126 letter-spacing: 0.04em;
127 text-transform: uppercase;
128 font-weight: 600;
129 }
130 .issues-hero-eyebrow .issues-hero-repo {
131 color: var(--accent);
132 text-transform: none;
133 letter-spacing: -0.005em;
134 font-weight: 600;
135 }
136 .issues-hero-title {
137 font-family: var(--font-display);
138 font-size: clamp(28px, 4vw, 40px);
139 font-weight: 800;
140 letter-spacing: -0.028em;
141 line-height: 1.05;
142 margin: 0 0 10px;
143 color: var(--text-strong);
144 }
145 .issues-hero-sub {
146 font-size: 15px;
147 color: var(--text-muted);
148 margin: 0;
149 line-height: 1.5;
150 max-width: 580px;
151 }
152 .issues-hero-actions {
153 display: flex;
154 gap: 8px;
155 flex-wrap: wrap;
156 }
157 @media (max-width: 720px) {
158 .issues-hero { padding: 24px 20px; }
159 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
160 .issues-hero-actions { width: 100%; }
161 .issues-hero-actions .btn { flex: 1; min-width: 0; }
162 }
163
f1dc7c7Claude164 /* Mobile rules — added in the 720px sweep. Kept additive only. */
165 @media (max-width: 720px) {
166 .issues-toolbar { flex-direction: column; align-items: stretch; }
167 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
168 .issues-filter { min-height: 40px; padding: 10px 14px; }
169 .issues-row { padding: 12px 14px; gap: 10px; }
170 .issues-row-side { flex-wrap: wrap; gap: 8px; }
171 .issues-detail-hero { padding: 18px; }
172 .issues-detail-attr { font-size: 13px; }
173 .issues-composer-actions { gap: 8px; }
174 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
175 .issues-empty { padding: 40px 20px; }
176 }
177
f7ad7b8Claude178 /* Count chip + filter pills */
179 .issues-toolbar {
180 display: flex;
181 align-items: center;
182 justify-content: space-between;
183 gap: 12px;
184 flex-wrap: wrap;
185 margin: 0 0 16px;
186 }
187 .issues-filters {
188 display: inline-flex;
189 background: var(--bg-elevated);
190 border: 1px solid var(--border);
191 border-radius: 9999px;
192 padding: 4px;
193 gap: 2px;
194 }
195 .issues-filter {
196 display: inline-flex;
197 align-items: center;
198 gap: 6px;
199 padding: 6px 14px;
200 border-radius: 9999px;
201 font-size: 13px;
202 font-weight: 500;
203 color: var(--text-muted);
204 text-decoration: none;
205 transition: color 120ms ease, background 120ms ease;
206 line-height: 1.4;
207 }
208 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
209 .issues-filter.is-active {
6fd5915Claude210 background: rgba(91,110,232,0.14);
f7ad7b8Claude211 color: var(--text-strong);
212 }
213 .issues-filter-count {
214 font-variant-numeric: tabular-nums;
215 font-size: 11.5px;
216 color: var(--text-muted);
217 background: rgba(255,255,255,0.04);
218 padding: 1px 7px;
219 border-radius: 9999px;
220 }
221 .issues-filter.is-active .issues-filter-count {
6fd5915Claude222 background: rgba(91,110,232,0.18);
f7ad7b8Claude223 color: var(--text);
224 }
225
7a28902Claude226 /* Issue search form */
227 .issues-search-form {
228 display: flex;
229 align-items: center;
230 gap: 0;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 9999px;
234 overflow: hidden;
235 flex: 1;
236 max-width: 280px;
237 transition: border-color 120ms ease;
238 }
6fd5915Claude239 .issues-search-form:focus-within { border-color: rgba(91,110,232,0.55); }
7a28902Claude240 .issues-search-input {
241 flex: 1;
242 background: transparent;
243 color: var(--text);
244 border: none;
245 outline: none;
246 padding: 6px 14px;
247 font-size: 13px;
248 font-family: var(--font-sans, inherit);
249 min-width: 0;
250 }
251 .issues-search-input::placeholder { color: var(--text-muted); }
252 .issues-search-btn {
253 background: transparent;
254 border: none;
255 color: var(--text-muted);
256 padding: 6px 12px 6px 8px;
257 cursor: pointer;
258 display: flex;
259 align-items: center;
260 }
261 .issues-search-btn:hover { color: var(--text); }
262 .issues-filter-banner {
263 margin-bottom: 12px;
264 padding: 8px 14px;
6fd5915Claude265 background: rgba(91,110,232,0.06);
266 border: 1px solid rgba(91,110,232,0.2);
7a28902Claude267 border-radius: 8px;
268 font-size: 13px;
269 color: var(--text-muted);
270 }
271 .issues-filter-clear {
6fd5915Claude272 color: var(--accent, #5b6ee8);
7a28902Claude273 text-decoration: underline;
274 cursor: pointer;
275 }
276 .issues-label-badge {
277 display: inline-block;
6fd5915Claude278 background: rgba(91,110,232,0.15);
7a28902Claude279 color: #a78bfa;
280 border-radius: 9999px;
281 padding: 1px 8px;
282 font-size: 11.5px;
283 font-weight: 600;
284 }
285
f7ad7b8Claude286 /* Issue list — modernised rows */
287 .issues-list {
288 list-style: none;
289 margin: 0;
290 padding: 0;
291 border: 1px solid var(--border);
292 border-radius: 12px;
293 overflow: hidden;
294 background: var(--bg-elevated);
295 }
296 .issues-row {
297 display: flex;
298 align-items: flex-start;
299 gap: 14px;
300 padding: 14px 18px;
301 border-bottom: 1px solid var(--border);
302 transition: background 120ms ease, transform 120ms ease;
303 }
304 .issues-row:last-child { border-bottom: none; }
6fd5915Claude305 .issues-row:hover { background: rgba(91,110,232,0.04); }
f7ad7b8Claude306 .issues-row-icon {
307 width: 18px;
308 height: 18px;
309 flex-shrink: 0;
310 margin-top: 3px;
311 display: inline-flex;
312 align-items: center;
313 justify-content: center;
314 }
315 .issues-row-icon.is-open { color: #34d399; }
6fd5915Claude316 .issues-row-icon.is-closed { color: #5b6ee8; }
f7ad7b8Claude317 .issues-row-main { flex: 1; min-width: 0; }
318 .issues-row-title {
319 font-family: var(--font-display);
320 font-size: 15.5px;
321 font-weight: 600;
322 line-height: 1.35;
323 letter-spacing: -0.012em;
324 margin: 0;
325 display: flex;
326 flex-wrap: wrap;
327 align-items: center;
328 gap: 8px;
329 }
330 .issues-row-title a {
331 color: var(--text-strong);
332 text-decoration: none;
333 transition: color 120ms ease;
334 }
335 .issues-row-title a:hover { color: var(--accent); }
336 .issues-row-meta {
337 margin-top: 5px;
338 font-size: 12.5px;
339 color: var(--text-muted);
340 line-height: 1.5;
341 }
342 .issues-row-meta strong { color: var(--text); font-weight: 600; }
343 .issues-row-side {
344 display: flex;
345 align-items: center;
346 gap: 12px;
347 color: var(--text-muted);
348 font-size: 12.5px;
349 flex-shrink: 0;
350 }
351 .issues-row-comments {
352 display: inline-flex;
353 align-items: center;
354 gap: 5px;
355 color: var(--text-muted);
356 text-decoration: none;
357 }
358 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
359
360 /* Label pills (rendered inline on titles) */
361 .issues-label {
362 display: inline-flex;
363 align-items: center;
364 padding: 2px 9px;
365 border-radius: 9999px;
366 font-size: 11.5px;
367 font-weight: 600;
368 line-height: 1.4;
6fd5915Claude369 background: rgba(91,110,232,0.10);
f7ad7b8Claude370 color: var(--text-strong);
6fd5915Claude371 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude372 letter-spacing: 0.005em;
373 }
374
375 /* Polished empty state */
376 .issues-empty {
377 margin: 0;
378 padding: 56px 32px;
379 background: var(--bg-elevated);
380 border: 1px solid var(--border);
381 border-radius: 16px;
382 text-align: center;
383 position: relative;
384 overflow: hidden;
385 }
386 .issues-empty::before {
387 content: '';
388 position: absolute;
389 top: 0; left: 0; right: 0;
390 height: 2px;
6fd5915Claude391 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude392 opacity: 0.55;
393 pointer-events: none;
394 }
395 .issues-empty-art {
396 width: 96px;
397 height: 96px;
398 margin: 0 auto 18px;
399 display: block;
400 opacity: 0.85;
401 }
402 .issues-empty-title {
403 font-family: var(--font-display);
404 font-size: 22px;
405 font-weight: 700;
406 letter-spacing: -0.018em;
407 color: var(--text-strong);
408 margin: 0 0 8px;
409 }
410 .issues-empty-sub {
411 font-size: 14.5px;
412 color: var(--text-muted);
413 line-height: 1.55;
414 margin: 0 auto 22px;
415 max-width: 460px;
416 }
417 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
418
419 /* ─── Detail page ─── */
420 .issues-detail-hero {
421 position: relative;
422 margin: 4px 0 20px;
423 padding: 22px 26px;
424 background: var(--bg-elevated);
425 border: 1px solid var(--border);
426 border-radius: 16px;
427 overflow: hidden;
428 }
429 .issues-detail-hero::before {
430 content: '';
431 position: absolute;
432 top: 0; left: 0; right: 0;
433 height: 2px;
6fd5915Claude434 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude435 opacity: 0.7;
436 pointer-events: none;
437 }
438 .issues-detail-title {
439 font-family: var(--font-display);
440 font-size: clamp(22px, 3vw, 30px);
441 font-weight: 700;
442 letter-spacing: -0.022em;
443 line-height: 1.18;
444 color: var(--text-strong);
445 margin: 0 0 12px;
446 }
447 .issues-detail-title .issues-detail-number {
448 color: var(--text-muted);
449 font-weight: 500;
450 margin-left: 8px;
451 }
452 .issues-detail-attr {
453 display: flex;
454 align-items: center;
455 gap: 12px;
456 flex-wrap: wrap;
457 font-size: 14px;
458 color: var(--text-muted);
459 }
460 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
461 .issues-state-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 6px;
465 padding: 5px 12px;
466 border-radius: 9999px;
467 font-size: 12.5px;
468 font-weight: 600;
469 line-height: 1.4;
470 letter-spacing: 0.005em;
471 }
472 .issues-state-pill.is-open {
473 background: rgba(52,211,153,0.12);
474 color: #34d399;
475 border: 1px solid rgba(52,211,153,0.35);
476 }
477 .issues-state-pill.is-closed {
478 background: rgba(182,157,255,0.12);
6fd5915Claude479 color: #5b6ee8;
f7ad7b8Claude480 border: 1px solid rgba(182,157,255,0.35);
481 }
482 .issues-detail-spacer { flex: 1; }
483 .issues-detail-labels {
484 margin-top: 12px;
485 display: flex;
486 gap: 6px;
487 flex-wrap: wrap;
488 }
489
490 /* Comment thread */
491 .issues-thread { margin-top: 18px; }
492 .issues-comment {
493 position: relative;
494 border: 1px solid var(--border);
495 border-radius: 12px;
496 overflow: hidden;
497 background: var(--bg-elevated);
498 margin-bottom: 14px;
499 transition: border-color 160ms ease, box-shadow 160ms ease;
500 }
501 .issues-comment:hover {
502 border-color: var(--border-strong, rgba(255,255,255,0.13));
503 }
504 .issues-comment-header {
505 background: var(--bg-secondary);
506 padding: 10px 16px;
507 border-bottom: 1px solid var(--border);
508 display: flex;
509 align-items: center;
510 gap: 10px;
511 font-size: 13px;
512 color: var(--text-muted);
513 }
514 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
515 .issues-comment-body { padding: 14px 18px; }
516 .issues-comment-author-pill {
517 display: inline-flex;
518 align-items: center;
519 padding: 1px 8px;
520 border-radius: 9999px;
6fd5915Claude521 background: rgba(91,110,232,0.10);
f7ad7b8Claude522 color: var(--accent);
523 font-size: 11px;
524 font-weight: 600;
525 letter-spacing: 0.02em;
526 text-transform: uppercase;
527 }
528
529 /* AI Review comment — distinct purple-accent treatment */
530 .issues-comment.is-ai {
6fd5915Claude531 border-color: rgba(91,110,232,0.45);
532 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 12px 32px -16px rgba(91,110,232,0.25);
f7ad7b8Claude533 }
534 .issues-comment.is-ai::before {
535 content: '';
536 position: absolute;
537 top: 0; left: 0; right: 0;
538 height: 2px;
6fd5915Claude539 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude540 pointer-events: none;
541 }
542 .issues-comment.is-ai .issues-comment-header {
6fd5915Claude543 background: linear-gradient(180deg, rgba(91,110,232,0.08), rgba(91,110,232,0.02));
544 border-bottom-color: rgba(91,110,232,0.22);
f7ad7b8Claude545 }
546 .issues-ai-badge {
547 display: inline-flex;
548 align-items: center;
549 gap: 5px;
550 padding: 2px 9px;
551 border-radius: 9999px;
6fd5915Claude552 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude553 color: #fff;
554 font-size: 11px;
555 font-weight: 700;
556 letter-spacing: 0.04em;
557 text-transform: uppercase;
558 line-height: 1.4;
559 }
560 .issues-ai-badge::before {
561 content: '';
562 width: 6px;
563 height: 6px;
564 border-radius: 9999px;
565 background: #fff;
566 opacity: 0.92;
567 box-shadow: 0 0 6px rgba(255,255,255,0.7);
568 }
569
a7460bfClaude570 .issues-bot-badge {
571 display: inline-flex; align-items: center; gap: 3px;
572 padding: 1px 7px;
573 font-size: 10px;
574 font-weight: 600;
575 color: var(--fg-muted);
576 background: var(--bg-elevated);
577 border: 1px solid var(--border);
578 border-radius: 9999px;
579 }
580
f7ad7b8Claude581 /* Composer */
582 .issues-composer {
583 margin-top: 22px;
584 border: 1px solid var(--border);
585 border-radius: 12px;
586 overflow: hidden;
587 background: var(--bg-elevated);
588 transition: border-color 160ms ease, box-shadow 160ms ease;
589 }
590 .issues-composer:focus-within {
6fd5915Claude591 border-color: rgba(91,110,232,0.55);
592 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
f7ad7b8Claude593 }
594 .issues-composer-header {
595 display: flex;
596 align-items: center;
597 justify-content: space-between;
598 gap: 8px;
599 padding: 10px 14px;
600 background: var(--bg-secondary);
601 border-bottom: 1px solid var(--border);
602 font-size: 12.5px;
603 color: var(--text-muted);
604 }
605 .issues-composer-tag {
606 font-weight: 600;
607 color: var(--text);
608 letter-spacing: -0.005em;
609 }
610 .issues-composer-hint a {
611 color: var(--text-muted);
612 text-decoration: none;
613 border-bottom: 1px dashed currentColor;
614 }
615 .issues-composer-hint a:hover { color: var(--accent); }
616 .issues-composer textarea {
617 display: block;
618 width: 100%;
619 border: 0;
620 background: transparent;
621 color: var(--text);
622 padding: 14px 16px;
623 font-family: var(--font-mono);
624 font-size: 13.5px;
625 line-height: 1.55;
626 resize: vertical;
627 outline: none;
628 min-height: 140px;
629 }
630 .issues-composer-actions {
631 display: flex;
632 align-items: center;
633 gap: 8px;
634 padding: 10px 14px;
635 border-top: 1px solid var(--border);
636 background: var(--bg-secondary);
637 flex-wrap: wrap;
638 }
639
640 /* Info banner inside detail */
641 .issues-info-banner {
642 margin: 0 0 14px;
643 padding: 10px 14px;
644 border-radius: 12px;
6fd5915Claude645 background: rgba(91,110,232,0.08);
646 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude647 color: var(--text);
648 font-size: 13.5px;
649 }
240c477Claude650
651 /* ─── Linked PRs ─── */
652 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
653 .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; }
654 .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; }
655 .iss-linked-pr-row:last-child { border-bottom: none; }
656 .iss-linked-pr-row:hover { background: var(--bg-hover); }
657 .iss-linked-pr-icon.is-open { color: #34d399; }
658 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
659 .iss-linked-pr-icon.is-closed { color: #8b949e; }
660 .iss-linked-pr-icon.is-draft { color: #8b949e; }
661 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
662 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
663 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
664 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
665 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
666 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
667 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude668
8d1483cClaude669 /* ─── Bulk action UI ─── */
670 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
671 .bulk-bar {
672 display: none;
673 align-items: center;
674 gap: 10px;
675 padding: 10px 16px;
676 background: var(--bg-elevated);
677 border: 1px solid var(--border-strong);
678 border-radius: var(--r-md);
679 margin-bottom: 12px;
680 position: sticky;
681 top: calc(var(--header-h) + 8px);
682 z-index: 10;
683 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
684 }
685 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
686 .bulk-bar-btn {
687 padding: 6px 12px;
688 border-radius: var(--r-sm);
689 border: 1px solid var(--border);
690 background: var(--bg-surface);
691 color: var(--text);
692 font-size: 13px;
693 cursor: pointer;
694 transition: background 120ms;
695 }
696 .bulk-bar-btn:hover { background: var(--bg-hover); }
697 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
698 .bulk-bar-clear:hover { color: var(--text); }
699
0224546Claude700 /* ─── Issue detail metadata sidebar ─── */
701 .issue-detail-layout {
702 display: flex;
703 align-items: flex-start;
704 gap: 24px;
705 }
706 .issue-detail-main {
707 flex: 1;
708 min-width: 0;
709 }
710 .issue-meta-sidebar {
711 width: 240px;
712 flex-shrink: 0;
713 display: flex;
714 flex-direction: column;
715 gap: 20px;
716 }
717 .issue-meta-section {
718 border-top: 1px solid var(--border);
719 padding-top: 14px;
720 }
721 .issue-meta-section:first-child {
722 border-top: none;
723 padding-top: 0;
724 }
725 .issue-meta-label {
726 font-size: 12px;
727 font-weight: 600;
728 color: var(--text-muted);
729 text-transform: uppercase;
730 letter-spacing: 0.06em;
731 margin-bottom: 8px;
732 }
733 .issue-meta-empty {
734 font-size: 13px;
735 color: var(--text-subtle, var(--text-muted));
736 font-style: italic;
737 }
738 .issue-meta-label-pill {
739 display: inline-flex;
740 align-items: center;
741 padding: 3px 10px;
742 border-radius: 9999px;
743 font-size: 12px;
744 font-weight: 600;
745 line-height: 1.4;
746 margin: 2px 3px 2px 0;
747 border: 1px solid transparent;
748 }
749 .issue-meta-labels {
750 display: flex;
751 flex-wrap: wrap;
752 gap: 4px;
753 }
754 .issue-meta-assignee {
755 display: flex;
756 align-items: center;
757 gap: 8px;
758 font-size: 13px;
759 color: var(--text);
760 }
761 .issue-meta-avatar {
762 width: 24px;
763 height: 24px;
764 border-radius: 9999px;
765 background: rgba(140,109,255,0.25);
766 color: var(--text-strong);
767 font-size: 11px;
768 font-weight: 700;
769 display: inline-flex;
770 align-items: center;
771 justify-content: center;
772 flex-shrink: 0;
773 text-transform: uppercase;
774 }
775 .issue-meta-milestone-link {
776 font-size: 13px;
777 color: var(--text-link, var(--accent));
778 text-decoration: none;
779 display: inline-flex;
780 align-items: center;
781 gap: 5px;
782 }
783 .issue-meta-milestone-link:hover { text-decoration: underline; }
784 @media (max-width: 720px) {
785 .issue-detail-layout { flex-direction: column; }
786 .issue-meta-sidebar { width: 100%; order: 1; }
787 .issue-detail-main { order: 0; }
788 }
789
f5b9ef5Claude790 /* ─── Sort controls (issue list) ─── */
791 .issues-sort-row {
792 display: flex;
793 align-items: center;
794 gap: 6px;
795 margin: 0 0 12px;
796 flex-wrap: wrap;
797 }
798 .issues-sort-label {
799 font-size: 12.5px;
800 color: var(--text-muted);
801 font-weight: 600;
802 margin-right: 2px;
803 }
804 .issues-sort-opt {
805 font-size: 12.5px;
806 color: var(--text-muted);
807 text-decoration: none;
808 padding: 3px 10px;
809 border-radius: 9999px;
810 border: 1px solid transparent;
811 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
812 }
813 .issues-sort-opt:hover {
814 background: var(--bg-hover);
815 color: var(--text);
816 }
817 .issues-sort-opt.is-active {
6fd5915Claude818 background: rgba(91,110,232,0.12);
f5b9ef5Claude819 color: var(--text-link);
6fd5915Claude820 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude821 font-weight: 600;
822 }
f7ad7b8Claude823`;
824
825// Pre-rendered <style> tag (constant, reused per request).
826const IssuesStyle = () => (
827 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
828);
829
830// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
831const IssuesEmptySvg = () => (
832 <svg
833 class="issues-empty-art"
834 viewBox="0 0 96 96"
835 fill="none"
836 xmlns="http://www.w3.org/2000/svg"
837 aria-hidden="true"
838 >
839 <defs>
840 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
6fd5915Claude841 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.55" />
842 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.55" />
f7ad7b8Claude843 </linearGradient>
844 </defs>
6fd5915Claude845 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(91,110,232,0.04)" />
f7ad7b8Claude846 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
847 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
848 </svg>
849);
850
851// Detect AI Triage comments by the marker the triage helper writes.
852function isAiTriageComment(body: string | null | undefined): boolean {
853 if (!body) return false;
854 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
855}
856
79136bbClaude857// Helper to resolve repo from :owner/:repo params
858async function resolveRepo(ownerName: string, repoName: string) {
859 const [owner] = await db
860 .select()
861 .from(users)
862 .where(eq(users.username, ownerName))
863 .limit(1);
864 if (!owner) return null;
865
866 const [repo] = await db
867 .select()
868 .from(repositories)
869 .where(
870 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
871 )
872 .limit(1);
873 if (!repo) return null;
874
875 return { owner, repo };
876}
877
8d1483cClaude878// Bulk issue operations (close / reopen multiple issues at once)
879issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
880 const { owner: ownerName, repo: repoName } = c.req.param();
881 const user = c.get("user")!;
882 const body = await c.req.parseBody();
883 const action = String(body.action || "");
884 const rawNumbers = body["numbers[]"];
885 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
886 .map(n => parseInt(String(n), 10))
887 .filter(n => !isNaN(n) && n > 0);
888
889 if (!numbers.length || !["close","reopen"].includes(action)) {
890 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
891 }
892
893 const resolved = await resolveRepo(ownerName, repoName);
894 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
895
896 const newState = action === "close" ? "closed" : "open";
897 await db.update(issues)
898 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
899 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
900
901 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
902 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
903});
904
79136bbClaude905// Issue list
04f6b7fClaude906issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude907 const { owner: ownerName, repo: repoName } = c.req.param();
908 const user = c.get("user");
909 const state = c.req.query("state") || "open";
7a28902Claude910 const searchQ = (c.req.query("q") || "").trim();
911 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude912 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude913 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude914 // Bounded pagination — unbounded selects ran a full table scan + O(n)
915 // sort on every page load; with 10k+ issues the request would hang.
916 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
917 const page = Math.max(1, Number(c.req.query("page")) || 1);
918 const offset = (page - 1) * perPage;
79136bbClaude919
f1dc7c7Claude920 // ── Loading skeleton (flag-gated) ──
921 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
922 // user see the shape of the issue list before the DB count + select
923 // resolve. Behind a flag — we don't ship flashes.
924 if (c.req.query("skeleton") === "1") {
925 return c.html(
926 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
927 <IssuesStyle />
928 <RepoHeader owner={ownerName} repo={repoName} />
929 <IssueNav owner={ownerName} repo={repoName} active="issues" />
930 <style
931 dangerouslySetInnerHTML={{
932 __html: `
933 .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; }
934 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
935 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
936 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
937 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
938 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
939 .issues-skel-row { height: 62px; border-radius: 10px; }
940 `,
941 }}
942 />
943 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
944 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
945 <div class="issues-skel-list" aria-hidden="true">
946 {Array.from({ length: 8 }).map(() => (
947 <div class="issues-skel issues-skel-row" />
948 ))}
949 </div>
950 <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">
951 Loading issues for {ownerName}/{repoName}…
952 </span>
953 </Layout>
954 );
955 }
956
79136bbClaude957 const resolved = await resolveRepo(ownerName, repoName);
958 if (!resolved) {
959 return c.html(
960 <Layout title="Not Found" user={user}>
bb0f894Claude961 <EmptyState title="Repository not found" />
79136bbClaude962 </Layout>,
963 404
964 );
965 }
966
967 const { repo } = resolved;
968
7a28902Claude969 // If label filter is set, find issue IDs that have that label
970 let labelFilteredIds: string[] | null = null;
971 if (labelFilter) {
972 const [matchedLabel] = await db
973 .select({ id: labels.id })
974 .from(labels)
975 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
976 .limit(1);
977 if (matchedLabel) {
978 const rows = await db
979 .select({ issueId: issueLabels.issueId })
980 .from(issueLabels)
981 .where(eq(issueLabels.labelId, matchedLabel.id));
982 labelFilteredIds = rows.map((r) => r.issueId);
983 } else {
984 labelFilteredIds = []; // no matches
985 }
986 }
987
85c4e13Claude988 // If milestone filter is set, resolve the milestone ID
989 let milestoneFilterId: string | null = null;
990 if (milestoneFilter) {
991 const [matchedMs] = await db
992 .select({ id: milestones.id })
993 .from(milestones)
994 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
995 .limit(1);
996 milestoneFilterId = matchedMs?.id ?? null;
997 }
998
7a28902Claude999 const baseWhere = and(
1000 eq(issues.repositoryId, repo.id),
1001 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
1002 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
1003 labelFilteredIds !== null && labelFilteredIds.length > 0
1004 ? inArray(issues.id, labelFilteredIds)
1005 : labelFilteredIds !== null && labelFilteredIds.length === 0
1006 ? sql`false`
85c4e13Claude1007 : undefined,
1008 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude1009 );
1010
79136bbClaude1011 const issueList = await db
1012 .select({
1013 issue: issues,
1014 author: { username: users.username },
1015 })
1016 .from(issues)
1017 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude1018 .where(baseWhere)
f5b9ef5Claude1019 .orderBy(
1020 sort === "oldest" ? asc(issues.createdAt)
1021 : sort === "updated" ? desc(issues.updatedAt)
1022 : desc(issues.createdAt) // newest (default)
1023 )
6ea2109Claude1024 .limit(perPage)
1025 .offset(offset);
79136bbClaude1026
1027 // Count open/closed
1028 const [counts] = await db
1029 .select({
1030 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
1031 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
1032 })
1033 .from(issues)
1034 .where(eq(issues.repositoryId, repo.id));
1035
85c4e13Claude1036 // Count open milestones for the "N Milestones" link
1037 const [milestoneCounts] = await db
1038 .select({
1039 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
1040 })
1041 .from(milestones)
1042 .where(eq(milestones.repositoryId, repo.id));
1043
cb5a796Claude1044 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
1045 const pendingCountList = viewerIsOwnerOnList
1046 ? await countPendingForRepo(repo.id)
1047 : 0;
1048
79136bbClaude1049 return c.html(
1050 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1051 <IssuesStyle />
79136bbClaude1052 <RepoHeader owner={ownerName} repo={repoName} />
1053 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1054 <PendingCommentsBanner
1055 owner={ownerName}
1056 repo={repoName}
1057 count={pendingCountList}
1058 />
f7ad7b8Claude1059 <section class="issues-hero">
1060 <div class="issues-hero-bg" aria-hidden="true">
1061 <div class="issues-hero-orb" />
1062 </div>
1063 <div class="issues-hero-inner">
1064 <div class="issues-hero-text">
1065 <div class="issues-hero-eyebrow">
1066 Issue tracker \u00B7{" "}
1067 <span class="issues-hero-repo">
1068 {ownerName}/{repoName}
1069 </span>
1070 </div>
1071 <h1 class="issues-hero-title">
1072 Track <span class="gradient-text">what matters</span>.
1073 </h1>
1074 <p class="issues-hero-sub">
1075 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1076 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
1077 : `${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.`}
1078 </p>
1079 </div>
1080 <div class="issues-hero-actions">
1081 {user && (
1082 <a
1083 href={`/${ownerName}/${repoName}/issues/new`}
1084 class="btn btn-primary"
1085 >
1086 + New issue
1087 </a>
1088 )}
85c4e13Claude1089 <a
1090 href={`/${ownerName}/${repoName}/milestones`}
1091 class="btn"
1092 title="View milestones for this repository"
1093 >
1094 Milestones
1095 {Number(milestoneCounts?.open ?? 0) > 0 && (
6fd5915Claude1096 <span style="margin-left:5px;font-size:11.5px;background:rgba(91,110,232,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
85c4e13Claude1097 {Number(milestoneCounts?.open ?? 0)}
1098 </span>
1099 )}
1100 </a>
f7ad7b8Claude1101 <a href={`/${ownerName}/${repoName}`} class="btn">
1102 Back to code
1103 </a>
1104 </div>
1105 </div>
1106 </section>
1107
1108 <div class="issues-toolbar">
1109 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1110 <a
1111 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1112 href={`/${ownerName}/${repoName}/issues?state=open`}
1113 role="tab"
1114 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1115 >
f7ad7b8Claude1116 <span aria-hidden="true">{"\u25CB"}</span>
1117 <span>Open</span>
1118 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1119 </a>
1120 <a
1121 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1122 href={`/${ownerName}/${repoName}/issues?state=closed`}
1123 role="tab"
1124 aria-selected={state === "closed" ? "true" : "false"}
1125 >
1126 <span aria-hidden="true">{"\u2713"}</span>
1127 <span>Closed</span>
1128 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1129 </a>
1130 </div>
7a28902Claude1131 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1132 <input type="hidden" name="state" value={state} />
1133 <input
1134 type="search"
1135 name="q"
1136 value={searchQ}
1137 placeholder="Search issues\u2026"
1138 class="issues-search-input"
1139 aria-label="Search issues by title"
1140 />
1141 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1142 <button type="submit" class="issues-search-btn" aria-label="Search">
1143 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1144 <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" />
1145 </svg>
1146 </button>
1147 </form>
f7ad7b8Claude1148 </div>
7a28902Claude1149 {(searchQ || labelFilter) && (
1150 <div class="issues-filter-banner">
1151 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1152 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1153 {" \u00B7 "}
1154 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1155 </div>
1156 )}
f7ad7b8Claude1157
f5b9ef5Claude1158 <div class="issues-sort-row">
1159 <span class="issues-sort-label">Sort:</span>
1160 {(["newest", "oldest", "updated"] as const).map((s) => (
1161 <a
1162 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1163 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1164 >
1165 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1166 </a>
1167 ))}
1168 </div>
1169
79136bbClaude1170 {issueList.length === 0 ? (
f7ad7b8Claude1171 <div class="issues-empty">
1172 <IssuesEmptySvg />
1173 <h2 class="issues-empty-title">
1174 {state === "closed"
1175 ? "No closed issues yet"
1176 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1177 ? "No issues \u2014 yet"
1178 : "Nothing open right now"}
1179 </h2>
1180 <p class="issues-empty-sub">
1181 {state === "closed"
1182 ? "Closed issues will show up here once the team starts shipping fixes."
1183 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1184 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1185 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1186 </p>
1187 <div class="issues-empty-cta">
1188 {user && state !== "closed" && (
1189 <a
1190 href={`/${ownerName}/${repoName}/issues/new`}
1191 class="btn btn-primary"
1192 >
1193 + Open the first issue
1194 </a>
1195 )}
79136bbClaude1196 {state === "closed" && (
f7ad7b8Claude1197 <a
1198 href={`/${ownerName}/${repoName}/issues?state=open`}
1199 class="btn"
1200 >
1201 View open issues
1202 </a>
79136bbClaude1203 )}
f7ad7b8Claude1204 </div>
1205 </div>
79136bbClaude1206 ) : (
8d1483cClaude1207 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1208 <input type="hidden" name="action" id="bulk-action" value="" />
1209 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1210 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1211 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1212 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1213 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1214 </div>
1215 <ul class="issues-list">
1216 {issueList.map(({ issue, author }) => (
1217 <li class="issues-row">
1218 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1219 <div
1220 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1221 aria-hidden="true"
1222 title={issue.state === "open" ? "Open" : "Closed"}
1223 >
1224 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1225 </div>
8d1483cClaude1226 <div class="issues-row-main">
1227 <h3 class="issues-row-title">
1228 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1229 {issue.title}
1230 </a>
1231 </h3>
1232 <div class="issues-row-meta">
1233 #{issue.number} opened by{" "}
1234 <strong>{author.username}</strong>{" "}
1235 {formatRelative(issue.createdAt)}
1236 </div>
1237 </div>
1238 </li>
1239 ))}
1240 </ul>
1241 <script dangerouslySetInnerHTML={{ __html: `
1242function bulkSubmit(action){
1243 document.getElementById('bulk-action').value=action;
1244 document.getElementById('bulk-form').submit();
1245}
1246function bulkClear(){
1247 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1248 updateBulkBar();
1249}
1250function updateBulkBar(){
1251 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1252 var bar=document.getElementById('bulk-bar');
1253 var cnt=document.getElementById('bulk-bar-count');
1254 if(bar){bar.style.display=checked>0?'flex':'none';}
1255 if(cnt){cnt.textContent=checked+' selected';}
1256}
1257document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1258` }} />
1259 </form>
79136bbClaude1260 )}
1261 </Layout>
1262 );
1263});
1264
1265// New issue form
1266issueRoutes.get(
1267 "/:owner/:repo/issues/new",
1268 softAuth,
1269 requireAuth,
04f6b7fClaude1270 requireRepoAccess("write"),
79136bbClaude1271 async (c) => {
1272 const { owner: ownerName, repo: repoName } = c.req.param();
1273 const user = c.get("user")!;
1274 const error = c.req.query("error");
24cf2caClaude1275 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1276
85c4e13Claude1277 // Load open milestones for the dropdown
1278 const resolved = await resolveRepo(ownerName, repoName);
1279 const openMilestones = resolved
1280 ? await db
1281 .select({ id: milestones.id, title: milestones.title })
1282 .from(milestones)
1283 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1284 .orderBy(milestones.title)
1285 : [];
1286
79136bbClaude1287 return c.html(
1288 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1289 <IssuesStyle />
79136bbClaude1290 <RepoHeader owner={ownerName} repo={repoName} />
1291 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1292 <Container maxWidth={800}>
f7ad7b8Claude1293 <section class="issues-hero" style="margin-top:4px">
1294 <div class="issues-hero-bg" aria-hidden="true">
1295 <div class="issues-hero-orb" />
1296 </div>
1297 <div class="issues-hero-inner">
1298 <div class="issues-hero-text">
1299 <div class="issues-hero-eyebrow">
1300 New issue ·{" "}
1301 <span class="issues-hero-repo">
1302 {ownerName}/{repoName}
1303 </span>
1304 </div>
1305 <h1 class="issues-hero-title">
1306 File <span class="gradient-text">it cleanly</span>.
1307 </h1>
1308 <p class="issues-hero-sub">
1309 AI Triage will read the body the moment you submit and post
1310 suggested labels, priority, and possible duplicates within
1311 seconds. You stay in control — nothing is applied.
1312 </p>
1313 </div>
1314 </div>
1315 </section>
79136bbClaude1316 {error && (
bb0f894Claude1317 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1318 )}
0316dbbClaude1319 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1320 <FormGroup>
1321 <Input
79136bbClaude1322 type="text"
1323 name="title"
1324 required
1325 placeholder="Title"
bb0f894Claude1326 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1327 aria-label="Issue title"
79136bbClaude1328 />
bb0f894Claude1329 </FormGroup>
1330 <FormGroup>
1331 <TextArea
79136bbClaude1332 name="body"
1333 rows={12}
1334 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1335 mono
79136bbClaude1336 />
bb0f894Claude1337 </FormGroup>
85c4e13Claude1338 {openMilestones.length > 0 && (
1339 <FormGroup>
1340 <label
1341 for="milestone_id"
1342 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1343 >
1344 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1345 </label>
1346 <select
1347 id="milestone_id"
1348 name="milestone_id"
1349 style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;padding:8px 12px;outline:none;width:100%;max-width:340px"
1350 >
1351 <option value="">No milestone</option>
1352 {openMilestones.map((ms) => (
1353 <option value={ms.id}>{ms.title}</option>
1354 ))}
1355 </select>
1356 </FormGroup>
1357 )}
bb0f894Claude1358 <Button type="submit" variant="primary">
79136bbClaude1359 Submit new issue
bb0f894Claude1360 </Button>
1361 </Form>
1362 </Container>
79136bbClaude1363 </Layout>
1364 );
1365 }
1366);
1367
1368// Create issue
1369issueRoutes.post(
1370 "/:owner/:repo/issues/new",
1371 softAuth,
1372 requireAuth,
04f6b7fClaude1373 requireRepoAccess("write"),
79136bbClaude1374 async (c) => {
1375 const { owner: ownerName, repo: repoName } = c.req.param();
1376 const user = c.get("user")!;
1377 const body = await c.req.parseBody();
1378 const title = String(body.title || "").trim();
1379 const issueBody = String(body.body || "").trim();
85c4e13Claude1380 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1381
1382 if (!title) {
1383 return c.redirect(
1384 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1385 );
1386 }
1387
1388 const resolved = await resolveRepo(ownerName, repoName);
1389 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1390
85c4e13Claude1391 // Validate milestone belongs to this repo if provided
1392 let validatedMilestoneId: string | null = null;
1393 if (milestoneIdRaw) {
1394 const [msRow] = await db
1395 .select({ id: milestones.id })
1396 .from(milestones)
1397 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1398 .limit(1);
1399 validatedMilestoneId = msRow?.id ?? null;
1400 }
1401
79136bbClaude1402 const [issue] = await db
1403 .insert(issues)
1404 .values({
1405 repositoryId: resolved.repo.id,
1406 authorId: user.id,
1407 title,
1408 body: issueBody || null,
85c4e13Claude1409 milestoneId: validatedMilestoneId,
79136bbClaude1410 })
1411 .returning();
1412
1413 // Update issue count
1414 await db
1415 .update(repositories)
1416 .set({ issueCount: resolved.repo.issueCount + 1 })
1417 .where(eq(repositories.id, resolved.repo.id));
1418
a9ada5fClaude1419 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1420 // suggested labels, priority, summary, and a possible-duplicate
1421 // callout. Suggestions only — nothing applied automatically.
1422 triggerIssueTriage({
1423 ownerName,
1424 repoName,
1425 repositoryId: resolved.repo.id,
1426 issueId: issue.id,
1427 issueNumber: issue.number,
1428 authorId: user.id,
1429 title,
1430 body: issueBody,
a28cedeClaude1431 }).catch((err) => {
1432 console.warn(
1433 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1434 err instanceof Error ? err.message : err
1435 );
1436 });
a9ada5fClaude1437
79136bbClaude1438 return c.redirect(
1439 `/${ownerName}/${repoName}/issues/${issue.number}`
1440 );
1441 }
1442);
1443
1444// View single issue
04f6b7fClaude1445issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1446 const { owner: ownerName, repo: repoName } = c.req.param();
1447 const issueNum = parseInt(c.req.param("number"), 10);
1448 const user = c.get("user");
1449
1450 const resolved = await resolveRepo(ownerName, repoName);
1451 if (!resolved) {
1452 return c.html(
1453 <Layout title="Not Found" user={user}>
bb0f894Claude1454 <EmptyState title="Not found" />
79136bbClaude1455 </Layout>,
1456 404
1457 );
1458 }
1459
1460 const [issue] = await db
1461 .select()
1462 .from(issues)
1463 .where(
1464 and(
1465 eq(issues.repositoryId, resolved.repo.id),
1466 eq(issues.number, issueNum)
1467 )
1468 )
1469 .limit(1);
1470
1471 if (!issue) {
1472 return c.html(
1473 <Layout title="Not Found" user={user}>
bb0f894Claude1474 <EmptyState title="Issue not found" />
79136bbClaude1475 </Layout>,
1476 404
1477 );
1478 }
1479
1480 const [author] = await db
1481 .select()
1482 .from(users)
1483 .where(eq(users.id, issue.authorId))
1484 .limit(1);
1485
cb5a796Claude1486 // Get comments. We pull every row and filter by moderation_status +
1487 // viewer identity client-side (in the JSX below) so a moderator/owner
1488 // sees them all while non-author viewers only ever see 'approved'.
1489 const allComments = await db
79136bbClaude1490 .select({
1491 comment: issueComments,
cb5a796Claude1492 author: { id: users.id, username: users.username },
79136bbClaude1493 })
1494 .from(issueComments)
1495 .innerJoin(users, eq(issueComments.authorId, users.id))
1496 .where(eq(issueComments.issueId, issue.id))
1497 .orderBy(asc(issueComments.createdAt));
1498
cb5a796Claude1499 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1500 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1501 // Owner always sees everything (so they can spot conversations going
1502 // sideways even before they hit the queue page).
1503 if (viewerIsOwner) return true;
1504 if (comment.moderationStatus === "approved") return true;
1505 // The comment's own author sees their pending comment so they know
1506 // it landed (with an "Awaiting approval" badge in the render below).
1507 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1508 return true;
1509 }
1510 return false;
1511 });
1512
1513 // Pending banner — when the viewer is the repo owner, show a strip at
1514 // the top of the page nudging them to the moderation queue. Inline on
1515 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1516 const pendingCount = viewerIsOwner
1517 ? await countPendingForRepo(resolved.repo.id)
1518 : 0;
1519
6fc53bdClaude1520 // Load reactions for the issue + each comment in parallel.
1521 const [issueReactions, ...commentReactions] = await Promise.all([
1522 summariseReactions("issue", issue.id, user?.id),
1523 ...comments.map((row) =>
1524 summariseReactions("issue_comment", row.comment.id, user?.id)
1525 ),
1526 ]);
1527
79136bbClaude1528 const canManage =
1529 user &&
1530 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1531 const info = c.req.query("info");
79136bbClaude1532
0224546Claude1533 // Labels attached to this issue.
1534 const issueDetailLabels = await db
1535 .select({ id: labels.id, name: labels.name, color: labels.color })
1536 .from(issueLabels)
1537 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1538 .where(eq(issueLabels.issueId, issue.id));
1539
1540 // Milestone for this issue (if any).
1541 const issueMilestone = issue.milestoneId
1542 ? await db
1543 .select({ id: milestones.id, title: milestones.title })
1544 .from(milestones)
1545 .where(eq(milestones.id, issue.milestoneId))
1546 .limit(1)
1547 .then((rows) => rows[0] ?? null)
1548 : null;
1549
240c477Claude1550 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1551 const issueRefPattern = `%#${issue.number}%`;
1552 const linkedPrs = await db
1553 .select({
1554 number: pullRequests.number,
1555 title: pullRequests.title,
1556 state: pullRequests.state,
1557 isDraft: pullRequests.isDraft,
1558 })
1559 .from(pullRequests)
1560 .where(
1561 and(
1562 eq(pullRequests.repositoryId, resolved.repo.id),
1563 or(
1564 ilike(pullRequests.title, issueRefPattern),
1565 ilike(pullRequests.body, issueRefPattern),
1566 )
1567 )
1568 )
1569 .orderBy(desc(pullRequests.createdAt))
1570 .limit(8);
1571
79136bbClaude1572 return c.html(
1573 <Layout
1574 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1575 user={user}
1576 >
f7ad7b8Claude1577 <IssuesStyle />
79136bbClaude1578 <RepoHeader owner={ownerName} repo={repoName} />
1579 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1580 <PendingCommentsBanner
1581 owner={ownerName}
1582 repo={repoName}
1583 count={pendingCount}
1584 />
b584e52Claude1585 <div
1586 id="live-comment-banner"
1587 class="alert"
1588 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1589 >
1590 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1591 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1592 reload to view
1593 </a>
1594 </div>
1595 <script
1596 dangerouslySetInnerHTML={{
1597 __html: liveCommentBannerScript({
1598 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1599 bannerElementId: "live-comment-banner",
1600 }),
1601 }}
1602 />
829a046Claude1603 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1604 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1605 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1606 <div class="issue-detail">
58915a9Claude1607 {info && (
f7ad7b8Claude1608 <div class="issues-info-banner">
58915a9Claude1609 {decodeURIComponent(info)}
1610 </div>
1611 )}
f7ad7b8Claude1612
1613 <section class="issues-detail-hero">
1614 <h1 class="issues-detail-title">
1615 {issue.title}
1616 <span class="issues-detail-number">#{issue.number}</span>
1617 </h1>
1618 <div class="issues-detail-attr">
1619 <span
1620 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1621 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1622 >
f7ad7b8Claude1623 <span aria-hidden="true">
1624 {issue.state === "open" ? "\u25CB" : "\u2713"}
1625 </span>
1626 {issue.state === "open" ? "Open" : "Closed"}
1627 </span>
1628 <span>
1629 <strong>{author?.username || "unknown"}</strong> opened this
1630 issue {formatRelative(issue.createdAt)}
1631 </span>
1632 <span class="issues-detail-spacer" />
1633 {issue.state === "open" && user && user.id === resolved.owner.id && (
1634 <a
1635 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1636 class="btn btn-primary"
1637 style="font-size:13px;padding:6px 12px"
1638 title="Generate a draft pull request from this issue using Claude"
1639 >
1640 Build with AI
1641 </a>
1642 )}
f928118Claude1643 {issue.state === "open" && user && isAiAvailable() && (
1644 <form
1645 method="post"
1646 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1647 style="display:inline"
1648 title="Let AI implement this feature automatically"
1649 >
1650 <button
1651 type="submit"
1652 class="btn btn-primary"
6fd5915Claude1653 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);border:none;cursor:pointer"
f928118Claude1654 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1655 >
1656 Ship It
1657 </button>
1658 </form>
1659 )}
c922868Claude1660 {issue.state === "open" && user && (
1661 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1662 <summary
1663 class="btn"
1664 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1665 title="Create a new branch for this issue"
1666 >
1667 Create branch
1668 </summary>
1669 <div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:12px 14px;min-width:280px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.3)">
1670 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1671 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1672 <input
1673 type="text"
1674 name="branchName"
1675 class="input"
1676 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1677 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1678 pattern="[a-zA-Z0-9._\\-/]+"
1679 required
1680 />
1681 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1682 Create branch
1683 </button>
1684 </form>
1685 </div>
1686 </details>
1687 )}
f7ad7b8Claude1688 </div>
1689 </section>
1690
0224546Claude1691 <div class="issue-detail-layout">
1692 <div class="issue-detail-main">
f7ad7b8Claude1693 <div class="issues-thread">
1694 {issue.body && (
1695 <article class="issues-comment">
1696 <header class="issues-comment-header">
1697 <strong>{author?.username || "unknown"}</strong>
1698 <span class="issues-comment-author-pill">Author</span>
1699 <span>commented {formatRelative(issue.createdAt)}</span>
1700 </header>
1701 <div class="issues-comment-body">
1702 <div
1703 class="markdown-body"
1704 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1705 />
1706 </div>
1707 </article>
fbf4aefClaude1708 )}
79136bbClaude1709
f7ad7b8Claude1710 {comments.map(({ comment, author: commentAuthor }) => {
1711 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1712 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1713 return (
cb5a796Claude1714 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1715 <header class="issues-comment-header">
1716 <strong>{commentAuthor.username}</strong>
a7460bfClaude1717 {commentAuthor.username === BOT_USERNAME && (
1718 <span class="issues-bot-badge">&#x1F916; bot</span>
1719 )}
f7ad7b8Claude1720 {isAi ? (
1721 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1722 AI Review
1723 </span>
1724 ) : null}
cb5a796Claude1725 {isPending ? (
1726 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1727 Awaiting approval
1728 </span>
1729 ) : null}
f7ad7b8Claude1730 <span>commented {formatRelative(comment.createdAt)}</span>
1731 </header>
1732 <div class="issues-comment-body">
1733 <div
1734 class="markdown-body"
1735 dangerouslySetInnerHTML={{
1736 __html: renderMarkdown(comment.body),
1737 }}
1738 />
1739 </div>
1740 </article>
1741 );
1742 })}
1743 </div>
79136bbClaude1744
240c477Claude1745 {linkedPrs.length > 0 && (
1746 <div class="iss-linked-prs">
1747 <div class="iss-linked-prs-head">
1748 <span>Linked pull requests</span>
1749 <span>{linkedPrs.length}</span>
1750 </div>
1751 {linkedPrs.map((lpr) => {
1752 const prState = lpr.isDraft ? "draft" : lpr.state;
1753 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1754 return (
1755 <a
1756 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1757 class="iss-linked-pr-row"
1758 >
1759 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1760 <span class="iss-linked-pr-title">{lpr.title}</span>
1761 <span class="iss-linked-pr-num">#{lpr.number}</span>
1762 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1763 </a>
1764 );
1765 })}
1766 </div>
1767 )}
1768
79136bbClaude1769 {user && (
f7ad7b8Claude1770 <form
1771 class="issues-composer"
1772 method="post"
1773 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1774 >
1775 <div class="issues-composer-header">
1776 <span class="issues-composer-tag">Add a comment</span>
1777 <span class="issues-composer-hint">
1778 <a
1779 href="https://docs.github.com/en/get-started/writing-on-github"
1780 target="_blank"
1781 rel="noopener noreferrer"
1782 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1783 >
1784 Markdown supported
1785 </a>
1786 </span>
1787 </div>
1788 <textarea
1789 name="body"
1790 rows={6}
1791 required
6cd2f0eClaude1792 data-md-preview=""
f7ad7b8Claude1793 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1794 />
1795 <div class="issues-composer-actions">
1796 <button type="submit" class="btn btn-primary">
1797 Comment
1798 </button>
1799 {canManage && (
1800 <button
1801 type="submit"
1802 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1803 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1804 >
1805 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1806 </button>
f7ad7b8Claude1807 )}
1808 {canManage && issue.state === "open" && (
1809 <button
1810 type="submit"
1811 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1812 formnovalidate
1813 class="btn"
1814 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1815 >
1816 Re-run AI triage
1817 </button>
1818 )}
1819 </div>
1820 </form>
79136bbClaude1821 )}
0224546Claude1822 </div>{/* /issue-detail-main */}
1823
1824 {/* Metadata sidebar — Labels, Assignees, Milestone */}
1825 <aside class="issue-meta-sidebar">
1826 {/* Labels */}
1827 <div class="issue-meta-section">
1828 <div class="issue-meta-label">Labels</div>
1829 {issueDetailLabels.length === 0 ? (
1830 <span class="issue-meta-empty">None yet</span>
1831 ) : (
1832 <div class="issue-meta-labels">
1833 {issueDetailLabels.map((lbl) => {
1834 // Compute luminance from hex color to choose dark/light text
1835 const hex = lbl.color.replace("#", "");
1836 const r = parseInt(hex.slice(0, 2), 16) / 255;
1837 const g = parseInt(hex.slice(2, 4), 16) / 255;
1838 const b = parseInt(hex.slice(4, 6), 16) / 255;
1839 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1840 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
1841 return (
1842 <span
1843 class="issue-meta-label-pill"
1844 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
1845 >
1846 {lbl.name}
1847 </span>
1848 );
1849 })}
1850 </div>
1851 )}
1852 </div>
1853
1854 {/* Assignees — field not yet in schema; placeholder */}
1855 <div class="issue-meta-section">
1856 <div class="issue-meta-label">Assignees</div>
1857 <span class="issue-meta-empty">No one assigned</span>
1858 </div>
1859
1860 {/* Milestone */}
1861 <div class="issue-meta-section">
1862 <div class="issue-meta-label">Milestone</div>
1863 {issueMilestone ? (
1864 <a
1865 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
1866 class="issue-meta-milestone-link"
1867 >
1868 &#x25CE; {issueMilestone.title}
1869 </a>
1870 ) : (
1871 <span class="issue-meta-empty">No milestone</span>
1872 )}
1873 </div>
1874 </aside>
1875 </div>{/* /issue-detail-layout */}
79136bbClaude1876 </div>
641aa42Claude1877 {/* Issue keyboard hints bar */}
1878 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1879 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
1880 </div>
1881 <style dangerouslySetInnerHTML={{ __html: `
1882 .kbd-hints {
1883 position: fixed;
1884 bottom: 0;
1885 left: 0;
1886 right: 0;
1887 z-index: 90;
1888 padding: 6px 24px;
1889 background: var(--bg-secondary);
1890 border-top: 1px solid var(--border);
1891 font-size: 12px;
1892 color: var(--text-muted);
1893 display: flex;
1894 align-items: center;
1895 gap: 8px;
1896 flex-wrap: wrap;
1897 }
1898 .kbd-hints kbd {
1899 font-family: var(--font-mono);
1900 font-size: 10px;
1901 background: var(--bg-elevated);
1902 border: 1px solid var(--border);
1903 border-bottom-width: 2px;
1904 border-radius: 4px;
1905 padding: 1px 5px;
1906 color: var(--text);
1907 line-height: 1.5;
1908 }
1909 main { padding-bottom: 40px; }
1910 ` }} />
1911 {/* Repo context commands for command palette */}
1912 <script
1913 id="cmdk-repo-context"
1914 dangerouslySetInnerHTML={{
1915 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1916 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1917 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1918 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
1919 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
1920 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
1921 ])};`,
1922 }}
1923 />
1924 {/* Issue keyboard shortcuts */}
1925 <script dangerouslySetInnerHTML={{ __html: `
1926 (function(){
1927 function isTyping(t){
1928 t = t || {};
1929 var tag = (t.tagName || '').toLowerCase();
1930 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
1931 }
1932 document.addEventListener('keydown', function(e){
1933 if (isTyping(e.target)) return;
1934 if (e.metaKey || e.ctrlKey || e.altKey) return;
1935 if (e.key === 'c') {
1936 e.preventDefault();
1937 var box = document.querySelector('textarea[name="body"]');
1938 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
1939 }
1940 if (e.key === 'e') {
1941 e.preventDefault();
1942 // Focus the issue title and make it editable if possible
1943 var titleEl = document.querySelector('.issues-title');
1944 if (titleEl) titleEl.scrollIntoView({block:'center'});
1945 }
1946 if (e.key === 'x') {
1947 e.preventDefault();
1948 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
1949 if (closeBtn) {
1950 var confirmed = window.confirm('Close/reopen this issue?');
1951 if (confirmed) closeBtn.click();
1952 }
1953 }
1954 if (e.key === 'Escape') {
1955 var focused = document.activeElement;
1956 if (focused) focused.blur();
1957 }
1958 });
1959 })();
1960 ` }} />
79136bbClaude1961 </Layout>
1962 );
1963});
1964
cb5a796Claude1965// Add comment.
1966//
1967// Permission model: we accept comments from ANY authenticated user (read
1968// access required — public repos satisfy that, private repos still need a
1969// collaborator row). The moderation gate in `decideInitialStatus`
1970// determines whether the comment is published immediately or queued for
1971// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1972// "anti-impersonation" rationale.
79136bbClaude1973issueRoutes.post(
1974 "/:owner/:repo/issues/:number/comment",
1975 softAuth,
1976 requireAuth,
cb5a796Claude1977 requireRepoAccess("read"),
79136bbClaude1978 async (c) => {
1979 const { owner: ownerName, repo: repoName } = c.req.param();
1980 const issueNum = parseInt(c.req.param("number"), 10);
1981 const user = c.get("user")!;
1982 const body = await c.req.parseBody();
1983 const commentBody = String(body.body || "").trim();
1984
1985 if (!commentBody) {
1986 return c.redirect(
1987 `/${ownerName}/${repoName}/issues/${issueNum}`
1988 );
1989 }
1990
1991 const resolved = await resolveRepo(ownerName, repoName);
1992 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1993
1994 const [issue] = await db
1995 .select()
1996 .from(issues)
1997 .where(
1998 and(
1999 eq(issues.repositoryId, resolved.repo.id),
2000 eq(issues.number, issueNum)
2001 )
2002 )
2003 .limit(1);
2004
2005 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2006
cb5a796Claude2007 // Decide moderation status BEFORE insert so the row lands in the right
2008 // initial state. Collaborators, the issue author, and trusted users
2009 // get 'approved'; banned users get 'rejected' (silent drop); everyone
2010 // else gets 'pending'.
2011 const decision = await decideInitialStatus({
2012 commenterUserId: user.id,
2013 repositoryId: resolved.repo.id,
2014 kind: "issue",
2015 threadId: issue.id,
2016 });
2017
d4ac5c3Claude2018 const [inserted] = await db
2019 .insert(issueComments)
2020 .values({
2021 issueId: issue.id,
2022 authorId: user.id,
2023 body: commentBody,
cb5a796Claude2024 moderationStatus: decision.status,
d4ac5c3Claude2025 })
2026 .returning();
2027
cb5a796Claude2028 // Live update only when visible. Don't leak pending/rejected via SSE.
2029 if (inserted && decision.status === "approved") {
d4ac5c3Claude2030 try {
2031 const { publish } = await import("../lib/sse");
2032 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
2033 event: "issue-comment",
2034 data: {
2035 issueId: issue.id,
2036 commentId: inserted.id,
2037 authorId: user.id,
2038 authorUsername: user.username,
2039 },
2040 });
2041 } catch {
2042 /* SSE is best-effort */
2043 }
b7ecb14Claude2044 // Notify the issue author — fire-and-forget, never blocks the response.
2045 if (issue.authorId && issue.authorId !== user.id) {
2046 void import("../lib/notify").then(({ createNotification }) =>
2047 createNotification({
2048 userId: issue.authorId,
2049 type: "issue_comment",
2050 title: `New comment on "${issue.title}"`,
2051 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
2052 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
2053 repoId: resolved.repo.id,
2054 })
2055 ).catch(() => { /* never block the response */ });
2056 }
d4ac5c3Claude2057 }
79136bbClaude2058
cb5a796Claude2059 if (decision.status === "pending") {
2060 // Notify repo owner that a comment is awaiting review.
2061 void notifyOwnerOfPendingComment({
2062 repositoryId: resolved.repo.id,
2063 commenterUsername: user.username,
2064 kind: "issue",
2065 threadNumber: issueNum,
2066 ownerUsername: ownerName,
2067 repoName,
2068 });
2069 return c.redirect(
2070 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2071 );
2072 }
2073 if (decision.status === "rejected") {
2074 // Silent ban — the commenter is on the banned list. Don't reveal
2075 // the gate; just send them back to the page as if it posted.
2076 return c.redirect(
2077 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2078 );
2079 }
2080
79136bbClaude2081 return c.redirect(
2082 `/${ownerName}/${repoName}/issues/${issueNum}`
2083 );
2084 }
2085);
2086
2087// Close issue
2088issueRoutes.post(
2089 "/:owner/:repo/issues/:number/close",
2090 softAuth,
2091 requireAuth,
04f6b7fClaude2092 requireRepoAccess("write"),
79136bbClaude2093 async (c) => {
2094 const { owner: ownerName, repo: repoName } = c.req.param();
2095 const issueNum = parseInt(c.req.param("number"), 10);
2096
2097 const resolved = await resolveRepo(ownerName, repoName);
2098 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2099
2100 await db
2101 .update(issues)
2102 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2103 .where(
2104 and(
2105 eq(issues.repositoryId, resolved.repo.id),
2106 eq(issues.number, issueNum)
2107 )
2108 );
2109
2110 return c.redirect(
2111 `/${ownerName}/${repoName}/issues/${issueNum}`
2112 );
2113 }
2114);
2115
2116// Reopen issue
2117issueRoutes.post(
2118 "/:owner/:repo/issues/:number/reopen",
2119 softAuth,
2120 requireAuth,
04f6b7fClaude2121 requireRepoAccess("write"),
79136bbClaude2122 async (c) => {
2123 const { owner: ownerName, repo: repoName } = c.req.param();
2124 const issueNum = parseInt(c.req.param("number"), 10);
2125
2126 const resolved = await resolveRepo(ownerName, repoName);
2127 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2128
2129 await db
2130 .update(issues)
2131 .set({ state: "open", closedAt: null, updatedAt: new Date() })
2132 .where(
2133 and(
2134 eq(issues.repositoryId, resolved.repo.id),
2135 eq(issues.number, issueNum)
2136 )
2137 );
2138
2139 return c.redirect(
2140 `/${ownerName}/${repoName}/issues/${issueNum}`
2141 );
2142 }
2143);
2144
2145// Shared nav component with issues tab
2146const IssueNav = ({
2147 owner,
2148 repo,
2149 active,
2150}: {
2151 owner: string;
2152 repo: string;
2153 active: "code" | "commits" | "issues";
2154}) => (
bb0f894Claude2155 <TabNav
2156 tabs={[
2157 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2158 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2159 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2160 ]}
2161 />
79136bbClaude2162);
2163
58915a9Claude2164// Re-run AI triage on demand (e.g. after the issue body has been edited).
2165// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2166issueRoutes.post(
2167 "/:owner/:repo/issues/:number/ai-retriage",
2168 softAuth,
2169 requireAuth,
2170 requireRepoAccess("write"),
2171 async (c) => {
2172 const { owner: ownerName, repo: repoName } = c.req.param();
2173 const issueNum = parseInt(c.req.param("number"), 10);
2174 const user = c.get("user")!;
2175 const resolved = await resolveRepo(ownerName, repoName);
2176 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2177
2178 const [issue] = await db
2179 .select()
2180 .from(issues)
2181 .where(
2182 and(
2183 eq(issues.repositoryId, resolved.repo.id),
2184 eq(issues.number, issueNum)
2185 )
2186 )
2187 .limit(1);
2188 if (!issue) {
2189 return c.redirect(`/${ownerName}/${repoName}/issues`);
2190 }
2191
2192 triggerIssueTriage(
2193 {
2194 ownerName,
2195 repoName,
2196 repositoryId: resolved.repo.id,
2197 issueId: issue.id,
2198 issueNumber: issue.number,
2199 authorId: user.id,
2200 title: issue.title,
2201 body: issue.body || "",
2202 },
2203 { force: true }
a28cedeClaude2204 ).catch((err) => {
2205 console.warn(
2206 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2207 err instanceof Error ? err.message : err
2208 );
2209 });
58915a9Claude2210
2211 return c.redirect(
2212 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2213 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2214 )}`
2215 );
2216 }
2217);
2218
c922868Claude2219// ─── Create branch from issue ─────────────────────────────────────────────────
2220// POST /:owner/:repo/issues/:number/branch
2221// Creates a new git branch from the repo default branch, pre-named after the
2222// issue. Write access required. Zero-config — no new DB tables.
2223
2224issueRoutes.post(
2225 "/:owner/:repo/issues/:number/branch",
2226 softAuth,
2227 requireAuth,
2228 requireRepoAccess("write"),
2229 async (c) => {
2230 const { owner: ownerName, repo: repoName } = c.req.param();
2231 const issueNum = parseInt(c.req.param("number"), 10);
2232
2233 const resolved = await resolveRepo(ownerName, repoName);
2234 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2235
2236 const [issue] = await db
2237 .select({ id: issues.id, number: issues.number, title: issues.title })
2238 .from(issues)
2239 .where(
2240 and(
2241 eq(issues.repositoryId, resolved.repo.id),
2242 eq(issues.number, issueNum)
2243 )
2244 )
2245 .limit(1);
2246
2247 if (!issue) {
2248 return c.redirect(`/${ownerName}/${repoName}/issues`);
2249 }
2250
2251 const body = await c.req.formData().catch(() => null);
2252 const rawName = (body?.get("branchName") as string | null)?.trim();
2253
2254 // Derive a slug from the issue title
2255 const titleSlug = issue.title
2256 .toLowerCase()
2257 .replace(/[^a-z0-9]+/g, "-")
2258 .replace(/^-+|-+$/g, "")
2259 .slice(0, 40);
2260 const suggested = `issue-${issue.number}-${titleSlug}`;
2261 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2262 ? rawName
2263 : suggested;
2264
2265 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2266 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2267
2268 if (!sha) {
2269 return c.redirect(
2270 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2271 "Cannot create branch: repository has no commits yet."
2272 )}`
2273 );
2274 }
2275
2276 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2277 if (!ok) {
2278 return c.redirect(
2279 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2280 `Failed to create branch '${branchName}'. It may already exist.`
2281 )}`
2282 );
2283 }
2284
2285 return c.redirect(
2286 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2287 `Branch '${branchName}' created from ${defaultBranch}.`
2288 )}`
2289 );
2290 }
2291);
2292
79136bbClaude2293export default issueRoutes;
2294export { IssueNav };