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.tsxBlame1924 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
240c477Claude6import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
79136bbClaude7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
240c477Claude15 pullRequests,
79136bbClaude16} from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude19import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude20import { ReactionsBar } from "../views/reactions";
21import { summariseReactions } from "../lib/reactions";
24cf2caClaude22import { loadIssueTemplate } from "../lib/templates";
79136bbClaude23import { renderMarkdown } from "../lib/markdown";
b584e52Claude24import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude25import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude26import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude27import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude28import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude31import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude32import {
33 decideInitialStatus,
34 notifyOwnerOfPendingComment,
35 countPendingForRepo,
36} from "../lib/comment-moderation";
bb0f894Claude37import {
38 Flex,
39 Container,
40 PageHeader,
41 Form,
42 FormGroup,
43 Input,
44 TextArea,
45 Button,
46 LinkButton,
47 Badge,
48 EmptyState,
49 TabNav,
50 FilterTabs,
51 List,
52 ListItem,
53 Alert,
54 CommentBox,
55 CommentForm,
56 formatRelative,
57} from "../views/ui";
c922868Claude58import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
79136bbClaude59
60const issueRoutes = new Hono<AuthEnv>();
61
f7ad7b8Claude62// ---------------------------------------------------------------------------
63// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
64// other routes/shared views. All design tokens come from :root in layout.tsx.
65// ---------------------------------------------------------------------------
66const issuesStyles = `
67 /* Hero card — list page */
68 .issues-hero {
69 position: relative;
70 margin: 4px 0 24px;
71 padding: 28px 32px;
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 16px;
75 overflow: hidden;
76 }
77 .issues-hero::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
83 opacity: 0.7;
84 pointer-events: none;
85 }
86 .issues-hero-bg {
87 position: absolute;
88 inset: -30% -10% auto auto;
89 width: 360px;
90 height: 360px;
91 pointer-events: none;
92 z-index: 0;
93 }
94 .issues-hero-orb {
95 position: absolute;
96 inset: 0;
97 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
98 filter: blur(80px);
99 opacity: 0.7;
100 animation: issuesHeroOrb 14s ease-in-out infinite;
101 }
102 @keyframes issuesHeroOrb {
103 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
104 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
105 }
106 @media (prefers-reduced-motion: reduce) {
107 .issues-hero-orb { animation: none; }
108 }
109 .issues-hero-inner {
110 position: relative;
111 z-index: 1;
112 display: flex;
113 justify-content: space-between;
114 align-items: flex-end;
115 gap: 24px;
116 flex-wrap: wrap;
117 }
118 .issues-hero-text { flex: 1; min-width: 280px; }
119 .issues-hero-eyebrow {
120 font-size: 12.5px;
121 color: var(--text-muted);
122 margin-bottom: 8px;
123 letter-spacing: 0.04em;
124 text-transform: uppercase;
125 font-weight: 600;
126 }
127 .issues-hero-eyebrow .issues-hero-repo {
128 color: var(--accent);
129 text-transform: none;
130 letter-spacing: -0.005em;
131 font-weight: 600;
132 }
133 .issues-hero-title {
134 font-family: var(--font-display);
135 font-size: clamp(28px, 4vw, 40px);
136 font-weight: 800;
137 letter-spacing: -0.028em;
138 line-height: 1.05;
139 margin: 0 0 10px;
140 color: var(--text-strong);
141 }
142 .issues-hero-sub {
143 font-size: 15px;
144 color: var(--text-muted);
145 margin: 0;
146 line-height: 1.5;
147 max-width: 580px;
148 }
149 .issues-hero-actions {
150 display: flex;
151 gap: 8px;
152 flex-wrap: wrap;
153 }
154 @media (max-width: 720px) {
155 .issues-hero { padding: 24px 20px; }
156 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
157 .issues-hero-actions { width: 100%; }
158 .issues-hero-actions .btn { flex: 1; min-width: 0; }
159 }
160
f1dc7c7Claude161 /* Mobile rules — added in the 720px sweep. Kept additive only. */
162 @media (max-width: 720px) {
163 .issues-toolbar { flex-direction: column; align-items: stretch; }
164 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
165 .issues-filter { min-height: 40px; padding: 10px 14px; }
166 .issues-row { padding: 12px 14px; gap: 10px; }
167 .issues-row-side { flex-wrap: wrap; gap: 8px; }
168 .issues-detail-hero { padding: 18px; }
169 .issues-detail-attr { font-size: 13px; }
170 .issues-composer-actions { gap: 8px; }
171 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
172 .issues-empty { padding: 40px 20px; }
173 }
174
f7ad7b8Claude175 /* Count chip + filter pills */
176 .issues-toolbar {
177 display: flex;
178 align-items: center;
179 justify-content: space-between;
180 gap: 12px;
181 flex-wrap: wrap;
182 margin: 0 0 16px;
183 }
184 .issues-filters {
185 display: inline-flex;
186 background: var(--bg-elevated);
187 border: 1px solid var(--border);
188 border-radius: 9999px;
189 padding: 4px;
190 gap: 2px;
191 }
192 .issues-filter {
193 display: inline-flex;
194 align-items: center;
195 gap: 6px;
196 padding: 6px 14px;
197 border-radius: 9999px;
198 font-size: 13px;
199 font-weight: 500;
200 color: var(--text-muted);
201 text-decoration: none;
202 transition: color 120ms ease, background 120ms ease;
203 line-height: 1.4;
204 }
205 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
206 .issues-filter.is-active {
207 background: rgba(140,109,255,0.14);
208 color: var(--text-strong);
209 }
210 .issues-filter-count {
211 font-variant-numeric: tabular-nums;
212 font-size: 11.5px;
213 color: var(--text-muted);
214 background: rgba(255,255,255,0.04);
215 padding: 1px 7px;
216 border-radius: 9999px;
217 }
218 .issues-filter.is-active .issues-filter-count {
219 background: rgba(140,109,255,0.18);
220 color: var(--text);
221 }
222
7a28902Claude223 /* Issue search form */
224 .issues-search-form {
225 display: flex;
226 align-items: center;
227 gap: 0;
228 background: var(--bg-elevated);
229 border: 1px solid var(--border);
230 border-radius: 9999px;
231 overflow: hidden;
232 flex: 1;
233 max-width: 280px;
234 transition: border-color 120ms ease;
235 }
236 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
237 .issues-search-input {
238 flex: 1;
239 background: transparent;
240 color: var(--text);
241 border: none;
242 outline: none;
243 padding: 6px 14px;
244 font-size: 13px;
245 font-family: var(--font-sans, inherit);
246 min-width: 0;
247 }
248 .issues-search-input::placeholder { color: var(--text-muted); }
249 .issues-search-btn {
250 background: transparent;
251 border: none;
252 color: var(--text-muted);
253 padding: 6px 12px 6px 8px;
254 cursor: pointer;
255 display: flex;
256 align-items: center;
257 }
258 .issues-search-btn:hover { color: var(--text); }
259 .issues-filter-banner {
260 margin-bottom: 12px;
261 padding: 8px 14px;
262 background: rgba(140,109,255,0.06);
263 border: 1px solid rgba(140,109,255,0.2);
264 border-radius: 8px;
265 font-size: 13px;
266 color: var(--text-muted);
267 }
268 .issues-filter-clear {
269 color: var(--accent, #8c6dff);
270 text-decoration: underline;
271 cursor: pointer;
272 }
273 .issues-label-badge {
274 display: inline-block;
275 background: rgba(140,109,255,0.15);
276 color: #a78bfa;
277 border-radius: 9999px;
278 padding: 1px 8px;
279 font-size: 11.5px;
280 font-weight: 600;
281 }
282
f7ad7b8Claude283 /* Issue list — modernised rows */
284 .issues-list {
285 list-style: none;
286 margin: 0;
287 padding: 0;
288 border: 1px solid var(--border);
289 border-radius: 12px;
290 overflow: hidden;
291 background: var(--bg-elevated);
292 }
293 .issues-row {
294 display: flex;
295 align-items: flex-start;
296 gap: 14px;
297 padding: 14px 18px;
298 border-bottom: 1px solid var(--border);
299 transition: background 120ms ease, transform 120ms ease;
300 }
301 .issues-row:last-child { border-bottom: none; }
302 .issues-row:hover { background: rgba(140,109,255,0.04); }
303 .issues-row-icon {
304 width: 18px;
305 height: 18px;
306 flex-shrink: 0;
307 margin-top: 3px;
308 display: inline-flex;
309 align-items: center;
310 justify-content: center;
311 }
312 .issues-row-icon.is-open { color: #34d399; }
313 .issues-row-icon.is-closed { color: #b69dff; }
314 .issues-row-main { flex: 1; min-width: 0; }
315 .issues-row-title {
316 font-family: var(--font-display);
317 font-size: 15.5px;
318 font-weight: 600;
319 line-height: 1.35;
320 letter-spacing: -0.012em;
321 margin: 0;
322 display: flex;
323 flex-wrap: wrap;
324 align-items: center;
325 gap: 8px;
326 }
327 .issues-row-title a {
328 color: var(--text-strong);
329 text-decoration: none;
330 transition: color 120ms ease;
331 }
332 .issues-row-title a:hover { color: var(--accent); }
333 .issues-row-meta {
334 margin-top: 5px;
335 font-size: 12.5px;
336 color: var(--text-muted);
337 line-height: 1.5;
338 }
339 .issues-row-meta strong { color: var(--text); font-weight: 600; }
340 .issues-row-side {
341 display: flex;
342 align-items: center;
343 gap: 12px;
344 color: var(--text-muted);
345 font-size: 12.5px;
346 flex-shrink: 0;
347 }
348 .issues-row-comments {
349 display: inline-flex;
350 align-items: center;
351 gap: 5px;
352 color: var(--text-muted);
353 text-decoration: none;
354 }
355 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
356
357 /* Label pills (rendered inline on titles) */
358 .issues-label {
359 display: inline-flex;
360 align-items: center;
361 padding: 2px 9px;
362 border-radius: 9999px;
363 font-size: 11.5px;
364 font-weight: 600;
365 line-height: 1.4;
366 background: rgba(140,109,255,0.10);
367 color: var(--text-strong);
368 border: 1px solid rgba(140,109,255,0.28);
369 letter-spacing: 0.005em;
370 }
371
372 /* Polished empty state */
373 .issues-empty {
374 margin: 0;
375 padding: 56px 32px;
376 background: var(--bg-elevated);
377 border: 1px solid var(--border);
378 border-radius: 16px;
379 text-align: center;
380 position: relative;
381 overflow: hidden;
382 }
383 .issues-empty::before {
384 content: '';
385 position: absolute;
386 top: 0; left: 0; right: 0;
387 height: 2px;
388 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
389 opacity: 0.55;
390 pointer-events: none;
391 }
392 .issues-empty-art {
393 width: 96px;
394 height: 96px;
395 margin: 0 auto 18px;
396 display: block;
397 opacity: 0.85;
398 }
399 .issues-empty-title {
400 font-family: var(--font-display);
401 font-size: 22px;
402 font-weight: 700;
403 letter-spacing: -0.018em;
404 color: var(--text-strong);
405 margin: 0 0 8px;
406 }
407 .issues-empty-sub {
408 font-size: 14.5px;
409 color: var(--text-muted);
410 line-height: 1.55;
411 margin: 0 auto 22px;
412 max-width: 460px;
413 }
414 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
415
416 /* ─── Detail page ─── */
417 .issues-detail-hero {
418 position: relative;
419 margin: 4px 0 20px;
420 padding: 22px 26px;
421 background: var(--bg-elevated);
422 border: 1px solid var(--border);
423 border-radius: 16px;
424 overflow: hidden;
425 }
426 .issues-detail-hero::before {
427 content: '';
428 position: absolute;
429 top: 0; left: 0; right: 0;
430 height: 2px;
431 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
432 opacity: 0.7;
433 pointer-events: none;
434 }
435 .issues-detail-title {
436 font-family: var(--font-display);
437 font-size: clamp(22px, 3vw, 30px);
438 font-weight: 700;
439 letter-spacing: -0.022em;
440 line-height: 1.18;
441 color: var(--text-strong);
442 margin: 0 0 12px;
443 }
444 .issues-detail-title .issues-detail-number {
445 color: var(--text-muted);
446 font-weight: 500;
447 margin-left: 8px;
448 }
449 .issues-detail-attr {
450 display: flex;
451 align-items: center;
452 gap: 12px;
453 flex-wrap: wrap;
454 font-size: 14px;
455 color: var(--text-muted);
456 }
457 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
458 .issues-state-pill {
459 display: inline-flex;
460 align-items: center;
461 gap: 6px;
462 padding: 5px 12px;
463 border-radius: 9999px;
464 font-size: 12.5px;
465 font-weight: 600;
466 line-height: 1.4;
467 letter-spacing: 0.005em;
468 }
469 .issues-state-pill.is-open {
470 background: rgba(52,211,153,0.12);
471 color: #34d399;
472 border: 1px solid rgba(52,211,153,0.35);
473 }
474 .issues-state-pill.is-closed {
475 background: rgba(182,157,255,0.12);
476 color: #b69dff;
477 border: 1px solid rgba(182,157,255,0.35);
478 }
479 .issues-detail-spacer { flex: 1; }
480 .issues-detail-labels {
481 margin-top: 12px;
482 display: flex;
483 gap: 6px;
484 flex-wrap: wrap;
485 }
486
487 /* Comment thread */
488 .issues-thread { margin-top: 18px; }
489 .issues-comment {
490 position: relative;
491 border: 1px solid var(--border);
492 border-radius: 12px;
493 overflow: hidden;
494 background: var(--bg-elevated);
495 margin-bottom: 14px;
496 transition: border-color 160ms ease, box-shadow 160ms ease;
497 }
498 .issues-comment:hover {
499 border-color: var(--border-strong, rgba(255,255,255,0.13));
500 }
501 .issues-comment-header {
502 background: var(--bg-secondary);
503 padding: 10px 16px;
504 border-bottom: 1px solid var(--border);
505 display: flex;
506 align-items: center;
507 gap: 10px;
508 font-size: 13px;
509 color: var(--text-muted);
510 }
511 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
512 .issues-comment-body { padding: 14px 18px; }
513 .issues-comment-author-pill {
514 display: inline-flex;
515 align-items: center;
516 padding: 1px 8px;
517 border-radius: 9999px;
518 background: rgba(140,109,255,0.10);
519 color: var(--accent);
520 font-size: 11px;
521 font-weight: 600;
522 letter-spacing: 0.02em;
523 text-transform: uppercase;
524 }
525
526 /* AI Review comment — distinct purple-accent treatment */
527 .issues-comment.is-ai {
528 border-color: rgba(140,109,255,0.45);
529 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
530 }
531 .issues-comment.is-ai::before {
532 content: '';
533 position: absolute;
534 top: 0; left: 0; right: 0;
535 height: 2px;
536 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
537 pointer-events: none;
538 }
539 .issues-comment.is-ai .issues-comment-header {
540 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
541 border-bottom-color: rgba(140,109,255,0.22);
542 }
543 .issues-ai-badge {
544 display: inline-flex;
545 align-items: center;
546 gap: 5px;
547 padding: 2px 9px;
548 border-radius: 9999px;
549 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
550 color: #fff;
551 font-size: 11px;
552 font-weight: 700;
553 letter-spacing: 0.04em;
554 text-transform: uppercase;
555 line-height: 1.4;
556 }
557 .issues-ai-badge::before {
558 content: '';
559 width: 6px;
560 height: 6px;
561 border-radius: 9999px;
562 background: #fff;
563 opacity: 0.92;
564 box-shadow: 0 0 6px rgba(255,255,255,0.7);
565 }
566
567 /* Composer */
568 .issues-composer {
569 margin-top: 22px;
570 border: 1px solid var(--border);
571 border-radius: 12px;
572 overflow: hidden;
573 background: var(--bg-elevated);
574 transition: border-color 160ms ease, box-shadow 160ms ease;
575 }
576 .issues-composer:focus-within {
577 border-color: rgba(140,109,255,0.55);
578 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
579 }
580 .issues-composer-header {
581 display: flex;
582 align-items: center;
583 justify-content: space-between;
584 gap: 8px;
585 padding: 10px 14px;
586 background: var(--bg-secondary);
587 border-bottom: 1px solid var(--border);
588 font-size: 12.5px;
589 color: var(--text-muted);
590 }
591 .issues-composer-tag {
592 font-weight: 600;
593 color: var(--text);
594 letter-spacing: -0.005em;
595 }
596 .issues-composer-hint a {
597 color: var(--text-muted);
598 text-decoration: none;
599 border-bottom: 1px dashed currentColor;
600 }
601 .issues-composer-hint a:hover { color: var(--accent); }
602 .issues-composer textarea {
603 display: block;
604 width: 100%;
605 border: 0;
606 background: transparent;
607 color: var(--text);
608 padding: 14px 16px;
609 font-family: var(--font-mono);
610 font-size: 13.5px;
611 line-height: 1.55;
612 resize: vertical;
613 outline: none;
614 min-height: 140px;
615 }
616 .issues-composer-actions {
617 display: flex;
618 align-items: center;
619 gap: 8px;
620 padding: 10px 14px;
621 border-top: 1px solid var(--border);
622 background: var(--bg-secondary);
623 flex-wrap: wrap;
624 }
625
626 /* Info banner inside detail */
627 .issues-info-banner {
628 margin: 0 0 14px;
629 padding: 10px 14px;
630 border-radius: 12px;
631 background: rgba(140,109,255,0.08);
632 border: 1px solid rgba(140,109,255,0.28);
633 color: var(--text);
634 font-size: 13.5px;
635 }
240c477Claude636
637 /* ─── Linked PRs ─── */
638 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
639 .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; }
640 .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; }
641 .iss-linked-pr-row:last-child { border-bottom: none; }
642 .iss-linked-pr-row:hover { background: var(--bg-hover); }
643 .iss-linked-pr-icon.is-open { color: #34d399; }
644 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
645 .iss-linked-pr-icon.is-closed { color: #8b949e; }
646 .iss-linked-pr-icon.is-draft { color: #8b949e; }
647 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
648 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
649 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
650 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
651 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
652 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
653 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude654
8d1483cClaude655 /* ─── Bulk action UI ─── */
656 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
657 .bulk-bar {
658 display: none;
659 align-items: center;
660 gap: 10px;
661 padding: 10px 16px;
662 background: var(--bg-elevated);
663 border: 1px solid var(--border-strong);
664 border-radius: var(--r-md);
665 margin-bottom: 12px;
666 position: sticky;
667 top: calc(var(--header-h) + 8px);
668 z-index: 10;
669 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
670 }
671 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
672 .bulk-bar-btn {
673 padding: 6px 12px;
674 border-radius: var(--r-sm);
675 border: 1px solid var(--border);
676 background: var(--bg-surface);
677 color: var(--text);
678 font-size: 13px;
679 cursor: pointer;
680 transition: background 120ms;
681 }
682 .bulk-bar-btn:hover { background: var(--bg-hover); }
683 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
684 .bulk-bar-clear:hover { color: var(--text); }
685
f5b9ef5Claude686 /* ─── Sort controls (issue list) ─── */
687 .issues-sort-row {
688 display: flex;
689 align-items: center;
690 gap: 6px;
691 margin: 0 0 12px;
692 flex-wrap: wrap;
693 }
694 .issues-sort-label {
695 font-size: 12.5px;
696 color: var(--text-muted);
697 font-weight: 600;
698 margin-right: 2px;
699 }
700 .issues-sort-opt {
701 font-size: 12.5px;
702 color: var(--text-muted);
703 text-decoration: none;
704 padding: 3px 10px;
705 border-radius: 9999px;
706 border: 1px solid transparent;
707 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
708 }
709 .issues-sort-opt:hover {
710 background: var(--bg-hover);
711 color: var(--text);
712 }
713 .issues-sort-opt.is-active {
714 background: rgba(140,109,255,0.12);
715 color: var(--text-link);
716 border-color: rgba(140,109,255,0.35);
717 font-weight: 600;
718 }
f7ad7b8Claude719`;
720
721// Pre-rendered <style> tag (constant, reused per request).
722const IssuesStyle = () => (
723 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
724);
725
726// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
727const IssuesEmptySvg = () => (
728 <svg
729 class="issues-empty-art"
730 viewBox="0 0 96 96"
731 fill="none"
732 xmlns="http://www.w3.org/2000/svg"
733 aria-hidden="true"
734 >
735 <defs>
736 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
737 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
738 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
739 </linearGradient>
740 </defs>
741 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
742 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
743 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
744 </svg>
745);
746
747// Detect AI Triage comments by the marker the triage helper writes.
748function isAiTriageComment(body: string | null | undefined): boolean {
749 if (!body) return false;
750 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
751}
752
79136bbClaude753// Helper to resolve repo from :owner/:repo params
754async function resolveRepo(ownerName: string, repoName: string) {
755 const [owner] = await db
756 .select()
757 .from(users)
758 .where(eq(users.username, ownerName))
759 .limit(1);
760 if (!owner) return null;
761
762 const [repo] = await db
763 .select()
764 .from(repositories)
765 .where(
766 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
767 )
768 .limit(1);
769 if (!repo) return null;
770
771 return { owner, repo };
772}
773
8d1483cClaude774// Bulk issue operations (close / reopen multiple issues at once)
775issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
776 const { owner: ownerName, repo: repoName } = c.req.param();
777 const user = c.get("user")!;
778 const body = await c.req.parseBody();
779 const action = String(body.action || "");
780 const rawNumbers = body["numbers[]"];
781 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
782 .map(n => parseInt(String(n), 10))
783 .filter(n => !isNaN(n) && n > 0);
784
785 if (!numbers.length || !["close","reopen"].includes(action)) {
786 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
787 }
788
789 const resolved = await resolveRepo(ownerName, repoName);
790 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
791
792 const newState = action === "close" ? "closed" : "open";
793 await db.update(issues)
794 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
795 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
796
797 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
798 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
799});
800
79136bbClaude801// Issue list
04f6b7fClaude802issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude803 const { owner: ownerName, repo: repoName } = c.req.param();
804 const user = c.get("user");
805 const state = c.req.query("state") || "open";
7a28902Claude806 const searchQ = (c.req.query("q") || "").trim();
807 const labelFilter = (c.req.query("label") || "").trim();
f5b9ef5Claude808 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude809 // Bounded pagination — unbounded selects ran a full table scan + O(n)
810 // sort on every page load; with 10k+ issues the request would hang.
811 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
812 const page = Math.max(1, Number(c.req.query("page")) || 1);
813 const offset = (page - 1) * perPage;
79136bbClaude814
f1dc7c7Claude815 // ── Loading skeleton (flag-gated) ──
816 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
817 // user see the shape of the issue list before the DB count + select
818 // resolve. Behind a flag — we don't ship flashes.
819 if (c.req.query("skeleton") === "1") {
820 return c.html(
821 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
822 <IssuesStyle />
823 <RepoHeader owner={ownerName} repo={repoName} />
824 <IssueNav owner={ownerName} repo={repoName} active="issues" />
825 <style
826 dangerouslySetInnerHTML={{
827 __html: `
828 .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; }
829 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
830 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
831 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
832 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
833 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
834 .issues-skel-row { height: 62px; border-radius: 10px; }
835 `,
836 }}
837 />
838 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
839 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
840 <div class="issues-skel-list" aria-hidden="true">
841 {Array.from({ length: 8 }).map(() => (
842 <div class="issues-skel issues-skel-row" />
843 ))}
844 </div>
845 <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">
846 Loading issues for {ownerName}/{repoName}…
847 </span>
848 </Layout>
849 );
850 }
851
79136bbClaude852 const resolved = await resolveRepo(ownerName, repoName);
853 if (!resolved) {
854 return c.html(
855 <Layout title="Not Found" user={user}>
bb0f894Claude856 <EmptyState title="Repository not found" />
79136bbClaude857 </Layout>,
858 404
859 );
860 }
861
862 const { repo } = resolved;
863
7a28902Claude864 // If label filter is set, find issue IDs that have that label
865 let labelFilteredIds: string[] | null = null;
866 if (labelFilter) {
867 const [matchedLabel] = await db
868 .select({ id: labels.id })
869 .from(labels)
870 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
871 .limit(1);
872 if (matchedLabel) {
873 const rows = await db
874 .select({ issueId: issueLabels.issueId })
875 .from(issueLabels)
876 .where(eq(issueLabels.labelId, matchedLabel.id));
877 labelFilteredIds = rows.map((r) => r.issueId);
878 } else {
879 labelFilteredIds = []; // no matches
880 }
881 }
882
883 const baseWhere = and(
884 eq(issues.repositoryId, repo.id),
885 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
886 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
887 labelFilteredIds !== null && labelFilteredIds.length > 0
888 ? inArray(issues.id, labelFilteredIds)
889 : labelFilteredIds !== null && labelFilteredIds.length === 0
890 ? sql`false`
891 : undefined
892 );
893
79136bbClaude894 const issueList = await db
895 .select({
896 issue: issues,
897 author: { username: users.username },
898 })
899 .from(issues)
900 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude901 .where(baseWhere)
f5b9ef5Claude902 .orderBy(
903 sort === "oldest" ? asc(issues.createdAt)
904 : sort === "updated" ? desc(issues.updatedAt)
905 : desc(issues.createdAt) // newest (default)
906 )
6ea2109Claude907 .limit(perPage)
908 .offset(offset);
79136bbClaude909
910 // Count open/closed
911 const [counts] = await db
912 .select({
913 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
914 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
915 })
916 .from(issues)
917 .where(eq(issues.repositoryId, repo.id));
918
cb5a796Claude919 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
920 const pendingCountList = viewerIsOwnerOnList
921 ? await countPendingForRepo(repo.id)
922 : 0;
923
79136bbClaude924 return c.html(
925 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude926 <IssuesStyle />
79136bbClaude927 <RepoHeader owner={ownerName} repo={repoName} />
928 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude929 <PendingCommentsBanner
930 owner={ownerName}
931 repo={repoName}
932 count={pendingCountList}
933 />
f7ad7b8Claude934 <section class="issues-hero">
935 <div class="issues-hero-bg" aria-hidden="true">
936 <div class="issues-hero-orb" />
937 </div>
938 <div class="issues-hero-inner">
939 <div class="issues-hero-text">
940 <div class="issues-hero-eyebrow">
941 Issue tracker \u00B7{" "}
942 <span class="issues-hero-repo">
943 {ownerName}/{repoName}
944 </span>
945 </div>
946 <h1 class="issues-hero-title">
947 Track <span class="gradient-text">what matters</span>.
948 </h1>
949 <p class="issues-hero-sub">
950 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
951 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
952 : `${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.`}
953 </p>
954 </div>
955 <div class="issues-hero-actions">
956 {user && (
957 <a
958 href={`/${ownerName}/${repoName}/issues/new`}
959 class="btn btn-primary"
960 >
961 + New issue
962 </a>
963 )}
964 <a href={`/${ownerName}/${repoName}`} class="btn">
965 Back to code
966 </a>
967 </div>
968 </div>
969 </section>
970
971 <div class="issues-toolbar">
972 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
973 <a
974 class={`issues-filter${state === "open" ? " is-active" : ""}`}
975 href={`/${ownerName}/${repoName}/issues?state=open`}
976 role="tab"
977 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude978 >
f7ad7b8Claude979 <span aria-hidden="true">{"\u25CB"}</span>
980 <span>Open</span>
981 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
982 </a>
983 <a
984 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
985 href={`/${ownerName}/${repoName}/issues?state=closed`}
986 role="tab"
987 aria-selected={state === "closed" ? "true" : "false"}
988 >
989 <span aria-hidden="true">{"\u2713"}</span>
990 <span>Closed</span>
991 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
992 </a>
993 </div>
7a28902Claude994 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
995 <input type="hidden" name="state" value={state} />
996 <input
997 type="search"
998 name="q"
999 value={searchQ}
1000 placeholder="Search issues\u2026"
1001 class="issues-search-input"
1002 aria-label="Search issues by title"
1003 />
1004 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1005 <button type="submit" class="issues-search-btn" aria-label="Search">
1006 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1007 <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" />
1008 </svg>
1009 </button>
1010 </form>
f7ad7b8Claude1011 </div>
7a28902Claude1012 {(searchQ || labelFilter) && (
1013 <div class="issues-filter-banner">
1014 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1015 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1016 {" \u00B7 "}
1017 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1018 </div>
1019 )}
f7ad7b8Claude1020
f5b9ef5Claude1021 <div class="issues-sort-row">
1022 <span class="issues-sort-label">Sort:</span>
1023 {(["newest", "oldest", "updated"] as const).map((s) => (
1024 <a
1025 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1026 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1027 >
1028 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1029 </a>
1030 ))}
1031 </div>
1032
79136bbClaude1033 {issueList.length === 0 ? (
f7ad7b8Claude1034 <div class="issues-empty">
1035 <IssuesEmptySvg />
1036 <h2 class="issues-empty-title">
1037 {state === "closed"
1038 ? "No closed issues yet"
1039 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1040 ? "No issues \u2014 yet"
1041 : "Nothing open right now"}
1042 </h2>
1043 <p class="issues-empty-sub">
1044 {state === "closed"
1045 ? "Closed issues will show up here once the team starts shipping fixes."
1046 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1047 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1048 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1049 </p>
1050 <div class="issues-empty-cta">
1051 {user && state !== "closed" && (
1052 <a
1053 href={`/${ownerName}/${repoName}/issues/new`}
1054 class="btn btn-primary"
1055 >
1056 + Open the first issue
1057 </a>
1058 )}
79136bbClaude1059 {state === "closed" && (
f7ad7b8Claude1060 <a
1061 href={`/${ownerName}/${repoName}/issues?state=open`}
1062 class="btn"
1063 >
1064 View open issues
1065 </a>
79136bbClaude1066 )}
f7ad7b8Claude1067 </div>
1068 </div>
79136bbClaude1069 ) : (
8d1483cClaude1070 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1071 <input type="hidden" name="action" id="bulk-action" value="" />
1072 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1073 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1074 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1075 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1076 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1077 </div>
1078 <ul class="issues-list">
1079 {issueList.map(({ issue, author }) => (
1080 <li class="issues-row">
1081 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1082 <div
1083 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1084 aria-hidden="true"
1085 title={issue.state === "open" ? "Open" : "Closed"}
1086 >
1087 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1088 </div>
8d1483cClaude1089 <div class="issues-row-main">
1090 <h3 class="issues-row-title">
1091 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1092 {issue.title}
1093 </a>
1094 </h3>
1095 <div class="issues-row-meta">
1096 #{issue.number} opened by{" "}
1097 <strong>{author.username}</strong>{" "}
1098 {formatRelative(issue.createdAt)}
1099 </div>
1100 </div>
1101 </li>
1102 ))}
1103 </ul>
1104 <script dangerouslySetInnerHTML={{ __html: `
1105function bulkSubmit(action){
1106 document.getElementById('bulk-action').value=action;
1107 document.getElementById('bulk-form').submit();
1108}
1109function bulkClear(){
1110 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1111 updateBulkBar();
1112}
1113function updateBulkBar(){
1114 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1115 var bar=document.getElementById('bulk-bar');
1116 var cnt=document.getElementById('bulk-bar-count');
1117 if(bar){bar.style.display=checked>0?'flex':'none';}
1118 if(cnt){cnt.textContent=checked+' selected';}
1119}
1120document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1121` }} />
1122 </form>
79136bbClaude1123 )}
1124 </Layout>
1125 );
1126});
1127
1128// New issue form
1129issueRoutes.get(
1130 "/:owner/:repo/issues/new",
1131 softAuth,
1132 requireAuth,
04f6b7fClaude1133 requireRepoAccess("write"),
79136bbClaude1134 async (c) => {
1135 const { owner: ownerName, repo: repoName } = c.req.param();
1136 const user = c.get("user")!;
1137 const error = c.req.query("error");
24cf2caClaude1138 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1139
1140 return c.html(
1141 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1142 <IssuesStyle />
79136bbClaude1143 <RepoHeader owner={ownerName} repo={repoName} />
1144 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1145 <Container maxWidth={800}>
f7ad7b8Claude1146 <section class="issues-hero" style="margin-top:4px">
1147 <div class="issues-hero-bg" aria-hidden="true">
1148 <div class="issues-hero-orb" />
1149 </div>
1150 <div class="issues-hero-inner">
1151 <div class="issues-hero-text">
1152 <div class="issues-hero-eyebrow">
1153 New issue ·{" "}
1154 <span class="issues-hero-repo">
1155 {ownerName}/{repoName}
1156 </span>
1157 </div>
1158 <h1 class="issues-hero-title">
1159 File <span class="gradient-text">it cleanly</span>.
1160 </h1>
1161 <p class="issues-hero-sub">
1162 AI Triage will read the body the moment you submit and post
1163 suggested labels, priority, and possible duplicates within
1164 seconds. You stay in control — nothing is applied.
1165 </p>
1166 </div>
1167 </div>
1168 </section>
79136bbClaude1169 {error && (
bb0f894Claude1170 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1171 )}
0316dbbClaude1172 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1173 <FormGroup>
1174 <Input
79136bbClaude1175 type="text"
1176 name="title"
1177 required
1178 placeholder="Title"
bb0f894Claude1179 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1180 aria-label="Issue title"
79136bbClaude1181 />
bb0f894Claude1182 </FormGroup>
1183 <FormGroup>
1184 <TextArea
79136bbClaude1185 name="body"
1186 rows={12}
1187 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1188 mono
79136bbClaude1189 />
bb0f894Claude1190 </FormGroup>
1191 <Button type="submit" variant="primary">
79136bbClaude1192 Submit new issue
bb0f894Claude1193 </Button>
1194 </Form>
1195 </Container>
79136bbClaude1196 </Layout>
1197 );
1198 }
1199);
1200
1201// Create issue
1202issueRoutes.post(
1203 "/:owner/:repo/issues/new",
1204 softAuth,
1205 requireAuth,
04f6b7fClaude1206 requireRepoAccess("write"),
79136bbClaude1207 async (c) => {
1208 const { owner: ownerName, repo: repoName } = c.req.param();
1209 const user = c.get("user")!;
1210 const body = await c.req.parseBody();
1211 const title = String(body.title || "").trim();
1212 const issueBody = String(body.body || "").trim();
1213
1214 if (!title) {
1215 return c.redirect(
1216 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1217 );
1218 }
1219
1220 const resolved = await resolveRepo(ownerName, repoName);
1221 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1222
1223 const [issue] = await db
1224 .insert(issues)
1225 .values({
1226 repositoryId: resolved.repo.id,
1227 authorId: user.id,
1228 title,
1229 body: issueBody || null,
1230 })
1231 .returning();
1232
1233 // Update issue count
1234 await db
1235 .update(repositories)
1236 .set({ issueCount: resolved.repo.issueCount + 1 })
1237 .where(eq(repositories.id, resolved.repo.id));
1238
a9ada5fClaude1239 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1240 // suggested labels, priority, summary, and a possible-duplicate
1241 // callout. Suggestions only — nothing applied automatically.
1242 triggerIssueTriage({
1243 ownerName,
1244 repoName,
1245 repositoryId: resolved.repo.id,
1246 issueId: issue.id,
1247 issueNumber: issue.number,
1248 authorId: user.id,
1249 title,
1250 body: issueBody,
a28cedeClaude1251 }).catch((err) => {
1252 console.warn(
1253 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1254 err instanceof Error ? err.message : err
1255 );
1256 });
a9ada5fClaude1257
79136bbClaude1258 return c.redirect(
1259 `/${ownerName}/${repoName}/issues/${issue.number}`
1260 );
1261 }
1262);
1263
1264// View single issue
04f6b7fClaude1265issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1266 const { owner: ownerName, repo: repoName } = c.req.param();
1267 const issueNum = parseInt(c.req.param("number"), 10);
1268 const user = c.get("user");
1269
1270 const resolved = await resolveRepo(ownerName, repoName);
1271 if (!resolved) {
1272 return c.html(
1273 <Layout title="Not Found" user={user}>
bb0f894Claude1274 <EmptyState title="Not found" />
79136bbClaude1275 </Layout>,
1276 404
1277 );
1278 }
1279
1280 const [issue] = await db
1281 .select()
1282 .from(issues)
1283 .where(
1284 and(
1285 eq(issues.repositoryId, resolved.repo.id),
1286 eq(issues.number, issueNum)
1287 )
1288 )
1289 .limit(1);
1290
1291 if (!issue) {
1292 return c.html(
1293 <Layout title="Not Found" user={user}>
bb0f894Claude1294 <EmptyState title="Issue not found" />
79136bbClaude1295 </Layout>,
1296 404
1297 );
1298 }
1299
1300 const [author] = await db
1301 .select()
1302 .from(users)
1303 .where(eq(users.id, issue.authorId))
1304 .limit(1);
1305
cb5a796Claude1306 // Get comments. We pull every row and filter by moderation_status +
1307 // viewer identity client-side (in the JSX below) so a moderator/owner
1308 // sees them all while non-author viewers only ever see 'approved'.
1309 const allComments = await db
79136bbClaude1310 .select({
1311 comment: issueComments,
cb5a796Claude1312 author: { id: users.id, username: users.username },
79136bbClaude1313 })
1314 .from(issueComments)
1315 .innerJoin(users, eq(issueComments.authorId, users.id))
1316 .where(eq(issueComments.issueId, issue.id))
1317 .orderBy(asc(issueComments.createdAt));
1318
cb5a796Claude1319 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1320 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1321 // Owner always sees everything (so they can spot conversations going
1322 // sideways even before they hit the queue page).
1323 if (viewerIsOwner) return true;
1324 if (comment.moderationStatus === "approved") return true;
1325 // The comment's own author sees their pending comment so they know
1326 // it landed (with an "Awaiting approval" badge in the render below).
1327 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1328 return true;
1329 }
1330 return false;
1331 });
1332
1333 // Pending banner — when the viewer is the repo owner, show a strip at
1334 // the top of the page nudging them to the moderation queue. Inline on
1335 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1336 const pendingCount = viewerIsOwner
1337 ? await countPendingForRepo(resolved.repo.id)
1338 : 0;
1339
6fc53bdClaude1340 // Load reactions for the issue + each comment in parallel.
1341 const [issueReactions, ...commentReactions] = await Promise.all([
1342 summariseReactions("issue", issue.id, user?.id),
1343 ...comments.map((row) =>
1344 summariseReactions("issue_comment", row.comment.id, user?.id)
1345 ),
1346 ]);
1347
79136bbClaude1348 const canManage =
1349 user &&
1350 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1351 const info = c.req.query("info");
79136bbClaude1352
240c477Claude1353 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1354 const issueRefPattern = `%#${issue.number}%`;
1355 const linkedPrs = await db
1356 .select({
1357 number: pullRequests.number,
1358 title: pullRequests.title,
1359 state: pullRequests.state,
1360 isDraft: pullRequests.isDraft,
1361 })
1362 .from(pullRequests)
1363 .where(
1364 and(
1365 eq(pullRequests.repositoryId, resolved.repo.id),
1366 or(
1367 ilike(pullRequests.title, issueRefPattern),
1368 ilike(pullRequests.body, issueRefPattern),
1369 )
1370 )
1371 )
1372 .orderBy(desc(pullRequests.createdAt))
1373 .limit(8);
1374
79136bbClaude1375 return c.html(
1376 <Layout
1377 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1378 user={user}
1379 >
f7ad7b8Claude1380 <IssuesStyle />
79136bbClaude1381 <RepoHeader owner={ownerName} repo={repoName} />
1382 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1383 <PendingCommentsBanner
1384 owner={ownerName}
1385 repo={repoName}
1386 count={pendingCount}
1387 />
b584e52Claude1388 <div
1389 id="live-comment-banner"
1390 class="alert"
1391 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1392 >
1393 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1394 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1395 reload to view
1396 </a>
1397 </div>
1398 <script
1399 dangerouslySetInnerHTML={{
1400 __html: liveCommentBannerScript({
1401 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1402 bannerElementId: "live-comment-banner",
1403 }),
1404 }}
1405 />
829a046Claude1406 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1407 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1408 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1409 <div class="issue-detail">
58915a9Claude1410 {info && (
f7ad7b8Claude1411 <div class="issues-info-banner">
58915a9Claude1412 {decodeURIComponent(info)}
1413 </div>
1414 )}
f7ad7b8Claude1415
1416 <section class="issues-detail-hero">
1417 <h1 class="issues-detail-title">
1418 {issue.title}
1419 <span class="issues-detail-number">#{issue.number}</span>
1420 </h1>
1421 <div class="issues-detail-attr">
1422 <span
1423 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1424 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1425 >
f7ad7b8Claude1426 <span aria-hidden="true">
1427 {issue.state === "open" ? "\u25CB" : "\u2713"}
1428 </span>
1429 {issue.state === "open" ? "Open" : "Closed"}
1430 </span>
1431 <span>
1432 <strong>{author?.username || "unknown"}</strong> opened this
1433 issue {formatRelative(issue.createdAt)}
1434 </span>
1435 <span class="issues-detail-spacer" />
1436 {issue.state === "open" && user && user.id === resolved.owner.id && (
1437 <a
1438 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1439 class="btn btn-primary"
1440 style="font-size:13px;padding:6px 12px"
1441 title="Generate a draft pull request from this issue using Claude"
1442 >
1443 Build with AI
1444 </a>
1445 )}
c922868Claude1446 {issue.state === "open" && user && (
1447 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1448 <summary
1449 class="btn"
1450 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1451 title="Create a new branch for this issue"
1452 >
1453 Create branch
1454 </summary>
1455 <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)">
1456 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1457 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1458 <input
1459 type="text"
1460 name="branchName"
1461 class="input"
1462 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1463 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1464 pattern="[a-zA-Z0-9._\\-/]+"
1465 required
1466 />
1467 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1468 Create branch
1469 </button>
1470 </form>
1471 </div>
1472 </details>
1473 )}
f7ad7b8Claude1474 </div>
1475 </section>
1476
1477 <div class="issues-thread">
1478 {issue.body && (
1479 <article class="issues-comment">
1480 <header class="issues-comment-header">
1481 <strong>{author?.username || "unknown"}</strong>
1482 <span class="issues-comment-author-pill">Author</span>
1483 <span>commented {formatRelative(issue.createdAt)}</span>
1484 </header>
1485 <div class="issues-comment-body">
1486 <div
1487 class="markdown-body"
1488 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1489 />
1490 </div>
1491 </article>
fbf4aefClaude1492 )}
79136bbClaude1493
f7ad7b8Claude1494 {comments.map(({ comment, author: commentAuthor }) => {
1495 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1496 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1497 return (
cb5a796Claude1498 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1499 <header class="issues-comment-header">
1500 <strong>{commentAuthor.username}</strong>
1501 {isAi ? (
1502 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1503 AI Review
1504 </span>
1505 ) : null}
cb5a796Claude1506 {isPending ? (
1507 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1508 Awaiting approval
1509 </span>
1510 ) : null}
f7ad7b8Claude1511 <span>commented {formatRelative(comment.createdAt)}</span>
1512 </header>
1513 <div class="issues-comment-body">
1514 <div
1515 class="markdown-body"
1516 dangerouslySetInnerHTML={{
1517 __html: renderMarkdown(comment.body),
1518 }}
1519 />
1520 </div>
1521 </article>
1522 );
1523 })}
1524 </div>
79136bbClaude1525
240c477Claude1526 {linkedPrs.length > 0 && (
1527 <div class="iss-linked-prs">
1528 <div class="iss-linked-prs-head">
1529 <span>Linked pull requests</span>
1530 <span>{linkedPrs.length}</span>
1531 </div>
1532 {linkedPrs.map((lpr) => {
1533 const prState = lpr.isDraft ? "draft" : lpr.state;
1534 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1535 return (
1536 <a
1537 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1538 class="iss-linked-pr-row"
1539 >
1540 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1541 <span class="iss-linked-pr-title">{lpr.title}</span>
1542 <span class="iss-linked-pr-num">#{lpr.number}</span>
1543 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1544 </a>
1545 );
1546 })}
1547 </div>
1548 )}
1549
79136bbClaude1550 {user && (
f7ad7b8Claude1551 <form
1552 class="issues-composer"
1553 method="post"
1554 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1555 >
1556 <div class="issues-composer-header">
1557 <span class="issues-composer-tag">Add a comment</span>
1558 <span class="issues-composer-hint">
1559 <a
1560 href="https://docs.github.com/en/get-started/writing-on-github"
1561 target="_blank"
1562 rel="noopener noreferrer"
1563 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1564 >
1565 Markdown supported
1566 </a>
1567 </span>
1568 </div>
1569 <textarea
1570 name="body"
1571 rows={6}
1572 required
6cd2f0eClaude1573 data-md-preview=""
f7ad7b8Claude1574 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1575 />
1576 <div class="issues-composer-actions">
1577 <button type="submit" class="btn btn-primary">
1578 Comment
1579 </button>
1580 {canManage && (
1581 <button
1582 type="submit"
1583 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1584 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1585 >
1586 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1587 </button>
f7ad7b8Claude1588 )}
1589 {canManage && issue.state === "open" && (
1590 <button
1591 type="submit"
1592 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1593 formnovalidate
1594 class="btn"
1595 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1596 >
1597 Re-run AI triage
1598 </button>
1599 )}
1600 </div>
1601 </form>
79136bbClaude1602 )}
1603 </div>
1604 </Layout>
1605 );
1606});
1607
cb5a796Claude1608// Add comment.
1609//
1610// Permission model: we accept comments from ANY authenticated user (read
1611// access required — public repos satisfy that, private repos still need a
1612// collaborator row). The moderation gate in `decideInitialStatus`
1613// determines whether the comment is published immediately or queued for
1614// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1615// "anti-impersonation" rationale.
79136bbClaude1616issueRoutes.post(
1617 "/:owner/:repo/issues/:number/comment",
1618 softAuth,
1619 requireAuth,
cb5a796Claude1620 requireRepoAccess("read"),
79136bbClaude1621 async (c) => {
1622 const { owner: ownerName, repo: repoName } = c.req.param();
1623 const issueNum = parseInt(c.req.param("number"), 10);
1624 const user = c.get("user")!;
1625 const body = await c.req.parseBody();
1626 const commentBody = String(body.body || "").trim();
1627
1628 if (!commentBody) {
1629 return c.redirect(
1630 `/${ownerName}/${repoName}/issues/${issueNum}`
1631 );
1632 }
1633
1634 const resolved = await resolveRepo(ownerName, repoName);
1635 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1636
1637 const [issue] = await db
1638 .select()
1639 .from(issues)
1640 .where(
1641 and(
1642 eq(issues.repositoryId, resolved.repo.id),
1643 eq(issues.number, issueNum)
1644 )
1645 )
1646 .limit(1);
1647
1648 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1649
cb5a796Claude1650 // Decide moderation status BEFORE insert so the row lands in the right
1651 // initial state. Collaborators, the issue author, and trusted users
1652 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1653 // else gets 'pending'.
1654 const decision = await decideInitialStatus({
1655 commenterUserId: user.id,
1656 repositoryId: resolved.repo.id,
1657 kind: "issue",
1658 threadId: issue.id,
1659 });
1660
d4ac5c3Claude1661 const [inserted] = await db
1662 .insert(issueComments)
1663 .values({
1664 issueId: issue.id,
1665 authorId: user.id,
1666 body: commentBody,
cb5a796Claude1667 moderationStatus: decision.status,
d4ac5c3Claude1668 })
1669 .returning();
1670
cb5a796Claude1671 // Live update only when visible. Don't leak pending/rejected via SSE.
1672 if (inserted && decision.status === "approved") {
d4ac5c3Claude1673 try {
1674 const { publish } = await import("../lib/sse");
1675 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1676 event: "issue-comment",
1677 data: {
1678 issueId: issue.id,
1679 commentId: inserted.id,
1680 authorId: user.id,
1681 authorUsername: user.username,
1682 },
1683 });
1684 } catch {
1685 /* SSE is best-effort */
1686 }
1687 }
79136bbClaude1688
cb5a796Claude1689 if (decision.status === "pending") {
1690 // Notify repo owner that a comment is awaiting review.
1691 void notifyOwnerOfPendingComment({
1692 repositoryId: resolved.repo.id,
1693 commenterUsername: user.username,
1694 kind: "issue",
1695 threadNumber: issueNum,
1696 ownerUsername: ownerName,
1697 repoName,
1698 });
1699 return c.redirect(
1700 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1701 );
1702 }
1703 if (decision.status === "rejected") {
1704 // Silent ban — the commenter is on the banned list. Don't reveal
1705 // the gate; just send them back to the page as if it posted.
1706 return c.redirect(
1707 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1708 );
1709 }
1710
79136bbClaude1711 return c.redirect(
1712 `/${ownerName}/${repoName}/issues/${issueNum}`
1713 );
1714 }
1715);
1716
1717// Close issue
1718issueRoutes.post(
1719 "/:owner/:repo/issues/:number/close",
1720 softAuth,
1721 requireAuth,
04f6b7fClaude1722 requireRepoAccess("write"),
79136bbClaude1723 async (c) => {
1724 const { owner: ownerName, repo: repoName } = c.req.param();
1725 const issueNum = parseInt(c.req.param("number"), 10);
1726
1727 const resolved = await resolveRepo(ownerName, repoName);
1728 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1729
1730 await db
1731 .update(issues)
1732 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1733 .where(
1734 and(
1735 eq(issues.repositoryId, resolved.repo.id),
1736 eq(issues.number, issueNum)
1737 )
1738 );
1739
1740 return c.redirect(
1741 `/${ownerName}/${repoName}/issues/${issueNum}`
1742 );
1743 }
1744);
1745
1746// Reopen issue
1747issueRoutes.post(
1748 "/:owner/:repo/issues/:number/reopen",
1749 softAuth,
1750 requireAuth,
04f6b7fClaude1751 requireRepoAccess("write"),
79136bbClaude1752 async (c) => {
1753 const { owner: ownerName, repo: repoName } = c.req.param();
1754 const issueNum = parseInt(c.req.param("number"), 10);
1755
1756 const resolved = await resolveRepo(ownerName, repoName);
1757 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1758
1759 await db
1760 .update(issues)
1761 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1762 .where(
1763 and(
1764 eq(issues.repositoryId, resolved.repo.id),
1765 eq(issues.number, issueNum)
1766 )
1767 );
1768
1769 return c.redirect(
1770 `/${ownerName}/${repoName}/issues/${issueNum}`
1771 );
1772 }
1773);
1774
1775// Shared nav component with issues tab
1776const IssueNav = ({
1777 owner,
1778 repo,
1779 active,
1780}: {
1781 owner: string;
1782 repo: string;
1783 active: "code" | "commits" | "issues";
1784}) => (
bb0f894Claude1785 <TabNav
1786 tabs={[
1787 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1788 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1789 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1790 ]}
1791 />
79136bbClaude1792);
1793
58915a9Claude1794// Re-run AI triage on demand (e.g. after the issue body has been edited).
1795// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1796issueRoutes.post(
1797 "/:owner/:repo/issues/:number/ai-retriage",
1798 softAuth,
1799 requireAuth,
1800 requireRepoAccess("write"),
1801 async (c) => {
1802 const { owner: ownerName, repo: repoName } = c.req.param();
1803 const issueNum = parseInt(c.req.param("number"), 10);
1804 const user = c.get("user")!;
1805 const resolved = await resolveRepo(ownerName, repoName);
1806 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1807
1808 const [issue] = await db
1809 .select()
1810 .from(issues)
1811 .where(
1812 and(
1813 eq(issues.repositoryId, resolved.repo.id),
1814 eq(issues.number, issueNum)
1815 )
1816 )
1817 .limit(1);
1818 if (!issue) {
1819 return c.redirect(`/${ownerName}/${repoName}/issues`);
1820 }
1821
1822 triggerIssueTriage(
1823 {
1824 ownerName,
1825 repoName,
1826 repositoryId: resolved.repo.id,
1827 issueId: issue.id,
1828 issueNumber: issue.number,
1829 authorId: user.id,
1830 title: issue.title,
1831 body: issue.body || "",
1832 },
1833 { force: true }
a28cedeClaude1834 ).catch((err) => {
1835 console.warn(
1836 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1837 err instanceof Error ? err.message : err
1838 );
1839 });
58915a9Claude1840
1841 return c.redirect(
1842 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1843 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1844 )}`
1845 );
1846 }
1847);
1848
c922868Claude1849// ─── Create branch from issue ─────────────────────────────────────────────────
1850// POST /:owner/:repo/issues/:number/branch
1851// Creates a new git branch from the repo default branch, pre-named after the
1852// issue. Write access required. Zero-config — no new DB tables.
1853
1854issueRoutes.post(
1855 "/:owner/:repo/issues/:number/branch",
1856 softAuth,
1857 requireAuth,
1858 requireRepoAccess("write"),
1859 async (c) => {
1860 const { owner: ownerName, repo: repoName } = c.req.param();
1861 const issueNum = parseInt(c.req.param("number"), 10);
1862
1863 const resolved = await resolveRepo(ownerName, repoName);
1864 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
1865
1866 const [issue] = await db
1867 .select({ id: issues.id, number: issues.number, title: issues.title })
1868 .from(issues)
1869 .where(
1870 and(
1871 eq(issues.repositoryId, resolved.repo.id),
1872 eq(issues.number, issueNum)
1873 )
1874 )
1875 .limit(1);
1876
1877 if (!issue) {
1878 return c.redirect(`/${ownerName}/${repoName}/issues`);
1879 }
1880
1881 const body = await c.req.formData().catch(() => null);
1882 const rawName = (body?.get("branchName") as string | null)?.trim();
1883
1884 // Derive a slug from the issue title
1885 const titleSlug = issue.title
1886 .toLowerCase()
1887 .replace(/[^a-z0-9]+/g, "-")
1888 .replace(/^-+|-+$/g, "")
1889 .slice(0, 40);
1890 const suggested = `issue-${issue.number}-${titleSlug}`;
1891 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
1892 ? rawName
1893 : suggested;
1894
1895 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
1896 const sha = await resolveRef(ownerName, repoName, defaultBranch);
1897
1898 if (!sha) {
1899 return c.redirect(
1900 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1901 "Cannot create branch: repository has no commits yet."
1902 )}`
1903 );
1904 }
1905
1906 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
1907 if (!ok) {
1908 return c.redirect(
1909 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1910 `Failed to create branch '${branchName}'. It may already exist.`
1911 )}`
1912 );
1913 }
1914
1915 return c.redirect(
1916 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
1917 `Branch '${branchName}' created from ${defaultBranch}.`
1918 )}`
1919 );
1920 }
1921);
1922
79136bbClaude1923export default issueRoutes;
1924export { IssueNav };