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.tsxBlame554 lines · 2 contributors
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"
5db1b25copilot-swe-agent[bot]208 aria-label="Issue title"
79136bbClaude209 />
bb0f894Claude210 </FormGroup>
211 <FormGroup>
212 <TextArea
79136bbClaude213 name="body"
214 rows={12}
215 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude216 mono
79136bbClaude217 />
bb0f894Claude218 </FormGroup>
219 <Button type="submit" variant="primary">
79136bbClaude220 Submit new issue
bb0f894Claude221 </Button>
222 </Form>
223 </Container>
79136bbClaude224 </Layout>
225 );
226 }
227);
228
229// Create issue
230issueRoutes.post(
231 "/:owner/:repo/issues/new",
232 softAuth,
233 requireAuth,
04f6b7fClaude234 requireRepoAccess("write"),
79136bbClaude235 async (c) => {
236 const { owner: ownerName, repo: repoName } = c.req.param();
237 const user = c.get("user")!;
238 const body = await c.req.parseBody();
239 const title = String(body.title || "").trim();
240 const issueBody = String(body.body || "").trim();
241
242 if (!title) {
243 return c.redirect(
244 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
245 );
246 }
247
248 const resolved = await resolveRepo(ownerName, repoName);
249 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
250
251 const [issue] = await db
252 .insert(issues)
253 .values({
254 repositoryId: resolved.repo.id,
255 authorId: user.id,
256 title,
257 body: issueBody || null,
258 })
259 .returning();
260
261 // Update issue count
262 await db
263 .update(repositories)
264 .set({ issueCount: resolved.repo.issueCount + 1 })
265 .where(eq(repositories.id, resolved.repo.id));
266
267 return c.redirect(
268 `/${ownerName}/${repoName}/issues/${issue.number}`
269 );
270 }
271);
272
273// View single issue
04f6b7fClaude274issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const issueNum = parseInt(c.req.param("number"), 10);
277 const user = c.get("user");
278
279 const resolved = await resolveRepo(ownerName, repoName);
280 if (!resolved) {
281 return c.html(
282 <Layout title="Not Found" user={user}>
bb0f894Claude283 <EmptyState title="Not found" />
79136bbClaude284 </Layout>,
285 404
286 );
287 }
288
289 const [issue] = await db
290 .select()
291 .from(issues)
292 .where(
293 and(
294 eq(issues.repositoryId, resolved.repo.id),
295 eq(issues.number, issueNum)
296 )
297 )
298 .limit(1);
299
300 if (!issue) {
301 return c.html(
302 <Layout title="Not Found" user={user}>
bb0f894Claude303 <EmptyState title="Issue not found" />
79136bbClaude304 </Layout>,
305 404
306 );
307 }
308
309 const [author] = await db
310 .select()
311 .from(users)
312 .where(eq(users.id, issue.authorId))
313 .limit(1);
314
315 // Get comments
316 const comments = await db
317 .select({
318 comment: issueComments,
319 author: { username: users.username },
320 })
321 .from(issueComments)
322 .innerJoin(users, eq(issueComments.authorId, users.id))
323 .where(eq(issueComments.issueId, issue.id))
324 .orderBy(asc(issueComments.createdAt));
325
6fc53bdClaude326 // Load reactions for the issue + each comment in parallel.
327 const [issueReactions, ...commentReactions] = await Promise.all([
328 summariseReactions("issue", issue.id, user?.id),
329 ...comments.map((row) =>
330 summariseReactions("issue_comment", row.comment.id, user?.id)
331 ),
332 ]);
333
79136bbClaude334 const canManage =
335 user &&
336 (user.id === resolved.owner.id || user.id === issue.authorId);
337
338 return c.html(
339 <Layout
340 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
341 user={user}
342 >
343 <RepoHeader owner={ownerName} repo={repoName} />
344 <IssueNav owner={ownerName} repo={repoName} active="issues" />
345 <div class="issue-detail">
346 <h2>
347 {issue.title}{" "}
bb0f894Claude348 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude349 #{issue.number}
350 </span>
351 </h2>
bb0f894Claude352 <Flex align="center" gap={8} style="margin:8px 0 20px">
353 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude354 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude355 </Badge>
356 <span style="color:var(--text-muted);font-size:14px">
357 <strong style="color:var(--text)">
79136bbClaude358 {author?.username || "unknown"}
359 </strong>{" "}
360 opened this issue {formatRelative(issue.createdAt)}
361 </span>
fbf4aefClaude362 {issue.state === "open" && user && user.id === resolved.owner.id && (
363 <a
364 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
365 class="btn btn-primary"
366 style="margin-left:auto;font-size:13px;padding:4px 10px"
367 title="Generate a draft pull request from this issue using Claude"
368 >
369 Build with AI
370 </a>
371 )}
bb0f894Claude372 </Flex>
79136bbClaude373
374 {issue.body && (
bb0f894Claude375 <CommentBox
376 author={author?.username || "unknown"}
377 date={issue.createdAt}
378 body={renderMarkdown(issue.body)}
379 />
79136bbClaude380 )}
381
382 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude383 <CommentBox
384 author={commentAuthor.username}
385 date={comment.createdAt}
386 body={renderMarkdown(comment.body)}
387 />
79136bbClaude388 ))}
389
390 {user && (
391 <div style="margin-top: 20px">
392 <form
cce7944Claude393 method="post"
79136bbClaude394 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
395 >
396 <div class="form-group">
397 <textarea
398 name="body"
399 rows={6}
400 required
401 placeholder="Leave a comment... (Markdown supported)"
402 style="font-family: var(--font-mono); font-size: 13px"
403 />
404 </div>
405 <div style="display: flex; gap: 8px">
406 <button type="submit" class="btn btn-primary">
407 Comment
408 </button>
409 {canManage && (
410 <button
411 type="submit"
412 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
413 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
414 >
415 {issue.state === "open"
416 ? "Close issue"
417 : "Reopen issue"}
418 </button>
419 )}
420 </div>
421 </form>
422 </div>
423 )}
424 </div>
425 </Layout>
426 );
427});
428
429// Add comment
430issueRoutes.post(
431 "/:owner/:repo/issues/:number/comment",
432 softAuth,
433 requireAuth,
04f6b7fClaude434 requireRepoAccess("write"),
79136bbClaude435 async (c) => {
436 const { owner: ownerName, repo: repoName } = c.req.param();
437 const issueNum = parseInt(c.req.param("number"), 10);
438 const user = c.get("user")!;
439 const body = await c.req.parseBody();
440 const commentBody = String(body.body || "").trim();
441
442 if (!commentBody) {
443 return c.redirect(
444 `/${ownerName}/${repoName}/issues/${issueNum}`
445 );
446 }
447
448 const resolved = await resolveRepo(ownerName, repoName);
449 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
450
451 const [issue] = await db
452 .select()
453 .from(issues)
454 .where(
455 and(
456 eq(issues.repositoryId, resolved.repo.id),
457 eq(issues.number, issueNum)
458 )
459 )
460 .limit(1);
461
462 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
463
464 await db.insert(issueComments).values({
465 issueId: issue.id,
466 authorId: user.id,
467 body: commentBody,
468 });
469
470 return c.redirect(
471 `/${ownerName}/${repoName}/issues/${issueNum}`
472 );
473 }
474);
475
476// Close issue
477issueRoutes.post(
478 "/:owner/:repo/issues/:number/close",
479 softAuth,
480 requireAuth,
04f6b7fClaude481 requireRepoAccess("write"),
79136bbClaude482 async (c) => {
483 const { owner: ownerName, repo: repoName } = c.req.param();
484 const issueNum = parseInt(c.req.param("number"), 10);
485
486 const resolved = await resolveRepo(ownerName, repoName);
487 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
488
489 await db
490 .update(issues)
491 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
492 .where(
493 and(
494 eq(issues.repositoryId, resolved.repo.id),
495 eq(issues.number, issueNum)
496 )
497 );
498
499 return c.redirect(
500 `/${ownerName}/${repoName}/issues/${issueNum}`
501 );
502 }
503);
504
505// Reopen issue
506issueRoutes.post(
507 "/:owner/:repo/issues/:number/reopen",
508 softAuth,
509 requireAuth,
04f6b7fClaude510 requireRepoAccess("write"),
79136bbClaude511 async (c) => {
512 const { owner: ownerName, repo: repoName } = c.req.param();
513 const issueNum = parseInt(c.req.param("number"), 10);
514
515 const resolved = await resolveRepo(ownerName, repoName);
516 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
517
518 await db
519 .update(issues)
520 .set({ state: "open", closedAt: null, updatedAt: new Date() })
521 .where(
522 and(
523 eq(issues.repositoryId, resolved.repo.id),
524 eq(issues.number, issueNum)
525 )
526 );
527
528 return c.redirect(
529 `/${ownerName}/${repoName}/issues/${issueNum}`
530 );
531 }
532);
533
534// Shared nav component with issues tab
535const IssueNav = ({
536 owner,
537 repo,
538 active,
539}: {
540 owner: string;
541 repo: string;
542 active: "code" | "commits" | "issues";
543}) => (
bb0f894Claude544 <TabNav
545 tabs={[
546 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
547 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
548 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
549 ]}
550 />
79136bbClaude551);
552
553export default issueRoutes;
554export { IssueNav };