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.tsxBlame2570 lines · 3 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";
a74f4edccanty labs8import { fireWebhooks } from "./webhooks";
79136bbClaude9import {
10 issues,
11 issueComments,
12 repositories,
13 users,
14 labels,
15 issueLabels,
240c477Claude16 pullRequests,
85c4e13Claude17 milestones,
79136bbClaude18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude21import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude22import { ReactionsBar } from "../views/reactions";
23import { summariseReactions } from "../lib/reactions";
24cf2caClaude24import { loadIssueTemplate } from "../lib/templates";
79136bbClaude25import { renderMarkdown } from "../lib/markdown";
b584e52Claude26import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude27import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude28import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude29import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude30import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude31import { softAuth, requireAuth } from "../middleware/auth";
32import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude33import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude34import {
35 decideInitialStatus,
36 notifyOwnerOfPendingComment,
37 countPendingForRepo,
38} from "../lib/comment-moderation";
bb0f894Claude39import {
40 Flex,
41 Container,
42 PageHeader,
43 Form,
44 FormGroup,
45 Input,
46 TextArea,
47 Button,
48 LinkButton,
49 Badge,
50 EmptyState,
51 TabNav,
52 FilterTabs,
53 List,
54 ListItem,
55 Alert,
56 CommentBox,
57 CommentForm,
58 formatRelative,
59} from "../views/ui";
c922868Claude60import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude61import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude62import { isAiAvailable } from "../lib/ai-client";
79136bbClaude63
64const issueRoutes = new Hono<AuthEnv>();
65
f7ad7b8Claude66// ---------------------------------------------------------------------------
67// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
68// other routes/shared views. All design tokens come from :root in layout.tsx.
69// ---------------------------------------------------------------------------
70const issuesStyles = `
71 /* Hero card — list page */
72 .issues-hero {
73 position: relative;
74 margin: 4px 0 24px;
75 padding: 28px 32px;
76 background: var(--bg-elevated);
77 border: 1px solid var(--border);
78 border-radius: 16px;
79 overflow: hidden;
80 }
81 .issues-hero::before {
82 content: '';
83 position: absolute;
84 top: 0; left: 0; right: 0;
85 height: 2px;
6fd5915Claude86 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude87 opacity: 0.7;
88 pointer-events: none;
89 }
90 .issues-hero-bg {
91 position: absolute;
92 inset: -30% -10% auto auto;
93 width: 360px;
94 height: 360px;
95 pointer-events: none;
96 z-index: 0;
97 }
98 .issues-hero-orb {
99 position: absolute;
100 inset: 0;
6fd5915Claude101 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
f7ad7b8Claude102 filter: blur(80px);
103 opacity: 0.7;
104 animation: issuesHeroOrb 14s ease-in-out infinite;
105 }
106 @keyframes issuesHeroOrb {
107 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
108 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
109 }
110 @media (prefers-reduced-motion: reduce) {
111 .issues-hero-orb { animation: none; }
112 }
113 .issues-hero-inner {
114 position: relative;
115 z-index: 1;
116 display: flex;
117 justify-content: space-between;
118 align-items: flex-end;
119 gap: 24px;
120 flex-wrap: wrap;
121 }
122 .issues-hero-text { flex: 1; min-width: 280px; }
123 .issues-hero-eyebrow {
124 font-size: 12.5px;
125 color: var(--text-muted);
126 margin-bottom: 8px;
127 letter-spacing: 0.04em;
128 text-transform: uppercase;
129 font-weight: 600;
130 }
131 .issues-hero-eyebrow .issues-hero-repo {
132 color: var(--accent);
133 text-transform: none;
134 letter-spacing: -0.005em;
135 font-weight: 600;
136 }
137 .issues-hero-title {
138 font-family: var(--font-display);
139 font-size: clamp(28px, 4vw, 40px);
140 font-weight: 800;
141 letter-spacing: -0.028em;
142 line-height: 1.05;
143 margin: 0 0 10px;
144 color: var(--text-strong);
145 }
146 .issues-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 580px;
152 }
153 .issues-hero-actions {
154 display: flex;
155 gap: 8px;
156 flex-wrap: wrap;
157 }
158 @media (max-width: 720px) {
159 .issues-hero { padding: 24px 20px; }
160 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
161 .issues-hero-actions { width: 100%; }
162 .issues-hero-actions .btn { flex: 1; min-width: 0; }
163 }
164
f1dc7c7Claude165 /* Mobile rules — added in the 720px sweep. Kept additive only. */
166 @media (max-width: 720px) {
167 .issues-toolbar { flex-direction: column; align-items: stretch; }
168 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
169 .issues-filter { min-height: 40px; padding: 10px 14px; }
170 .issues-row { padding: 12px 14px; gap: 10px; }
171 .issues-row-side { flex-wrap: wrap; gap: 8px; }
172 .issues-detail-hero { padding: 18px; }
173 .issues-detail-attr { font-size: 13px; }
174 .issues-composer-actions { gap: 8px; }
175 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
176 .issues-empty { padding: 40px 20px; }
177 }
178
f7ad7b8Claude179 /* Count chip + filter pills */
180 .issues-toolbar {
181 display: flex;
182 align-items: center;
183 justify-content: space-between;
184 gap: 12px;
185 flex-wrap: wrap;
186 margin: 0 0 16px;
187 }
188 .issues-filters {
189 display: inline-flex;
190 background: var(--bg-elevated);
191 border: 1px solid var(--border);
192 border-radius: 9999px;
193 padding: 4px;
194 gap: 2px;
195 }
196 .issues-filter {
197 display: inline-flex;
198 align-items: center;
199 gap: 6px;
200 padding: 6px 14px;
201 border-radius: 9999px;
202 font-size: 13px;
203 font-weight: 500;
204 color: var(--text-muted);
205 text-decoration: none;
206 transition: color 120ms ease, background 120ms ease;
207 line-height: 1.4;
208 }
209 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
210 .issues-filter.is-active {
6fd5915Claude211 background: rgba(91,110,232,0.14);
f7ad7b8Claude212 color: var(--text-strong);
213 }
214 .issues-filter-count {
215 font-variant-numeric: tabular-nums;
216 font-size: 11.5px;
217 color: var(--text-muted);
218 background: rgba(255,255,255,0.04);
219 padding: 1px 7px;
220 border-radius: 9999px;
221 }
222 .issues-filter.is-active .issues-filter-count {
6fd5915Claude223 background: rgba(91,110,232,0.18);
f7ad7b8Claude224 color: var(--text);
225 }
226
7a28902Claude227 /* Issue search form */
228 .issues-search-form {
229 display: flex;
230 align-items: center;
231 gap: 0;
232 background: var(--bg-elevated);
233 border: 1px solid var(--border);
234 border-radius: 9999px;
235 overflow: hidden;
236 flex: 1;
237 max-width: 280px;
238 transition: border-color 120ms ease;
239 }
6fd5915Claude240 .issues-search-form:focus-within { border-color: rgba(91,110,232,0.55); }
7a28902Claude241 .issues-search-input {
242 flex: 1;
243 background: transparent;
244 color: var(--text);
245 border: none;
246 outline: none;
247 padding: 6px 14px;
248 font-size: 13px;
249 font-family: var(--font-sans, inherit);
250 min-width: 0;
251 }
252 .issues-search-input::placeholder { color: var(--text-muted); }
253 .issues-search-btn {
254 background: transparent;
255 border: none;
256 color: var(--text-muted);
257 padding: 6px 12px 6px 8px;
258 cursor: pointer;
259 display: flex;
260 align-items: center;
261 }
262 .issues-search-btn:hover { color: var(--text); }
263 .issues-filter-banner {
264 margin-bottom: 12px;
265 padding: 8px 14px;
6fd5915Claude266 background: rgba(91,110,232,0.06);
267 border: 1px solid rgba(91,110,232,0.2);
7a28902Claude268 border-radius: 8px;
269 font-size: 13px;
270 color: var(--text-muted);
271 }
272 .issues-filter-clear {
6fd5915Claude273 color: var(--accent, #5b6ee8);
7a28902Claude274 text-decoration: underline;
275 cursor: pointer;
276 }
277 .issues-label-badge {
278 display: inline-block;
6fd5915Claude279 background: rgba(91,110,232,0.15);
7a28902Claude280 color: #a78bfa;
281 border-radius: 9999px;
282 padding: 1px 8px;
283 font-size: 11.5px;
284 font-weight: 600;
285 }
286
f7ad7b8Claude287 /* Issue list — modernised rows */
288 .issues-list {
289 list-style: none;
290 margin: 0;
291 padding: 0;
292 border: 1px solid var(--border);
293 border-radius: 12px;
294 overflow: hidden;
295 background: var(--bg-elevated);
296 }
297 .issues-row {
298 display: flex;
299 align-items: flex-start;
300 gap: 14px;
301 padding: 14px 18px;
302 border-bottom: 1px solid var(--border);
303 transition: background 120ms ease, transform 120ms ease;
304 }
305 .issues-row:last-child { border-bottom: none; }
6fd5915Claude306 .issues-row:hover { background: rgba(91,110,232,0.04); }
f7ad7b8Claude307 .issues-row-icon {
308 width: 18px;
309 height: 18px;
310 flex-shrink: 0;
311 margin-top: 3px;
312 display: inline-flex;
313 align-items: center;
314 justify-content: center;
315 }
316 .issues-row-icon.is-open { color: #34d399; }
6fd5915Claude317 .issues-row-icon.is-closed { color: #5b6ee8; }
f7ad7b8Claude318 .issues-row-main { flex: 1; min-width: 0; }
319 .issues-row-title {
320 font-family: var(--font-display);
321 font-size: 15.5px;
322 font-weight: 600;
323 line-height: 1.35;
324 letter-spacing: -0.012em;
325 margin: 0;
326 display: flex;
327 flex-wrap: wrap;
328 align-items: center;
329 gap: 8px;
330 }
331 .issues-row-title a {
332 color: var(--text-strong);
333 text-decoration: none;
334 transition: color 120ms ease;
335 }
336 .issues-row-title a:hover { color: var(--accent); }
337 .issues-row-meta {
338 margin-top: 5px;
339 font-size: 12.5px;
340 color: var(--text-muted);
341 line-height: 1.5;
342 }
343 .issues-row-meta strong { color: var(--text); font-weight: 600; }
344 .issues-row-side {
345 display: flex;
346 align-items: center;
347 gap: 12px;
348 color: var(--text-muted);
349 font-size: 12.5px;
350 flex-shrink: 0;
351 }
352 .issues-row-comments {
353 display: inline-flex;
354 align-items: center;
355 gap: 5px;
356 color: var(--text-muted);
357 text-decoration: none;
358 }
359 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
360
361 /* Label pills (rendered inline on titles) */
362 .issues-label {
363 display: inline-flex;
364 align-items: center;
365 padding: 2px 9px;
366 border-radius: 9999px;
367 font-size: 11.5px;
368 font-weight: 600;
369 line-height: 1.4;
6fd5915Claude370 background: rgba(91,110,232,0.10);
f7ad7b8Claude371 color: var(--text-strong);
6fd5915Claude372 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude373 letter-spacing: 0.005em;
374 }
375
376 /* Polished empty state */
377 .issues-empty {
378 margin: 0;
379 padding: 56px 32px;
380 background: var(--bg-elevated);
381 border: 1px solid var(--border);
382 border-radius: 16px;
383 text-align: center;
384 position: relative;
385 overflow: hidden;
386 }
387 .issues-empty::before {
388 content: '';
389 position: absolute;
390 top: 0; left: 0; right: 0;
391 height: 2px;
6fd5915Claude392 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude393 opacity: 0.55;
394 pointer-events: none;
395 }
396 .issues-empty-art {
397 width: 96px;
398 height: 96px;
399 margin: 0 auto 18px;
400 display: block;
401 opacity: 0.85;
402 }
403 .issues-empty-title {
404 font-family: var(--font-display);
405 font-size: 22px;
406 font-weight: 700;
407 letter-spacing: -0.018em;
408 color: var(--text-strong);
409 margin: 0 0 8px;
410 }
411 .issues-empty-sub {
412 font-size: 14.5px;
413 color: var(--text-muted);
414 line-height: 1.55;
415 margin: 0 auto 22px;
416 max-width: 460px;
417 }
418 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
419
420 /* ─── Detail page ─── */
421 .issues-detail-hero {
422 position: relative;
423 margin: 4px 0 20px;
424 padding: 22px 26px;
425 background: var(--bg-elevated);
426 border: 1px solid var(--border);
427 border-radius: 16px;
428 overflow: hidden;
429 }
430 .issues-detail-hero::before {
431 content: '';
432 position: absolute;
433 top: 0; left: 0; right: 0;
434 height: 2px;
6fd5915Claude435 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude436 opacity: 0.7;
437 pointer-events: none;
438 }
439 .issues-detail-title {
440 font-family: var(--font-display);
441 font-size: clamp(22px, 3vw, 30px);
442 font-weight: 700;
443 letter-spacing: -0.022em;
444 line-height: 1.18;
445 color: var(--text-strong);
446 margin: 0 0 12px;
447 }
448 .issues-detail-title .issues-detail-number {
449 color: var(--text-muted);
450 font-weight: 500;
451 margin-left: 8px;
452 }
453 .issues-detail-attr {
454 display: flex;
455 align-items: center;
456 gap: 12px;
457 flex-wrap: wrap;
458 font-size: 14px;
459 color: var(--text-muted);
460 }
461 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
462 .issues-state-pill {
463 display: inline-flex;
464 align-items: center;
465 gap: 6px;
466 padding: 5px 12px;
467 border-radius: 9999px;
468 font-size: 12.5px;
469 font-weight: 600;
470 line-height: 1.4;
471 letter-spacing: 0.005em;
472 }
473 .issues-state-pill.is-open {
474 background: rgba(52,211,153,0.12);
475 color: #34d399;
476 border: 1px solid rgba(52,211,153,0.35);
477 }
478 .issues-state-pill.is-closed {
479 background: rgba(182,157,255,0.12);
6fd5915Claude480 color: #5b6ee8;
f7ad7b8Claude481 border: 1px solid rgba(182,157,255,0.35);
482 }
483 .issues-detail-spacer { flex: 1; }
2c61840Claude484
485 /* Sub-issues / epics */
486 .issues-epic-crumb {
487 display: inline-flex;
488 align-items: center;
489 gap: 6px;
490 font-size: 12px;
491 color: var(--text-muted);
492 margin-bottom: var(--space-2);
493 }
494 .issues-epic-crumb a { color: var(--accent); text-decoration: none; }
495 .issues-epic-crumb a:hover { text-decoration: underline; }
496 .issues-sub-panel {
497 background: var(--bg-elevated);
498 border: 1px solid var(--border);
499 border-radius: 12px;
500 margin-bottom: var(--space-4);
501 overflow: hidden;
502 }
503 .issues-sub-panel-head {
504 display: flex;
505 align-items: center;
506 justify-content: space-between;
507 padding: var(--space-3) var(--space-4);
508 border-bottom: 1px solid var(--border);
509 font-size: 12px;
510 font-weight: 700;
511 text-transform: uppercase;
512 letter-spacing: 0.07em;
513 color: var(--text-muted);
514 }
515 .issues-sub-row {
516 display: flex;
517 align-items: center;
518 gap: var(--space-3);
519 padding: var(--space-2) var(--space-4);
520 font-size: 13px;
521 transition: background 120ms;
522 }
523 .issues-sub-row:hover { background: var(--bg-hover); }
524 .issues-sub-row-icon { font-size: 12px; flex-shrink: 0; }
525 .issues-sub-row-icon.is-open { color: #3fb950; }
526 .issues-sub-row-icon.is-closed { color: #5b6ee8; }
527 .issues-sub-row-title { flex: 1; min-width: 0; }
528 .issues-sub-row-title a { color: var(--text); text-decoration: none; font-weight: 500; }
529 .issues-sub-row-title a:hover { color: var(--accent); text-decoration: underline; }
530 .issues-sub-row-num { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
531 .issues-sub-row-author { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
532 /* Epic pill on the issue list */
533 .issues-tag-epic {
534 display: inline-flex; align-items: center; gap: 4px;
535 padding: 2px 7px;
536 font-size: 11px; font-weight: 600;
537 border-radius: 9999px;
538 background: rgba(210,153,34,0.12);
539 color: #d29922;
540 border: 1px solid rgba(210,153,34,0.35);
541 }
542 .issues-tag-child {
543 display: inline-flex; align-items: center; gap: 4px;
544 padding: 2px 7px;
545 font-size: 11px; font-weight: 600;
546 border-radius: 9999px;
547 background: rgba(88,166,255,0.10);
548 color: #58a6ff;
549 border: 1px solid rgba(88,166,255,0.25);
550 }
551
f7ad7b8Claude552 .issues-detail-labels {
553 margin-top: 12px;
554 display: flex;
555 gap: 6px;
556 flex-wrap: wrap;
557 }
558
559 /* Comment thread */
560 .issues-thread { margin-top: 18px; }
561 .issues-comment {
562 position: relative;
563 border: 1px solid var(--border);
564 border-radius: 12px;
565 overflow: hidden;
566 background: var(--bg-elevated);
567 margin-bottom: 14px;
568 transition: border-color 160ms ease, box-shadow 160ms ease;
569 }
570 .issues-comment:hover {
571 border-color: var(--border-strong, rgba(255,255,255,0.13));
572 }
573 .issues-comment-header {
574 background: var(--bg-secondary);
575 padding: 10px 16px;
576 border-bottom: 1px solid var(--border);
577 display: flex;
578 align-items: center;
579 gap: 10px;
580 font-size: 13px;
581 color: var(--text-muted);
582 }
583 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
584 .issues-comment-body { padding: 14px 18px; }
585 .issues-comment-author-pill {
586 display: inline-flex;
587 align-items: center;
588 padding: 1px 8px;
589 border-radius: 9999px;
6fd5915Claude590 background: rgba(91,110,232,0.10);
f7ad7b8Claude591 color: var(--accent);
592 font-size: 11px;
593 font-weight: 600;
594 letter-spacing: 0.02em;
595 text-transform: uppercase;
596 }
597
598 /* AI Review comment — distinct purple-accent treatment */
599 .issues-comment.is-ai {
6fd5915Claude600 border-color: rgba(91,110,232,0.45);
601 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 12px 32px -16px rgba(91,110,232,0.25);
f7ad7b8Claude602 }
603 .issues-comment.is-ai::before {
604 content: '';
605 position: absolute;
606 top: 0; left: 0; right: 0;
607 height: 2px;
6fd5915Claude608 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude609 pointer-events: none;
610 }
611 .issues-comment.is-ai .issues-comment-header {
6fd5915Claude612 background: linear-gradient(180deg, rgba(91,110,232,0.08), rgba(91,110,232,0.02));
613 border-bottom-color: rgba(91,110,232,0.22);
f7ad7b8Claude614 }
615 .issues-ai-badge {
616 display: inline-flex;
617 align-items: center;
618 gap: 5px;
619 padding: 2px 9px;
620 border-radius: 9999px;
6fd5915Claude621 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude622 color: #fff;
623 font-size: 11px;
624 font-weight: 700;
625 letter-spacing: 0.04em;
626 text-transform: uppercase;
627 line-height: 1.4;
628 }
629 .issues-ai-badge::before {
630 content: '';
631 width: 6px;
632 height: 6px;
633 border-radius: 9999px;
634 background: #fff;
635 opacity: 0.92;
636 box-shadow: 0 0 6px rgba(255,255,255,0.7);
637 }
638
a7460bfClaude639 .issues-bot-badge {
640 display: inline-flex; align-items: center; gap: 3px;
641 padding: 1px 7px;
642 font-size: 10px;
643 font-weight: 600;
644 color: var(--fg-muted);
645 background: var(--bg-elevated);
646 border: 1px solid var(--border);
647 border-radius: 9999px;
648 }
649
f7ad7b8Claude650 /* Composer */
651 .issues-composer {
652 margin-top: 22px;
653 border: 1px solid var(--border);
654 border-radius: 12px;
655 overflow: hidden;
656 background: var(--bg-elevated);
657 transition: border-color 160ms ease, box-shadow 160ms ease;
658 }
659 .issues-composer:focus-within {
6fd5915Claude660 border-color: rgba(91,110,232,0.55);
661 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
f7ad7b8Claude662 }
663 .issues-composer-header {
664 display: flex;
665 align-items: center;
666 justify-content: space-between;
667 gap: 8px;
668 padding: 10px 14px;
669 background: var(--bg-secondary);
670 border-bottom: 1px solid var(--border);
671 font-size: 12.5px;
672 color: var(--text-muted);
673 }
674 .issues-composer-tag {
675 font-weight: 600;
676 color: var(--text);
677 letter-spacing: -0.005em;
678 }
679 .issues-composer-hint a {
680 color: var(--text-muted);
681 text-decoration: none;
682 border-bottom: 1px dashed currentColor;
683 }
684 .issues-composer-hint a:hover { color: var(--accent); }
685 .issues-composer textarea {
686 display: block;
687 width: 100%;
688 border: 0;
689 background: transparent;
690 color: var(--text);
691 padding: 14px 16px;
692 font-family: var(--font-mono);
693 font-size: 13.5px;
694 line-height: 1.55;
695 resize: vertical;
696 outline: none;
697 min-height: 140px;
698 }
699 .issues-composer-actions {
700 display: flex;
701 align-items: center;
702 gap: 8px;
703 padding: 10px 14px;
704 border-top: 1px solid var(--border);
705 background: var(--bg-secondary);
706 flex-wrap: wrap;
707 }
708
709 /* Info banner inside detail */
710 .issues-info-banner {
711 margin: 0 0 14px;
712 padding: 10px 14px;
713 border-radius: 12px;
6fd5915Claude714 background: rgba(91,110,232,0.08);
715 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude716 color: var(--text);
717 font-size: 13.5px;
718 }
240c477Claude719
720 /* ─── Linked PRs ─── */
721 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
722 .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; }
723 .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; }
724 .iss-linked-pr-row:last-child { border-bottom: none; }
725 .iss-linked-pr-row:hover { background: var(--bg-hover); }
726 .iss-linked-pr-icon.is-open { color: #34d399; }
727 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
728 .iss-linked-pr-icon.is-closed { color: #8b949e; }
729 .iss-linked-pr-icon.is-draft { color: #8b949e; }
730 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
731 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
732 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
733 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
734 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
735 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
736 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude737
8d1483cClaude738 /* ─── Bulk action UI ─── */
739 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
740 .bulk-bar {
741 display: none;
742 align-items: center;
743 gap: 10px;
744 padding: 10px 16px;
745 background: var(--bg-elevated);
746 border: 1px solid var(--border-strong);
747 border-radius: var(--r-md);
748 margin-bottom: 12px;
749 position: sticky;
750 top: calc(var(--header-h) + 8px);
751 z-index: 10;
752 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
753 }
754 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
755 .bulk-bar-btn {
756 padding: 6px 12px;
757 border-radius: var(--r-sm);
758 border: 1px solid var(--border);
759 background: var(--bg-surface);
760 color: var(--text);
761 font-size: 13px;
762 cursor: pointer;
763 transition: background 120ms;
764 }
765 .bulk-bar-btn:hover { background: var(--bg-hover); }
766 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
767 .bulk-bar-clear:hover { color: var(--text); }
768
0224546Claude769 /* ─── Issue detail metadata sidebar ─── */
770 .issue-detail-layout {
771 display: flex;
772 align-items: flex-start;
773 gap: 24px;
774 }
775 .issue-detail-main {
776 flex: 1;
777 min-width: 0;
778 }
779 .issue-meta-sidebar {
780 width: 240px;
781 flex-shrink: 0;
782 display: flex;
783 flex-direction: column;
784 gap: 20px;
785 }
786 .issue-meta-section {
787 border-top: 1px solid var(--border);
788 padding-top: 14px;
789 }
790 .issue-meta-section:first-child {
791 border-top: none;
792 padding-top: 0;
793 }
794 .issue-meta-label {
795 font-size: 12px;
796 font-weight: 600;
797 color: var(--text-muted);
798 text-transform: uppercase;
799 letter-spacing: 0.06em;
800 margin-bottom: 8px;
801 }
802 .issue-meta-empty {
803 font-size: 13px;
804 color: var(--text-subtle, var(--text-muted));
805 font-style: italic;
806 }
807 .issue-meta-label-pill {
808 display: inline-flex;
809 align-items: center;
810 padding: 3px 10px;
811 border-radius: 9999px;
812 font-size: 12px;
813 font-weight: 600;
814 line-height: 1.4;
815 margin: 2px 3px 2px 0;
816 border: 1px solid transparent;
817 }
818 .issue-meta-labels {
819 display: flex;
820 flex-wrap: wrap;
821 gap: 4px;
822 }
823 .issue-meta-assignee {
824 display: flex;
825 align-items: center;
826 gap: 8px;
827 font-size: 13px;
828 color: var(--text);
829 }
830 .issue-meta-avatar {
831 width: 24px;
832 height: 24px;
833 border-radius: 9999px;
834 background: rgba(140,109,255,0.25);
835 color: var(--text-strong);
836 font-size: 11px;
837 font-weight: 700;
838 display: inline-flex;
839 align-items: center;
840 justify-content: center;
841 flex-shrink: 0;
842 text-transform: uppercase;
843 }
844 .issue-meta-milestone-link {
845 font-size: 13px;
846 color: var(--text-link, var(--accent));
847 text-decoration: none;
848 display: inline-flex;
849 align-items: center;
850 gap: 5px;
851 }
852 .issue-meta-milestone-link:hover { text-decoration: underline; }
853 @media (max-width: 720px) {
854 .issue-detail-layout { flex-direction: column; }
855 .issue-meta-sidebar { width: 100%; order: 1; }
856 .issue-detail-main { order: 0; }
857 }
858
f5b9ef5Claude859 /* ─── Sort controls (issue list) ─── */
860 .issues-sort-row {
861 display: flex;
862 align-items: center;
863 gap: 6px;
864 margin: 0 0 12px;
865 flex-wrap: wrap;
866 }
867 .issues-sort-label {
868 font-size: 12.5px;
869 color: var(--text-muted);
870 font-weight: 600;
871 margin-right: 2px;
872 }
873 .issues-sort-opt {
874 font-size: 12.5px;
875 color: var(--text-muted);
876 text-decoration: none;
877 padding: 3px 10px;
878 border-radius: 9999px;
879 border: 1px solid transparent;
880 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
881 }
882 .issues-sort-opt:hover {
883 background: var(--bg-hover);
884 color: var(--text);
885 }
886 .issues-sort-opt.is-active {
6fd5915Claude887 background: rgba(91,110,232,0.12);
f5b9ef5Claude888 color: var(--text-link);
6fd5915Claude889 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude890 font-weight: 600;
891 }
f7ad7b8Claude892`;
893
894// Pre-rendered <style> tag (constant, reused per request).
895const IssuesStyle = () => (
896 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
897);
898
899// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
900const IssuesEmptySvg = () => (
901 <svg
902 class="issues-empty-art"
903 viewBox="0 0 96 96"
904 fill="none"
905 xmlns="http://www.w3.org/2000/svg"
906 aria-hidden="true"
907 >
908 <defs>
909 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
6fd5915Claude910 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.55" />
911 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.55" />
f7ad7b8Claude912 </linearGradient>
913 </defs>
6fd5915Claude914 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(91,110,232,0.04)" />
f7ad7b8Claude915 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
916 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
917 </svg>
918);
919
920// Detect AI Triage comments by the marker the triage helper writes.
921function isAiTriageComment(body: string | null | undefined): boolean {
922 if (!body) return false;
923 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
924}
925
79136bbClaude926// Helper to resolve repo from :owner/:repo params
927async function resolveRepo(ownerName: string, repoName: string) {
928 const [owner] = await db
929 .select()
930 .from(users)
931 .where(eq(users.username, ownerName))
932 .limit(1);
933 if (!owner) return null;
934
935 const [repo] = await db
936 .select()
937 .from(repositories)
938 .where(
939 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
940 )
941 .limit(1);
942 if (!repo) return null;
943
944 return { owner, repo };
945}
946
8d1483cClaude947// Bulk issue operations (close / reopen multiple issues at once)
948issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
949 const { owner: ownerName, repo: repoName } = c.req.param();
950 const user = c.get("user")!;
951 const body = await c.req.parseBody();
952 const action = String(body.action || "");
953 const rawNumbers = body["numbers[]"];
954 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
955 .map(n => parseInt(String(n), 10))
956 .filter(n => !isNaN(n) && n > 0);
957
958 if (!numbers.length || !["close","reopen"].includes(action)) {
959 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
960 }
961
962 const resolved = await resolveRepo(ownerName, repoName);
963 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
964
965 const newState = action === "close" ? "closed" : "open";
966 await db.update(issues)
967 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
968 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
969
970 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
971 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
972});
973
79136bbClaude974// Issue list
04f6b7fClaude975issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude976 const { owner: ownerName, repo: repoName } = c.req.param();
977 const user = c.get("user");
978 const state = c.req.query("state") || "open";
7a28902Claude979 const searchQ = (c.req.query("q") || "").trim();
980 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude981 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude982 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude983 // Bounded pagination — unbounded selects ran a full table scan + O(n)
984 // sort on every page load; with 10k+ issues the request would hang.
985 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
986 const page = Math.max(1, Number(c.req.query("page")) || 1);
987 const offset = (page - 1) * perPage;
79136bbClaude988
f1dc7c7Claude989 // ── Loading skeleton (flag-gated) ──
990 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
991 // user see the shape of the issue list before the DB count + select
992 // resolve. Behind a flag — we don't ship flashes.
993 if (c.req.query("skeleton") === "1") {
994 return c.html(
995 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
996 <IssuesStyle />
997 <RepoHeader owner={ownerName} repo={repoName} />
998 <IssueNav owner={ownerName} repo={repoName} active="issues" />
999 <style
1000 dangerouslySetInnerHTML={{
1001 __html: `
1002 .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; }
1003 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1004 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
1005 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
1006 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
1007 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
1008 .issues-skel-row { height: 62px; border-radius: 10px; }
1009 `,
1010 }}
1011 />
1012 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
1013 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
1014 <div class="issues-skel-list" aria-hidden="true">
1015 {Array.from({ length: 8 }).map(() => (
1016 <div class="issues-skel issues-skel-row" />
1017 ))}
1018 </div>
1019 <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">
1020 Loading issues for {ownerName}/{repoName}…
1021 </span>
1022 </Layout>
1023 );
1024 }
1025
79136bbClaude1026 const resolved = await resolveRepo(ownerName, repoName);
1027 if (!resolved) {
1028 return c.html(
1029 <Layout title="Not Found" user={user}>
bb0f894Claude1030 <EmptyState title="Repository not found" />
79136bbClaude1031 </Layout>,
1032 404
1033 );
1034 }
1035
1036 const { repo } = resolved;
1037
7a28902Claude1038 // If label filter is set, find issue IDs that have that label
1039 let labelFilteredIds: string[] | null = null;
1040 if (labelFilter) {
1041 const [matchedLabel] = await db
1042 .select({ id: labels.id })
1043 .from(labels)
1044 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
1045 .limit(1);
1046 if (matchedLabel) {
1047 const rows = await db
1048 .select({ issueId: issueLabels.issueId })
1049 .from(issueLabels)
1050 .where(eq(issueLabels.labelId, matchedLabel.id));
1051 labelFilteredIds = rows.map((r) => r.issueId);
1052 } else {
1053 labelFilteredIds = []; // no matches
1054 }
1055 }
1056
85c4e13Claude1057 // If milestone filter is set, resolve the milestone ID
1058 let milestoneFilterId: string | null = null;
1059 if (milestoneFilter) {
1060 const [matchedMs] = await db
1061 .select({ id: milestones.id })
1062 .from(milestones)
1063 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
1064 .limit(1);
1065 milestoneFilterId = matchedMs?.id ?? null;
1066 }
1067
7a28902Claude1068 const baseWhere = and(
1069 eq(issues.repositoryId, repo.id),
1070 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
1071 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
1072 labelFilteredIds !== null && labelFilteredIds.length > 0
1073 ? inArray(issues.id, labelFilteredIds)
1074 : labelFilteredIds !== null && labelFilteredIds.length === 0
1075 ? sql`false`
85c4e13Claude1076 : undefined,
1077 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude1078 );
1079
79136bbClaude1080 const issueList = await db
1081 .select({
1082 issue: issues,
1083 author: { username: users.username },
1084 })
1085 .from(issues)
1086 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude1087 .where(baseWhere)
f5b9ef5Claude1088 .orderBy(
1089 sort === "oldest" ? asc(issues.createdAt)
1090 : sort === "updated" ? desc(issues.updatedAt)
1091 : desc(issues.createdAt) // newest (default)
1092 )
6ea2109Claude1093 .limit(perPage)
1094 .offset(offset);
79136bbClaude1095
2c61840Claude1096 // Batch-load sub-issue counts for epics in this list page
1097 const issueIds = issueList.map((r) => r.issue.id);
1098 const subCountMap = new Map<string, number>();
1099 const parentNumberMap = new Map<string, number>(); // childIssueId → parentIssueNumber
1100 if (issueIds.length > 0) {
1101 const [subRows, parentRows] = await Promise.all([
1102 // Count children per parent (to show "N sub-issues" on epics)
1103 db
1104 .select({
1105 parentId: issues.parentIssueId,
1106 cnt: sql<number>`count(*)::int`,
1107 })
1108 .from(issues)
1109 .where(
1110 and(
1111 inArray(issues.parentIssueId, issueIds),
1112 eq(issues.repositoryId, repo.id)
1113 )
1114 )
1115 .groupBy(issues.parentIssueId),
1116 // Resolve parent numbers for children (to show "Part of #N")
1117 issueList
1118 .filter((r) => r.issue.parentIssueId !== null)
1119 .length > 0
1120 ? db
1121 .select({ id: issues.id, number: issues.number })
1122 .from(issues)
1123 .where(
1124 inArray(
1125 issues.id,
1126 issueList
1127 .filter((r) => r.issue.parentIssueId !== null)
1128 .map((r) => r.issue.parentIssueId as string)
1129 )
1130 )
1131 : Promise.resolve([]),
1132 ]);
1133 subRows.forEach((r) => {
1134 if (r.parentId) subCountMap.set(r.parentId, Number(r.cnt));
1135 });
1136 parentRows.forEach((p) => parentNumberMap.set(p.id, p.number));
1137 }
1138
79136bbClaude1139 // Count open/closed
1140 const [counts] = await db
1141 .select({
1142 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
1143 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
1144 })
1145 .from(issues)
1146 .where(eq(issues.repositoryId, repo.id));
1147
85c4e13Claude1148 // Count open milestones for the "N Milestones" link
1149 const [milestoneCounts] = await db
1150 .select({
1151 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
1152 })
1153 .from(milestones)
1154 .where(eq(milestones.repositoryId, repo.id));
1155
cb5a796Claude1156 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
1157 const pendingCountList = viewerIsOwnerOnList
1158 ? await countPendingForRepo(repo.id)
1159 : 0;
1160
79136bbClaude1161 return c.html(
1162 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1163 <IssuesStyle />
79136bbClaude1164 <RepoHeader owner={ownerName} repo={repoName} />
1165 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1166 <PendingCommentsBanner
1167 owner={ownerName}
1168 repo={repoName}
1169 count={pendingCountList}
1170 />
f7ad7b8Claude1171 <section class="issues-hero">
1172 <div class="issues-hero-bg" aria-hidden="true">
1173 <div class="issues-hero-orb" />
1174 </div>
1175 <div class="issues-hero-inner">
1176 <div class="issues-hero-text">
1177 <div class="issues-hero-eyebrow">
1178 Issue tracker \u00B7{" "}
1179 <span class="issues-hero-repo">
1180 {ownerName}/{repoName}
1181 </span>
1182 </div>
1183 <h1 class="issues-hero-title">
1184 Track <span class="gradient-text">what matters</span>.
1185 </h1>
1186 <p class="issues-hero-sub">
1187 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1188 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
1189 : `${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.`}
1190 </p>
1191 </div>
1192 <div class="issues-hero-actions">
1193 {user && (
1194 <a
1195 href={`/${ownerName}/${repoName}/issues/new`}
1196 class="btn btn-primary"
1197 >
1198 + New issue
1199 </a>
1200 )}
85c4e13Claude1201 <a
1202 href={`/${ownerName}/${repoName}/milestones`}
1203 class="btn"
1204 title="View milestones for this repository"
1205 >
1206 Milestones
1207 {Number(milestoneCounts?.open ?? 0) > 0 && (
6fd5915Claude1208 <span style="margin-left:5px;font-size:11.5px;background:rgba(91,110,232,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
85c4e13Claude1209 {Number(milestoneCounts?.open ?? 0)}
1210 </span>
1211 )}
1212 </a>
f7ad7b8Claude1213 <a href={`/${ownerName}/${repoName}`} class="btn">
1214 Back to code
1215 </a>
1216 </div>
1217 </div>
1218 </section>
1219
1220 <div class="issues-toolbar">
1221 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1222 <a
1223 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1224 href={`/${ownerName}/${repoName}/issues?state=open`}
1225 role="tab"
1226 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1227 >
f7ad7b8Claude1228 <span aria-hidden="true">{"\u25CB"}</span>
1229 <span>Open</span>
1230 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1231 </a>
1232 <a
1233 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1234 href={`/${ownerName}/${repoName}/issues?state=closed`}
1235 role="tab"
1236 aria-selected={state === "closed" ? "true" : "false"}
1237 >
1238 <span aria-hidden="true">{"\u2713"}</span>
1239 <span>Closed</span>
1240 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1241 </a>
1242 </div>
7a28902Claude1243 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1244 <input type="hidden" name="state" value={state} />
1245 <input
1246 type="search"
1247 name="q"
1248 value={searchQ}
1249 placeholder="Search issues\u2026"
1250 class="issues-search-input"
1251 aria-label="Search issues by title"
1252 />
1253 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1254 <button type="submit" class="issues-search-btn" aria-label="Search">
1255 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1256 <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" />
1257 </svg>
1258 </button>
1259 </form>
f7ad7b8Claude1260 </div>
7a28902Claude1261 {(searchQ || labelFilter) && (
1262 <div class="issues-filter-banner">
1263 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1264 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1265 {" \u00B7 "}
1266 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1267 </div>
1268 )}
f7ad7b8Claude1269
f5b9ef5Claude1270 <div class="issues-sort-row">
1271 <span class="issues-sort-label">Sort:</span>
1272 {(["newest", "oldest", "updated"] as const).map((s) => (
1273 <a
1274 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1275 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1276 >
1277 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1278 </a>
1279 ))}
1280 </div>
1281
79136bbClaude1282 {issueList.length === 0 ? (
f7ad7b8Claude1283 <div class="issues-empty">
1284 <IssuesEmptySvg />
1285 <h2 class="issues-empty-title">
1286 {state === "closed"
1287 ? "No closed issues yet"
1288 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1289 ? "No issues \u2014 yet"
1290 : "Nothing open right now"}
1291 </h2>
1292 <p class="issues-empty-sub">
1293 {state === "closed"
1294 ? "Closed issues will show up here once the team starts shipping fixes."
1295 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1296 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1297 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1298 </p>
1299 <div class="issues-empty-cta">
1300 {user && state !== "closed" && (
1301 <a
1302 href={`/${ownerName}/${repoName}/issues/new`}
1303 class="btn btn-primary"
1304 >
1305 + Open the first issue
1306 </a>
1307 )}
79136bbClaude1308 {state === "closed" && (
f7ad7b8Claude1309 <a
1310 href={`/${ownerName}/${repoName}/issues?state=open`}
1311 class="btn"
1312 >
1313 View open issues
1314 </a>
79136bbClaude1315 )}
f7ad7b8Claude1316 </div>
1317 </div>
79136bbClaude1318 ) : (
8d1483cClaude1319 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1320 <input type="hidden" name="action" id="bulk-action" value="" />
1321 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1322 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1323 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1324 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1325 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1326 </div>
1327 <ul class="issues-list">
1328 {issueList.map(({ issue, author }) => (
1329 <li class="issues-row">
1330 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1331 <div
1332 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1333 aria-hidden="true"
1334 title={issue.state === "open" ? "Open" : "Closed"}
1335 >
1336 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1337 </div>
8d1483cClaude1338 <div class="issues-row-main">
1339 <h3 class="issues-row-title">
1340 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1341 {issue.title}
1342 </a>
2c61840Claude1343 {subCountMap.has(issue.id) && (
1344 <span class="issues-tag-epic" title={`Epic with ${subCountMap.get(issue.id)} sub-issue${subCountMap.get(issue.id) === 1 ? "" : "s"}`}>
1345 📌 Epic · {subCountMap.get(issue.id)}
1346 </span>
1347 )}
1348 {issue.parentIssueId && parentNumberMap.has(issue.parentIssueId) && (
1349 <span class="issues-tag-child" title={`Sub-issue of #${parentNumberMap.get(issue.parentIssueId)}`}>
1350 ↳ #{parentNumberMap.get(issue.parentIssueId)}
1351 </span>
1352 )}
8d1483cClaude1353 </h3>
1354 <div class="issues-row-meta">
1355 #{issue.number} opened by{" "}
1356 <strong>{author.username}</strong>{" "}
1357 {formatRelative(issue.createdAt)}
1358 </div>
1359 </div>
1360 </li>
1361 ))}
1362 </ul>
1363 <script dangerouslySetInnerHTML={{ __html: `
1364function bulkSubmit(action){
1365 document.getElementById('bulk-action').value=action;
1366 document.getElementById('bulk-form').submit();
1367}
1368function bulkClear(){
1369 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1370 updateBulkBar();
1371}
1372function updateBulkBar(){
1373 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1374 var bar=document.getElementById('bulk-bar');
1375 var cnt=document.getElementById('bulk-bar-count');
1376 if(bar){bar.style.display=checked>0?'flex':'none';}
1377 if(cnt){cnt.textContent=checked+' selected';}
1378}
1379document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1380` }} />
1381 </form>
79136bbClaude1382 )}
1383 </Layout>
1384 );
1385});
1386
1387// New issue form
1388issueRoutes.get(
1389 "/:owner/:repo/issues/new",
1390 softAuth,
1391 requireAuth,
04f6b7fClaude1392 requireRepoAccess("write"),
79136bbClaude1393 async (c) => {
1394 const { owner: ownerName, repo: repoName } = c.req.param();
1395 const user = c.get("user")!;
1396 const error = c.req.query("error");
24cf2caClaude1397 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1398
85c4e13Claude1399 // Load open milestones for the dropdown
1400 const resolved = await resolveRepo(ownerName, repoName);
1401 const openMilestones = resolved
1402 ? await db
1403 .select({ id: milestones.id, title: milestones.title })
1404 .from(milestones)
1405 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1406 .orderBy(milestones.title)
1407 : [];
1408
79136bbClaude1409 return c.html(
1410 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1411 <IssuesStyle />
79136bbClaude1412 <RepoHeader owner={ownerName} repo={repoName} />
1413 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1414 <Container maxWidth={800}>
f7ad7b8Claude1415 <section class="issues-hero" style="margin-top:4px">
1416 <div class="issues-hero-bg" aria-hidden="true">
1417 <div class="issues-hero-orb" />
1418 </div>
1419 <div class="issues-hero-inner">
1420 <div class="issues-hero-text">
1421 <div class="issues-hero-eyebrow">
1422 New issue ·{" "}
1423 <span class="issues-hero-repo">
1424 {ownerName}/{repoName}
1425 </span>
1426 </div>
1427 <h1 class="issues-hero-title">
1428 File <span class="gradient-text">it cleanly</span>.
1429 </h1>
1430 <p class="issues-hero-sub">
1431 AI Triage will read the body the moment you submit and post
1432 suggested labels, priority, and possible duplicates within
1433 seconds. You stay in control — nothing is applied.
1434 </p>
1435 </div>
1436 </div>
1437 </section>
79136bbClaude1438 {error && (
bb0f894Claude1439 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1440 )}
0316dbbClaude1441 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1442 <FormGroup>
1443 <Input
79136bbClaude1444 type="text"
1445 name="title"
1446 required
1447 placeholder="Title"
bb0f894Claude1448 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1449 aria-label="Issue title"
79136bbClaude1450 />
bb0f894Claude1451 </FormGroup>
1452 <FormGroup>
1453 <TextArea
79136bbClaude1454 name="body"
1455 rows={12}
1456 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1457 mono
79136bbClaude1458 />
bb0f894Claude1459 </FormGroup>
85c4e13Claude1460 {openMilestones.length > 0 && (
1461 <FormGroup>
1462 <label
1463 for="milestone_id"
1464 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1465 >
1466 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1467 </label>
1468 <select
1469 id="milestone_id"
1470 name="milestone_id"
1471 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"
1472 >
1473 <option value="">No milestone</option>
1474 {openMilestones.map((ms) => (
1475 <option value={ms.id}>{ms.title}</option>
1476 ))}
1477 </select>
1478 </FormGroup>
1479 )}
bb0f894Claude1480 <Button type="submit" variant="primary">
79136bbClaude1481 Submit new issue
bb0f894Claude1482 </Button>
1483 </Form>
1484 </Container>
79136bbClaude1485 </Layout>
1486 );
1487 }
1488);
1489
1490// Create issue
1491issueRoutes.post(
1492 "/:owner/:repo/issues/new",
1493 softAuth,
1494 requireAuth,
04f6b7fClaude1495 requireRepoAccess("write"),
79136bbClaude1496 async (c) => {
1497 const { owner: ownerName, repo: repoName } = c.req.param();
1498 const user = c.get("user")!;
1499 const body = await c.req.parseBody();
1500 const title = String(body.title || "").trim();
1501 const issueBody = String(body.body || "").trim();
85c4e13Claude1502 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1503
1504 if (!title) {
1505 return c.redirect(
1506 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1507 );
1508 }
1509
1510 const resolved = await resolveRepo(ownerName, repoName);
1511 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1512
85c4e13Claude1513 // Validate milestone belongs to this repo if provided
1514 let validatedMilestoneId: string | null = null;
1515 if (milestoneIdRaw) {
1516 const [msRow] = await db
1517 .select({ id: milestones.id })
1518 .from(milestones)
1519 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1520 .limit(1);
1521 validatedMilestoneId = msRow?.id ?? null;
1522 }
1523
79136bbClaude1524 const [issue] = await db
1525 .insert(issues)
1526 .values({
1527 repositoryId: resolved.repo.id,
1528 authorId: user.id,
1529 title,
1530 body: issueBody || null,
85c4e13Claude1531 milestoneId: validatedMilestoneId,
79136bbClaude1532 })
1533 .returning();
1534
1535 // Update issue count
1536 await db
1537 .update(repositories)
1538 .set({ issueCount: resolved.repo.issueCount + 1 })
1539 .where(eq(repositories.id, resolved.repo.id));
1540
a74f4edccanty labs1541 void fireWebhooks(resolved.repo.id, "issue", {
1542 action: "opened",
1543 number: issue.number,
1544 title,
1545 });
1546
a9ada5fClaude1547 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1548 // suggested labels, priority, summary, and a possible-duplicate
1549 // callout. Suggestions only — nothing applied automatically.
1550 triggerIssueTriage({
1551 ownerName,
1552 repoName,
1553 repositoryId: resolved.repo.id,
1554 issueId: issue.id,
1555 issueNumber: issue.number,
1556 authorId: user.id,
1557 title,
1558 body: issueBody,
a28cedeClaude1559 }).catch((err) => {
1560 console.warn(
1561 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1562 err instanceof Error ? err.message : err
1563 );
1564 });
a9ada5fClaude1565
79136bbClaude1566 return c.redirect(
1567 `/${ownerName}/${repoName}/issues/${issue.number}`
1568 );
1569 }
1570);
1571
1572// View single issue
04f6b7fClaude1573issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1574 const { owner: ownerName, repo: repoName } = c.req.param();
1575 const issueNum = parseInt(c.req.param("number"), 10);
1576 const user = c.get("user");
1577
1578 const resolved = await resolveRepo(ownerName, repoName);
1579 if (!resolved) {
1580 return c.html(
1581 <Layout title="Not Found" user={user}>
bb0f894Claude1582 <EmptyState title="Not found" />
79136bbClaude1583 </Layout>,
1584 404
1585 );
1586 }
1587
1588 const [issue] = await db
1589 .select()
1590 .from(issues)
1591 .where(
1592 and(
1593 eq(issues.repositoryId, resolved.repo.id),
1594 eq(issues.number, issueNum)
1595 )
1596 )
1597 .limit(1);
1598
1599 if (!issue) {
1600 return c.html(
1601 <Layout title="Not Found" user={user}>
bb0f894Claude1602 <EmptyState title="Issue not found" />
79136bbClaude1603 </Layout>,
1604 404
1605 );
1606 }
1607
1608 const [author] = await db
1609 .select()
1610 .from(users)
1611 .where(eq(users.id, issue.authorId))
1612 .limit(1);
1613
cb5a796Claude1614 // Get comments. We pull every row and filter by moderation_status +
1615 // viewer identity client-side (in the JSX below) so a moderator/owner
1616 // sees them all while non-author viewers only ever see 'approved'.
1617 const allComments = await db
79136bbClaude1618 .select({
1619 comment: issueComments,
cb5a796Claude1620 author: { id: users.id, username: users.username },
79136bbClaude1621 })
1622 .from(issueComments)
1623 .innerJoin(users, eq(issueComments.authorId, users.id))
1624 .where(eq(issueComments.issueId, issue.id))
1625 .orderBy(asc(issueComments.createdAt));
1626
cb5a796Claude1627 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1628 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1629 // Owner always sees everything (so they can spot conversations going
1630 // sideways even before they hit the queue page).
1631 if (viewerIsOwner) return true;
1632 if (comment.moderationStatus === "approved") return true;
1633 // The comment's own author sees their pending comment so they know
1634 // it landed (with an "Awaiting approval" badge in the render below).
1635 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1636 return true;
1637 }
1638 return false;
1639 });
1640
1641 // Pending banner — when the viewer is the repo owner, show a strip at
1642 // the top of the page nudging them to the moderation queue. Inline on
1643 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1644 const pendingCount = viewerIsOwner
1645 ? await countPendingForRepo(resolved.repo.id)
1646 : 0;
1647
2c61840Claude1648 // Sub-issues / epics — load parent issue (if this is a child) and
1649 // any child issues (if this is an epic), in parallel with reactions.
1650 const [parentIssueRow, subIssueRows] = await Promise.all([
1651 issue.parentIssueId
1652 ? db
1653 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
1654 .from(issues)
1655 .where(eq(issues.id, issue.parentIssueId))
1656 .limit(1)
1657 .then((r) => r[0] ?? null)
1658 : Promise.resolve(null),
1659 db
1660 .select({
1661 id: issues.id,
1662 number: issues.number,
1663 title: issues.title,
1664 state: issues.state,
1665 authorUsername: users.username,
1666 })
1667 .from(issues)
1668 .innerJoin(users, eq(issues.authorId, users.id))
1669 .where(and(eq(issues.parentIssueId, issue.id), eq(issues.repositoryId, resolved.repo.id)))
1670 .orderBy(asc(issues.number)),
1671 ]);
1672
6fc53bdClaude1673 // Load reactions for the issue + each comment in parallel.
1674 const [issueReactions, ...commentReactions] = await Promise.all([
1675 summariseReactions("issue", issue.id, user?.id),
1676 ...comments.map((row) =>
1677 summariseReactions("issue_comment", row.comment.id, user?.id)
1678 ),
1679 ]);
1680
79136bbClaude1681 const canManage =
1682 user &&
1683 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1684 const info = c.req.query("info");
79136bbClaude1685
0224546Claude1686 // Labels attached to this issue.
1687 const issueDetailLabels = await db
1688 .select({ id: labels.id, name: labels.name, color: labels.color })
1689 .from(issueLabels)
1690 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1691 .where(eq(issueLabels.issueId, issue.id));
1692
1693 // Milestone for this issue (if any).
1694 const issueMilestone = issue.milestoneId
1695 ? await db
1696 .select({ id: milestones.id, title: milestones.title })
1697 .from(milestones)
1698 .where(eq(milestones.id, issue.milestoneId))
1699 .limit(1)
1700 .then((rows) => rows[0] ?? null)
1701 : null;
1702
240c477Claude1703 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1704 const issueRefPattern = `%#${issue.number}%`;
1705 const linkedPrs = await db
1706 .select({
1707 number: pullRequests.number,
1708 title: pullRequests.title,
1709 state: pullRequests.state,
1710 isDraft: pullRequests.isDraft,
1711 })
1712 .from(pullRequests)
1713 .where(
1714 and(
1715 eq(pullRequests.repositoryId, resolved.repo.id),
1716 or(
1717 ilike(pullRequests.title, issueRefPattern),
1718 ilike(pullRequests.body, issueRefPattern),
1719 )
1720 )
1721 )
1722 .orderBy(desc(pullRequests.createdAt))
1723 .limit(8);
1724
79136bbClaude1725 return c.html(
1726 <Layout
1727 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1728 user={user}
1729 >
f7ad7b8Claude1730 <IssuesStyle />
79136bbClaude1731 <RepoHeader owner={ownerName} repo={repoName} />
1732 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1733 <PendingCommentsBanner
1734 owner={ownerName}
1735 repo={repoName}
1736 count={pendingCount}
1737 />
b584e52Claude1738 <div
1739 id="live-comment-banner"
1740 class="alert"
1741 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1742 >
1743 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1744 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1745 reload to view
1746 </a>
1747 </div>
1748 <script
1749 dangerouslySetInnerHTML={{
1750 __html: liveCommentBannerScript({
1751 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1752 bannerElementId: "live-comment-banner",
1753 }),
1754 }}
1755 />
829a046Claude1756 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1757 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1758 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1759 <div class="issue-detail">
58915a9Claude1760 {info && (
f7ad7b8Claude1761 <div class="issues-info-banner">
58915a9Claude1762 {decodeURIComponent(info)}
1763 </div>
1764 )}
f7ad7b8Claude1765
1766 <section class="issues-detail-hero">
2c61840Claude1767 {parentIssueRow && (
1768 <div class="issues-epic-crumb">
1769 <span>📌 Part of epic</span>
1770 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`}>
1771 #{parentIssueRow.number} — {parentIssueRow.title}
1772 </a>
1773 </div>
1774 )}
f7ad7b8Claude1775 <h1 class="issues-detail-title">
1776 {issue.title}
1777 <span class="issues-detail-number">#{issue.number}</span>
1778 </h1>
1779 <div class="issues-detail-attr">
1780 <span
1781 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1782 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1783 >
f7ad7b8Claude1784 <span aria-hidden="true">
1785 {issue.state === "open" ? "\u25CB" : "\u2713"}
1786 </span>
1787 {issue.state === "open" ? "Open" : "Closed"}
1788 </span>
1789 <span>
1790 <strong>{author?.username || "unknown"}</strong> opened this
1791 issue {formatRelative(issue.createdAt)}
1792 </span>
1793 <span class="issues-detail-spacer" />
1794 {issue.state === "open" && user && user.id === resolved.owner.id && (
1795 <a
1796 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1797 class="btn btn-primary"
1798 style="font-size:13px;padding:6px 12px"
1799 title="Generate a draft pull request from this issue using Claude"
1800 >
1801 Build with AI
1802 </a>
1803 )}
f928118Claude1804 {issue.state === "open" && user && isAiAvailable() && (
1805 <form
1806 method="post"
1807 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1808 style="display:inline"
1809 title="Let AI implement this feature automatically"
1810 >
1811 <button
1812 type="submit"
1813 class="btn btn-primary"
6fd5915Claude1814 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);border:none;cursor:pointer"
f928118Claude1815 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1816 >
1817 Ship It
1818 </button>
1819 </form>
1820 )}
c922868Claude1821 {issue.state === "open" && user && (
1822 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1823 <summary
1824 class="btn"
1825 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1826 title="Create a new branch for this issue"
1827 >
1828 Create branch
1829 </summary>
1830 <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)">
1831 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1832 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1833 <input
1834 type="text"
1835 name="branchName"
1836 class="input"
1837 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1838 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1839 pattern="[a-zA-Z0-9._\\-/]+"
1840 required
1841 />
1842 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1843 Create branch
1844 </button>
1845 </form>
1846 </div>
1847 </details>
1848 )}
f7ad7b8Claude1849 </div>
1850 </section>
1851
0224546Claude1852 <div class="issue-detail-layout">
1853 <div class="issue-detail-main">
2c61840Claude1854
1855 {subIssueRows.length > 0 && (
1856 <div class="issues-sub-panel">
1857 <div class="issues-sub-panel-head">
1858 <span>Sub-issues</span>
1859 <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">
1860 {subIssueRows.filter((s) => s.state === "open").length}/{subIssueRows.length} open
1861 </span>
1862 </div>
1863 {subIssueRows.map((sub) => (
1864 <div class="issues-sub-row" key={sub.id}>
1865 <span class={`issues-sub-row-icon ${sub.state === "open" ? "is-open" : "is-closed"}`}>
1866 {sub.state === "open" ? "○" : "✓"}
1867 </span>
1868 <span class="issues-sub-row-title">
1869 <a href={`/${ownerName}/${repoName}/issues/${sub.number}`}>{sub.title}</a>
1870 </span>
1871 <span class="issues-sub-row-author">{sub.authorUsername}</span>
1872 <span class="issues-sub-row-num">#{sub.number}</span>
1873 </div>
1874 ))}
1875 </div>
1876 )}
1877
f7ad7b8Claude1878 <div class="issues-thread">
1879 {issue.body && (
1880 <article class="issues-comment">
1881 <header class="issues-comment-header">
1882 <strong>{author?.username || "unknown"}</strong>
1883 <span class="issues-comment-author-pill">Author</span>
1884 <span>commented {formatRelative(issue.createdAt)}</span>
1885 </header>
1886 <div class="issues-comment-body">
1887 <div
1888 class="markdown-body"
1889 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1890 />
1891 </div>
1892 </article>
fbf4aefClaude1893 )}
79136bbClaude1894
f7ad7b8Claude1895 {comments.map(({ comment, author: commentAuthor }) => {
1896 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1897 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1898 return (
cb5a796Claude1899 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1900 <header class="issues-comment-header">
1901 <strong>{commentAuthor.username}</strong>
a7460bfClaude1902 {commentAuthor.username === BOT_USERNAME && (
1903 <span class="issues-bot-badge">&#x1F916; bot</span>
1904 )}
f7ad7b8Claude1905 {isAi ? (
1906 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1907 AI Review
1908 </span>
1909 ) : null}
cb5a796Claude1910 {isPending ? (
1911 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1912 Awaiting approval
1913 </span>
1914 ) : null}
f7ad7b8Claude1915 <span>commented {formatRelative(comment.createdAt)}</span>
1916 </header>
1917 <div class="issues-comment-body">
1918 <div
1919 class="markdown-body"
1920 dangerouslySetInnerHTML={{
1921 __html: renderMarkdown(comment.body),
1922 }}
1923 />
1924 </div>
1925 </article>
1926 );
1927 })}
1928 </div>
79136bbClaude1929
240c477Claude1930 {linkedPrs.length > 0 && (
1931 <div class="iss-linked-prs">
1932 <div class="iss-linked-prs-head">
1933 <span>Linked pull requests</span>
1934 <span>{linkedPrs.length}</span>
1935 </div>
1936 {linkedPrs.map((lpr) => {
1937 const prState = lpr.isDraft ? "draft" : lpr.state;
1938 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1939 return (
1940 <a
1941 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1942 class="iss-linked-pr-row"
1943 >
1944 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1945 <span class="iss-linked-pr-title">{lpr.title}</span>
1946 <span class="iss-linked-pr-num">#{lpr.number}</span>
1947 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1948 </a>
1949 );
1950 })}
1951 </div>
1952 )}
1953
79136bbClaude1954 {user && (
f7ad7b8Claude1955 <form
1956 class="issues-composer"
1957 method="post"
1958 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1959 >
1960 <div class="issues-composer-header">
1961 <span class="issues-composer-tag">Add a comment</span>
1962 <span class="issues-composer-hint">
1963 <a
1964 href="https://docs.github.com/en/get-started/writing-on-github"
1965 target="_blank"
1966 rel="noopener noreferrer"
1967 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1968 >
1969 Markdown supported
1970 </a>
1971 </span>
1972 </div>
1973 <textarea
1974 name="body"
1975 rows={6}
1976 required
6cd2f0eClaude1977 data-md-preview=""
f7ad7b8Claude1978 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1979 />
1980 <div class="issues-composer-actions">
1981 <button type="submit" class="btn btn-primary">
1982 Comment
1983 </button>
1984 {canManage && (
1985 <button
1986 type="submit"
1987 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1988 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1989 >
1990 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1991 </button>
f7ad7b8Claude1992 )}
1993 {canManage && issue.state === "open" && (
1994 <button
1995 type="submit"
1996 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1997 formnovalidate
1998 class="btn"
1999 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
2000 >
2001 Re-run AI triage
2002 </button>
2003 )}
2004 </div>
2005 </form>
79136bbClaude2006 )}
0224546Claude2007 </div>{/* /issue-detail-main */}
2008
2009 {/* Metadata sidebar — Labels, Assignees, Milestone */}
2010 <aside class="issue-meta-sidebar">
2011 {/* Labels */}
2012 <div class="issue-meta-section">
2013 <div class="issue-meta-label">Labels</div>
2014 {issueDetailLabels.length === 0 ? (
2015 <span class="issue-meta-empty">None yet</span>
2016 ) : (
2017 <div class="issue-meta-labels">
2018 {issueDetailLabels.map((lbl) => {
2019 // Compute luminance from hex color to choose dark/light text
2020 const hex = lbl.color.replace("#", "");
2021 const r = parseInt(hex.slice(0, 2), 16) / 255;
2022 const g = parseInt(hex.slice(2, 4), 16) / 255;
2023 const b = parseInt(hex.slice(4, 6), 16) / 255;
2024 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
2025 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
2026 return (
2027 <span
2028 class="issue-meta-label-pill"
2029 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
2030 >
2031 {lbl.name}
2032 </span>
2033 );
2034 })}
2035 </div>
2036 )}
2037 </div>
2038
2039 {/* Assignees — field not yet in schema; placeholder */}
2040 <div class="issue-meta-section">
2041 <div class="issue-meta-label">Assignees</div>
2042 <span class="issue-meta-empty">No one assigned</span>
2043 </div>
2044
2045 {/* Milestone */}
2046 <div class="issue-meta-section">
2047 <div class="issue-meta-label">Milestone</div>
2048 {issueMilestone ? (
2049 <a
2050 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
2051 class="issue-meta-milestone-link"
2052 >
2053 &#x25CE; {issueMilestone.title}
2054 </a>
2055 ) : (
2056 <span class="issue-meta-empty">No milestone</span>
2057 )}
2058 </div>
2c61840Claude2059 {/* Epic / parent issue */}
2060 {viewerIsOwner && (
2061 <div class="issue-meta-section">
2062 <div class="issue-meta-label">Epic (parent issue)</div>
2063 {parentIssueRow ? (
2064 <div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">
2065 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`} style="color:var(--accent);text-decoration:none">
2066 #{parentIssueRow.number} {parentIssueRow.title.slice(0, 40)}{parentIssueRow.title.length > 40 ? "…" : ""}
2067 </a>
2068 </div>
2069 ) : (
2070 <span class="issue-meta-empty">None</span>
2071 )}
2072 <form
2073 method="post"
2074 action={`/${ownerName}/${repoName}/issues/${issue.number}/set-parent`}
2075 style="display:flex;gap:6px;margin-top:6px"
2076 >
2077 <input
2078 type="number"
2079 name="parent_number"
2080 placeholder={parentIssueRow ? "Clear (enter 0)" : "Issue #"}
2081 style="width:100%;padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-inset);color:var(--text)"
2082 min="0"
2083 />
2084 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px;white-space:nowrap">
2085 Set
2086 </button>
2087 </form>
2088 </div>
2089 )}
0224546Claude2090 </aside>
2091 </div>{/* /issue-detail-layout */}
79136bbClaude2092 </div>
641aa42Claude2093 {/* Issue keyboard hints bar */}
2094 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
2095 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
2096 </div>
2097 <style dangerouslySetInnerHTML={{ __html: `
2098 .kbd-hints {
2099 position: fixed;
2100 bottom: 0;
2101 left: 0;
2102 right: 0;
2103 z-index: 90;
2104 padding: 6px 24px;
2105 background: var(--bg-secondary);
2106 border-top: 1px solid var(--border);
2107 font-size: 12px;
2108 color: var(--text-muted);
2109 display: flex;
2110 align-items: center;
2111 gap: 8px;
2112 flex-wrap: wrap;
2113 }
2114 .kbd-hints kbd {
2115 font-family: var(--font-mono);
2116 font-size: 10px;
2117 background: var(--bg-elevated);
2118 border: 1px solid var(--border);
2119 border-bottom-width: 2px;
2120 border-radius: 4px;
2121 padding: 1px 5px;
2122 color: var(--text);
2123 line-height: 1.5;
2124 }
2125 main { padding-bottom: 40px; }
2126 ` }} />
2127 {/* Repo context commands for command palette */}
2128 <script
2129 id="cmdk-repo-context"
2130 dangerouslySetInnerHTML={{
2131 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
2132 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
2133 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
2134 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
2135 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
2136 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
2137 ])};`,
2138 }}
2139 />
2140 {/* Issue keyboard shortcuts */}
2141 <script dangerouslySetInnerHTML={{ __html: `
2142 (function(){
2143 function isTyping(t){
2144 t = t || {};
2145 var tag = (t.tagName || '').toLowerCase();
2146 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
2147 }
2148 document.addEventListener('keydown', function(e){
2149 if (isTyping(e.target)) return;
2150 if (e.metaKey || e.ctrlKey || e.altKey) return;
2151 if (e.key === 'c') {
2152 e.preventDefault();
2153 var box = document.querySelector('textarea[name="body"]');
2154 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
2155 }
2156 if (e.key === 'e') {
2157 e.preventDefault();
2158 // Focus the issue title and make it editable if possible
2159 var titleEl = document.querySelector('.issues-title');
2160 if (titleEl) titleEl.scrollIntoView({block:'center'});
2161 }
2162 if (e.key === 'x') {
2163 e.preventDefault();
2164 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
2165 if (closeBtn) {
2166 var confirmed = window.confirm('Close/reopen this issue?');
2167 if (confirmed) closeBtn.click();
2168 }
2169 }
2170 if (e.key === 'Escape') {
2171 var focused = document.activeElement;
2172 if (focused) focused.blur();
2173 }
2174 });
2175 })();
2176 ` }} />
79136bbClaude2177 </Layout>
2178 );
2179});
2180
cb5a796Claude2181// Add comment.
2182//
2183// Permission model: we accept comments from ANY authenticated user (read
2184// access required — public repos satisfy that, private repos still need a
2185// collaborator row). The moderation gate in `decideInitialStatus`
2186// determines whether the comment is published immediately or queued for
2187// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
2188// "anti-impersonation" rationale.
79136bbClaude2189issueRoutes.post(
2190 "/:owner/:repo/issues/:number/comment",
2191 softAuth,
2192 requireAuth,
cb5a796Claude2193 requireRepoAccess("read"),
79136bbClaude2194 async (c) => {
2195 const { owner: ownerName, repo: repoName } = c.req.param();
2196 const issueNum = parseInt(c.req.param("number"), 10);
2197 const user = c.get("user")!;
2198 const body = await c.req.parseBody();
2199 const commentBody = String(body.body || "").trim();
2200
2201 if (!commentBody) {
2202 return c.redirect(
2203 `/${ownerName}/${repoName}/issues/${issueNum}`
2204 );
2205 }
2206
2207 const resolved = await resolveRepo(ownerName, repoName);
2208 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2209
2210 const [issue] = await db
2211 .select()
2212 .from(issues)
2213 .where(
2214 and(
2215 eq(issues.repositoryId, resolved.repo.id),
2216 eq(issues.number, issueNum)
2217 )
2218 )
2219 .limit(1);
2220
2221 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2222
cb5a796Claude2223 // Decide moderation status BEFORE insert so the row lands in the right
2224 // initial state. Collaborators, the issue author, and trusted users
2225 // get 'approved'; banned users get 'rejected' (silent drop); everyone
2226 // else gets 'pending'.
2227 const decision = await decideInitialStatus({
2228 commenterUserId: user.id,
2229 repositoryId: resolved.repo.id,
2230 kind: "issue",
2231 threadId: issue.id,
2232 });
2233
d4ac5c3Claude2234 const [inserted] = await db
2235 .insert(issueComments)
2236 .values({
2237 issueId: issue.id,
2238 authorId: user.id,
2239 body: commentBody,
cb5a796Claude2240 moderationStatus: decision.status,
d4ac5c3Claude2241 })
2242 .returning();
2243
cb5a796Claude2244 // Live update only when visible. Don't leak pending/rejected via SSE.
2245 if (inserted && decision.status === "approved") {
a74f4edccanty labs2246 void fireWebhooks(resolved.repo.id, "issue", {
2247 action: "commented",
2248 number: issueNum,
2249 });
d4ac5c3Claude2250 try {
2251 const { publish } = await import("../lib/sse");
2252 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
2253 event: "issue-comment",
2254 data: {
2255 issueId: issue.id,
2256 commentId: inserted.id,
2257 authorId: user.id,
2258 authorUsername: user.username,
2259 },
2260 });
2261 } catch {
2262 /* SSE is best-effort */
2263 }
b7ecb14Claude2264 // Notify the issue author — fire-and-forget, never blocks the response.
2265 if (issue.authorId && issue.authorId !== user.id) {
2266 void import("../lib/notify").then(({ createNotification }) =>
2267 createNotification({
2268 userId: issue.authorId,
2269 type: "issue_comment",
2270 title: `New comment on "${issue.title}"`,
2271 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
2272 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
2273 repoId: resolved.repo.id,
2274 })
2275 ).catch(() => { /* never block the response */ });
2276 }
d4ac5c3Claude2277 }
79136bbClaude2278
cb5a796Claude2279 if (decision.status === "pending") {
2280 // Notify repo owner that a comment is awaiting review.
2281 void notifyOwnerOfPendingComment({
2282 repositoryId: resolved.repo.id,
2283 commenterUsername: user.username,
2284 kind: "issue",
2285 threadNumber: issueNum,
2286 ownerUsername: ownerName,
2287 repoName,
2288 });
2289 return c.redirect(
2290 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2291 );
2292 }
2293 if (decision.status === "rejected") {
2294 // Silent ban — the commenter is on the banned list. Don't reveal
2295 // the gate; just send them back to the page as if it posted.
2296 return c.redirect(
2297 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2298 );
2299 }
2300
79136bbClaude2301 return c.redirect(
2302 `/${ownerName}/${repoName}/issues/${issueNum}`
2303 );
2304 }
2305);
2306
2307// Close issue
2308issueRoutes.post(
2309 "/:owner/:repo/issues/:number/close",
2310 softAuth,
2311 requireAuth,
04f6b7fClaude2312 requireRepoAccess("write"),
79136bbClaude2313 async (c) => {
2314 const { owner: ownerName, repo: repoName } = c.req.param();
2315 const issueNum = parseInt(c.req.param("number"), 10);
2316
2317 const resolved = await resolveRepo(ownerName, repoName);
2318 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2319
2320 await db
2321 .update(issues)
2322 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2323 .where(
2324 and(
2325 eq(issues.repositoryId, resolved.repo.id),
2326 eq(issues.number, issueNum)
2327 )
2328 );
a74f4edccanty labs2329 void fireWebhooks(resolved.repo.id, "issue", {
2330 action: "closed",
2331 number: issueNum,
2332 });
79136bbClaude2333
2334 return c.redirect(
2335 `/${ownerName}/${repoName}/issues/${issueNum}`
2336 );
2337 }
2338);
2339
2340// Reopen issue
2341issueRoutes.post(
2342 "/:owner/:repo/issues/:number/reopen",
2343 softAuth,
2344 requireAuth,
04f6b7fClaude2345 requireRepoAccess("write"),
79136bbClaude2346 async (c) => {
2347 const { owner: ownerName, repo: repoName } = c.req.param();
2348 const issueNum = parseInt(c.req.param("number"), 10);
2349
2350 const resolved = await resolveRepo(ownerName, repoName);
2351 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2352
2353 await db
2354 .update(issues)
2355 .set({ state: "open", closedAt: null, updatedAt: new Date() })
2356 .where(
2357 and(
2358 eq(issues.repositoryId, resolved.repo.id),
2359 eq(issues.number, issueNum)
2360 )
2361 );
a74f4edccanty labs2362 void fireWebhooks(resolved.repo.id, "issue", {
2363 action: "reopened",
2364 number: issueNum,
2365 });
79136bbClaude2366
2367 return c.redirect(
2368 `/${ownerName}/${repoName}/issues/${issueNum}`
2369 );
2370 }
2371);
2372
2373// Shared nav component with issues tab
2374const IssueNav = ({
2375 owner,
2376 repo,
2377 active,
2378}: {
2379 owner: string;
2380 repo: string;
2381 active: "code" | "commits" | "issues";
2382}) => (
bb0f894Claude2383 <TabNav
2384 tabs={[
2385 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2386 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2387 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2388 ]}
2389 />
79136bbClaude2390);
2391
58915a9Claude2392// Re-run AI triage on demand (e.g. after the issue body has been edited).
2393// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2394issueRoutes.post(
2395 "/:owner/:repo/issues/:number/ai-retriage",
2396 softAuth,
2397 requireAuth,
2398 requireRepoAccess("write"),
2399 async (c) => {
2400 const { owner: ownerName, repo: repoName } = c.req.param();
2401 const issueNum = parseInt(c.req.param("number"), 10);
2402 const user = c.get("user")!;
2403 const resolved = await resolveRepo(ownerName, repoName);
2404 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2405
2406 const [issue] = await db
2407 .select()
2408 .from(issues)
2409 .where(
2410 and(
2411 eq(issues.repositoryId, resolved.repo.id),
2412 eq(issues.number, issueNum)
2413 )
2414 )
2415 .limit(1);
2416 if (!issue) {
2417 return c.redirect(`/${ownerName}/${repoName}/issues`);
2418 }
2419
2420 triggerIssueTriage(
2421 {
2422 ownerName,
2423 repoName,
2424 repositoryId: resolved.repo.id,
2425 issueId: issue.id,
2426 issueNumber: issue.number,
2427 authorId: user.id,
2428 title: issue.title,
2429 body: issue.body || "",
2430 },
2431 { force: true }
a28cedeClaude2432 ).catch((err) => {
2433 console.warn(
2434 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2435 err instanceof Error ? err.message : err
2436 );
2437 });
58915a9Claude2438
2439 return c.redirect(
2440 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2441 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2442 )}`
2443 );
2444 }
2445);
2446
c922868Claude2447// ─── Create branch from issue ─────────────────────────────────────────────────
2448// POST /:owner/:repo/issues/:number/branch
2449// Creates a new git branch from the repo default branch, pre-named after the
2450// issue. Write access required. Zero-config — no new DB tables.
2451
2452issueRoutes.post(
2453 "/:owner/:repo/issues/:number/branch",
2454 softAuth,
2455 requireAuth,
2456 requireRepoAccess("write"),
2457 async (c) => {
2458 const { owner: ownerName, repo: repoName } = c.req.param();
2459 const issueNum = parseInt(c.req.param("number"), 10);
2460
2461 const resolved = await resolveRepo(ownerName, repoName);
2462 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2463
2464 const [issue] = await db
2465 .select({ id: issues.id, number: issues.number, title: issues.title })
2466 .from(issues)
2467 .where(
2468 and(
2469 eq(issues.repositoryId, resolved.repo.id),
2470 eq(issues.number, issueNum)
2471 )
2472 )
2473 .limit(1);
2474
2475 if (!issue) {
2476 return c.redirect(`/${ownerName}/${repoName}/issues`);
2477 }
2478
2479 const body = await c.req.formData().catch(() => null);
2480 const rawName = (body?.get("branchName") as string | null)?.trim();
2481
2482 // Derive a slug from the issue title
2483 const titleSlug = issue.title
2484 .toLowerCase()
2485 .replace(/[^a-z0-9]+/g, "-")
2486 .replace(/^-+|-+$/g, "")
2487 .slice(0, 40);
2488 const suggested = `issue-${issue.number}-${titleSlug}`;
2489 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2490 ? rawName
2491 : suggested;
2492
2493 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2494 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2495
2496 if (!sha) {
2497 return c.redirect(
2498 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2499 "Cannot create branch: repository has no commits yet."
2500 )}`
2501 );
2502 }
2503
2504 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2505 if (!ok) {
2506 return c.redirect(
2507 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2508 `Failed to create branch '${branchName}'. It may already exist.`
2509 )}`
2510 );
2511 }
2512
2513 return c.redirect(
2514 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2515 `Branch '${branchName}' created from ${defaultBranch}.`
2516 )}`
2517 );
2518 }
2519);
2520
2c61840Claude2521// POST /:owner/:repo/issues/:number/set-parent
2522// Sets or clears the parent (epic) for an issue.
2523issueRoutes.post(
2524 "/:owner/:repo/issues/:number/set-parent",
2525 softAuth,
2526 requireAuth,
2527 requireRepoAccess("write"),
2528 async (c) => {
2529 const { owner: ownerName, repo: repoName } = c.req.param();
2530 const issueNum = parseInt(c.req.param("number"), 10);
2531 const form = await c.req.formData();
2532 const parentNum = form.get("parent_number")?.toString().trim() ?? "";
2533
2534 const resolved = await resolveRepo(ownerName, repoName);
2535 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2536
2537 const [issue] = await db
2538 .select({ id: issues.id })
2539 .from(issues)
2540 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, issueNum)))
2541 .limit(1);
2542 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2543
2544 // Clear parent
2545 if (!parentNum || parentNum === "0") {
2546 await db.update(issues).set({ parentIssueId: null }).where(eq(issues.id, issue.id));
2547 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Removed from epic.")}`);
2548 }
2549
2550 const pNum = parseInt(parentNum, 10);
2551 if (isNaN(pNum) || pNum === issueNum) {
2552 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Invalid parent issue number.")}`);
2553 }
2554
2555 const [parent] = await db
2556 .select({ id: issues.id })
2557 .from(issues)
2558 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, pNum)))
2559 .limit(1);
2560 if (!parent) {
2561 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Issue #${pNum} not found.`)}`);
2562 }
2563
2564 await db.update(issues).set({ parentIssueId: parent.id }).where(eq(issues.id, issue.id));
2565 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Added to epic #${pNum}.`)}`);
2566 }
2567);
2568
79136bbClaude2569export default issueRoutes;
2570export { IssueNav };