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.tsxBlame594 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";
79136bbClaude23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude25import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude26import {
27 Flex,
28 Container,
29 PageHeader,
30 Form,
31 FormGroup,
32 Input,
33 TextArea,
34 Button,
35 LinkButton,
36 Badge,
37 EmptyState,
38 TabNav,
39 FilterTabs,
40 List,
41 ListItem,
42 Alert,
43 CommentBox,
44 CommentForm,
45 formatRelative,
46} from "../views/ui";
79136bbClaude47
48const issueRoutes = new Hono<AuthEnv>();
49
50// Helper to resolve repo from :owner/:repo params
51async function resolveRepo(ownerName: string, repoName: string) {
52 const [owner] = await db
53 .select()
54 .from(users)
55 .where(eq(users.username, ownerName))
56 .limit(1);
57 if (!owner) return null;
58
59 const [repo] = await db
60 .select()
61 .from(repositories)
62 .where(
63 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
64 )
65 .limit(1);
66 if (!repo) return null;
67
68 return { owner, repo };
69}
70
71// Issue list
04f6b7fClaude72issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude73 const { owner: ownerName, repo: repoName } = c.req.param();
74 const user = c.get("user");
75 const state = c.req.query("state") || "open";
76
77 const resolved = await resolveRepo(ownerName, repoName);
78 if (!resolved) {
79 return c.html(
80 <Layout title="Not Found" user={user}>
bb0f894Claude81 <EmptyState title="Repository not found" />
79136bbClaude82 </Layout>,
83 404
84 );
85 }
86
87 const { repo } = resolved;
88
89 const issueList = await db
90 .select({
91 issue: issues,
92 author: { username: users.username },
93 })
94 .from(issues)
95 .innerJoin(users, eq(issues.authorId, users.id))
96 .where(
97 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
98 )
99 .orderBy(desc(issues.createdAt));
100
101 // Count open/closed
102 const [counts] = await db
103 .select({
104 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
105 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
106 })
107 .from(issues)
108 .where(eq(issues.repositoryId, repo.id));
109
110 return c.html(
111 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
112 <RepoHeader owner={ownerName} repo={repoName} />
113 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude114 <Flex justify="space-between" align="center" style="margin-bottom:16px">
115 <FilterTabs
116 tabs={[
117 {
118 label: `${counts?.open ?? 0} Open`,
119 href: `/${ownerName}/${repoName}/issues?state=open`,
120 active: state === "open",
121 },
122 {
123 label: `${counts?.closed ?? 0} Closed`,
124 href: `/${ownerName}/${repoName}/issues?state=closed`,
125 active: state === "closed",
126 },
127 ]}
128 />
79136bbClaude129 {user && (
bb0f894Claude130 <LinkButton
79136bbClaude131 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude132 variant="primary"
79136bbClaude133 >
134 New issue
bb0f894Claude135 </LinkButton>
79136bbClaude136 )}
bb0f894Claude137 </Flex>
79136bbClaude138 {issueList.length === 0 ? (
bb0f894Claude139 <EmptyState>
79136bbClaude140 <p>
141 No {state} issues.
142 {state === "closed" && (
143 <span>
144 {" "}
145 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
146 View open issues
147 </a>
148 </span>
149 )}
150 </p>
bb0f894Claude151 </EmptyState>
79136bbClaude152 ) : (
bb0f894Claude153 <List>
79136bbClaude154 {issueList.map(({ issue, author }) => (
bb0f894Claude155 <ListItem>
79136bbClaude156 <div
157 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
158 >
159 {issue.state === "open" ? "\u25CB" : "\u2713"}
160 </div>
161 <div>
162 <div class="issue-title">
163 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
164 {issue.title}
165 </a>
166 </div>
167 <div class="issue-meta">
168 #{issue.number} opened by {author.username}{" "}
169 {formatRelative(issue.createdAt)}
170 </div>
171 </div>
bb0f894Claude172 </ListItem>
79136bbClaude173 ))}
bb0f894Claude174 </List>
79136bbClaude175 )}
176 </Layout>
177 );
178});
179
180// New issue form
181issueRoutes.get(
182 "/:owner/:repo/issues/new",
183 softAuth,
184 requireAuth,
04f6b7fClaude185 requireRepoAccess("write"),
79136bbClaude186 async (c) => {
187 const { owner: ownerName, repo: repoName } = c.req.param();
188 const user = c.get("user")!;
189 const error = c.req.query("error");
24cf2caClaude190 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude191
192 return c.html(
193 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
194 <RepoHeader owner={ownerName} repo={repoName} />
195 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude196 <Container maxWidth={800}>
197 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude198 {error && (
bb0f894Claude199 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude200 )}
0316dbbClaude201 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
202 <FormGroup>
203 <Input
79136bbClaude204 type="text"
205 name="title"
206 required
207 placeholder="Title"
bb0f894Claude208 style="font-size:16px;padding:10px 14px"
79136bbClaude209 />
bb0f894Claude210 </FormGroup>
211 <FormGroup>
212 <TextArea
79136bbClaude213 name="body"
214 rows={12}
215 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude216 mono
79136bbClaude217 />
bb0f894Claude218 </FormGroup>
219 <Button type="submit" variant="primary">
79136bbClaude220 Submit new issue
bb0f894Claude221 </Button>
222 </Form>
223 </Container>
79136bbClaude224 </Layout>
225 );
226 }
227);
228
229// Create issue
230issueRoutes.post(
231 "/:owner/:repo/issues/new",
232 softAuth,
233 requireAuth,
04f6b7fClaude234 requireRepoAccess("write"),
79136bbClaude235 async (c) => {
236 const { owner: ownerName, repo: repoName } = c.req.param();
237 const user = c.get("user")!;
238 const body = await c.req.parseBody();
239 const title = String(body.title || "").trim();
240 const issueBody = String(body.body || "").trim();
241
242 if (!title) {
243 return c.redirect(
244 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
245 );
246 }
247
248 const resolved = await resolveRepo(ownerName, repoName);
249 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
250
251 const [issue] = await db
252 .insert(issues)
253 .values({
254 repositoryId: resolved.repo.id,
255 authorId: user.id,
256 title,
257 body: issueBody || null,
258 })
259 .returning();
260
261 // Update issue count
262 await db
263 .update(repositories)
264 .set({ issueCount: resolved.repo.issueCount + 1 })
265 .where(eq(repositories.id, resolved.repo.id));
266
267 return c.redirect(
268 `/${ownerName}/${repoName}/issues/${issue.number}`
269 );
270 }
271);
272
273// View single issue
04f6b7fClaude274issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const issueNum = parseInt(c.req.param("number"), 10);
277 const user = c.get("user");
278
279 const resolved = await resolveRepo(ownerName, repoName);
280 if (!resolved) {
281 return c.html(
282 <Layout title="Not Found" user={user}>
bb0f894Claude283 <EmptyState title="Not found" />
79136bbClaude284 </Layout>,
285 404
286 );
287 }
288
289 const [issue] = await db
290 .select()
291 .from(issues)
292 .where(
293 and(
294 eq(issues.repositoryId, resolved.repo.id),
295 eq(issues.number, issueNum)
296 )
297 )
298 .limit(1);
299
300 if (!issue) {
301 return c.html(
302 <Layout title="Not Found" user={user}>
bb0f894Claude303 <EmptyState title="Issue not found" />
79136bbClaude304 </Layout>,
305 404
306 );
307 }
308
309 const [author] = await db
310 .select()
311 .from(users)
312 .where(eq(users.id, issue.authorId))
313 .limit(1);
314
315 // Get comments
316 const comments = await db
317 .select({
318 comment: issueComments,
319 author: { username: users.username },
320 })
321 .from(issueComments)
322 .innerJoin(users, eq(issueComments.authorId, users.id))
323 .where(eq(issueComments.issueId, issue.id))
324 .orderBy(asc(issueComments.createdAt));
325
6fc53bdClaude326 // Load reactions for the issue + each comment in parallel.
327 const [issueReactions, ...commentReactions] = await Promise.all([
328 summariseReactions("issue", issue.id, user?.id),
329 ...comments.map((row) =>
330 summariseReactions("issue_comment", row.comment.id, user?.id)
331 ),
332 ]);
333
79136bbClaude334 const canManage =
335 user &&
336 (user.id === resolved.owner.id || user.id === issue.authorId);
337
338 return c.html(
339 <Layout
340 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
341 user={user}
342 >
343 <RepoHeader owner={ownerName} repo={repoName} />
344 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude345 <div
346 id="live-comment-banner"
347 class="alert"
348 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
349 >
350 <strong class="js-live-count">0</strong> new comment(s) —{" "}
351 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
352 reload to view
353 </a>
354 </div>
355 <script
356 dangerouslySetInnerHTML={{
357 __html: liveCommentBannerScript({
358 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
359 bannerElementId: "live-comment-banner",
360 }),
361 }}
362 />
79136bbClaude363 <div class="issue-detail">
364 <h2>
365 {issue.title}{" "}
bb0f894Claude366 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude367 #{issue.number}
368 </span>
369 </h2>
bb0f894Claude370 <Flex align="center" gap={8} style="margin:8px 0 20px">
371 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude372 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude373 </Badge>
374 <span style="color:var(--text-muted);font-size:14px">
375 <strong style="color:var(--text)">
79136bbClaude376 {author?.username || "unknown"}
377 </strong>{" "}
378 opened this issue {formatRelative(issue.createdAt)}
379 </span>
fbf4aefClaude380 {issue.state === "open" && user && user.id === resolved.owner.id && (
381 <a
382 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
383 class="btn btn-primary"
384 style="margin-left:auto;font-size:13px;padding:4px 10px"
385 title="Generate a draft pull request from this issue using Claude"
386 >
387 Build with AI
388 </a>
389 )}
bb0f894Claude390 </Flex>
79136bbClaude391
392 {issue.body && (
bb0f894Claude393 <CommentBox
394 author={author?.username || "unknown"}
395 date={issue.createdAt}
396 body={renderMarkdown(issue.body)}
397 />
79136bbClaude398 )}
399
400 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude401 <CommentBox
402 author={commentAuthor.username}
403 date={comment.createdAt}
404 body={renderMarkdown(comment.body)}
405 />
79136bbClaude406 ))}
407
408 {user && (
409 <div style="margin-top: 20px">
410 <form
cce7944Claude411 method="post"
79136bbClaude412 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
413 >
414 <div class="form-group">
415 <textarea
416 name="body"
417 rows={6}
418 required
419 placeholder="Leave a comment... (Markdown supported)"
420 style="font-family: var(--font-mono); font-size: 13px"
421 />
422 </div>
423 <div style="display: flex; gap: 8px">
424 <button type="submit" class="btn btn-primary">
425 Comment
426 </button>
427 {canManage && (
428 <button
429 type="submit"
430 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
431 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
432 >
433 {issue.state === "open"
434 ? "Close issue"
435 : "Reopen issue"}
436 </button>
437 )}
438 </div>
439 </form>
440 </div>
441 )}
442 </div>
443 </Layout>
444 );
445});
446
447// Add comment
448issueRoutes.post(
449 "/:owner/:repo/issues/:number/comment",
450 softAuth,
451 requireAuth,
04f6b7fClaude452 requireRepoAccess("write"),
79136bbClaude453 async (c) => {
454 const { owner: ownerName, repo: repoName } = c.req.param();
455 const issueNum = parseInt(c.req.param("number"), 10);
456 const user = c.get("user")!;
457 const body = await c.req.parseBody();
458 const commentBody = String(body.body || "").trim();
459
460 if (!commentBody) {
461 return c.redirect(
462 `/${ownerName}/${repoName}/issues/${issueNum}`
463 );
464 }
465
466 const resolved = await resolveRepo(ownerName, repoName);
467 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
468
469 const [issue] = await db
470 .select()
471 .from(issues)
472 .where(
473 and(
474 eq(issues.repositoryId, resolved.repo.id),
475 eq(issues.number, issueNum)
476 )
477 )
478 .limit(1);
479
480 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
481
d4ac5c3Claude482 const [inserted] = await db
483 .insert(issueComments)
484 .values({
485 issueId: issue.id,
486 authorId: user.id,
487 body: commentBody,
488 })
489 .returning();
490
491 // Live update: nudge any browser tabs subscribed to this issue. Pure
492 // fanout — never blocks the redirect, never throws into the request.
493 if (inserted) {
494 try {
495 const { publish } = await import("../lib/sse");
496 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
497 event: "issue-comment",
498 data: {
499 issueId: issue.id,
500 commentId: inserted.id,
501 authorId: user.id,
502 authorUsername: user.username,
503 },
504 });
505 } catch {
506 /* SSE is best-effort */
507 }
508 }
79136bbClaude509
510 return c.redirect(
511 `/${ownerName}/${repoName}/issues/${issueNum}`
512 );
513 }
514);
515
516// Close issue
517issueRoutes.post(
518 "/:owner/:repo/issues/:number/close",
519 softAuth,
520 requireAuth,
04f6b7fClaude521 requireRepoAccess("write"),
79136bbClaude522 async (c) => {
523 const { owner: ownerName, repo: repoName } = c.req.param();
524 const issueNum = parseInt(c.req.param("number"), 10);
525
526 const resolved = await resolveRepo(ownerName, repoName);
527 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
528
529 await db
530 .update(issues)
531 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
532 .where(
533 and(
534 eq(issues.repositoryId, resolved.repo.id),
535 eq(issues.number, issueNum)
536 )
537 );
538
539 return c.redirect(
540 `/${ownerName}/${repoName}/issues/${issueNum}`
541 );
542 }
543);
544
545// Reopen issue
546issueRoutes.post(
547 "/:owner/:repo/issues/:number/reopen",
548 softAuth,
549 requireAuth,
04f6b7fClaude550 requireRepoAccess("write"),
79136bbClaude551 async (c) => {
552 const { owner: ownerName, repo: repoName } = c.req.param();
553 const issueNum = parseInt(c.req.param("number"), 10);
554
555 const resolved = await resolveRepo(ownerName, repoName);
556 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
557
558 await db
559 .update(issues)
560 .set({ state: "open", closedAt: null, updatedAt: new Date() })
561 .where(
562 and(
563 eq(issues.repositoryId, resolved.repo.id),
564 eq(issues.number, issueNum)
565 )
566 );
567
568 return c.redirect(
569 `/${ownerName}/${repoName}/issues/${issueNum}`
570 );
571 }
572);
573
574// Shared nav component with issues tab
575const IssueNav = ({
576 owner,
577 repo,
578 active,
579}: {
580 owner: string;
581 repo: string;
582 active: "code" | "commits" | "issues";
583}) => (
bb0f894Claude584 <TabNav
585 tabs={[
586 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
587 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
588 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
589 ]}
590 />
79136bbClaude591);
592
593export default issueRoutes;
594export { IssueNav };