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.tsxBlame609 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);
352
353 return c.html(
354 <Layout
355 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
356 user={user}
357 >
358 <RepoHeader owner={ownerName} repo={repoName} />
359 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude360 <div
361 id="live-comment-banner"
362 class="alert"
363 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
364 >
365 <strong class="js-live-count">0</strong> new comment(s) —{" "}
366 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
367 reload to view
368 </a>
369 </div>
370 <script
371 dangerouslySetInnerHTML={{
372 __html: liveCommentBannerScript({
373 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
374 bannerElementId: "live-comment-banner",
375 }),
376 }}
377 />
79136bbClaude378 <div class="issue-detail">
379 <h2>
380 {issue.title}{" "}
bb0f894Claude381 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude382 #{issue.number}
383 </span>
384 </h2>
bb0f894Claude385 <Flex align="center" gap={8} style="margin:8px 0 20px">
386 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude387 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude388 </Badge>
389 <span style="color:var(--text-muted);font-size:14px">
390 <strong style="color:var(--text)">
79136bbClaude391 {author?.username || "unknown"}
392 </strong>{" "}
393 opened this issue {formatRelative(issue.createdAt)}
394 </span>
fbf4aefClaude395 {issue.state === "open" && user && user.id === resolved.owner.id && (
396 <a
397 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
398 class="btn btn-primary"
399 style="margin-left:auto;font-size:13px;padding:4px 10px"
400 title="Generate a draft pull request from this issue using Claude"
401 >
402 Build with AI
403 </a>
404 )}
bb0f894Claude405 </Flex>
79136bbClaude406
407 {issue.body && (
bb0f894Claude408 <CommentBox
409 author={author?.username || "unknown"}
410 date={issue.createdAt}
411 body={renderMarkdown(issue.body)}
412 />
79136bbClaude413 )}
414
415 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude416 <CommentBox
417 author={commentAuthor.username}
418 date={comment.createdAt}
419 body={renderMarkdown(comment.body)}
420 />
79136bbClaude421 ))}
422
423 {user && (
424 <div style="margin-top: 20px">
425 <form
cce7944Claude426 method="post"
79136bbClaude427 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
428 >
429 <div class="form-group">
430 <textarea
431 name="body"
432 rows={6}
433 required
434 placeholder="Leave a comment... (Markdown supported)"
435 style="font-family: var(--font-mono); font-size: 13px"
436 />
437 </div>
438 <div style="display: flex; gap: 8px">
439 <button type="submit" class="btn btn-primary">
440 Comment
441 </button>
442 {canManage && (
443 <button
444 type="submit"
445 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
446 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
447 >
448 {issue.state === "open"
449 ? "Close issue"
450 : "Reopen issue"}
451 </button>
452 )}
453 </div>
454 </form>
455 </div>
456 )}
457 </div>
458 </Layout>
459 );
460});
461
462// Add comment
463issueRoutes.post(
464 "/:owner/:repo/issues/:number/comment",
465 softAuth,
466 requireAuth,
04f6b7fClaude467 requireRepoAccess("write"),
79136bbClaude468 async (c) => {
469 const { owner: ownerName, repo: repoName } = c.req.param();
470 const issueNum = parseInt(c.req.param("number"), 10);
471 const user = c.get("user")!;
472 const body = await c.req.parseBody();
473 const commentBody = String(body.body || "").trim();
474
475 if (!commentBody) {
476 return c.redirect(
477 `/${ownerName}/${repoName}/issues/${issueNum}`
478 );
479 }
480
481 const resolved = await resolveRepo(ownerName, repoName);
482 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
483
484 const [issue] = await db
485 .select()
486 .from(issues)
487 .where(
488 and(
489 eq(issues.repositoryId, resolved.repo.id),
490 eq(issues.number, issueNum)
491 )
492 )
493 .limit(1);
494
495 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
496
d4ac5c3Claude497 const [inserted] = await db
498 .insert(issueComments)
499 .values({
500 issueId: issue.id,
501 authorId: user.id,
502 body: commentBody,
503 })
504 .returning();
505
506 // Live update: nudge any browser tabs subscribed to this issue. Pure
507 // fanout — never blocks the redirect, never throws into the request.
508 if (inserted) {
509 try {
510 const { publish } = await import("../lib/sse");
511 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
512 event: "issue-comment",
513 data: {
514 issueId: issue.id,
515 commentId: inserted.id,
516 authorId: user.id,
517 authorUsername: user.username,
518 },
519 });
520 } catch {
521 /* SSE is best-effort */
522 }
523 }
79136bbClaude524
525 return c.redirect(
526 `/${ownerName}/${repoName}/issues/${issueNum}`
527 );
528 }
529);
530
531// Close issue
532issueRoutes.post(
533 "/:owner/:repo/issues/:number/close",
534 softAuth,
535 requireAuth,
04f6b7fClaude536 requireRepoAccess("write"),
79136bbClaude537 async (c) => {
538 const { owner: ownerName, repo: repoName } = c.req.param();
539 const issueNum = parseInt(c.req.param("number"), 10);
540
541 const resolved = await resolveRepo(ownerName, repoName);
542 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
543
544 await db
545 .update(issues)
546 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
547 .where(
548 and(
549 eq(issues.repositoryId, resolved.repo.id),
550 eq(issues.number, issueNum)
551 )
552 );
553
554 return c.redirect(
555 `/${ownerName}/${repoName}/issues/${issueNum}`
556 );
557 }
558);
559
560// Reopen issue
561issueRoutes.post(
562 "/:owner/:repo/issues/:number/reopen",
563 softAuth,
564 requireAuth,
04f6b7fClaude565 requireRepoAccess("write"),
79136bbClaude566 async (c) => {
567 const { owner: ownerName, repo: repoName } = c.req.param();
568 const issueNum = parseInt(c.req.param("number"), 10);
569
570 const resolved = await resolveRepo(ownerName, repoName);
571 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
572
573 await db
574 .update(issues)
575 .set({ state: "open", closedAt: null, updatedAt: new Date() })
576 .where(
577 and(
578 eq(issues.repositoryId, resolved.repo.id),
579 eq(issues.number, issueNum)
580 )
581 );
582
583 return c.redirect(
584 `/${ownerName}/${repoName}/issues/${issueNum}`
585 );
586 }
587);
588
589// Shared nav component with issues tab
590const IssueNav = ({
591 owner,
592 repo,
593 active,
594}: {
595 owner: string;
596 repo: string;
597 active: "code" | "commits" | "issues";
598}) => (
bb0f894Claude599 <TabNav
600 tabs={[
601 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
602 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
603 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
604 ]}
605 />
79136bbClaude606);
607
608export default issueRoutes;
609export { IssueNav };