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

issues.tsx

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

issues.tsxBlame1497 lines · 2 contributors
79136bbClaude1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude18import { PendingCommentsBanner } from "../views/pending-comments-banner";
6fc53bdClaude19import { ReactionsBar } from "../views/reactions";
20import { summariseReactions } from "../lib/reactions";
24cf2caClaude21import { loadIssueTemplate } from "../lib/templates";
79136bbClaude22import { renderMarkdown } from "../lib/markdown";
b584e52Claude23import { liveCommentBannerScript } from "../lib/sse-client";
f7ad7b8Claude24import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
79136bbClaude25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude27import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude28import {
29 decideInitialStatus,
30 notifyOwnerOfPendingComment,
31 countPendingForRepo,
32} from "../lib/comment-moderation";
bb0f894Claude33import {
34 Flex,
35 Container,
36 PageHeader,
37 Form,
38 FormGroup,
39 Input,
40 TextArea,
41 Button,
42 LinkButton,
43 Badge,
44 EmptyState,
45 TabNav,
46 FilterTabs,
47 List,
48 ListItem,
49 Alert,
50 CommentBox,
51 CommentForm,
52 formatRelative,
53} from "../views/ui";
79136bbClaude54
55const issueRoutes = new Hono<AuthEnv>();
56
f7ad7b8Claude57// ---------------------------------------------------------------------------
58// Visual polish: inline CSS scoped to `.issues-*` so it never collides with
59// other routes/shared views. All design tokens come from :root in layout.tsx.
60// ---------------------------------------------------------------------------
61const issuesStyles = `
62 /* Hero card — list page */
63 .issues-hero {
64 position: relative;
65 margin: 4px 0 24px;
66 padding: 28px 32px;
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 }
72 .issues-hero::before {
73 content: '';
74 position: absolute;
75 top: 0; left: 0; right: 0;
76 height: 2px;
77 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
78 opacity: 0.7;
79 pointer-events: none;
80 }
81 .issues-hero-bg {
82 position: absolute;
83 inset: -30% -10% auto auto;
84 width: 360px;
85 height: 360px;
86 pointer-events: none;
87 z-index: 0;
88 }
89 .issues-hero-orb {
90 position: absolute;
91 inset: 0;
92 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
93 filter: blur(80px);
94 opacity: 0.7;
95 animation: issuesHeroOrb 14s ease-in-out infinite;
96 }
97 @keyframes issuesHeroOrb {
98 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
99 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
100 }
101 @media (prefers-reduced-motion: reduce) {
102 .issues-hero-orb { animation: none; }
103 }
104 .issues-hero-inner {
105 position: relative;
106 z-index: 1;
107 display: flex;
108 justify-content: space-between;
109 align-items: flex-end;
110 gap: 24px;
111 flex-wrap: wrap;
112 }
113 .issues-hero-text { flex: 1; min-width: 280px; }
114 .issues-hero-eyebrow {
115 font-size: 12.5px;
116 color: var(--text-muted);
117 margin-bottom: 8px;
118 letter-spacing: 0.04em;
119 text-transform: uppercase;
120 font-weight: 600;
121 }
122 .issues-hero-eyebrow .issues-hero-repo {
123 color: var(--accent);
124 text-transform: none;
125 letter-spacing: -0.005em;
126 font-weight: 600;
127 }
128 .issues-hero-title {
129 font-family: var(--font-display);
130 font-size: clamp(28px, 4vw, 40px);
131 font-weight: 800;
132 letter-spacing: -0.028em;
133 line-height: 1.05;
134 margin: 0 0 10px;
135 color: var(--text-strong);
136 }
137 .issues-hero-sub {
138 font-size: 15px;
139 color: var(--text-muted);
140 margin: 0;
141 line-height: 1.5;
142 max-width: 580px;
143 }
144 .issues-hero-actions {
145 display: flex;
146 gap: 8px;
147 flex-wrap: wrap;
148 }
149 @media (max-width: 720px) {
150 .issues-hero { padding: 24px 20px; }
151 .issues-hero-inner { flex-direction: column; align-items: flex-start; }
152 .issues-hero-actions { width: 100%; }
153 .issues-hero-actions .btn { flex: 1; min-width: 0; }
154 }
155
f1dc7c7Claude156 /* Mobile rules — added in the 720px sweep. Kept additive only. */
157 @media (max-width: 720px) {
158 .issues-toolbar { flex-direction: column; align-items: stretch; }
159 .issues-filters { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
160 .issues-filter { min-height: 40px; padding: 10px 14px; }
161 .issues-row { padding: 12px 14px; gap: 10px; }
162 .issues-row-side { flex-wrap: wrap; gap: 8px; }
163 .issues-detail-hero { padding: 18px; }
164 .issues-detail-attr { font-size: 13px; }
165 .issues-composer-actions { gap: 8px; }
166 .issues-composer-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
167 .issues-empty { padding: 40px 20px; }
168 }
169
f7ad7b8Claude170 /* Count chip + filter pills */
171 .issues-toolbar {
172 display: flex;
173 align-items: center;
174 justify-content: space-between;
175 gap: 12px;
176 flex-wrap: wrap;
177 margin: 0 0 16px;
178 }
179 .issues-filters {
180 display: inline-flex;
181 background: var(--bg-elevated);
182 border: 1px solid var(--border);
183 border-radius: 9999px;
184 padding: 4px;
185 gap: 2px;
186 }
187 .issues-filter {
188 display: inline-flex;
189 align-items: center;
190 gap: 6px;
191 padding: 6px 14px;
192 border-radius: 9999px;
193 font-size: 13px;
194 font-weight: 500;
195 color: var(--text-muted);
196 text-decoration: none;
197 transition: color 120ms ease, background 120ms ease;
198 line-height: 1.4;
199 }
200 .issues-filter:hover { color: var(--text-strong); text-decoration: none; }
201 .issues-filter.is-active {
202 background: rgba(140,109,255,0.14);
203 color: var(--text-strong);
204 }
205 .issues-filter-count {
206 font-variant-numeric: tabular-nums;
207 font-size: 11.5px;
208 color: var(--text-muted);
209 background: rgba(255,255,255,0.04);
210 padding: 1px 7px;
211 border-radius: 9999px;
212 }
213 .issues-filter.is-active .issues-filter-count {
214 background: rgba(140,109,255,0.18);
215 color: var(--text);
216 }
217
218 /* Issue list — modernised rows */
219 .issues-list {
220 list-style: none;
221 margin: 0;
222 padding: 0;
223 border: 1px solid var(--border);
224 border-radius: 12px;
225 overflow: hidden;
226 background: var(--bg-elevated);
227 }
228 .issues-row {
229 display: flex;
230 align-items: flex-start;
231 gap: 14px;
232 padding: 14px 18px;
233 border-bottom: 1px solid var(--border);
234 transition: background 120ms ease, transform 120ms ease;
235 }
236 .issues-row:last-child { border-bottom: none; }
237 .issues-row:hover { background: rgba(140,109,255,0.04); }
238 .issues-row-icon {
239 width: 18px;
240 height: 18px;
241 flex-shrink: 0;
242 margin-top: 3px;
243 display: inline-flex;
244 align-items: center;
245 justify-content: center;
246 }
247 .issues-row-icon.is-open { color: #34d399; }
248 .issues-row-icon.is-closed { color: #b69dff; }
249 .issues-row-main { flex: 1; min-width: 0; }
250 .issues-row-title {
251 font-family: var(--font-display);
252 font-size: 15.5px;
253 font-weight: 600;
254 line-height: 1.35;
255 letter-spacing: -0.012em;
256 margin: 0;
257 display: flex;
258 flex-wrap: wrap;
259 align-items: center;
260 gap: 8px;
261 }
262 .issues-row-title a {
263 color: var(--text-strong);
264 text-decoration: none;
265 transition: color 120ms ease;
266 }
267 .issues-row-title a:hover { color: var(--accent); }
268 .issues-row-meta {
269 margin-top: 5px;
270 font-size: 12.5px;
271 color: var(--text-muted);
272 line-height: 1.5;
273 }
274 .issues-row-meta strong { color: var(--text); font-weight: 600; }
275 .issues-row-side {
276 display: flex;
277 align-items: center;
278 gap: 12px;
279 color: var(--text-muted);
280 font-size: 12.5px;
281 flex-shrink: 0;
282 }
283 .issues-row-comments {
284 display: inline-flex;
285 align-items: center;
286 gap: 5px;
287 color: var(--text-muted);
288 text-decoration: none;
289 }
290 .issues-row-comments:hover { color: var(--accent); text-decoration: none; }
291
292 /* Label pills (rendered inline on titles) */
293 .issues-label {
294 display: inline-flex;
295 align-items: center;
296 padding: 2px 9px;
297 border-radius: 9999px;
298 font-size: 11.5px;
299 font-weight: 600;
300 line-height: 1.4;
301 background: rgba(140,109,255,0.10);
302 color: var(--text-strong);
303 border: 1px solid rgba(140,109,255,0.28);
304 letter-spacing: 0.005em;
305 }
306
307 /* Polished empty state */
308 .issues-empty {
309 margin: 0;
310 padding: 56px 32px;
311 background: var(--bg-elevated);
312 border: 1px solid var(--border);
313 border-radius: 16px;
314 text-align: center;
315 position: relative;
316 overflow: hidden;
317 }
318 .issues-empty::before {
319 content: '';
320 position: absolute;
321 top: 0; left: 0; right: 0;
322 height: 2px;
323 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
324 opacity: 0.55;
325 pointer-events: none;
326 }
327 .issues-empty-art {
328 width: 96px;
329 height: 96px;
330 margin: 0 auto 18px;
331 display: block;
332 opacity: 0.85;
333 }
334 .issues-empty-title {
335 font-family: var(--font-display);
336 font-size: 22px;
337 font-weight: 700;
338 letter-spacing: -0.018em;
339 color: var(--text-strong);
340 margin: 0 0 8px;
341 }
342 .issues-empty-sub {
343 font-size: 14.5px;
344 color: var(--text-muted);
345 line-height: 1.55;
346 margin: 0 auto 22px;
347 max-width: 460px;
348 }
349 .issues-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
350
351 /* ─── Detail page ─── */
352 .issues-detail-hero {
353 position: relative;
354 margin: 4px 0 20px;
355 padding: 22px 26px;
356 background: var(--bg-elevated);
357 border: 1px solid var(--border);
358 border-radius: 16px;
359 overflow: hidden;
360 }
361 .issues-detail-hero::before {
362 content: '';
363 position: absolute;
364 top: 0; left: 0; right: 0;
365 height: 2px;
366 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
367 opacity: 0.7;
368 pointer-events: none;
369 }
370 .issues-detail-title {
371 font-family: var(--font-display);
372 font-size: clamp(22px, 3vw, 30px);
373 font-weight: 700;
374 letter-spacing: -0.022em;
375 line-height: 1.18;
376 color: var(--text-strong);
377 margin: 0 0 12px;
378 }
379 .issues-detail-title .issues-detail-number {
380 color: var(--text-muted);
381 font-weight: 500;
382 margin-left: 8px;
383 }
384 .issues-detail-attr {
385 display: flex;
386 align-items: center;
387 gap: 12px;
388 flex-wrap: wrap;
389 font-size: 14px;
390 color: var(--text-muted);
391 }
392 .issues-detail-attr strong { color: var(--text); font-weight: 600; }
393 .issues-state-pill {
394 display: inline-flex;
395 align-items: center;
396 gap: 6px;
397 padding: 5px 12px;
398 border-radius: 9999px;
399 font-size: 12.5px;
400 font-weight: 600;
401 line-height: 1.4;
402 letter-spacing: 0.005em;
403 }
404 .issues-state-pill.is-open {
405 background: rgba(52,211,153,0.12);
406 color: #34d399;
407 border: 1px solid rgba(52,211,153,0.35);
408 }
409 .issues-state-pill.is-closed {
410 background: rgba(182,157,255,0.12);
411 color: #b69dff;
412 border: 1px solid rgba(182,157,255,0.35);
413 }
414 .issues-detail-spacer { flex: 1; }
415 .issues-detail-labels {
416 margin-top: 12px;
417 display: flex;
418 gap: 6px;
419 flex-wrap: wrap;
420 }
421
422 /* Comment thread */
423 .issues-thread { margin-top: 18px; }
424 .issues-comment {
425 position: relative;
426 border: 1px solid var(--border);
427 border-radius: 12px;
428 overflow: hidden;
429 background: var(--bg-elevated);
430 margin-bottom: 14px;
431 transition: border-color 160ms ease, box-shadow 160ms ease;
432 }
433 .issues-comment:hover {
434 border-color: var(--border-strong, rgba(255,255,255,0.13));
435 }
436 .issues-comment-header {
437 background: var(--bg-secondary);
438 padding: 10px 16px;
439 border-bottom: 1px solid var(--border);
440 display: flex;
441 align-items: center;
442 gap: 10px;
443 font-size: 13px;
444 color: var(--text-muted);
445 }
446 .issues-comment-header strong { color: var(--text-strong); font-weight: 600; }
447 .issues-comment-body { padding: 14px 18px; }
448 .issues-comment-author-pill {
449 display: inline-flex;
450 align-items: center;
451 padding: 1px 8px;
452 border-radius: 9999px;
453 background: rgba(140,109,255,0.10);
454 color: var(--accent);
455 font-size: 11px;
456 font-weight: 600;
457 letter-spacing: 0.02em;
458 text-transform: uppercase;
459 }
460
461 /* AI Review comment — distinct purple-accent treatment */
462 .issues-comment.is-ai {
463 border-color: rgba(140,109,255,0.45);
464 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -16px rgba(140,109,255,0.25);
465 }
466 .issues-comment.is-ai::before {
467 content: '';
468 position: absolute;
469 top: 0; left: 0; right: 0;
470 height: 2px;
471 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
472 pointer-events: none;
473 }
474 .issues-comment.is-ai .issues-comment-header {
475 background: linear-gradient(180deg, rgba(140,109,255,0.08), rgba(140,109,255,0.02));
476 border-bottom-color: rgba(140,109,255,0.22);
477 }
478 .issues-ai-badge {
479 display: inline-flex;
480 align-items: center;
481 gap: 5px;
482 padding: 2px 9px;
483 border-radius: 9999px;
484 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
485 color: #fff;
486 font-size: 11px;
487 font-weight: 700;
488 letter-spacing: 0.04em;
489 text-transform: uppercase;
490 line-height: 1.4;
491 }
492 .issues-ai-badge::before {
493 content: '';
494 width: 6px;
495 height: 6px;
496 border-radius: 9999px;
497 background: #fff;
498 opacity: 0.92;
499 box-shadow: 0 0 6px rgba(255,255,255,0.7);
500 }
501
502 /* Composer */
503 .issues-composer {
504 margin-top: 22px;
505 border: 1px solid var(--border);
506 border-radius: 12px;
507 overflow: hidden;
508 background: var(--bg-elevated);
509 transition: border-color 160ms ease, box-shadow 160ms ease;
510 }
511 .issues-composer:focus-within {
512 border-color: rgba(140,109,255,0.55);
513 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
514 }
515 .issues-composer-header {
516 display: flex;
517 align-items: center;
518 justify-content: space-between;
519 gap: 8px;
520 padding: 10px 14px;
521 background: var(--bg-secondary);
522 border-bottom: 1px solid var(--border);
523 font-size: 12.5px;
524 color: var(--text-muted);
525 }
526 .issues-composer-tag {
527 font-weight: 600;
528 color: var(--text);
529 letter-spacing: -0.005em;
530 }
531 .issues-composer-hint a {
532 color: var(--text-muted);
533 text-decoration: none;
534 border-bottom: 1px dashed currentColor;
535 }
536 .issues-composer-hint a:hover { color: var(--accent); }
537 .issues-composer textarea {
538 display: block;
539 width: 100%;
540 border: 0;
541 background: transparent;
542 color: var(--text);
543 padding: 14px 16px;
544 font-family: var(--font-mono);
545 font-size: 13.5px;
546 line-height: 1.55;
547 resize: vertical;
548 outline: none;
549 min-height: 140px;
550 }
551 .issues-composer-actions {
552 display: flex;
553 align-items: center;
554 gap: 8px;
555 padding: 10px 14px;
556 border-top: 1px solid var(--border);
557 background: var(--bg-secondary);
558 flex-wrap: wrap;
559 }
560
561 /* Info banner inside detail */
562 .issues-info-banner {
563 margin: 0 0 14px;
564 padding: 10px 14px;
565 border-radius: 12px;
566 background: rgba(140,109,255,0.08);
567 border: 1px solid rgba(140,109,255,0.28);
568 color: var(--text);
569 font-size: 13.5px;
570 }
571`;
572
573// Pre-rendered <style> tag (constant, reused per request).
574const IssuesStyle = () => (
575 <style dangerouslySetInnerHTML={{ __html: issuesStyles }} />
576);
577
578// Inline empty-state SVG — a softly-tinted speech-bubble icon. No external assets.
579const IssuesEmptySvg = () => (
580 <svg
581 class="issues-empty-art"
582 viewBox="0 0 96 96"
583 fill="none"
584 xmlns="http://www.w3.org/2000/svg"
585 aria-hidden="true"
586 >
587 <defs>
588 <linearGradient id="issuesEmptyG" x1="0" y1="0" x2="1" y2="1">
589 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.55" />
590 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.55" />
591 </linearGradient>
592 </defs>
593 <circle cx="48" cy="48" r="42" stroke="url(#issuesEmptyG)" stroke-width="1.5" fill="rgba(140,109,255,0.04)" />
594 <circle cx="48" cy="48" r="14" stroke="url(#issuesEmptyG)" stroke-width="2" fill="none" />
595 <path d="M48 30v8M48 58v8M30 48h8M58 48h8" stroke="url(#issuesEmptyG)" stroke-width="2" stroke-linecap="round" />
596 </svg>
597);
598
599// Detect AI Triage comments by the marker the triage helper writes.
600function isAiTriageComment(body: string | null | undefined): boolean {
601 if (!body) return false;
602 return body.includes(ISSUE_TRIAGE_MARKER) || body.trimStart().startsWith("## AI Triage");
603}
604
79136bbClaude605// Helper to resolve repo from :owner/:repo params
606async function resolveRepo(ownerName: string, repoName: string) {
607 const [owner] = await db
608 .select()
609 .from(users)
610 .where(eq(users.username, ownerName))
611 .limit(1);
612 if (!owner) return null;
613
614 const [repo] = await db
615 .select()
616 .from(repositories)
617 .where(
618 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
619 )
620 .limit(1);
621 if (!repo) return null;
622
623 return { owner, repo };
624}
625
626// Issue list
04f6b7fClaude627issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude628 const { owner: ownerName, repo: repoName } = c.req.param();
629 const user = c.get("user");
630 const state = c.req.query("state") || "open";
6ea2109Claude631 // Bounded pagination — unbounded selects ran a full table scan + O(n)
632 // sort on every page load; with 10k+ issues the request would hang.
633 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
634 const page = Math.max(1, Number(c.req.query("page")) || 1);
635 const offset = (page - 1) * perPage;
79136bbClaude636
f1dc7c7Claude637 // ── Loading skeleton (flag-gated) ──
638 // Renders an SSR'd row skeleton when `?skeleton=1` is set. Lets the
639 // user see the shape of the issue list before the DB count + select
640 // resolve. Behind a flag — we don't ship flashes.
641 if (c.req.query("skeleton") === "1") {
642 return c.html(
643 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
644 <IssuesStyle />
645 <RepoHeader owner={ownerName} repo={repoName} />
646 <IssueNav owner={ownerName} repo={repoName} active="issues" />
647 <style
648 dangerouslySetInnerHTML={{
649 __html: `
650 .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; }
651 @keyframes issuesSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
652 @media (prefers-reduced-motion: reduce) { .issues-skel { animation: none; } }
653 .issues-skel-hero { height: 168px; border-radius: 16px; margin: 4px 0 24px; }
654 .issues-skel-toolbar { height: 44px; width: 240px; border-radius: 9999px; margin-bottom: 16px; }
655 .issues-skel-list { display: flex; flex-direction: column; gap: 8px; }
656 .issues-skel-row { height: 62px; border-radius: 10px; }
657 `,
658 }}
659 />
660 <div class="issues-skel issues-skel-hero" aria-hidden="true" />
661 <div class="issues-skel issues-skel-toolbar" aria-hidden="true" />
662 <div class="issues-skel-list" aria-hidden="true">
663 {Array.from({ length: 8 }).map(() => (
664 <div class="issues-skel issues-skel-row" />
665 ))}
666 </div>
667 <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">
668 Loading issues for {ownerName}/{repoName}…
669 </span>
670 </Layout>
671 );
672 }
673
79136bbClaude674 const resolved = await resolveRepo(ownerName, repoName);
675 if (!resolved) {
676 return c.html(
677 <Layout title="Not Found" user={user}>
bb0f894Claude678 <EmptyState title="Repository not found" />
79136bbClaude679 </Layout>,
680 404
681 );
682 }
683
684 const { repo } = resolved;
685
686 const issueList = await db
687 .select({
688 issue: issues,
689 author: { username: users.username },
690 })
691 .from(issues)
692 .innerJoin(users, eq(issues.authorId, users.id))
693 .where(
694 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
695 )
6ea2109Claude696 .orderBy(desc(issues.createdAt))
697 .limit(perPage)
698 .offset(offset);
79136bbClaude699
700 // Count open/closed
701 const [counts] = await db
702 .select({
703 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
704 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
705 })
706 .from(issues)
707 .where(eq(issues.repositoryId, repo.id));
708
cb5a796Claude709 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
710 const pendingCountList = viewerIsOwnerOnList
711 ? await countPendingForRepo(repo.id)
712 : 0;
713
79136bbClaude714 return c.html(
715 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude716 <IssuesStyle />
79136bbClaude717 <RepoHeader owner={ownerName} repo={repoName} />
718 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude719 <PendingCommentsBanner
720 owner={ownerName}
721 repo={repoName}
722 count={pendingCountList}
723 />
f7ad7b8Claude724 <section class="issues-hero">
725 <div class="issues-hero-bg" aria-hidden="true">
726 <div class="issues-hero-orb" />
727 </div>
728 <div class="issues-hero-inner">
729 <div class="issues-hero-text">
730 <div class="issues-hero-eyebrow">
731 Issue tracker \u00B7{" "}
732 <span class="issues-hero-repo">
733 {ownerName}/{repoName}
734 </span>
735 </div>
736 <h1 class="issues-hero-title">
737 Track <span class="gradient-text">what matters</span>.
738 </h1>
739 <p class="issues-hero-sub">
740 {(Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
741 ? "Bugs, ideas, and roadmap items live here. Open the first one and AI Triage will draft a starter classification within seconds."
742 : `${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.`}
743 </p>
744 </div>
745 <div class="issues-hero-actions">
746 {user && (
747 <a
748 href={`/${ownerName}/${repoName}/issues/new`}
749 class="btn btn-primary"
750 >
751 + New issue
752 </a>
753 )}
754 <a href={`/${ownerName}/${repoName}`} class="btn">
755 Back to code
756 </a>
757 </div>
758 </div>
759 </section>
760
761 <div class="issues-toolbar">
762 <div class="issues-filters" role="tablist" aria-label="Issue state filter">
763 <a
764 class={`issues-filter${state === "open" ? " is-active" : ""}`}
765 href={`/${ownerName}/${repoName}/issues?state=open`}
766 role="tab"
767 aria-selected={state === "open" ? "true" : "false"}
79136bbClaude768 >
f7ad7b8Claude769 <span aria-hidden="true">{"\u25CB"}</span>
770 <span>Open</span>
771 <span class="issues-filter-count">{Number(counts?.open ?? 0)}</span>
772 </a>
773 <a
774 class={`issues-filter${state === "closed" ? " is-active" : ""}`}
775 href={`/${ownerName}/${repoName}/issues?state=closed`}
776 role="tab"
777 aria-selected={state === "closed" ? "true" : "false"}
778 >
779 <span aria-hidden="true">{"\u2713"}</span>
780 <span>Closed</span>
781 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
782 </a>
783 </div>
784 </div>
785
79136bbClaude786 {issueList.length === 0 ? (
f7ad7b8Claude787 <div class="issues-empty">
788 <IssuesEmptySvg />
789 <h2 class="issues-empty-title">
790 {state === "closed"
791 ? "No closed issues yet"
792 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
793 ? "No issues \u2014 yet"
794 : "Nothing open right now"}
795 </h2>
796 <p class="issues-empty-sub">
797 {state === "closed"
798 ? "Closed issues will show up here once the team starts shipping fixes."
799 : (Number(counts?.open ?? 0) + Number(counts?.closed ?? 0)) === 0
800 ? "File the first one and AI Triage will draft a starter classification \u2014 labels, priority, and a duplicate sweep \u2014 within seconds."
801 : "All caught up. New filings will appear here, with AI Triage suggestions auto-posted to every thread."}
802 </p>
803 <div class="issues-empty-cta">
804 {user && state !== "closed" && (
805 <a
806 href={`/${ownerName}/${repoName}/issues/new`}
807 class="btn btn-primary"
808 >
809 + Open the first issue
810 </a>
811 )}
79136bbClaude812 {state === "closed" && (
f7ad7b8Claude813 <a
814 href={`/${ownerName}/${repoName}/issues?state=open`}
815 class="btn"
816 >
817 View open issues
818 </a>
79136bbClaude819 )}
f7ad7b8Claude820 </div>
821 </div>
79136bbClaude822 ) : (
f7ad7b8Claude823 <ul class="issues-list">
79136bbClaude824 {issueList.map(({ issue, author }) => (
f7ad7b8Claude825 <li class="issues-row">
79136bbClaude826 <div
f7ad7b8Claude827 class={`issues-row-icon ${issue.state === "open" ? "is-open" : "is-closed"}`}
828 aria-hidden="true"
829 title={issue.state === "open" ? "Open" : "Closed"}
79136bbClaude830 >
831 {issue.state === "open" ? "\u25CB" : "\u2713"}
832 </div>
f7ad7b8Claude833 <div class="issues-row-main">
834 <h3 class="issues-row-title">
79136bbClaude835 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
836 {issue.title}
837 </a>
f7ad7b8Claude838 </h3>
839 <div class="issues-row-meta">
840 #{issue.number} opened by{" "}
841 <strong>{author.username}</strong>{" "}
79136bbClaude842 {formatRelative(issue.createdAt)}
843 </div>
844 </div>
f7ad7b8Claude845 </li>
79136bbClaude846 ))}
f7ad7b8Claude847 </ul>
79136bbClaude848 )}
849 </Layout>
850 );
851});
852
853// New issue form
854issueRoutes.get(
855 "/:owner/:repo/issues/new",
856 softAuth,
857 requireAuth,
04f6b7fClaude858 requireRepoAccess("write"),
79136bbClaude859 async (c) => {
860 const { owner: ownerName, repo: repoName } = c.req.param();
861 const user = c.get("user")!;
862 const error = c.req.query("error");
24cf2caClaude863 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude864
865 return c.html(
866 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
f7ad7b8Claude867 <IssuesStyle />
79136bbClaude868 <RepoHeader owner={ownerName} repo={repoName} />
869 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude870 <Container maxWidth={800}>
f7ad7b8Claude871 <section class="issues-hero" style="margin-top:4px">
872 <div class="issues-hero-bg" aria-hidden="true">
873 <div class="issues-hero-orb" />
874 </div>
875 <div class="issues-hero-inner">
876 <div class="issues-hero-text">
877 <div class="issues-hero-eyebrow">
878 New issue ·{" "}
879 <span class="issues-hero-repo">
880 {ownerName}/{repoName}
881 </span>
882 </div>
883 <h1 class="issues-hero-title">
884 File <span class="gradient-text">it cleanly</span>.
885 </h1>
886 <p class="issues-hero-sub">
887 AI Triage will read the body the moment you submit and post
888 suggested labels, priority, and possible duplicates within
889 seconds. You stay in control — nothing is applied.
890 </p>
891 </div>
892 </div>
893 </section>
79136bbClaude894 {error && (
bb0f894Claude895 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude896 )}
0316dbbClaude897 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
898 <FormGroup>
899 <Input
79136bbClaude900 type="text"
901 name="title"
902 required
903 placeholder="Title"
bb0f894Claude904 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]905 aria-label="Issue title"
79136bbClaude906 />
bb0f894Claude907 </FormGroup>
908 <FormGroup>
909 <TextArea
79136bbClaude910 name="body"
911 rows={12}
912 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude913 mono
79136bbClaude914 />
bb0f894Claude915 </FormGroup>
916 <Button type="submit" variant="primary">
79136bbClaude917 Submit new issue
bb0f894Claude918 </Button>
919 </Form>
920 </Container>
79136bbClaude921 </Layout>
922 );
923 }
924);
925
926// Create issue
927issueRoutes.post(
928 "/:owner/:repo/issues/new",
929 softAuth,
930 requireAuth,
04f6b7fClaude931 requireRepoAccess("write"),
79136bbClaude932 async (c) => {
933 const { owner: ownerName, repo: repoName } = c.req.param();
934 const user = c.get("user")!;
935 const body = await c.req.parseBody();
936 const title = String(body.title || "").trim();
937 const issueBody = String(body.body || "").trim();
938
939 if (!title) {
940 return c.redirect(
941 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
942 );
943 }
944
945 const resolved = await resolveRepo(ownerName, repoName);
946 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
947
948 const [issue] = await db
949 .insert(issues)
950 .values({
951 repositoryId: resolved.repo.id,
952 authorId: user.id,
953 title,
954 body: issueBody || null,
955 })
956 .returning();
957
958 // Update issue count
959 await db
960 .update(repositories)
961 .set({ issueCount: resolved.repo.issueCount + 1 })
962 .where(eq(repositories.id, resolved.repo.id));
963
a9ada5fClaude964 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
965 // suggested labels, priority, summary, and a possible-duplicate
966 // callout. Suggestions only — nothing applied automatically.
967 triggerIssueTriage({
968 ownerName,
969 repoName,
970 repositoryId: resolved.repo.id,
971 issueId: issue.id,
972 issueNumber: issue.number,
973 authorId: user.id,
974 title,
975 body: issueBody,
a28cedeClaude976 }).catch((err) => {
977 console.warn(
978 `[issue-triage] triage trigger failed for issue ${issue.id}:`,
979 err instanceof Error ? err.message : err
980 );
981 });
a9ada5fClaude982
79136bbClaude983 return c.redirect(
984 `/${ownerName}/${repoName}/issues/${issue.number}`
985 );
986 }
987);
988
989// View single issue
04f6b7fClaude990issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude991 const { owner: ownerName, repo: repoName } = c.req.param();
992 const issueNum = parseInt(c.req.param("number"), 10);
993 const user = c.get("user");
994
995 const resolved = await resolveRepo(ownerName, repoName);
996 if (!resolved) {
997 return c.html(
998 <Layout title="Not Found" user={user}>
bb0f894Claude999 <EmptyState title="Not found" />
79136bbClaude1000 </Layout>,
1001 404
1002 );
1003 }
1004
1005 const [issue] = await db
1006 .select()
1007 .from(issues)
1008 .where(
1009 and(
1010 eq(issues.repositoryId, resolved.repo.id),
1011 eq(issues.number, issueNum)
1012 )
1013 )
1014 .limit(1);
1015
1016 if (!issue) {
1017 return c.html(
1018 <Layout title="Not Found" user={user}>
bb0f894Claude1019 <EmptyState title="Issue not found" />
79136bbClaude1020 </Layout>,
1021 404
1022 );
1023 }
1024
1025 const [author] = await db
1026 .select()
1027 .from(users)
1028 .where(eq(users.id, issue.authorId))
1029 .limit(1);
1030
cb5a796Claude1031 // Get comments. We pull every row and filter by moderation_status +
1032 // viewer identity client-side (in the JSX below) so a moderator/owner
1033 // sees them all while non-author viewers only ever see 'approved'.
1034 const allComments = await db
79136bbClaude1035 .select({
1036 comment: issueComments,
cb5a796Claude1037 author: { id: users.id, username: users.username },
79136bbClaude1038 })
1039 .from(issueComments)
1040 .innerJoin(users, eq(issueComments.authorId, users.id))
1041 .where(eq(issueComments.issueId, issue.id))
1042 .orderBy(asc(issueComments.createdAt));
1043
cb5a796Claude1044 const viewerIsOwner = !!(user && user.id === resolved.owner.id);
1045 const comments = allComments.filter(({ comment, author: cAuthor }) => {
1046 // Owner always sees everything (so they can spot conversations going
1047 // sideways even before they hit the queue page).
1048 if (viewerIsOwner) return true;
1049 if (comment.moderationStatus === "approved") return true;
1050 // The comment's own author sees their pending comment so they know
1051 // it landed (with an "Awaiting approval" badge in the render below).
1052 if (user && cAuthor.id === user.id && comment.moderationStatus === "pending") {
1053 return true;
1054 }
1055 return false;
1056 });
1057
1058 // Pending banner — when the viewer is the repo owner, show a strip at
1059 // the top of the page nudging them to the moderation queue. Inline on
1060 // this page because RepoNav is locked (see CLAUDE.md / build bible).
1061 const pendingCount = viewerIsOwner
1062 ? await countPendingForRepo(resolved.repo.id)
1063 : 0;
1064
6fc53bdClaude1065 // Load reactions for the issue + each comment in parallel.
1066 const [issueReactions, ...commentReactions] = await Promise.all([
1067 summariseReactions("issue", issue.id, user?.id),
1068 ...comments.map((row) =>
1069 summariseReactions("issue_comment", row.comment.id, user?.id)
1070 ),
1071 ]);
1072
79136bbClaude1073 const canManage =
1074 user &&
1075 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude1076 const info = c.req.query("info");
79136bbClaude1077
1078 return c.html(
1079 <Layout
1080 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
1081 user={user}
1082 >
f7ad7b8Claude1083 <IssuesStyle />
79136bbClaude1084 <RepoHeader owner={ownerName} repo={repoName} />
1085 <IssueNav owner={ownerName} repo={repoName} active="issues" />
cb5a796Claude1086 <PendingCommentsBanner
1087 owner={ownerName}
1088 repo={repoName}
1089 count={pendingCount}
1090 />
b584e52Claude1091 <div
1092 id="live-comment-banner"
1093 class="alert"
1094 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1095 >
1096 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1097 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1098 reload to view
1099 </a>
1100 </div>
1101 <script
1102 dangerouslySetInnerHTML={{
1103 __html: liveCommentBannerScript({
1104 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
1105 bannerElementId: "live-comment-banner",
1106 }),
1107 }}
1108 />
79136bbClaude1109 <div class="issue-detail">
58915a9Claude1110 {info && (
f7ad7b8Claude1111 <div class="issues-info-banner">
58915a9Claude1112 {decodeURIComponent(info)}
1113 </div>
1114 )}
f7ad7b8Claude1115
1116 <section class="issues-detail-hero">
1117 <h1 class="issues-detail-title">
1118 {issue.title}
1119 <span class="issues-detail-number">#{issue.number}</span>
1120 </h1>
1121 <div class="issues-detail-attr">
1122 <span
1123 class={`issues-state-pill ${issue.state === "open" ? "is-open" : "is-closed"}`}
1124 title={issue.state === "open" ? "Open" : "Closed"}
fbf4aefClaude1125 >
f7ad7b8Claude1126 <span aria-hidden="true">
1127 {issue.state === "open" ? "\u25CB" : "\u2713"}
1128 </span>
1129 {issue.state === "open" ? "Open" : "Closed"}
1130 </span>
1131 <span>
1132 <strong>{author?.username || "unknown"}</strong> opened this
1133 issue {formatRelative(issue.createdAt)}
1134 </span>
1135 <span class="issues-detail-spacer" />
1136 {issue.state === "open" && user && user.id === resolved.owner.id && (
1137 <a
1138 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
1139 class="btn btn-primary"
1140 style="font-size:13px;padding:6px 12px"
1141 title="Generate a draft pull request from this issue using Claude"
1142 >
1143 Build with AI
1144 </a>
1145 )}
1146 </div>
1147 </section>
1148
1149 <div class="issues-thread">
1150 {issue.body && (
1151 <article class="issues-comment">
1152 <header class="issues-comment-header">
1153 <strong>{author?.username || "unknown"}</strong>
1154 <span class="issues-comment-author-pill">Author</span>
1155 <span>commented {formatRelative(issue.createdAt)}</span>
1156 </header>
1157 <div class="issues-comment-body">
1158 <div
1159 class="markdown-body"
1160 dangerouslySetInnerHTML={{ __html: renderMarkdown(issue.body) }}
1161 />
1162 </div>
1163 </article>
fbf4aefClaude1164 )}
79136bbClaude1165
f7ad7b8Claude1166 {comments.map(({ comment, author: commentAuthor }) => {
1167 const isAi = isAiTriageComment(comment.body);
cb5a796Claude1168 const isPending = comment.moderationStatus === "pending";
f7ad7b8Claude1169 return (
cb5a796Claude1170 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
f7ad7b8Claude1171 <header class="issues-comment-header">
1172 <strong>{commentAuthor.username}</strong>
1173 {isAi ? (
1174 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
1175 AI Review
1176 </span>
1177 ) : null}
cb5a796Claude1178 {isPending ? (
1179 <span class="modq-pending-badge" title="This comment is awaiting the repository owner's approval — only you and the owner can see it.">
1180 Awaiting approval
1181 </span>
1182 ) : null}
f7ad7b8Claude1183 <span>commented {formatRelative(comment.createdAt)}</span>
1184 </header>
1185 <div class="issues-comment-body">
1186 <div
1187 class="markdown-body"
1188 dangerouslySetInnerHTML={{
1189 __html: renderMarkdown(comment.body),
1190 }}
1191 />
1192 </div>
1193 </article>
1194 );
1195 })}
1196 </div>
79136bbClaude1197
1198 {user && (
f7ad7b8Claude1199 <form
1200 class="issues-composer"
1201 method="post"
1202 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
1203 >
1204 <div class="issues-composer-header">
1205 <span class="issues-composer-tag">Add a comment</span>
1206 <span class="issues-composer-hint">
1207 <a
1208 href="https://docs.github.com/en/get-started/writing-on-github"
1209 target="_blank"
1210 rel="noopener noreferrer"
1211 title="Markdown supported: **bold**, _italic_, `code`, links, lists, > quotes"
1212 >
1213 Markdown supported
1214 </a>
1215 </span>
1216 </div>
1217 <textarea
1218 name="body"
1219 rows={6}
1220 required
1221 placeholder="Leave a comment... fenced code blocks, lists, links, and quotes all supported."
1222 />
1223 <div class="issues-composer-actions">
1224 <button type="submit" class="btn btn-primary">
1225 Comment
1226 </button>
1227 {canManage && (
1228 <button
1229 type="submit"
1230 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
1231 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
1232 >
1233 {issue.state === "open" ? "Close issue" : "Reopen issue"}
79136bbClaude1234 </button>
f7ad7b8Claude1235 )}
1236 {canManage && issue.state === "open" && (
1237 <button
1238 type="submit"
1239 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
1240 formnovalidate
1241 class="btn"
1242 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1243 >
1244 Re-run AI triage
1245 </button>
1246 )}
1247 </div>
1248 </form>
79136bbClaude1249 )}
1250 </div>
1251 </Layout>
1252 );
1253});
1254
cb5a796Claude1255// Add comment.
1256//
1257// Permission model: we accept comments from ANY authenticated user (read
1258// access required — public repos satisfy that, private repos still need a
1259// collaborator row). The moderation gate in `decideInitialStatus`
1260// determines whether the comment is published immediately or queued for
1261// the repo owner's approval. See `src/lib/comment-moderation.ts` for the
1262// "anti-impersonation" rationale.
79136bbClaude1263issueRoutes.post(
1264 "/:owner/:repo/issues/:number/comment",
1265 softAuth,
1266 requireAuth,
cb5a796Claude1267 requireRepoAccess("read"),
79136bbClaude1268 async (c) => {
1269 const { owner: ownerName, repo: repoName } = c.req.param();
1270 const issueNum = parseInt(c.req.param("number"), 10);
1271 const user = c.get("user")!;
1272 const body = await c.req.parseBody();
1273 const commentBody = String(body.body || "").trim();
1274
1275 if (!commentBody) {
1276 return c.redirect(
1277 `/${ownerName}/${repoName}/issues/${issueNum}`
1278 );
1279 }
1280
1281 const resolved = await resolveRepo(ownerName, repoName);
1282 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1283
1284 const [issue] = await db
1285 .select()
1286 .from(issues)
1287 .where(
1288 and(
1289 eq(issues.repositoryId, resolved.repo.id),
1290 eq(issues.number, issueNum)
1291 )
1292 )
1293 .limit(1);
1294
1295 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
1296
cb5a796Claude1297 // Decide moderation status BEFORE insert so the row lands in the right
1298 // initial state. Collaborators, the issue author, and trusted users
1299 // get 'approved'; banned users get 'rejected' (silent drop); everyone
1300 // else gets 'pending'.
1301 const decision = await decideInitialStatus({
1302 commenterUserId: user.id,
1303 repositoryId: resolved.repo.id,
1304 kind: "issue",
1305 threadId: issue.id,
1306 });
1307
d4ac5c3Claude1308 const [inserted] = await db
1309 .insert(issueComments)
1310 .values({
1311 issueId: issue.id,
1312 authorId: user.id,
1313 body: commentBody,
cb5a796Claude1314 moderationStatus: decision.status,
d4ac5c3Claude1315 })
1316 .returning();
1317
cb5a796Claude1318 // Live update only when visible. Don't leak pending/rejected via SSE.
1319 if (inserted && decision.status === "approved") {
d4ac5c3Claude1320 try {
1321 const { publish } = await import("../lib/sse");
1322 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
1323 event: "issue-comment",
1324 data: {
1325 issueId: issue.id,
1326 commentId: inserted.id,
1327 authorId: user.id,
1328 authorUsername: user.username,
1329 },
1330 });
1331 } catch {
1332 /* SSE is best-effort */
1333 }
1334 }
79136bbClaude1335
cb5a796Claude1336 if (decision.status === "pending") {
1337 // Notify repo owner that a comment is awaiting review.
1338 void notifyOwnerOfPendingComment({
1339 repositoryId: resolved.repo.id,
1340 commenterUsername: user.username,
1341 kind: "issue",
1342 threadNumber: issueNum,
1343 ownerUsername: ownerName,
1344 repoName,
1345 });
1346 return c.redirect(
1347 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1348 );
1349 }
1350 if (decision.status === "rejected") {
1351 // Silent ban — the commenter is on the banned list. Don't reveal
1352 // the gate; just send them back to the page as if it posted.
1353 return c.redirect(
1354 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
1355 );
1356 }
1357
79136bbClaude1358 return c.redirect(
1359 `/${ownerName}/${repoName}/issues/${issueNum}`
1360 );
1361 }
1362);
1363
1364// Close issue
1365issueRoutes.post(
1366 "/:owner/:repo/issues/:number/close",
1367 softAuth,
1368 requireAuth,
04f6b7fClaude1369 requireRepoAccess("write"),
79136bbClaude1370 async (c) => {
1371 const { owner: ownerName, repo: repoName } = c.req.param();
1372 const issueNum = parseInt(c.req.param("number"), 10);
1373
1374 const resolved = await resolveRepo(ownerName, repoName);
1375 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1376
1377 await db
1378 .update(issues)
1379 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1380 .where(
1381 and(
1382 eq(issues.repositoryId, resolved.repo.id),
1383 eq(issues.number, issueNum)
1384 )
1385 );
1386
1387 return c.redirect(
1388 `/${ownerName}/${repoName}/issues/${issueNum}`
1389 );
1390 }
1391);
1392
1393// Reopen issue
1394issueRoutes.post(
1395 "/:owner/:repo/issues/:number/reopen",
1396 softAuth,
1397 requireAuth,
04f6b7fClaude1398 requireRepoAccess("write"),
79136bbClaude1399 async (c) => {
1400 const { owner: ownerName, repo: repoName } = c.req.param();
1401 const issueNum = parseInt(c.req.param("number"), 10);
1402
1403 const resolved = await resolveRepo(ownerName, repoName);
1404 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1405
1406 await db
1407 .update(issues)
1408 .set({ state: "open", closedAt: null, updatedAt: new Date() })
1409 .where(
1410 and(
1411 eq(issues.repositoryId, resolved.repo.id),
1412 eq(issues.number, issueNum)
1413 )
1414 );
1415
1416 return c.redirect(
1417 `/${ownerName}/${repoName}/issues/${issueNum}`
1418 );
1419 }
1420);
1421
1422// Shared nav component with issues tab
1423const IssueNav = ({
1424 owner,
1425 repo,
1426 active,
1427}: {
1428 owner: string;
1429 repo: string;
1430 active: "code" | "commits" | "issues";
1431}) => (
bb0f894Claude1432 <TabNav
1433 tabs={[
1434 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1435 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1436 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1437 ]}
1438 />
79136bbClaude1439);
1440
58915a9Claude1441// Re-run AI triage on demand (e.g. after the issue body has been edited).
1442// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
1443issueRoutes.post(
1444 "/:owner/:repo/issues/:number/ai-retriage",
1445 softAuth,
1446 requireAuth,
1447 requireRepoAccess("write"),
1448 async (c) => {
1449 const { owner: ownerName, repo: repoName } = c.req.param();
1450 const issueNum = parseInt(c.req.param("number"), 10);
1451 const user = c.get("user")!;
1452 const resolved = await resolveRepo(ownerName, repoName);
1453 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1454
1455 const [issue] = await db
1456 .select()
1457 .from(issues)
1458 .where(
1459 and(
1460 eq(issues.repositoryId, resolved.repo.id),
1461 eq(issues.number, issueNum)
1462 )
1463 )
1464 .limit(1);
1465 if (!issue) {
1466 return c.redirect(`/${ownerName}/${repoName}/issues`);
1467 }
1468
1469 triggerIssueTriage(
1470 {
1471 ownerName,
1472 repoName,
1473 repositoryId: resolved.repo.id,
1474 issueId: issue.id,
1475 issueNumber: issue.number,
1476 authorId: user.id,
1477 title: issue.title,
1478 body: issue.body || "",
1479 },
1480 { force: true }
a28cedeClaude1481 ).catch((err) => {
1482 console.warn(
1483 `[issue-triage] re-triage failed for issue ${issue.id}:`,
1484 err instanceof Error ? err.message : err
1485 );
1486 });
58915a9Claude1487
1488 return c.redirect(
1489 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
1490 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
1491 )}`
1492 );
1493 }
1494);
1495
79136bbClaude1496export default issueRoutes;
1497export { IssueNav };