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.tsxBlame509 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";
18import { renderMarkdown } from "../lib/markdown";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
bb0f894Claude21import {
22 Flex,
23 Container,
24 PageHeader,
25 Form,
26 FormGroup,
27 Input,
28 TextArea,
29 Button,
30 LinkButton,
31 Badge,
32 EmptyState,
33 TabNav,
34 FilterTabs,
35 List,
36 ListItem,
37 Alert,
38 CommentBox,
39 CommentForm,
40 formatRelative,
41} from "../views/ui";
79136bbClaude42
43const issueRoutes = new Hono<AuthEnv>();
44
45// Helper to resolve repo from :owner/:repo params
46async function resolveRepo(ownerName: string, repoName: string) {
47 const [owner] = await db
48 .select()
49 .from(users)
50 .where(eq(users.username, ownerName))
51 .limit(1);
52 if (!owner) return null;
53
54 const [repo] = await db
55 .select()
56 .from(repositories)
57 .where(
58 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
59 )
60 .limit(1);
61 if (!repo) return null;
62
63 return { owner, repo };
64}
65
66// Issue list
67issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
68 const { owner: ownerName, repo: repoName } = c.req.param();
69 const user = c.get("user");
70 const state = c.req.query("state") || "open";
71
72 const resolved = await resolveRepo(ownerName, repoName);
73 if (!resolved) {
74 return c.html(
75 <Layout title="Not Found" user={user}>
bb0f894Claude76 <EmptyState title="Repository not found" />
79136bbClaude77 </Layout>,
78 404
79 );
80 }
81
82 const { repo } = resolved;
83
84 const issueList = await db
85 .select({
86 issue: issues,
87 author: { username: users.username },
88 })
89 .from(issues)
90 .innerJoin(users, eq(issues.authorId, users.id))
91 .where(
92 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
93 )
94 .orderBy(desc(issues.createdAt));
95
96 // Count open/closed
97 const [counts] = await db
98 .select({
99 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
100 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
101 })
102 .from(issues)
103 .where(eq(issues.repositoryId, repo.id));
104
105 return c.html(
106 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
107 <RepoHeader owner={ownerName} repo={repoName} />
108 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude109 <Flex justify="space-between" align="center" style="margin-bottom:16px">
110 <FilterTabs
111 tabs={[
112 {
113 label: `${counts?.open ?? 0} Open`,
114 href: `/${ownerName}/${repoName}/issues?state=open`,
115 active: state === "open",
116 },
117 {
118 label: `${counts?.closed ?? 0} Closed`,
119 href: `/${ownerName}/${repoName}/issues?state=closed`,
120 active: state === "closed",
121 },
122 ]}
123 />
79136bbClaude124 {user && (
bb0f894Claude125 <LinkButton
79136bbClaude126 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude127 variant="primary"
79136bbClaude128 >
129 New issue
bb0f894Claude130 </LinkButton>
79136bbClaude131 )}
bb0f894Claude132 </Flex>
79136bbClaude133 {issueList.length === 0 ? (
bb0f894Claude134 <EmptyState>
79136bbClaude135 <p>
136 No {state} issues.
137 {state === "closed" && (
138 <span>
139 {" "}
140 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
141 View open issues
142 </a>
143 </span>
144 )}
145 </p>
bb0f894Claude146 </EmptyState>
79136bbClaude147 ) : (
bb0f894Claude148 <List>
79136bbClaude149 {issueList.map(({ issue, author }) => (
bb0f894Claude150 <ListItem>
79136bbClaude151 <div
152 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
153 >
154 {issue.state === "open" ? "\u25CB" : "\u2713"}
155 </div>
156 <div>
157 <div class="issue-title">
158 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
159 {issue.title}
160 </a>
161 </div>
162 <div class="issue-meta">
163 #{issue.number} opened by {author.username}{" "}
164 {formatRelative(issue.createdAt)}
165 </div>
166 </div>
bb0f894Claude167 </ListItem>
79136bbClaude168 ))}
bb0f894Claude169 </List>
79136bbClaude170 )}
171 </Layout>
172 );
173});
174
175// New issue form
176issueRoutes.get(
177 "/:owner/:repo/issues/new",
178 softAuth,
179 requireAuth,
180 async (c) => {
181 const { owner: ownerName, repo: repoName } = c.req.param();
182 const user = c.get("user")!;
183 const error = c.req.query("error");
184
185 return c.html(
186 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
187 <RepoHeader owner={ownerName} repo={repoName} />
188 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude189 <Container maxWidth={800}>
190 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude191 {error && (
bb0f894Claude192 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude193 )}
bb0f894Claude194 <Form action={`/${ownerName}/${repoName}/issues/new`} method="POST">
195 <FormGroup>
196 <Input
79136bbClaude197 name="title"
198 required
199 placeholder="Title"
bb0f894Claude200 style="font-size:16px;padding:10px 14px"
79136bbClaude201 />
bb0f894Claude202 </FormGroup>
203 <FormGroup>
204 <TextArea
79136bbClaude205 name="body"
206 rows={12}
207 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude208 mono
79136bbClaude209 />
bb0f894Claude210 </FormGroup>
211 <Button type="submit" variant="primary">
79136bbClaude212 Submit new issue
bb0f894Claude213 </Button>
214 </Form>
215 </Container>
79136bbClaude216 </Layout>
217 );
218 }
219);
220
221// Create issue
222issueRoutes.post(
223 "/:owner/:repo/issues/new",
224 softAuth,
225 requireAuth,
226 async (c) => {
227 const { owner: ownerName, repo: repoName } = c.req.param();
228 const user = c.get("user")!;
229 const body = await c.req.parseBody();
230 const title = String(body.title || "").trim();
231 const issueBody = String(body.body || "").trim();
232
233 if (!title) {
234 return c.redirect(
235 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
236 );
237 }
238
239 const resolved = await resolveRepo(ownerName, repoName);
240 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
241
242 const [issue] = await db
243 .insert(issues)
244 .values({
245 repositoryId: resolved.repo.id,
246 authorId: user.id,
247 title,
248 body: issueBody || null,
249 })
250 .returning();
251
252 // Update issue count
253 await db
254 .update(repositories)
255 .set({ issueCount: resolved.repo.issueCount + 1 })
256 .where(eq(repositories.id, resolved.repo.id));
257
258 return c.redirect(
259 `/${ownerName}/${repoName}/issues/${issue.number}`
260 );
261 }
262);
263
264// View single issue
265issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
266 const { owner: ownerName, repo: repoName } = c.req.param();
267 const issueNum = parseInt(c.req.param("number"), 10);
268 const user = c.get("user");
269
270 const resolved = await resolveRepo(ownerName, repoName);
271 if (!resolved) {
272 return c.html(
273 <Layout title="Not Found" user={user}>
bb0f894Claude274 <EmptyState title="Not found" />
79136bbClaude275 </Layout>,
276 404
277 );
278 }
279
280 const [issue] = await db
281 .select()
282 .from(issues)
283 .where(
284 and(
285 eq(issues.repositoryId, resolved.repo.id),
286 eq(issues.number, issueNum)
287 )
288 )
289 .limit(1);
290
291 if (!issue) {
292 return c.html(
293 <Layout title="Not Found" user={user}>
bb0f894Claude294 <EmptyState title="Issue not found" />
79136bbClaude295 </Layout>,
296 404
297 );
298 }
299
300 const [author] = await db
301 .select()
302 .from(users)
303 .where(eq(users.id, issue.authorId))
304 .limit(1);
305
306 // Get comments
307 const comments = await db
308 .select({
309 comment: issueComments,
310 author: { username: users.username },
311 })
312 .from(issueComments)
313 .innerJoin(users, eq(issueComments.authorId, users.id))
314 .where(eq(issueComments.issueId, issue.id))
315 .orderBy(asc(issueComments.createdAt));
316
317 const canManage =
318 user &&
319 (user.id === resolved.owner.id || user.id === issue.authorId);
320
321 return c.html(
322 <Layout
323 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
324 user={user}
325 >
326 <RepoHeader owner={ownerName} repo={repoName} />
327 <IssueNav owner={ownerName} repo={repoName} active="issues" />
328 <div class="issue-detail">
329 <h2>
330 {issue.title}{" "}
bb0f894Claude331 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude332 #{issue.number}
333 </span>
334 </h2>
bb0f894Claude335 <Flex align="center" gap={8} style="margin:8px 0 20px">
336 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude337 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude338 </Badge>
339 <span style="color:var(--text-muted);font-size:14px">
340 <strong style="color:var(--text)">
79136bbClaude341 {author?.username || "unknown"}
342 </strong>{" "}
343 opened this issue {formatRelative(issue.createdAt)}
344 </span>
bb0f894Claude345 </Flex>
79136bbClaude346
347 {issue.body && (
bb0f894Claude348 <CommentBox
349 author={author?.username || "unknown"}
350 date={issue.createdAt}
351 body={renderMarkdown(issue.body)}
352 />
79136bbClaude353 )}
354
355 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude356 <CommentBox
357 author={commentAuthor.username}
358 date={comment.createdAt}
359 body={renderMarkdown(comment.body)}
360 />
79136bbClaude361 ))}
362
363 {user && (
bb0f894Claude364 <CommentForm
365 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
366 submitLabel="Comment"
367 extraActions={
368 canManage && (
369 <Button
370 type="submit"
371 variant={issue.state === "open" ? "danger" : "default"}
372 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
373 >
374 {issue.state === "open"
375 ? "Close issue"
376 : "Reopen issue"}
377 </Button>
378 )
379 }
380 />
79136bbClaude381 )}
382 </div>
383 </Layout>
384 );
385});
386
387// Add comment
388issueRoutes.post(
389 "/:owner/:repo/issues/:number/comment",
390 softAuth,
391 requireAuth,
392 async (c) => {
393 const { owner: ownerName, repo: repoName } = c.req.param();
394 const issueNum = parseInt(c.req.param("number"), 10);
395 const user = c.get("user")!;
396 const body = await c.req.parseBody();
397 const commentBody = String(body.body || "").trim();
398
399 if (!commentBody) {
400 return c.redirect(
401 `/${ownerName}/${repoName}/issues/${issueNum}`
402 );
403 }
404
405 const resolved = await resolveRepo(ownerName, repoName);
406 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
407
408 const [issue] = await db
409 .select()
410 .from(issues)
411 .where(
412 and(
413 eq(issues.repositoryId, resolved.repo.id),
414 eq(issues.number, issueNum)
415 )
416 )
417 .limit(1);
418
419 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
420
421 await db.insert(issueComments).values({
422 issueId: issue.id,
423 authorId: user.id,
424 body: commentBody,
425 });
426
427 return c.redirect(
428 `/${ownerName}/${repoName}/issues/${issueNum}`
429 );
430 }
431);
432
433// Close issue
434issueRoutes.post(
435 "/:owner/:repo/issues/:number/close",
436 softAuth,
437 requireAuth,
438 async (c) => {
439 const { owner: ownerName, repo: repoName } = c.req.param();
440 const issueNum = parseInt(c.req.param("number"), 10);
441
442 const resolved = await resolveRepo(ownerName, repoName);
443 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
444
445 await db
446 .update(issues)
447 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
448 .where(
449 and(
450 eq(issues.repositoryId, resolved.repo.id),
451 eq(issues.number, issueNum)
452 )
453 );
454
455 return c.redirect(
456 `/${ownerName}/${repoName}/issues/${issueNum}`
457 );
458 }
459);
460
461// Reopen issue
462issueRoutes.post(
463 "/:owner/:repo/issues/:number/reopen",
464 softAuth,
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const issueNum = parseInt(c.req.param("number"), 10);
469
470 const resolved = await resolveRepo(ownerName, repoName);
471 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
472
473 await db
474 .update(issues)
475 .set({ state: "open", closedAt: null, updatedAt: new Date() })
476 .where(
477 and(
478 eq(issues.repositoryId, resolved.repo.id),
479 eq(issues.number, issueNum)
480 )
481 );
482
483 return c.redirect(
484 `/${ownerName}/${repoName}/issues/${issueNum}`
485 );
486 }
487);
488
489// Shared nav component with issues tab
490const IssueNav = ({
491 owner,
492 repo,
493 active,
494}: {
495 owner: string;
496 repo: string;
497 active: "code" | "commits" | "issues";
498}) => (
bb0f894Claude499 <TabNav
500 tabs={[
501 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
502 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
503 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
504 ]}
505 />
79136bbClaude506);
507
508export default issueRoutes;
509export { IssueNav };