Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

issues.tsx

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

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