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.tsxBlame2029 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";
79136bbClaude61
62const issueRoutes = new Hono<AuthEnv>();
63
f7ad7b8Claude64// ---------------------------------------------------------------------------
65// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
66// other routes/shared views. All design tokens come from :root in layout.tsx.
67// ---------------------------------------------------------------------------
68const issuesStyles = `
69 /* Hero card — list page */
70 .issues-hero {
71 position: relative;
72 margin: 4px 0 24px;
73 padding: 28px 32px;
74 background: var(--bg-elevated);
75 border: 1px solid var(--border);
76 border-radius: 16px;
77 overflow: hidden;
78 }
79 .issues-hero::before {
80 content: '';
81 position: absolute;
82 top: 0; left: 0; right: 0;
83 height: 2px;
84 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
85 opacity: 0.7;
86 pointer-events: none;
87 }
88 .issues-hero-bg {
89 position: absolute;
90 inset: -30% -10% auto auto;
91 width: 360px;
92 height: 360px;
93 pointer-events: none;
94 z-index: 0;
95 }
96 .issues-hero-orb {
97 position: absolute;
98 inset: 0;
99 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
100 filter: blur(80px);
101 opacity: 0.7;
102 animation: issuesHeroOrb 14s ease-in-out infinite;
103 }
104 @keyframes issuesHeroOrb {
105 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
106 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
107 }
108 @media (prefers-reduced-motion: reduce) {
109 .issues-hero-orb { animation: none; }
110 }
111 .issues-hero-inner {
112 position: relative;
113 z-index: 1;
114 display: flex;
115 justify-content: space-between;
116 align-items: flex-end;
117 gap: 24px;
118 flex-wrap: wrap;
119 }
120 .issues-hero-text { flex: 1; min-width: 280px; }
121 .issues-hero-eyebrow {
122 font-size: 12.5px;
123 color: var(--text-muted);
124 margin-bottom: 8px;
125 letter-spacing: 0.04em;
126 text-transform: uppercase;
127 font-weight: 600;
128 }
129 .issues-hero-eyebrow .issues-hero-repo {
130 color: var(--accent);
131 text-transform: none;
132 letter-spacing: -0.005em;
133 font-weight: 600;
134 }
135 .issues-hero-title {
136 font-family: var(--font-display);
137 font-size: clamp(28px, 4vw, 40px);
138 font-weight: 800;
139 letter-spacing: -0.028em;
140 line-height: 1.05;
141 margin: 0 0 10px;
142 color: var(--text-strong);
143 }
144 .issues-hero-sub {
145 font-size: 15px;
146 color: var(--text-muted);
147 margin: 0;
148 line-height: 1.5;
149 max-width: 580px;
150 }
151 .issues-hero-actions {
152 display: flex;
153 gap: 8px;
154 flex-wrap: wrap;
155 }
156 @media (max-width: 720px) {
157 .issues-hero { padding: 24px 20px; }
158 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
159 .issues-hero-actions { width: 100%; }
160 .issues-hero-actions .btn { flex: 1; min-width: 0; }
161 }
162
f1dc7c7Claude163 /* Mobile rules — added in the 720px sweep. Kept additive only. */
164 @media (max-width: 720px) {
165 .issues-toolbar { flex-direction: column; align-items: stretch; }
166 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
167 .issues-filter { min-height: 40px; padding: 10px 14px; }
168 .issues-row { padding: 12px 14px; gap: 10px; }
169 .issues-row-side { flex-wrap: wrap; gap: 8px; }
170 .issues-detail-hero { padding: 18px; }
171 .issues-detail-attr { font-size: 13px; }
172 .issues-composer-actions { gap: 8px; }
173 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
174 .issues-empty { padding: 40px 20px; }
175 }
176
f7ad7b8Claude177 /* Count chip + filter pills */
178 .issues-toolbar {
179 display: flex;
180 align-items: center;
181 justify-content: space-between;
182 gap: 12px;
183 flex-wrap: wrap;
184 margin: 0 0 16px;
185 }
186 .issues-filters {
187 display: inline-flex;
188 background: var(--bg-elevated);
189 border: 1px solid var(--border);
190 border-radius: 9999px;
191 padding: 4px;
192 gap: 2px;
193 }
194 .issues-filter {
195 display: inline-flex;
196 align-items: center;
197 gap: 6px;
198 padding: 6px 14px;
199 border-radius: 9999px;
200 font-size: 13px;
201 font-weight: 500;
202 color: var(--text-muted);
203 text-decoration: none;
204 transition: color 120ms ease, background 120ms ease;
205 line-height: 1.4;
206 }
207 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
208 .issues-filter.is-active {
209 background: rgba(140,109,255,0.14);
210 color: var(--text-strong);
211 }
212 .issues-filter-count {
213 font-variant-numeric: tabular-nums;
214 font-size: 11.5px;
215 color: var(--text-muted);
216 background: rgba(255,255,255,0.04);
217 padding: 1px 7px;
218 border-radius: 9999px;
219 }
220 .issues-filter.is-active .issues-filter-count {
221 background: rgba(140,109,255,0.18);
222 color: var(--text);
223 }
224
7a28902Claude225 /* Issue search form */
226 .issues-search-form {
227 display: flex;
228 align-items: center;
229 gap: 0;
230 background: var(--bg-elevated);
231 border: 1px solid var(--border);
232 border-radius: 9999px;
233 overflow: hidden;
234 flex: 1;
235 max-width: 280px;
236 transition: border-color 120ms ease;
237 }
238 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
239 .issues-search-input {
240 flex: 1;
241 background: transparent;
242 color: var(--text);
243 border: none;
244 outline: none;
245 padding: 6px 14px;
246 font-size: 13px;
247 font-family: var(--font-sans, inherit);
248 min-width: 0;
249 }
250 .issues-search-input::placeholder { color: var(--text-muted); }
251 .issues-search-btn {
252 background: transparent;
253 border: none;
254 color: var(--text-muted);
255 padding: 6px 12px 6px 8px;
256 cursor: pointer;
257 display: flex;
258 align-items: center;
259 }
260 .issues-search-btn:hover { color: var(--text); }
261 .issues-filter-banner {
262 margin-bottom: 12px;
263 padding: 8px 14px;
264 background: rgba(140,109,255,0.06);
265 border: 1px solid rgba(140,109,255,0.2);
266 border-radius: 8px;
267 font-size: 13px;
268 color: var(--text-muted);
269 }
270 .issues-filter-clear {
271 color: var(--accent, #8c6dff);
272 text-decoration: underline;
273 cursor: pointer;
274 }
275 .issues-label-badge {
276 display: inline-block;
277 background: rgba(140,109,255,0.15);
278 color: #a78bfa;
279 border-radius: 9999px;
280 padding: 1px 8px;
281 font-size: 11.5px;
282 font-weight: 600;
283 }
284
f7ad7b8Claude285 /* Issue list — modernised rows */
286 .issues-list {
287 list-style: none;
288 margin: 0;
289 padding: 0;
290 border: 1px solid var(--border);
291 border-radius: 12px;
292 overflow: hidden;
293 background: var(--bg-elevated);
294 }
295 .issues-row {
296 display: flex;
297 align-items: flex-start;
298 gap: 14px;
299 padding: 14px 18px;
300 border-bottom: 1px solid var(--border);
301 transition: background 120ms ease, transform 120ms ease;
302 }
303 .issues-row:last-child { border-bottom: none; }
304 .issues-row:hover { background: rgba(140,109,255,0.04); }
305 .issues-row-icon {
306 width: 18px;
307 height: 18px;
308 flex-shrink: 0;
309 margin-top: 3px;
310 display: inline-flex;
311 align-items: center;
312 justify-content: center;
313 }
314 .issues-row-icon.is-open { color: #34d399; }
315 .issues-row-icon.is-closed { color: #b69dff; }
316 .issues-row-main { flex: 1; min-width: 0; }
317 .issues-row-title {
318 font-family: var(--font-display);
319 font-size: 15.5px;
320 font-weight: 600;
321 line-height: 1.35;
322 letter-spacing: -0.012em;
323 margin: 0;
324 display: flex;
325 flex-wrap: wrap;
326 align-items: center;
327 gap: 8px;
328 }
329 .issues-row-title a {
330 color: var(--text-strong);
331 text-decoration: none;
332 transition: color 120ms ease;
333 }
334 .issues-row-title a:hover { color: var(--accent); }
335 .issues-row-meta {
336 margin-top: 5px;
337 font-size: 12.5px;
338 color: var(--text-muted);
339 line-height: 1.5;
340 }
341 .issues-row-meta strong { color: var(--text); font-weight: 600; }
342 .issues-row-side {
343 display: flex;
344 align-items: center;
345 gap: 12px;
346 color: var(--text-muted);
347 font-size: 12.5px;
348 flex-shrink: 0;
349 }
350 .issues-row-comments {
351 display: inline-flex;
352 align-items: center;
353 gap: 5px;
354 color: var(--text-muted);
355 text-decoration: none;
356 }
357 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
358
359 /* Label pills (rendered inline on titles) */
360 .issues-label {
361 display: inline-flex;
362 align-items: center;
363 padding: 2px 9px;
364 border-radius: 9999px;
365 font-size: 11.5px;
366 font-weight: 600;
367 line-height: 1.4;
368 background: rgba(140,109,255,0.10);
369 color: var(--text-strong);
370 border: 1px solid rgba(140,109,255,0.28);
371 letter-spacing: 0.005em;
372 }
373
374 /* Polished empty state */
375 .issues-empty {
376 margin: 0;
377 padding: 56px 32px;
378 background: var(--bg-elevated);
379 border: 1px solid var(--border);
380 border-radius: 16px;
381 text-align: center;
382 position: relative;
383 overflow: hidden;
384 }
385 .issues-empty::before {
386 content: '';
387 position: absolute;
388 top: 0; left: 0; right: 0;
389 height: 2px;
390 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
391 opacity: 0.55;
392 pointer-events: none;
393 }
394 .issues-empty-art {
395 width: 96px;
396 height: 96px;
397 margin: 0 auto 18px;
398 display: block;
399 opacity: 0.85;
400 }
401 .issues-empty-title {
402 font-family: var(--font-display);
403 font-size: 22px;
404 font-weight: 700;
405 letter-spacing: -0.018em;
406 color: var(--text-strong);
407 margin: 0 0 8px;
408 }
409 .issues-empty-sub {
410 font-size: 14.5px;
411 color: var(--text-muted);
412 line-height: 1.55;
413 margin: 0 auto 22px;
414 max-width: 460px;
415 }
416 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
417
418 /* ─── Detail page ─── */
419 .issues-detail-hero {
420 position: relative;
421 margin: 4px 0 20px;
422 padding: 22px 26px;
423 background: var(--bg-elevated);
424 border: 1px solid var(--border);
425 border-radius: 16px;
426 overflow: hidden;
427 }
428 .issues-detail-hero::before {
429 content: '';
430 position: absolute;
431 top: 0; left: 0; right: 0;
432 height: 2px;
433 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
434 opacity: 0.7;
435 pointer-events: none;
436 }
437 .issues-detail-title {
438 font-family: var(--font-display);
439 font-size: clamp(22px, 3vw, 30px);
440 font-weight: 700;
441 letter-spacing: -0.022em;
442 line-height: 1.18;
443 color: var(--text-strong);
444 margin: 0 0 12px;
445 }
446 .issues-detail-title .issues-detail-number {
447 color: var(--text-muted);
448 font-weight: 500;
449 margin-left: 8px;
450 }
451 .issues-detail-attr {
452 display: flex;
453 align-items: center;
454 gap: 12px;
455 flex-wrap: wrap;
456 font-size: 14px;
457 color: var(--text-muted);
458 }
459 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
460 .issues-state-pill {
461 display: inline-flex;
462 align-items: center;
463 gap: 6px;
464 padding: 5px 12px;
465 border-radius: 9999px;
466 font-size: 12.5px;
467 font-weight: 600;
468 line-height: 1.4;
469 letter-spacing: 0.005em;
470 }
471 .issues-state-pill.is-open {
472 background: rgba(52,211,153,0.12);
473 color: #34d399;
474 border: 1px solid rgba(52,211,153,0.35);
475 }
476 .issues-state-pill.is-closed {
477 background: rgba(182,157,255,0.12);
478 color: #b69dff;
479 border: 1px solid rgba(182,157,255,0.35);
480 }
481 .issues-detail-spacer { flex: 1; }
482 .issues-detail-labels {
483 margin-top: 12px;
484 display: flex;
485 gap: 6px;
486 flex-wrap: wrap;
487 }
488
489 /* Comment thread */
490 .issues-thread { margin-top: 18px; }
491 .issues-comment {
492 position: relative;
493 border: 1px solid var(--border);
494 border-radius: 12px;
495 overflow: hidden;
496 background: var(--bg-elevated);
497 margin-bottom: 14px;
498 transition: border-color 160ms ease, box-shadow 160ms ease;
499 }
500 .issues-comment:hover {
501 border-color: var(--border-strong, rgba(255,255,255,0.13));
502 }
503 .issues-comment-header {
504 background: var(--bg-secondary);
505 padding: 10px 16px;
506 border-bottom: 1px solid var(--border);
507 display: flex;
508 align-items: center;
509 gap: 10px;
510 font-size: 13px;
511 color: var(--text-muted);
512 }
513 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
514 .issues-comment-body { padding: 14px 18px; }
515 .issues-comment-author-pill {
516 display: inline-flex;
517 align-items: center;
518 padding: 1px 8px;
519 border-radius: 9999px;
520 background: rgba(140,109,255,0.10);
521 color: var(--accent);
522 font-size: 11px;
523 font-weight: 600;
524 letter-spacing: 0.02em;
525 text-transform: uppercase;
526 }
527
528 /* AI Review comment — distinct purple-accent treatment */
529 .issues-comment.is-ai {
530 border-color: rgba(140,109,255,0.45);
531 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
532 }
533 .issues-comment.is-ai::before {
534 content: '';
535 position: absolute;
536 top: 0; left: 0; right: 0;
537 height: 2px;
538 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
539 pointer-events: none;
540 }
541 .issues-comment.is-ai .issues-comment-header {
542 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
543 border-bottom-color: rgba(140,109,255,0.22);
544 }
545 .issues-ai-badge {
546 display: inline-flex;
547 align-items: center;
548 gap: 5px;
549 padding: 2px 9px;
550 border-radius: 9999px;
551 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
552 color: #fff;
553 font-size: 11px;
554 font-weight: 700;
555 letter-spacing: 0.04em;
556 text-transform: uppercase;
557 line-height: 1.4;
558 }
559 .issues-ai-badge::before {
560 content: '';
561 width: 6px;
562 height: 6px;
563 border-radius: 9999px;
564 background: #fff;
565 opacity: 0.92;
566 box-shadow: 0 0 6px rgba(255,255,255,0.7);
567 }
568
a7460bfClaude569 .issues-bot-badge {
570 display: inline-flex; align-items: center; gap: 3px;
571 padding: 1px 7px;
572 font-size: 10px;
573 font-weight: 600;
574 color: var(--fg-muted);
575 background: var(--bg-elevated);
576 border: 1px solid var(--border);
577 border-radius: 9999px;
578 }
579
f7ad7b8Claude580 /* Composer */
581 .issues-composer {
582 margin-top: 22px;
583 border: 1px solid var(--border);
584 border-radius: 12px;
585 overflow: hidden;
586 background: var(--bg-elevated);
587 transition: border-color 160ms ease, box-shadow 160ms ease;
588 }
589 .issues-composer:focus-within {
590 border-color: rgba(140,109,255,0.55);
591 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
592 }
593 .issues-composer-header {
594 display: flex;
595 align-items: center;
596 justify-content: space-between;
597 gap: 8px;
598 padding: 10px 14px;
599 background: var(--bg-secondary);
600 border-bottom: 1px solid var(--border);
601 font-size: 12.5px;
602 color: var(--text-muted);
603 }
604 .issues-composer-tag {
605 font-weight: 600;
606 color: var(--text);
607 letter-spacing: -0.005em;
608 }
609 .issues-composer-hint a {
610 color: var(--text-muted);
611 text-decoration: none;
612 border-bottom: 1px dashed currentColor;
613 }
614 .issues-composer-hint a:hover { color: var(--accent); }
615 .issues-composer textarea {
616 display: block;
617 width: 100%;
618 border: 0;
619 background: transparent;
620 color: var(--text);
621 padding: 14px 16px;
622 font-family: var(--font-mono);
623 font-size: 13.5px;
624 line-height: 1.55;
625 resize: vertical;
626 outline: none;
627 min-height: 140px;
628 }
629 .issues-composer-actions {
630 display: flex;
631 align-items: center;
632 gap: 8px;
633 padding: 10px 14px;
634 border-top: 1px solid var(--border);
635 background: var(--bg-secondary);
636 flex-wrap: wrap;
637 }
638
639 /* Info banner inside detail */
640 .issues-info-banner {
641 margin: 0 0 14px;
642 padding: 10px 14px;
643 border-radius: 12px;
644 background: rgba(140,109,255,0.08);
645 border: 1px solid rgba(140,109,255,0.28);
646 color: var(--text);
647 font-size: 13.5px;
648 }
240c477Claude649
650 /* ─── Linked PRs ─── */
651 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
652 .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; }
653 .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; }
654 .iss-linked-pr-row:last-child { border-bottom: none; }
655 .iss-linked-pr-row:hover { background: var(--bg-hover); }
656 .iss-linked-pr-icon.is-open { color: #34d399; }
657 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
658 .iss-linked-pr-icon.is-closed { color: #8b949e; }
659 .iss-linked-pr-icon.is-draft { color: #8b949e; }
660 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
661 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
662 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
663 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
664 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
665 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
666 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude667
8d1483cClaude668 /* ─── Bulk action UI ─── */
669 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
670 .bulk-bar {
671 display: none;
672 align-items: center;
673 gap: 10px;
674 padding: 10px 16px;
675 background: var(--bg-elevated);
676 border: 1px solid var(--border-strong);
677 border-radius: var(--r-md);
678 margin-bottom: 12px;
679 position: sticky;
680 top: calc(var(--header-h) + 8px);
681 z-index: 10;
682 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
683 }
684 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
685 .bulk-bar-btn {
686 padding: 6px 12px;
687 border-radius: var(--r-sm);
688 border: 1px solid var(--border);
689 background: var(--bg-surface);
690 color: var(--text);
691 font-size: 13px;
692 cursor: pointer;
693 transition: background 120ms;
694 }
695 .bulk-bar-btn:hover { background: var(--bg-hover); }
696 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
697 .bulk-bar-clear:hover { color: var(--text); }
698
f5b9ef5Claude699 /* ─── Sort controls (issue list) ─── */
700 .issues-sort-row {
701 display: flex;
702 align-items: center;
703 gap: 6px;
704 margin: 0 0 12px;
705 flex-wrap: wrap;
706 }
707 .issues-sort-label {
708 font-size: 12.5px;
709 color: var(--text-muted);
710 font-weight: 600;
711 margin-right: 2px;
712 }
713 .issues-sort-opt {
714 font-size: 12.5px;
715 color: var(--text-muted);
716 text-decoration: none;
717 padding: 3px 10px;
718 border-radius: 9999px;
719 border: 1px solid transparent;
720 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
721 }
722 .issues-sort-opt:hover {
723 background: var(--bg-hover);
724 color: var(--text);
725 }
726 .issues-sort-opt.is-active {
727 background: rgba(140,109,255,0.12);
728 color: var(--text-link);
729 border-color: rgba(140,109,255,0.35);
730 font-weight: 600;
731 }
f7ad7b8Claude732`;
733
734// Pre-rendered <style> tag (constant, reused per request).
735const IssuesStyle = () => (
736 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
737);
738
739// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
740const IssuesEmptySvg = () => (
741 <svg
742 class="issues-empty-art"
743 viewBox="0 0 96 96"
744 fill="none"
745 xmlns="http://www.w3.org/2000/svg"
746 aria-hidden="true"
747 >
748 <defs>
749 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
750 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
751 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
752 </linearGradient>
753 </defs>
754 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
755 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
756 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
757 </svg>
758);
759
760// Detect AI Triage comments by the marker the triage helper writes.
761function isAiTriageComment(body: string | null | undefined): boolean {
762 if (!body) return false;
763 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
764}
765
79136bbClaude766// Helper to resolve repo from :owner/:repo params
767async function resolveRepo(ownerName: string, repoName: string) {
768 const [owner] = await db
769 .select()
770 .from(users)
771 .where(eq(users.username, ownerName))
772 .limit(1);
773 if (!owner) return null;
774
775 const [repo] = await db
776 .select()
777 .from(repositories)
778 .where(
779 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
780 )
781 .limit(1);
782 if (!repo) return null;
783
784 return { owner, repo };
785}
786
8d1483cClaude787// Bulk issue operations (close / reopen multiple issues at once)
788issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
789 const { owner: ownerName, repo: repoName } = c.req.param();
790 const user = c.get("user")!;
791 const body = await c.req.parseBody();
792 const action = String(body.action || "");
793 const rawNumbers = body["numbers[]"];
794 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
795 .map(n => parseInt(String(n), 10))
796 .filter(n => !isNaN(n) && n > 0);
797
798 if (!numbers.length || !["close","reopen"].includes(action)) {
799 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
800 }
801
802 const resolved = await resolveRepo(ownerName, repoName);
803 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
804
805 const newState = action === "close" ? "closed" : "open";
806 await db.update(issues)
807 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
808 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
809
810 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
811 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
812});
813
79136bbClaude814// Issue list
04f6b7fClaude815issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude816 const { owner: ownerName, repo: repoName } = c.req.param();
817 const user = c.get("user");
818 const state = c.req.query("state") || "open";
7a28902Claude819 const searchQ = (c.req.query("q") || "").trim();
820 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude821 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude822 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude823 // Bounded pagination — unbounded selects ran a full table scan + O(n)
824 // sort on every page load; with 10k+ issues the request would hang.
825 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
826 const page = Math.max(1, Number(c.req.query("page")) || 1);
827 const offset = (page - 1) * perPage;
79136bbClaude828
f1dc7c7Claude829 // ── Loading skeleton (flag-gated) ──
830 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
831 // user see the shape of the issue list before the DB count + select
832 // resolve. Behind a flag — we don't ship flashes.
833 if (c.req.query("skeleton") === "1") {
834 return c.html(
835 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
836 <IssuesStyle />
837 <RepoHeader owner={ownerName} repo={repoName} />
838 <IssueNav owner={ownerName} repo={repoName} active="issues" />
839 <style
840 dangerouslySetInnerHTML={{
841 __html: `
842 .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; }
843 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
844 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
845 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
846 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
847 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
848 .issues-skel-row { height: 62px; border-radius: 10px; }
849 `,
850 }}
851 />
852 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
853 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
854 <div class="issues-skel-list" aria-hidden="true">
855 {Array.from({ length: 8 }).map(() => (
856 <div class="issues-skel issues-skel-row" />
857 ))}
858 </div>
859 <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">
860 Loading issues for {ownerName}/{repoName}…
861 </span>
862 </Layout>
863 );
864 }
865
79136bbClaude866 const resolved = await resolveRepo(ownerName, repoName);
867 if (!resolved) {
868 return c.html(
869 <Layout title="Not Found" user={user}>
bb0f894Claude870 <EmptyState title="Repository not found" />
79136bbClaude871 </Layout>,
872 404
873 );
874 }
875
876 const { repo } = resolved;
877
7a28902Claude878 // If label filter is set, find issue IDs that have that label
879 let labelFilteredIds: string[] | null = null;
880 if (labelFilter) {
881 const [matchedLabel] = await db
882 .select({ id: labels.id })
883 .from(labels)
884 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
885 .limit(1);
886 if (matchedLabel) {
887 const rows = await db
888 .select({ issueId: issueLabels.issueId })
889 .from(issueLabels)
890 .where(eq(issueLabels.labelId, matchedLabel.id));
891 labelFilteredIds = rows.map((r) => r.issueId);
892 } else {
893 labelFilteredIds = []; // no matches
894 }
895 }
896
85c4e13Claude897 // If milestone filter is set, resolve the milestone ID
898 let milestoneFilterId: string | null = null;
899 if (milestoneFilter) {
900 const [matchedMs] = await db
901 .select({ id: milestones.id })
902 .from(milestones)
903 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
904 .limit(1);
905 milestoneFilterId = matchedMs?.id ?? null;
906 }
907
7a28902Claude908 const baseWhere = and(
909 eq(issues.repositoryId, repo.id),
910 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
911 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
912 labelFilteredIds !== null && labelFilteredIds.length > 0
913 ? inArray(issues.id, labelFilteredIds)
914 : labelFilteredIds !== null && labelFilteredIds.length === 0
915 ? sql`false`
85c4e13Claude916 : undefined,
917 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude918 );
919
79136bbClaude920 const issueList = await db
921 .select({
922 issue: issues,
923 author: { username: users.username },
924 })
925 .from(issues)
926 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude927 .where(baseWhere)
f5b9ef5Claude928 .orderBy(
929 sort === "oldest" ? asc(issues.createdAt)
930 : sort === "updated" ? desc(issues.updatedAt)
931 : desc(issues.createdAt) // newest (default)
932 )
6ea2109Claude933 .limit(perPage)
934 .offset(offset);
79136bbClaude935
936 // Count open/closed
937 const [counts] = await db
938 .select({
939 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
940 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
941 })
942 .from(issues)
943 .where(eq(issues.repositoryId, repo.id));
944
85c4e13Claude945 // Count open milestones for the "N Milestones" link
946 const [milestoneCounts] = await db
947 .select({
948 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
949 })
950 .from(milestones)
951 .where(eq(milestones.repositoryId, repo.id));
952
cb5a796Claude953 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
954 const pendingCountList = viewerIsOwnerOnList
955 ? await countPendingForRepo(repo.id)
956 : 0;
957
79136bbClaude958 return c.html(
959 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude960 <IssuesStyle />
79136bbClaude961 <RepoHeader owner={ownerName} repo={repoName} />
962 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude963 <PendingCommentsBanner
964 owner={ownerName}
965 repo={repoName}
966 count={pendingCountList}
967 />
f7ad7b8Claude968 <section class="issues-hero">
969 <div class="issues-hero-bg" aria-hidden="true">
970 <div class="issues-hero-orb" />
971 </div>
972 <div class="issues-hero-inner">
973 <div class="issues-hero-text">
974 <div class="issues-hero-eyebrow">
975 Issue tracker \u00B7{" "}
976 <span class="issues-hero-repo">
977 {ownerName}/{repoName}
978 </span>
979 </div>
980 <h1 class="issues-hero-title">
981 Track <span class="gradient-text">what matters</span>.
982 </h1>
983 <p class="issues-hero-sub">
984 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
985 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
986 : `${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.`}
987 </p>
988 </div>
989 <div class="issues-hero-actions">
990 {user && (
991 <a
992 href={`/${ownerName}/${repoName}/issues/new`}
993 class="btn btn-primary"
994 >
995 + New issue
996 </a>
997 )}
85c4e13Claude998 <a
999 href={`/${ownerName}/${repoName}/milestones`}
1000 class="btn"
1001 title="View milestones for this repository"
1002 >
1003 Milestones
1004 {Number(milestoneCounts?.open ?? 0) > 0 && (
1005 <span style="margin-left:5px;font-size:11.5px;background:rgba(140,109,255,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
1006 {Number(milestoneCounts?.open ?? 0)}
1007 </span>
1008 )}
1009 </a>
f7ad7b8Claude1010 <a href={`/${ownerName}/${repoName}`} class="btn">
1011 Back to code
1012 </a>
1013 </div>
1014 </div>
1015 </section>
1016
1017 <div class="issues-toolbar">
1018 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1019 <a
1020 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1021 href={`/${ownerName}/${repoName}/issues?state=open`}
1022 role="tab"
1023 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1024 >
f7ad7b8Claude1025 <span aria-hidden="true">{"\u25CB"}</span>
1026 <span>Open</span>
1027 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1028 </a>
1029 <a
1030 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1031 href={`/${ownerName}/${repoName}/issues?state=closed`}
1032 role="tab"
1033 aria-selected={state === "closed" ? "true" : "false"}
1034 >
1035 <span aria-hidden="true">{"\u2713"}</span>
1036 <span>Closed</span>
1037 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1038 </a>
1039 </div>
7a28902Claude1040 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1041 <input type="hidden" name="state" value={state} />
1042 <input
1043 type="search"
1044 name="q"
1045 value={searchQ}
1046 placeholder="Search issues\u2026"
1047 class="issues-search-input"
1048 aria-label="Search issues by title"
1049 />
1050 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1051 <button type="submit" class="issues-search-btn" aria-label="Search">
1052 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1053 <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" />
1054 </svg>
1055 </button>
1056 </form>
f7ad7b8Claude1057 </div>
7a28902Claude1058 {(searchQ || labelFilter) && (
1059 <div class="issues-filter-banner">
1060 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1061 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1062 {" \u00B7 "}
1063 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1064 </div>
1065 )}
f7ad7b8Claude1066
f5b9ef5Claude1067 <div class="issues-sort-row">
1068 <span class="issues-sort-label">Sort:</span>
1069 {(["newest", "oldest", "updated"] as const).map((s) => (
1070 <a
1071 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1072 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1073 >
1074 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1075 </a>
1076 ))}
1077 </div>
1078
79136bbClaude1079 {issueList.length === 0 ? (
f7ad7b8Claude1080 <div class="issues-empty">
1081 <IssuesEmptySvg />
1082 <h2 class="issues-empty-title">
1083 {state === "closed"
1084 ? "No closed issues yet"
1085 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1086 ? "No issues \u2014 yet"
1087 : "Nothing open right now"}
1088 </h2>
1089 <p class="issues-empty-sub">
1090 {state === "closed"
1091 ? "Closed issues will show up here once the team starts shipping fixes."
1092 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1093 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1094 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1095 </p>
1096 <div class="issues-empty-cta">
1097 {user && state !== "closed" && (
1098 <a
1099 href={`/${ownerName}/${repoName}/issues/new`}
1100 class="btn btn-primary"
1101 >
1102 + Open the first issue
1103 </a>
1104 )}
79136bbClaude1105 {state === "closed" && (
f7ad7b8Claude1106 <a
1107 href={`/${ownerName}/${repoName}/issues?state=open`}
1108 class="btn"
1109 >
1110 View open issues
1111 </a>
79136bbClaude1112 )}
f7ad7b8Claude1113 </div>
1114 </div>
79136bbClaude1115 ) : (
8d1483cClaude1116 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1117 <input type="hidden" name="action" id="bulk-action" value="" />
1118 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1119 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1120 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1121 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1122 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1123 </div>
1124 <ul class="issues-list">
1125 {issueList.map(({ issue, author }) => (
1126 <li class="issues-row">
1127 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1128 <div
1129 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1130 aria-hidden="true"
1131 title={issue.state === "open" ? "Open" : "Closed"}
1132 >
1133 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1134 </div>
8d1483cClaude1135 <div class="issues-row-main">
1136 <h3 class="issues-row-title">
1137 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1138 {issue.title}
1139 </a>
1140 </h3>
1141 <div class="issues-row-meta">
1142 #{issue.number} opened by{" "}
1143 <strong>{author.username}</strong>{" "}
1144 {formatRelative(issue.createdAt)}
1145 </div>
1146 </div>
1147 </li>
1148 ))}
1149 </ul>
1150 <script dangerouslySetInnerHTML={{ __html: `
1151function bulkSubmit(action){
1152 document.getElementById('bulk-action').value=action;
1153 document.getElementById('bulk-form').submit();
1154}
1155function bulkClear(){
1156 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1157 updateBulkBar();
1158}
1159function updateBulkBar(){
1160 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1161 var bar=document.getElementById('bulk-bar');
1162 var cnt=document.getElementById('bulk-bar-count');
1163 if(bar){bar.style.display=checked>0?'flex':'none';}
1164 if(cnt){cnt.textContent=checked+' selected';}
1165}
1166document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1167` }} />
1168 </form>
79136bbClaude1169 )}
1170 </Layout>
1171 );
1172});
1173
1174// New issue form
1175issueRoutes.get(
1176 "/:owner/:repo/issues/new",
1177 softAuth,
1178 requireAuth,
04f6b7fClaude1179 requireRepoAccess("write"),
79136bbClaude1180 async (c) => {
1181 const { owner: ownerName, repo: repoName } = c.req.param();
1182 const user = c.get("user")!;
1183 const error = c.req.query("error");
24cf2caClaude1184 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1185
85c4e13Claude1186 // Load open milestones for the dropdown
1187 const resolved = await resolveRepo(ownerName, repoName);
1188 const openMilestones = resolved
1189 ? await db
1190 .select({ id: milestones.id, title: milestones.title })
1191 .from(milestones)
1192 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1193 .orderBy(milestones.title)
1194 : [];
1195
79136bbClaude1196 return c.html(
1197 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1198 <IssuesStyle />
79136bbClaude1199 <RepoHeader owner={ownerName} repo={repoName} />
1200 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1201 <Container maxWidth={800}>
f7ad7b8Claude1202 <section class="issues-hero" style="margin-top:4px">
1203 <div class="issues-hero-bg" aria-hidden="true">
1204 <div class="issues-hero-orb" />
1205 </div>
1206 <div class="issues-hero-inner">
1207 <div class="issues-hero-text">
1208 <div class="issues-hero-eyebrow">
1209 New issue ·{" "}
1210 <span class="issues-hero-repo">
1211 {ownerName}/{repoName}
1212 </span>
1213 </div>
1214 <h1 class="issues-hero-title">
1215 File <span class="gradient-text">it cleanly</span>.
1216 </h1>
1217 <p class="issues-hero-sub">
1218 AI Triage will read the body the moment you submit and post
1219 suggested labels, priority, and possible duplicates within
1220 seconds. You stay in control — nothing is applied.
1221 </p>
1222 </div>
1223 </div>
1224 </section>
79136bbClaude1225 {error && (
bb0f894Claude1226 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1227 )}
0316dbbClaude1228 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1229 <FormGroup>
1230 <Input
79136bbClaude1231 type="text"
1232 name="title"
1233 required
1234 placeholder="Title"
bb0f894Claude1235 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1236 aria-label="Issue title"
79136bbClaude1237 />
bb0f894Claude1238 </FormGroup>
1239 <FormGroup>
1240 <TextArea
79136bbClaude1241 name="body"
1242 rows={12}
1243 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1244 mono
79136bbClaude1245 />
bb0f894Claude1246 </FormGroup>
85c4e13Claude1247 {openMilestones.length > 0 && (
1248 <FormGroup>
1249 <label
1250 for="milestone_id"
1251 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1252 >
1253 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1254 </label>
1255 <select
1256 id="milestone_id"
1257 name="milestone_id"
1258 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"
1259 >
1260 <option value="">No milestone</option>
1261 {openMilestones.map((ms) => (
1262 <option value={ms.id}>{ms.title}</option>
1263 ))}
1264 </select>
1265 </FormGroup>
1266 )}
bb0f894Claude1267 <Button type="submit" variant="primary">
79136bbClaude1268 Submit new issue
bb0f894Claude1269 </Button>
1270 </Form>
1271 </Container>
79136bbClaude1272 </Layout>
1273 );
1274 }
1275);
1276
1277// Create issue
1278issueRoutes.post(
1279 "/:owner/:repo/issues/new",
1280 softAuth,
1281 requireAuth,
04f6b7fClaude1282 requireRepoAccess("write"),
79136bbClaude1283 async (c) => {
1284 const { owner: ownerName, repo: repoName } = c.req.param();
1285 const user = c.get("user")!;
1286 const body = await c.req.parseBody();
1287 const title = String(body.title || "").trim();
1288 const issueBody = String(body.body || "").trim();
85c4e13Claude1289 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1290
1291 if (!title) {
1292 return c.redirect(
1293 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1294 );
1295 }
1296
1297 const resolved = await resolveRepo(ownerName, repoName);
1298 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1299
85c4e13Claude1300 // Validate milestone belongs to this repo if provided
1301 let validatedMilestoneId: string | null = null;
1302 if (milestoneIdRaw) {
1303 const [msRow] = await db
1304 .select({ id: milestones.id })
1305 .from(milestones)
1306 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1307 .limit(1);
1308 validatedMilestoneId = msRow?.id ?? null;
1309 }
1310
79136bbClaude1311 const [issue] = await db
1312 .insert(issues)
1313 .values({
1314 repositoryId: resolved.repo.id,
1315 authorId: user.id,
1316 title,
1317 body: issueBody || null,
85c4e13Claude1318 milestoneId: validatedMilestoneId,
79136bbClaude1319 })
1320 .returning();
1321
1322 // Update issue count
1323 await db
1324 .update(repositories)
1325 .set({ issueCount: resolved.repo.issueCount + 1 })
1326 .where(eq(repositories.id, resolved.repo.id));
1327
a9ada5fClaude1328 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1329 // suggested labels, priority, summary, and a possible-duplicate
1330 // callout. Suggestions only — nothing applied automatically.
1331 triggerIssueTriage({
1332 ownerName,
1333 repoName,
1334 repositoryId: resolved.repo.id,
1335 issueId: issue.id,
1336 issueNumber: issue.number,
1337 authorId: user.id,
1338 title,
1339 body: issueBody,
a28cedeClaude1340 }).catch((err) => {
1341 console.warn(
1342 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1343 err instanceof Error ? err.message : err
1344 );
1345 });
a9ada5fClaude1346
79136bbClaude1347 return c.redirect(
1348 `/${ownerName}/${repoName}/issues/${issue.number}`
1349 );
1350 }
1351);
1352
1353// View single issue
04f6b7fClaude1354issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1355 const { owner: ownerName, repo: repoName } = c.req.param();
1356 const issueNum = parseInt(c.req.param("number"), 10);
1357 const user = c.get("user");
1358
1359 const resolved = await resolveRepo(ownerName, repoName);
1360 if (!resolved) {
1361 return c.html(
1362 <Layout title="Not Found" user={user}>
bb0f894Claude1363 <EmptyState title="Not found" />
79136bbClaude1364 </Layout>,
1365 404
1366 );
1367 }
1368
1369 const [issue] = await db
1370 .select()
1371 .from(issues)
1372 .where(
1373 and(
1374 eq(issues.repositoryId, resolved.repo.id),
1375 eq(issues.number, issueNum)
1376 )
1377 )
1378 .limit(1);
1379
1380 if (!issue) {
1381 return c.html(
1382 <Layout title="Not Found" user={user}>
bb0f894Claude1383 <EmptyState title="Issue not found" />
79136bbClaude1384 </Layout>,
1385 404
1386 );
1387 }
1388
1389 const [author] = await db
1390 .select()
1391 .from(users)
1392 .where(eq(users.id, issue.authorId))
1393 .limit(1);
1394
cb5a796Claude1395 // Get comments. We pull every row and filter by moderation_status +
1396 // viewer identity client-side (in the JSX below) so a moderator/owner
1397 // sees them all while non-author viewers only ever see 'approved'.
1398 const allComments = await db
79136bbClaude1399 .select({
1400 comment: issueComments,
cb5a796Claude1401 author: { id: users.id, username: users.username },
79136bbClaude1402 })
1403 .from(issueComments)
1404 .innerJoin(users, eq(issueComments.authorId, users.id))
1405 .where(eq(issueComments.issueId, issue.id))
1406 .orderBy(asc(issueComments.createdAt));
1407
cb5a796Claude1408 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1409 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1410 // Owner always sees everything (so they can spot conversations going
1411 // sideways even before they hit the queue page).
1412 if (viewerIsOwner) return true;
1413 if (comment.moderationStatus === "approved") return true;
1414 // The comment's own author sees their pending comment so they know
1415 // it landed (with an "Awaiting approval" badge in the render below).
1416 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1417 return true;
1418 }
1419 return false;
1420 });
1421
1422 // Pending banner — when the viewer is the repo owner, show a strip at
1423 // the top of the page nudging them to the moderation queue. Inline on
1424 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1425 const pendingCount = viewerIsOwner
1426 ? await countPendingForRepo(resolved.repo.id)
1427 : 0;
1428
6fc53bdClaude1429 // Load reactions for the issue + each comment in parallel.
1430 const [issueReactions, ...commentReactions] = await Promise.all([
1431 summariseReactions("issue", issue.id, user?.id),
1432 ...comments.map((row) =>
1433 summariseReactions("issue_comment", row.comment.id, user?.id)
1434 ),
1435 ]);
1436
79136bbClaude1437 const canManage =
1438 user &&
1439 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1440 const info = c.req.query("info");
79136bbClaude1441
240c477Claude1442 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1443 const issueRefPattern = `%#${issue.number}%`;
1444 const linkedPrs = await db
1445 .select({
1446 number: pullRequests.number,
1447 title: pullRequests.title,
1448 state: pullRequests.state,
1449 isDraft: pullRequests.isDraft,
1450 })
1451 .from(pullRequests)
1452 .where(
1453 and(
1454 eq(pullRequests.repositoryId, resolved.repo.id),
1455 or(
1456 ilike(pullRequests.title, issueRefPattern),
1457 ilike(pullRequests.body, issueRefPattern),
1458 )
1459 )
1460 )
1461 .orderBy(desc(pullRequests.createdAt))
1462 .limit(8);
1463
79136bbClaude1464 return c.html(
1465 <Layout
1466 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1467 user={user}
1468 >
f7ad7b8Claude1469 <IssuesStyle />
79136bbClaude1470 <RepoHeader owner={ownerName} repo={repoName} />
1471 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1472 <PendingCommentsBanner
1473 owner={ownerName}
1474 repo={repoName}
1475 count={pendingCount}
1476 />
b584e52Claude1477 <div
1478 id="live-comment-banner"
1479 class="alert"
1480 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1481 >
1482 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1483 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1484 reload to view
1485 </a>
1486 </div>
1487 <script
1488 dangerouslySetInnerHTML={{
1489 __html: liveCommentBannerScript({
1490 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1491 bannerElementId: "live-comment-banner",
1492 }),
1493 }}
1494 />
829a046Claude1495 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1496 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1497 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1498 <div class="issue-detail">
58915a9Claude1499 {info && (
f7ad7b8Claude1500 <div class="issues-info-banner">
58915a9Claude1501 {decodeURIComponent(info)}
1502 </div>
1503 )}
f7ad7b8Claude1504
1505 <section class="issues-detail-hero">
1506 <h1 class="issues-detail-title">
1507 {issue.title}
1508 <span class="issues-detail-number">#{issue.number}</span>
1509 </h1>
1510 <div class="issues-detail-attr">
1511 <span
1512 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1513 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1514 >
f7ad7b8Claude1515 <span aria-hidden="true">
1516 {issue.state === "open" ? "\u25CB" : "\u2713"}
1517 </span>
1518 {issue.state === "open" ? "Open" : "Closed"}
1519 </span>
1520 <span>
1521 <strong>{author?.username || "unknown"}</strong> opened this
1522 issue {formatRelative(issue.createdAt)}
1523 </span>
1524 <span class="issues-detail-spacer" />
1525 {issue.state === "open" && user && user.id === resolved.owner.id && (
1526 <a
1527 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1528 class="btn btn-primary"
1529 style="font-size:13px;padding:6px 12px"
1530 title="Generate a draft pull request from this issue using Claude"
1531 >
1532 Build with AI
1533 </a>
1534 )}
c922868Claude1535 {issue.state === "open" && user && (
1536 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1537 <summary
1538 class="btn"
1539 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1540 title="Create a new branch for this issue"
1541 >
1542 Create branch
1543 </summary>
1544 <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)">
1545 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1546 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1547 <input
1548 type="text"
1549 name="branchName"
1550 class="input"
1551 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1552 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1553 pattern="[a-zA-Z0-9._\\-/]+"
1554 required
1555 />
1556 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1557 Create branch
1558 </button>
1559 </form>
1560 </div>
1561 </details>
1562 )}
f7ad7b8Claude1563 </div>
1564 </section>
1565
1566 <div class="issues-thread">
1567 {issue.body && (
1568 <article class="issues-comment">
1569 <header class="issues-comment-header">
1570 <strong>{author?.username || "unknown"}</strong>
1571 <span class="issues-comment-author-pill">Author</span>
1572 <span>commented {formatRelative(issue.createdAt)}</span>
1573 </header>
1574 <div class="issues-comment-body">
1575 <div
1576 class="markdown-body"
1577 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1578 />
1579 </div>
1580 </article>
fbf4aefClaude1581 )}
79136bbClaude1582
f7ad7b8Claude1583 {comments.map(({ comment, author: commentAuthor }) => {
1584 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1585 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1586 return (
cb5a796Claude1587 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1588 <header class="issues-comment-header">
1589 <strong>{commentAuthor.username}</strong>
a7460bfClaude1590 {commentAuthor.username === BOT_USERNAME && (
1591 <span class="issues-bot-badge">&#x1F916; bot</span>
1592 )}
f7ad7b8Claude1593 {isAi ? (
1594 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1595 AI Review
1596 </span>
1597 ) : null}
cb5a796Claude1598 {isPending ? (
1599 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1600 Awaiting approval
1601 </span>
1602 ) : null}
f7ad7b8Claude1603 <span>commented {formatRelative(comment.createdAt)}</span>
1604 </header>
1605 <div class="issues-comment-body">
1606 <div
1607 class="markdown-body"
1608 dangerouslySetInnerHTML={{
1609 __html: renderMarkdown(comment.body),
1610 }}
1611 />
1612 </div>
1613 </article>
1614 );
1615 })}
1616 </div>
79136bbClaude1617
240c477Claude1618 {linkedPrs.length > 0 && (
1619 <div class="iss-linked-prs">
1620 <div class="iss-linked-prs-head">
1621 <span>Linked pull requests</span>
1622 <span>{linkedPrs.length}</span>
1623 </div>
1624 {linkedPrs.map((lpr) => {
1625 const prState = lpr.isDraft ? "draft" : lpr.state;
1626 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1627 return (
1628 <a
1629 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1630 class="iss-linked-pr-row"
1631 >
1632 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1633 <span class="iss-linked-pr-title">{lpr.title}</span>
1634 <span class="iss-linked-pr-num">#{lpr.number}</span>
1635 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1636 </a>
1637 );
1638 })}
1639 </div>
1640 )}
1641
79136bbClaude1642 {user && (
f7ad7b8Claude1643 <form
1644 class="issues-composer"
1645 method="post"
1646 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1647 >
1648 <div class="issues-composer-header">
1649 <span class="issues-composer-tag">Add a comment</span>
1650 <span class="issues-composer-hint">
1651 <a
1652 href="https://docs.github.com/en/get-started/writing-on-github"
1653 target="_blank"
1654 rel="noopener noreferrer"
1655 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1656 >
1657 Markdown supported
1658 </a>
1659 </span>
1660 </div>
1661 <textarea
1662 name="body"
1663 rows={6}
1664 required
6cd2f0eClaude1665 data-md-preview=""
f7ad7b8Claude1666 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1667 />
1668 <div class="issues-composer-actions">
1669 <button type="submit" class="btn btn-primary">
1670 Comment
1671 </button>
1672 {canManage && (
1673 <button
1674 type="submit"
1675 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1676 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1677 >
1678 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1679 </button>
f7ad7b8Claude1680 )}
1681 {canManage && issue.state === "open" && (
1682 <button
1683 type="submit"
1684 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1685 formnovalidate
1686 class="btn"
1687 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1688 >
1689 Re-run AI triage
1690 </button>
1691 )}
1692 </div>
1693 </form>
79136bbClaude1694 )}
1695 </div>
1696 </Layout>
1697 );
1698});
1699
cb5a796Claude1700// Add comment.
1701//
1702// Permission model: we accept comments from ANY authenticated user (read
1703// access required — public repos satisfy that, private repos still need a
1704// collaborator row). The moderation gate in `decideInitialStatus`
1705// determines whether the comment is published immediately or queued for
1706// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1707// "anti-impersonation" rationale.
79136bbClaude1708issueRoutes.post(
1709 "/:owner/:repo/issues/:number/comment",
1710 softAuth,
1711 requireAuth,
cb5a796Claude1712 requireRepoAccess("read"),
79136bbClaude1713 async (c) => {
1714 const { owner: ownerName, repo: repoName } = c.req.param();
1715 const issueNum = parseInt(c.req.param("number"), 10);
1716 const user = c.get("user")!;
1717 const body = await c.req.parseBody();
1718 const commentBody = String(body.body || "").trim();
1719
1720 if (!commentBody) {
1721 return c.redirect(
1722 `/${ownerName}/${repoName}/issues/${issueNum}`
1723 );
1724 }
1725
1726 const resolved = await resolveRepo(ownerName, repoName);
1727 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1728
1729 const [issue] = await db
1730 .select()
1731 .from(issues)
1732 .where(
1733 and(
1734 eq(issues.repositoryId, resolved.repo.id),
1735 eq(issues.number, issueNum)
1736 )
1737 )
1738 .limit(1);
1739
1740 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1741
cb5a796Claude1742 // Decide moderation status BEFORE insert so the row lands in the right
1743 // initial state. Collaborators, the issue author, and trusted users
1744 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1745 // else gets 'pending'.
1746 const decision = await decideInitialStatus({
1747 commenterUserId: user.id,
1748 repositoryId: resolved.repo.id,
1749 kind: "issue",
1750 threadId: issue.id,
1751 });
1752
d4ac5c3Claude1753 const [inserted] = await db
1754 .insert(issueComments)
1755 .values({
1756 issueId: issue.id,
1757 authorId: user.id,
1758 body: commentBody,
cb5a796Claude1759 moderationStatus: decision.status,
d4ac5c3Claude1760 })
1761 .returning();
1762
cb5a796Claude1763 // Live update only when visible. Don't leak pending/rejected via SSE.
1764 if (inserted && decision.status === "approved") {
d4ac5c3Claude1765 try {
1766 const { publish } = await import("../lib/sse");
1767 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1768 event: "issue-comment",
1769 data: {
1770 issueId: issue.id,
1771 commentId: inserted.id,
1772 authorId: user.id,
1773 authorUsername: user.username,
1774 },
1775 });
1776 } catch {
1777 /* SSE is best-effort */
1778 }
b7ecb14Claude1779 // Notify the issue author — fire-and-forget, never blocks the response.
1780 if (issue.authorId && issue.authorId !== user.id) {
1781 void import("../lib/notify").then(({ createNotification }) =>
1782 createNotification({
1783 userId: issue.authorId,
1784 type: "issue_comment",
1785 title: `New comment on "${issue.title}"`,
1786 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
1787 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
1788 repoId: resolved.repo.id,
1789 })
1790 ).catch(() => { /* never block the response */ });
1791 }
d4ac5c3Claude1792 }
79136bbClaude1793
cb5a796Claude1794 if (decision.status === "pending") {
1795 // Notify repo owner that a comment is awaiting review.
1796 void notifyOwnerOfPendingComment({
1797 repositoryId: resolved.repo.id,
1798 commenterUsername: user.username,
1799 kind: "issue",
1800 threadNumber: issueNum,
1801 ownerUsername: ownerName,
1802 repoName,
1803 });
1804 return c.redirect(
1805 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1806 );
1807 }
1808 if (decision.status === "rejected") {
1809 // Silent ban — the commenter is on the banned list. Don't reveal
1810 // the gate; just send them back to the page as if it posted.
1811 return c.redirect(
1812 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1813 );
1814 }
1815
79136bbClaude1816 return c.redirect(
1817 `/${ownerName}/${repoName}/issues/${issueNum}`
1818 );
1819 }
1820);
1821
1822// Close issue
1823issueRoutes.post(
1824 "/:owner/:repo/issues/:number/close",
1825 softAuth,
1826 requireAuth,
04f6b7fClaude1827 requireRepoAccess("write"),
79136bbClaude1828 async (c) => {
1829 const { owner: ownerName, repo: repoName } = c.req.param();
1830 const issueNum = parseInt(c.req.param("number"), 10);
1831
1832 const resolved = await resolveRepo(ownerName, repoName);
1833 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1834
1835 await db
1836 .update(issues)
1837 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1838 .where(
1839 and(
1840 eq(issues.repositoryId, resolved.repo.id),
1841 eq(issues.number, issueNum)
1842 )
1843 );
1844
1845 return c.redirect(
1846 `/${ownerName}/${repoName}/issues/${issueNum}`
1847 );
1848 }
1849);
1850
1851// Reopen issue
1852issueRoutes.post(
1853 "/:owner/:repo/issues/:number/reopen",
1854 softAuth,
1855 requireAuth,
04f6b7fClaude1856 requireRepoAccess("write"),
79136bbClaude1857 async (c) => {
1858 const { owner: ownerName, repo: repoName } = c.req.param();
1859 const issueNum = parseInt(c.req.param("number"), 10);
1860
1861 const resolved = await resolveRepo(ownerName, repoName);
1862 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1863
1864 await db
1865 .update(issues)
1866 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1867 .where(
1868 and(
1869 eq(issues.repositoryId, resolved.repo.id),
1870 eq(issues.number, issueNum)
1871 )
1872 );
1873
1874 return c.redirect(
1875 `/${ownerName}/${repoName}/issues/${issueNum}`
1876 );
1877 }
1878);
1879
1880// Shared nav component with issues tab
1881const IssueNav = ({
1882 owner,
1883 repo,
1884 active,
1885}: {
1886 owner: string;
1887 repo: string;
1888 active: "code" | "commits" | "issues";
1889}) => (
bb0f894Claude1890 <TabNav
1891 tabs={[
1892 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1893 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1894 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1895 ]}
1896 />
79136bbClaude1897);
1898
58915a9Claude1899// Re-run AI triage on demand (e.g. after the issue body has been edited).
1900// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1901issueRoutes.post(
1902 "/:owner/:repo/issues/:number/ai-retriage",
1903 softAuth,
1904 requireAuth,
1905 requireRepoAccess("write"),
1906 async (c) => {
1907 const { owner: ownerName, repo: repoName } = c.req.param();
1908 const issueNum = parseInt(c.req.param("number"), 10);
1909 const user = c.get("user")!;
1910 const resolved = await resolveRepo(ownerName, repoName);
1911 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1912
1913 const [issue] = await db
1914 .select()
1915 .from(issues)
1916 .where(
1917 and(
1918 eq(issues.repositoryId, resolved.repo.id),
1919 eq(issues.number, issueNum)
1920 )
1921 )
1922 .limit(1);
1923 if (!issue) {
1924 return c.redirect(`/${ownerName}/${repoName}/issues`);
1925 }
1926
1927 triggerIssueTriage(
1928 {
1929 ownerName,
1930 repoName,
1931 repositoryId: resolved.repo.id,
1932 issueId: issue.id,
1933 issueNumber: issue.number,
1934 authorId: user.id,
1935 title: issue.title,
1936 body: issue.body || "",
1937 },
1938 { force: true }
a28cedeClaude1939 ).catch((err) => {
1940 console.warn(
1941 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1942 err instanceof Error ? err.message : err
1943 );
1944 });
58915a9Claude1945
1946 return c.redirect(
1947 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1948 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1949 )}`
1950 );
1951 }
1952);
1953
c922868Claude1954// ─── Create branch from issue ─────────────────────────────────────────────────
1955// POST /:owner/:repo/issues/:number/branch
1956// Creates a new git branch from the repo default branch, pre-named after the
1957// issue. Write access required. Zero-config — no new DB tables.
1958
1959issueRoutes.post(
1960 "/:owner/:repo/issues/:number/branch",
1961 softAuth,
1962 requireAuth,
1963 requireRepoAccess("write"),
1964 async (c) => {
1965 const { owner: ownerName, repo: repoName } = c.req.param();
1966 const issueNum = parseInt(c.req.param("number"), 10);
1967
1968 const resolved = await resolveRepo(ownerName, repoName);
1969 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
1970
1971 const [issue] = await db
1972 .select({ id: issues.id, number: issues.number, title: issues.title })
1973 .from(issues)
1974 .where(
1975 and(
1976 eq(issues.repositoryId, resolved.repo.id),
1977 eq(issues.number, issueNum)
1978 )
1979 )
1980 .limit(1);
1981
1982 if (!issue) {
1983 return c.redirect(`/${ownerName}/${repoName}/issues`);
1984 }
1985
1986 const body = await c.req.formData().catch(() => null);
1987 const rawName = (body?.get("branchName") as string | null)?.trim();
1988
1989 // Derive a slug from the issue title
1990 const titleSlug = issue.title
1991 .toLowerCase()
1992 .replace(/[^a-z0-9]+/g, "-")
1993 .replace(/^-+|-+$/g, "")
1994 .slice(0, 40);
1995 const suggested = `issue-${issue.number}-${titleSlug}`;
1996 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
1997 ? rawName
1998 : suggested;
1999
2000 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2001 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2002
2003 if (!sha) {
2004 return c.redirect(
2005 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2006 "Cannot create branch: repository has no commits yet."
2007 )}`
2008 );
2009 }
2010
2011 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2012 if (!ok) {
2013 return c.redirect(
2014 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2015 `Failed to create branch '${branchName}'. It may already exist.`
2016 )}`
2017 );
2018 }
2019
2020 return c.redirect(
2021 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2022 `Branch '${branchName}' created from ${defaultBranch}.`
2023 )}`
2024 );
2025 }
2026);
2027
79136bbClaude2028export default issueRoutes;
2029export { IssueNav };