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.tsxBlame2434 lines · 4 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
fc623bfccantynz-alt6import { parseIdNumber } from "../lib/route-params";
240c477Claude7import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
79136bbClaude8import { db } from "../db";
a74f4edccanty labs9import { fireWebhooks } from "./webhooks";
79136bbClaude10import {
11 issues,
12 issueComments,
13 repositories,
14 users,
15 labels,
16 issueLabels,
240c477Claude17 pullRequests,
85c4e13Claude18 milestones,
79136bbClaude19} from "../db/schema";
20import { Layout } from "../views/layout";
f6730d0ccantynz-alt21import {
22 RepoHeader,
23 RepoNav,
24 PageHeader as GxPageHeader,
25 Button as GxButton,
26 Badge as GxBadge,
27 sharedComponentStyles,
28} from "../views/components";
cb5a796Claude29import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude30import { ReactionsBar } from "../views/reactions";
31import { summariseReactions } from "../lib/reactions";
24cf2caClaude32import { loadIssueTemplate } from "../lib/templates";
79136bbClaude33import { renderMarkdown } from "../lib/markdown";
b584e52Claude34import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude35import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude36import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude37import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude38import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude39import { softAuth, requireAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude41import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude42import {
43 decideInitialStatus,
44 notifyOwnerOfPendingComment,
45 countPendingForRepo,
46} from "../lib/comment-moderation";
bb0f894Claude47import {
48 Flex,
49 Container,
50 PageHeader,
51 Form,
52 FormGroup,
53 Input,
54 TextArea,
55 Button,
56 LinkButton,
57 Badge,
58 EmptyState,
59 TabNav,
60 FilterTabs,
61 List,
62 ListItem,
63 Alert,
64 CommentBox,
65 CommentForm,
66 formatRelative,
67} from "../views/ui";
c922868Claude68import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude69import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude70import { isAiAvailable } from "../lib/ai-client";
79136bbClaude71
72const issueRoutes = new Hono<AuthEnv>();
73
f7ad7b8Claude74// ---------------------------------------------------------------------------
75// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
76// other routes/shared views. All design tokens come from :root in layout.tsx.
77// ---------------------------------------------------------------------------
78const issuesStyles = `
f1dc7c7Claude79 /* Mobile rules — added in the 720px sweep. Kept additive only. */
80 @media (max-width: 720px) {
81 .issues-toolbar { flex-direction: column; align-items: stretch; }
82 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
83 .issues-filter { min-height: 40px; padding: 10px 14px; }
84 .issues-row { padding: 12px 14px; gap: 10px; }
85 .issues-row-side { flex-wrap: wrap; gap: 8px; }
86 .issues-detail-hero { padding: 18px; }
87 .issues-detail-attr { font-size: 13px; }
88 .issues-composer-actions { gap: 8px; }
89 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
90 .issues-empty { padding: 40px 20px; }
91 }
92
f7ad7b8Claude93 /* Count chip + filter pills */
94 .issues-toolbar {
95 display: flex;
96 align-items: center;
97 justify-content: space-between;
98 gap: 12px;
99 flex-wrap: wrap;
100 margin: 0 0 16px;
101 }
102 .issues-filters {
103 display: inline-flex;
104 background: var(--bg-elevated);
105 border: 1px solid var(--border);
106 border-radius: 9999px;
107 padding: 4px;
108 gap: 2px;
109 }
110 .issues-filter {
111 display: inline-flex;
112 align-items: center;
113 gap: 6px;
114 padding: 6px 14px;
115 border-radius: 9999px;
116 font-size: 13px;
117 font-weight: 500;
118 color: var(--text-muted);
119 text-decoration: none;
120 transition: color 120ms ease, background 120ms ease;
121 line-height: 1.4;
122 }
123 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
124 .issues-filter.is-active {
6fd5915Claude125 background: rgba(91,110,232,0.14);
f7ad7b8Claude126 color: var(--text-strong);
127 }
128 .issues-filter-count {
129 font-variant-numeric: tabular-nums;
130 font-size: 11.5px;
131 color: var(--text-muted);
132 background: rgba(255,255,255,0.04);
133 padding: 1px 7px;
134 border-radius: 9999px;
135 }
136 .issues-filter.is-active .issues-filter-count {
6fd5915Claude137 background: rgba(91,110,232,0.18);
f7ad7b8Claude138 color: var(--text);
139 }
140
7a28902Claude141 /* Issue search form */
142 .issues-search-form {
143 display: flex;
144 align-items: center;
145 gap: 0;
146 background: var(--bg-elevated);
147 border: 1px solid var(--border);
148 border-radius: 9999px;
149 overflow: hidden;
150 flex: 1;
151 max-width: 280px;
152 transition: border-color 120ms ease;
153 }
6fd5915Claude154 .issues-search-form:focus-within { border-color: rgba(91,110,232,0.55); }
7a28902Claude155 .issues-search-input {
156 flex: 1;
157 background: transparent;
158 color: var(--text);
159 border: none;
160 outline: none;
161 padding: 6px 14px;
162 font-size: 13px;
163 font-family: var(--font-sans, inherit);
164 min-width: 0;
165 }
166 .issues-search-input::placeholder { color: var(--text-muted); }
167 .issues-search-btn {
168 background: transparent;
169 border: none;
170 color: var(--text-muted);
171 padding: 6px 12px 6px 8px;
172 cursor: pointer;
173 display: flex;
174 align-items: center;
175 }
176 .issues-search-btn:hover { color: var(--text); }
177 .issues-filter-banner {
178 margin-bottom: 12px;
179 padding: 8px 14px;
6fd5915Claude180 background: rgba(91,110,232,0.06);
181 border: 1px solid rgba(91,110,232,0.2);
7a28902Claude182 border-radius: 8px;
183 font-size: 13px;
184 color: var(--text-muted);
185 }
186 .issues-filter-clear {
6fd5915Claude187 color: var(--accent, #5b6ee8);
7a28902Claude188 text-decoration: underline;
189 cursor: pointer;
190 }
191 .issues-label-badge {
192 display: inline-block;
6fd5915Claude193 background: rgba(91,110,232,0.15);
7a28902Claude194 color: #a78bfa;
195 border-radius: 9999px;
196 padding: 1px 8px;
197 font-size: 11.5px;
198 font-weight: 600;
199 }
200
f7ad7b8Claude201 /* Issue list — modernised rows */
202 .issues-list {
203 list-style: none;
204 margin: 0;
205 padding: 0;
206 border: 1px solid var(--border);
207 border-radius: 12px;
208 overflow: hidden;
209 background: var(--bg-elevated);
210 }
211 .issues-row {
212 display: flex;
213 align-items: flex-start;
214 gap: 14px;
215 padding: 14px 18px;
216 border-bottom: 1px solid var(--border);
217 transition: background 120ms ease, transform 120ms ease;
218 }
219 .issues-row:last-child { border-bottom: none; }
6fd5915Claude220 .issues-row:hover { background: rgba(91,110,232,0.04); }
f7ad7b8Claude221 .issues-row-icon {
222 width: 18px;
223 height: 18px;
224 flex-shrink: 0;
225 margin-top: 3px;
226 display: inline-flex;
227 align-items: center;
228 justify-content: center;
229 }
e589f77ccantynz-alt230 .issues-row-icon.is-open { color: var(--green); }
231 .issues-row-icon.is-closed { color: var(--accent); }
f7ad7b8Claude232 .issues-row-main { flex: 1; min-width: 0; }
233 .issues-row-title {
234 font-family: var(--font-display);
235 font-size: 15.5px;
236 font-weight: 600;
237 line-height: 1.35;
238 letter-spacing: -0.012em;
239 margin: 0;
240 display: flex;
241 flex-wrap: wrap;
242 align-items: center;
243 gap: 8px;
244 }
245 .issues-row-title a {
246 color: var(--text-strong);
247 text-decoration: none;
248 transition: color 120ms ease;
249 }
250 .issues-row-title a:hover { color: var(--accent); }
251 .issues-row-meta {
252 margin-top: 5px;
253 font-size: 12.5px;
254 color: var(--text-muted);
255 line-height: 1.5;
256 }
257 .issues-row-meta strong { color: var(--text); font-weight: 600; }
258 .issues-row-side {
259 display: flex;
260 align-items: center;
261 gap: 12px;
262 color: var(--text-muted);
263 font-size: 12.5px;
264 flex-shrink: 0;
265 }
266 .issues-row-comments {
267 display: inline-flex;
268 align-items: center;
269 gap: 5px;
270 color: var(--text-muted);
271 text-decoration: none;
272 }
273 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
274
275 /* Label pills (rendered inline on titles) */
276 .issues-label {
277 display: inline-flex;
278 align-items: center;
279 padding: 2px 9px;
280 border-radius: 9999px;
281 font-size: 11.5px;
282 font-weight: 600;
283 line-height: 1.4;
6fd5915Claude284 background: rgba(91,110,232,0.10);
f7ad7b8Claude285 color: var(--text-strong);
6fd5915Claude286 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude287 letter-spacing: 0.005em;
288 }
289
290 /* Polished empty state */
291 .issues-empty {
292 margin: 0;
293 padding: 56px 32px;
294 background: var(--bg-elevated);
295 border: 1px solid var(--border);
296 border-radius: 16px;
297 text-align: center;
298 position: relative;
299 overflow: hidden;
300 }
301 .issues-empty::before {
302 content: '';
303 position: absolute;
304 top: 0; left: 0; right: 0;
305 height: 2px;
6fd5915Claude306 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude307 opacity: 0.55;
308 pointer-events: none;
309 }
310 .issues-empty-art {
311 width: 96px;
312 height: 96px;
313 margin: 0 auto 18px;
314 display: block;
315 opacity: 0.85;
316 }
317 .issues-empty-title {
318 font-family: var(--font-display);
319 font-size: 22px;
320 font-weight: 700;
321 letter-spacing: -0.018em;
322 color: var(--text-strong);
323 margin: 0 0 8px;
324 }
325 .issues-empty-sub {
326 font-size: 14.5px;
327 color: var(--text-muted);
328 line-height: 1.55;
329 margin: 0 auto 22px;
330 max-width: 460px;
331 }
332 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
333
334 /* ─── Detail page ─── */
335 .issues-detail-hero {
336 position: relative;
337 margin: 4px 0 20px;
338 padding: 22px 26px;
339 background: var(--bg-elevated);
340 border: 1px solid var(--border);
341 border-radius: 16px;
342 overflow: hidden;
343 }
344 .issues-detail-hero::before {
345 content: '';
346 position: absolute;
347 top: 0; left: 0; right: 0;
348 height: 2px;
6fd5915Claude349 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f7ad7b8Claude350 opacity: 0.7;
351 pointer-events: none;
352 }
353 .issues-detail-title {
354 font-family: var(--font-display);
355 font-size: clamp(22px, 3vw, 30px);
356 font-weight: 700;
357 letter-spacing: -0.022em;
358 line-height: 1.18;
359 color: var(--text-strong);
360 margin: 0 0 12px;
361 }
362 .issues-detail-title .issues-detail-number {
363 color: var(--text-muted);
364 font-weight: 500;
365 margin-left: 8px;
366 }
367 .issues-detail-attr {
368 display: flex;
369 align-items: center;
370 gap: 12px;
371 flex-wrap: wrap;
372 font-size: 14px;
373 color: var(--text-muted);
374 }
375 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
376 .issues-detail-spacer { flex: 1; }
2c61840Claude377
378 /* Sub-issues / epics */
379 .issues-epic-crumb {
380 display: inline-flex;
381 align-items: center;
382 gap: 6px;
383 font-size: 12px;
384 color: var(--text-muted);
385 margin-bottom: var(--space-2);
386 }
387 .issues-epic-crumb a { color: var(--accent); text-decoration: none; }
388 .issues-epic-crumb a:hover { text-decoration: underline; }
389 .issues-sub-panel {
390 background: var(--bg-elevated);
391 border: 1px solid var(--border);
392 border-radius: 12px;
393 margin-bottom: var(--space-4);
394 overflow: hidden;
395 }
396 .issues-sub-panel-head {
397 display: flex;
398 align-items: center;
399 justify-content: space-between;
400 padding: var(--space-3) var(--space-4);
401 border-bottom: 1px solid var(--border);
402 font-size: 12px;
403 font-weight: 700;
404 text-transform: uppercase;
405 letter-spacing: 0.07em;
406 color: var(--text-muted);
407 }
408 .issues-sub-row {
409 display: flex;
410 align-items: center;
411 gap: var(--space-3);
412 padding: var(--space-2) var(--space-4);
413 font-size: 13px;
414 transition: background 120ms;
415 }
416 .issues-sub-row:hover { background: var(--bg-hover); }
417 .issues-sub-row-icon { font-size: 12px; flex-shrink: 0; }
e589f77ccantynz-alt418 .issues-sub-row-icon.is-open { color: var(--green); }
419 .issues-sub-row-icon.is-closed { color: var(--accent); }
2c61840Claude420 .issues-sub-row-title { flex: 1; min-width: 0; }
421 .issues-sub-row-title a { color: var(--text); text-decoration: none; font-weight: 500; }
422 .issues-sub-row-title a:hover { color: var(--accent); text-decoration: underline; }
423 .issues-sub-row-num { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
424 .issues-sub-row-author { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
425 /* Epic pill on the issue list */
426 .issues-tag-epic {
427 display: inline-flex; align-items: center; gap: 4px;
428 padding: 2px 7px;
429 font-size: 11px; font-weight: 600;
430 border-radius: 9999px;
431 background: rgba(210,153,34,0.12);
432 color: #d29922;
433 border: 1px solid rgba(210,153,34,0.35);
434 }
435 .issues-tag-child {
436 display: inline-flex; align-items: center; gap: 4px;
437 padding: 2px 7px;
438 font-size: 11px; font-weight: 600;
439 border-radius: 9999px;
440 background: rgba(88,166,255,0.10);
441 color: #58a6ff;
442 border: 1px solid rgba(88,166,255,0.25);
443 }
444
f7ad7b8Claude445 .issues-detail-labels {
446 margin-top: 12px;
447 display: flex;
448 gap: 6px;
449 flex-wrap: wrap;
450 }
451
452 /* Comment thread */
453 .issues-thread { margin-top: 18px; }
454 .issues-comment {
455 position: relative;
456 border: 1px solid var(--border);
457 border-radius: 12px;
458 overflow: hidden;
459 background: var(--bg-elevated);
460 margin-bottom: 14px;
461 transition: border-color 160ms ease, box-shadow 160ms ease;
462 }
463 .issues-comment:hover {
464 border-color: var(--border-strong, rgba(255,255,255,0.13));
465 }
466 .issues-comment-header {
467 background: var(--bg-secondary);
468 padding: 10px 16px;
469 border-bottom: 1px solid var(--border);
470 display: flex;
471 align-items: center;
472 gap: 10px;
473 font-size: 13px;
474 color: var(--text-muted);
475 }
476 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
477 .issues-comment-body { padding: 14px 18px; }
478 .issues-comment-author-pill {
479 display: inline-flex;
480 align-items: center;
481 padding: 1px 8px;
482 border-radius: 9999px;
6fd5915Claude483 background: rgba(91,110,232,0.10);
f7ad7b8Claude484 color: var(--accent);
485 font-size: 11px;
486 font-weight: 600;
487 letter-spacing: 0.02em;
488 text-transform: uppercase;
489 }
490
491 /* AI Review comment — distinct purple-accent treatment */
492 .issues-comment.is-ai {
6fd5915Claude493 border-color: rgba(91,110,232,0.45);
494 box-shadow: 0 0 0 1px rgba(91,110,232,0.18), 0 12px 32px -16px rgba(91,110,232,0.25);
f7ad7b8Claude495 }
496 .issues-comment.is-ai::before {
497 content: '';
498 position: absolute;
499 top: 0; left: 0; right: 0;
500 height: 2px;
6fd5915Claude501 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude502 pointer-events: none;
503 }
504 .issues-comment.is-ai .issues-comment-header {
6fd5915Claude505 background: linear-gradient(180deg, rgba(91,110,232,0.08), rgba(91,110,232,0.02));
506 border-bottom-color: rgba(91,110,232,0.22);
f7ad7b8Claude507 }
508 .issues-ai-badge {
509 display: inline-flex;
510 align-items: center;
511 gap: 5px;
512 padding: 2px 9px;
513 border-radius: 9999px;
6fd5915Claude514 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f7ad7b8Claude515 color: #fff;
516 font-size: 11px;
517 font-weight: 700;
518 letter-spacing: 0.04em;
519 text-transform: uppercase;
520 line-height: 1.4;
521 }
522 .issues-ai-badge::before {
523 content: '';
524 width: 6px;
525 height: 6px;
526 border-radius: 9999px;
527 background: #fff;
528 opacity: 0.92;
529 box-shadow: 0 0 6px rgba(255,255,255,0.7);
530 }
531
a7460bfClaude532 .issues-bot-badge {
533 display: inline-flex; align-items: center; gap: 3px;
534 padding: 1px 7px;
535 font-size: 10px;
536 font-weight: 600;
537 color: var(--fg-muted);
538 background: var(--bg-elevated);
539 border: 1px solid var(--border);
540 border-radius: 9999px;
541 }
542
f7ad7b8Claude543 /* Composer */
544 .issues-composer {
545 margin-top: 22px;
546 border: 1px solid var(--border);
547 border-radius: 12px;
548 overflow: hidden;
549 background: var(--bg-elevated);
550 transition: border-color 160ms ease, box-shadow 160ms ease;
551 }
552 .issues-composer:focus-within {
6fd5915Claude553 border-color: rgba(91,110,232,0.55);
554 box-shadow: 0 0 0 3px rgba(91,110,232,0.16);
f7ad7b8Claude555 }
556 .issues-composer-header {
557 display: flex;
558 align-items: center;
559 justify-content: space-between;
560 gap: 8px;
561 padding: 10px 14px;
562 background: var(--bg-secondary);
563 border-bottom: 1px solid var(--border);
564 font-size: 12.5px;
565 color: var(--text-muted);
566 }
567 .issues-composer-tag {
568 font-weight: 600;
569 color: var(--text);
570 letter-spacing: -0.005em;
571 }
572 .issues-composer-hint a {
573 color: var(--text-muted);
574 text-decoration: none;
575 border-bottom: 1px dashed currentColor;
576 }
577 .issues-composer-hint a:hover { color: var(--accent); }
578 .issues-composer textarea {
579 display: block;
580 width: 100%;
581 border: 0;
582 background: transparent;
583 color: var(--text);
584 padding: 14px 16px;
585 font-family: var(--font-mono);
586 font-size: 13.5px;
587 line-height: 1.55;
588 resize: vertical;
589 outline: none;
590 min-height: 140px;
591 }
592 .issues-composer-actions {
593 display: flex;
594 align-items: center;
595 gap: 8px;
596 padding: 10px 14px;
597 border-top: 1px solid var(--border);
598 background: var(--bg-secondary);
599 flex-wrap: wrap;
600 }
601
602 /* Info banner inside detail */
603 .issues-info-banner {
604 margin: 0 0 14px;
605 padding: 10px 14px;
606 border-radius: 12px;
6fd5915Claude607 background: rgba(91,110,232,0.08);
608 border: 1px solid rgba(91,110,232,0.28);
f7ad7b8Claude609 color: var(--text);
610 font-size: 13.5px;
611 }
240c477Claude612
613 /* ─── Linked PRs ─── */
614 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
615 .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; }
616 .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; }
617 .iss-linked-pr-row:last-child { border-bottom: none; }
618 .iss-linked-pr-row:hover { background: var(--bg-hover); }
e589f77ccantynz-alt619 .iss-linked-pr-icon.is-open { color: var(--green); }
240c477Claude620 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
621 .iss-linked-pr-icon.is-closed { color: #8b949e; }
622 .iss-linked-pr-icon.is-draft { color: #8b949e; }
623 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
624 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
625 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
e589f77ccantynz-alt626 .iss-linked-pr-state.is-open { color: var(--green); background: rgba(52,211,153,0.10); }
240c477Claude627 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
628 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
629 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude630
8d1483cClaude631 /* ─── Bulk action UI ─── */
632 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
633 .bulk-bar {
634 display: none;
635 align-items: center;
636 gap: 10px;
637 padding: 10px 16px;
638 background: var(--bg-elevated);
639 border: 1px solid var(--border-strong);
640 border-radius: var(--r-md);
641 margin-bottom: 12px;
642 position: sticky;
643 top: calc(var(--header-h) + 8px);
644 z-index: 10;
645 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
646 }
647 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
648 .bulk-bar-btn {
649 padding: 6px 12px;
650 border-radius: var(--r-sm);
651 border: 1px solid var(--border);
652 background: var(--bg-surface);
653 color: var(--text);
654 font-size: 13px;
655 cursor: pointer;
656 transition: background 120ms;
657 }
658 .bulk-bar-btn:hover { background: var(--bg-hover); }
659 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
660 .bulk-bar-clear:hover { color: var(--text); }
661
0224546Claude662 /* ─── Issue detail metadata sidebar ─── */
663 .issue-detail-layout {
664 display: flex;
665 align-items: flex-start;
666 gap: 24px;
667 }
668 .issue-detail-main {
669 flex: 1;
670 min-width: 0;
671 }
672 .issue-meta-sidebar {
673 width: 240px;
674 flex-shrink: 0;
675 display: flex;
676 flex-direction: column;
677 gap: 20px;
678 }
679 .issue-meta-section {
680 border-top: 1px solid var(--border);
681 padding-top: 14px;
682 }
683 .issue-meta-section:first-child {
684 border-top: none;
685 padding-top: 0;
686 }
687 .issue-meta-label {
688 font-size: 12px;
689 font-weight: 600;
690 color: var(--text-muted);
691 text-transform: uppercase;
692 letter-spacing: 0.06em;
693 margin-bottom: 8px;
694 }
695 .issue-meta-empty {
696 font-size: 13px;
697 color: var(--text-subtle, var(--text-muted));
698 font-style: italic;
699 }
700 .issue-meta-label-pill {
701 display: inline-flex;
702 align-items: center;
703 padding: 3px 10px;
704 border-radius: 9999px;
705 font-size: 12px;
706 font-weight: 600;
707 line-height: 1.4;
708 margin: 2px 3px 2px 0;
709 border: 1px solid transparent;
710 }
711 .issue-meta-labels {
712 display: flex;
713 flex-wrap: wrap;
714 gap: 4px;
715 }
716 .issue-meta-assignee {
717 display: flex;
718 align-items: center;
719 gap: 8px;
720 font-size: 13px;
721 color: var(--text);
722 }
723 .issue-meta-avatar {
724 width: 24px;
725 height: 24px;
726 border-radius: 9999px;
727 background: rgba(140,109,255,0.25);
728 color: var(--text-strong);
729 font-size: 11px;
730 font-weight: 700;
731 display: inline-flex;
732 align-items: center;
733 justify-content: center;
734 flex-shrink: 0;
735 text-transform: uppercase;
736 }
737 .issue-meta-milestone-link {
738 font-size: 13px;
739 color: var(--text-link, var(--accent));
740 text-decoration: none;
741 display: inline-flex;
742 align-items: center;
743 gap: 5px;
744 }
745 .issue-meta-milestone-link:hover { text-decoration: underline; }
746 @media (max-width: 720px) {
747 .issue-detail-layout { flex-direction: column; }
748 .issue-meta-sidebar { width: 100%; order: 1; }
749 .issue-detail-main { order: 0; }
750 }
751
f5b9ef5Claude752 /* ─── Sort controls (issue list) ─── */
753 .issues-sort-row {
754 display: flex;
755 align-items: center;
756 gap: 6px;
757 margin: 0 0 12px;
758 flex-wrap: wrap;
759 }
760 .issues-sort-label {
761 font-size: 12.5px;
762 color: var(--text-muted);
763 font-weight: 600;
764 margin-right: 2px;
765 }
766 .issues-sort-opt {
767 font-size: 12.5px;
768 color: var(--text-muted);
769 text-decoration: none;
770 padding: 3px 10px;
771 border-radius: 9999px;
772 border: 1px solid transparent;
773 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
774 }
775 .issues-sort-opt:hover {
776 background: var(--bg-hover);
777 color: var(--text);
778 }
779 .issues-sort-opt.is-active {
6fd5915Claude780 background: rgba(91,110,232,0.12);
f5b9ef5Claude781 color: var(--text-link);
6fd5915Claude782 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude783 font-weight: 600;
784 }
f7ad7b8Claude785`;
786
787// Pre-rendered <style> tag (constant, reused per request).
788const IssuesStyle = () => (
789 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
790);
791
f6730d0ccantynz-alt792// Shared design-system primitive styles (PageHeader / Button / Badge).
793// Idempotent — safe to include once per page that uses the gx-* primitives.
794const SharedStyle = () => (
795 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
796);
797
f7ad7b8Claude798// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
799const IssuesEmptySvg = () => (
800 <svg
801 class="issues-empty-art"
802 viewBox="0 0 96 96"
803 fill="none"
804 xmlns="http://www.w3.org/2000/svg"
805 aria-hidden="true"
806 >
807 <defs>
808 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
6fd5915Claude809 <stop offset="0%" stop-color="#5b6ee8" stop-opacity="0.55" />
810 <stop offset="100%" stop-color="#5f8fa0" stop-opacity="0.55" />
f7ad7b8Claude811 </linearGradient>
812 </defs>
6fd5915Claude813 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(91,110,232,0.04)" />
f7ad7b8Claude814 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
815 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
816 </svg>
817);
818
819// Detect AI Triage comments by the marker the triage helper writes.
820function isAiTriageComment(body: string | null | undefined): boolean {
821 if (!body) return false;
822 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
823}
824
79136bbClaude825// Helper to resolve repo from :owner/:repo params
826async function resolveRepo(ownerName: string, repoName: string) {
827 const [owner] = await db
828 .select()
829 .from(users)
830 .where(eq(users.username, ownerName))
831 .limit(1);
832 if (!owner) return null;
833
834 const [repo] = await db
835 .select()
836 .from(repositories)
837 .where(
838 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
839 )
840 .limit(1);
841 if (!repo) return null;
842
843 return { owner, repo };
844}
845
8d1483cClaude846// Bulk issue operations (close / reopen multiple issues at once)
847issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
848 const { owner: ownerName, repo: repoName } = c.req.param();
849 const user = c.get("user")!;
850 const body = await c.req.parseBody();
851 const action = String(body.action || "");
852 const rawNumbers = body["numbers[]"];
853 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
854 .map(n => parseInt(String(n), 10))
855 .filter(n => !isNaN(n) && n > 0);
856
857 if (!numbers.length || !["close","reopen"].includes(action)) {
858 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
859 }
860
861 const resolved = await resolveRepo(ownerName, repoName);
862 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
863
864 const newState = action === "close" ? "closed" : "open";
865 await db.update(issues)
866 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
867 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
868
869 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
870 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
871});
872
79136bbClaude873// Issue list
04f6b7fClaude874issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude875 const { owner: ownerName, repo: repoName } = c.req.param();
876 const user = c.get("user");
877 const state = c.req.query("state") || "open";
7a28902Claude878 const searchQ = (c.req.query("q") || "").trim();
879 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude880 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude881 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude882 // Bounded pagination — unbounded selects ran a full table scan + O(n)
883 // sort on every page load; with 10k+ issues the request would hang.
884 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
885 const page = Math.max(1, Number(c.req.query("page")) || 1);
886 const offset = (page - 1) * perPage;
79136bbClaude887
f1dc7c7Claude888 // ── Loading skeleton (flag-gated) ──
889 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
890 // user see the shape of the issue list before the DB count + select
891 // resolve. Behind a flag — we don't ship flashes.
892 if (c.req.query("skeleton") === "1") {
893 return c.html(
894 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
895 <IssuesStyle />
896 <RepoHeader owner={ownerName} repo={repoName} />
897 <IssueNav owner={ownerName} repo={repoName} active="issues" />
898 <style
899 dangerouslySetInnerHTML={{
900 __html: `
901 .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; }
902 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
903 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
904 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
905 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
906 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
907 .issues-skel-row { height: 62px; border-radius: 10px; }
908 `,
909 }}
910 />
911 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
912 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
913 <div class="issues-skel-list" aria-hidden="true">
914 {Array.from({ length: 8 }).map(() => (
915 <div class="issues-skel issues-skel-row" />
916 ))}
917 </div>
918 <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">
919 Loading issues for {ownerName}/{repoName}…
920 </span>
921 </Layout>
922 );
923 }
924
79136bbClaude925 const resolved = await resolveRepo(ownerName, repoName);
926 if (!resolved) {
927 return c.html(
928 <Layout title="Not Found" user={user}>
bb0f894Claude929 <EmptyState title="Repository not found" />
79136bbClaude930 </Layout>,
931 404
932 );
933 }
934
935 const { repo } = resolved;
936
7a28902Claude937 // If label filter is set, find issue IDs that have that label
938 let labelFilteredIds: string[] | null = null;
939 if (labelFilter) {
940 const [matchedLabel] = await db
941 .select({ id: labels.id })
942 .from(labels)
943 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
944 .limit(1);
945 if (matchedLabel) {
946 const rows = await db
947 .select({ issueId: issueLabels.issueId })
948 .from(issueLabels)
949 .where(eq(issueLabels.labelId, matchedLabel.id));
950 labelFilteredIds = rows.map((r) => r.issueId);
951 } else {
952 labelFilteredIds = []; // no matches
953 }
954 }
955
85c4e13Claude956 // If milestone filter is set, resolve the milestone ID
957 let milestoneFilterId: string | null = null;
958 if (milestoneFilter) {
959 const [matchedMs] = await db
960 .select({ id: milestones.id })
961 .from(milestones)
962 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
963 .limit(1);
964 milestoneFilterId = matchedMs?.id ?? null;
965 }
966
7a28902Claude967 const baseWhere = and(
968 eq(issues.repositoryId, repo.id),
969 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
970 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
971 labelFilteredIds !== null && labelFilteredIds.length > 0
972 ? inArray(issues.id, labelFilteredIds)
973 : labelFilteredIds !== null && labelFilteredIds.length === 0
974 ? sql`false`
85c4e13Claude975 : undefined,
976 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude977 );
978
79136bbClaude979 const issueList = await db
980 .select({
981 issue: issues,
982 author: { username: users.username },
983 })
984 .from(issues)
985 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude986 .where(baseWhere)
f5b9ef5Claude987 .orderBy(
988 sort === "oldest" ? asc(issues.createdAt)
989 : sort === "updated" ? desc(issues.updatedAt)
990 : desc(issues.createdAt) // newest (default)
991 )
6ea2109Claude992 .limit(perPage)
993 .offset(offset);
79136bbClaude994
2c61840Claude995 // Batch-load sub-issue counts for epics in this list page
996 const issueIds = issueList.map((r) => r.issue.id);
997 const subCountMap = new Map<string, number>();
998 const parentNumberMap = new Map<string, number>(); // childIssueId → parentIssueNumber
999 if (issueIds.length > 0) {
1000 const [subRows, parentRows] = await Promise.all([
1001 // Count children per parent (to show "N sub-issues" on epics)
1002 db
1003 .select({
1004 parentId: issues.parentIssueId,
1005 cnt: sql<number>`count(*)::int`,
1006 })
1007 .from(issues)
1008 .where(
1009 and(
1010 inArray(issues.parentIssueId, issueIds),
1011 eq(issues.repositoryId, repo.id)
1012 )
1013 )
1014 .groupBy(issues.parentIssueId),
1015 // Resolve parent numbers for children (to show "Part of #N")
1016 issueList
1017 .filter((r) => r.issue.parentIssueId !== null)
1018 .length > 0
1019 ? db
1020 .select({ id: issues.id, number: issues.number })
1021 .from(issues)
1022 .where(
1023 inArray(
1024 issues.id,
1025 issueList
1026 .filter((r) => r.issue.parentIssueId !== null)
1027 .map((r) => r.issue.parentIssueId as string)
1028 )
1029 )
1030 : Promise.resolve([]),
1031 ]);
1032 subRows.forEach((r) => {
1033 if (r.parentId) subCountMap.set(r.parentId, Number(r.cnt));
1034 });
1035 parentRows.forEach((p) => parentNumberMap.set(p.id, p.number));
1036 }
1037
79136bbClaude1038 // Count open/closed
1039 const [counts] = await db
1040 .select({
02aa828ccantynz-alt1041 open: sql<number>`(count(*) filter (where ${issues.state} = 'open'))::int`,
1042 closed: sql<number>`(count(*) filter (where ${issues.state} = 'closed'))::int`,
79136bbClaude1043 })
1044 .from(issues)
1045 .where(eq(issues.repositoryId, repo.id));
1046
85c4e13Claude1047 // Count open milestones for the "N Milestones" link
1048 const [milestoneCounts] = await db
1049 .select({
1050 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
1051 })
1052 .from(milestones)
1053 .where(eq(milestones.repositoryId, repo.id));
1054
cb5a796Claude1055 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
1056 const pendingCountList = viewerIsOwnerOnList
1057 ? await countPendingForRepo(repo.id)
1058 : 0;
1059
79136bbClaude1060 return c.html(
1061 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1062 <IssuesStyle />
f6730d0ccantynz-alt1063 <SharedStyle />
79136bbClaude1064 <RepoHeader owner={ownerName} repo={repoName} />
1065 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1066 <PendingCommentsBanner
1067 owner={ownerName}
1068 repo={repoName}
1069 count={pendingCountList}
1070 />
f6730d0ccantynz-alt1071 <GxPageHeader
1072 eyebrow={`Issue tracker \u00B7 ${ownerName}/${repoName}`}
1073 title="Track what matters."
1074 lede={
1075 (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1076 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
1077 : `${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.`
1078 }
1079 actions={
1080 <>
f7ad7b8Claude1081 {user && (
f6730d0ccantynz-alt1082 <GxButton href={`/${ownerName}/${repoName}/issues/new`} variant="primary">
f7ad7b8Claude1083 + New issue
f6730d0ccantynz-alt1084 </GxButton>
f7ad7b8Claude1085 )}
f6730d0ccantynz-alt1086 <GxButton
85c4e13Claude1087 href={`/${ownerName}/${repoName}/milestones`}
f6730d0ccantynz-alt1088 variant="secondary"
85c4e13Claude1089 title="View milestones for this repository"
1090 >
1091 Milestones
1092 {Number(milestoneCounts?.open ?? 0) > 0 && (
6fd5915Claude1093 <span style="margin-left:5px;font-size:11.5px;background:rgba(91,110,232,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
85c4e13Claude1094 {Number(milestoneCounts?.open ?? 0)}
1095 </span>
1096 )}
f6730d0ccantynz-alt1097 </GxButton>
1098 <GxButton href={`/${ownerName}/${repoName}`} variant="secondary">
f7ad7b8Claude1099 Back to code
f6730d0ccantynz-alt1100 </GxButton>
1101 </>
1102 }
1103 />
f7ad7b8Claude1104
1105 <div class="issues-toolbar">
1106 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1107 <a
1108 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1109 href={`/${ownerName}/${repoName}/issues?state=open`}
1110 role="tab"
1111 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1112 >
f7ad7b8Claude1113 <span aria-hidden="true">{"\u25CB"}</span>
1114 <span>Open</span>
1115 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1116 </a>
1117 <a
1118 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1119 href={`/${ownerName}/${repoName}/issues?state=closed`}
1120 role="tab"
1121 aria-selected={state === "closed" ? "true" : "false"}
1122 >
1123 <span aria-hidden="true">{"\u2713"}</span>
1124 <span>Closed</span>
1125 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1126 </a>
1127 </div>
7a28902Claude1128 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1129 <input type="hidden" name="state" value={state} />
1130 <input
1131 type="search"
1132 name="q"
1133 value={searchQ}
1134 placeholder="Search issues\u2026"
1135 class="issues-search-input"
1136 aria-label="Search issues by title"
1137 />
1138 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1139 <button type="submit" class="issues-search-btn" aria-label="Search">
1140 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1141 <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" />
1142 </svg>
1143 </button>
1144 </form>
f7ad7b8Claude1145 </div>
7a28902Claude1146 {(searchQ || labelFilter) && (
1147 <div class="issues-filter-banner">
1148 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1149 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1150 {" \u00B7 "}
1151 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1152 </div>
1153 )}
f7ad7b8Claude1154
f5b9ef5Claude1155 <div class="issues-sort-row">
1156 <span class="issues-sort-label">Sort:</span>
1157 {(["newest", "oldest", "updated"] as const).map((s) => (
1158 <a
1159 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1160 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1161 >
1162 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1163 </a>
1164 ))}
1165 </div>
1166
79136bbClaude1167 {issueList.length === 0 ? (
f7ad7b8Claude1168 <div class="issues-empty">
1169 <IssuesEmptySvg />
1170 <h2 class="issues-empty-title">
1171 {state === "closed"
1172 ? "No closed issues yet"
1173 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1174 ? "No issues \u2014 yet"
1175 : "Nothing open right now"}
1176 </h2>
1177 <p class="issues-empty-sub">
1178 {state === "closed"
1179 ? "Closed issues will show up here once the team starts shipping fixes."
1180 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1181 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1182 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1183 </p>
1184 <div class="issues-empty-cta">
1185 {user && state !== "closed" && (
1186 <a
1187 href={`/${ownerName}/${repoName}/issues/new`}
1188 class="btn btn-primary"
1189 >
1190 + Open the first issue
1191 </a>
1192 )}
79136bbClaude1193 {state === "closed" && (
f7ad7b8Claude1194 <a
1195 href={`/${ownerName}/${repoName}/issues?state=open`}
1196 class="btn"
1197 >
1198 View open issues
1199 </a>
79136bbClaude1200 )}
f7ad7b8Claude1201 </div>
1202 </div>
79136bbClaude1203 ) : (
8d1483cClaude1204 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1205 <input type="hidden" name="action" id="bulk-action" value="" />
1206 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1207 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1208 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1209 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1210 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1211 </div>
1212 <ul class="issues-list">
1213 {issueList.map(({ issue, author }) => (
1214 <li class="issues-row">
1215 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1216 <div
1217 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1218 aria-hidden="true"
1219 title={issue.state === "open" ? "Open" : "Closed"}
1220 >
1221 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1222 </div>
8d1483cClaude1223 <div class="issues-row-main">
1224 <h3 class="issues-row-title">
1225 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1226 {issue.title}
1227 </a>
2c61840Claude1228 {subCountMap.has(issue.id) && (
1229 <span class="issues-tag-epic" title={`Epic with ${subCountMap.get(issue.id)} sub-issue${subCountMap.get(issue.id) === 1 ? "" : "s"}`}>
1230 📌 Epic · {subCountMap.get(issue.id)}
1231 </span>
1232 )}
1233 {issue.parentIssueId && parentNumberMap.has(issue.parentIssueId) && (
1234 <span class="issues-tag-child" title={`Sub-issue of #${parentNumberMap.get(issue.parentIssueId)}`}>
1235 ↳ #{parentNumberMap.get(issue.parentIssueId)}
1236 </span>
1237 )}
8d1483cClaude1238 </h3>
1239 <div class="issues-row-meta">
1240 #{issue.number} opened by{" "}
1241 <strong>{author.username}</strong>{" "}
1242 {formatRelative(issue.createdAt)}
1243 </div>
1244 </div>
1245 </li>
1246 ))}
1247 </ul>
1248 <script dangerouslySetInnerHTML={{ __html: `
1249function bulkSubmit(action){
1250 document.getElementById('bulk-action').value=action;
1251 document.getElementById('bulk-form').submit();
1252}
1253function bulkClear(){
1254 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1255 updateBulkBar();
1256}
1257function updateBulkBar(){
1258 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1259 var bar=document.getElementById('bulk-bar');
1260 var cnt=document.getElementById('bulk-bar-count');
1261 if(bar){bar.style.display=checked>0?'flex':'none';}
1262 if(cnt){cnt.textContent=checked+' selected';}
1263}
1264document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1265` }} />
1266 </form>
79136bbClaude1267 )}
1268 </Layout>
1269 );
1270});
1271
1272// New issue form
1273issueRoutes.get(
1274 "/:owner/:repo/issues/new",
1275 softAuth,
1276 requireAuth,
04f6b7fClaude1277 requireRepoAccess("write"),
79136bbClaude1278 async (c) => {
1279 const { owner: ownerName, repo: repoName } = c.req.param();
1280 const user = c.get("user")!;
1281 const error = c.req.query("error");
24cf2caClaude1282 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1283
85c4e13Claude1284 // Load open milestones for the dropdown
1285 const resolved = await resolveRepo(ownerName, repoName);
1286 const openMilestones = resolved
1287 ? await db
1288 .select({ id: milestones.id, title: milestones.title })
1289 .from(milestones)
1290 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1291 .orderBy(milestones.title)
1292 : [];
1293
79136bbClaude1294 return c.html(
1295 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1296 <IssuesStyle />
f6730d0ccantynz-alt1297 <SharedStyle />
79136bbClaude1298 <RepoHeader owner={ownerName} repo={repoName} />
1299 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1300 <Container maxWidth={800}>
f6730d0ccantynz-alt1301 <GxPageHeader
1302 eyebrow={`New issue · ${ownerName}/${repoName}`}
1303 title="File it cleanly."
1304 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."
1305 />
79136bbClaude1306 {error && (
bb0f894Claude1307 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1308 )}
0316dbbClaude1309 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1310 <FormGroup>
1311 <Input
79136bbClaude1312 type="text"
1313 name="title"
1314 required
1315 placeholder="Title"
bb0f894Claude1316 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1317 aria-label="Issue title"
79136bbClaude1318 />
bb0f894Claude1319 </FormGroup>
1320 <FormGroup>
1321 <TextArea
79136bbClaude1322 name="body"
1323 rows={12}
1324 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1325 mono
79136bbClaude1326 />
bb0f894Claude1327 </FormGroup>
85c4e13Claude1328 {openMilestones.length > 0 && (
1329 <FormGroup>
1330 <label
1331 for="milestone_id"
1332 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1333 >
1334 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1335 </label>
1336 <select
1337 id="milestone_id"
1338 name="milestone_id"
1339 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"
1340 >
1341 <option value="">No milestone</option>
1342 {openMilestones.map((ms) => (
1343 <option value={ms.id}>{ms.title}</option>
1344 ))}
1345 </select>
1346 </FormGroup>
1347 )}
bb0f894Claude1348 <Button type="submit" variant="primary">
79136bbClaude1349 Submit new issue
bb0f894Claude1350 </Button>
1351 </Form>
1352 </Container>
79136bbClaude1353 </Layout>
1354 );
1355 }
1356);
1357
1358// Create issue
1359issueRoutes.post(
1360 "/:owner/:repo/issues/new",
1361 softAuth,
1362 requireAuth,
04f6b7fClaude1363 requireRepoAccess("write"),
79136bbClaude1364 async (c) => {
1365 const { owner: ownerName, repo: repoName } = c.req.param();
1366 const user = c.get("user")!;
1367 const body = await c.req.parseBody();
1368 const title = String(body.title || "").trim();
1369 const issueBody = String(body.body || "").trim();
85c4e13Claude1370 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1371
1372 if (!title) {
1373 return c.redirect(
1374 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1375 );
1376 }
1377
1378 const resolved = await resolveRepo(ownerName, repoName);
1379 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1380
85c4e13Claude1381 // Validate milestone belongs to this repo if provided
1382 let validatedMilestoneId: string | null = null;
1383 if (milestoneIdRaw) {
1384 const [msRow] = await db
1385 .select({ id: milestones.id })
1386 .from(milestones)
1387 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1388 .limit(1);
1389 validatedMilestoneId = msRow?.id ?? null;
1390 }
1391
79136bbClaude1392 const [issue] = await db
1393 .insert(issues)
1394 .values({
1395 repositoryId: resolved.repo.id,
1396 authorId: user.id,
1397 title,
1398 body: issueBody || null,
85c4e13Claude1399 milestoneId: validatedMilestoneId,
79136bbClaude1400 })
1401 .returning();
1402
1403 // Update issue count
1404 await db
1405 .update(repositories)
1406 .set({ issueCount: resolved.repo.issueCount + 1 })
1407 .where(eq(repositories.id, resolved.repo.id));
1408
a74f4edccanty labs1409 void fireWebhooks(resolved.repo.id, "issue", {
1410 action: "opened",
1411 number: issue.number,
1412 title,
1413 });
1414
a9ada5fClaude1415 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1416 // suggested labels, priority, summary, and a possible-duplicate
1417 // callout. Suggestions only — nothing applied automatically.
1418 triggerIssueTriage({
1419 ownerName,
1420 repoName,
1421 repositoryId: resolved.repo.id,
1422 issueId: issue.id,
1423 issueNumber: issue.number,
1424 authorId: user.id,
1425 title,
1426 body: issueBody,
a28cedeClaude1427 }).catch((err) => {
1428 console.warn(
1429 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1430 err instanceof Error ? err.message : err
1431 );
1432 });
a9ada5fClaude1433
79136bbClaude1434 return c.redirect(
1435 `/${ownerName}/${repoName}/issues/${issue.number}`
1436 );
1437 }
1438);
1439
1440// View single issue
04f6b7fClaude1441issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1442 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt1443 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
79136bbClaude1444 const user = c.get("user");
1445
1446 const resolved = await resolveRepo(ownerName, repoName);
1447 if (!resolved) {
1448 return c.html(
1449 <Layout title="Not Found" user={user}>
bb0f894Claude1450 <EmptyState title="Not found" />
79136bbClaude1451 </Layout>,
1452 404
1453 );
1454 }
1455
1456 const [issue] = await db
1457 .select()
1458 .from(issues)
1459 .where(
1460 and(
1461 eq(issues.repositoryId, resolved.repo.id),
1462 eq(issues.number, issueNum)
1463 )
1464 )
1465 .limit(1);
1466
1467 if (!issue) {
1468 return c.html(
1469 <Layout title="Not Found" user={user}>
bb0f894Claude1470 <EmptyState title="Issue not found" />
79136bbClaude1471 </Layout>,
1472 404
1473 );
1474 }
1475
1476 const [author] = await db
1477 .select()
1478 .from(users)
1479 .where(eq(users.id, issue.authorId))
1480 .limit(1);
1481
cb5a796Claude1482 // Get comments. We pull every row and filter by moderation_status +
1483 // viewer identity client-side (in the JSX below) so a moderator/owner
1484 // sees them all while non-author viewers only ever see 'approved'.
1485 const allComments = await db
79136bbClaude1486 .select({
1487 comment: issueComments,
cb5a796Claude1488 author: { id: users.id, username: users.username },
79136bbClaude1489 })
1490 .from(issueComments)
1491 .innerJoin(users, eq(issueComments.authorId, users.id))
1492 .where(eq(issueComments.issueId, issue.id))
1493 .orderBy(asc(issueComments.createdAt));
1494
cb5a796Claude1495 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1496 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1497 // Owner always sees everything (so they can spot conversations going
1498 // sideways even before they hit the queue page).
1499 if (viewerIsOwner) return true;
1500 if (comment.moderationStatus === "approved") return true;
1501 // The comment's own author sees their pending comment so they know
1502 // it landed (with an "Awaiting approval" badge in the render below).
1503 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1504 return true;
1505 }
1506 return false;
1507 });
1508
1509 // Pending banner — when the viewer is the repo owner, show a strip at
1510 // the top of the page nudging them to the moderation queue. Inline on
1511 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1512 const pendingCount = viewerIsOwner
1513 ? await countPendingForRepo(resolved.repo.id)
1514 : 0;
1515
2c61840Claude1516 // Sub-issues / epics — load parent issue (if this is a child) and
1517 // any child issues (if this is an epic), in parallel with reactions.
1518 const [parentIssueRow, subIssueRows] = await Promise.all([
1519 issue.parentIssueId
1520 ? db
1521 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
1522 .from(issues)
1523 .where(eq(issues.id, issue.parentIssueId))
1524 .limit(1)
1525 .then((r) => r[0] ?? null)
1526 : Promise.resolve(null),
1527 db
1528 .select({
1529 id: issues.id,
1530 number: issues.number,
1531 title: issues.title,
1532 state: issues.state,
1533 authorUsername: users.username,
1534 })
1535 .from(issues)
1536 .innerJoin(users, eq(issues.authorId, users.id))
1537 .where(and(eq(issues.parentIssueId, issue.id), eq(issues.repositoryId, resolved.repo.id)))
1538 .orderBy(asc(issues.number)),
1539 ]);
1540
6fc53bdClaude1541 // Load reactions for the issue + each comment in parallel.
1542 const [issueReactions, ...commentReactions] = await Promise.all([
1543 summariseReactions("issue", issue.id, user?.id),
1544 ...comments.map((row) =>
1545 summariseReactions("issue_comment", row.comment.id, user?.id)
1546 ),
1547 ]);
1548
79136bbClaude1549 const canManage =
1550 user &&
1551 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1552 const info = c.req.query("info");
79136bbClaude1553
0224546Claude1554 // Labels attached to this issue.
1555 const issueDetailLabels = await db
1556 .select({ id: labels.id, name: labels.name, color: labels.color })
1557 .from(issueLabels)
1558 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1559 .where(eq(issueLabels.issueId, issue.id));
1560
1561 // Milestone for this issue (if any).
1562 const issueMilestone = issue.milestoneId
1563 ? await db
1564 .select({ id: milestones.id, title: milestones.title })
1565 .from(milestones)
1566 .where(eq(milestones.id, issue.milestoneId))
1567 .limit(1)
1568 .then((rows) => rows[0] ?? null)
1569 : null;
1570
240c477Claude1571 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1572 const issueRefPattern = `%#${issue.number}%`;
1573 const linkedPrs = await db
1574 .select({
1575 number: pullRequests.number,
1576 title: pullRequests.title,
1577 state: pullRequests.state,
1578 isDraft: pullRequests.isDraft,
1579 })
1580 .from(pullRequests)
1581 .where(
1582 and(
1583 eq(pullRequests.repositoryId, resolved.repo.id),
1584 or(
1585 ilike(pullRequests.title, issueRefPattern),
1586 ilike(pullRequests.body, issueRefPattern),
1587 )
1588 )
1589 )
1590 .orderBy(desc(pullRequests.createdAt))
1591 .limit(8);
1592
79136bbClaude1593 return c.html(
1594 <Layout
1595 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1596 user={user}
1597 >
f7ad7b8Claude1598 <IssuesStyle />
f6730d0ccantynz-alt1599 <SharedStyle />
79136bbClaude1600 <RepoHeader owner={ownerName} repo={repoName} />
1601 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1602 <PendingCommentsBanner
1603 owner={ownerName}
1604 repo={repoName}
1605 count={pendingCount}
1606 />
b584e52Claude1607 <div
1608 id="live-comment-banner"
1609 class="alert"
1610 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1611 >
1612 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1613 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1614 reload to view
1615 </a>
1616 </div>
1617 <script
1618 dangerouslySetInnerHTML={{
1619 __html: liveCommentBannerScript({
1620 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1621 bannerElementId: "live-comment-banner",
1622 }),
1623 }}
1624 />
829a046Claude1625 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1626 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1627 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1628 <div class="issue-detail">
58915a9Claude1629 {info && (
f7ad7b8Claude1630 <div class="issues-info-banner">
58915a9Claude1631 {decodeURIComponent(info)}
1632 </div>
1633 )}
f7ad7b8Claude1634
1635 <section class="issues-detail-hero">
2c61840Claude1636 {parentIssueRow && (
1637 <div class="issues-epic-crumb">
1638 <span>📌 Part of epic</span>
1639 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`}>
1640 #{parentIssueRow.number} — {parentIssueRow.title}
1641 </a>
1642 </div>
1643 )}
f7ad7b8Claude1644 <h1 class="issues-detail-title">
1645 {issue.title}
1646 <span class="issues-detail-number">#{issue.number}</span>
1647 </h1>
1648 <div class="issues-detail-attr">
f6730d0ccantynz-alt1649 <GxBadge variant={issue.state === "open" ? "success" : "info"}>
f7ad7b8Claude1650 <span aria-hidden="true">
1651 {issue.state === "open" ? "\u25CB" : "\u2713"}
1652 </span>
1653 {issue.state === "open" ? "Open" : "Closed"}
f6730d0ccantynz-alt1654 </GxBadge>
f7ad7b8Claude1655 <span>
1656 <strong>{author?.username || "unknown"}</strong> opened this
1657 issue {formatRelative(issue.createdAt)}
1658 </span>
1659 <span class="issues-detail-spacer" />
1660 {issue.state === "open" && user && user.id === resolved.owner.id && (
1661 <a
1662 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1663 class="btn btn-primary"
1664 style="font-size:13px;padding:6px 12px"
1665 title="Generate a draft pull request from this issue using Claude"
1666 >
1667 Build with AI
1668 </a>
1669 )}
f928118Claude1670 {issue.state === "open" && user && isAiAvailable() && (
1671 <form
1672 method="post"
1673 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1674 style="display:inline"
1675 title="Let AI implement this feature automatically"
1676 >
1677 <button
1678 type="submit"
1679 class="btn btn-primary"
6fd5915Claude1680 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);border:none;cursor:pointer"
f928118Claude1681 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1682 >
1683 Ship It
1684 </button>
1685 </form>
1686 )}
c922868Claude1687 {issue.state === "open" && user && (
1688 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1689 <summary
1690 class="btn"
1691 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1692 title="Create a new branch for this issue"
1693 >
1694 Create branch
1695 </summary>
1696 <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)">
1697 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1698 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1699 <input
1700 type="text"
1701 name="branchName"
1702 class="input"
1703 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1704 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1705 pattern="[a-zA-Z0-9._\\-/]+"
1706 required
1707 />
1708 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1709 Create branch
1710 </button>
1711 </form>
1712 </div>
1713 </details>
1714 )}
f7ad7b8Claude1715 </div>
1716 </section>
1717
0224546Claude1718 <div class="issue-detail-layout">
1719 <div class="issue-detail-main">
2c61840Claude1720
1721 {subIssueRows.length > 0 && (
1722 <div class="issues-sub-panel">
1723 <div class="issues-sub-panel-head">
1724 <span>Sub-issues</span>
1725 <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">
1726 {subIssueRows.filter((s) => s.state === "open").length}/{subIssueRows.length} open
1727 </span>
1728 </div>
1729 {subIssueRows.map((sub) => (
1730 <div class="issues-sub-row" key={sub.id}>
1731 <span class={`issues-sub-row-icon ${sub.state === "open" ? "is-open" : "is-closed"}`}>
1732 {sub.state === "open" ? "○" : "✓"}
1733 </span>
1734 <span class="issues-sub-row-title">
1735 <a href={`/${ownerName}/${repoName}/issues/${sub.number}`}>{sub.title}</a>
1736 </span>
1737 <span class="issues-sub-row-author">{sub.authorUsername}</span>
1738 <span class="issues-sub-row-num">#{sub.number}</span>
1739 </div>
1740 ))}
1741 </div>
1742 )}
1743
f7ad7b8Claude1744 <div class="issues-thread">
1745 {issue.body && (
1746 <article class="issues-comment">
1747 <header class="issues-comment-header">
1748 <strong>{author?.username || "unknown"}</strong>
1749 <span class="issues-comment-author-pill">Author</span>
1750 <span>commented {formatRelative(issue.createdAt)}</span>
1751 </header>
1752 <div class="issues-comment-body">
1753 <div
1754 class="markdown-body"
1755 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1756 />
1757 </div>
1758 </article>
fbf4aefClaude1759 )}
79136bbClaude1760
f7ad7b8Claude1761 {comments.map(({ comment, author: commentAuthor }) => {
1762 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1763 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1764 return (
cb5a796Claude1765 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1766 <header class="issues-comment-header">
1767 <strong>{commentAuthor.username}</strong>
a7460bfClaude1768 {commentAuthor.username === BOT_USERNAME && (
1769 <span class="issues-bot-badge">&#x1F916; bot</span>
1770 )}
f7ad7b8Claude1771 {isAi ? (
1772 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1773 AI Review
1774 </span>
1775 ) : null}
cb5a796Claude1776 {isPending ? (
1777 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1778 Awaiting approval
1779 </span>
1780 ) : null}
f7ad7b8Claude1781 <span>commented {formatRelative(comment.createdAt)}</span>
1782 </header>
1783 <div class="issues-comment-body">
1784 <div
1785 class="markdown-body"
1786 dangerouslySetInnerHTML={{
1787 __html: renderMarkdown(comment.body),
1788 }}
1789 />
1790 </div>
1791 </article>
1792 );
1793 })}
1794 </div>
79136bbClaude1795
240c477Claude1796 {linkedPrs.length > 0 && (
1797 <div class="iss-linked-prs">
1798 <div class="iss-linked-prs-head">
1799 <span>Linked pull requests</span>
1800 <span>{linkedPrs.length}</span>
1801 </div>
1802 {linkedPrs.map((lpr) => {
1803 const prState = lpr.isDraft ? "draft" : lpr.state;
1804 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1805 return (
1806 <a
1807 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1808 class="iss-linked-pr-row"
1809 >
1810 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1811 <span class="iss-linked-pr-title">{lpr.title}</span>
1812 <span class="iss-linked-pr-num">#{lpr.number}</span>
1813 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1814 </a>
1815 );
1816 })}
1817 </div>
1818 )}
1819
79136bbClaude1820 {user && (
f7ad7b8Claude1821 <form
1822 class="issues-composer"
1823 method="post"
1824 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1825 >
1826 <div class="issues-composer-header">
1827 <span class="issues-composer-tag">Add a comment</span>
1828 <span class="issues-composer-hint">
1829 <a
1830 href="https://docs.github.com/en/get-started/writing-on-github"
1831 target="_blank"
1832 rel="noopener noreferrer"
1833 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1834 >
1835 Markdown supported
1836 </a>
1837 </span>
1838 </div>
1839 <textarea
1840 name="body"
1841 rows={6}
1842 required
6cd2f0eClaude1843 data-md-preview=""
f7ad7b8Claude1844 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1845 />
1846 <div class="issues-composer-actions">
1847 <button type="submit" class="btn btn-primary">
1848 Comment
1849 </button>
1850 {canManage && (
1851 <button
1852 type="submit"
1853 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1854 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1855 >
1856 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1857 </button>
f7ad7b8Claude1858 )}
1859 {canManage && issue.state === "open" && (
1860 <button
1861 type="submit"
1862 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1863 formnovalidate
1864 class="btn"
1865 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1866 >
1867 Re-run AI triage
1868 </button>
1869 )}
1870 </div>
1871 </form>
79136bbClaude1872 )}
0224546Claude1873 </div>{/* /issue-detail-main */}
1874
1875 {/* Metadata sidebar — Labels, Assignees, Milestone */}
1876 <aside class="issue-meta-sidebar">
1877 {/* Labels */}
1878 <div class="issue-meta-section">
1879 <div class="issue-meta-label">Labels</div>
1880 {issueDetailLabels.length === 0 ? (
1881 <span class="issue-meta-empty">None yet</span>
1882 ) : (
1883 <div class="issue-meta-labels">
1884 {issueDetailLabels.map((lbl) => {
1885 // Compute luminance from hex color to choose dark/light text
1886 const hex = lbl.color.replace("#", "");
1887 const r = parseInt(hex.slice(0, 2), 16) / 255;
1888 const g = parseInt(hex.slice(2, 4), 16) / 255;
1889 const b = parseInt(hex.slice(4, 6), 16) / 255;
1890 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1891 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
1892 return (
1893 <span
1894 class="issue-meta-label-pill"
1895 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
1896 >
1897 {lbl.name}
1898 </span>
1899 );
1900 })}
1901 </div>
1902 )}
1903 </div>
1904
1905 {/* Assignees — field not yet in schema; placeholder */}
1906 <div class="issue-meta-section">
1907 <div class="issue-meta-label">Assignees</div>
1908 <span class="issue-meta-empty">No one assigned</span>
1909 </div>
1910
1911 {/* Milestone */}
1912 <div class="issue-meta-section">
1913 <div class="issue-meta-label">Milestone</div>
1914 {issueMilestone ? (
1915 <a
1916 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
1917 class="issue-meta-milestone-link"
1918 >
1919 &#x25CE; {issueMilestone.title}
1920 </a>
1921 ) : (
1922 <span class="issue-meta-empty">No milestone</span>
1923 )}
1924 </div>
2c61840Claude1925 {/* Epic / parent issue */}
1926 {viewerIsOwner && (
1927 <div class="issue-meta-section">
1928 <div class="issue-meta-label">Epic (parent issue)</div>
1929 {parentIssueRow ? (
1930 <div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">
1931 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`} style="color:var(--accent);text-decoration:none">
1932 #{parentIssueRow.number} {parentIssueRow.title.slice(0, 40)}{parentIssueRow.title.length > 40 ? "…" : ""}
1933 </a>
1934 </div>
1935 ) : (
1936 <span class="issue-meta-empty">None</span>
1937 )}
1938 <form
1939 method="post"
1940 action={`/${ownerName}/${repoName}/issues/${issue.number}/set-parent`}
1941 style="display:flex;gap:6px;margin-top:6px"
1942 >
1943 <input
1944 type="number"
1945 name="parent_number"
1946 placeholder={parentIssueRow ? "Clear (enter 0)" : "Issue #"}
1947 style="width:100%;padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-inset);color:var(--text)"
1948 min="0"
1949 />
1950 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px;white-space:nowrap">
1951 Set
1952 </button>
1953 </form>
1954 </div>
1955 )}
0224546Claude1956 </aside>
1957 </div>{/* /issue-detail-layout */}
79136bbClaude1958 </div>
641aa42Claude1959 {/* Issue keyboard hints bar */}
1960 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1961 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
1962 </div>
1963 <style dangerouslySetInnerHTML={{ __html: `
1964 .kbd-hints {
1965 position: fixed;
1966 bottom: 0;
1967 left: 0;
1968 right: 0;
1969 z-index: 90;
1970 padding: 6px 24px;
1971 background: var(--bg-secondary);
1972 border-top: 1px solid var(--border);
1973 font-size: 12px;
1974 color: var(--text-muted);
1975 display: flex;
1976 align-items: center;
1977 gap: 8px;
1978 flex-wrap: wrap;
1979 }
1980 .kbd-hints kbd {
1981 font-family: var(--font-mono);
1982 font-size: 10px;
1983 background: var(--bg-elevated);
1984 border: 1px solid var(--border);
1985 border-bottom-width: 2px;
1986 border-radius: 4px;
1987 padding: 1px 5px;
1988 color: var(--text);
1989 line-height: 1.5;
1990 }
1991 main { padding-bottom: 40px; }
1992 ` }} />
1993 {/* Repo context commands for command palette */}
1994 <script
1995 id="cmdk-repo-context"
1996 dangerouslySetInnerHTML={{
1997 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1998 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1999 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
2000 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
2001 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
2002 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
2003 ])};`,
2004 }}
2005 />
2006 {/* Issue keyboard shortcuts */}
2007 <script dangerouslySetInnerHTML={{ __html: `
2008 (function(){
2009 function isTyping(t){
2010 t = t || {};
2011 var tag = (t.tagName || '').toLowerCase();
2012 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
2013 }
2014 document.addEventListener('keydown', function(e){
2015 if (isTyping(e.target)) return;
2016 if (e.metaKey || e.ctrlKey || e.altKey) return;
2017 if (e.key === 'c') {
2018 e.preventDefault();
2019 var box = document.querySelector('textarea[name="body"]');
2020 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
2021 }
2022 if (e.key === 'e') {
2023 e.preventDefault();
2024 // Focus the issue title and make it editable if possible
2025 var titleEl = document.querySelector('.issues-title');
2026 if (titleEl) titleEl.scrollIntoView({block:'center'});
2027 }
2028 if (e.key === 'x') {
2029 e.preventDefault();
2030 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
2031 if (closeBtn) {
2032 var confirmed = window.confirm('Close/reopen this issue?');
2033 if (confirmed) closeBtn.click();
2034 }
2035 }
2036 if (e.key === 'Escape') {
2037 var focused = document.activeElement;
2038 if (focused) focused.blur();
2039 }
2040 });
2041 })();
2042 ` }} />
79136bbClaude2043 </Layout>
2044 );
2045});
2046
cb5a796Claude2047// Add comment.
2048//
2049// Permission model: we accept comments from ANY authenticated user (read
2050// access required — public repos satisfy that, private repos still need a
2051// collaborator row). The moderation gate in `decideInitialStatus`
2052// determines whether the comment is published immediately or queued for
2053// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
2054// "anti-impersonation" rationale.
79136bbClaude2055issueRoutes.post(
2056 "/:owner/:repo/issues/:number/comment",
2057 softAuth,
2058 requireAuth,
cb5a796Claude2059 requireRepoAccess("read"),
79136bbClaude2060 async (c) => {
2061 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2062 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
79136bbClaude2063 const user = c.get("user")!;
2064 const body = await c.req.parseBody();
2065 const commentBody = String(body.body || "").trim();
2066
2067 if (!commentBody) {
2068 return c.redirect(
2069 `/${ownerName}/${repoName}/issues/${issueNum}`
2070 );
2071 }
2072
2073 const resolved = await resolveRepo(ownerName, repoName);
2074 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2075
2076 const [issue] = await db
2077 .select()
2078 .from(issues)
2079 .where(
2080 and(
2081 eq(issues.repositoryId, resolved.repo.id),
2082 eq(issues.number, issueNum)
2083 )
2084 )
2085 .limit(1);
2086
2087 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2088
cb5a796Claude2089 // Decide moderation status BEFORE insert so the row lands in the right
2090 // initial state. Collaborators, the issue author, and trusted users
2091 // get 'approved'; banned users get 'rejected' (silent drop); everyone
2092 // else gets 'pending'.
2093 const decision = await decideInitialStatus({
2094 commenterUserId: user.id,
2095 repositoryId: resolved.repo.id,
2096 kind: "issue",
2097 threadId: issue.id,
2098 });
2099
d4ac5c3Claude2100 const [inserted] = await db
2101 .insert(issueComments)
2102 .values({
2103 issueId: issue.id,
2104 authorId: user.id,
2105 body: commentBody,
cb5a796Claude2106 moderationStatus: decision.status,
d4ac5c3Claude2107 })
2108 .returning();
2109
cb5a796Claude2110 // Live update only when visible. Don't leak pending/rejected via SSE.
2111 if (inserted && decision.status === "approved") {
a74f4edccanty labs2112 void fireWebhooks(resolved.repo.id, "issue", {
2113 action: "commented",
2114 number: issueNum,
2115 });
d4ac5c3Claude2116 try {
2117 const { publish } = await import("../lib/sse");
2118 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
2119 event: "issue-comment",
2120 data: {
2121 issueId: issue.id,
2122 commentId: inserted.id,
2123 authorId: user.id,
2124 authorUsername: user.username,
2125 },
2126 });
2127 } catch {
2128 /* SSE is best-effort */
2129 }
b7ecb14Claude2130 // Notify the issue author — fire-and-forget, never blocks the response.
2131 if (issue.authorId && issue.authorId !== user.id) {
2132 void import("../lib/notify").then(({ createNotification }) =>
2133 createNotification({
2134 userId: issue.authorId,
2135 type: "issue_comment",
2136 title: `New comment on "${issue.title}"`,
2137 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
2138 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
2139 repoId: resolved.repo.id,
2140 })
2141 ).catch(() => { /* never block the response */ });
2142 }
d4ac5c3Claude2143 }
79136bbClaude2144
cb5a796Claude2145 if (decision.status === "pending") {
2146 // Notify repo owner that a comment is awaiting review.
2147 void notifyOwnerOfPendingComment({
2148 repositoryId: resolved.repo.id,
2149 commenterUsername: user.username,
2150 kind: "issue",
2151 threadNumber: issueNum,
2152 ownerUsername: ownerName,
2153 repoName,
2154 });
2155 return c.redirect(
2156 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2157 );
2158 }
2159 if (decision.status === "rejected") {
2160 // Silent ban — the commenter is on the banned list. Don't reveal
2161 // the gate; just send them back to the page as if it posted.
2162 return c.redirect(
2163 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2164 );
2165 }
2166
79136bbClaude2167 return c.redirect(
2168 `/${ownerName}/${repoName}/issues/${issueNum}`
2169 );
2170 }
2171);
2172
2173// Close issue
2174issueRoutes.post(
2175 "/:owner/:repo/issues/:number/close",
2176 softAuth,
2177 requireAuth,
04f6b7fClaude2178 requireRepoAccess("write"),
79136bbClaude2179 async (c) => {
2180 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2181 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
79136bbClaude2182
2183 const resolved = await resolveRepo(ownerName, repoName);
2184 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2185
2186 await db
2187 .update(issues)
2188 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2189 .where(
2190 and(
2191 eq(issues.repositoryId, resolved.repo.id),
2192 eq(issues.number, issueNum)
2193 )
2194 );
a74f4edccanty labs2195 void fireWebhooks(resolved.repo.id, "issue", {
2196 action: "closed",
2197 number: issueNum,
2198 });
79136bbClaude2199
2200 return c.redirect(
2201 `/${ownerName}/${repoName}/issues/${issueNum}`
2202 );
2203 }
2204);
2205
2206// Reopen issue
2207issueRoutes.post(
2208 "/:owner/:repo/issues/:number/reopen",
2209 softAuth,
2210 requireAuth,
04f6b7fClaude2211 requireRepoAccess("write"),
79136bbClaude2212 async (c) => {
2213 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2214 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
79136bbClaude2215
2216 const resolved = await resolveRepo(ownerName, repoName);
2217 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2218
2219 await db
2220 .update(issues)
2221 .set({ state: "open", closedAt: null, updatedAt: new Date() })
2222 .where(
2223 and(
2224 eq(issues.repositoryId, resolved.repo.id),
2225 eq(issues.number, issueNum)
2226 )
2227 );
a74f4edccanty labs2228 void fireWebhooks(resolved.repo.id, "issue", {
2229 action: "reopened",
2230 number: issueNum,
2231 });
79136bbClaude2232
2233 return c.redirect(
2234 `/${ownerName}/${repoName}/issues/${issueNum}`
2235 );
2236 }
2237);
2238
2239// Shared nav component with issues tab
621081eccantynz-alt2240// Thin wrapper over the shared RepoNav so this page — and the ~7 other
2241// route files that import IssueNav (ai-explain, ai-tests, compare,
2242// nl-search, semantic-search, migration-assistant, ai-changelog) — all
2243// render the ONE canonical repo nav instead of a stripped 3-tab bar.
2244// Previously a deliberate minimal nav because RepoNav was treated as
2245// locked; owner-directed nav unification this session reverses that.
79136bbClaude2246const IssueNav = ({
2247 owner,
2248 repo,
2249 active,
2250}: {
2251 owner: string;
2252 repo: string;
2253 active: "code" | "commits" | "issues";
621081eccantynz-alt2254}) => <RepoNav owner={owner} repo={repo} active={active} />;
79136bbClaude2255
58915a9Claude2256// Re-run AI triage on demand (e.g. after the issue body has been edited).
2257// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2258issueRoutes.post(
2259 "/:owner/:repo/issues/:number/ai-retriage",
2260 softAuth,
2261 requireAuth,
2262 requireRepoAccess("write"),
2263 async (c) => {
2264 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2265 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
58915a9Claude2266 const user = c.get("user")!;
2267 const resolved = await resolveRepo(ownerName, repoName);
2268 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2269
2270 const [issue] = await db
2271 .select()
2272 .from(issues)
2273 .where(
2274 and(
2275 eq(issues.repositoryId, resolved.repo.id),
2276 eq(issues.number, issueNum)
2277 )
2278 )
2279 .limit(1);
2280 if (!issue) {
2281 return c.redirect(`/${ownerName}/${repoName}/issues`);
2282 }
2283
2284 triggerIssueTriage(
2285 {
2286 ownerName,
2287 repoName,
2288 repositoryId: resolved.repo.id,
2289 issueId: issue.id,
2290 issueNumber: issue.number,
2291 authorId: user.id,
2292 title: issue.title,
2293 body: issue.body || "",
2294 },
2295 { force: true }
a28cedeClaude2296 ).catch((err) => {
2297 console.warn(
2298 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2299 err instanceof Error ? err.message : err
2300 );
2301 });
58915a9Claude2302
2303 return c.redirect(
2304 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2305 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2306 )}`
2307 );
2308 }
2309);
2310
c922868Claude2311// ─── Create branch from issue ─────────────────────────────────────────────────
2312// POST /:owner/:repo/issues/:number/branch
2313// Creates a new git branch from the repo default branch, pre-named after the
2314// issue. Write access required. Zero-config — no new DB tables.
2315
2316issueRoutes.post(
2317 "/:owner/:repo/issues/:number/branch",
2318 softAuth,
2319 requireAuth,
2320 requireRepoAccess("write"),
2321 async (c) => {
2322 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2323 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
c922868Claude2324
2325 const resolved = await resolveRepo(ownerName, repoName);
2326 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2327
2328 const [issue] = await db
2329 .select({ id: issues.id, number: issues.number, title: issues.title })
2330 .from(issues)
2331 .where(
2332 and(
2333 eq(issues.repositoryId, resolved.repo.id),
2334 eq(issues.number, issueNum)
2335 )
2336 )
2337 .limit(1);
2338
2339 if (!issue) {
2340 return c.redirect(`/${ownerName}/${repoName}/issues`);
2341 }
2342
2343 const body = await c.req.formData().catch(() => null);
2344 const rawName = (body?.get("branchName") as string | null)?.trim();
2345
2346 // Derive a slug from the issue title
2347 const titleSlug = issue.title
2348 .toLowerCase()
2349 .replace(/[^a-z0-9]+/g, "-")
2350 .replace(/^-+|-+$/g, "")
2351 .slice(0, 40);
2352 const suggested = `issue-${issue.number}-${titleSlug}`;
2353 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2354 ? rawName
2355 : suggested;
2356
2357 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2358 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2359
2360 if (!sha) {
2361 return c.redirect(
2362 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2363 "Cannot create branch: repository has no commits yet."
2364 )}`
2365 );
2366 }
2367
2368 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2369 if (!ok) {
2370 return c.redirect(
2371 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2372 `Failed to create branch '${branchName}'. It may already exist.`
2373 )}`
2374 );
2375 }
2376
2377 return c.redirect(
2378 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2379 `Branch '${branchName}' created from ${defaultBranch}.`
2380 )}`
2381 );
2382 }
2383);
2384
2c61840Claude2385// POST /:owner/:repo/issues/:number/set-parent
2386// Sets or clears the parent (epic) for an issue.
2387issueRoutes.post(
2388 "/:owner/:repo/issues/:number/set-parent",
2389 softAuth,
2390 requireAuth,
2391 requireRepoAccess("write"),
2392 async (c) => {
2393 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt2394 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
2c61840Claude2395 const form = await c.req.formData();
2396 const parentNum = form.get("parent_number")?.toString().trim() ?? "";
2397
2398 const resolved = await resolveRepo(ownerName, repoName);
2399 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2400
2401 const [issue] = await db
2402 .select({ id: issues.id })
2403 .from(issues)
2404 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, issueNum)))
2405 .limit(1);
2406 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2407
2408 // Clear parent
2409 if (!parentNum || parentNum === "0") {
2410 await db.update(issues).set({ parentIssueId: null }).where(eq(issues.id, issue.id));
2411 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Removed from epic.")}`);
2412 }
2413
2414 const pNum = parseInt(parentNum, 10);
2415 if (isNaN(pNum) || pNum === issueNum) {
2416 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Invalid parent issue number.")}`);
2417 }
2418
2419 const [parent] = await db
2420 .select({ id: issues.id })
2421 .from(issues)
2422 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, pNum)))
2423 .limit(1);
2424 if (!parent) {
2425 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Issue #${pNum} not found.`)}`);
2426 }
2427
2428 await db.update(issues).set({ parentIssueId: parent.id }).where(eq(issues.id, issue.id));
2429 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Added to epic #${pNum}.`)}`);
2430 }
2431);
2432
79136bbClaude2433export default issueRoutes;
2434export { IssueNav };