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.tsxBlame2131 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
240c477Claude6import { eq, and, desc, asc, sql, ilike, inArray, or } from "drizzle-orm";
79136bbClaude7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
240c477Claude15 pullRequests,
85c4e13Claude16 milestones,
79136bbClaude17} from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude20import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude21import { ReactionsBar } from "../views/reactions";
22import { summariseReactions } from "../lib/reactions";
24cf2caClaude23import { loadIssueTemplate } from "../lib/templates";
79136bbClaude24import { renderMarkdown } from "../lib/markdown";
b584e52Claude25import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude26import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude27import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude28import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
f7ad7b8Claude29import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude32import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude33import {
34 decideInitialStatus,
35 notifyOwnerOfPendingComment,
36 countPendingForRepo,
37} from "../lib/comment-moderation";
bb0f894Claude38import {
39 Flex,
40 Container,
41 PageHeader,
42 Form,
43 FormGroup,
44 Input,
45 TextArea,
46 Button,
47 LinkButton,
48 Badge,
49 EmptyState,
50 TabNav,
51 FilterTabs,
52 List,
53 ListItem,
54 Alert,
55 CommentBox,
56 CommentForm,
57 formatRelative,
58} from "../views/ui";
c922868Claude59import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
a7460bfClaude60import { BOT_USERNAME } from "../lib/bot-user";
f928118Claude61import { isAiAvailable } from "../lib/ai-client";
79136bbClaude62
63const issueRoutes = new Hono<AuthEnv>();
64
f7ad7b8Claude65// ---------------------------------------------------------------------------
66// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
67// other routes/shared views. All design tokens come from :root in layout.tsx.
68// ---------------------------------------------------------------------------
69const issuesStyles = `
70 /* Hero card — list page */
71 .issues-hero {
72 position: relative;
73 margin: 4px 0 24px;
74 padding: 28px 32px;
75 background: var(--bg-elevated);
76 border: 1px solid var(--border);
77 border-radius: 16px;
78 overflow: hidden;
79 }
80 .issues-hero::before {
81 content: '';
82 position: absolute;
83 top: 0; left: 0; right: 0;
84 height: 2px;
85 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
86 opacity: 0.7;
87 pointer-events: none;
88 }
89 .issues-hero-bg {
90 position: absolute;
91 inset: -30% -10% auto auto;
92 width: 360px;
93 height: 360px;
94 pointer-events: none;
95 z-index: 0;
96 }
97 .issues-hero-orb {
98 position: absolute;
99 inset: 0;
100 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
101 filter: blur(80px);
102 opacity: 0.7;
103 animation: issuesHeroOrb 14s ease-in-out infinite;
104 }
105 @keyframes issuesHeroOrb {
106 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
107 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
108 }
109 @media (prefers-reduced-motion: reduce) {
110 .issues-hero-orb { animation: none; }
111 }
112 .issues-hero-inner {
113 position: relative;
114 z-index: 1;
115 display: flex;
116 justify-content: space-between;
117 align-items: flex-end;
118 gap: 24px;
119 flex-wrap: wrap;
120 }
121 .issues-hero-text { flex: 1; min-width: 280px; }
122 .issues-hero-eyebrow {
123 font-size: 12.5px;
124 color: var(--text-muted);
125 margin-bottom: 8px;
126 letter-spacing: 0.04em;
127 text-transform: uppercase;
128 font-weight: 600;
129 }
130 .issues-hero-eyebrow .issues-hero-repo {
131 color: var(--accent);
132 text-transform: none;
133 letter-spacing: -0.005em;
134 font-weight: 600;
135 }
136 .issues-hero-title {
137 font-family: var(--font-display);
138 font-size: clamp(28px, 4vw, 40px);
139 font-weight: 800;
140 letter-spacing: -0.028em;
141 line-height: 1.05;
142 margin: 0 0 10px;
143 color: var(--text-strong);
144 }
145 .issues-hero-sub {
146 font-size: 15px;
147 color: var(--text-muted);
148 margin: 0;
149 line-height: 1.5;
150 max-width: 580px;
151 }
152 .issues-hero-actions {
153 display: flex;
154 gap: 8px;
155 flex-wrap: wrap;
156 }
157 @media (max-width: 720px) {
158 .issues-hero { padding: 24px 20px; }
159 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
160 .issues-hero-actions { width: 100%; }
161 .issues-hero-actions .btn { flex: 1; min-width: 0; }
162 }
163
f1dc7c7Claude164 /* Mobile rules — added in the 720px sweep. Kept additive only. */
165 @media (max-width: 720px) {
166 .issues-toolbar { flex-direction: column; align-items: stretch; }
167 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
168 .issues-filter { min-height: 40px; padding: 10px 14px; }
169 .issues-row { padding: 12px 14px; gap: 10px; }
170 .issues-row-side { flex-wrap: wrap; gap: 8px; }
171 .issues-detail-hero { padding: 18px; }
172 .issues-detail-attr { font-size: 13px; }
173 .issues-composer-actions { gap: 8px; }
174 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
175 .issues-empty { padding: 40px 20px; }
176 }
177
f7ad7b8Claude178 /* Count chip + filter pills */
179 .issues-toolbar {
180 display: flex;
181 align-items: center;
182 justify-content: space-between;
183 gap: 12px;
184 flex-wrap: wrap;
185 margin: 0 0 16px;
186 }
187 .issues-filters {
188 display: inline-flex;
189 background: var(--bg-elevated);
190 border: 1px solid var(--border);
191 border-radius: 9999px;
192 padding: 4px;
193 gap: 2px;
194 }
195 .issues-filter {
196 display: inline-flex;
197 align-items: center;
198 gap: 6px;
199 padding: 6px 14px;
200 border-radius: 9999px;
201 font-size: 13px;
202 font-weight: 500;
203 color: var(--text-muted);
204 text-decoration: none;
205 transition: color 120ms ease, background 120ms ease;
206 line-height: 1.4;
207 }
208 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
209 .issues-filter.is-active {
210 background: rgba(140,109,255,0.14);
211 color: var(--text-strong);
212 }
213 .issues-filter-count {
214 font-variant-numeric: tabular-nums;
215 font-size: 11.5px;
216 color: var(--text-muted);
217 background: rgba(255,255,255,0.04);
218 padding: 1px 7px;
219 border-radius: 9999px;
220 }
221 .issues-filter.is-active .issues-filter-count {
222 background: rgba(140,109,255,0.18);
223 color: var(--text);
224 }
225
7a28902Claude226 /* Issue search form */
227 .issues-search-form {
228 display: flex;
229 align-items: center;
230 gap: 0;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 9999px;
234 overflow: hidden;
235 flex: 1;
236 max-width: 280px;
237 transition: border-color 120ms ease;
238 }
239 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
240 .issues-search-input {
241 flex: 1;
242 background: transparent;
243 color: var(--text);
244 border: none;
245 outline: none;
246 padding: 6px 14px;
247 font-size: 13px;
248 font-family: var(--font-sans, inherit);
249 min-width: 0;
250 }
251 .issues-search-input::placeholder { color: var(--text-muted); }
252 .issues-search-btn {
253 background: transparent;
254 border: none;
255 color: var(--text-muted);
256 padding: 6px 12px 6px 8px;
257 cursor: pointer;
258 display: flex;
259 align-items: center;
260 }
261 .issues-search-btn:hover { color: var(--text); }
262 .issues-filter-banner {
263 margin-bottom: 12px;
264 padding: 8px 14px;
265 background: rgba(140,109,255,0.06);
266 border: 1px solid rgba(140,109,255,0.2);
267 border-radius: 8px;
268 font-size: 13px;
269 color: var(--text-muted);
270 }
271 .issues-filter-clear {
272 color: var(--accent, #8c6dff);
273 text-decoration: underline;
274 cursor: pointer;
275 }
276 .issues-label-badge {
277 display: inline-block;
278 background: rgba(140,109,255,0.15);
279 color: #a78bfa;
280 border-radius: 9999px;
281 padding: 1px 8px;
282 font-size: 11.5px;
283 font-weight: 600;
284 }
285
f7ad7b8Claude286 /* Issue list — modernised rows */
287 .issues-list {
288 list-style: none;
289 margin: 0;
290 padding: 0;
291 border: 1px solid var(--border);
292 border-radius: 12px;
293 overflow: hidden;
294 background: var(--bg-elevated);
295 }
296 .issues-row {
297 display: flex;
298 align-items: flex-start;
299 gap: 14px;
300 padding: 14px 18px;
301 border-bottom: 1px solid var(--border);
302 transition: background 120ms ease, transform 120ms ease;
303 }
304 .issues-row:last-child { border-bottom: none; }
305 .issues-row:hover { background: rgba(140,109,255,0.04); }
306 .issues-row-icon {
307 width: 18px;
308 height: 18px;
309 flex-shrink: 0;
310 margin-top: 3px;
311 display: inline-flex;
312 align-items: center;
313 justify-content: center;
314 }
315 .issues-row-icon.is-open { color: #34d399; }
316 .issues-row-icon.is-closed { color: #b69dff; }
317 .issues-row-main { flex: 1; min-width: 0; }
318 .issues-row-title {
319 font-family: var(--font-display);
320 font-size: 15.5px;
321 font-weight: 600;
322 line-height: 1.35;
323 letter-spacing: -0.012em;
324 margin: 0;
325 display: flex;
326 flex-wrap: wrap;
327 align-items: center;
328 gap: 8px;
329 }
330 .issues-row-title a {
331 color: var(--text-strong);
332 text-decoration: none;
333 transition: color 120ms ease;
334 }
335 .issues-row-title a:hover { color: var(--accent); }
336 .issues-row-meta {
337 margin-top: 5px;
338 font-size: 12.5px;
339 color: var(--text-muted);
340 line-height: 1.5;
341 }
342 .issues-row-meta strong { color: var(--text); font-weight: 600; }
343 .issues-row-side {
344 display: flex;
345 align-items: center;
346 gap: 12px;
347 color: var(--text-muted);
348 font-size: 12.5px;
349 flex-shrink: 0;
350 }
351 .issues-row-comments {
352 display: inline-flex;
353 align-items: center;
354 gap: 5px;
355 color: var(--text-muted);
356 text-decoration: none;
357 }
358 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
359
360 /* Label pills (rendered inline on titles) */
361 .issues-label {
362 display: inline-flex;
363 align-items: center;
364 padding: 2px 9px;
365 border-radius: 9999px;
366 font-size: 11.5px;
367 font-weight: 600;
368 line-height: 1.4;
369 background: rgba(140,109,255,0.10);
370 color: var(--text-strong);
371 border: 1px solid rgba(140,109,255,0.28);
372 letter-spacing: 0.005em;
373 }
374
375 /* Polished empty state */
376 .issues-empty {
377 margin: 0;
378 padding: 56px 32px;
379 background: var(--bg-elevated);
380 border: 1px solid var(--border);
381 border-radius: 16px;
382 text-align: center;
383 position: relative;
384 overflow: hidden;
385 }
386 .issues-empty::before {
387 content: '';
388 position: absolute;
389 top: 0; left: 0; right: 0;
390 height: 2px;
391 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
392 opacity: 0.55;
393 pointer-events: none;
394 }
395 .issues-empty-art {
396 width: 96px;
397 height: 96px;
398 margin: 0 auto 18px;
399 display: block;
400 opacity: 0.85;
401 }
402 .issues-empty-title {
403 font-family: var(--font-display);
404 font-size: 22px;
405 font-weight: 700;
406 letter-spacing: -0.018em;
407 color: var(--text-strong);
408 margin: 0 0 8px;
409 }
410 .issues-empty-sub {
411 font-size: 14.5px;
412 color: var(--text-muted);
413 line-height: 1.55;
414 margin: 0 auto 22px;
415 max-width: 460px;
416 }
417 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
418
419 /* ─── Detail page ─── */
420 .issues-detail-hero {
421 position: relative;
422 margin: 4px 0 20px;
423 padding: 22px 26px;
424 background: var(--bg-elevated);
425 border: 1px solid var(--border);
426 border-radius: 16px;
427 overflow: hidden;
428 }
429 .issues-detail-hero::before {
430 content: '';
431 position: absolute;
432 top: 0; left: 0; right: 0;
433 height: 2px;
434 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
435 opacity: 0.7;
436 pointer-events: none;
437 }
438 .issues-detail-title {
439 font-family: var(--font-display);
440 font-size: clamp(22px, 3vw, 30px);
441 font-weight: 700;
442 letter-spacing: -0.022em;
443 line-height: 1.18;
444 color: var(--text-strong);
445 margin: 0 0 12px;
446 }
447 .issues-detail-title .issues-detail-number {
448 color: var(--text-muted);
449 font-weight: 500;
450 margin-left: 8px;
451 }
452 .issues-detail-attr {
453 display: flex;
454 align-items: center;
455 gap: 12px;
456 flex-wrap: wrap;
457 font-size: 14px;
458 color: var(--text-muted);
459 }
460 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
461 .issues-state-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 6px;
465 padding: 5px 12px;
466 border-radius: 9999px;
467 font-size: 12.5px;
468 font-weight: 600;
469 line-height: 1.4;
470 letter-spacing: 0.005em;
471 }
472 .issues-state-pill.is-open {
473 background: rgba(52,211,153,0.12);
474 color: #34d399;
475 border: 1px solid rgba(52,211,153,0.35);
476 }
477 .issues-state-pill.is-closed {
478 background: rgba(182,157,255,0.12);
479 color: #b69dff;
480 border: 1px solid rgba(182,157,255,0.35);
481 }
482 .issues-detail-spacer { flex: 1; }
483 .issues-detail-labels {
484 margin-top: 12px;
485 display: flex;
486 gap: 6px;
487 flex-wrap: wrap;
488 }
489
490 /* Comment thread */
491 .issues-thread { margin-top: 18px; }
492 .issues-comment {
493 position: relative;
494 border: 1px solid var(--border);
495 border-radius: 12px;
496 overflow: hidden;
497 background: var(--bg-elevated);
498 margin-bottom: 14px;
499 transition: border-color 160ms ease, box-shadow 160ms ease;
500 }
501 .issues-comment:hover {
502 border-color: var(--border-strong, rgba(255,255,255,0.13));
503 }
504 .issues-comment-header {
505 background: var(--bg-secondary);
506 padding: 10px 16px;
507 border-bottom: 1px solid var(--border);
508 display: flex;
509 align-items: center;
510 gap: 10px;
511 font-size: 13px;
512 color: var(--text-muted);
513 }
514 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
515 .issues-comment-body { padding: 14px 18px; }
516 .issues-comment-author-pill {
517 display: inline-flex;
518 align-items: center;
519 padding: 1px 8px;
520 border-radius: 9999px;
521 background: rgba(140,109,255,0.10);
522 color: var(--accent);
523 font-size: 11px;
524 font-weight: 600;
525 letter-spacing: 0.02em;
526 text-transform: uppercase;
527 }
528
529 /* AI Review comment — distinct purple-accent treatment */
530 .issues-comment.is-ai {
531 border-color: rgba(140,109,255,0.45);
532 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
533 }
534 .issues-comment.is-ai::before {
535 content: '';
536 position: absolute;
537 top: 0; left: 0; right: 0;
538 height: 2px;
539 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
540 pointer-events: none;
541 }
542 .issues-comment.is-ai .issues-comment-header {
543 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
544 border-bottom-color: rgba(140,109,255,0.22);
545 }
546 .issues-ai-badge {
547 display: inline-flex;
548 align-items: center;
549 gap: 5px;
550 padding: 2px 9px;
551 border-radius: 9999px;
552 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
553 color: #fff;
554 font-size: 11px;
555 font-weight: 700;
556 letter-spacing: 0.04em;
557 text-transform: uppercase;
558 line-height: 1.4;
559 }
560 .issues-ai-badge::before {
561 content: '';
562 width: 6px;
563 height: 6px;
564 border-radius: 9999px;
565 background: #fff;
566 opacity: 0.92;
567 box-shadow: 0 0 6px rgba(255,255,255,0.7);
568 }
569
a7460bfClaude570 .issues-bot-badge {
571 display: inline-flex; align-items: center; gap: 3px;
572 padding: 1px 7px;
573 font-size: 10px;
574 font-weight: 600;
575 color: var(--fg-muted);
576 background: var(--bg-elevated);
577 border: 1px solid var(--border);
578 border-radius: 9999px;
579 }
580
f7ad7b8Claude581 /* Composer */
582 .issues-composer {
583 margin-top: 22px;
584 border: 1px solid var(--border);
585 border-radius: 12px;
586 overflow: hidden;
587 background: var(--bg-elevated);
588 transition: border-color 160ms ease, box-shadow 160ms ease;
589 }
590 .issues-composer:focus-within {
591 border-color: rgba(140,109,255,0.55);
592 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
593 }
594 .issues-composer-header {
595 display: flex;
596 align-items: center;
597 justify-content: space-between;
598 gap: 8px;
599 padding: 10px 14px;
600 background: var(--bg-secondary);
601 border-bottom: 1px solid var(--border);
602 font-size: 12.5px;
603 color: var(--text-muted);
604 }
605 .issues-composer-tag {
606 font-weight: 600;
607 color: var(--text);
608 letter-spacing: -0.005em;
609 }
610 .issues-composer-hint a {
611 color: var(--text-muted);
612 text-decoration: none;
613 border-bottom: 1px dashed currentColor;
614 }
615 .issues-composer-hint a:hover { color: var(--accent); }
616 .issues-composer textarea {
617 display: block;
618 width: 100%;
619 border: 0;
620 background: transparent;
621 color: var(--text);
622 padding: 14px 16px;
623 font-family: var(--font-mono);
624 font-size: 13.5px;
625 line-height: 1.55;
626 resize: vertical;
627 outline: none;
628 min-height: 140px;
629 }
630 .issues-composer-actions {
631 display: flex;
632 align-items: center;
633 gap: 8px;
634 padding: 10px 14px;
635 border-top: 1px solid var(--border);
636 background: var(--bg-secondary);
637 flex-wrap: wrap;
638 }
639
640 /* Info banner inside detail */
641 .issues-info-banner {
642 margin: 0 0 14px;
643 padding: 10px 14px;
644 border-radius: 12px;
645 background: rgba(140,109,255,0.08);
646 border: 1px solid rgba(140,109,255,0.28);
647 color: var(--text);
648 font-size: 13.5px;
649 }
240c477Claude650
651 /* ─── Linked PRs ─── */
652 .iss-linked-prs { margin-top: 16px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
653 .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; }
654 .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; }
655 .iss-linked-pr-row:last-child { border-bottom: none; }
656 .iss-linked-pr-row:hover { background: var(--bg-hover); }
657 .iss-linked-pr-icon.is-open { color: #34d399; }
658 .iss-linked-pr-icon.is-merged { color: #a78bfa; }
659 .iss-linked-pr-icon.is-closed { color: #8b949e; }
660 .iss-linked-pr-icon.is-draft { color: #8b949e; }
661 .iss-linked-pr-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
662 .iss-linked-pr-num { color: var(--text-muted); font-size: 12px; }
663 .iss-linked-pr-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
664 .iss-linked-pr-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
665 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
666 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
667 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
f5b9ef5Claude668
8d1483cClaude669 /* ─── Bulk action UI ─── */
670 .bulk-cb { width:16px; height:16px; accent-color:var(--accent); cursor:pointer; flex-shrink:0; }
671 .bulk-bar {
672 display: none;
673 align-items: center;
674 gap: 10px;
675 padding: 10px 16px;
676 background: var(--bg-elevated);
677 border: 1px solid var(--border-strong);
678 border-radius: var(--r-md);
679 margin-bottom: 12px;
680 position: sticky;
681 top: calc(var(--header-h) + 8px);
682 z-index: 10;
683 box-shadow: 0 4px 16px -4px rgba(0,0,0,0.4);
684 }
685 .bulk-bar-count { font-size:13px; font-weight:600; color:var(--text-strong); flex:1; }
686 .bulk-bar-btn {
687 padding: 6px 12px;
688 border-radius: var(--r-sm);
689 border: 1px solid var(--border);
690 background: var(--bg-surface);
691 color: var(--text);
692 font-size: 13px;
693 cursor: pointer;
694 transition: background 120ms;
695 }
696 .bulk-bar-btn:hover { background: var(--bg-hover); }
697 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
698 .bulk-bar-clear:hover { color: var(--text); }
699
f5b9ef5Claude700 /* ─── Sort controls (issue list) ─── */
701 .issues-sort-row {
702 display: flex;
703 align-items: center;
704 gap: 6px;
705 margin: 0 0 12px;
706 flex-wrap: wrap;
707 }
708 .issues-sort-label {
709 font-size: 12.5px;
710 color: var(--text-muted);
711 font-weight: 600;
712 margin-right: 2px;
713 }
714 .issues-sort-opt {
715 font-size: 12.5px;
716 color: var(--text-muted);
717 text-decoration: none;
718 padding: 3px 10px;
719 border-radius: 9999px;
720 border: 1px solid transparent;
721 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
722 }
723 .issues-sort-opt:hover {
724 background: var(--bg-hover);
725 color: var(--text);
726 }
727 .issues-sort-opt.is-active {
728 background: rgba(140,109,255,0.12);
729 color: var(--text-link);
730 border-color: rgba(140,109,255,0.35);
731 font-weight: 600;
732 }
f7ad7b8Claude733`;
734
735// Pre-rendered <style> tag (constant, reused per request).
736const IssuesStyle = () => (
737 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
738);
739
740// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
741const IssuesEmptySvg = () => (
742 <svg
743 class="issues-empty-art"
744 viewBox="0 0 96 96"
745 fill="none"
746 xmlns="http://www.w3.org/2000/svg"
747 aria-hidden="true"
748 >
749 <defs>
750 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
751 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
752 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
753 </linearGradient>
754 </defs>
755 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
756 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
757 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
758 </svg>
759);
760
761// Detect AI Triage comments by the marker the triage helper writes.
762function isAiTriageComment(body: string | null | undefined): boolean {
763 if (!body) return false;
764 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
765}
766
79136bbClaude767// Helper to resolve repo from :owner/:repo params
768async function resolveRepo(ownerName: string, repoName: string) {
769 const [owner] = await db
770 .select()
771 .from(users)
772 .where(eq(users.username, ownerName))
773 .limit(1);
774 if (!owner) return null;
775
776 const [repo] = await db
777 .select()
778 .from(repositories)
779 .where(
780 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
781 )
782 .limit(1);
783 if (!repo) return null;
784
785 return { owner, repo };
786}
787
8d1483cClaude788// Bulk issue operations (close / reopen multiple issues at once)
789issueRoutes.post("/:owner/:repo/issues/bulk", requireAuth, requireRepoAccess("write"), async (c) => {
790 const { owner: ownerName, repo: repoName } = c.req.param();
791 const user = c.get("user")!;
792 const body = await c.req.parseBody();
793 const action = String(body.action || "");
794 const rawNumbers = body["numbers[]"];
795 const numbers = (Array.isArray(rawNumbers) ? rawNumbers : rawNumbers ? [rawNumbers] : [])
796 .map(n => parseInt(String(n), 10))
797 .filter(n => !isNaN(n) && n > 0);
798
799 if (!numbers.length || !["close","reopen"].includes(action)) {
800 return c.redirect(`/${ownerName}/${repoName}/issues?error=${encodeURIComponent("Invalid bulk action")}`);
801 }
802
803 const resolved = await resolveRepo(ownerName, repoName);
804 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
805
806 const newState = action === "close" ? "closed" : "open";
807 await db.update(issues)
808 .set({ state: newState, closedAt: action === "close" ? new Date() : null, updatedAt: new Date() })
809 .where(and(eq(issues.repositoryId, resolved.repo.id), inArray(issues.number, numbers)));
810
811 const stateParam = action === "close" ? "open" : "closed"; // stay on current view
812 return c.redirect(`/${ownerName}/${repoName}/issues?state=${stateParam === "closed" ? "closed" : "open"}&success=${encodeURIComponent(`${numbers.length} issue(s) ${action}d`)}`);
813});
814
79136bbClaude815// Issue list
04f6b7fClaude816issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude817 const { owner: ownerName, repo: repoName } = c.req.param();
818 const user = c.get("user");
819 const state = c.req.query("state") || "open";
7a28902Claude820 const searchQ = (c.req.query("q") || "").trim();
821 const labelFilter = (c.req.query("label") || "").trim();
85c4e13Claude822 const milestoneFilter = (c.req.query("milestone") || "").trim();
f5b9ef5Claude823 const sort = (c.req.query("sort") || "newest").trim();
6ea2109Claude824 // Bounded pagination — unbounded selects ran a full table scan + O(n)
825 // sort on every page load; with 10k+ issues the request would hang.
826 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
827 const page = Math.max(1, Number(c.req.query("page")) || 1);
828 const offset = (page - 1) * perPage;
79136bbClaude829
f1dc7c7Claude830 // ── Loading skeleton (flag-gated) ──
831 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
832 // user see the shape of the issue list before the DB count + select
833 // resolve. Behind a flag — we don't ship flashes.
834 if (c.req.query("skeleton") === "1") {
835 return c.html(
836 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
837 <IssuesStyle />
838 <RepoHeader owner={ownerName} repo={repoName} />
839 <IssueNav owner={ownerName} repo={repoName} active="issues" />
840 <style
841 dangerouslySetInnerHTML={{
842 __html: `
843 .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; }
844 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
845 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
846 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
847 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
848 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
849 .issues-skel-row { height: 62px; border-radius: 10px; }
850 `,
851 }}
852 />
853 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
854 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
855 <div class="issues-skel-list" aria-hidden="true">
856 {Array.from({ length: 8 }).map(() => (
857 <div class="issues-skel issues-skel-row" />
858 ))}
859 </div>
860 <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">
861 Loading issues for {ownerName}/{repoName}…
862 </span>
863 </Layout>
864 );
865 }
866
79136bbClaude867 const resolved = await resolveRepo(ownerName, repoName);
868 if (!resolved) {
869 return c.html(
870 <Layout title="Not Found" user={user}>
bb0f894Claude871 <EmptyState title="Repository not found" />
79136bbClaude872 </Layout>,
873 404
874 );
875 }
876
877 const { repo } = resolved;
878
7a28902Claude879 // If label filter is set, find issue IDs that have that label
880 let labelFilteredIds: string[] | null = null;
881 if (labelFilter) {
882 const [matchedLabel] = await db
883 .select({ id: labels.id })
884 .from(labels)
885 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
886 .limit(1);
887 if (matchedLabel) {
888 const rows = await db
889 .select({ issueId: issueLabels.issueId })
890 .from(issueLabels)
891 .where(eq(issueLabels.labelId, matchedLabel.id));
892 labelFilteredIds = rows.map((r) => r.issueId);
893 } else {
894 labelFilteredIds = []; // no matches
895 }
896 }
897
85c4e13Claude898 // If milestone filter is set, resolve the milestone ID
899 let milestoneFilterId: string | null = null;
900 if (milestoneFilter) {
901 const [matchedMs] = await db
902 .select({ id: milestones.id })
903 .from(milestones)
904 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
905 .limit(1);
906 milestoneFilterId = matchedMs?.id ?? null;
907 }
908
7a28902Claude909 const baseWhere = and(
910 eq(issues.repositoryId, repo.id),
911 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
912 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
913 labelFilteredIds !== null && labelFilteredIds.length > 0
914 ? inArray(issues.id, labelFilteredIds)
915 : labelFilteredIds !== null && labelFilteredIds.length === 0
916 ? sql`false`
85c4e13Claude917 : undefined,
918 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
7a28902Claude919 );
920
79136bbClaude921 const issueList = await db
922 .select({
923 issue: issues,
924 author: { username: users.username },
925 })
926 .from(issues)
927 .innerJoin(users, eq(issues.authorId, users.id))
7a28902Claude928 .where(baseWhere)
f5b9ef5Claude929 .orderBy(
930 sort === "oldest" ? asc(issues.createdAt)
931 : sort === "updated" ? desc(issues.updatedAt)
932 : desc(issues.createdAt) // newest (default)
933 )
6ea2109Claude934 .limit(perPage)
935 .offset(offset);
79136bbClaude936
937 // Count open/closed
938 const [counts] = await db
939 .select({
940 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
941 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
942 })
943 .from(issues)
944 .where(eq(issues.repositoryId, repo.id));
945
85c4e13Claude946 // Count open milestones for the "N Milestones" link
947 const [milestoneCounts] = await db
948 .select({
949 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
950 })
951 .from(milestones)
952 .where(eq(milestones.repositoryId, repo.id));
953
cb5a796Claude954 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
955 const pendingCountList = viewerIsOwnerOnList
956 ? await countPendingForRepo(repo.id)
957 : 0;
958
79136bbClaude959 return c.html(
960 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude961 <IssuesStyle />
79136bbClaude962 <RepoHeader owner={ownerName} repo={repoName} />
963 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude964 <PendingCommentsBanner
965 owner={ownerName}
966 repo={repoName}
967 count={pendingCountList}
968 />
f7ad7b8Claude969 <section class="issues-hero">
970 <div class="issues-hero-bg" aria-hidden="true">
971 <div class="issues-hero-orb" />
972 </div>
973 <div class="issues-hero-inner">
974 <div class="issues-hero-text">
975 <div class="issues-hero-eyebrow">
976 Issue tracker \u00B7{" "}
977 <span class="issues-hero-repo">
978 {ownerName}/{repoName}
979 </span>
980 </div>
981 <h1 class="issues-hero-title">
982 Track <span class="gradient-text">what matters</span>.
983 </h1>
984 <p class="issues-hero-sub">
985 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
986 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
987 : `${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.`}
988 </p>
989 </div>
990 <div class="issues-hero-actions">
991 {user && (
992 <a
993 href={`/${ownerName}/${repoName}/issues/new`}
994 class="btn btn-primary"
995 >
996 + New issue
997 </a>
998 )}
85c4e13Claude999 <a
1000 href={`/${ownerName}/${repoName}/milestones`}
1001 class="btn"
1002 title="View milestones for this repository"
1003 >
1004 Milestones
1005 {Number(milestoneCounts?.open ?? 0) > 0 && (
1006 <span style="margin-left:5px;font-size:11.5px;background:rgba(140,109,255,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
1007 {Number(milestoneCounts?.open ?? 0)}
1008 </span>
1009 )}
1010 </a>
f7ad7b8Claude1011 <a href={`/${ownerName}/${repoName}`} class="btn">
1012 Back to code
1013 </a>
1014 </div>
1015 </div>
1016 </section>
1017
1018 <div class="issues-toolbar">
1019 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
1020 <a
1021 class={`issues-filter${state === "open" ? " is-active" : ""}`}
1022 href={`/${ownerName}/${repoName}/issues?state=open`}
1023 role="tab"
1024 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude1025 >
f7ad7b8Claude1026 <span aria-hidden="true">{"\u25CB"}</span>
1027 <span>Open</span>
1028 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
1029 </a>
1030 <a
1031 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
1032 href={`/${ownerName}/${repoName}/issues?state=closed`}
1033 role="tab"
1034 aria-selected={state === "closed" ? "true" : "false"}
1035 >
1036 <span aria-hidden="true">{"\u2713"}</span>
1037 <span>Closed</span>
1038 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
1039 </a>
1040 </div>
7a28902Claude1041 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
1042 <input type="hidden" name="state" value={state} />
1043 <input
1044 type="search"
1045 name="q"
1046 value={searchQ}
1047 placeholder="Search issues\u2026"
1048 class="issues-search-input"
1049 aria-label="Search issues by title"
1050 />
1051 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
1052 <button type="submit" class="issues-search-btn" aria-label="Search">
1053 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
1054 <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" />
1055 </svg>
1056 </button>
1057 </form>
f7ad7b8Claude1058 </div>
7a28902Claude1059 {(searchQ || labelFilter) && (
1060 <div class="issues-filter-banner">
1061 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
1062 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
1063 {" \u00B7 "}
1064 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
1065 </div>
1066 )}
f7ad7b8Claude1067
f5b9ef5Claude1068 <div class="issues-sort-row">
1069 <span class="issues-sort-label">Sort:</span>
1070 {(["newest", "oldest", "updated"] as const).map((s) => (
1071 <a
1072 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
1073 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
1074 >
1075 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
1076 </a>
1077 ))}
1078 </div>
1079
79136bbClaude1080 {issueList.length === 0 ? (
f7ad7b8Claude1081 <div class="issues-empty">
1082 <IssuesEmptySvg />
1083 <h2 class="issues-empty-title">
1084 {state === "closed"
1085 ? "No closed issues yet"
1086 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1087 ? "No issues \u2014 yet"
1088 : "Nothing open right now"}
1089 </h2>
1090 <p class="issues-empty-sub">
1091 {state === "closed"
1092 ? "Closed issues will show up here once the team starts shipping fixes."
1093 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
1094 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
1095 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
1096 </p>
1097 <div class="issues-empty-cta">
1098 {user && state !== "closed" && (
1099 <a
1100 href={`/${ownerName}/${repoName}/issues/new`}
1101 class="btn btn-primary"
1102 >
1103 + Open the first issue
1104 </a>
1105 )}
79136bbClaude1106 {state === "closed" && (
f7ad7b8Claude1107 <a
1108 href={`/${ownerName}/${repoName}/issues?state=open`}
1109 class="btn"
1110 >
1111 View open issues
1112 </a>
79136bbClaude1113 )}
f7ad7b8Claude1114 </div>
1115 </div>
79136bbClaude1116 ) : (
8d1483cClaude1117 <form id="bulk-form" method="post" action={`/${ownerName}/${repoName}/issues/bulk`}>
1118 <input type="hidden" name="action" id="bulk-action" value="" />
1119 <div class="bulk-bar" id="bulk-bar" aria-hidden="true">
1120 <span class="bulk-bar-count" id="bulk-bar-count">0 selected</span>
1121 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('close')">Close selected</button>
1122 <button type="button" class="bulk-bar-btn" onclick="bulkSubmit('reopen')">Reopen selected</button>
1123 <button type="button" class="bulk-bar-clear" onclick="bulkClear()">Clear</button>
1124 </div>
1125 <ul class="issues-list">
1126 {issueList.map(({ issue, author }) => (
1127 <li class="issues-row">
1128 <input type="checkbox" name="numbers[]" value={String(issue.number)} class="bulk-cb" aria-label={`Select issue #${issue.number}`} />
1129 <div
1130 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
1131 aria-hidden="true"
1132 title={issue.state === "open" ? "Open" : "Closed"}
1133 >
1134 {issue.state === "open" ? "\u25CB" : "\u2713"}
79136bbClaude1135 </div>
8d1483cClaude1136 <div class="issues-row-main">
1137 <h3 class="issues-row-title">
1138 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
1139 {issue.title}
1140 </a>
1141 </h3>
1142 <div class="issues-row-meta">
1143 #{issue.number} opened by{" "}
1144 <strong>{author.username}</strong>{" "}
1145 {formatRelative(issue.createdAt)}
1146 </div>
1147 </div>
1148 </li>
1149 ))}
1150 </ul>
1151 <script dangerouslySetInnerHTML={{ __html: `
1152function bulkSubmit(action){
1153 document.getElementById('bulk-action').value=action;
1154 document.getElementById('bulk-form').submit();
1155}
1156function bulkClear(){
1157 document.querySelectorAll('.bulk-cb').forEach(function(cb){cb.checked=false;});
1158 updateBulkBar();
1159}
1160function updateBulkBar(){
1161 var checked=document.querySelectorAll('.bulk-cb:checked').length;
1162 var bar=document.getElementById('bulk-bar');
1163 var cnt=document.getElementById('bulk-bar-count');
1164 if(bar){bar.style.display=checked>0?'flex':'none';}
1165 if(cnt){cnt.textContent=checked+' selected';}
1166}
1167document.addEventListener('change',function(e){if(e.target&&e.target.classList.contains('bulk-cb'))updateBulkBar();});
1168` }} />
1169 </form>
79136bbClaude1170 )}
1171 </Layout>
1172 );
1173});
1174
1175// New issue form
1176issueRoutes.get(
1177 "/:owner/:repo/issues/new",
1178 softAuth,
1179 requireAuth,
04f6b7fClaude1180 requireRepoAccess("write"),
79136bbClaude1181 async (c) => {
1182 const { owner: ownerName, repo: repoName } = c.req.param();
1183 const user = c.get("user")!;
1184 const error = c.req.query("error");
24cf2caClaude1185 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude1186
85c4e13Claude1187 // Load open milestones for the dropdown
1188 const resolved = await resolveRepo(ownerName, repoName);
1189 const openMilestones = resolved
1190 ? await db
1191 .select({ id: milestones.id, title: milestones.title })
1192 .from(milestones)
1193 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1194 .orderBy(milestones.title)
1195 : [];
1196
79136bbClaude1197 return c.html(
1198 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude1199 <IssuesStyle />
79136bbClaude1200 <RepoHeader owner={ownerName} repo={repoName} />
1201 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude1202 <Container maxWidth={800}>
f7ad7b8Claude1203 <section class="issues-hero" style="margin-top:4px">
1204 <div class="issues-hero-bg" aria-hidden="true">
1205 <div class="issues-hero-orb" />
1206 </div>
1207 <div class="issues-hero-inner">
1208 <div class="issues-hero-text">
1209 <div class="issues-hero-eyebrow">
1210 New issue ·{" "}
1211 <span class="issues-hero-repo">
1212 {ownerName}/{repoName}
1213 </span>
1214 </div>
1215 <h1 class="issues-hero-title">
1216 File <span class="gradient-text">it cleanly</span>.
1217 </h1>
1218 <p class="issues-hero-sub">
1219 AI Triage will read the body the moment you submit and post
1220 suggested labels, priority, and possible duplicates within
1221 seconds. You stay in control — nothing is applied.
1222 </p>
1223 </div>
1224 </div>
1225 </section>
79136bbClaude1226 {error && (
bb0f894Claude1227 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude1228 )}
0316dbbClaude1229 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
1230 <FormGroup>
1231 <Input
79136bbClaude1232 type="text"
1233 name="title"
1234 required
1235 placeholder="Title"
bb0f894Claude1236 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]1237 aria-label="Issue title"
79136bbClaude1238 />
bb0f894Claude1239 </FormGroup>
1240 <FormGroup>
1241 <TextArea
79136bbClaude1242 name="body"
1243 rows={12}
1244 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude1245 mono
79136bbClaude1246 />
bb0f894Claude1247 </FormGroup>
85c4e13Claude1248 {openMilestones.length > 0 && (
1249 <FormGroup>
1250 <label
1251 for="milestone_id"
1252 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1253 >
1254 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1255 </label>
1256 <select
1257 id="milestone_id"
1258 name="milestone_id"
1259 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"
1260 >
1261 <option value="">No milestone</option>
1262 {openMilestones.map((ms) => (
1263 <option value={ms.id}>{ms.title}</option>
1264 ))}
1265 </select>
1266 </FormGroup>
1267 )}
bb0f894Claude1268 <Button type="submit" variant="primary">
79136bbClaude1269 Submit new issue
bb0f894Claude1270 </Button>
1271 </Form>
1272 </Container>
79136bbClaude1273 </Layout>
1274 );
1275 }
1276);
1277
1278// Create issue
1279issueRoutes.post(
1280 "/:owner/:repo/issues/new",
1281 softAuth,
1282 requireAuth,
04f6b7fClaude1283 requireRepoAccess("write"),
79136bbClaude1284 async (c) => {
1285 const { owner: ownerName, repo: repoName } = c.req.param();
1286 const user = c.get("user")!;
1287 const body = await c.req.parseBody();
1288 const title = String(body.title || "").trim();
1289 const issueBody = String(body.body || "").trim();
85c4e13Claude1290 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
79136bbClaude1291
1292 if (!title) {
1293 return c.redirect(
1294 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
1295 );
1296 }
1297
1298 const resolved = await resolveRepo(ownerName, repoName);
1299 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1300
85c4e13Claude1301 // Validate milestone belongs to this repo if provided
1302 let validatedMilestoneId: string | null = null;
1303 if (milestoneIdRaw) {
1304 const [msRow] = await db
1305 .select({ id: milestones.id })
1306 .from(milestones)
1307 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1308 .limit(1);
1309 validatedMilestoneId = msRow?.id ?? null;
1310 }
1311
79136bbClaude1312 const [issue] = await db
1313 .insert(issues)
1314 .values({
1315 repositoryId: resolved.repo.id,
1316 authorId: user.id,
1317 title,
1318 body: issueBody || null,
85c4e13Claude1319 milestoneId: validatedMilestoneId,
79136bbClaude1320 })
1321 .returning();
1322
1323 // Update issue count
1324 await db
1325 .update(repositories)
1326 .set({ issueCount: resolved.repo.issueCount + 1 })
1327 .where(eq(repositories.id, resolved.repo.id));
1328
a9ada5fClaude1329 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
1330 // suggested labels, priority, summary, and a possible-duplicate
1331 // callout. Suggestions only — nothing applied automatically.
1332 triggerIssueTriage({
1333 ownerName,
1334 repoName,
1335 repositoryId: resolved.repo.id,
1336 issueId: issue.id,
1337 issueNumber: issue.number,
1338 authorId: user.id,
1339 title,
1340 body: issueBody,
a28cedeClaude1341 }).catch((err) => {
1342 console.warn(
1343 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
1344 err instanceof Error ? err.message : err
1345 );
1346 });
a9ada5fClaude1347
79136bbClaude1348 return c.redirect(
1349 `/${ownerName}/${repoName}/issues/${issue.number}`
1350 );
1351 }
1352);
1353
1354// View single issue
04f6b7fClaude1355issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude1356 const { owner: ownerName, repo: repoName } = c.req.param();
1357 const issueNum = parseInt(c.req.param("number"), 10);
1358 const user = c.get("user");
1359
1360 const resolved = await resolveRepo(ownerName, repoName);
1361 if (!resolved) {
1362 return c.html(
1363 <Layout title="Not Found" user={user}>
bb0f894Claude1364 <EmptyState title="Not found" />
79136bbClaude1365 </Layout>,
1366 404
1367 );
1368 }
1369
1370 const [issue] = await db
1371 .select()
1372 .from(issues)
1373 .where(
1374 and(
1375 eq(issues.repositoryId, resolved.repo.id),
1376 eq(issues.number, issueNum)
1377 )
1378 )
1379 .limit(1);
1380
1381 if (!issue) {
1382 return c.html(
1383 <Layout title="Not Found" user={user}>
bb0f894Claude1384 <EmptyState title="Issue not found" />
79136bbClaude1385 </Layout>,
1386 404
1387 );
1388 }
1389
1390 const [author] = await db
1391 .select()
1392 .from(users)
1393 .where(eq(users.id, issue.authorId))
1394 .limit(1);
1395
cb5a796Claude1396 // Get comments. We pull every row and filter by moderation_status +
1397 // viewer identity client-side (in the JSX below) so a moderator/owner
1398 // sees them all while non-author viewers only ever see 'approved'.
1399 const allComments = await db
79136bbClaude1400 .select({
1401 comment: issueComments,
cb5a796Claude1402 author: { id: users.id, username: users.username },
79136bbClaude1403 })
1404 .from(issueComments)
1405 .innerJoin(users, eq(issueComments.authorId, users.id))
1406 .where(eq(issueComments.issueId, issue.id))
1407 .orderBy(asc(issueComments.createdAt));
1408
cb5a796Claude1409 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1410 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1411 // Owner always sees everything (so they can spot conversations going
1412 // sideways even before they hit the queue page).
1413 if (viewerIsOwner) return true;
1414 if (comment.moderationStatus === "approved") return true;
1415 // The comment's own author sees their pending comment so they know
1416 // it landed (with an "Awaiting approval" badge in the render below).
1417 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1418 return true;
1419 }
1420 return false;
1421 });
1422
1423 // Pending banner — when the viewer is the repo owner, show a strip at
1424 // the top of the page nudging them to the moderation queue. Inline on
1425 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1426 const pendingCount = viewerIsOwner
1427 ? await countPendingForRepo(resolved.repo.id)
1428 : 0;
1429
6fc53bdClaude1430 // Load reactions for the issue + each comment in parallel.
1431 const [issueReactions, ...commentReactions] = await Promise.all([
1432 summariseReactions("issue", issue.id, user?.id),
1433 ...comments.map((row) =>
1434 summariseReactions("issue_comment", row.comment.id, user?.id)
1435 ),
1436 ]);
1437
79136bbClaude1438 const canManage =
1439 user &&
1440 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1441 const info = c.req.query("info");
79136bbClaude1442
240c477Claude1443 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
1444 const issueRefPattern = `%#${issue.number}%`;
1445 const linkedPrs = await db
1446 .select({
1447 number: pullRequests.number,
1448 title: pullRequests.title,
1449 state: pullRequests.state,
1450 isDraft: pullRequests.isDraft,
1451 })
1452 .from(pullRequests)
1453 .where(
1454 and(
1455 eq(pullRequests.repositoryId, resolved.repo.id),
1456 or(
1457 ilike(pullRequests.title, issueRefPattern),
1458 ilike(pullRequests.body, issueRefPattern),
1459 )
1460 )
1461 )
1462 .orderBy(desc(pullRequests.createdAt))
1463 .limit(8);
1464
79136bbClaude1465 return c.html(
1466 <Layout
1467 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1468 user={user}
1469 >
f7ad7b8Claude1470 <IssuesStyle />
79136bbClaude1471 <RepoHeader owner={ownerName} repo={repoName} />
1472 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1473 <PendingCommentsBanner
1474 owner={ownerName}
1475 repo={repoName}
1476 count={pendingCount}
1477 />
b584e52Claude1478 <div
1479 id="live-comment-banner"
1480 class="alert"
1481 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1482 >
1483 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1484 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1485 reload to view
1486 </a>
1487 </div>
1488 <script
1489 dangerouslySetInnerHTML={{
1490 __html: liveCommentBannerScript({
1491 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1492 bannerElementId: "live-comment-banner",
1493 }),
1494 }}
1495 />
829a046Claude1496 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude1497 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude1498 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
79136bbClaude1499 <div class="issue-detail">
58915a9Claude1500 {info && (
f7ad7b8Claude1501 <div class="issues-info-banner">
58915a9Claude1502 {decodeURIComponent(info)}
1503 </div>
1504 )}
f7ad7b8Claude1505
1506 <section class="issues-detail-hero">
1507 <h1 class="issues-detail-title">
1508 {issue.title}
1509 <span class="issues-detail-number">#{issue.number}</span>
1510 </h1>
1511 <div class="issues-detail-attr">
1512 <span
1513 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1514 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1515 >
f7ad7b8Claude1516 <span aria-hidden="true">
1517 {issue.state === "open" ? "\u25CB" : "\u2713"}
1518 </span>
1519 {issue.state === "open" ? "Open" : "Closed"}
1520 </span>
1521 <span>
1522 <strong>{author?.username || "unknown"}</strong> opened this
1523 issue {formatRelative(issue.createdAt)}
1524 </span>
1525 <span class="issues-detail-spacer" />
1526 {issue.state === "open" && user && user.id === resolved.owner.id && (
1527 <a
1528 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1529 class="btn btn-primary"
1530 style="font-size:13px;padding:6px 12px"
1531 title="Generate a draft pull request from this issue using Claude"
1532 >
1533 Build with AI
1534 </a>
1535 )}
f928118Claude1536 {issue.state === "open" && user && isAiAvailable() && (
1537 <form
1538 method="post"
1539 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1540 style="display:inline"
1541 title="Let AI implement this feature automatically"
1542 >
1543 <button
1544 type="submit"
1545 class="btn btn-primary"
1546 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#8c6dff,#36c5d6);border:none;cursor:pointer"
1547 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1548 >
1549 Ship It
1550 </button>
1551 </form>
1552 )}
c922868Claude1553 {issue.state === "open" && user && (
1554 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
1555 <summary
1556 class="btn"
1557 style="font-size:13px;padding:6px 12px;cursor:pointer;list-style:none"
1558 title="Create a new branch for this issue"
1559 >
1560 Create branch
1561 </summary>
1562 <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)">
1563 <form method="post" action={`/${ownerName}/${repoName}/issues/${issue.number}/branch`}>
1564 <label style="display:block;font-size:12px;color:var(--fg-muted);margin-bottom:4px">Branch name</label>
1565 <input
1566 type="text"
1567 name="branchName"
1568 class="input"
1569 style="width:100%;font-size:13px;padding:5px 8px;margin-bottom:8px"
1570 value={`issue-${issue.number}-${issue.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)}`}
1571 pattern="[a-zA-Z0-9._\\-/]+"
1572 required
1573 />
1574 <button type="submit" class="btn btn-primary" style="width:100%;font-size:13px">
1575 Create branch
1576 </button>
1577 </form>
1578 </div>
1579 </details>
1580 )}
f7ad7b8Claude1581 </div>
1582 </section>
1583
1584 <div class="issues-thread">
1585 {issue.body && (
1586 <article class="issues-comment">
1587 <header class="issues-comment-header">
1588 <strong>{author?.username || "unknown"}</strong>
1589 <span class="issues-comment-author-pill">Author</span>
1590 <span>commented {formatRelative(issue.createdAt)}</span>
1591 </header>
1592 <div class="issues-comment-body">
1593 <div
1594 class="markdown-body"
1595 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1596 />
1597 </div>
1598 </article>
fbf4aefClaude1599 )}
79136bbClaude1600
f7ad7b8Claude1601 {comments.map(({ comment, author: commentAuthor }) => {
1602 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1603 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1604 return (
cb5a796Claude1605 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1606 <header class="issues-comment-header">
1607 <strong>{commentAuthor.username}</strong>
a7460bfClaude1608 {commentAuthor.username === BOT_USERNAME && (
1609 <span class="issues-bot-badge">&#x1F916; bot</span>
1610 )}
f7ad7b8Claude1611 {isAi ? (
1612 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1613 AI Review
1614 </span>
1615 ) : null}
cb5a796Claude1616 {isPending ? (
1617 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1618 Awaiting approval
1619 </span>
1620 ) : null}
f7ad7b8Claude1621 <span>commented {formatRelative(comment.createdAt)}</span>
1622 </header>
1623 <div class="issues-comment-body">
1624 <div
1625 class="markdown-body"
1626 dangerouslySetInnerHTML={{
1627 __html: renderMarkdown(comment.body),
1628 }}
1629 />
1630 </div>
1631 </article>
1632 );
1633 })}
1634 </div>
79136bbClaude1635
240c477Claude1636 {linkedPrs.length > 0 && (
1637 <div class="iss-linked-prs">
1638 <div class="iss-linked-prs-head">
1639 <span>Linked pull requests</span>
1640 <span>{linkedPrs.length}</span>
1641 </div>
1642 {linkedPrs.map((lpr) => {
1643 const prState = lpr.isDraft ? "draft" : lpr.state;
1644 const prIcon = prState === "merged" ? "⮬" : prState === "closed" ? "✕" : prState === "draft" ? "◌" : "○";
1645 return (
1646 <a
1647 href={`/${ownerName}/${repoName}/pulls/${lpr.number}`}
1648 class="iss-linked-pr-row"
1649 >
1650 <span class={`iss-linked-pr-icon is-${prState}`} aria-hidden="true">{prIcon}</span>
1651 <span class="iss-linked-pr-title">{lpr.title}</span>
1652 <span class="iss-linked-pr-num">#{lpr.number}</span>
1653 <span class={`iss-linked-pr-state is-${prState}`}>{prState}</span>
1654 </a>
1655 );
1656 })}
1657 </div>
1658 )}
1659
79136bbClaude1660 {user && (
f7ad7b8Claude1661 <form
1662 class="issues-composer"
1663 method="post"
1664 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1665 >
1666 <div class="issues-composer-header">
1667 <span class="issues-composer-tag">Add a comment</span>
1668 <span class="issues-composer-hint">
1669 <a
1670 href="https://docs.github.com/en/get-started/writing-on-github"
1671 target="_blank"
1672 rel="noopener noreferrer"
1673 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1674 >
1675 Markdown supported
1676 </a>
1677 </span>
1678 </div>
1679 <textarea
1680 name="body"
1681 rows={6}
1682 required
6cd2f0eClaude1683 data-md-preview=""
f7ad7b8Claude1684 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1685 />
1686 <div class="issues-composer-actions">
1687 <button type="submit" class="btn btn-primary">
1688 Comment
1689 </button>
1690 {canManage && (
1691 <button
1692 type="submit"
1693 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1694 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1695 >
1696 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1697 </button>
f7ad7b8Claude1698 )}
1699 {canManage && issue.state === "open" && (
1700 <button
1701 type="submit"
1702 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1703 formnovalidate
1704 class="btn"
1705 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1706 >
1707 Re-run AI triage
1708 </button>
1709 )}
1710 </div>
1711 </form>
79136bbClaude1712 )}
1713 </div>
641aa42Claude1714 {/* Issue keyboard hints bar */}
1715 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1716 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>x</kbd> close/reopen &middot; <kbd>?</kbd> shortcuts
1717 </div>
1718 <style dangerouslySetInnerHTML={{ __html: `
1719 .kbd-hints {
1720 position: fixed;
1721 bottom: 0;
1722 left: 0;
1723 right: 0;
1724 z-index: 90;
1725 padding: 6px 24px;
1726 background: var(--bg-secondary);
1727 border-top: 1px solid var(--border);
1728 font-size: 12px;
1729 color: var(--text-muted);
1730 display: flex;
1731 align-items: center;
1732 gap: 8px;
1733 flex-wrap: wrap;
1734 }
1735 .kbd-hints kbd {
1736 font-family: var(--font-mono);
1737 font-size: 10px;
1738 background: var(--bg-elevated);
1739 border: 1px solid var(--border);
1740 border-bottom-width: 2px;
1741 border-radius: 4px;
1742 padding: 1px 5px;
1743 color: var(--text);
1744 line-height: 1.5;
1745 }
1746 main { padding-bottom: 40px; }
1747 ` }} />
1748 {/* Repo context commands for command palette */}
1749 <script
1750 id="cmdk-repo-context"
1751 dangerouslySetInnerHTML={{
1752 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1753 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1754 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1755 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
1756 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
1757 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
1758 ])};`,
1759 }}
1760 />
1761 {/* Issue keyboard shortcuts */}
1762 <script dangerouslySetInnerHTML={{ __html: `
1763 (function(){
1764 function isTyping(t){
1765 t = t || {};
1766 var tag = (t.tagName || '').toLowerCase();
1767 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
1768 }
1769 document.addEventListener('keydown', function(e){
1770 if (isTyping(e.target)) return;
1771 if (e.metaKey || e.ctrlKey || e.altKey) return;
1772 if (e.key === 'c') {
1773 e.preventDefault();
1774 var box = document.querySelector('textarea[name="body"]');
1775 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
1776 }
1777 if (e.key === 'e') {
1778 e.preventDefault();
1779 // Focus the issue title and make it editable if possible
1780 var titleEl = document.querySelector('.issues-title');
1781 if (titleEl) titleEl.scrollIntoView({block:'center'});
1782 }
1783 if (e.key === 'x') {
1784 e.preventDefault();
1785 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
1786 if (closeBtn) {
1787 var confirmed = window.confirm('Close/reopen this issue?');
1788 if (confirmed) closeBtn.click();
1789 }
1790 }
1791 if (e.key === 'Escape') {
1792 var focused = document.activeElement;
1793 if (focused) focused.blur();
1794 }
1795 });
1796 })();
1797 ` }} />
79136bbClaude1798 </Layout>
1799 );
1800});
1801
cb5a796Claude1802// Add comment.
1803//
1804// Permission model: we accept comments from ANY authenticated user (read
1805// access required — public repos satisfy that, private repos still need a
1806// collaborator row). The moderation gate in `decideInitialStatus`
1807// determines whether the comment is published immediately or queued for
1808// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1809// "anti-impersonation" rationale.
79136bbClaude1810issueRoutes.post(
1811 "/:owner/:repo/issues/:number/comment",
1812 softAuth,
1813 requireAuth,
cb5a796Claude1814 requireRepoAccess("read"),
79136bbClaude1815 async (c) => {
1816 const { owner: ownerName, repo: repoName } = c.req.param();
1817 const issueNum = parseInt(c.req.param("number"), 10);
1818 const user = c.get("user")!;
1819 const body = await c.req.parseBody();
1820 const commentBody = String(body.body || "").trim();
1821
1822 if (!commentBody) {
1823 return c.redirect(
1824 `/${ownerName}/${repoName}/issues/${issueNum}`
1825 );
1826 }
1827
1828 const resolved = await resolveRepo(ownerName, repoName);
1829 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1830
1831 const [issue] = await db
1832 .select()
1833 .from(issues)
1834 .where(
1835 and(
1836 eq(issues.repositoryId, resolved.repo.id),
1837 eq(issues.number, issueNum)
1838 )
1839 )
1840 .limit(1);
1841
1842 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1843
cb5a796Claude1844 // Decide moderation status BEFORE insert so the row lands in the right
1845 // initial state. Collaborators, the issue author, and trusted users
1846 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1847 // else gets 'pending'.
1848 const decision = await decideInitialStatus({
1849 commenterUserId: user.id,
1850 repositoryId: resolved.repo.id,
1851 kind: "issue",
1852 threadId: issue.id,
1853 });
1854
d4ac5c3Claude1855 const [inserted] = await db
1856 .insert(issueComments)
1857 .values({
1858 issueId: issue.id,
1859 authorId: user.id,
1860 body: commentBody,
cb5a796Claude1861 moderationStatus: decision.status,
d4ac5c3Claude1862 })
1863 .returning();
1864
cb5a796Claude1865 // Live update only when visible. Don't leak pending/rejected via SSE.
1866 if (inserted && decision.status === "approved") {
d4ac5c3Claude1867 try {
1868 const { publish } = await import("../lib/sse");
1869 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1870 event: "issue-comment",
1871 data: {
1872 issueId: issue.id,
1873 commentId: inserted.id,
1874 authorId: user.id,
1875 authorUsername: user.username,
1876 },
1877 });
1878 } catch {
1879 /* SSE is best-effort */
1880 }
b7ecb14Claude1881 // Notify the issue author — fire-and-forget, never blocks the response.
1882 if (issue.authorId && issue.authorId !== user.id) {
1883 void import("../lib/notify").then(({ createNotification }) =>
1884 createNotification({
1885 userId: issue.authorId,
1886 type: "issue_comment",
1887 title: `New comment on "${issue.title}"`,
1888 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
1889 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
1890 repoId: resolved.repo.id,
1891 })
1892 ).catch(() => { /* never block the response */ });
1893 }
d4ac5c3Claude1894 }
79136bbClaude1895
cb5a796Claude1896 if (decision.status === "pending") {
1897 // Notify repo owner that a comment is awaiting review.
1898 void notifyOwnerOfPendingComment({
1899 repositoryId: resolved.repo.id,
1900 commenterUsername: user.username,
1901 kind: "issue",
1902 threadNumber: issueNum,
1903 ownerUsername: ownerName,
1904 repoName,
1905 });
1906 return c.redirect(
1907 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1908 );
1909 }
1910 if (decision.status === "rejected") {
1911 // Silent ban — the commenter is on the banned list. Don't reveal
1912 // the gate; just send them back to the page as if it posted.
1913 return c.redirect(
1914 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1915 );
1916 }
1917
79136bbClaude1918 return c.redirect(
1919 `/${ownerName}/${repoName}/issues/${issueNum}`
1920 );
1921 }
1922);
1923
1924// Close issue
1925issueRoutes.post(
1926 "/:owner/:repo/issues/:number/close",
1927 softAuth,
1928 requireAuth,
04f6b7fClaude1929 requireRepoAccess("write"),
79136bbClaude1930 async (c) => {
1931 const { owner: ownerName, repo: repoName } = c.req.param();
1932 const issueNum = parseInt(c.req.param("number"), 10);
1933
1934 const resolved = await resolveRepo(ownerName, repoName);
1935 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1936
1937 await db
1938 .update(issues)
1939 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1940 .where(
1941 and(
1942 eq(issues.repositoryId, resolved.repo.id),
1943 eq(issues.number, issueNum)
1944 )
1945 );
1946
1947 return c.redirect(
1948 `/${ownerName}/${repoName}/issues/${issueNum}`
1949 );
1950 }
1951);
1952
1953// Reopen issue
1954issueRoutes.post(
1955 "/:owner/:repo/issues/:number/reopen",
1956 softAuth,
1957 requireAuth,
04f6b7fClaude1958 requireRepoAccess("write"),
79136bbClaude1959 async (c) => {
1960 const { owner: ownerName, repo: repoName } = c.req.param();
1961 const issueNum = parseInt(c.req.param("number"), 10);
1962
1963 const resolved = await resolveRepo(ownerName, repoName);
1964 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1965
1966 await db
1967 .update(issues)
1968 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1969 .where(
1970 and(
1971 eq(issues.repositoryId, resolved.repo.id),
1972 eq(issues.number, issueNum)
1973 )
1974 );
1975
1976 return c.redirect(
1977 `/${ownerName}/${repoName}/issues/${issueNum}`
1978 );
1979 }
1980);
1981
1982// Shared nav component with issues tab
1983const IssueNav = ({
1984 owner,
1985 repo,
1986 active,
1987}: {
1988 owner: string;
1989 repo: string;
1990 active: "code" | "commits" | "issues";
1991}) => (
bb0f894Claude1992 <TabNav
1993 tabs={[
1994 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1995 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1996 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1997 ]}
1998 />
79136bbClaude1999);
2000
58915a9Claude2001// Re-run AI triage on demand (e.g. after the issue body has been edited).
2002// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
2003issueRoutes.post(
2004 "/:owner/:repo/issues/:number/ai-retriage",
2005 softAuth,
2006 requireAuth,
2007 requireRepoAccess("write"),
2008 async (c) => {
2009 const { owner: ownerName, repo: repoName } = c.req.param();
2010 const issueNum = parseInt(c.req.param("number"), 10);
2011 const user = c.get("user")!;
2012 const resolved = await resolveRepo(ownerName, repoName);
2013 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2014
2015 const [issue] = await db
2016 .select()
2017 .from(issues)
2018 .where(
2019 and(
2020 eq(issues.repositoryId, resolved.repo.id),
2021 eq(issues.number, issueNum)
2022 )
2023 )
2024 .limit(1);
2025 if (!issue) {
2026 return c.redirect(`/${ownerName}/${repoName}/issues`);
2027 }
2028
2029 triggerIssueTriage(
2030 {
2031 ownerName,
2032 repoName,
2033 repositoryId: resolved.repo.id,
2034 issueId: issue.id,
2035 issueNumber: issue.number,
2036 authorId: user.id,
2037 title: issue.title,
2038 body: issue.body || "",
2039 },
2040 { force: true }
a28cedeClaude2041 ).catch((err) => {
2042 console.warn(
2043 `[issue-triage] re-triage failed for issue ${issue.id}:`,
2044 err instanceof Error ? err.message : err
2045 );
2046 });
58915a9Claude2047
2048 return c.redirect(
2049 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2050 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
2051 )}`
2052 );
2053 }
2054);
2055
c922868Claude2056// ─── Create branch from issue ─────────────────────────────────────────────────
2057// POST /:owner/:repo/issues/:number/branch
2058// Creates a new git branch from the repo default branch, pre-named after the
2059// issue. Write access required. Zero-config — no new DB tables.
2060
2061issueRoutes.post(
2062 "/:owner/:repo/issues/:number/branch",
2063 softAuth,
2064 requireAuth,
2065 requireRepoAccess("write"),
2066 async (c) => {
2067 const { owner: ownerName, repo: repoName } = c.req.param();
2068 const issueNum = parseInt(c.req.param("number"), 10);
2069
2070 const resolved = await resolveRepo(ownerName, repoName);
2071 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2072
2073 const [issue] = await db
2074 .select({ id: issues.id, number: issues.number, title: issues.title })
2075 .from(issues)
2076 .where(
2077 and(
2078 eq(issues.repositoryId, resolved.repo.id),
2079 eq(issues.number, issueNum)
2080 )
2081 )
2082 .limit(1);
2083
2084 if (!issue) {
2085 return c.redirect(`/${ownerName}/${repoName}/issues`);
2086 }
2087
2088 const body = await c.req.formData().catch(() => null);
2089 const rawName = (body?.get("branchName") as string | null)?.trim();
2090
2091 // Derive a slug from the issue title
2092 const titleSlug = issue.title
2093 .toLowerCase()
2094 .replace(/[^a-z0-9]+/g, "-")
2095 .replace(/^-+|-+$/g, "")
2096 .slice(0, 40);
2097 const suggested = `issue-${issue.number}-${titleSlug}`;
2098 const branchName = rawName && /^[a-zA-Z0-9._\-/]+$/.test(rawName)
2099 ? rawName
2100 : suggested;
2101
2102 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
2103 const sha = await resolveRef(ownerName, repoName, defaultBranch);
2104
2105 if (!sha) {
2106 return c.redirect(
2107 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2108 "Cannot create branch: repository has no commits yet."
2109 )}`
2110 );
2111 }
2112
2113 const ok = await updateRef(ownerName, repoName, `refs/heads/${branchName}`, sha);
2114 if (!ok) {
2115 return c.redirect(
2116 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2117 `Failed to create branch '${branchName}'. It may already exist.`
2118 )}`
2119 );
2120 }
2121
2122 return c.redirect(
2123 `/${ownerName}/${repoName}/tree/${branchName}?info=${encodeURIComponent(
2124 `Branch '${branchName}' created from ${defaultBranch}.`
2125 )}`
2126 );
2127 }
2128);
2129
79136bbClaude2130export default issueRoutes;
2131export { IssueNav };