Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame2551 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
240c477Claude6import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
79136bbClaude7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
240c477Claude15 pullRequests,
85c4e13Claude16 milestones,
79136bbClaude17} from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude20import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude21import { ReactionsBar } from "../views/reactions";
22import { summariseReactions } from "../lib/reactions";
24cf2caClaude23import { loadIssueTemplate } from "../lib/templates";
79136bbClaude24import { renderMarkdown } from "../lib/markdown";
b584e52Claude25import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude26import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude27import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude28import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude29import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude32import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude33import {
34 decideInitialStatus,
35 notifyOwnerOfPendingComment,
36 countPendingForRepo,
37} from "../lib/comment-moderation";
bb0f894Claude38import {
39 Flex,
40 Container,
41 PageHeader,
42 Form,
43 FormGroup,
44 Input,
45 TextArea,
46 Button,
47 LinkButton,
48 Badge,
49 EmptyState,
50 TabNav,
51 FilterTabs,
52 List,
53 ListItem,
54 Alert,
55 CommentBox,
56 CommentForm,
57 formatRelative,
58} from "../views/ui";
c922868Claude59import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude60import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude61import { isAiAvailable } from "../lib/ai-client";
79136bbClaude62
63const issueRoutes = new Hono<AuthEnv>();
64
f7ad7b8Claude65// ---------------------------------------------------------------------------
66// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
67// other routes/shared views. All design tokens come from :root in layout.tsx.
68// ---------------------------------------------------------------------------
69const issuesStyles = `
70 /* Hero card — list page */
71 .issues-hero {
72 position: relative;
73 margin: 4px 0 24px;
74 padding: 28px 32px;
75 background: var(--bg-elevated);
76 border: 1px solid var(--border);
77 border-radius: 16px;
78 overflow: hidden;
79 }
80 .issues-hero::before {
81 content: '';
82 position: absolute;
83 top: 0; left: 0; right: 0;
84 height: 2px;
6fd5915Claude85 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude86 opacity: 0.7;
87 pointer-events: none;
88 }
89 .issues-hero-bg {
90 position: absolute;
91 inset: -30% -10% auto auto;
92 width: 360px;
93 height: 360px;
94 pointer-events: none;
95 z-index: 0;
96 }
97 .issues-hero-orb {
98 position: absolute;
99 inset: 0;
6fd5915Claude100 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
f7ad7b8Claude101 filter: blur(80px);
102 opacity: 0.7;
103 animation: issuesHeroOrb 14s ease-in-out infinite;
104 }
105 @keyframes issuesHeroOrb {
106 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
107 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
108 }
109 @media (prefers-reduced-motion: reduce) {
110 .issues-hero-orb { animation: none; }
111 }
112 .issues-hero-inner {
113 position: relative;
114 z-index: 1;
115 display: flex;
116 justify-content: space-between;
117 align-items: flex-end;
118 gap: 24px;
119 flex-wrap: wrap;
120 }
121 .issues-hero-text { flex: 1; min-width: 280px; }
122 .issues-hero-eyebrow {
123 font-size: 12.5px;
124 color: var(--text-muted);
125 margin-bottom: 8px;
126 letter-spacing: 0.04em;
127 text-transform: uppercase;
128 font-weight: 600;
129 }
130 .issues-hero-eyebrow .issues-hero-repo {
131 color: var(--accent);
132 text-transform: none;
133 letter-spacing: -0.005em;
134 font-weight: 600;
135 }
136 .issues-hero-title {
137 font-family: var(--font-display);
138 font-size: clamp(28px, 4vw, 40px);
139 font-weight: 800;
140 letter-spacing: -0.028em;
141 line-height: 1.05;
142 margin: 0 0 10px;
143 color: var(--text-strong);
144 }
145 .issues-hero-sub {
146 font-size: 15px;
147 color: var(--text-muted);
148 margin: 0;
149 line-height: 1.5;
150 max-width: 580px;
151 }
152 .issues-hero-actions {
153 display: flex;
154 gap: 8px;
155 flex-wrap: wrap;
156 }
157 @media (max-width: 720px) {
158 .issues-hero { padding: 24px 20px; }
159 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
160 .issues-hero-actions { width: 100%; }
161 .issues-hero-actions .btn { flex: 1; min-width: 0; }
162 }
163
f1dc7c7Claude164 /* Mobile rules — added in the 720px sweep. Kept additive only. */
165 @media (max-width: 720px) {
166 .issues-toolbar { flex-direction: column; align-items: stretch; }
167 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
168 .issues-filter { min-height: 40px; padding: 10px 14px; }
169 .issues-row { padding: 12px 14px; gap: 10px; }
170 .issues-row-side { flex-wrap: wrap; gap: 8px; }
171 .issues-detail-hero { padding: 18px; }
172 .issues-detail-attr { font-size: 13px; }
173 .issues-composer-actions { gap: 8px; }
174 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
175 .issues-empty { padding: 40px 20px; }
176 }
177
f7ad7b8Claude178 /* Count chip + filter pills */
179 .issues-toolbar {
180 display: flex;
181 align-items: center;
182 justify-content: space-between;
183 gap: 12px;
184 flex-wrap: wrap;
185 margin: 0 0 16px;
186 }
187 .issues-filters {
188 display: inline-flex;
189 background: var(--bg-elevated);
190 border: 1px solid var(--border);
191 border-radius: 9999px;
192 padding: 4px;
193 gap: 2px;
194 }
195 .issues-filter {
196 display: inline-flex;
197 align-items: center;
198 gap: 6px;
199 padding: 6px 14px;
200 border-radius: 9999px;
201 font-size: 13px;
202 font-weight: 500;
203 color: var(--text-muted);
204 text-decoration: none;
205 transition: color 120ms ease, background 120ms ease;
206 line-height: 1.4;
207 }
208 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
209 .issues-filter.is-active {
6fd5915Claude210 background: rgba(91,110,232,0.14);
f7ad7b8Claude211 color: var(--text-strong);
212 }
213 .issues-filter-count {
214 font-variant-numeric: tabular-nums;
215 font-size: 11.5px;
216 color: var(--text-muted);
217 background: rgba(255,255,255,0.04);
218 padding: 1px 7px;
219 border-radius: 9999px;
220 }
221 .issues-filter.is-active .issues-filter-count {
6fd5915Claude222 background: rgba(91,110,232,0.18);
f7ad7b8Claude223 color: var(--text);
224 }
225
7a28902Claude226 /* Issue search form */
227 .issues-search-form {
228 display: flex;
229 align-items: center;
230 gap: 0;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 9999px;
234 overflow: hidden;
235 flex: 1;
236 max-width: 280px;
237 transition: border-color 120ms ease;
238 }
6fd5915Claude239 .issues-search-form:focus-within { border-color: rgba(91,110,232,0.55); }
7a28902Claude240 .issues-search-input {
241 flex: 1;
242 background: transparent;
243 color: var(--text);
244 border: none;
245 outline: none;
246 padding: 6px 14px;
247 font-size: 13px;
248 font-family: var(--font-sans, inherit);
249 min-width: 0;
250 }
251 .issues-search-input::placeholder { color: var(--text-muted); }
252 .issues-search-btn {
253 background: transparent;
254 border: none;
255 color: var(--text-muted);
256 padding: 6px 12px 6px 8px;
257 cursor: pointer;
258 display: flex;
259 align-items: center;
260 }
261 .issues-search-btn:hover { color: var(--text); }
262 .issues-filter-banner {
263 margin-bottom: 12px;
264 padding: 8px 14px;
6fd5915Claude265 background: rgba(91,110,232,0.06);
266 border: 1px solid rgba(91,110,232,0.2);
7a28902Claude267 border-radius: 8px;
268 font-size: 13px;
269 color: var(--text-muted);
270 }
271 .issues-filter-clear {
6fd5915Claude272 color: var(--accent, #5b6ee8);
7a28902Claude273 text-decoration: underline;
274 cursor: pointer;
275 }
276 .issues-label-badge {
277 display: inline-block;
6fd5915Claude278 background: rgba(91,110,232,0.15);
7a28902Claude279 color: #a78bfa;
280 border-radius: 9999px;
281 padding: 1px 8px;
282 font-size: 11.5px;
283 font-weight: 600;
284 }
285
f7ad7b8Claude286 /* Issue list — modernised rows */
287 .issues-list {
288 list-style: none;
289 margin: 0;
290 padding: 0;
291 border: 1px solid var(--border);
292 border-radius: 12px;
293 overflow: hidden;
294 background: var(--bg-elevated);
295 }
296 .issues-row {
297 display: flex;
298 align-items: flex-start;
299 gap: 14px;
300 padding: 14px 18px;
301 border-bottom: 1px solid var(--border);
302 transition: background 120ms ease, transform 120ms ease;
303 }
304 .issues-row:last-child { border-bottom: none; }
6fd5915Claude305 .issues-row:hover { background: rgba(91,110,232,0.04); }
f7ad7b8Claude306 .issues-row-icon {
307 width: 18px;
308 height: 18px;
309 flex-shrink: 0;
310 margin-top: 3px;
311 display: inline-flex;
312 align-items: center;
313 justify-content: center;
314 }
315 .issues-row-icon.is-open { color: #34d399; }
6fd5915Claude316 .issues-row-icon.is-closed { color: #5b6ee8; }
f7ad7b8Claude317 .issues-row-main { flex: 1; min-width: 0; }
318 .issues-row-title {
319 font-family: var(--font-display);
320 font-size: 15.5px;
321 font-weight: 600;
322 line-height: 1.35;
323 letter-spacing: -0.012em;
324 margin: 0;
325 display: flex;
326 flex-wrap: wrap;
327 align-items: center;
328 gap: 8px;
329 }
330 .issues-row-title a {
331 color: var(--text-strong);
332 text-decoration: none;
333 transition: color 120ms ease;
334 }
335 .issues-row-title a:hover { color: var(--accent); }
336 .issues-row-meta {
337 margin-top: 5px;
338 font-size: 12.5px;
339 color: var(--text-muted);
340 line-height: 1.5;
341 }
342 .issues-row-meta strong { color: var(--text); font-weight: 600; }
343 .issues-row-side {
344 display: flex;
345 align-items: center;
346 gap: 12px;
347 color: var(--text-muted);
348 font-size: 12.5px;
349 flex-shrink: 0;
350 }
351 .issues-row-comments {
352 display: inline-flex;
353 align-items: center;
354 gap: 5px;
355 color: var(--text-muted);
356 text-decoration: none;
357 }
358 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
359
360 /* Label pills (rendered inline on titles) */
361 .issues-label {
362 display: inline-flex;
363 align-items: center;
364 padding: 2px 9px;
365 border-radius: 9999px;
366 font-size: 11.5px;
367 font-weight: 600;
368 line-height: 1.4;
6fd5915Claude369 background: rgba(91,110,232,0.10);
f7ad7b8Claude370 color: var(--text-strong);
6fd5915Claude371 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude372 letter-spacing: 0.005em;
373 }
374
375 /* Polished empty state */
376 .issues-empty {
377 margin: 0;
378 padding: 56px 32px;
379 background: var(--bg-elevated);
380 border: 1px solid var(--border);
381 border-radius: 16px;
382 text-align: center;
383 position: relative;
384 overflow: hidden;
385 }
386 .issues-empty::before {
387 content: '';
388 position: absolute;
389 top: 0; left: 0; right: 0;
390 height: 2px;
6fd5915Claude391 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude392 opacity: 0.55;
393 pointer-events: none;
394 }
395 .issues-empty-art {
396 width: 96px;
397 height: 96px;
398 margin: 0 auto 18px;
399 display: block;
400 opacity: 0.85;
401 }
402 .issues-empty-title {
403 font-family: var(--font-display);
404 font-size: 22px;
405 font-weight: 700;
406 letter-spacing: -0.018em;
407 color: var(--text-strong);
408 margin: 0 0 8px;
409 }
410 .issues-empty-sub {
411 font-size: 14.5px;
412 color: var(--text-muted);
413 line-height: 1.55;
414 margin: 0 auto 22px;
415 max-width: 460px;
416 }
417 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
418
419 /* ─── Detail page ─── */
420 .issues-detail-hero {
421 position: relative;
422 margin: 4px 0 20px;
423 padding: 22px 26px;
424 background: var(--bg-elevated);
425 border: 1px solid var(--border);
426 border-radius: 16px;
427 overflow: hidden;
428 }
429 .issues-detail-hero::before {
430 content: '';
431 position: absolute;
432 top: 0; left: 0; right: 0;
433 height: 2px;
6fd5915Claude434 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude435 opacity: 0.7;
436 pointer-events: none;
437 }
438 .issues-detail-title {
439 font-family: var(--font-display);
440 font-size: clamp(22px, 3vw, 30px);
441 font-weight: 700;
442 letter-spacing: -0.022em;
443 line-height: 1.18;
444 color: var(--text-strong);
445 margin: 0 0 12px;
446 }
447 .issues-detail-title .issues-detail-number {
448 color: var(--text-muted);
449 font-weight: 500;
450 margin-left: 8px;
451 }
452 .issues-detail-attr {
453 display: flex;
454 align-items: center;
455 gap: 12px;
456 flex-wrap: wrap;
457 font-size: 14px;
458 color: var(--text-muted);
459 }
460 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
461 .issues-state-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 6px;
465 padding: 5px 12px;
466 border-radius: 9999px;
467 font-size: 12.5px;
468 font-weight: 600;
469 line-height: 1.4;
470 letter-spacing: 0.005em;
471 }
472 .issues-state-pill.is-open {
473 background: rgba(52,211,153,0.12);
474 color: #34d399;
475 border: 1px solid rgba(52,211,153,0.35);
476 }
477 .issues-state-pill.is-closed {
478 background: rgba(182,157,255,0.12);
6fd5915Claude479 color: #5b6ee8;
f7ad7b8Claude480 border: 1px solid rgba(182,157,255,0.35);
481 }
482 .issues-detail-spacer { flex: 1; }
2c61840Claude483
484 /* Sub-issues / epics */
485 .issues-epic-crumb {
486 display: inline-flex;
487 align-items: center;
488 gap: 6px;
489 font-size: 12px;
490 color: var(--text-muted);
491 margin-bottom: var(--space-2);
492 }
493 .issues-epic-crumb a { color: var(--accent); text-decoration: none; }
494 .issues-epic-crumb a:hover { text-decoration: underline; }
495 .issues-sub-panel {
496 background: var(--bg-elevated);
497 border: 1px solid var(--border);
498 border-radius: 12px;
499 margin-bottom: var(--space-4);
500 overflow: hidden;
501 }
502 .issues-sub-panel-head {
503 display: flex;
504 align-items: center;
505 justify-content: space-between;
506 padding: var(--space-3) var(--space-4);
507 border-bottom: 1px solid var(--border);
508 font-size: 12px;
509 font-weight: 700;
510 text-transform: uppercase;
511 letter-spacing: 0.07em;
512 color: var(--text-muted);
513 }
514 .issues-sub-row {
515 display: flex;
516 align-items: center;
517 gap: var(--space-3);
518 padding: var(--space-2) var(--space-4);
519 font-size: 13px;
520 transition: background 120ms;
521 }
522 .issues-sub-row:hover { background: var(--bg-hover); }
523 .issues-sub-row-icon { font-size: 12px; flex-shrink: 0; }
524 .issues-sub-row-icon.is-open { color: #3fb950; }
525 .issues-sub-row-icon.is-closed { color: #5b6ee8; }
526 .issues-sub-row-title { flex: 1; min-width: 0; }
527 .issues-sub-row-title a { color: var(--text); text-decoration: none; font-weight: 500; }
528 .issues-sub-row-title a:hover { color: var(--accent); text-decoration: underline; }
529 .issues-sub-row-num { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
530 .issues-sub-row-author { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
531 /* Epic pill on the issue list */
532 .issues-tag-epic {
533 display: inline-flex; align-items: center; gap: 4px;
534 padding: 2px 7px;
535 font-size: 11px; font-weight: 600;
536 border-radius: 9999px;
537 background: rgba(210,153,34,0.12);
538 color: #d29922;
539 border: 1px solid rgba(210,153,34,0.35);
540 }
541 .issues-tag-child {
542 display: inline-flex; align-items: center; gap: 4px;
543 padding: 2px 7px;
544 font-size: 11px; font-weight: 600;
545 border-radius: 9999px;
546 background: rgba(88,166,255,0.10);
547 color: #58a6ff;
548 border: 1px solid rgba(88,166,255,0.25);
549 }
550
f7ad7b8Claude551 .issues-detail-labels {
552 margin-top: 12px;
553 display: flex;
554 gap: 6px;
555 flex-wrap: wrap;
556 }
557
558 /* Comment thread */
559 .issues-thread { margin-top: 18px; }
560 .issues-comment {
561 position: relative;
562 border: 1px solid var(--border);
563 border-radius: 12px;
564 overflow: hidden;
565 background: var(--bg-elevated);
566 margin-bottom: 14px;
567 transition: border-color 160ms ease, box-shadow 160ms ease;
568 }
569 .issues-comment:hover {
570 border-color: var(--border-strong, rgba(255,255,255,0.13));
571 }
572 .issues-comment-header {
573 background: var(--bg-secondary);
574 padding: 10px 16px;
575 border-bottom: 1px solid var(--border);
576 display: flex;
577 align-items: center;
578 gap: 10px;
579 font-size: 13px;
580 color: var(--text-muted);
581 }
582 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
583 .issues-comment-body { padding: 14px 18px; }
584 .issues-comment-author-pill {
585 display: inline-flex;
586 align-items: center;
587 padding: 1px 8px;
588 border-radius: 9999px;
6fd5915Claude589 background: rgba(91,110,232,0.10);
f7ad7b8Claude590 color: var(--accent);
591 font-size: 11px;
592 font-weight: 600;
593 letter-spacing: 0.02em;
594 text-transform: uppercase;
595 }
596
597 /* AI Review comment — distinct purple-accent treatment */
598 .issues-comment.is-ai {
6fd5915Claude599 border-color: rgba(91,110,232,0.45);
600 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 12px 32px -16px rgba(91,110,232,0.25);
f7ad7b8Claude601 }
602 .issues-comment.is-ai::before {
603 content: '';
604 position: absolute;
605 top: 0; left: 0; right: 0;
606 height: 2px;
6fd5915Claude607 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude608 pointer-events: none;
609 }
610 .issues-comment.is-ai .issues-comment-header {
6fd5915Claude611 background: linear-gradient(180deg, rgba(91,110,232,0.08), rgba(91,110,232,0.02));
612 border-bottom-color: rgba(91,110,232,0.22);
f7ad7b8Claude613 }
614 .issues-ai-badge {
615 display: inline-flex;
616 align-items: center;
617 gap: 5px;
618 padding: 2px 9px;
619 border-radius: 9999px;
6fd5915Claude620 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude621 color: #fff;
622 font-size: 11px;
623 font-weight: 700;
624 letter-spacing: 0.04em;
625 text-transform: uppercase;
626 line-height: 1.4;
627 }
628 .issues-ai-badge::before {
629 content: '';
630 width: 6px;
631 height: 6px;
632 border-radius: 9999px;
633 background: #fff;
634 opacity: 0.92;
635 box-shadow: 0 0 6px rgba(255,255,255,0.7);
636 }
637
a7460bfClaude638 .issues-bot-badge {
639 display: inline-flex; align-items: center; gap: 3px;
640 padding: 1px 7px;
641 font-size: 10px;
642 font-weight: 600;
643 color: var(--fg-muted);
644 background: var(--bg-elevated);
645 border: 1px solid var(--border);
646 border-radius: 9999px;
647 }
648
f7ad7b8Claude649 /* Composer */
650 .issues-composer {
651 margin-top: 22px;
652 border: 1px solid var(--border);
653 border-radius: 12px;
654 overflow: hidden;
655 background: var(--bg-elevated);
656 transition: border-color 160ms ease, box-shadow 160ms ease;
657 }
658 .issues-composer:focus-within {
6fd5915Claude659 border-color: rgba(91,110,232,0.55);
660 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
f7ad7b8Claude661 }
662 .issues-composer-header {
663 display: flex;
664 align-items: center;
665 justify-content: space-between;
666 gap: 8px;
667 padding: 10px 14px;
668 background: var(--bg-secondary);
669 border-bottom: 1px solid var(--border);
670 font-size: 12.5px;
671 color: var(--text-muted);
672 }
673 .issues-composer-tag {
674 font-weight: 600;
675 color: var(--text);
676 letter-spacing: -0.005em;
677 }
678 .issues-composer-hint a {
679 color: var(--text-muted);
680 text-decoration: none;
681 border-bottom: 1px dashed currentColor;
682 }
683 .issues-composer-hint a:hover { color: var(--accent); }
684 .issues-composer textarea {
685 display: block;
686 width: 100%;
687 border: 0;
688 background: transparent;
689 color: var(--text);
690 padding: 14px 16px;
691 font-family: var(--font-mono);
692 font-size: 13.5px;
693 line-height: 1.55;
694 resize: vertical;
695 outline: none;
696 min-height: 140px;
697 }
698 .issues-composer-actions {
699 display: flex;
700 align-items: center;
701 gap: 8px;
702 padding: 10px 14px;
703 border-top: 1px solid var(--border);
704 background: var(--bg-secondary);
705 flex-wrap: wrap;
706 }
707
708 /* Info banner inside detail */
709 .issues-info-banner {
710 margin: 0 0 14px;
711 padding: 10px 14px;
712 border-radius: 12px;
6fd5915Claude713 background: rgba(91,110,232,0.08);
714 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude715 color: var(--text);
716 font-size: 13.5px;
717 }
240c477Claude718
719 /* ─── Linked PRs ─── */
720 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
721 .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; }
722 .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; }
723 .iss-linked-pr-row:last-child { border-bottom: none; }
724 .iss-linked-pr-row:hover { background: var(--bg-hover); }
725 .iss-linked-pr-icon.is-open { color: #34d399; }
726 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
727 .iss-linked-pr-icon.is-closed { color: #8b949e; }
728 .iss-linked-pr-icon.is-draft { color: #8b949e; }
729 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
730 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
731 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
732 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
733 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
734 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
735 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude736
8d1483cClaude737 /* ─── Bulk action UI ─── */
738 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
739 .bulk-bar {
740 display: none;
741 align-items: center;
742 gap: 10px;
743 padding: 10px 16px;
744 background: var(--bg-elevated);
745 border: 1px solid var(--border-strong);
746 border-radius: var(--r-md);
747 margin-bottom: 12px;
748 position: sticky;
749 top: calc(var(--header-h) + 8px);
750 z-index: 10;
751 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
752 }
753 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
754 .bulk-bar-btn {
755 padding: 6px 12px;
756 border-radius: var(--r-sm);
757 border: 1px solid var(--border);
758 background: var(--bg-surface);
759 color: var(--text);
760 font-size: 13px;
761 cursor: pointer;
762 transition: background 120ms;
763 }
764 .bulk-bar-btn:hover { background: var(--bg-hover); }
765 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
766 .bulk-bar-clear:hover { color: var(--text); }
767
0224546Claude768 /* ─── Issue detail metadata sidebar ─── */
769 .issue-detail-layout {
770 display: flex;
771 align-items: flex-start;
772 gap: 24px;
773 }
774 .issue-detail-main {
775 flex: 1;
776 min-width: 0;
777 }
778 .issue-meta-sidebar {
779 width: 240px;
780 flex-shrink: 0;
781 display: flex;
782 flex-direction: column;
783 gap: 20px;
784 }
785 .issue-meta-section {
786 border-top: 1px solid var(--border);
787 padding-top: 14px;
788 }
789 .issue-meta-section:first-child {
790 border-top: none;
791 padding-top: 0;
792 }
793 .issue-meta-label {
794 font-size: 12px;
795 font-weight: 600;
796 color: var(--text-muted);
797 text-transform: uppercase;
798 letter-spacing: 0.06em;
799 margin-bottom: 8px;
800 }
801 .issue-meta-empty {
802 font-size: 13px;
803 color: var(--text-subtle, var(--text-muted));
804 font-style: italic;
805 }
806 .issue-meta-label-pill {
807 display: inline-flex;
808 align-items: center;
809 padding: 3px 10px;
810 border-radius: 9999px;
811 font-size: 12px;
812 font-weight: 600;
813 line-height: 1.4;
814 margin: 2px 3px 2px 0;
815 border: 1px solid transparent;
816 }
817 .issue-meta-labels {
818 display: flex;
819 flex-wrap: wrap;
820 gap: 4px;
821 }
822 .issue-meta-assignee {
823 display: flex;
824 align-items: center;
825 gap: 8px;
826 font-size: 13px;
827 color: var(--text);
828 }
829 .issue-meta-avatar {
830 width: 24px;
831 height: 24px;
832 border-radius: 9999px;
833 background: rgba(140,109,255,0.25);
834 color: var(--text-strong);
835 font-size: 11px;
836 font-weight: 700;
837 display: inline-flex;
838 align-items: center;
839 justify-content: center;
840 flex-shrink: 0;
841 text-transform: uppercase;
842 }
843 .issue-meta-milestone-link {
844 font-size: 13px;
845 color: var(--text-link, var(--accent));
846 text-decoration: none;
847 display: inline-flex;
848 align-items: center;
849 gap: 5px;
850 }
851 .issue-meta-milestone-link:hover { text-decoration: underline; }
852 @media (max-width: 720px) {
853 .issue-detail-layout { flex-direction: column; }
854 .issue-meta-sidebar { width: 100%; order: 1; }
855 .issue-detail-main { order: 0; }
856 }
857
f5b9ef5Claude858 /* ─── Sort controls (issue list) ─── */
859 .issues-sort-row {
860 display: flex;
861 align-items: center;
862 gap: 6px;
863 margin: 0 0 12px;
864 flex-wrap: wrap;
865 }
866 .issues-sort-label {
867 font-size: 12.5px;
868 color: var(--text-muted);
869 font-weight: 600;
870 margin-right: 2px;
871 }
872 .issues-sort-opt {
873 font-size: 12.5px;
874 color: var(--text-muted);
875 text-decoration: none;
876 padding: 3px 10px;
877 border-radius: 9999px;
878 border: 1px solid transparent;
879 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
880 }
881 .issues-sort-opt:hover {
882 background: var(--bg-hover);
883 color: var(--text);
884 }
885 .issues-sort-opt.is-active {
6fd5915Claude886 background: rgba(91,110,232,0.12);
f5b9ef5Claude887 color: var(--text-link);
6fd5915Claude888 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude889 font-weight: 600;
890 }
f7ad7b8Claude891`;
892
893// Pre-rendered <style> tag (constant, reused per request).
894const IssuesStyle = () => (
895 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
896);
897
898// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
899const IssuesEmptySvg = () => (
900 <svg
901 class="issues-empty-art"
902 viewBox="0 0 96 96"
903 fill="none"
904 xmlns="http://www.w3.org/2000/svg"
905 aria-hidden="true"
906 >
907 <defs>
908 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
6fd5915Claude909 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.55" />
910 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.55" />
f7ad7b8Claude911 </linearGradient>
912 </defs>
6fd5915Claude913 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(91,110,232,0.04)" />
f7ad7b8Claude914 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
915 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
916 </svg>
917);
918
919// Detect AI Triage comments by the marker the triage helper writes.
920function isAiTriageComment(body: string | null | undefined): boolean {
921 if (!body) return false;
922 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
923}
924
79136bbClaude925// Helper to resolve repo from :owner/:repo params
926async function resolveRepo(ownerName: string, repoName: string) {
927 const [owner] = await db
928 .select()
929 .from(users)
930 .where(eq(users.username, ownerName))
931 .limit(1);
932 if (!owner) return null;
933
934 const [repo] = await db
935 .select()
936 .from(repositories)
937 .where(
938 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
939 )
940 .limit(1);
941 if (!repo) return null;
942
943 return { owner, repo };
944}
945
8d1483cClaude946// Bulk issue operations (close / reopen multiple issues at once)
947issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
948 const { owner: ownerName, repo: repoName } = c.req.param();
949 const user = c.get("user")!;
950 const body = await c.req.parseBody();
951 const action = String(body.action || "");
952 const rawNumbers = body["numbers[]"];
953 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
954 .map(n => parseInt(String(n), 10))
955 .filter(n => !isNaN(n) && n > 0);
956
957 if (!numbers.length || !["close","reopen"].includes(action)) {
958 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
959 }
960
961 const resolved = await resolveRepo(ownerName, repoName);
962 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
963
964 const newState = action === "close" ? "closed" : "open";
965 await db.update(issues)
966 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
967 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
968
969 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
970 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
971});
972
79136bbClaude973// Issue list
04f6b7fClaude974issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude975 const { owner: ownerName, repo: repoName } = c.req.param();
976 const user = c.get("user");
977 const state = c.req.query("state") || "open";
7a28902Claude978 const searchQ = (c.req.query("q") || "").trim();
979 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude980 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude981 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude982 // Bounded pagination — unbounded selects ran a full table scan + O(n)
983 // sort on every page load; with 10k+ issues the request would hang.
984 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
985 const page = Math.max(1, Number(c.req.query("page")) || 1);
986 const offset = (page - 1) * perPage;
79136bbClaude987
f1dc7c7Claude988 // ── Loading skeleton (flag-gated) ──
989 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
990 // user see the shape of the issue list before the DB count + select
991 // resolve. Behind a flag — we don't ship flashes.
992 if (c.req.query("skeleton") === "1") {
993 return c.html(
994 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
995 <IssuesStyle />
996 <RepoHeader owner={ownerName} repo={repoName} />
997 <IssueNav owner={ownerName} repo={repoName} active="issues" />
998 <style
999 dangerouslySetInnerHTML={{
1000 __html: `
1001 .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; }
1002 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1003 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
1004 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
1005 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
1006 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
1007 .issues-skel-row { height: 62px; border-radius: 10px; }
1008 `,
1009 }}
1010 />
1011 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
1012 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
1013 <div class="issues-skel-list" aria-hidden="true">
1014 {Array.from({ length: 8 }).map(() => (
1015 <div class="issues-skel issues-skel-row" />
1016 ))}
1017 </div>
1018 <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">
1019 Loading issues for {ownerName}/{repoName}…
1020 </span>
1021 </Layout>
1022 );
1023 }
1024
79136bbClaude1025 const resolved = await resolveRepo(ownerName, repoName);
1026 if (!resolved) {
1027 return c.html(
1028 <Layout title="Not Found" user={user}>
bb0f894Claude1029 <EmptyState title="Repository not found" />
79136bbClaude1030 </Layout>,
1031 404
1032 );
1033 }
1034
1035 const { repo } = resolved;
1036
7a28902Claude1037 // If label filter is set, find issue IDs that have that label
1038 let labelFilteredIds: string[] | null = null;
1039 if (labelFilter) {
1040 const [matchedLabel] = await db
1041 .select({ id: labels.id })
1042 .from(labels)
1043 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
1044 .limit(1);
1045 if (matchedLabel) {
1046 const rows = await db
1047 .select({ issueId: issueLabels.issueId })
1048 .from(issueLabels)
1049 .where(eq(issueLabels.labelId, matchedLabel.id));
1050 labelFilteredIds = rows.map((r) => r.issueId);
1051 } else {
1052 labelFilteredIds = []; // no matches
1053 }
1054 }
1055
85c4e13Claude1056 // If milestone filter is set, resolve the milestone ID
1057 let milestoneFilterId: string | null = null;
1058 if (milestoneFilter) {
1059 const [matchedMs] = await db
1060 .select({ id: milestones.id })
1061 .from(milestones)
1062 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
1063 .limit(1);
1064 milestoneFilterId = matchedMs?.id ?? null;
1065 }
1066
7a28902Claude1067 const baseWhere = and(
1068 eq(issues.repositoryId, repo.id),
1069 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
1070 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
1071 labelFilteredIds !== null && labelFilteredIds.length > 0
1072 ? inArray(issues.id, labelFilteredIds)
1073 : labelFilteredIds !== null && labelFilteredIds.length === 0
1074 ? sql`false`
85c4e13Claude1075 : undefined,
1076 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude1077 );
1078
79136bbClaude1079 const issueList = await db
1080 .select({
1081 issue: issues,
1082 author: { username: users.username },
1083 })
1084 .from(issues)
1085 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude1086 .where(baseWhere)
f5b9ef5Claude1087 .orderBy(
1088 sort === "oldest" ? asc(issues.createdAt)
1089 : sort === "updated" ? desc(issues.updatedAt)
1090 : desc(issues.createdAt) // newest (default)
1091 )
6ea2109Claude1092 .limit(perPage)
1093 .offset(offset);
79136bbClaude1094
2c61840Claude1095 // Batch-load sub-issue counts for epics in this list page
1096 const issueIds = issueList.map((r) => r.issue.id);
1097 const subCountMap = new Map<string, number>();
1098 const parentNumberMap = new Map<string, number>(); // childIssueId → parentIssueNumber
1099 if (issueIds.length > 0) {
1100 const [subRows, parentRows] = await Promise.all([
1101 // Count children per parent (to show "N sub-issues" on epics)
1102 db
1103 .select({
1104 parentId: issues.parentIssueId,
1105 cnt: sql<number>`count(*)::int`,
1106 })
1107 .from(issues)
1108 .where(
1109 and(
1110 inArray(issues.parentIssueId, issueIds),
1111 eq(issues.repositoryId, repo.id)
1112 )
1113 )
1114 .groupBy(issues.parentIssueId),
1115 // Resolve parent numbers for children (to show "Part of #N")
1116 issueList
1117 .filter((r) => r.issue.parentIssueId !== null)
1118 .length > 0
1119 ? db
1120 .select({ id: issues.id, number: issues.number })
1121 .from(issues)
1122 .where(
1123 inArray(
1124 issues.id,
1125 issueList
1126 .filter((r) => r.issue.parentIssueId !== null)
1127 .map((r) => r.issue.parentIssueId as string)
1128 )
1129 )
1130 : Promise.resolve([]),
1131 ]);
1132 subRows.forEach((r) => {
1133 if (r.parentId) subCountMap.set(r.parentId, Number(r.cnt));
1134 });
1135 parentRows.forEach((p) => parentNumberMap.set(p.id, p.number));
1136 }
1137
79136bbClaude1138 // Count open/closed
1139 const [counts] = await db
1140 .select({
1141 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
1142 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
1143 })
1144 .from(issues)
1145 .where(eq(issues.repositoryId, repo.id));
1146
85c4e13Claude1147 // Count open milestones for the "N Milestones" link
1148 const [milestoneCounts] = await db
1149 .select({
1150 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
1151 })
1152 .from(milestones)
1153 .where(eq(milestones.repositoryId, repo.id));
1154
cb5a796Claude1155 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
1156 const pendingCountList = viewerIsOwnerOnList
1157 ? await countPendingForRepo(repo.id)
1158 : 0;
1159
79136bbClaude1160 return c.html(
1161 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1162 <IssuesStyle />
79136bbClaude1163 <RepoHeader owner={ownerName} repo={repoName} />
1164 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1165 <PendingCommentsBanner
1166 owner={ownerName}
1167 repo={repoName}
1168 count={pendingCountList}
1169 />
f7ad7b8Claude1170 <section class="issues-hero">
1171 <div class="issues-hero-bg" aria-hidden="true">
1172 <div class="issues-hero-orb" />
1173 </div>
1174 <div class="issues-hero-inner">
1175 <div class="issues-hero-text">
1176 <div class="issues-hero-eyebrow">
1177 Issue tracker \u00B7{" "}
1178 <span class="issues-hero-repo">
1179 {ownerName}/{repoName}
1180 </span>
1181 </div>
1182 <h1 class="issues-hero-title">
1183 Track <span class="gradient-text">what matters</span>.
1184 </h1>
1185 <p class="issues-hero-sub">
1186 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1187 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
1188 : `${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.`}
1189 </p>
1190 </div>
1191 <div class="issues-hero-actions">
1192 {user && (
1193 <a
1194 href={`/${ownerName}/${repoName}/issues/new`}
1195 class="btn btn-primary"
1196 >
1197 + New issue
1198 </a>
1199 )}
85c4e13Claude1200 <a
1201 href={`/${ownerName}/${repoName}/milestones`}
1202 class="btn"
1203 title="View milestones for this repository"
1204 >
1205 Milestones
1206 {Number(milestoneCounts?.open ?? 0) > 0 && (
6fd5915Claude1207 <span style="margin-left:5px;font-size:11.5px;background:rgba(91,110,232,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
85c4e13Claude1208 {Number(milestoneCounts?.open ?? 0)}
1209 </span>
1210 )}
1211 </a>
f7ad7b8Claude1212 <a href={`/${ownerName}/${repoName}`} class="btn">
1213 Back to code
1214 </a>
1215 </div>
1216 </div>
1217 </section>
1218
1219 <div class="issues-toolbar">
1220 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1221 <a
1222 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1223 href={`/${ownerName}/${repoName}/issues?state=open`}
1224 role="tab"
1225 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1226 >
f7ad7b8Claude1227 <span aria-hidden="true">{"\u25CB"}</span>
1228 <span>Open</span>
1229 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1230 </a>
1231 <a
1232 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1233 href={`/${ownerName}/${repoName}/issues?state=closed`}
1234 role="tab"
1235 aria-selected={state === "closed" ? "true" : "false"}
1236 >
1237 <span aria-hidden="true">{"\u2713"}</span>
1238 <span>Closed</span>
1239 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1240 </a>
1241 </div>
7a28902Claude1242 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1243 <input type="hidden" name="state" value={state} />
1244 <input
1245 type="search"
1246 name="q"
1247 value={searchQ}
1248 placeholder="Search issues\u2026"
1249 class="issues-search-input"
1250 aria-label="Search issues by title"
1251 />
1252 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1253 <button type="submit" class="issues-search-btn" aria-label="Search">
1254 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1255 <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" />
1256 </svg>
1257 </button>
1258 </form>
f7ad7b8Claude1259 </div>
7a28902Claude1260 {(searchQ || labelFilter) && (
1261 <div class="issues-filter-banner">
1262 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1263 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1264 {" \u00B7 "}
1265 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1266 </div>
1267 )}
f7ad7b8Claude1268
f5b9ef5Claude1269 <div class="issues-sort-row">
1270 <span class="issues-sort-label">Sort:</span>
1271 {(["newest", "oldest", "updated"] as const).map((s) => (
1272 <a
1273 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1274 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1275 >
1276 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1277 </a>
1278 ))}
1279 </div>
1280
79136bbClaude1281 {issueList.length === 0 ? (
f7ad7b8Claude1282 <div class="issues-empty">
1283 <IssuesEmptySvg />
1284 <h2 class="issues-empty-title">
1285 {state === "closed"
1286 ? "No closed issues yet"
1287 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1288 ? "No issues \u2014 yet"
1289 : "Nothing open right now"}
1290 </h2>
1291 <p class="issues-empty-sub">
1292 {state === "closed"
1293 ? "Closed issues will show up here once the team starts shipping fixes."
1294 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1295 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1296 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1297 </p>
1298 <div class="issues-empty-cta">
1299 {user && state !== "closed" && (
1300 <a
1301 href={`/${ownerName}/${repoName}/issues/new`}
1302 class="btn btn-primary"
1303 >
1304 + Open the first issue
1305 </a>
1306 )}
79136bbClaude1307 {state === "closed" && (
f7ad7b8Claude1308 <a
1309 href={`/${ownerName}/${repoName}/issues?state=open`}
1310 class="btn"
1311 >
1312 View open issues
1313 </a>
79136bbClaude1314 )}
f7ad7b8Claude1315 </div>
1316 </div>
79136bbClaude1317 ) : (
8d1483cClaude1318 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1319 <input type="hidden" name="action" id="bulk-action" value="" />
1320 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1321 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1322 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1323 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1324 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1325 </div>
1326 <ul class="issues-list">
1327 {issueList.map(({ issue, author }) => (
1328 <li class="issues-row">
1329 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1330 <div
1331 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1332 aria-hidden="true"
1333 title={issue.state === "open" ? "Open" : "Closed"}
1334 >
1335 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1336 </div>
8d1483cClaude1337 <div class="issues-row-main">
1338 <h3 class="issues-row-title">
1339 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1340 {issue.title}
1341 </a>
2c61840Claude1342 {subCountMap.has(issue.id) && (
1343 <span class="issues-tag-epic" title={`Epic with ${subCountMap.get(issue.id)} sub-issue${subCountMap.get(issue.id) === 1 ? "" : "s"}`}>
1344 📌 Epic · {subCountMap.get(issue.id)}
1345 </span>
1346 )}
1347 {issue.parentIssueId && parentNumberMap.has(issue.parentIssueId) && (
1348 <span class="issues-tag-child" title={`Sub-issue of #${parentNumberMap.get(issue.parentIssueId)}`}>
1349 ↳ #{parentNumberMap.get(issue.parentIssueId)}
1350 </span>
1351 )}
8d1483cClaude1352 </h3>
1353 <div class="issues-row-meta">
1354 #{issue.number} opened by{" "}
1355 <strong>{author.username}</strong>{" "}
1356 {formatRelative(issue.createdAt)}
1357 </div>
1358 </div>
1359 </li>
1360 ))}
1361 </ul>
1362 <script dangerouslySetInnerHTML={{ __html: `
1363function bulkSubmit(action){
1364 document.getElementById('bulk-action').value=action;
1365 document.getElementById('bulk-form').submit();
1366}
1367function bulkClear(){
1368 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1369 updateBulkBar();
1370}
1371function updateBulkBar(){
1372 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1373 var bar=document.getElementById('bulk-bar');
1374 var cnt=document.getElementById('bulk-bar-count');
1375 if(bar){bar.style.display=checked>0?'flex':'none';}
1376 if(cnt){cnt.textContent=checked+' selected';}
1377}
1378document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1379` }} />
1380 </form>
79136bbClaude1381 )}
1382 </Layout>
1383 );
1384});
1385
1386// New issue form
1387issueRoutes.get(
1388 "/:owner/:repo/issues/new",
1389 softAuth,
1390 requireAuth,
04f6b7fClaude1391 requireRepoAccess("write"),
79136bbClaude1392 async (c) => {
1393 const { owner: ownerName, repo: repoName } = c.req.param();
1394 const user = c.get("user")!;
1395 const error = c.req.query("error");
24cf2caClaude1396 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1397
85c4e13Claude1398 // Load open milestones for the dropdown
1399 const resolved = await resolveRepo(ownerName, repoName);
1400 const openMilestones = resolved
1401 ? await db
1402 .select({ id: milestones.id, title: milestones.title })
1403 .from(milestones)
1404 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1405 .orderBy(milestones.title)
1406 : [];
1407
79136bbClaude1408 return c.html(
1409 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1410 <IssuesStyle />
79136bbClaude1411 <RepoHeader owner={ownerName} repo={repoName} />
1412 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1413 <Container maxWidth={800}>
f7ad7b8Claude1414 <section class="issues-hero" style="margin-top:4px">
1415 <div class="issues-hero-bg" aria-hidden="true">
1416 <div class="issues-hero-orb" />
1417 </div>
1418 <div class="issues-hero-inner">
1419 <div class="issues-hero-text">
1420 <div class="issues-hero-eyebrow">
1421 New issue ·{" "}
1422 <span class="issues-hero-repo">
1423 {ownerName}/{repoName}
1424 </span>
1425 </div>
1426 <h1 class="issues-hero-title">
1427 File <span class="gradient-text">it cleanly</span>.
1428 </h1>
1429 <p class="issues-hero-sub">
1430 AI Triage will read the body the moment you submit and post
1431 suggested labels, priority, and possible duplicates within
1432 seconds. You stay in control — nothing is applied.
1433 </p>
1434 </div>
1435 </div>
1436 </section>
79136bbClaude1437 {error && (
bb0f894Claude1438 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1439 )}
0316dbbClaude1440 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1441 <FormGroup>
1442 <Input
79136bbClaude1443 type="text"
1444 name="title"
1445 required
1446 placeholder="Title"
bb0f894Claude1447 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1448 aria-label="Issue title"
79136bbClaude1449 />
bb0f894Claude1450 </FormGroup>
1451 <FormGroup>
1452 <TextArea
79136bbClaude1453 name="body"
1454 rows={12}
1455 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1456 mono
79136bbClaude1457 />
bb0f894Claude1458 </FormGroup>
85c4e13Claude1459 {openMilestones.length > 0 && (
1460 <FormGroup>
1461 <label
1462 for="milestone_id"
1463 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1464 >
1465 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1466 </label>
1467 <select
1468 id="milestone_id"
1469 name="milestone_id"
1470 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"
1471 >
1472 <option value="">No milestone</option>
1473 {openMilestones.map((ms) => (
1474 <option value={ms.id}>{ms.title}</option>
1475 ))}
1476 </select>
1477 </FormGroup>
1478 )}
bb0f894Claude1479 <Button type="submit" variant="primary">
79136bbClaude1480 Submit new issue
bb0f894Claude1481 </Button>
1482 </Form>
1483 </Container>
79136bbClaude1484 </Layout>
1485 );
1486 }
1487);
1488
1489// Create issue
1490issueRoutes.post(
1491 "/:owner/:repo/issues/new",
1492 softAuth,
1493 requireAuth,
04f6b7fClaude1494 requireRepoAccess("write"),
79136bbClaude1495 async (c) => {
1496 const { owner: ownerName, repo: repoName } = c.req.param();
1497 const user = c.get("user")!;
1498 const body = await c.req.parseBody();
1499 const title = String(body.title || "").trim();
1500 const issueBody = String(body.body || "").trim();
85c4e13Claude1501 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1502
1503 if (!title) {
1504 return c.redirect(
1505 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1506 );
1507 }
1508
1509 const resolved = await resolveRepo(ownerName, repoName);
1510 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1511
85c4e13Claude1512 // Validate milestone belongs to this repo if provided
1513 let validatedMilestoneId: string | null = null;
1514 if (milestoneIdRaw) {
1515 const [msRow] = await db
1516 .select({ id: milestones.id })
1517 .from(milestones)
1518 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1519 .limit(1);
1520 validatedMilestoneId = msRow?.id ?? null;
1521 }
1522
79136bbClaude1523 const [issue] = await db
1524 .insert(issues)
1525 .values({
1526 repositoryId: resolved.repo.id,
1527 authorId: user.id,
1528 title,
1529 body: issueBody || null,
85c4e13Claude1530 milestoneId: validatedMilestoneId,
79136bbClaude1531 })
1532 .returning();
1533
1534 // Update issue count
1535 await db
1536 .update(repositories)
1537 .set({ issueCount: resolved.repo.issueCount + 1 })
1538 .where(eq(repositories.id, resolved.repo.id));
1539
a9ada5fClaude1540 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1541 // suggested labels, priority, summary, and a possible-duplicate
1542 // callout. Suggestions only — nothing applied automatically.
1543 triggerIssueTriage({
1544 ownerName,
1545 repoName,
1546 repositoryId: resolved.repo.id,
1547 issueId: issue.id,
1548 issueNumber: issue.number,
1549 authorId: user.id,
1550 title,
1551 body: issueBody,
a28cedeClaude1552 }).catch((err) => {
1553 console.warn(
1554 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1555 err instanceof Error ? err.message : err
1556 );
1557 });
a9ada5fClaude1558
79136bbClaude1559 return c.redirect(
1560 `/${ownerName}/${repoName}/issues/${issue.number}`
1561 );
1562 }
1563);
1564
1565// View single issue
04f6b7fClaude1566issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1567 const { owner: ownerName, repo: repoName } = c.req.param();
1568 const issueNum = parseInt(c.req.param("number"), 10);
1569 const user = c.get("user");
1570
1571 const resolved = await resolveRepo(ownerName, repoName);
1572 if (!resolved) {
1573 return c.html(
1574 <Layout title="Not Found" user={user}>
bb0f894Claude1575 <EmptyState title="Not found" />
79136bbClaude1576 </Layout>,
1577 404
1578 );
1579 }
1580
1581 const [issue] = await db
1582 .select()
1583 .from(issues)
1584 .where(
1585 and(
1586 eq(issues.repositoryId, resolved.repo.id),
1587 eq(issues.number, issueNum)
1588 )
1589 )
1590 .limit(1);
1591
1592 if (!issue) {
1593 return c.html(
1594 <Layout title="Not Found" user={user}>
bb0f894Claude1595 <EmptyState title="Issue not found" />
79136bbClaude1596 </Layout>,
1597 404
1598 );
1599 }
1600
1601 const [author] = await db
1602 .select()
1603 .from(users)
1604 .where(eq(users.id, issue.authorId))
1605 .limit(1);
1606
cb5a796Claude1607 // Get comments. We pull every row and filter by moderation_status +
1608 // viewer identity client-side (in the JSX below) so a moderator/owner
1609 // sees them all while non-author viewers only ever see 'approved'.
1610 const allComments = await db
79136bbClaude1611 .select({
1612 comment: issueComments,
cb5a796Claude1613 author: { id: users.id, username: users.username },
79136bbClaude1614 })
1615 .from(issueComments)
1616 .innerJoin(users, eq(issueComments.authorId, users.id))
1617 .where(eq(issueComments.issueId, issue.id))
1618 .orderBy(asc(issueComments.createdAt));
1619
cb5a796Claude1620 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1621 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1622 // Owner always sees everything (so they can spot conversations going
1623 // sideways even before they hit the queue page).
1624 if (viewerIsOwner) return true;
1625 if (comment.moderationStatus === "approved") return true;
1626 // The comment's own author sees their pending comment so they know
1627 // it landed (with an "Awaiting approval" badge in the render below).
1628 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1629 return true;
1630 }
1631 return false;
1632 });
1633
1634 // Pending banner — when the viewer is the repo owner, show a strip at
1635 // the top of the page nudging them to the moderation queue. Inline on
1636 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1637 const pendingCount = viewerIsOwner
1638 ? await countPendingForRepo(resolved.repo.id)
1639 : 0;
1640
2c61840Claude1641 // Sub-issues / epics — load parent issue (if this is a child) and
1642 // any child issues (if this is an epic), in parallel with reactions.
1643 const [parentIssueRow, subIssueRows] = await Promise.all([
1644 issue.parentIssueId
1645 ? db
1646 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
1647 .from(issues)
1648 .where(eq(issues.id, issue.parentIssueId))
1649 .limit(1)
1650 .then((r) => r[0] ?? null)
1651 : Promise.resolve(null),
1652 db
1653 .select({
1654 id: issues.id,
1655 number: issues.number,
1656 title: issues.title,
1657 state: issues.state,
1658 authorUsername: users.username,
1659 })
1660 .from(issues)
1661 .innerJoin(users, eq(issues.authorId, users.id))
1662 .where(and(eq(issues.parentIssueId, issue.id), eq(issues.repositoryId, resolved.repo.id)))
1663 .orderBy(asc(issues.number)),
1664 ]);
1665
6fc53bdClaude1666 // Load reactions for the issue + each comment in parallel.
1667 const [issueReactions, ...commentReactions] = await Promise.all([
1668 summariseReactions("issue", issue.id, user?.id),
1669 ...comments.map((row) =>
1670 summariseReactions("issue_comment", row.comment.id, user?.id)
1671 ),
1672 ]);
1673
79136bbClaude1674 const canManage =
1675 user &&
1676 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1677 const info = c.req.query("info");
79136bbClaude1678
0224546Claude1679 // Labels attached to this issue.
1680 const issueDetailLabels = await db
1681 .select({ id: labels.id, name: labels.name, color: labels.color })
1682 .from(issueLabels)
1683 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1684 .where(eq(issueLabels.issueId, issue.id));
1685
1686 // Milestone for this issue (if any).
1687 const issueMilestone = issue.milestoneId
1688 ? await db
1689 .select({ id: milestones.id, title: milestones.title })
1690 .from(milestones)
1691 .where(eq(milestones.id, issue.milestoneId))
1692 .limit(1)
1693 .then((rows) => rows[0] ?? null)
1694 : null;
1695
240c477Claude1696 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1697 const issueRefPattern = `%#${issue.number}%`;
1698 const linkedPrs = await db
1699 .select({
1700 number: pullRequests.number,
1701 title: pullRequests.title,
1702 state: pullRequests.state,
1703 isDraft: pullRequests.isDraft,
1704 })
1705 .from(pullRequests)
1706 .where(
1707 and(
1708 eq(pullRequests.repositoryId, resolved.repo.id),
1709 or(
1710 ilike(pullRequests.title, issueRefPattern),
1711 ilike(pullRequests.body, issueRefPattern),
1712 )
1713 )
1714 )
1715 .orderBy(desc(pullRequests.createdAt))
1716 .limit(8);
1717
79136bbClaude1718 return c.html(
1719 <Layout
1720 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1721 user={user}
1722 >
f7ad7b8Claude1723 <IssuesStyle />
79136bbClaude1724 <RepoHeader owner={ownerName} repo={repoName} />
1725 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1726 <PendingCommentsBanner
1727 owner={ownerName}
1728 repo={repoName}
1729 count={pendingCount}
1730 />
b584e52Claude1731 <div
1732 id="live-comment-banner"
1733 class="alert"
1734 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1735 >
1736 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1737 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1738 reload to view
1739 </a>
1740 </div>
1741 <script
1742 dangerouslySetInnerHTML={{
1743 __html: liveCommentBannerScript({
1744 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1745 bannerElementId: "live-comment-banner",
1746 }),
1747 }}
1748 />
829a046Claude1749 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1750 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1751 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1752 <div class="issue-detail">
58915a9Claude1753 {info && (
f7ad7b8Claude1754 <div class="issues-info-banner">
58915a9Claude1755 {decodeURIComponent(info)}
1756 </div>
1757 )}
f7ad7b8Claude1758
1759 <section class="issues-detail-hero">
2c61840Claude1760 {parentIssueRow && (
1761 <div class="issues-epic-crumb">
1762 <span>📌 Part of epic</span>
1763 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`}>
1764 #{parentIssueRow.number} — {parentIssueRow.title}
1765 </a>
1766 </div>
1767 )}
f7ad7b8Claude1768 <h1 class="issues-detail-title">
1769 {issue.title}
1770 <span class="issues-detail-number">#{issue.number}</span>
1771 </h1>
1772 <div class="issues-detail-attr">
1773 <span
1774 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1775 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1776 >
f7ad7b8Claude1777 <span aria-hidden="true">
1778 {issue.state === "open" ? "\u25CB" : "\u2713"}
1779 </span>
1780 {issue.state === "open" ? "Open" : "Closed"}
1781 </span>
1782 <span>
1783 <strong>{author?.username || "unknown"}</strong> opened this
1784 issue {formatRelative(issue.createdAt)}
1785 </span>
1786 <span class="issues-detail-spacer" />
1787 {issue.state === "open" && user && user.id === resolved.owner.id && (
1788 <a
1789 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1790 class="btn btn-primary"
1791 style="font-size:13px;padding:6px 12px"
1792 title="Generate a draft pull request from this issue using Claude"
1793 >
1794 Build with AI
1795 </a>
1796 )}
f928118Claude1797 {issue.state === "open" && user && isAiAvailable() && (
1798 <form
1799 method="post"
1800 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1801 style="display:inline"
1802 title="Let AI implement this feature automatically"
1803 >
1804 <button
1805 type="submit"
1806 class="btn btn-primary"
6fd5915Claude1807 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);border:none;cursor:pointer"
f928118Claude1808 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1809 >
1810 Ship It
1811 </button>
1812 </form>
1813 )}
c922868Claude1814 {issue.state === "open" && user && (
1815 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1816 <summary
1817 class="btn"
1818 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1819 title="Create a new branch for this issue"
1820 >
1821 Create branch
1822 </summary>
1823 <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)">
1824 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1825 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1826 <input
1827 type="text"
1828 name="branchName"
1829 class="input"
1830 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1831 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1832 pattern="[a-zA-Z0-9._\\-/]+"
1833 required
1834 />
1835 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1836 Create branch
1837 </button>
1838 </form>
1839 </div>
1840 </details>
1841 )}
f7ad7b8Claude1842 </div>
1843 </section>
1844
0224546Claude1845 <div class="issue-detail-layout">
1846 <div class="issue-detail-main">
2c61840Claude1847
1848 {subIssueRows.length > 0 && (
1849 <div class="issues-sub-panel">
1850 <div class="issues-sub-panel-head">
1851 <span>Sub-issues</span>
1852 <span style="font-size:11px;background:var(--bg-inset);border:1px solid var(--border);border-radius:10px;padding:1px 8px;font-weight:500;letter-spacing:0">
1853 {subIssueRows.filter((s) => s.state === "open").length}/{subIssueRows.length} open
1854 </span>
1855 </div>
1856 {subIssueRows.map((sub) => (
1857 <div class="issues-sub-row" key={sub.id}>
1858 <span class={`issues-sub-row-icon ${sub.state === "open" ? "is-open" : "is-closed"}`}>
1859 {sub.state === "open" ? "○" : "✓"}
1860 </span>
1861 <span class="issues-sub-row-title">
1862 <a href={`/${ownerName}/${repoName}/issues/${sub.number}`}>{sub.title}</a>
1863 </span>
1864 <span class="issues-sub-row-author">{sub.authorUsername}</span>
1865 <span class="issues-sub-row-num">#{sub.number}</span>
1866 </div>
1867 ))}
1868 </div>
1869 )}
1870
f7ad7b8Claude1871 <div class="issues-thread">
1872 {issue.body && (
1873 <article class="issues-comment">
1874 <header class="issues-comment-header">
1875 <strong>{author?.username || "unknown"}</strong>
1876 <span class="issues-comment-author-pill">Author</span>
1877 <span>commented {formatRelative(issue.createdAt)}</span>
1878 </header>
1879 <div class="issues-comment-body">
1880 <div
1881 class="markdown-body"
1882 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1883 />
1884 </div>
1885 </article>
fbf4aefClaude1886 )}
79136bbClaude1887
f7ad7b8Claude1888 {comments.map(({ comment, author: commentAuthor }) => {
1889 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1890 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1891 return (
cb5a796Claude1892 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1893 <header class="issues-comment-header">
1894 <strong>{commentAuthor.username}</strong>
a7460bfClaude1895 {commentAuthor.username === BOT_USERNAME && (
1896 <span class="issues-bot-badge">&#x1F916; bot</span>
1897 )}
f7ad7b8Claude1898 {isAi ? (
1899 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1900 AI Review
1901 </span>
1902 ) : null}
cb5a796Claude1903 {isPending ? (
1904 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1905 Awaiting approval
1906 </span>
1907 ) : null}
f7ad7b8Claude1908 <span>commented {formatRelative(comment.createdAt)}</span>
1909 </header>
1910 <div class="issues-comment-body">
1911 <div
1912 class="markdown-body"
1913 dangerouslySetInnerHTML={{
1914 __html: renderMarkdown(comment.body),
1915 }}
1916 />
1917 </div>
1918 </article>
1919 );
1920 })}
1921 </div>
79136bbClaude1922
240c477Claude1923 {linkedPrs.length > 0 && (
1924 <div class="iss-linked-prs">
1925 <div class="iss-linked-prs-head">
1926 <span>Linked pull requests</span>
1927 <span>{linkedPrs.length}</span>
1928 </div>
1929 {linkedPrs.map((lpr) => {
1930 const prState = lpr.isDraft ? "draft" : lpr.state;
1931 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1932 return (
1933 <a
1934 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1935 class="iss-linked-pr-row"
1936 >
1937 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1938 <span class="iss-linked-pr-title">{lpr.title}</span>
1939 <span class="iss-linked-pr-num">#{lpr.number}</span>
1940 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1941 </a>
1942 );
1943 })}
1944 </div>
1945 )}
1946
79136bbClaude1947 {user && (
f7ad7b8Claude1948 <form
1949 class="issues-composer"
1950 method="post"
1951 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1952 >
1953 <div class="issues-composer-header">
1954 <span class="issues-composer-tag">Add a comment</span>
1955 <span class="issues-composer-hint">
1956 <a
1957 href="https://docs.github.com/en/get-started/writing-on-github"
1958 target="_blank"
1959 rel="noopener noreferrer"
1960 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1961 >
1962 Markdown supported
1963 </a>
1964 </span>
1965 </div>
1966 <textarea
1967 name="body"
1968 rows={6}
1969 required
6cd2f0eClaude1970 data-md-preview=""
f7ad7b8Claude1971 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1972 />
1973 <div class="issues-composer-actions">
1974 <button type="submit" class="btn btn-primary">
1975 Comment
1976 </button>
1977 {canManage && (
1978 <button
1979 type="submit"
1980 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1981 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1982 >
1983 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1984 </button>
f7ad7b8Claude1985 )}
1986 {canManage && issue.state === "open" && (
1987 <button
1988 type="submit"
1989 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1990 formnovalidate
1991 class="btn"
1992 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1993 >
1994 Re-run AI triage
1995 </button>
1996 )}
1997 </div>
1998 </form>
79136bbClaude1999 )}
0224546Claude2000 </div>{/* /issue-detail-main */}
2001
2002 {/* Metadata sidebar — Labels, Assignees, Milestone */}
2003 <aside class="issue-meta-sidebar">
2004 {/* Labels */}
2005 <div class="issue-meta-section">
2006 <div class="issue-meta-label">Labels</div>
2007 {issueDetailLabels.length === 0 ? (
2008 <span class="issue-meta-empty">None yet</span>
2009 ) : (
2010 <div class="issue-meta-labels">
2011 {issueDetailLabels.map((lbl) => {
2012 // Compute luminance from hex color to choose dark/light text
2013 const hex = lbl.color.replace("#", "");
2014 const r = parseInt(hex.slice(0, 2), 16) / 255;
2015 const g = parseInt(hex.slice(2, 4), 16) / 255;
2016 const b = parseInt(hex.slice(4, 6), 16) / 255;
2017 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
2018 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
2019 return (
2020 <span
2021 class="issue-meta-label-pill"
2022 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
2023 >
2024 {lbl.name}
2025 </span>
2026 );
2027 })}
2028 </div>
2029 )}
2030 </div>
2031
2032 {/* Assignees — field not yet in schema; placeholder */}
2033 <div class="issue-meta-section">
2034 <div class="issue-meta-label">Assignees</div>
2035 <span class="issue-meta-empty">No one assigned</span>
2036 </div>
2037
2038 {/* Milestone */}
2039 <div class="issue-meta-section">
2040 <div class="issue-meta-label">Milestone</div>
2041 {issueMilestone ? (
2042 <a
2043 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
2044 class="issue-meta-milestone-link"
2045 >
2046 &#x25CE; {issueMilestone.title}
2047 </a>
2048 ) : (
2049 <span class="issue-meta-empty">No milestone</span>
2050 )}
2051 </div>
2c61840Claude2052 {/* Epic / parent issue */}
2053 {viewerIsOwner && (
2054 <div class="issue-meta-section">
2055 <div class="issue-meta-label">Epic (parent issue)</div>
2056 {parentIssueRow ? (
2057 <div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">
2058 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`} style="color:var(--accent);text-decoration:none">
2059 #{parentIssueRow.number} {parentIssueRow.title.slice(0, 40)}{parentIssueRow.title.length > 40 ? "…" : ""}
2060 </a>
2061 </div>
2062 ) : (
2063 <span class="issue-meta-empty">None</span>
2064 )}
2065 <form
2066 method="post"
2067 action={`/${ownerName}/${repoName}/issues/${issue.number}/set-parent`}
2068 style="display:flex;gap:6px;margin-top:6px"
2069 >
2070 <input
2071 type="number"
2072 name="parent_number"
2073 placeholder={parentIssueRow ? "Clear (enter 0)" : "Issue #"}
2074 style="width:100%;padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-inset);color:var(--text)"
2075 min="0"
2076 />
2077 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px;white-space:nowrap">
2078 Set
2079 </button>
2080 </form>
2081 </div>
2082 )}
0224546Claude2083 </aside>
2084 </div>{/* /issue-detail-layout */}
79136bbClaude2085 </div>
641aa42Claude2086 {/* Issue keyboard hints bar */}
2087 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
2088 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
2089 </div>
2090 <style dangerouslySetInnerHTML={{ __html: `
2091 .kbd-hints {
2092 position: fixed;
2093 bottom: 0;
2094 left: 0;
2095 right: 0;
2096 z-index: 90;
2097 padding: 6px 24px;
2098 background: var(--bg-secondary);
2099 border-top: 1px solid var(--border);
2100 font-size: 12px;
2101 color: var(--text-muted);
2102 display: flex;
2103 align-items: center;
2104 gap: 8px;
2105 flex-wrap: wrap;
2106 }
2107 .kbd-hints kbd {
2108 font-family: var(--font-mono);
2109 font-size: 10px;
2110 background: var(--bg-elevated);
2111 border: 1px solid var(--border);
2112 border-bottom-width: 2px;
2113 border-radius: 4px;
2114 padding: 1px 5px;
2115 color: var(--text);
2116 line-height: 1.5;
2117 }
2118 main { padding-bottom: 40px; }
2119 ` }} />
2120 {/* Repo context commands for command palette */}
2121 <script
2122 id="cmdk-repo-context"
2123 dangerouslySetInnerHTML={{
2124 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
2125 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
2126 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
2127 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
2128 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
2129 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
2130 ])};`,
2131 }}
2132 />
2133 {/* Issue keyboard shortcuts */}
2134 <script dangerouslySetInnerHTML={{ __html: `
2135 (function(){
2136 function isTyping(t){
2137 t = t || {};
2138 var tag = (t.tagName || '').toLowerCase();
2139 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
2140 }
2141 document.addEventListener('keydown', function(e){
2142 if (isTyping(e.target)) return;
2143 if (e.metaKey || e.ctrlKey || e.altKey) return;
2144 if (e.key === 'c') {
2145 e.preventDefault();
2146 var box = document.querySelector('textarea[name="body"]');
2147 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
2148 }
2149 if (e.key === 'e') {
2150 e.preventDefault();
2151 // Focus the issue title and make it editable if possible
2152 var titleEl = document.querySelector('.issues-title');
2153 if (titleEl) titleEl.scrollIntoView({block:'center'});
2154 }
2155 if (e.key === 'x') {
2156 e.preventDefault();
2157 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
2158 if (closeBtn) {
2159 var confirmed = window.confirm('Close/reopen this issue?');
2160 if (confirmed) closeBtn.click();
2161 }
2162 }
2163 if (e.key === 'Escape') {
2164 var focused = document.activeElement;
2165 if (focused) focused.blur();
2166 }
2167 });
2168 })();
2169 ` }} />
79136bbClaude2170 </Layout>
2171 );
2172});
2173
cb5a796Claude2174// Add comment.
2175//
2176// Permission model: we accept comments from ANY authenticated user (read
2177// access required — public repos satisfy that, private repos still need a
2178// collaborator row). The moderation gate in `decideInitialStatus`
2179// determines whether the comment is published immediately or queued for
2180// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
2181// "anti-impersonation" rationale.
79136bbClaude2182issueRoutes.post(
2183 "/:owner/:repo/issues/:number/comment",
2184 softAuth,
2185 requireAuth,
cb5a796Claude2186 requireRepoAccess("read"),
79136bbClaude2187 async (c) => {
2188 const { owner: ownerName, repo: repoName } = c.req.param();
2189 const issueNum = parseInt(c.req.param("number"), 10);
2190 const user = c.get("user")!;
2191 const body = await c.req.parseBody();
2192 const commentBody = String(body.body || "").trim();
2193
2194 if (!commentBody) {
2195 return c.redirect(
2196 `/${ownerName}/${repoName}/issues/${issueNum}`
2197 );
2198 }
2199
2200 const resolved = await resolveRepo(ownerName, repoName);
2201 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2202
2203 const [issue] = await db
2204 .select()
2205 .from(issues)
2206 .where(
2207 and(
2208 eq(issues.repositoryId, resolved.repo.id),
2209 eq(issues.number, issueNum)
2210 )
2211 )
2212 .limit(1);
2213
2214 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2215
cb5a796Claude2216 // Decide moderation status BEFORE insert so the row lands in the right
2217 // initial state. Collaborators, the issue author, and trusted users
2218 // get 'approved'; banned users get 'rejected' (silent drop); everyone
2219 // else gets 'pending'.
2220 const decision = await decideInitialStatus({
2221 commenterUserId: user.id,
2222 repositoryId: resolved.repo.id,
2223 kind: "issue",
2224 threadId: issue.id,
2225 });
2226
d4ac5c3Claude2227 const [inserted] = await db
2228 .insert(issueComments)
2229 .values({
2230 issueId: issue.id,
2231 authorId: user.id,
2232 body: commentBody,
cb5a796Claude2233 moderationStatus: decision.status,
d4ac5c3Claude2234 })
2235 .returning();
2236
cb5a796Claude2237 // Live update only when visible. Don't leak pending/rejected via SSE.
2238 if (inserted && decision.status === "approved") {
d4ac5c3Claude2239 try {
2240 const { publish } = await import("../lib/sse");
2241 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
2242 event: "issue-comment",
2243 data: {
2244 issueId: issue.id,
2245 commentId: inserted.id,
2246 authorId: user.id,
2247 authorUsername: user.username,
2248 },
2249 });
2250 } catch {
2251 /* SSE is best-effort */
2252 }
b7ecb14Claude2253 // Notify the issue author — fire-and-forget, never blocks the response.
2254 if (issue.authorId && issue.authorId !== user.id) {
2255 void import("../lib/notify").then(({ createNotification }) =>
2256 createNotification({
2257 userId: issue.authorId,
2258 type: "issue_comment",
2259 title: `New comment on "${issue.title}"`,
2260 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
2261 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
2262 repoId: resolved.repo.id,
2263 })
2264 ).catch(() => { /* never block the response */ });
2265 }
d4ac5c3Claude2266 }
79136bbClaude2267
cb5a796Claude2268 if (decision.status === "pending") {
2269 // Notify repo owner that a comment is awaiting review.
2270 void notifyOwnerOfPendingComment({
2271 repositoryId: resolved.repo.id,
2272 commenterUsername: user.username,
2273 kind: "issue",
2274 threadNumber: issueNum,
2275 ownerUsername: ownerName,
2276 repoName,
2277 });
2278 return c.redirect(
2279 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2280 );
2281 }
2282 if (decision.status === "rejected") {
2283 // Silent ban — the commenter is on the banned list. Don't reveal
2284 // the gate; just send them back to the page as if it posted.
2285 return c.redirect(
2286 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2287 );
2288 }
2289
79136bbClaude2290 return c.redirect(
2291 `/${ownerName}/${repoName}/issues/${issueNum}`
2292 );
2293 }
2294);
2295
2296// Close issue
2297issueRoutes.post(
2298 "/:owner/:repo/issues/:number/close",
2299 softAuth,
2300 requireAuth,
04f6b7fClaude2301 requireRepoAccess("write"),
79136bbClaude2302 async (c) => {
2303 const { owner: ownerName, repo: repoName } = c.req.param();
2304 const issueNum = parseInt(c.req.param("number"), 10);
2305
2306 const resolved = await resolveRepo(ownerName, repoName);
2307 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2308
2309 await db
2310 .update(issues)
2311 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2312 .where(
2313 and(
2314 eq(issues.repositoryId, resolved.repo.id),
2315 eq(issues.number, issueNum)
2316 )
2317 );
2318
2319 return c.redirect(
2320 `/${ownerName}/${repoName}/issues/${issueNum}`
2321 );
2322 }
2323);
2324
2325// Reopen issue
2326issueRoutes.post(
2327 "/:owner/:repo/issues/:number/reopen",
2328 softAuth,
2329 requireAuth,
04f6b7fClaude2330 requireRepoAccess("write"),
79136bbClaude2331 async (c) => {
2332 const { owner: ownerName, repo: repoName } = c.req.param();
2333 const issueNum = parseInt(c.req.param("number"), 10);
2334
2335 const resolved = await resolveRepo(ownerName, repoName);
2336 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2337
2338 await db
2339 .update(issues)
2340 .set({ state: "open", closedAt: null, updatedAt: new Date() })
2341 .where(
2342 and(
2343 eq(issues.repositoryId, resolved.repo.id),
2344 eq(issues.number, issueNum)
2345 )
2346 );
2347
2348 return c.redirect(
2349 `/${ownerName}/${repoName}/issues/${issueNum}`
2350 );
2351 }
2352);
2353
2354// Shared nav component with issues tab
2355const IssueNav = ({
2356 owner,
2357 repo,
2358 active,
2359}: {
2360 owner: string;
2361 repo: string;
2362 active: "code" | "commits" | "issues";
2363}) => (
bb0f894Claude2364 <TabNav
2365 tabs={[
2366 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2367 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2368 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2369 ]}
2370 />
79136bbClaude2371);
2372
58915a9Claude2373// Re-run AI triage on demand (e.g. after the issue body has been edited).
2374// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2375issueRoutes.post(
2376 "/:owner/:repo/issues/:number/ai-retriage",
2377 softAuth,
2378 requireAuth,
2379 requireRepoAccess("write"),
2380 async (c) => {
2381 const { owner: ownerName, repo: repoName } = c.req.param();
2382 const issueNum = parseInt(c.req.param("number"), 10);
2383 const user = c.get("user")!;
2384 const resolved = await resolveRepo(ownerName, repoName);
2385 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2386
2387 const [issue] = await db
2388 .select()
2389 .from(issues)
2390 .where(
2391 and(
2392 eq(issues.repositoryId, resolved.repo.id),
2393 eq(issues.number, issueNum)
2394 )
2395 )
2396 .limit(1);
2397 if (!issue) {
2398 return c.redirect(`/${ownerName}/${repoName}/issues`);
2399 }
2400
2401 triggerIssueTriage(
2402 {
2403 ownerName,
2404 repoName,
2405 repositoryId: resolved.repo.id,
2406 issueId: issue.id,
2407 issueNumber: issue.number,
2408 authorId: user.id,
2409 title: issue.title,
2410 body: issue.body || "",
2411 },
2412 { force: true }
a28cedeClaude2413 ).catch((err) => {
2414 console.warn(
2415 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2416 err instanceof Error ? err.message : err
2417 );
2418 });
58915a9Claude2419
2420 return c.redirect(
2421 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2422 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2423 )}`
2424 );
2425 }
2426);
2427
c922868Claude2428// ─── Create branch from issue ─────────────────────────────────────────────────
2429// POST /:owner/:repo/issues/:number/branch
2430// Creates a new git branch from the repo default branch, pre-named after the
2431// issue. Write access required. Zero-config — no new DB tables.
2432
2433issueRoutes.post(
2434 "/:owner/:repo/issues/:number/branch",
2435 softAuth,
2436 requireAuth,
2437 requireRepoAccess("write"),
2438 async (c) => {
2439 const { owner: ownerName, repo: repoName } = c.req.param();
2440 const issueNum = parseInt(c.req.param("number"), 10);
2441
2442 const resolved = await resolveRepo(ownerName, repoName);
2443 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2444
2445 const [issue] = await db
2446 .select({ id: issues.id, number: issues.number, title: issues.title })
2447 .from(issues)
2448 .where(
2449 and(
2450 eq(issues.repositoryId, resolved.repo.id),
2451 eq(issues.number, issueNum)
2452 )
2453 )
2454 .limit(1);
2455
2456 if (!issue) {
2457 return c.redirect(`/${ownerName}/${repoName}/issues`);
2458 }
2459
2460 const body = await c.req.formData().catch(() => null);
2461 const rawName = (body?.get("branchName") as string | null)?.trim();
2462
2463 // Derive a slug from the issue title
2464 const titleSlug = issue.title
2465 .toLowerCase()
2466 .replace(/[^a-z0-9]+/g, "-")
2467 .replace(/^-+|-+$/g, "")
2468 .slice(0, 40);
2469 const suggested = `issue-${issue.number}-${titleSlug}`;
2470 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2471 ? rawName
2472 : suggested;
2473
2474 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2475 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2476
2477 if (!sha) {
2478 return c.redirect(
2479 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2480 "Cannot create branch: repository has no commits yet."
2481 )}`
2482 );
2483 }
2484
2485 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2486 if (!ok) {
2487 return c.redirect(
2488 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2489 `Failed to create branch '${branchName}'. It may already exist.`
2490 )}`
2491 );
2492 }
2493
2494 return c.redirect(
2495 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2496 `Branch '${branchName}' created from ${defaultBranch}.`
2497 )}`
2498 );
2499 }
2500);
2501
2c61840Claude2502// POST /:owner/:repo/issues/:number/set-parent
2503// Sets or clears the parent (epic) for an issue.
2504issueRoutes.post(
2505 "/:owner/:repo/issues/:number/set-parent",
2506 softAuth,
2507 requireAuth,
2508 requireRepoAccess("write"),
2509 async (c) => {
2510 const { owner: ownerName, repo: repoName } = c.req.param();
2511 const issueNum = parseInt(c.req.param("number"), 10);
2512 const form = await c.req.formData();
2513 const parentNum = form.get("parent_number")?.toString().trim() ?? "";
2514
2515 const resolved = await resolveRepo(ownerName, repoName);
2516 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2517
2518 const [issue] = await db
2519 .select({ id: issues.id })
2520 .from(issues)
2521 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, issueNum)))
2522 .limit(1);
2523 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2524
2525 // Clear parent
2526 if (!parentNum || parentNum === "0") {
2527 await db.update(issues).set({ parentIssueId: null }).where(eq(issues.id, issue.id));
2528 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Removed from epic.")}`);
2529 }
2530
2531 const pNum = parseInt(parentNum, 10);
2532 if (isNaN(pNum) || pNum === issueNum) {
2533 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Invalid parent issue number.")}`);
2534 }
2535
2536 const [parent] = await db
2537 .select({ id: issues.id })
2538 .from(issues)
2539 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, pNum)))
2540 .limit(1);
2541 if (!parent) {
2542 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Issue #${pNum} not found.`)}`);
2543 }
2544
2545 await db.update(issues).set({ parentIssueId: parent.id }).where(eq(issues.id, issue.id));
2546 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Added to epic #${pNum}.`)}`);
2547 }
2548);
2549
79136bbClaude2550export default issueRoutes;
2551export { IssueNav };