Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame543 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";
04f6b7fClaude24import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude25import {
26 Flex,
27 Container,
28 PageHeader,
29 Form,
30 FormGroup,
31 Input,
32 TextArea,
33 Button,
34 LinkButton,
35 Badge,
36 EmptyState,
37 TabNav,
38 FilterTabs,
39 List,
40 ListItem,
41 Alert,
42 CommentBox,
43 CommentForm,
44 formatRelative,
45} from "../views/ui";
79136bbClaude46
47const issueRoutes = new Hono<AuthEnv>();
48
49// Helper to resolve repo from :owner/:repo params
50async function resolveRepo(ownerName: string, repoName: string) {
51 const [owner] = await db
52 .select()
53 .from(users)
54 .where(eq(users.username, ownerName))
55 .limit(1);
56 if (!owner) return null;
57
58 const [repo] = await db
59 .select()
60 .from(repositories)
61 .where(
62 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
63 )
64 .limit(1);
65 if (!repo) return null;
66
67 return { owner, repo };
68}
69
70// Issue list
04f6b7fClaude71issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude72 const { owner: ownerName, repo: repoName } = c.req.param();
73 const user = c.get("user");
74 const state = c.req.query("state") || "open";
75
76 const resolved = await resolveRepo(ownerName, repoName);
77 if (!resolved) {
78 return c.html(
79 <Layout title="Not Found" user={user}>
bb0f894Claude80 <EmptyState title="Repository not found" />
79136bbClaude81 </Layout>,
82 404
83 );
84 }
85
86 const { repo } = resolved;
87
88 const issueList = await db
89 .select({
90 issue: issues,
91 author: { username: users.username },
92 })
93 .from(issues)
94 .innerJoin(users, eq(issues.authorId, users.id))
95 .where(
96 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
97 )
98 .orderBy(desc(issues.createdAt));
99
100 // Count open/closed
101 const [counts] = await db
102 .select({
103 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
104 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
105 })
106 .from(issues)
107 .where(eq(issues.repositoryId, repo.id));
108
109 return c.html(
110 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
111 <RepoHeader owner={ownerName} repo={repoName} />
112 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude113 <Flex justify="space-between" align="center" style="margin-bottom:16px">
114 <FilterTabs
115 tabs={[
116 {
117 label: `${counts?.open ?? 0} Open`,
118 href: `/${ownerName}/${repoName}/issues?state=open`,
119 active: state === "open",
120 },
121 {
122 label: `${counts?.closed ?? 0} Closed`,
123 href: `/${ownerName}/${repoName}/issues?state=closed`,
124 active: state === "closed",
125 },
126 ]}
127 />
79136bbClaude128 {user && (
bb0f894Claude129 <LinkButton
79136bbClaude130 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude131 variant="primary"
79136bbClaude132 >
133 New issue
bb0f894Claude134 </LinkButton>
79136bbClaude135 )}
bb0f894Claude136 </Flex>
79136bbClaude137 {issueList.length === 0 ? (
bb0f894Claude138 <EmptyState>
79136bbClaude139 <p>
140 No {state} issues.
141 {state === "closed" && (
142 <span>
143 {" "}
144 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
145 View open issues
146 </a>
147 </span>
148 )}
149 </p>
bb0f894Claude150 </EmptyState>
79136bbClaude151 ) : (
bb0f894Claude152 <List>
79136bbClaude153 {issueList.map(({ issue, author }) => (
bb0f894Claude154 <ListItem>
79136bbClaude155 <div
156 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
157 >
158 {issue.state === "open" ? "\u25CB" : "\u2713"}
159 </div>
160 <div>
161 <div class="issue-title">
162 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
163 {issue.title}
164 </a>
165 </div>
166 <div class="issue-meta">
167 #{issue.number} opened by {author.username}{" "}
168 {formatRelative(issue.createdAt)}
169 </div>
170 </div>
bb0f894Claude171 </ListItem>
79136bbClaude172 ))}
bb0f894Claude173 </List>
79136bbClaude174 )}
175 </Layout>
176 );
177});
178
179// New issue form
180issueRoutes.get(
181 "/:owner/:repo/issues/new",
182 softAuth,
183 requireAuth,
04f6b7fClaude184 requireRepoAccess("write"),
79136bbClaude185 async (c) => {
186 const { owner: ownerName, repo: repoName } = c.req.param();
187 const user = c.get("user")!;
188 const error = c.req.query("error");
24cf2caClaude189 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude190
191 return c.html(
192 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
193 <RepoHeader owner={ownerName} repo={repoName} />
194 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude195 <Container maxWidth={800}>
196 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude197 {error && (
bb0f894Claude198 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude199 )}
0316dbbClaude200 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
201 <FormGroup>
202 <Input
79136bbClaude203 type="text"
204 name="title"
205 required
206 placeholder="Title"
bb0f894Claude207 style="font-size:16px;padding:10px 14px"
79136bbClaude208 />
bb0f894Claude209 </FormGroup>
210 <FormGroup>
211 <TextArea
79136bbClaude212 name="body"
213 rows={12}
214 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude215 mono
79136bbClaude216 />
bb0f894Claude217 </FormGroup>
218 <Button type="submit" variant="primary">
79136bbClaude219 Submit new issue
bb0f894Claude220 </Button>
221 </Form>
222 </Container>
79136bbClaude223 </Layout>
224 );
225 }
226);
227
228// Create issue
229issueRoutes.post(
230 "/:owner/:repo/issues/new",
231 softAuth,
232 requireAuth,
04f6b7fClaude233 requireRepoAccess("write"),
79136bbClaude234 async (c) => {
235 const { owner: ownerName, repo: repoName } = c.req.param();
236 const user = c.get("user")!;
237 const body = await c.req.parseBody();
238 const title = String(body.title || "").trim();
239 const issueBody = String(body.body || "").trim();
240
241 if (!title) {
242 return c.redirect(
243 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
244 );
245 }
246
247 const resolved = await resolveRepo(ownerName, repoName);
248 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
249
250 const [issue] = await db
251 .insert(issues)
252 .values({
253 repositoryId: resolved.repo.id,
254 authorId: user.id,
255 title,
256 body: issueBody || null,
257 })
258 .returning();
259
260 // Update issue count
261 await db
262 .update(repositories)
263 .set({ issueCount: resolved.repo.issueCount + 1 })
264 .where(eq(repositories.id, resolved.repo.id));
265
266 return c.redirect(
267 `/${ownerName}/${repoName}/issues/${issue.number}`
268 );
269 }
270);
271
272// View single issue
04f6b7fClaude273issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude274 const { owner: ownerName, repo: repoName } = c.req.param();
275 const issueNum = parseInt(c.req.param("number"), 10);
276 const user = c.get("user");
277
278 const resolved = await resolveRepo(ownerName, repoName);
279 if (!resolved) {
280 return c.html(
281 <Layout title="Not Found" user={user}>
bb0f894Claude282 <EmptyState title="Not found" />
79136bbClaude283 </Layout>,
284 404
285 );
286 }
287
288 const [issue] = await db
289 .select()
290 .from(issues)
291 .where(
292 and(
293 eq(issues.repositoryId, resolved.repo.id),
294 eq(issues.number, issueNum)
295 )
296 )
297 .limit(1);
298
299 if (!issue) {
300 return c.html(
301 <Layout title="Not Found" user={user}>
bb0f894Claude302 <EmptyState title="Issue not found" />
79136bbClaude303 </Layout>,
304 404
305 );
306 }
307
308 const [author] = await db
309 .select()
310 .from(users)
311 .where(eq(users.id, issue.authorId))
312 .limit(1);
313
314 // Get comments
315 const comments = await db
316 .select({
317 comment: issueComments,
318 author: { username: users.username },
319 })
320 .from(issueComments)
321 .innerJoin(users, eq(issueComments.authorId, users.id))
322 .where(eq(issueComments.issueId, issue.id))
323 .orderBy(asc(issueComments.createdAt));
324
6fc53bdClaude325 // Load reactions for the issue + each comment in parallel.
326 const [issueReactions, ...commentReactions] = await Promise.all([
327 summariseReactions("issue", issue.id, user?.id),
328 ...comments.map((row) =>
329 summariseReactions("issue_comment", row.comment.id, user?.id)
330 ),
331 ]);
332
79136bbClaude333 const canManage =
334 user &&
335 (user.id === resolved.owner.id || user.id === issue.authorId);
336
337 return c.html(
338 <Layout
339 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
340 user={user}
341 >
342 <RepoHeader owner={ownerName} repo={repoName} />
343 <IssueNav owner={ownerName} repo={repoName} active="issues" />
344 <div class="issue-detail">
345 <h2>
346 {issue.title}{" "}
bb0f894Claude347 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude348 #{issue.number}
349 </span>
350 </h2>
bb0f894Claude351 <Flex align="center" gap={8} style="margin:8px 0 20px">
352 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude353 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude354 </Badge>
355 <span style="color:var(--text-muted);font-size:14px">
356 <strong style="color:var(--text)">
79136bbClaude357 {author?.username || "unknown"}
358 </strong>{" "}
359 opened this issue {formatRelative(issue.createdAt)}
360 </span>
bb0f894Claude361 </Flex>
79136bbClaude362
363 {issue.body && (
bb0f894Claude364 <CommentBox
365 author={author?.username || "unknown"}
366 date={issue.createdAt}
367 body={renderMarkdown(issue.body)}
368 />
79136bbClaude369 )}
370
371 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude372 <CommentBox
373 author={commentAuthor.username}
374 date={comment.createdAt}
375 body={renderMarkdown(comment.body)}
376 />
79136bbClaude377 ))}
378
379 {user && (
380 <div style="margin-top: 20px">
381 <form
cce7944Claude382 method="post"
79136bbClaude383 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
384 >
385 <div class="form-group">
386 <textarea
387 name="body"
388 rows={6}
389 required
390 placeholder="Leave a comment... (Markdown supported)"
391 style="font-family: var(--font-mono); font-size: 13px"
392 />
393 </div>
394 <div style="display: flex; gap: 8px">
395 <button type="submit" class="btn btn-primary">
396 Comment
397 </button>
398 {canManage && (
399 <button
400 type="submit"
401 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
402 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
403 >
404 {issue.state === "open"
405 ? "Close issue"
406 : "Reopen issue"}
407 </button>
408 )}
409 </div>
410 </form>
411 </div>
412 )}
413 </div>
414 </Layout>
415 );
416});
417
418// Add comment
419issueRoutes.post(
420 "/:owner/:repo/issues/:number/comment",
421 softAuth,
422 requireAuth,
04f6b7fClaude423 requireRepoAccess("write"),
79136bbClaude424 async (c) => {
425 const { owner: ownerName, repo: repoName } = c.req.param();
426 const issueNum = parseInt(c.req.param("number"), 10);
427 const user = c.get("user")!;
428 const body = await c.req.parseBody();
429 const commentBody = String(body.body || "").trim();
430
431 if (!commentBody) {
432 return c.redirect(
433 `/${ownerName}/${repoName}/issues/${issueNum}`
434 );
435 }
436
437 const resolved = await resolveRepo(ownerName, repoName);
438 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
439
440 const [issue] = await db
441 .select()
442 .from(issues)
443 .where(
444 and(
445 eq(issues.repositoryId, resolved.repo.id),
446 eq(issues.number, issueNum)
447 )
448 )
449 .limit(1);
450
451 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
452
453 await db.insert(issueComments).values({
454 issueId: issue.id,
455 authorId: user.id,
456 body: commentBody,
457 });
458
459 return c.redirect(
460 `/${ownerName}/${repoName}/issues/${issueNum}`
461 );
462 }
463);
464
465// Close issue
466issueRoutes.post(
467 "/:owner/:repo/issues/:number/close",
468 softAuth,
469 requireAuth,
04f6b7fClaude470 requireRepoAccess("write"),
79136bbClaude471 async (c) => {
472 const { owner: ownerName, repo: repoName } = c.req.param();
473 const issueNum = parseInt(c.req.param("number"), 10);
474
475 const resolved = await resolveRepo(ownerName, repoName);
476 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
477
478 await db
479 .update(issues)
480 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
481 .where(
482 and(
483 eq(issues.repositoryId, resolved.repo.id),
484 eq(issues.number, issueNum)
485 )
486 );
487
488 return c.redirect(
489 `/${ownerName}/${repoName}/issues/${issueNum}`
490 );
491 }
492);
493
494// Reopen issue
495issueRoutes.post(
496 "/:owner/:repo/issues/:number/reopen",
497 softAuth,
498 requireAuth,
04f6b7fClaude499 requireRepoAccess("write"),
79136bbClaude500 async (c) => {
501 const { owner: ownerName, repo: repoName } = c.req.param();
502 const issueNum = parseInt(c.req.param("number"), 10);
503
504 const resolved = await resolveRepo(ownerName, repoName);
505 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
506
507 await db
508 .update(issues)
509 .set({ state: "open", closedAt: null, updatedAt: new Date() })
510 .where(
511 and(
512 eq(issues.repositoryId, resolved.repo.id),
513 eq(issues.number, issueNum)
514 )
515 );
516
517 return c.redirect(
518 `/${ownerName}/${repoName}/issues/${issueNum}`
519 );
520 }
521);
522
523// Shared nav component with issues tab
524const IssueNav = ({
525 owner,
526 repo,
527 active,
528}: {
529 owner: string;
530 repo: string;
531 active: "code" | "commits" | "issues";
532}) => (
bb0f894Claude533 <TabNav
534 tabs={[
535 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
536 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
537 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
538 ]}
539 />
79136bbClaude540);
541
542export default issueRoutes;
543export { IssueNav };