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.tsxBlame521 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";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
bb0f894Claude24import {
25 Flex,
26 Container,
27 PageHeader,
28 Form,
29 FormGroup,
30 Input,
31 TextArea,
32 Button,
33 LinkButton,
34 Badge,
35 EmptyState,
36 TabNav,
37 FilterTabs,
38 List,
39 ListItem,
40 Alert,
41 CommentBox,
42 CommentForm,
43 formatRelative,
44} from "../views/ui";
79136bbClaude45
46const issueRoutes = new Hono<AuthEnv>();
47
48// Helper to resolve repo from :owner/:repo params
49async function resolveRepo(ownerName: string, repoName: string) {
50 const [owner] = await db
51 .select()
52 .from(users)
53 .where(eq(users.username, ownerName))
54 .limit(1);
55 if (!owner) return null;
56
57 const [repo] = await db
58 .select()
59 .from(repositories)
60 .where(
61 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
62 )
63 .limit(1);
64 if (!repo) return null;
65
66 return { owner, repo };
67}
68
69// Issue list
70issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
71 const { owner: ownerName, repo: repoName } = c.req.param();
72 const user = c.get("user");
73 const state = c.req.query("state") || "open";
74
75 const resolved = await resolveRepo(ownerName, repoName);
76 if (!resolved) {
77 return c.html(
78 <Layout title="Not Found" user={user}>
bb0f894Claude79 <EmptyState title="Repository not found" />
79136bbClaude80 </Layout>,
81 404
82 );
83 }
84
85 const { repo } = resolved;
86
87 const issueList = await db
88 .select({
89 issue: issues,
90 author: { username: users.username },
91 })
92 .from(issues)
93 .innerJoin(users, eq(issues.authorId, users.id))
94 .where(
95 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
96 )
97 .orderBy(desc(issues.createdAt));
98
99 // Count open/closed
100 const [counts] = await db
101 .select({
102 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
103 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
104 })
105 .from(issues)
106 .where(eq(issues.repositoryId, repo.id));
107
108 return c.html(
109 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
110 <RepoHeader owner={ownerName} repo={repoName} />
111 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude112 <Flex justify="space-between" align="center" style="margin-bottom:16px">
113 <FilterTabs
114 tabs={[
115 {
116 label: `${counts?.open ?? 0} Open`,
117 href: `/${ownerName}/${repoName}/issues?state=open`,
118 active: state === "open",
119 },
120 {
121 label: `${counts?.closed ?? 0} Closed`,
122 href: `/${ownerName}/${repoName}/issues?state=closed`,
123 active: state === "closed",
124 },
125 ]}
126 />
79136bbClaude127 {user && (
bb0f894Claude128 <LinkButton
79136bbClaude129 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude130 variant="primary"
79136bbClaude131 >
132 New issue
bb0f894Claude133 </LinkButton>
79136bbClaude134 )}
bb0f894Claude135 </Flex>
79136bbClaude136 {issueList.length === 0 ? (
bb0f894Claude137 <EmptyState>
79136bbClaude138 <p>
139 No {state} issues.
140 {state === "closed" && (
141 <span>
142 {" "}
143 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
144 View open issues
145 </a>
146 </span>
147 )}
148 </p>
bb0f894Claude149 </EmptyState>
79136bbClaude150 ) : (
bb0f894Claude151 <List>
79136bbClaude152 {issueList.map(({ issue, author }) => (
bb0f894Claude153 <ListItem>
79136bbClaude154 <div
155 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
156 >
157 {issue.state === "open" ? "\u25CB" : "\u2713"}
158 </div>
159 <div>
160 <div class="issue-title">
161 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
162 {issue.title}
163 </a>
164 </div>
165 <div class="issue-meta">
166 #{issue.number} opened by {author.username}{" "}
167 {formatRelative(issue.createdAt)}
168 </div>
169 </div>
bb0f894Claude170 </ListItem>
79136bbClaude171 ))}
bb0f894Claude172 </List>
79136bbClaude173 )}
174 </Layout>
175 );
176});
177
178// New issue form
179issueRoutes.get(
180 "/:owner/:repo/issues/new",
181 softAuth,
182 requireAuth,
183 async (c) => {
184 const { owner: ownerName, repo: repoName } = c.req.param();
185 const user = c.get("user")!;
186 const error = c.req.query("error");
24cf2caClaude187 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude188
189 return c.html(
190 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
191 <RepoHeader owner={ownerName} repo={repoName} />
192 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude193 <Container maxWidth={800}>
194 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude195 {error && (
bb0f894Claude196 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude197 )}
bb0f894Claude198 <Form action={`/${ownerName}/${repoName}/issues/new`} method="POST">
199 <FormGroup>
200 <Input
79136bbClaude201 name="title"
202 required
203 placeholder="Title"
bb0f894Claude204 style="font-size:16px;padding:10px 14px"
79136bbClaude205 />
bb0f894Claude206 </FormGroup>
207 <FormGroup>
208 <TextArea
79136bbClaude209 name="body"
210 rows={12}
211 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude212 mono
79136bbClaude213 />
bb0f894Claude214 </FormGroup>
215 <Button type="submit" variant="primary">
79136bbClaude216 Submit new issue
bb0f894Claude217 </Button>
218 </Form>
219 </Container>
79136bbClaude220 </Layout>
221 );
222 }
223);
224
225// Create issue
226issueRoutes.post(
227 "/:owner/:repo/issues/new",
228 softAuth,
229 requireAuth,
230 async (c) => {
231 const { owner: ownerName, repo: repoName } = c.req.param();
232 const user = c.get("user")!;
233 const body = await c.req.parseBody();
234 const title = String(body.title || "").trim();
235 const issueBody = String(body.body || "").trim();
236
237 if (!title) {
238 return c.redirect(
239 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
240 );
241 }
242
243 const resolved = await resolveRepo(ownerName, repoName);
244 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
245
246 const [issue] = await db
247 .insert(issues)
248 .values({
249 repositoryId: resolved.repo.id,
250 authorId: user.id,
251 title,
252 body: issueBody || null,
253 })
254 .returning();
255
256 // Update issue count
257 await db
258 .update(repositories)
259 .set({ issueCount: resolved.repo.issueCount + 1 })
260 .where(eq(repositories.id, resolved.repo.id));
261
262 return c.redirect(
263 `/${ownerName}/${repoName}/issues/${issue.number}`
264 );
265 }
266);
267
268// View single issue
269issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
270 const { owner: ownerName, repo: repoName } = c.req.param();
271 const issueNum = parseInt(c.req.param("number"), 10);
272 const user = c.get("user");
273
274 const resolved = await resolveRepo(ownerName, repoName);
275 if (!resolved) {
276 return c.html(
277 <Layout title="Not Found" user={user}>
bb0f894Claude278 <EmptyState title="Not found" />
79136bbClaude279 </Layout>,
280 404
281 );
282 }
283
284 const [issue] = await db
285 .select()
286 .from(issues)
287 .where(
288 and(
289 eq(issues.repositoryId, resolved.repo.id),
290 eq(issues.number, issueNum)
291 )
292 )
293 .limit(1);
294
295 if (!issue) {
296 return c.html(
297 <Layout title="Not Found" user={user}>
bb0f894Claude298 <EmptyState title="Issue not found" />
79136bbClaude299 </Layout>,
300 404
301 );
302 }
303
304 const [author] = await db
305 .select()
306 .from(users)
307 .where(eq(users.id, issue.authorId))
308 .limit(1);
309
310 // Get comments
311 const comments = await db
312 .select({
313 comment: issueComments,
314 author: { username: users.username },
315 })
316 .from(issueComments)
317 .innerJoin(users, eq(issueComments.authorId, users.id))
318 .where(eq(issueComments.issueId, issue.id))
319 .orderBy(asc(issueComments.createdAt));
320
6fc53bdClaude321 // Load reactions for the issue + each comment in parallel.
322 const [issueReactions, ...commentReactions] = await Promise.all([
323 summariseReactions("issue", issue.id, user?.id),
324 ...comments.map((row) =>
325 summariseReactions("issue_comment", row.comment.id, user?.id)
326 ),
327 ]);
328
79136bbClaude329 const canManage =
330 user &&
331 (user.id === resolved.owner.id || user.id === issue.authorId);
332
333 return c.html(
334 <Layout
335 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
336 user={user}
337 >
338 <RepoHeader owner={ownerName} repo={repoName} />
339 <IssueNav owner={ownerName} repo={repoName} active="issues" />
340 <div class="issue-detail">
341 <h2>
342 {issue.title}{" "}
bb0f894Claude343 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude344 #{issue.number}
345 </span>
346 </h2>
bb0f894Claude347 <Flex align="center" gap={8} style="margin:8px 0 20px">
348 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude349 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude350 </Badge>
351 <span style="color:var(--text-muted);font-size:14px">
352 <strong style="color:var(--text)">
79136bbClaude353 {author?.username || "unknown"}
354 </strong>{" "}
355 opened this issue {formatRelative(issue.createdAt)}
356 </span>
bb0f894Claude357 </Flex>
79136bbClaude358
359 {issue.body && (
bb0f894Claude360 <CommentBox
361 author={author?.username || "unknown"}
362 date={issue.createdAt}
363 body={renderMarkdown(issue.body)}
364 />
79136bbClaude365 )}
366
367 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude368 <CommentBox
369 author={commentAuthor.username}
370 date={comment.createdAt}
371 body={renderMarkdown(comment.body)}
372 />
79136bbClaude373 ))}
374
375 {user && (
bb0f894Claude376 <CommentForm
377 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
378 submitLabel="Comment"
379 extraActions={
380 canManage && (
381 <Button
382 type="submit"
383 variant={issue.state === "open" ? "danger" : "default"}
384 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
385 >
386 {issue.state === "open"
387 ? "Close issue"
388 : "Reopen issue"}
389 </Button>
390 )
391 }
392 />
79136bbClaude393 )}
394 </div>
395 </Layout>
396 );
397});
398
399// Add comment
400issueRoutes.post(
401 "/:owner/:repo/issues/:number/comment",
402 softAuth,
403 requireAuth,
404 async (c) => {
405 const { owner: ownerName, repo: repoName } = c.req.param();
406 const issueNum = parseInt(c.req.param("number"), 10);
407 const user = c.get("user")!;
408 const body = await c.req.parseBody();
409 const commentBody = String(body.body || "").trim();
410
411 if (!commentBody) {
412 return c.redirect(
413 `/${ownerName}/${repoName}/issues/${issueNum}`
414 );
415 }
416
417 const resolved = await resolveRepo(ownerName, repoName);
418 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
419
420 const [issue] = await db
421 .select()
422 .from(issues)
423 .where(
424 and(
425 eq(issues.repositoryId, resolved.repo.id),
426 eq(issues.number, issueNum)
427 )
428 )
429 .limit(1);
430
431 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
432
433 await db.insert(issueComments).values({
434 issueId: issue.id,
435 authorId: user.id,
436 body: commentBody,
437 });
438
439 return c.redirect(
440 `/${ownerName}/${repoName}/issues/${issueNum}`
441 );
442 }
443);
444
445// Close issue
446issueRoutes.post(
447 "/:owner/:repo/issues/:number/close",
448 softAuth,
449 requireAuth,
450 async (c) => {
451 const { owner: ownerName, repo: repoName } = c.req.param();
452 const issueNum = parseInt(c.req.param("number"), 10);
453
454 const resolved = await resolveRepo(ownerName, repoName);
455 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
456
457 await db
458 .update(issues)
459 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
460 .where(
461 and(
462 eq(issues.repositoryId, resolved.repo.id),
463 eq(issues.number, issueNum)
464 )
465 );
466
467 return c.redirect(
468 `/${ownerName}/${repoName}/issues/${issueNum}`
469 );
470 }
471);
472
473// Reopen issue
474issueRoutes.post(
475 "/:owner/:repo/issues/:number/reopen",
476 softAuth,
477 requireAuth,
478 async (c) => {
479 const { owner: ownerName, repo: repoName } = c.req.param();
480 const issueNum = parseInt(c.req.param("number"), 10);
481
482 const resolved = await resolveRepo(ownerName, repoName);
483 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
484
485 await db
486 .update(issues)
487 .set({ state: "open", closedAt: null, updatedAt: new Date() })
488 .where(
489 and(
490 eq(issues.repositoryId, resolved.repo.id),
491 eq(issues.number, issueNum)
492 )
493 );
494
495 return c.redirect(
496 `/${ownerName}/${repoName}/issues/${issueNum}`
497 );
498 }
499);
500
501// Shared nav component with issues tab
502const IssueNav = ({
503 owner,
504 repo,
505 active,
506}: {
507 owner: string;
508 repo: string;
509 active: "code" | "commits" | "issues";
510}) => (
bb0f894Claude511 <TabNav
512 tabs={[
513 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
514 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
515 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
516 ]}
517 />
79136bbClaude518);
519
520export default issueRoutes;
521export { IssueNav };