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.tsxBlame676 lines · 1 contributor
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";
6fc53bdClaude18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
24cf2caClaude20import { loadIssueTemplate } from "../lib/templates";
79136bbClaude21import { renderMarkdown } from "../lib/markdown";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
a9ada5fClaude23import { triggerIssueTriage } from "../lib/issue-triage";
79136bbClaude24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude26import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude27import {
28 Flex,
29 Container,
30 PageHeader,
31 Form,
32 FormGroup,
33 Input,
34 TextArea,
35 Button,
36 LinkButton,
37 Badge,
38 EmptyState,
39 TabNav,
40 FilterTabs,
41 List,
42 ListItem,
43 Alert,
44 CommentBox,
45 CommentForm,
46 formatRelative,
47} from "../views/ui";
79136bbClaude48
49const issueRoutes = new Hono<AuthEnv>();
50
51// Helper to resolve repo from :owner/:repo params
52async function resolveRepo(ownerName: string, repoName: string) {
53 const [owner] = await db
54 .select()
55 .from(users)
56 .where(eq(users.username, ownerName))
57 .limit(1);
58 if (!owner) return null;
59
60 const [repo] = await db
61 .select()
62 .from(repositories)
63 .where(
64 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
65 )
66 .limit(1);
67 if (!repo) return null;
68
69 return { owner, repo };
70}
71
72// Issue list
04f6b7fClaude73issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude74 const { owner: ownerName, repo: repoName } = c.req.param();
75 const user = c.get("user");
76 const state = c.req.query("state") || "open";
77
78 const resolved = await resolveRepo(ownerName, repoName);
79 if (!resolved) {
80 return c.html(
81 <Layout title="Not Found" user={user}>
bb0f894Claude82 <EmptyState title="Repository not found" />
79136bbClaude83 </Layout>,
84 404
85 );
86 }
87
88 const { repo } = resolved;
89
90 const issueList = await db
91 .select({
92 issue: issues,
93 author: { username: users.username },
94 })
95 .from(issues)
96 .innerJoin(users, eq(issues.authorId, users.id))
97 .where(
98 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
99 )
100 .orderBy(desc(issues.createdAt));
101
102 // Count open/closed
103 const [counts] = await db
104 .select({
105 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
106 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
107 })
108 .from(issues)
109 .where(eq(issues.repositoryId, repo.id));
110
111 return c.html(
112 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
113 <RepoHeader owner={ownerName} repo={repoName} />
114 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude115 <Flex justify="space-between" align="center" style="margin-bottom:16px">
116 <FilterTabs
117 tabs={[
118 {
119 label: `${counts?.open ?? 0} Open`,
120 href: `/${ownerName}/${repoName}/issues?state=open`,
121 active: state === "open",
122 },
123 {
124 label: `${counts?.closed ?? 0} Closed`,
125 href: `/${ownerName}/${repoName}/issues?state=closed`,
126 active: state === "closed",
127 },
128 ]}
129 />
79136bbClaude130 {user && (
bb0f894Claude131 <LinkButton
79136bbClaude132 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude133 variant="primary"
79136bbClaude134 >
135 New issue
bb0f894Claude136 </LinkButton>
79136bbClaude137 )}
bb0f894Claude138 </Flex>
79136bbClaude139 {issueList.length === 0 ? (
bb0f894Claude140 <EmptyState>
79136bbClaude141 <p>
142 No {state} issues.
143 {state === "closed" && (
144 <span>
145 {" "}
146 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
147 View open issues
148 </a>
149 </span>
150 )}
151 </p>
bb0f894Claude152 </EmptyState>
79136bbClaude153 ) : (
bb0f894Claude154 <List>
79136bbClaude155 {issueList.map(({ issue, author }) => (
bb0f894Claude156 <ListItem>
79136bbClaude157 <div
158 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
159 >
160 {issue.state === "open" ? "\u25CB" : "\u2713"}
161 </div>
162 <div>
163 <div class="issue-title">
164 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
165 {issue.title}
166 </a>
167 </div>
168 <div class="issue-meta">
169 #{issue.number} opened by {author.username}{" "}
170 {formatRelative(issue.createdAt)}
171 </div>
172 </div>
bb0f894Claude173 </ListItem>
79136bbClaude174 ))}
bb0f894Claude175 </List>
79136bbClaude176 )}
177 </Layout>
178 );
179});
180
181// New issue form
182issueRoutes.get(
183 "/:owner/:repo/issues/new",
184 softAuth,
185 requireAuth,
04f6b7fClaude186 requireRepoAccess("write"),
79136bbClaude187 async (c) => {
188 const { owner: ownerName, repo: repoName } = c.req.param();
189 const user = c.get("user")!;
190 const error = c.req.query("error");
24cf2caClaude191 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude192
193 return c.html(
194 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
195 <RepoHeader owner={ownerName} repo={repoName} />
196 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude197 <Container maxWidth={800}>
198 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude199 {error && (
bb0f894Claude200 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude201 )}
0316dbbClaude202 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
203 <FormGroup>
204 <Input
79136bbClaude205 type="text"
206 name="title"
207 required
208 placeholder="Title"
bb0f894Claude209 style="font-size:16px;padding:10px 14px"
79136bbClaude210 />
bb0f894Claude211 </FormGroup>
212 <FormGroup>
213 <TextArea
79136bbClaude214 name="body"
215 rows={12}
216 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude217 mono
79136bbClaude218 />
bb0f894Claude219 </FormGroup>
220 <Button type="submit" variant="primary">
79136bbClaude221 Submit new issue
bb0f894Claude222 </Button>
223 </Form>
224 </Container>
79136bbClaude225 </Layout>
226 );
227 }
228);
229
230// Create issue
231issueRoutes.post(
232 "/:owner/:repo/issues/new",
233 softAuth,
234 requireAuth,
04f6b7fClaude235 requireRepoAccess("write"),
79136bbClaude236 async (c) => {
237 const { owner: ownerName, repo: repoName } = c.req.param();
238 const user = c.get("user")!;
239 const body = await c.req.parseBody();
240 const title = String(body.title || "").trim();
241 const issueBody = String(body.body || "").trim();
242
243 if (!title) {
244 return c.redirect(
245 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
246 );
247 }
248
249 const resolved = await resolveRepo(ownerName, repoName);
250 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
251
252 const [issue] = await db
253 .insert(issues)
254 .values({
255 repositoryId: resolved.repo.id,
256 authorId: user.id,
257 title,
258 body: issueBody || null,
259 })
260 .returning();
261
262 // Update issue count
263 await db
264 .update(repositories)
265 .set({ issueCount: resolved.repo.issueCount + 1 })
266 .where(eq(repositories.id, resolved.repo.id));
267
a9ada5fClaude268 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
269 // suggested labels, priority, summary, and a possible-duplicate
270 // callout. Suggestions only — nothing applied automatically.
271 triggerIssueTriage({
272 ownerName,
273 repoName,
274 repositoryId: resolved.repo.id,
275 issueId: issue.id,
276 issueNumber: issue.number,
277 authorId: user.id,
278 title,
279 body: issueBody,
280 }).catch(() => {});
281
79136bbClaude282 return c.redirect(
283 `/${ownerName}/${repoName}/issues/${issue.number}`
284 );
285 }
286);
287
288// View single issue
04f6b7fClaude289issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude290 const { owner: ownerName, repo: repoName } = c.req.param();
291 const issueNum = parseInt(c.req.param("number"), 10);
292 const user = c.get("user");
293
294 const resolved = await resolveRepo(ownerName, repoName);
295 if (!resolved) {
296 return c.html(
297 <Layout title="Not Found" user={user}>
bb0f894Claude298 <EmptyState title="Not found" />
79136bbClaude299 </Layout>,
300 404
301 );
302 }
303
304 const [issue] = await db
305 .select()
306 .from(issues)
307 .where(
308 and(
309 eq(issues.repositoryId, resolved.repo.id),
310 eq(issues.number, issueNum)
311 )
312 )
313 .limit(1);
314
315 if (!issue) {
316 return c.html(
317 <Layout title="Not Found" user={user}>
bb0f894Claude318 <EmptyState title="Issue not found" />
79136bbClaude319 </Layout>,
320 404
321 );
322 }
323
324 const [author] = await db
325 .select()
326 .from(users)
327 .where(eq(users.id, issue.authorId))
328 .limit(1);
329
330 // Get comments
331 const comments = await db
332 .select({
333 comment: issueComments,
334 author: { username: users.username },
335 })
336 .from(issueComments)
337 .innerJoin(users, eq(issueComments.authorId, users.id))
338 .where(eq(issueComments.issueId, issue.id))
339 .orderBy(asc(issueComments.createdAt));
340
6fc53bdClaude341 // Load reactions for the issue + each comment in parallel.
342 const [issueReactions, ...commentReactions] = await Promise.all([
343 summariseReactions("issue", issue.id, user?.id),
344 ...comments.map((row) =>
345 summariseReactions("issue_comment", row.comment.id, user?.id)
346 ),
347 ]);
348
79136bbClaude349 const canManage =
350 user &&
351 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude352 const info = c.req.query("info");
79136bbClaude353
354 return c.html(
355 <Layout
356 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
357 user={user}
358 >
359 <RepoHeader owner={ownerName} repo={repoName} />
360 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude361 <div
362 id="live-comment-banner"
363 class="alert"
364 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
365 >
366 <strong class="js-live-count">0</strong> new comment(s) —{" "}
367 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
368 reload to view
369 </a>
370 </div>
371 <script
372 dangerouslySetInnerHTML={{
373 __html: liveCommentBannerScript({
374 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
375 bannerElementId: "live-comment-banner",
376 }),
377 }}
378 />
79136bbClaude379 <div class="issue-detail">
58915a9Claude380 {info && (
381 <div style="margin: 12px 0; padding: 10px 14px; border-radius: 6px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); color: var(--text); font-size: 14px">
382 {decodeURIComponent(info)}
383 </div>
384 )}
79136bbClaude385 <h2>
386 {issue.title}{" "}
bb0f894Claude387 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude388 #{issue.number}
389 </span>
390 </h2>
bb0f894Claude391 <Flex align="center" gap={8} style="margin:8px 0 20px">
392 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude393 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude394 </Badge>
395 <span style="color:var(--text-muted);font-size:14px">
396 <strong style="color:var(--text)">
79136bbClaude397 {author?.username || "unknown"}
398 </strong>{" "}
399 opened this issue {formatRelative(issue.createdAt)}
400 </span>
fbf4aefClaude401 {issue.state === "open" && user && user.id === resolved.owner.id && (
402 <a
403 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
404 class="btn btn-primary"
405 style="margin-left:auto;font-size:13px;padding:4px 10px"
406 title="Generate a draft pull request from this issue using Claude"
407 >
408 Build with AI
409 </a>
410 )}
bb0f894Claude411 </Flex>
79136bbClaude412
413 {issue.body && (
bb0f894Claude414 <CommentBox
415 author={author?.username || "unknown"}
416 date={issue.createdAt}
417 body={renderMarkdown(issue.body)}
418 />
79136bbClaude419 )}
420
421 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude422 <CommentBox
423 author={commentAuthor.username}
424 date={comment.createdAt}
425 body={renderMarkdown(comment.body)}
426 />
79136bbClaude427 ))}
428
429 {user && (
430 <div style="margin-top: 20px">
431 <form
cce7944Claude432 method="post"
79136bbClaude433 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
434 >
435 <div class="form-group">
436 <textarea
437 name="body"
438 rows={6}
439 required
440 placeholder="Leave a comment... (Markdown supported)"
441 style="font-family: var(--font-mono); font-size: 13px"
442 />
443 </div>
444 <div style="display: flex; gap: 8px">
445 <button type="submit" class="btn btn-primary">
446 Comment
447 </button>
448 {canManage && (
449 <button
450 type="submit"
451 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
452 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
453 >
454 {issue.state === "open"
455 ? "Close issue"
456 : "Reopen issue"}
457 </button>
458 )}
58915a9Claude459 {canManage && issue.state === "open" && (
460 <button
461 type="submit"
462 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
463 formnovalidate
464 class="btn"
465 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
466 >
467 Re-run AI triage
468 </button>
469 )}
79136bbClaude470 </div>
471 </form>
472 </div>
473 )}
474 </div>
475 </Layout>
476 );
477});
478
479// Add comment
480issueRoutes.post(
481 "/:owner/:repo/issues/:number/comment",
482 softAuth,
483 requireAuth,
04f6b7fClaude484 requireRepoAccess("write"),
79136bbClaude485 async (c) => {
486 const { owner: ownerName, repo: repoName } = c.req.param();
487 const issueNum = parseInt(c.req.param("number"), 10);
488 const user = c.get("user")!;
489 const body = await c.req.parseBody();
490 const commentBody = String(body.body || "").trim();
491
492 if (!commentBody) {
493 return c.redirect(
494 `/${ownerName}/${repoName}/issues/${issueNum}`
495 );
496 }
497
498 const resolved = await resolveRepo(ownerName, repoName);
499 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
500
501 const [issue] = await db
502 .select()
503 .from(issues)
504 .where(
505 and(
506 eq(issues.repositoryId, resolved.repo.id),
507 eq(issues.number, issueNum)
508 )
509 )
510 .limit(1);
511
512 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
513
d4ac5c3Claude514 const [inserted] = await db
515 .insert(issueComments)
516 .values({
517 issueId: issue.id,
518 authorId: user.id,
519 body: commentBody,
520 })
521 .returning();
522
523 // Live update: nudge any browser tabs subscribed to this issue. Pure
524 // fanout — never blocks the redirect, never throws into the request.
525 if (inserted) {
526 try {
527 const { publish } = await import("../lib/sse");
528 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
529 event: "issue-comment",
530 data: {
531 issueId: issue.id,
532 commentId: inserted.id,
533 authorId: user.id,
534 authorUsername: user.username,
535 },
536 });
537 } catch {
538 /* SSE is best-effort */
539 }
540 }
79136bbClaude541
542 return c.redirect(
543 `/${ownerName}/${repoName}/issues/${issueNum}`
544 );
545 }
546);
547
548// Close issue
549issueRoutes.post(
550 "/:owner/:repo/issues/:number/close",
551 softAuth,
552 requireAuth,
04f6b7fClaude553 requireRepoAccess("write"),
79136bbClaude554 async (c) => {
555 const { owner: ownerName, repo: repoName } = c.req.param();
556 const issueNum = parseInt(c.req.param("number"), 10);
557
558 const resolved = await resolveRepo(ownerName, repoName);
559 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
560
561 await db
562 .update(issues)
563 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
564 .where(
565 and(
566 eq(issues.repositoryId, resolved.repo.id),
567 eq(issues.number, issueNum)
568 )
569 );
570
571 return c.redirect(
572 `/${ownerName}/${repoName}/issues/${issueNum}`
573 );
574 }
575);
576
577// Reopen issue
578issueRoutes.post(
579 "/:owner/:repo/issues/:number/reopen",
580 softAuth,
581 requireAuth,
04f6b7fClaude582 requireRepoAccess("write"),
79136bbClaude583 async (c) => {
584 const { owner: ownerName, repo: repoName } = c.req.param();
585 const issueNum = parseInt(c.req.param("number"), 10);
586
587 const resolved = await resolveRepo(ownerName, repoName);
588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
589
590 await db
591 .update(issues)
592 .set({ state: "open", closedAt: null, updatedAt: new Date() })
593 .where(
594 and(
595 eq(issues.repositoryId, resolved.repo.id),
596 eq(issues.number, issueNum)
597 )
598 );
599
600 return c.redirect(
601 `/${ownerName}/${repoName}/issues/${issueNum}`
602 );
603 }
604);
605
606// Shared nav component with issues tab
607const IssueNav = ({
608 owner,
609 repo,
610 active,
611}: {
612 owner: string;
613 repo: string;
614 active: "code" | "commits" | "issues";
615}) => (
bb0f894Claude616 <TabNav
617 tabs={[
618 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
619 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
620 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
621 ]}
622 />
79136bbClaude623);
624
58915a9Claude625// Re-run AI triage on demand (e.g. after the issue body has been edited).
626// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
627issueRoutes.post(
628 "/:owner/:repo/issues/:number/ai-retriage",
629 softAuth,
630 requireAuth,
631 requireRepoAccess("write"),
632 async (c) => {
633 const { owner: ownerName, repo: repoName } = c.req.param();
634 const issueNum = parseInt(c.req.param("number"), 10);
635 const user = c.get("user")!;
636 const resolved = await resolveRepo(ownerName, repoName);
637 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
638
639 const [issue] = await db
640 .select()
641 .from(issues)
642 .where(
643 and(
644 eq(issues.repositoryId, resolved.repo.id),
645 eq(issues.number, issueNum)
646 )
647 )
648 .limit(1);
649 if (!issue) {
650 return c.redirect(`/${ownerName}/${repoName}/issues`);
651 }
652
653 triggerIssueTriage(
654 {
655 ownerName,
656 repoName,
657 repositoryId: resolved.repo.id,
658 issueId: issue.id,
659 issueNumber: issue.number,
660 authorId: user.id,
661 title: issue.title,
662 body: issue.body || "",
663 },
664 { force: true }
665 ).catch(() => {});
666
667 return c.redirect(
668 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
669 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
670 )}`
671 );
672 }
673);
674
79136bbClaude675export default issueRoutes;
676export { IssueNav };