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.tsxBlame537 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 )}
cce7944Claude198 <form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
79136bbClaude199 <div class="form-group">
200 <input
201 type="text"
202 name="title"
203 required
204 placeholder="Title"
bb0f894Claude205 style="font-size:16px;padding:10px 14px"
79136bbClaude206 />
bb0f894Claude207 </FormGroup>
208 <FormGroup>
209 <TextArea
79136bbClaude210 name="body"
211 rows={12}
212 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude213 mono
79136bbClaude214 />
bb0f894Claude215 </FormGroup>
216 <Button type="submit" variant="primary">
79136bbClaude217 Submit new issue
bb0f894Claude218 </Button>
219 </Form>
220 </Container>
79136bbClaude221 </Layout>
222 );
223 }
224);
225
226// Create issue
227issueRoutes.post(
228 "/:owner/:repo/issues/new",
229 softAuth,
230 requireAuth,
231 async (c) => {
232 const { owner: ownerName, repo: repoName } = c.req.param();
233 const user = c.get("user")!;
234 const body = await c.req.parseBody();
235 const title = String(body.title || "").trim();
236 const issueBody = String(body.body || "").trim();
237
238 if (!title) {
239 return c.redirect(
240 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
241 );
242 }
243
244 const resolved = await resolveRepo(ownerName, repoName);
245 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
246
247 const [issue] = await db
248 .insert(issues)
249 .values({
250 repositoryId: resolved.repo.id,
251 authorId: user.id,
252 title,
253 body: issueBody || null,
254 })
255 .returning();
256
257 // Update issue count
258 await db
259 .update(repositories)
260 .set({ issueCount: resolved.repo.issueCount + 1 })
261 .where(eq(repositories.id, resolved.repo.id));
262
263 return c.redirect(
264 `/${ownerName}/${repoName}/issues/${issue.number}`
265 );
266 }
267);
268
269// View single issue
270issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
271 const { owner: ownerName, repo: repoName } = c.req.param();
272 const issueNum = parseInt(c.req.param("number"), 10);
273 const user = c.get("user");
274
275 const resolved = await resolveRepo(ownerName, repoName);
276 if (!resolved) {
277 return c.html(
278 <Layout title="Not Found" user={user}>
bb0f894Claude279 <EmptyState title="Not found" />
79136bbClaude280 </Layout>,
281 404
282 );
283 }
284
285 const [issue] = await db
286 .select()
287 .from(issues)
288 .where(
289 and(
290 eq(issues.repositoryId, resolved.repo.id),
291 eq(issues.number, issueNum)
292 )
293 )
294 .limit(1);
295
296 if (!issue) {
297 return c.html(
298 <Layout title="Not Found" user={user}>
bb0f894Claude299 <EmptyState title="Issue not found" />
79136bbClaude300 </Layout>,
301 404
302 );
303 }
304
305 const [author] = await db
306 .select()
307 .from(users)
308 .where(eq(users.id, issue.authorId))
309 .limit(1);
310
311 // Get comments
312 const comments = await db
313 .select({
314 comment: issueComments,
315 author: { username: users.username },
316 })
317 .from(issueComments)
318 .innerJoin(users, eq(issueComments.authorId, users.id))
319 .where(eq(issueComments.issueId, issue.id))
320 .orderBy(asc(issueComments.createdAt));
321
6fc53bdClaude322 // Load reactions for the issue + each comment in parallel.
323 const [issueReactions, ...commentReactions] = await Promise.all([
324 summariseReactions("issue", issue.id, user?.id),
325 ...comments.map((row) =>
326 summariseReactions("issue_comment", row.comment.id, user?.id)
327 ),
328 ]);
329
79136bbClaude330 const canManage =
331 user &&
332 (user.id === resolved.owner.id || user.id === issue.authorId);
333
334 return c.html(
335 <Layout
336 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
337 user={user}
338 >
339 <RepoHeader owner={ownerName} repo={repoName} />
340 <IssueNav owner={ownerName} repo={repoName} active="issues" />
341 <div class="issue-detail">
342 <h2>
343 {issue.title}{" "}
bb0f894Claude344 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude345 #{issue.number}
346 </span>
347 </h2>
bb0f894Claude348 <Flex align="center" gap={8} style="margin:8px 0 20px">
349 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude350 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude351 </Badge>
352 <span style="color:var(--text-muted);font-size:14px">
353 <strong style="color:var(--text)">
79136bbClaude354 {author?.username || "unknown"}
355 </strong>{" "}
356 opened this issue {formatRelative(issue.createdAt)}
357 </span>
bb0f894Claude358 </Flex>
79136bbClaude359
360 {issue.body && (
bb0f894Claude361 <CommentBox
362 author={author?.username || "unknown"}
363 date={issue.createdAt}
364 body={renderMarkdown(issue.body)}
365 />
79136bbClaude366 )}
367
368 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude369 <CommentBox
370 author={commentAuthor.username}
371 date={comment.createdAt}
372 body={renderMarkdown(comment.body)}
373 />
79136bbClaude374 ))}
375
376 {user && (
377 <div style="margin-top: 20px">
378 <form
cce7944Claude379 method="post"
79136bbClaude380 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
381 >
382 <div class="form-group">
383 <textarea
384 name="body"
385 rows={6}
386 required
387 placeholder="Leave a comment... (Markdown supported)"
388 style="font-family: var(--font-mono); font-size: 13px"
389 />
390 </div>
391 <div style="display: flex; gap: 8px">
392 <button type="submit" class="btn btn-primary">
393 Comment
394 </button>
395 {canManage && (
396 <button
397 type="submit"
398 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
399 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
400 >
401 {issue.state === "open"
402 ? "Close issue"
403 : "Reopen issue"}
404 </button>
405 )}
406 </div>
407 </form>
408 </div>
409 )}
410 </div>
411 </Layout>
412 );
413});
414
415// Add comment
416issueRoutes.post(
417 "/:owner/:repo/issues/:number/comment",
418 softAuth,
419 requireAuth,
420 async (c) => {
421 const { owner: ownerName, repo: repoName } = c.req.param();
422 const issueNum = parseInt(c.req.param("number"), 10);
423 const user = c.get("user")!;
424 const body = await c.req.parseBody();
425 const commentBody = String(body.body || "").trim();
426
427 if (!commentBody) {
428 return c.redirect(
429 `/${ownerName}/${repoName}/issues/${issueNum}`
430 );
431 }
432
433 const resolved = await resolveRepo(ownerName, repoName);
434 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
435
436 const [issue] = await db
437 .select()
438 .from(issues)
439 .where(
440 and(
441 eq(issues.repositoryId, resolved.repo.id),
442 eq(issues.number, issueNum)
443 )
444 )
445 .limit(1);
446
447 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
448
449 await db.insert(issueComments).values({
450 issueId: issue.id,
451 authorId: user.id,
452 body: commentBody,
453 });
454
455 return c.redirect(
456 `/${ownerName}/${repoName}/issues/${issueNum}`
457 );
458 }
459);
460
461// Close issue
462issueRoutes.post(
463 "/:owner/:repo/issues/:number/close",
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: "closed", closedAt: new Date(), 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// Reopen issue
490issueRoutes.post(
491 "/:owner/:repo/issues/:number/reopen",
492 softAuth,
493 requireAuth,
494 async (c) => {
495 const { owner: ownerName, repo: repoName } = c.req.param();
496 const issueNum = parseInt(c.req.param("number"), 10);
497
498 const resolved = await resolveRepo(ownerName, repoName);
499 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
500
501 await db
502 .update(issues)
503 .set({ state: "open", closedAt: null, updatedAt: new Date() })
504 .where(
505 and(
506 eq(issues.repositoryId, resolved.repo.id),
507 eq(issues.number, issueNum)
508 )
509 );
510
511 return c.redirect(
512 `/${ownerName}/${repoName}/issues/${issueNum}`
513 );
514 }
515);
516
517// Shared nav component with issues tab
518const IssueNav = ({
519 owner,
520 repo,
521 active,
522}: {
523 owner: string;
524 repo: string;
525 active: "code" | "commits" | "issues";
526}) => (
bb0f894Claude527 <TabNav
528 tabs={[
529 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
530 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
531 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
532 ]}
533 />
79136bbClaude534);
535
536export default issueRoutes;
537export { IssueNav };