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.tsxBlame553 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>
fbf4aefClaude361 {issue.state === "open" && user && user.id === resolved.owner.id && (
362 <a
363 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
364 class="btn btn-primary"
365 style="margin-left:auto;font-size:13px;padding:4px 10px"
366 title="Generate a draft pull request from this issue using Claude"
367 >
368 Build with AI
369 </a>
370 )}
bb0f894Claude371 </Flex>
79136bbClaude372
373 {issue.body && (
bb0f894Claude374 <CommentBox
375 author={author?.username || "unknown"}
376 date={issue.createdAt}
377 body={renderMarkdown(issue.body)}
378 />
79136bbClaude379 )}
380
381 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude382 <CommentBox
383 author={commentAuthor.username}
384 date={comment.createdAt}
385 body={renderMarkdown(comment.body)}
386 />
79136bbClaude387 ))}
388
389 {user && (
390 <div style="margin-top: 20px">
391 <form
cce7944Claude392 method="post"
79136bbClaude393 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
394 >
395 <div class="form-group">
396 <textarea
397 name="body"
398 rows={6}
399 required
400 placeholder="Leave a comment... (Markdown supported)"
401 style="font-family: var(--font-mono); font-size: 13px"
402 />
403 </div>
404 <div style="display: flex; gap: 8px">
405 <button type="submit" class="btn btn-primary">
406 Comment
407 </button>
408 {canManage && (
409 <button
410 type="submit"
411 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
412 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
413 >
414 {issue.state === "open"
415 ? "Close issue"
416 : "Reopen issue"}
417 </button>
418 )}
419 </div>
420 </form>
421 </div>
422 )}
423 </div>
424 </Layout>
425 );
426});
427
428// Add comment
429issueRoutes.post(
430 "/:owner/:repo/issues/:number/comment",
431 softAuth,
432 requireAuth,
04f6b7fClaude433 requireRepoAccess("write"),
79136bbClaude434 async (c) => {
435 const { owner: ownerName, repo: repoName } = c.req.param();
436 const issueNum = parseInt(c.req.param("number"), 10);
437 const user = c.get("user")!;
438 const body = await c.req.parseBody();
439 const commentBody = String(body.body || "").trim();
440
441 if (!commentBody) {
442 return c.redirect(
443 `/${ownerName}/${repoName}/issues/${issueNum}`
444 );
445 }
446
447 const resolved = await resolveRepo(ownerName, repoName);
448 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
449
450 const [issue] = await db
451 .select()
452 .from(issues)
453 .where(
454 and(
455 eq(issues.repositoryId, resolved.repo.id),
456 eq(issues.number, issueNum)
457 )
458 )
459 .limit(1);
460
461 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
462
463 await db.insert(issueComments).values({
464 issueId: issue.id,
465 authorId: user.id,
466 body: commentBody,
467 });
468
469 return c.redirect(
470 `/${ownerName}/${repoName}/issues/${issueNum}`
471 );
472 }
473);
474
475// Close issue
476issueRoutes.post(
477 "/:owner/:repo/issues/:number/close",
478 softAuth,
479 requireAuth,
04f6b7fClaude480 requireRepoAccess("write"),
79136bbClaude481 async (c) => {
482 const { owner: ownerName, repo: repoName } = c.req.param();
483 const issueNum = parseInt(c.req.param("number"), 10);
484
485 const resolved = await resolveRepo(ownerName, repoName);
486 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
487
488 await db
489 .update(issues)
490 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
491 .where(
492 and(
493 eq(issues.repositoryId, resolved.repo.id),
494 eq(issues.number, issueNum)
495 )
496 );
497
498 return c.redirect(
499 `/${ownerName}/${repoName}/issues/${issueNum}`
500 );
501 }
502);
503
504// Reopen issue
505issueRoutes.post(
506 "/:owner/:repo/issues/:number/reopen",
507 softAuth,
508 requireAuth,
04f6b7fClaude509 requireRepoAccess("write"),
79136bbClaude510 async (c) => {
511 const { owner: ownerName, repo: repoName } = c.req.param();
512 const issueNum = parseInt(c.req.param("number"), 10);
513
514 const resolved = await resolveRepo(ownerName, repoName);
515 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
516
517 await db
518 .update(issues)
519 .set({ state: "open", closedAt: null, updatedAt: new Date() })
520 .where(
521 and(
522 eq(issues.repositoryId, resolved.repo.id),
523 eq(issues.number, issueNum)
524 )
525 );
526
527 return c.redirect(
528 `/${ownerName}/${repoName}/issues/${issueNum}`
529 );
530 }
531);
532
533// Shared nav component with issues tab
534const IssueNav = ({
535 owner,
536 repo,
537 active,
538}: {
539 owner: string;
540 repo: string;
541 active: "code" | "commits" | "issues";
542}) => (
bb0f894Claude543 <TabNav
544 tabs={[
545 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
546 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
547 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
548 ]}
549 />
79136bbClaude550);
551
552export default issueRoutes;
553export { IssueNav };