Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

issues.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

issues.tsxBlame2433 lines · 4 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";
f6730d0ccantynz-alt20import {
21 RepoHeader,
22 RepoNav,
23 PageHeader as GxPageHeader,
24 Button as GxButton,
25 Badge as GxBadge,
26 sharedComponentStyles,
27} from "../views/components";
cb5a796Claude28import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude29import { ReactionsBar } from "../views/reactions";
30import { summariseReactions } from "../lib/reactions";
24cf2caClaude31import { loadIssueTemplate } from "../lib/templates";
79136bbClaude32import { renderMarkdown } from "../lib/markdown";
b584e52Claude33import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude34import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude35import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude36import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude37import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude38import { softAuth, requireAuth } from "../middleware/auth";
39import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude40import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude41import {
42 decideInitialStatus,
43 notifyOwnerOfPendingComment,
44 countPendingForRepo,
45} from "../lib/comment-moderation";
bb0f894Claude46import {
47 Flex,
48 Container,
49 PageHeader,
50 Form,
51 FormGroup,
52 Input,
53 TextArea,
54 Button,
55 LinkButton,
56 Badge,
57 EmptyState,
58 TabNav,
59 FilterTabs,
60 List,
61 ListItem,
62 Alert,
63 CommentBox,
64 CommentForm,
65 formatRelative,
66} from "../views/ui";
c922868Claude67import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude68import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude69import { isAiAvailable } from "../lib/ai-client";
79136bbClaude70
71const issueRoutes = new Hono<AuthEnv>();
72
f7ad7b8Claude73// ---------------------------------------------------------------------------
74// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
75// other routes/shared views. All design tokens come from :root in layout.tsx.
76// ---------------------------------------------------------------------------
77const issuesStyles = `
f1dc7c7Claude78 /* Mobile rules — added in the 720px sweep. Kept additive only. */
79 @media (max-width: 720px) {
80 .issues-toolbar { flex-direction: column; align-items: stretch; }
81 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
82 .issues-filter { min-height: 40px; padding: 10px 14px; }
83 .issues-row { padding: 12px 14px; gap: 10px; }
84 .issues-row-side { flex-wrap: wrap; gap: 8px; }
85 .issues-detail-hero { padding: 18px; }
86 .issues-detail-attr { font-size: 13px; }
87 .issues-composer-actions { gap: 8px; }
88 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
89 .issues-empty { padding: 40px 20px; }
90 }
91
f7ad7b8Claude92 /* Count chip + filter pills */
93 .issues-toolbar {
94 display: flex;
95 align-items: center;
96 justify-content: space-between;
97 gap: 12px;
98 flex-wrap: wrap;
99 margin: 0 0 16px;
100 }
101 .issues-filters {
102 display: inline-flex;
103 background: var(--bg-elevated);
104 border: 1px solid var(--border);
105 border-radius: 9999px;
106 padding: 4px;
107 gap: 2px;
108 }
109 .issues-filter {
110 display: inline-flex;
111 align-items: center;
112 gap: 6px;
113 padding: 6px 14px;
114 border-radius: 9999px;
115 font-size: 13px;
116 font-weight: 500;
117 color: var(--text-muted);
118 text-decoration: none;
119 transition: color 120ms ease, background 120ms ease;
120 line-height: 1.4;
121 }
122 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
123 .issues-filter.is-active {
6fd5915Claude124 background: rgba(91,110,232,0.14);
f7ad7b8Claude125 color: var(--text-strong);
126 }
127 .issues-filter-count {
128 font-variant-numeric: tabular-nums;
129 font-size: 11.5px;
130 color: var(--text-muted);
131 background: rgba(255,255,255,0.04);
132 padding: 1px 7px;
133 border-radius: 9999px;
134 }
135 .issues-filter.is-active .issues-filter-count {
6fd5915Claude136 background: rgba(91,110,232,0.18);
f7ad7b8Claude137 color: var(--text);
138 }
139
7a28902Claude140 /* Issue search form */
141 .issues-search-form {
142 display: flex;
143 align-items: center;
144 gap: 0;
145 background: var(--bg-elevated);
146 border: 1px solid var(--border);
147 border-radius: 9999px;
148 overflow: hidden;
149 flex: 1;
150 max-width: 280px;
151 transition: border-color 120ms ease;
152 }
6fd5915Claude153 .issues-search-form:focus-within { border-color: rgba(91,110,232,0.55); }
7a28902Claude154 .issues-search-input {
155 flex: 1;
156 background: transparent;
157 color: var(--text);
158 border: none;
159 outline: none;
160 padding: 6px 14px;
161 font-size: 13px;
162 font-family: var(--font-sans, inherit);
163 min-width: 0;
164 }
165 .issues-search-input::placeholder { color: var(--text-muted); }
166 .issues-search-btn {
167 background: transparent;
168 border: none;
169 color: var(--text-muted);
170 padding: 6px 12px 6px 8px;
171 cursor: pointer;
172 display: flex;
173 align-items: center;
174 }
175 .issues-search-btn:hover { color: var(--text); }
176 .issues-filter-banner {
177 margin-bottom: 12px;
178 padding: 8px 14px;
6fd5915Claude179 background: rgba(91,110,232,0.06);
180 border: 1px solid rgba(91,110,232,0.2);
7a28902Claude181 border-radius: 8px;
182 font-size: 13px;
183 color: var(--text-muted);
184 }
185 .issues-filter-clear {
6fd5915Claude186 color: var(--accent, #5b6ee8);
7a28902Claude187 text-decoration: underline;
188 cursor: pointer;
189 }
190 .issues-label-badge {
191 display: inline-block;
6fd5915Claude192 background: rgba(91,110,232,0.15);
7a28902Claude193 color: #a78bfa;
194 border-radius: 9999px;
195 padding: 1px 8px;
196 font-size: 11.5px;
197 font-weight: 600;
198 }
199
f7ad7b8Claude200 /* Issue list — modernised rows */
201 .issues-list {
202 list-style: none;
203 margin: 0;
204 padding: 0;
205 border: 1px solid var(--border);
206 border-radius: 12px;
207 overflow: hidden;
208 background: var(--bg-elevated);
209 }
210 .issues-row {
211 display: flex;
212 align-items: flex-start;
213 gap: 14px;
214 padding: 14px 18px;
215 border-bottom: 1px solid var(--border);
216 transition: background 120ms ease, transform 120ms ease;
217 }
218 .issues-row:last-child { border-bottom: none; }
6fd5915Claude219 .issues-row:hover { background: rgba(91,110,232,0.04); }
f7ad7b8Claude220 .issues-row-icon {
221 width: 18px;
222 height: 18px;
223 flex-shrink: 0;
224 margin-top: 3px;
225 display: inline-flex;
226 align-items: center;
227 justify-content: center;
228 }
e589f77ccantynz-alt229 .issues-row-icon.is-open { color: var(--green); }
230 .issues-row-icon.is-closed { color: var(--accent); }
f7ad7b8Claude231 .issues-row-main { flex: 1; min-width: 0; }
232 .issues-row-title {
233 font-family: var(--font-display);
234 font-size: 15.5px;
235 font-weight: 600;
236 line-height: 1.35;
237 letter-spacing: -0.012em;
238 margin: 0;
239 display: flex;
240 flex-wrap: wrap;
241 align-items: center;
242 gap: 8px;
243 }
244 .issues-row-title a {
245 color: var(--text-strong);
246 text-decoration: none;
247 transition: color 120ms ease;
248 }
249 .issues-row-title a:hover { color: var(--accent); }
250 .issues-row-meta {
251 margin-top: 5px;
252 font-size: 12.5px;
253 color: var(--text-muted);
254 line-height: 1.5;
255 }
256 .issues-row-meta strong { color: var(--text); font-weight: 600; }
257 .issues-row-side {
258 display: flex;
259 align-items: center;
260 gap: 12px;
261 color: var(--text-muted);
262 font-size: 12.5px;
263 flex-shrink: 0;
264 }
265 .issues-row-comments {
266 display: inline-flex;
267 align-items: center;
268 gap: 5px;
269 color: var(--text-muted);
270 text-decoration: none;
271 }
272 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
273
274 /* Label pills (rendered inline on titles) */
275 .issues-label {
276 display: inline-flex;
277 align-items: center;
278 padding: 2px 9px;
279 border-radius: 9999px;
280 font-size: 11.5px;
281 font-weight: 600;
282 line-height: 1.4;
6fd5915Claude283 background: rgba(91,110,232,0.10);
f7ad7b8Claude284 color: var(--text-strong);
6fd5915Claude285 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude286 letter-spacing: 0.005em;
287 }
288
289 /* Polished empty state */
290 .issues-empty {
291 margin: 0;
292 padding: 56px 32px;
293 background: var(--bg-elevated);
294 border: 1px solid var(--border);
295 border-radius: 16px;
296 text-align: center;
297 position: relative;
298 overflow: hidden;
299 }
300 .issues-empty::before {
301 content: '';
302 position: absolute;
303 top: 0; left: 0; right: 0;
304 height: 2px;
6fd5915Claude305 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude306 opacity: 0.55;
307 pointer-events: none;
308 }
309 .issues-empty-art {
310 width: 96px;
311 height: 96px;
312 margin: 0 auto 18px;
313 display: block;
314 opacity: 0.85;
315 }
316 .issues-empty-title {
317 font-family: var(--font-display);
318 font-size: 22px;
319 font-weight: 700;
320 letter-spacing: -0.018em;
321 color: var(--text-strong);
322 margin: 0 0 8px;
323 }
324 .issues-empty-sub {
325 font-size: 14.5px;
326 color: var(--text-muted);
327 line-height: 1.55;
328 margin: 0 auto 22px;
329 max-width: 460px;
330 }
331 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
332
333 /* ─── Detail page ─── */
334 .issues-detail-hero {
335 position: relative;
336 margin: 4px 0 20px;
337 padding: 22px 26px;
338 background: var(--bg-elevated);
339 border: 1px solid var(--border);
340 border-radius: 16px;
341 overflow: hidden;
342 }
343 .issues-detail-hero::before {
344 content: '';
345 position: absolute;
346 top: 0; left: 0; right: 0;
347 height: 2px;
6fd5915Claude348 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude349 opacity: 0.7;
350 pointer-events: none;
351 }
352 .issues-detail-title {
353 font-family: var(--font-display);
354 font-size: clamp(22px, 3vw, 30px);
355 font-weight: 700;
356 letter-spacing: -0.022em;
357 line-height: 1.18;
358 color: var(--text-strong);
359 margin: 0 0 12px;
360 }
361 .issues-detail-title .issues-detail-number {
362 color: var(--text-muted);
363 font-weight: 500;
364 margin-left: 8px;
365 }
366 .issues-detail-attr {
367 display: flex;
368 align-items: center;
369 gap: 12px;
370 flex-wrap: wrap;
371 font-size: 14px;
372 color: var(--text-muted);
373 }
374 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
375 .issues-detail-spacer { flex: 1; }
2c61840Claude376
377 /* Sub-issues / epics */
378 .issues-epic-crumb {
379 display: inline-flex;
380 align-items: center;
381 gap: 6px;
382 font-size: 12px;
383 color: var(--text-muted);
384 margin-bottom: var(--space-2);
385 }
386 .issues-epic-crumb a { color: var(--accent); text-decoration: none; }
387 .issues-epic-crumb a:hover { text-decoration: underline; }
388 .issues-sub-panel {
389 background: var(--bg-elevated);
390 border: 1px solid var(--border);
391 border-radius: 12px;
392 margin-bottom: var(--space-4);
393 overflow: hidden;
394 }
395 .issues-sub-panel-head {
396 display: flex;
397 align-items: center;
398 justify-content: space-between;
399 padding: var(--space-3) var(--space-4);
400 border-bottom: 1px solid var(--border);
401 font-size: 12px;
402 font-weight: 700;
403 text-transform: uppercase;
404 letter-spacing: 0.07em;
405 color: var(--text-muted);
406 }
407 .issues-sub-row {
408 display: flex;
409 align-items: center;
410 gap: var(--space-3);
411 padding: var(--space-2) var(--space-4);
412 font-size: 13px;
413 transition: background 120ms;
414 }
415 .issues-sub-row:hover { background: var(--bg-hover); }
416 .issues-sub-row-icon { font-size: 12px; flex-shrink: 0; }
e589f77ccantynz-alt417 .issues-sub-row-icon.is-open { color: var(--green); }
418 .issues-sub-row-icon.is-closed { color: var(--accent); }
2c61840Claude419 .issues-sub-row-title { flex: 1; min-width: 0; }
420 .issues-sub-row-title a { color: var(--text); text-decoration: none; font-weight: 500; }
421 .issues-sub-row-title a:hover { color: var(--accent); text-decoration: underline; }
422 .issues-sub-row-num { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
423 .issues-sub-row-author { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
424 /* Epic pill on the issue list */
425 .issues-tag-epic {
426 display: inline-flex; align-items: center; gap: 4px;
427 padding: 2px 7px;
428 font-size: 11px; font-weight: 600;
429 border-radius: 9999px;
430 background: rgba(210,153,34,0.12);
431 color: #d29922;
432 border: 1px solid rgba(210,153,34,0.35);
433 }
434 .issues-tag-child {
435 display: inline-flex; align-items: center; gap: 4px;
436 padding: 2px 7px;
437 font-size: 11px; font-weight: 600;
438 border-radius: 9999px;
439 background: rgba(88,166,255,0.10);
440 color: #58a6ff;
441 border: 1px solid rgba(88,166,255,0.25);
442 }
443
f7ad7b8Claude444 .issues-detail-labels {
445 margin-top: 12px;
446 display: flex;
447 gap: 6px;
448 flex-wrap: wrap;
449 }
450
451 /* Comment thread */
452 .issues-thread { margin-top: 18px; }
453 .issues-comment {
454 position: relative;
455 border: 1px solid var(--border);
456 border-radius: 12px;
457 overflow: hidden;
458 background: var(--bg-elevated);
459 margin-bottom: 14px;
460 transition: border-color 160ms ease, box-shadow 160ms ease;
461 }
462 .issues-comment:hover {
463 border-color: var(--border-strong, rgba(255,255,255,0.13));
464 }
465 .issues-comment-header {
466 background: var(--bg-secondary);
467 padding: 10px 16px;
468 border-bottom: 1px solid var(--border);
469 display: flex;
470 align-items: center;
471 gap: 10px;
472 font-size: 13px;
473 color: var(--text-muted);
474 }
475 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
476 .issues-comment-body { padding: 14px 18px; }
477 .issues-comment-author-pill {
478 display: inline-flex;
479 align-items: center;
480 padding: 1px 8px;
481 border-radius: 9999px;
6fd5915Claude482 background: rgba(91,110,232,0.10);
f7ad7b8Claude483 color: var(--accent);
484 font-size: 11px;
485 font-weight: 600;
486 letter-spacing: 0.02em;
487 text-transform: uppercase;
488 }
489
490 /* AI Review comment — distinct purple-accent treatment */
491 .issues-comment.is-ai {
6fd5915Claude492 border-color: rgba(91,110,232,0.45);
493 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 12px 32px -16px rgba(91,110,232,0.25);
f7ad7b8Claude494 }
495 .issues-comment.is-ai::before {
496 content: '';
497 position: absolute;
498 top: 0; left: 0; right: 0;
499 height: 2px;
6fd5915Claude500 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude501 pointer-events: none;
502 }
503 .issues-comment.is-ai .issues-comment-header {
6fd5915Claude504 background: linear-gradient(180deg, rgba(91,110,232,0.08), rgba(91,110,232,0.02));
505 border-bottom-color: rgba(91,110,232,0.22);
f7ad7b8Claude506 }
507 .issues-ai-badge {
508 display: inline-flex;
509 align-items: center;
510 gap: 5px;
511 padding: 2px 9px;
512 border-radius: 9999px;
6fd5915Claude513 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude514 color: #fff;
515 font-size: 11px;
516 font-weight: 700;
517 letter-spacing: 0.04em;
518 text-transform: uppercase;
519 line-height: 1.4;
520 }
521 .issues-ai-badge::before {
522 content: '';
523 width: 6px;
524 height: 6px;
525 border-radius: 9999px;
526 background: #fff;
527 opacity: 0.92;
528 box-shadow: 0 0 6px rgba(255,255,255,0.7);
529 }
530
a7460bfClaude531 .issues-bot-badge {
532 display: inline-flex; align-items: center; gap: 3px;
533 padding: 1px 7px;
534 font-size: 10px;
535 font-weight: 600;
536 color: var(--fg-muted);
537 background: var(--bg-elevated);
538 border: 1px solid var(--border);
539 border-radius: 9999px;
540 }
541
f7ad7b8Claude542 /* Composer */
543 .issues-composer {
544 margin-top: 22px;
545 border: 1px solid var(--border);
546 border-radius: 12px;
547 overflow: hidden;
548 background: var(--bg-elevated);
549 transition: border-color 160ms ease, box-shadow 160ms ease;
550 }
551 .issues-composer:focus-within {
6fd5915Claude552 border-color: rgba(91,110,232,0.55);
553 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
f7ad7b8Claude554 }
555 .issues-composer-header {
556 display: flex;
557 align-items: center;
558 justify-content: space-between;
559 gap: 8px;
560 padding: 10px 14px;
561 background: var(--bg-secondary);
562 border-bottom: 1px solid var(--border);
563 font-size: 12.5px;
564 color: var(--text-muted);
565 }
566 .issues-composer-tag {
567 font-weight: 600;
568 color: var(--text);
569 letter-spacing: -0.005em;
570 }
571 .issues-composer-hint a {
572 color: var(--text-muted);
573 text-decoration: none;
574 border-bottom: 1px dashed currentColor;
575 }
576 .issues-composer-hint a:hover { color: var(--accent); }
577 .issues-composer textarea {
578 display: block;
579 width: 100%;
580 border: 0;
581 background: transparent;
582 color: var(--text);
583 padding: 14px 16px;
584 font-family: var(--font-mono);
585 font-size: 13.5px;
586 line-height: 1.55;
587 resize: vertical;
588 outline: none;
589 min-height: 140px;
590 }
591 .issues-composer-actions {
592 display: flex;
593 align-items: center;
594 gap: 8px;
595 padding: 10px 14px;
596 border-top: 1px solid var(--border);
597 background: var(--bg-secondary);
598 flex-wrap: wrap;
599 }
600
601 /* Info banner inside detail */
602 .issues-info-banner {
603 margin: 0 0 14px;
604 padding: 10px 14px;
605 border-radius: 12px;
6fd5915Claude606 background: rgba(91,110,232,0.08);
607 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude608 color: var(--text);
609 font-size: 13.5px;
610 }
240c477Claude611
612 /* ─── Linked PRs ─── */
613 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
614 .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; }
615 .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; }
616 .iss-linked-pr-row:last-child { border-bottom: none; }
617 .iss-linked-pr-row:hover { background: var(--bg-hover); }
e589f77ccantynz-alt618 .iss-linked-pr-icon.is-open { color: var(--green); }
240c477Claude619 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
620 .iss-linked-pr-icon.is-closed { color: #8b949e; }
621 .iss-linked-pr-icon.is-draft { color: #8b949e; }
622 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
623 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
624 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
e589f77ccantynz-alt625 .iss-linked-pr-state.is-open { color: var(--green); background: rgba(52,211,153,0.10); }
240c477Claude626 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
627 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
628 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude629
8d1483cClaude630 /* ─── Bulk action UI ─── */
631 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
632 .bulk-bar {
633 display: none;
634 align-items: center;
635 gap: 10px;
636 padding: 10px 16px;
637 background: var(--bg-elevated);
638 border: 1px solid var(--border-strong);
639 border-radius: var(--r-md);
640 margin-bottom: 12px;
641 position: sticky;
642 top: calc(var(--header-h) + 8px);
643 z-index: 10;
644 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
645 }
646 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
647 .bulk-bar-btn {
648 padding: 6px 12px;
649 border-radius: var(--r-sm);
650 border: 1px solid var(--border);
651 background: var(--bg-surface);
652 color: var(--text);
653 font-size: 13px;
654 cursor: pointer;
655 transition: background 120ms;
656 }
657 .bulk-bar-btn:hover { background: var(--bg-hover); }
658 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
659 .bulk-bar-clear:hover { color: var(--text); }
660
0224546Claude661 /* ─── Issue detail metadata sidebar ─── */
662 .issue-detail-layout {
663 display: flex;
664 align-items: flex-start;
665 gap: 24px;
666 }
667 .issue-detail-main {
668 flex: 1;
669 min-width: 0;
670 }
671 .issue-meta-sidebar {
672 width: 240px;
673 flex-shrink: 0;
674 display: flex;
675 flex-direction: column;
676 gap: 20px;
677 }
678 .issue-meta-section {
679 border-top: 1px solid var(--border);
680 padding-top: 14px;
681 }
682 .issue-meta-section:first-child {
683 border-top: none;
684 padding-top: 0;
685 }
686 .issue-meta-label {
687 font-size: 12px;
688 font-weight: 600;
689 color: var(--text-muted);
690 text-transform: uppercase;
691 letter-spacing: 0.06em;
692 margin-bottom: 8px;
693 }
694 .issue-meta-empty {
695 font-size: 13px;
696 color: var(--text-subtle, var(--text-muted));
697 font-style: italic;
698 }
699 .issue-meta-label-pill {
700 display: inline-flex;
701 align-items: center;
702 padding: 3px 10px;
703 border-radius: 9999px;
704 font-size: 12px;
705 font-weight: 600;
706 line-height: 1.4;
707 margin: 2px 3px 2px 0;
708 border: 1px solid transparent;
709 }
710 .issue-meta-labels {
711 display: flex;
712 flex-wrap: wrap;
713 gap: 4px;
714 }
715 .issue-meta-assignee {
716 display: flex;
717 align-items: center;
718 gap: 8px;
719 font-size: 13px;
720 color: var(--text);
721 }
722 .issue-meta-avatar {
723 width: 24px;
724 height: 24px;
725 border-radius: 9999px;
726 background: rgba(140,109,255,0.25);
727 color: var(--text-strong);
728 font-size: 11px;
729 font-weight: 700;
730 display: inline-flex;
731 align-items: center;
732 justify-content: center;
733 flex-shrink: 0;
734 text-transform: uppercase;
735 }
736 .issue-meta-milestone-link {
737 font-size: 13px;
738 color: var(--text-link, var(--accent));
739 text-decoration: none;
740 display: inline-flex;
741 align-items: center;
742 gap: 5px;
743 }
744 .issue-meta-milestone-link:hover { text-decoration: underline; }
745 @media (max-width: 720px) {
746 .issue-detail-layout { flex-direction: column; }
747 .issue-meta-sidebar { width: 100%; order: 1; }
748 .issue-detail-main { order: 0; }
749 }
750
f5b9ef5Claude751 /* ─── Sort controls (issue list) ─── */
752 .issues-sort-row {
753 display: flex;
754 align-items: center;
755 gap: 6px;
756 margin: 0 0 12px;
757 flex-wrap: wrap;
758 }
759 .issues-sort-label {
760 font-size: 12.5px;
761 color: var(--text-muted);
762 font-weight: 600;
763 margin-right: 2px;
764 }
765 .issues-sort-opt {
766 font-size: 12.5px;
767 color: var(--text-muted);
768 text-decoration: none;
769 padding: 3px 10px;
770 border-radius: 9999px;
771 border: 1px solid transparent;
772 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
773 }
774 .issues-sort-opt:hover {
775 background: var(--bg-hover);
776 color: var(--text);
777 }
778 .issues-sort-opt.is-active {
6fd5915Claude779 background: rgba(91,110,232,0.12);
f5b9ef5Claude780 color: var(--text-link);
6fd5915Claude781 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude782 font-weight: 600;
783 }
f7ad7b8Claude784`;
785
786// Pre-rendered <style> tag (constant, reused per request).
787const IssuesStyle = () => (
788 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
789);
790
f6730d0ccantynz-alt791// Shared design-system primitive styles (PageHeader / Button / Badge).
792// Idempotent — safe to include once per page that uses the gx-* primitives.
793const SharedStyle = () => (
794 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
795);
796
f7ad7b8Claude797// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
798const IssuesEmptySvg = () => (
799 <svg
800 class="issues-empty-art"
801 viewBox="0 0 96 96"
802 fill="none"
803 xmlns="http://www.w3.org/2000/svg"
804 aria-hidden="true"
805 >
806 <defs>
807 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
6fd5915Claude808 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.55" />
809 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.55" />
f7ad7b8Claude810 </linearGradient>
811 </defs>
6fd5915Claude812 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(91,110,232,0.04)" />
f7ad7b8Claude813 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
814 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
815 </svg>
816);
817
818// Detect AI Triage comments by the marker the triage helper writes.
819function isAiTriageComment(body: string | null | undefined): boolean {
820 if (!body) return false;
821 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
822}
823
79136bbClaude824// Helper to resolve repo from :owner/:repo params
825async function resolveRepo(ownerName: string, repoName: string) {
826 const [owner] = await db
827 .select()
828 .from(users)
829 .where(eq(users.username, ownerName))
830 .limit(1);
831 if (!owner) return null;
832
833 const [repo] = await db
834 .select()
835 .from(repositories)
836 .where(
837 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
838 )
839 .limit(1);
840 if (!repo) return null;
841
842 return { owner, repo };
843}
844
8d1483cClaude845// Bulk issue operations (close / reopen multiple issues at once)
846issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
847 const { owner: ownerName, repo: repoName } = c.req.param();
848 const user = c.get("user")!;
849 const body = await c.req.parseBody();
850 const action = String(body.action || "");
851 const rawNumbers = body["numbers[]"];
852 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
853 .map(n => parseInt(String(n), 10))
854 .filter(n => !isNaN(n) && n > 0);
855
856 if (!numbers.length || !["close","reopen"].includes(action)) {
857 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
858 }
859
860 const resolved = await resolveRepo(ownerName, repoName);
861 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
862
863 const newState = action === "close" ? "closed" : "open";
864 await db.update(issues)
865 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
866 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
867
868 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
869 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
870});
871
79136bbClaude872// Issue list
04f6b7fClaude873issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude874 const { owner: ownerName, repo: repoName } = c.req.param();
875 const user = c.get("user");
876 const state = c.req.query("state") || "open";
7a28902Claude877 const searchQ = (c.req.query("q") || "").trim();
878 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude879 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude880 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude881 // Bounded pagination — unbounded selects ran a full table scan + O(n)
882 // sort on every page load; with 10k+ issues the request would hang.
883 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
884 const page = Math.max(1, Number(c.req.query("page")) || 1);
885 const offset = (page - 1) * perPage;
79136bbClaude886
f1dc7c7Claude887 // ── Loading skeleton (flag-gated) ──
888 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
889 // user see the shape of the issue list before the DB count + select
890 // resolve. Behind a flag — we don't ship flashes.
891 if (c.req.query("skeleton") === "1") {
892 return c.html(
893 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
894 <IssuesStyle />
895 <RepoHeader owner={ownerName} repo={repoName} />
896 <IssueNav owner={ownerName} repo={repoName} active="issues" />
897 <style
898 dangerouslySetInnerHTML={{
899 __html: `
900 .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; }
901 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
902 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
903 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
904 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
905 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
906 .issues-skel-row { height: 62px; border-radius: 10px; }
907 `,
908 }}
909 />
910 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
911 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
912 <div class="issues-skel-list" aria-hidden="true">
913 {Array.from({ length: 8 }).map(() => (
914 <div class="issues-skel issues-skel-row" />
915 ))}
916 </div>
917 <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">
918 Loading issues for {ownerName}/{repoName}…
919 </span>
920 </Layout>
921 );
922 }
923
79136bbClaude924 const resolved = await resolveRepo(ownerName, repoName);
925 if (!resolved) {
926 return c.html(
927 <Layout title="Not Found" user={user}>
bb0f894Claude928 <EmptyState title="Repository not found" />
79136bbClaude929 </Layout>,
930 404
931 );
932 }
933
934 const { repo } = resolved;
935
7a28902Claude936 // If label filter is set, find issue IDs that have that label
937 let labelFilteredIds: string[] | null = null;
938 if (labelFilter) {
939 const [matchedLabel] = await db
940 .select({ id: labels.id })
941 .from(labels)
942 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
943 .limit(1);
944 if (matchedLabel) {
945 const rows = await db
946 .select({ issueId: issueLabels.issueId })
947 .from(issueLabels)
948 .where(eq(issueLabels.labelId, matchedLabel.id));
949 labelFilteredIds = rows.map((r) => r.issueId);
950 } else {
951 labelFilteredIds = []; // no matches
952 }
953 }
954
85c4e13Claude955 // If milestone filter is set, resolve the milestone ID
956 let milestoneFilterId: string | null = null;
957 if (milestoneFilter) {
958 const [matchedMs] = await db
959 .select({ id: milestones.id })
960 .from(milestones)
961 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
962 .limit(1);
963 milestoneFilterId = matchedMs?.id ?? null;
964 }
965
7a28902Claude966 const baseWhere = and(
967 eq(issues.repositoryId, repo.id),
968 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
969 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
970 labelFilteredIds !== null && labelFilteredIds.length > 0
971 ? inArray(issues.id, labelFilteredIds)
972 : labelFilteredIds !== null && labelFilteredIds.length === 0
973 ? sql`false`
85c4e13Claude974 : undefined,
975 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude976 );
977
79136bbClaude978 const issueList = await db
979 .select({
980 issue: issues,
981 author: { username: users.username },
982 })
983 .from(issues)
984 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude985 .where(baseWhere)
f5b9ef5Claude986 .orderBy(
987 sort === "oldest" ? asc(issues.createdAt)
988 : sort === "updated" ? desc(issues.updatedAt)
989 : desc(issues.createdAt) // newest (default)
990 )
6ea2109Claude991 .limit(perPage)
992 .offset(offset);
79136bbClaude993
2c61840Claude994 // Batch-load sub-issue counts for epics in this list page
995 const issueIds = issueList.map((r) => r.issue.id);
996 const subCountMap = new Map<string, number>();
997 const parentNumberMap = new Map<string, number>(); // childIssueId → parentIssueNumber
998 if (issueIds.length > 0) {
999 const [subRows, parentRows] = await Promise.all([
1000 // Count children per parent (to show "N sub-issues" on epics)
1001 db
1002 .select({
1003 parentId: issues.parentIssueId,
1004 cnt: sql<number>`count(*)::int`,
1005 })
1006 .from(issues)
1007 .where(
1008 and(
1009 inArray(issues.parentIssueId, issueIds),
1010 eq(issues.repositoryId, repo.id)
1011 )
1012 )
1013 .groupBy(issues.parentIssueId),
1014 // Resolve parent numbers for children (to show "Part of #N")
1015 issueList
1016 .filter((r) => r.issue.parentIssueId !== null)
1017 .length > 0
1018 ? db
1019 .select({ id: issues.id, number: issues.number })
1020 .from(issues)
1021 .where(
1022 inArray(
1023 issues.id,
1024 issueList
1025 .filter((r) => r.issue.parentIssueId !== null)
1026 .map((r) => r.issue.parentIssueId as string)
1027 )
1028 )
1029 : Promise.resolve([]),
1030 ]);
1031 subRows.forEach((r) => {
1032 if (r.parentId) subCountMap.set(r.parentId, Number(r.cnt));
1033 });
1034 parentRows.forEach((p) => parentNumberMap.set(p.id, p.number));
1035 }
1036
79136bbClaude1037 // Count open/closed
1038 const [counts] = await db
1039 .select({
1040 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
1041 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
1042 })
1043 .from(issues)
1044 .where(eq(issues.repositoryId, repo.id));
1045
85c4e13Claude1046 // Count open milestones for the "N Milestones" link
1047 const [milestoneCounts] = await db
1048 .select({
1049 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
1050 })
1051 .from(milestones)
1052 .where(eq(milestones.repositoryId, repo.id));
1053
cb5a796Claude1054 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
1055 const pendingCountList = viewerIsOwnerOnList
1056 ? await countPendingForRepo(repo.id)
1057 : 0;
1058
79136bbClaude1059 return c.html(
1060 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1061 <IssuesStyle />
f6730d0ccantynz-alt1062 <SharedStyle />
79136bbClaude1063 <RepoHeader owner={ownerName} repo={repoName} />
1064 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1065 <PendingCommentsBanner
1066 owner={ownerName}
1067 repo={repoName}
1068 count={pendingCountList}
1069 />
f6730d0ccantynz-alt1070 <GxPageHeader
1071 eyebrow={`Issue tracker \u00B7 ${ownerName}/${repoName}`}
1072 title="Track what matters."
1073 lede={
1074 (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1075 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
1076 : `${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.`
1077 }
1078 actions={
1079 <>
f7ad7b8Claude1080 {user && (
f6730d0ccantynz-alt1081 <GxButton href={`/${ownerName}/${repoName}/issues/new`} variant="primary">
f7ad7b8Claude1082 + New issue
f6730d0ccantynz-alt1083 </GxButton>
f7ad7b8Claude1084 )}
f6730d0ccantynz-alt1085 <GxButton
85c4e13Claude1086 href={`/${ownerName}/${repoName}/milestones`}
f6730d0ccantynz-alt1087 variant="secondary"
85c4e13Claude1088 title="View milestones for this repository"
1089 >
1090 Milestones
1091 {Number(milestoneCounts?.open ?? 0) > 0 && (
6fd5915Claude1092 <span style="margin-left:5px;font-size:11.5px;background:rgba(91,110,232,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
85c4e13Claude1093 {Number(milestoneCounts?.open ?? 0)}
1094 </span>
1095 )}
f6730d0ccantynz-alt1096 </GxButton>
1097 <GxButton href={`/${ownerName}/${repoName}`} variant="secondary">
f7ad7b8Claude1098 Back to code
f6730d0ccantynz-alt1099 </GxButton>
1100 </>
1101 }
1102 />
f7ad7b8Claude1103
1104 <div class="issues-toolbar">
1105 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1106 <a
1107 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1108 href={`/${ownerName}/${repoName}/issues?state=open`}
1109 role="tab"
1110 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1111 >
f7ad7b8Claude1112 <span aria-hidden="true">{"\u25CB"}</span>
1113 <span>Open</span>
1114 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1115 </a>
1116 <a
1117 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1118 href={`/${ownerName}/${repoName}/issues?state=closed`}
1119 role="tab"
1120 aria-selected={state === "closed" ? "true" : "false"}
1121 >
1122 <span aria-hidden="true">{"\u2713"}</span>
1123 <span>Closed</span>
1124 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1125 </a>
1126 </div>
7a28902Claude1127 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1128 <input type="hidden" name="state" value={state} />
1129 <input
1130 type="search"
1131 name="q"
1132 value={searchQ}
1133 placeholder="Search issues\u2026"
1134 class="issues-search-input"
1135 aria-label="Search issues by title"
1136 />
1137 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1138 <button type="submit" class="issues-search-btn" aria-label="Search">
1139 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1140 <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" />
1141 </svg>
1142 </button>
1143 </form>
f7ad7b8Claude1144 </div>
7a28902Claude1145 {(searchQ || labelFilter) && (
1146 <div class="issues-filter-banner">
1147 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1148 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1149 {" \u00B7 "}
1150 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1151 </div>
1152 )}
f7ad7b8Claude1153
f5b9ef5Claude1154 <div class="issues-sort-row">
1155 <span class="issues-sort-label">Sort:</span>
1156 {(["newest", "oldest", "updated"] as const).map((s) => (
1157 <a
1158 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1159 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1160 >
1161 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1162 </a>
1163 ))}
1164 </div>
1165
79136bbClaude1166 {issueList.length === 0 ? (
f7ad7b8Claude1167 <div class="issues-empty">
1168 <IssuesEmptySvg />
1169 <h2 class="issues-empty-title">
1170 {state === "closed"
1171 ? "No closed issues yet"
1172 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1173 ? "No issues \u2014 yet"
1174 : "Nothing open right now"}
1175 </h2>
1176 <p class="issues-empty-sub">
1177 {state === "closed"
1178 ? "Closed issues will show up here once the team starts shipping fixes."
1179 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1180 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1181 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1182 </p>
1183 <div class="issues-empty-cta">
1184 {user && state !== "closed" && (
1185 <a
1186 href={`/${ownerName}/${repoName}/issues/new`}
1187 class="btn btn-primary"
1188 >
1189 + Open the first issue
1190 </a>
1191 )}
79136bbClaude1192 {state === "closed" && (
f7ad7b8Claude1193 <a
1194 href={`/${ownerName}/${repoName}/issues?state=open`}
1195 class="btn"
1196 >
1197 View open issues
1198 </a>
79136bbClaude1199 )}
f7ad7b8Claude1200 </div>
1201 </div>
79136bbClaude1202 ) : (
8d1483cClaude1203 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1204 <input type="hidden" name="action" id="bulk-action" value="" />
1205 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1206 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1207 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1208 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1209 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1210 </div>
1211 <ul class="issues-list">
1212 {issueList.map(({ issue, author }) => (
1213 <li class="issues-row">
1214 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1215 <div
1216 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1217 aria-hidden="true"
1218 title={issue.state === "open" ? "Open" : "Closed"}
1219 >
1220 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1221 </div>
8d1483cClaude1222 <div class="issues-row-main">
1223 <h3 class="issues-row-title">
1224 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1225 {issue.title}
1226 </a>
2c61840Claude1227 {subCountMap.has(issue.id) && (
1228 <span class="issues-tag-epic" title={`Epic with ${subCountMap.get(issue.id)} sub-issue${subCountMap.get(issue.id) === 1 ? "" : "s"}`}>
1229 📌 Epic · {subCountMap.get(issue.id)}
1230 </span>
1231 )}
1232 {issue.parentIssueId && parentNumberMap.has(issue.parentIssueId) && (
1233 <span class="issues-tag-child" title={`Sub-issue of #${parentNumberMap.get(issue.parentIssueId)}`}>
1234 ↳ #{parentNumberMap.get(issue.parentIssueId)}
1235 </span>
1236 )}
8d1483cClaude1237 </h3>
1238 <div class="issues-row-meta">
1239 #{issue.number} opened by{" "}
1240 <strong>{author.username}</strong>{" "}
1241 {formatRelative(issue.createdAt)}
1242 </div>
1243 </div>
1244 </li>
1245 ))}
1246 </ul>
1247 <script dangerouslySetInnerHTML={{ __html: `
1248function bulkSubmit(action){
1249 document.getElementById('bulk-action').value=action;
1250 document.getElementById('bulk-form').submit();
1251}
1252function bulkClear(){
1253 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1254 updateBulkBar();
1255}
1256function updateBulkBar(){
1257 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1258 var bar=document.getElementById('bulk-bar');
1259 var cnt=document.getElementById('bulk-bar-count');
1260 if(bar){bar.style.display=checked>0?'flex':'none';}
1261 if(cnt){cnt.textContent=checked+' selected';}
1262}
1263document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1264` }} />
1265 </form>
79136bbClaude1266 )}
1267 </Layout>
1268 );
1269});
1270
1271// New issue form
1272issueRoutes.get(
1273 "/:owner/:repo/issues/new",
1274 softAuth,
1275 requireAuth,
04f6b7fClaude1276 requireRepoAccess("write"),
79136bbClaude1277 async (c) => {
1278 const { owner: ownerName, repo: repoName } = c.req.param();
1279 const user = c.get("user")!;
1280 const error = c.req.query("error");
24cf2caClaude1281 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1282
85c4e13Claude1283 // Load open milestones for the dropdown
1284 const resolved = await resolveRepo(ownerName, repoName);
1285 const openMilestones = resolved
1286 ? await db
1287 .select({ id: milestones.id, title: milestones.title })
1288 .from(milestones)
1289 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1290 .orderBy(milestones.title)
1291 : [];
1292
79136bbClaude1293 return c.html(
1294 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1295 <IssuesStyle />
f6730d0ccantynz-alt1296 <SharedStyle />
79136bbClaude1297 <RepoHeader owner={ownerName} repo={repoName} />
1298 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1299 <Container maxWidth={800}>
f6730d0ccantynz-alt1300 <GxPageHeader
1301 eyebrow={`New issue · ${ownerName}/${repoName}`}
1302 title="File it cleanly."
1303 lede="AI Triage will read the body the moment you submit and post suggested labels, priority, and possible duplicates within seconds. You stay in control — nothing is applied."
1304 />
79136bbClaude1305 {error && (
bb0f894Claude1306 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1307 )}
0316dbbClaude1308 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1309 <FormGroup>
1310 <Input
79136bbClaude1311 type="text"
1312 name="title"
1313 required
1314 placeholder="Title"
bb0f894Claude1315 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1316 aria-label="Issue title"
79136bbClaude1317 />
bb0f894Claude1318 </FormGroup>
1319 <FormGroup>
1320 <TextArea
79136bbClaude1321 name="body"
1322 rows={12}
1323 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1324 mono
79136bbClaude1325 />
bb0f894Claude1326 </FormGroup>
85c4e13Claude1327 {openMilestones.length > 0 && (
1328 <FormGroup>
1329 <label
1330 for="milestone_id"
1331 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1332 >
1333 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1334 </label>
1335 <select
1336 id="milestone_id"
1337 name="milestone_id"
1338 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"
1339 >
1340 <option value="">No milestone</option>
1341 {openMilestones.map((ms) => (
1342 <option value={ms.id}>{ms.title}</option>
1343 ))}
1344 </select>
1345 </FormGroup>
1346 )}
bb0f894Claude1347 <Button type="submit" variant="primary">
79136bbClaude1348 Submit new issue
bb0f894Claude1349 </Button>
1350 </Form>
1351 </Container>
79136bbClaude1352 </Layout>
1353 );
1354 }
1355);
1356
1357// Create issue
1358issueRoutes.post(
1359 "/:owner/:repo/issues/new",
1360 softAuth,
1361 requireAuth,
04f6b7fClaude1362 requireRepoAccess("write"),
79136bbClaude1363 async (c) => {
1364 const { owner: ownerName, repo: repoName } = c.req.param();
1365 const user = c.get("user")!;
1366 const body = await c.req.parseBody();
1367 const title = String(body.title || "").trim();
1368 const issueBody = String(body.body || "").trim();
85c4e13Claude1369 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1370
1371 if (!title) {
1372 return c.redirect(
1373 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1374 );
1375 }
1376
1377 const resolved = await resolveRepo(ownerName, repoName);
1378 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1379
85c4e13Claude1380 // Validate milestone belongs to this repo if provided
1381 let validatedMilestoneId: string | null = null;
1382 if (milestoneIdRaw) {
1383 const [msRow] = await db
1384 .select({ id: milestones.id })
1385 .from(milestones)
1386 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1387 .limit(1);
1388 validatedMilestoneId = msRow?.id ?? null;
1389 }
1390
79136bbClaude1391 const [issue] = await db
1392 .insert(issues)
1393 .values({
1394 repositoryId: resolved.repo.id,
1395 authorId: user.id,
1396 title,
1397 body: issueBody || null,
85c4e13Claude1398 milestoneId: validatedMilestoneId,
79136bbClaude1399 })
1400 .returning();
1401
1402 // Update issue count
1403 await db
1404 .update(repositories)
1405 .set({ issueCount: resolved.repo.issueCount + 1 })
1406 .where(eq(repositories.id, resolved.repo.id));
1407
a74f4edccanty labs1408 void fireWebhooks(resolved.repo.id, "issue", {
1409 action: "opened",
1410 number: issue.number,
1411 title,
1412 });
1413
a9ada5fClaude1414 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1415 // suggested labels, priority, summary, and a possible-duplicate
1416 // callout. Suggestions only — nothing applied automatically.
1417 triggerIssueTriage({
1418 ownerName,
1419 repoName,
1420 repositoryId: resolved.repo.id,
1421 issueId: issue.id,
1422 issueNumber: issue.number,
1423 authorId: user.id,
1424 title,
1425 body: issueBody,
a28cedeClaude1426 }).catch((err) => {
1427 console.warn(
1428 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1429 err instanceof Error ? err.message : err
1430 );
1431 });
a9ada5fClaude1432
79136bbClaude1433 return c.redirect(
1434 `/${ownerName}/${repoName}/issues/${issue.number}`
1435 );
1436 }
1437);
1438
1439// View single issue
04f6b7fClaude1440issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1441 const { owner: ownerName, repo: repoName } = c.req.param();
1442 const issueNum = parseInt(c.req.param("number"), 10);
1443 const user = c.get("user");
1444
1445 const resolved = await resolveRepo(ownerName, repoName);
1446 if (!resolved) {
1447 return c.html(
1448 <Layout title="Not Found" user={user}>
bb0f894Claude1449 <EmptyState title="Not found" />
79136bbClaude1450 </Layout>,
1451 404
1452 );
1453 }
1454
1455 const [issue] = await db
1456 .select()
1457 .from(issues)
1458 .where(
1459 and(
1460 eq(issues.repositoryId, resolved.repo.id),
1461 eq(issues.number, issueNum)
1462 )
1463 )
1464 .limit(1);
1465
1466 if (!issue) {
1467 return c.html(
1468 <Layout title="Not Found" user={user}>
bb0f894Claude1469 <EmptyState title="Issue not found" />
79136bbClaude1470 </Layout>,
1471 404
1472 );
1473 }
1474
1475 const [author] = await db
1476 .select()
1477 .from(users)
1478 .where(eq(users.id, issue.authorId))
1479 .limit(1);
1480
cb5a796Claude1481 // Get comments. We pull every row and filter by moderation_status +
1482 // viewer identity client-side (in the JSX below) so a moderator/owner
1483 // sees them all while non-author viewers only ever see 'approved'.
1484 const allComments = await db
79136bbClaude1485 .select({
1486 comment: issueComments,
cb5a796Claude1487 author: { id: users.id, username: users.username },
79136bbClaude1488 })
1489 .from(issueComments)
1490 .innerJoin(users, eq(issueComments.authorId, users.id))
1491 .where(eq(issueComments.issueId, issue.id))
1492 .orderBy(asc(issueComments.createdAt));
1493
cb5a796Claude1494 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1495 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1496 // Owner always sees everything (so they can spot conversations going
1497 // sideways even before they hit the queue page).
1498 if (viewerIsOwner) return true;
1499 if (comment.moderationStatus === "approved") return true;
1500 // The comment's own author sees their pending comment so they know
1501 // it landed (with an "Awaiting approval" badge in the render below).
1502 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1503 return true;
1504 }
1505 return false;
1506 });
1507
1508 // Pending banner — when the viewer is the repo owner, show a strip at
1509 // the top of the page nudging them to the moderation queue. Inline on
1510 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1511 const pendingCount = viewerIsOwner
1512 ? await countPendingForRepo(resolved.repo.id)
1513 : 0;
1514
2c61840Claude1515 // Sub-issues / epics — load parent issue (if this is a child) and
1516 // any child issues (if this is an epic), in parallel with reactions.
1517 const [parentIssueRow, subIssueRows] = await Promise.all([
1518 issue.parentIssueId
1519 ? db
1520 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
1521 .from(issues)
1522 .where(eq(issues.id, issue.parentIssueId))
1523 .limit(1)
1524 .then((r) => r[0] ?? null)
1525 : Promise.resolve(null),
1526 db
1527 .select({
1528 id: issues.id,
1529 number: issues.number,
1530 title: issues.title,
1531 state: issues.state,
1532 authorUsername: users.username,
1533 })
1534 .from(issues)
1535 .innerJoin(users, eq(issues.authorId, users.id))
1536 .where(and(eq(issues.parentIssueId, issue.id), eq(issues.repositoryId, resolved.repo.id)))
1537 .orderBy(asc(issues.number)),
1538 ]);
1539
6fc53bdClaude1540 // Load reactions for the issue + each comment in parallel.
1541 const [issueReactions, ...commentReactions] = await Promise.all([
1542 summariseReactions("issue", issue.id, user?.id),
1543 ...comments.map((row) =>
1544 summariseReactions("issue_comment", row.comment.id, user?.id)
1545 ),
1546 ]);
1547
79136bbClaude1548 const canManage =
1549 user &&
1550 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1551 const info = c.req.query("info");
79136bbClaude1552
0224546Claude1553 // Labels attached to this issue.
1554 const issueDetailLabels = await db
1555 .select({ id: labels.id, name: labels.name, color: labels.color })
1556 .from(issueLabels)
1557 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1558 .where(eq(issueLabels.issueId, issue.id));
1559
1560 // Milestone for this issue (if any).
1561 const issueMilestone = issue.milestoneId
1562 ? await db
1563 .select({ id: milestones.id, title: milestones.title })
1564 .from(milestones)
1565 .where(eq(milestones.id, issue.milestoneId))
1566 .limit(1)
1567 .then((rows) => rows[0] ?? null)
1568 : null;
1569
240c477Claude1570 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1571 const issueRefPattern = `%#${issue.number}%`;
1572 const linkedPrs = await db
1573 .select({
1574 number: pullRequests.number,
1575 title: pullRequests.title,
1576 state: pullRequests.state,
1577 isDraft: pullRequests.isDraft,
1578 })
1579 .from(pullRequests)
1580 .where(
1581 and(
1582 eq(pullRequests.repositoryId, resolved.repo.id),
1583 or(
1584 ilike(pullRequests.title, issueRefPattern),
1585 ilike(pullRequests.body, issueRefPattern),
1586 )
1587 )
1588 )
1589 .orderBy(desc(pullRequests.createdAt))
1590 .limit(8);
1591
79136bbClaude1592 return c.html(
1593 <Layout
1594 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1595 user={user}
1596 >
f7ad7b8Claude1597 <IssuesStyle />
f6730d0ccantynz-alt1598 <SharedStyle />
79136bbClaude1599 <RepoHeader owner={ownerName} repo={repoName} />
1600 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1601 <PendingCommentsBanner
1602 owner={ownerName}
1603 repo={repoName}
1604 count={pendingCount}
1605 />
b584e52Claude1606 <div
1607 id="live-comment-banner"
1608 class="alert"
1609 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1610 >
1611 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1612 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1613 reload to view
1614 </a>
1615 </div>
1616 <script
1617 dangerouslySetInnerHTML={{
1618 __html: liveCommentBannerScript({
1619 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1620 bannerElementId: "live-comment-banner",
1621 }),
1622 }}
1623 />
829a046Claude1624 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1625 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1626 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1627 <div class="issue-detail">
58915a9Claude1628 {info && (
f7ad7b8Claude1629 <div class="issues-info-banner">
58915a9Claude1630 {decodeURIComponent(info)}
1631 </div>
1632 )}
f7ad7b8Claude1633
1634 <section class="issues-detail-hero">
2c61840Claude1635 {parentIssueRow && (
1636 <div class="issues-epic-crumb">
1637 <span>📌 Part of epic</span>
1638 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`}>
1639 #{parentIssueRow.number} — {parentIssueRow.title}
1640 </a>
1641 </div>
1642 )}
f7ad7b8Claude1643 <h1 class="issues-detail-title">
1644 {issue.title}
1645 <span class="issues-detail-number">#{issue.number}</span>
1646 </h1>
1647 <div class="issues-detail-attr">
f6730d0ccantynz-alt1648 <GxBadge variant={issue.state === "open" ? "success" : "info"}>
f7ad7b8Claude1649 <span aria-hidden="true">
1650 {issue.state === "open" ? "\u25CB" : "\u2713"}
1651 </span>
1652 {issue.state === "open" ? "Open" : "Closed"}
f6730d0ccantynz-alt1653 </GxBadge>
f7ad7b8Claude1654 <span>
1655 <strong>{author?.username || "unknown"}</strong> opened this
1656 issue {formatRelative(issue.createdAt)}
1657 </span>
1658 <span class="issues-detail-spacer" />
1659 {issue.state === "open" && user && user.id === resolved.owner.id && (
1660 <a
1661 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1662 class="btn btn-primary"
1663 style="font-size:13px;padding:6px 12px"
1664 title="Generate a draft pull request from this issue using Claude"
1665 >
1666 Build with AI
1667 </a>
1668 )}
f928118Claude1669 {issue.state === "open" && user && isAiAvailable() && (
1670 <form
1671 method="post"
1672 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1673 style="display:inline"
1674 title="Let AI implement this feature automatically"
1675 >
1676 <button
1677 type="submit"
1678 class="btn btn-primary"
6fd5915Claude1679 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);border:none;cursor:pointer"
f928118Claude1680 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1681 >
1682 Ship It
1683 </button>
1684 </form>
1685 )}
c922868Claude1686 {issue.state === "open" && user && (
1687 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1688 <summary
1689 class="btn"
1690 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1691 title="Create a new branch for this issue"
1692 >
1693 Create branch
1694 </summary>
1695 <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)">
1696 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1697 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1698 <input
1699 type="text"
1700 name="branchName"
1701 class="input"
1702 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1703 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1704 pattern="[a-zA-Z0-9._\\-/]+"
1705 required
1706 />
1707 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1708 Create branch
1709 </button>
1710 </form>
1711 </div>
1712 </details>
1713 )}
f7ad7b8Claude1714 </div>
1715 </section>
1716
0224546Claude1717 <div class="issue-detail-layout">
1718 <div class="issue-detail-main">
2c61840Claude1719
1720 {subIssueRows.length > 0 && (
1721 <div class="issues-sub-panel">
1722 <div class="issues-sub-panel-head">
1723 <span>Sub-issues</span>
1724 <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">
1725 {subIssueRows.filter((s) => s.state === "open").length}/{subIssueRows.length} open
1726 </span>
1727 </div>
1728 {subIssueRows.map((sub) => (
1729 <div class="issues-sub-row" key={sub.id}>
1730 <span class={`issues-sub-row-icon ${sub.state === "open" ? "is-open" : "is-closed"}`}>
1731 {sub.state === "open" ? "○" : "✓"}
1732 </span>
1733 <span class="issues-sub-row-title">
1734 <a href={`/${ownerName}/${repoName}/issues/${sub.number}`}>{sub.title}</a>
1735 </span>
1736 <span class="issues-sub-row-author">{sub.authorUsername}</span>
1737 <span class="issues-sub-row-num">#{sub.number}</span>
1738 </div>
1739 ))}
1740 </div>
1741 )}
1742
f7ad7b8Claude1743 <div class="issues-thread">
1744 {issue.body && (
1745 <article class="issues-comment">
1746 <header class="issues-comment-header">
1747 <strong>{author?.username || "unknown"}</strong>
1748 <span class="issues-comment-author-pill">Author</span>
1749 <span>commented {formatRelative(issue.createdAt)}</span>
1750 </header>
1751 <div class="issues-comment-body">
1752 <div
1753 class="markdown-body"
1754 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1755 />
1756 </div>
1757 </article>
fbf4aefClaude1758 )}
79136bbClaude1759
f7ad7b8Claude1760 {comments.map(({ comment, author: commentAuthor }) => {
1761 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1762 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1763 return (
cb5a796Claude1764 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1765 <header class="issues-comment-header">
1766 <strong>{commentAuthor.username}</strong>
a7460bfClaude1767 {commentAuthor.username === BOT_USERNAME && (
1768 <span class="issues-bot-badge">&#x1F916; bot</span>
1769 )}
f7ad7b8Claude1770 {isAi ? (
1771 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1772 AI Review
1773 </span>
1774 ) : null}
cb5a796Claude1775 {isPending ? (
1776 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1777 Awaiting approval
1778 </span>
1779 ) : null}
f7ad7b8Claude1780 <span>commented {formatRelative(comment.createdAt)}</span>
1781 </header>
1782 <div class="issues-comment-body">
1783 <div
1784 class="markdown-body"
1785 dangerouslySetInnerHTML={{
1786 __html: renderMarkdown(comment.body),
1787 }}
1788 />
1789 </div>
1790 </article>
1791 );
1792 })}
1793 </div>
79136bbClaude1794
240c477Claude1795 {linkedPrs.length > 0 && (
1796 <div class="iss-linked-prs">
1797 <div class="iss-linked-prs-head">
1798 <span>Linked pull requests</span>
1799 <span>{linkedPrs.length}</span>
1800 </div>
1801 {linkedPrs.map((lpr) => {
1802 const prState = lpr.isDraft ? "draft" : lpr.state;
1803 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1804 return (
1805 <a
1806 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1807 class="iss-linked-pr-row"
1808 >
1809 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1810 <span class="iss-linked-pr-title">{lpr.title}</span>
1811 <span class="iss-linked-pr-num">#{lpr.number}</span>
1812 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1813 </a>
1814 );
1815 })}
1816 </div>
1817 )}
1818
79136bbClaude1819 {user && (
f7ad7b8Claude1820 <form
1821 class="issues-composer"
1822 method="post"
1823 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1824 >
1825 <div class="issues-composer-header">
1826 <span class="issues-composer-tag">Add a comment</span>
1827 <span class="issues-composer-hint">
1828 <a
1829 href="https://docs.github.com/en/get-started/writing-on-github"
1830 target="_blank"
1831 rel="noopener noreferrer"
1832 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1833 >
1834 Markdown supported
1835 </a>
1836 </span>
1837 </div>
1838 <textarea
1839 name="body"
1840 rows={6}
1841 required
6cd2f0eClaude1842 data-md-preview=""
f7ad7b8Claude1843 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1844 />
1845 <div class="issues-composer-actions">
1846 <button type="submit" class="btn btn-primary">
1847 Comment
1848 </button>
1849 {canManage && (
1850 <button
1851 type="submit"
1852 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1853 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1854 >
1855 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1856 </button>
f7ad7b8Claude1857 )}
1858 {canManage && issue.state === "open" && (
1859 <button
1860 type="submit"
1861 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1862 formnovalidate
1863 class="btn"
1864 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1865 >
1866 Re-run AI triage
1867 </button>
1868 )}
1869 </div>
1870 </form>
79136bbClaude1871 )}
0224546Claude1872 </div>{/* /issue-detail-main */}
1873
1874 {/* Metadata sidebar — Labels, Assignees, Milestone */}
1875 <aside class="issue-meta-sidebar">
1876 {/* Labels */}
1877 <div class="issue-meta-section">
1878 <div class="issue-meta-label">Labels</div>
1879 {issueDetailLabels.length === 0 ? (
1880 <span class="issue-meta-empty">None yet</span>
1881 ) : (
1882 <div class="issue-meta-labels">
1883 {issueDetailLabels.map((lbl) => {
1884 // Compute luminance from hex color to choose dark/light text
1885 const hex = lbl.color.replace("#", "");
1886 const r = parseInt(hex.slice(0, 2), 16) / 255;
1887 const g = parseInt(hex.slice(2, 4), 16) / 255;
1888 const b = parseInt(hex.slice(4, 6), 16) / 255;
1889 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1890 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
1891 return (
1892 <span
1893 class="issue-meta-label-pill"
1894 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
1895 >
1896 {lbl.name}
1897 </span>
1898 );
1899 })}
1900 </div>
1901 )}
1902 </div>
1903
1904 {/* Assignees — field not yet in schema; placeholder */}
1905 <div class="issue-meta-section">
1906 <div class="issue-meta-label">Assignees</div>
1907 <span class="issue-meta-empty">No one assigned</span>
1908 </div>
1909
1910 {/* Milestone */}
1911 <div class="issue-meta-section">
1912 <div class="issue-meta-label">Milestone</div>
1913 {issueMilestone ? (
1914 <a
1915 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
1916 class="issue-meta-milestone-link"
1917 >
1918 &#x25CE; {issueMilestone.title}
1919 </a>
1920 ) : (
1921 <span class="issue-meta-empty">No milestone</span>
1922 )}
1923 </div>
2c61840Claude1924 {/* Epic / parent issue */}
1925 {viewerIsOwner && (
1926 <div class="issue-meta-section">
1927 <div class="issue-meta-label">Epic (parent issue)</div>
1928 {parentIssueRow ? (
1929 <div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">
1930 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`} style="color:var(--accent);text-decoration:none">
1931 #{parentIssueRow.number} {parentIssueRow.title.slice(0, 40)}{parentIssueRow.title.length > 40 ? "…" : ""}
1932 </a>
1933 </div>
1934 ) : (
1935 <span class="issue-meta-empty">None</span>
1936 )}
1937 <form
1938 method="post"
1939 action={`/${ownerName}/${repoName}/issues/${issue.number}/set-parent`}
1940 style="display:flex;gap:6px;margin-top:6px"
1941 >
1942 <input
1943 type="number"
1944 name="parent_number"
1945 placeholder={parentIssueRow ? "Clear (enter 0)" : "Issue #"}
1946 style="width:100%;padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-inset);color:var(--text)"
1947 min="0"
1948 />
1949 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px;white-space:nowrap">
1950 Set
1951 </button>
1952 </form>
1953 </div>
1954 )}
0224546Claude1955 </aside>
1956 </div>{/* /issue-detail-layout */}
79136bbClaude1957 </div>
641aa42Claude1958 {/* Issue keyboard hints bar */}
1959 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1960 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
1961 </div>
1962 <style dangerouslySetInnerHTML={{ __html: `
1963 .kbd-hints {
1964 position: fixed;
1965 bottom: 0;
1966 left: 0;
1967 right: 0;
1968 z-index: 90;
1969 padding: 6px 24px;
1970 background: var(--bg-secondary);
1971 border-top: 1px solid var(--border);
1972 font-size: 12px;
1973 color: var(--text-muted);
1974 display: flex;
1975 align-items: center;
1976 gap: 8px;
1977 flex-wrap: wrap;
1978 }
1979 .kbd-hints kbd {
1980 font-family: var(--font-mono);
1981 font-size: 10px;
1982 background: var(--bg-elevated);
1983 border: 1px solid var(--border);
1984 border-bottom-width: 2px;
1985 border-radius: 4px;
1986 padding: 1px 5px;
1987 color: var(--text);
1988 line-height: 1.5;
1989 }
1990 main { padding-bottom: 40px; }
1991 ` }} />
1992 {/* Repo context commands for command palette */}
1993 <script
1994 id="cmdk-repo-context"
1995 dangerouslySetInnerHTML={{
1996 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1997 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1998 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1999 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
2000 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
2001 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
2002 ])};`,
2003 }}
2004 />
2005 {/* Issue keyboard shortcuts */}
2006 <script dangerouslySetInnerHTML={{ __html: `
2007 (function(){
2008 function isTyping(t){
2009 t = t || {};
2010 var tag = (t.tagName || '').toLowerCase();
2011 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
2012 }
2013 document.addEventListener('keydown', function(e){
2014 if (isTyping(e.target)) return;
2015 if (e.metaKey || e.ctrlKey || e.altKey) return;
2016 if (e.key === 'c') {
2017 e.preventDefault();
2018 var box = document.querySelector('textarea[name="body"]');
2019 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
2020 }
2021 if (e.key === 'e') {
2022 e.preventDefault();
2023 // Focus the issue title and make it editable if possible
2024 var titleEl = document.querySelector('.issues-title');
2025 if (titleEl) titleEl.scrollIntoView({block:'center'});
2026 }
2027 if (e.key === 'x') {
2028 e.preventDefault();
2029 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
2030 if (closeBtn) {
2031 var confirmed = window.confirm('Close/reopen this issue?');
2032 if (confirmed) closeBtn.click();
2033 }
2034 }
2035 if (e.key === 'Escape') {
2036 var focused = document.activeElement;
2037 if (focused) focused.blur();
2038 }
2039 });
2040 })();
2041 ` }} />
79136bbClaude2042 </Layout>
2043 );
2044});
2045
cb5a796Claude2046// Add comment.
2047//
2048// Permission model: we accept comments from ANY authenticated user (read
2049// access required — public repos satisfy that, private repos still need a
2050// collaborator row). The moderation gate in `decideInitialStatus`
2051// determines whether the comment is published immediately or queued for
2052// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
2053// "anti-impersonation" rationale.
79136bbClaude2054issueRoutes.post(
2055 "/:owner/:repo/issues/:number/comment",
2056 softAuth,
2057 requireAuth,
cb5a796Claude2058 requireRepoAccess("read"),
79136bbClaude2059 async (c) => {
2060 const { owner: ownerName, repo: repoName } = c.req.param();
2061 const issueNum = parseInt(c.req.param("number"), 10);
2062 const user = c.get("user")!;
2063 const body = await c.req.parseBody();
2064 const commentBody = String(body.body || "").trim();
2065
2066 if (!commentBody) {
2067 return c.redirect(
2068 `/${ownerName}/${repoName}/issues/${issueNum}`
2069 );
2070 }
2071
2072 const resolved = await resolveRepo(ownerName, repoName);
2073 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2074
2075 const [issue] = await db
2076 .select()
2077 .from(issues)
2078 .where(
2079 and(
2080 eq(issues.repositoryId, resolved.repo.id),
2081 eq(issues.number, issueNum)
2082 )
2083 )
2084 .limit(1);
2085
2086 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2087
cb5a796Claude2088 // Decide moderation status BEFORE insert so the row lands in the right
2089 // initial state. Collaborators, the issue author, and trusted users
2090 // get 'approved'; banned users get 'rejected' (silent drop); everyone
2091 // else gets 'pending'.
2092 const decision = await decideInitialStatus({
2093 commenterUserId: user.id,
2094 repositoryId: resolved.repo.id,
2095 kind: "issue",
2096 threadId: issue.id,
2097 });
2098
d4ac5c3Claude2099 const [inserted] = await db
2100 .insert(issueComments)
2101 .values({
2102 issueId: issue.id,
2103 authorId: user.id,
2104 body: commentBody,
cb5a796Claude2105 moderationStatus: decision.status,
d4ac5c3Claude2106 })
2107 .returning();
2108
cb5a796Claude2109 // Live update only when visible. Don't leak pending/rejected via SSE.
2110 if (inserted && decision.status === "approved") {
a74f4edccanty labs2111 void fireWebhooks(resolved.repo.id, "issue", {
2112 action: "commented",
2113 number: issueNum,
2114 });
d4ac5c3Claude2115 try {
2116 const { publish } = await import("../lib/sse");
2117 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
2118 event: "issue-comment",
2119 data: {
2120 issueId: issue.id,
2121 commentId: inserted.id,
2122 authorId: user.id,
2123 authorUsername: user.username,
2124 },
2125 });
2126 } catch {
2127 /* SSE is best-effort */
2128 }
b7ecb14Claude2129 // Notify the issue author — fire-and-forget, never blocks the response.
2130 if (issue.authorId && issue.authorId !== user.id) {
2131 void import("../lib/notify").then(({ createNotification }) =>
2132 createNotification({
2133 userId: issue.authorId,
2134 type: "issue_comment",
2135 title: `New comment on "${issue.title}"`,
2136 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
2137 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
2138 repoId: resolved.repo.id,
2139 })
2140 ).catch(() => { /* never block the response */ });
2141 }
d4ac5c3Claude2142 }
79136bbClaude2143
cb5a796Claude2144 if (decision.status === "pending") {
2145 // Notify repo owner that a comment is awaiting review.
2146 void notifyOwnerOfPendingComment({
2147 repositoryId: resolved.repo.id,
2148 commenterUsername: user.username,
2149 kind: "issue",
2150 threadNumber: issueNum,
2151 ownerUsername: ownerName,
2152 repoName,
2153 });
2154 return c.redirect(
2155 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2156 );
2157 }
2158 if (decision.status === "rejected") {
2159 // Silent ban — the commenter is on the banned list. Don't reveal
2160 // the gate; just send them back to the page as if it posted.
2161 return c.redirect(
2162 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2163 );
2164 }
2165
79136bbClaude2166 return c.redirect(
2167 `/${ownerName}/${repoName}/issues/${issueNum}`
2168 );
2169 }
2170);
2171
2172// Close issue
2173issueRoutes.post(
2174 "/:owner/:repo/issues/:number/close",
2175 softAuth,
2176 requireAuth,
04f6b7fClaude2177 requireRepoAccess("write"),
79136bbClaude2178 async (c) => {
2179 const { owner: ownerName, repo: repoName } = c.req.param();
2180 const issueNum = parseInt(c.req.param("number"), 10);
2181
2182 const resolved = await resolveRepo(ownerName, repoName);
2183 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2184
2185 await db
2186 .update(issues)
2187 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2188 .where(
2189 and(
2190 eq(issues.repositoryId, resolved.repo.id),
2191 eq(issues.number, issueNum)
2192 )
2193 );
a74f4edccanty labs2194 void fireWebhooks(resolved.repo.id, "issue", {
2195 action: "closed",
2196 number: issueNum,
2197 });
79136bbClaude2198
2199 return c.redirect(
2200 `/${ownerName}/${repoName}/issues/${issueNum}`
2201 );
2202 }
2203);
2204
2205// Reopen issue
2206issueRoutes.post(
2207 "/:owner/:repo/issues/:number/reopen",
2208 softAuth,
2209 requireAuth,
04f6b7fClaude2210 requireRepoAccess("write"),
79136bbClaude2211 async (c) => {
2212 const { owner: ownerName, repo: repoName } = c.req.param();
2213 const issueNum = parseInt(c.req.param("number"), 10);
2214
2215 const resolved = await resolveRepo(ownerName, repoName);
2216 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2217
2218 await db
2219 .update(issues)
2220 .set({ state: "open", closedAt: null, updatedAt: new Date() })
2221 .where(
2222 and(
2223 eq(issues.repositoryId, resolved.repo.id),
2224 eq(issues.number, issueNum)
2225 )
2226 );
a74f4edccanty labs2227 void fireWebhooks(resolved.repo.id, "issue", {
2228 action: "reopened",
2229 number: issueNum,
2230 });
79136bbClaude2231
2232 return c.redirect(
2233 `/${ownerName}/${repoName}/issues/${issueNum}`
2234 );
2235 }
2236);
2237
2238// Shared nav component with issues tab
621081eccantynz-alt2239// Thin wrapper over the shared RepoNav so this page — and the ~7 other
2240// route files that import IssueNav (ai-explain, ai-tests, compare,
2241// nl-search, semantic-search, migration-assistant, ai-changelog) — all
2242// render the ONE canonical repo nav instead of a stripped 3-tab bar.
2243// Previously a deliberate minimal nav because RepoNav was treated as
2244// locked; owner-directed nav unification this session reverses that.
79136bbClaude2245const IssueNav = ({
2246 owner,
2247 repo,
2248 active,
2249}: {
2250 owner: string;
2251 repo: string;
2252 active: "code" | "commits" | "issues";
621081eccantynz-alt2253}) => <RepoNav owner={owner} repo={repo} active={active} />;
79136bbClaude2254
58915a9Claude2255// Re-run AI triage on demand (e.g. after the issue body has been edited).
2256// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2257issueRoutes.post(
2258 "/:owner/:repo/issues/:number/ai-retriage",
2259 softAuth,
2260 requireAuth,
2261 requireRepoAccess("write"),
2262 async (c) => {
2263 const { owner: ownerName, repo: repoName } = c.req.param();
2264 const issueNum = parseInt(c.req.param("number"), 10);
2265 const user = c.get("user")!;
2266 const resolved = await resolveRepo(ownerName, repoName);
2267 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2268
2269 const [issue] = await db
2270 .select()
2271 .from(issues)
2272 .where(
2273 and(
2274 eq(issues.repositoryId, resolved.repo.id),
2275 eq(issues.number, issueNum)
2276 )
2277 )
2278 .limit(1);
2279 if (!issue) {
2280 return c.redirect(`/${ownerName}/${repoName}/issues`);
2281 }
2282
2283 triggerIssueTriage(
2284 {
2285 ownerName,
2286 repoName,
2287 repositoryId: resolved.repo.id,
2288 issueId: issue.id,
2289 issueNumber: issue.number,
2290 authorId: user.id,
2291 title: issue.title,
2292 body: issue.body || "",
2293 },
2294 { force: true }
a28cedeClaude2295 ).catch((err) => {
2296 console.warn(
2297 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2298 err instanceof Error ? err.message : err
2299 );
2300 });
58915a9Claude2301
2302 return c.redirect(
2303 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2304 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2305 )}`
2306 );
2307 }
2308);
2309
c922868Claude2310// ─── Create branch from issue ─────────────────────────────────────────────────
2311// POST /:owner/:repo/issues/:number/branch
2312// Creates a new git branch from the repo default branch, pre-named after the
2313// issue. Write access required. Zero-config — no new DB tables.
2314
2315issueRoutes.post(
2316 "/:owner/:repo/issues/:number/branch",
2317 softAuth,
2318 requireAuth,
2319 requireRepoAccess("write"),
2320 async (c) => {
2321 const { owner: ownerName, repo: repoName } = c.req.param();
2322 const issueNum = parseInt(c.req.param("number"), 10);
2323
2324 const resolved = await resolveRepo(ownerName, repoName);
2325 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2326
2327 const [issue] = await db
2328 .select({ id: issues.id, number: issues.number, title: issues.title })
2329 .from(issues)
2330 .where(
2331 and(
2332 eq(issues.repositoryId, resolved.repo.id),
2333 eq(issues.number, issueNum)
2334 )
2335 )
2336 .limit(1);
2337
2338 if (!issue) {
2339 return c.redirect(`/${ownerName}/${repoName}/issues`);
2340 }
2341
2342 const body = await c.req.formData().catch(() => null);
2343 const rawName = (body?.get("branchName") as string | null)?.trim();
2344
2345 // Derive a slug from the issue title
2346 const titleSlug = issue.title
2347 .toLowerCase()
2348 .replace(/[^a-z0-9]+/g, "-")
2349 .replace(/^-+|-+$/g, "")
2350 .slice(0, 40);
2351 const suggested = `issue-${issue.number}-${titleSlug}`;
2352 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2353 ? rawName
2354 : suggested;
2355
2356 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2357 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2358
2359 if (!sha) {
2360 return c.redirect(
2361 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2362 "Cannot create branch: repository has no commits yet."
2363 )}`
2364 );
2365 }
2366
2367 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2368 if (!ok) {
2369 return c.redirect(
2370 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2371 `Failed to create branch '${branchName}'. It may already exist.`
2372 )}`
2373 );
2374 }
2375
2376 return c.redirect(
2377 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2378 `Branch '${branchName}' created from ${defaultBranch}.`
2379 )}`
2380 );
2381 }
2382);
2383
2c61840Claude2384// POST /:owner/:repo/issues/:number/set-parent
2385// Sets or clears the parent (epic) for an issue.
2386issueRoutes.post(
2387 "/:owner/:repo/issues/:number/set-parent",
2388 softAuth,
2389 requireAuth,
2390 requireRepoAccess("write"),
2391 async (c) => {
2392 const { owner: ownerName, repo: repoName } = c.req.param();
2393 const issueNum = parseInt(c.req.param("number"), 10);
2394 const form = await c.req.formData();
2395 const parentNum = form.get("parent_number")?.toString().trim() ?? "";
2396
2397 const resolved = await resolveRepo(ownerName, repoName);
2398 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2399
2400 const [issue] = await db
2401 .select({ id: issues.id })
2402 .from(issues)
2403 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, issueNum)))
2404 .limit(1);
2405 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2406
2407 // Clear parent
2408 if (!parentNum || parentNum === "0") {
2409 await db.update(issues).set({ parentIssueId: null }).where(eq(issues.id, issue.id));
2410 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Removed from epic.")}`);
2411 }
2412
2413 const pNum = parseInt(parentNum, 10);
2414 if (isNaN(pNum) || pNum === issueNum) {
2415 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Invalid parent issue number.")}`);
2416 }
2417
2418 const [parent] = await db
2419 .select({ id: issues.id })
2420 .from(issues)
2421 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, pNum)))
2422 .limit(1);
2423 if (!parent) {
2424 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Issue #${pNum} not found.`)}`);
2425 }
2426
2427 await db.update(issues).set({ parentIssueId: parent.id }).where(eq(issues.id, issue.id));
2428 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Added to epic #${pNum}.`)}`);
2429 }
2430);
2431
79136bbClaude2432export default issueRoutes;
2433export { IssueNav };