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.tsxBlame677 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";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
a9ada5fClaude23import { triggerIssueTriage } from "../lib/issue-triage";
79136bbClaude24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude26import { requireRepoAccess } from "../middleware/repo-access";
bb0f894Claude27import {
28 Flex,
29 Container,
30 PageHeader,
31 Form,
32 FormGroup,
33 Input,
34 TextArea,
35 Button,
36 LinkButton,
37 Badge,
38 EmptyState,
39 TabNav,
40 FilterTabs,
41 List,
42 ListItem,
43 Alert,
44 CommentBox,
45 CommentForm,
46 formatRelative,
47} from "../views/ui";
79136bbClaude48
49const issueRoutes = new Hono<AuthEnv>();
50
51// Helper to resolve repo from :owner/:repo params
52async function resolveRepo(ownerName: string, repoName: string) {
53 const [owner] = await db
54 .select()
55 .from(users)
56 .where(eq(users.username, ownerName))
57 .limit(1);
58 if (!owner) return null;
59
60 const [repo] = await db
61 .select()
62 .from(repositories)
63 .where(
64 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
65 )
66 .limit(1);
67 if (!repo) return null;
68
69 return { owner, repo };
70}
71
72// Issue list
04f6b7fClaude73issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude74 const { owner: ownerName, repo: repoName } = c.req.param();
75 const user = c.get("user");
76 const state = c.req.query("state") || "open";
77
78 const resolved = await resolveRepo(ownerName, repoName);
79 if (!resolved) {
80 return c.html(
81 <Layout title="Not Found" user={user}>
bb0f894Claude82 <EmptyState title="Repository not found" />
79136bbClaude83 </Layout>,
84 404
85 );
86 }
87
88 const { repo } = resolved;
89
90 const issueList = await db
91 .select({
92 issue: issues,
93 author: { username: users.username },
94 })
95 .from(issues)
96 .innerJoin(users, eq(issues.authorId, users.id))
97 .where(
98 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
99 )
100 .orderBy(desc(issues.createdAt));
101
102 // Count open/closed
103 const [counts] = await db
104 .select({
105 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
106 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
107 })
108 .from(issues)
109 .where(eq(issues.repositoryId, repo.id));
110
111 return c.html(
112 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
113 <RepoHeader owner={ownerName} repo={repoName} />
114 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude115 <Flex justify="space-between" align="center" style="margin-bottom:16px">
116 <FilterTabs
117 tabs={[
118 {
119 label: `${counts?.open ?? 0} Open`,
120 href: `/${ownerName}/${repoName}/issues?state=open`,
121 active: state === "open",
122 },
123 {
124 label: `${counts?.closed ?? 0} Closed`,
125 href: `/${ownerName}/${repoName}/issues?state=closed`,
126 active: state === "closed",
127 },
128 ]}
129 />
79136bbClaude130 {user && (
bb0f894Claude131 <LinkButton
79136bbClaude132 href={`/${ownerName}/${repoName}/issues/new`}
bb0f894Claude133 variant="primary"
79136bbClaude134 >
135 New issue
bb0f894Claude136 </LinkButton>
79136bbClaude137 )}
bb0f894Claude138 </Flex>
79136bbClaude139 {issueList.length === 0 ? (
bb0f894Claude140 <EmptyState>
79136bbClaude141 <p>
142 No {state} issues.
143 {state === "closed" && (
144 <span>
145 {" "}
146 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
147 View open issues
148 </a>
149 </span>
150 )}
151 </p>
bb0f894Claude152 </EmptyState>
79136bbClaude153 ) : (
bb0f894Claude154 <List>
79136bbClaude155 {issueList.map(({ issue, author }) => (
bb0f894Claude156 <ListItem>
79136bbClaude157 <div
158 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
159 >
160 {issue.state === "open" ? "\u25CB" : "\u2713"}
161 </div>
162 <div>
163 <div class="issue-title">
164 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
165 {issue.title}
166 </a>
167 </div>
168 <div class="issue-meta">
169 #{issue.number} opened by {author.username}{" "}
170 {formatRelative(issue.createdAt)}
171 </div>
172 </div>
bb0f894Claude173 </ListItem>
79136bbClaude174 ))}
bb0f894Claude175 </List>
79136bbClaude176 )}
177 </Layout>
178 );
179});
180
181// New issue form
182issueRoutes.get(
183 "/:owner/:repo/issues/new",
184 softAuth,
185 requireAuth,
04f6b7fClaude186 requireRepoAccess("write"),
79136bbClaude187 async (c) => {
188 const { owner: ownerName, repo: repoName } = c.req.param();
189 const user = c.get("user")!;
190 const error = c.req.query("error");
24cf2caClaude191 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude192
193 return c.html(
194 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
195 <RepoHeader owner={ownerName} repo={repoName} />
196 <IssueNav owner={ownerName} repo={repoName} active="issues" />
bb0f894Claude197 <Container maxWidth={800}>
198 <h2 style="margin-bottom:16px">New issue</h2>
79136bbClaude199 {error && (
bb0f894Claude200 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude201 )}
0316dbbClaude202 <Form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
203 <FormGroup>
204 <Input
79136bbClaude205 type="text"
206 name="title"
207 required
208 placeholder="Title"
bb0f894Claude209 style="font-size:16px;padding:10px 14px"
5db1b25copilot-swe-agent[bot]210 aria-label="Issue title"
79136bbClaude211 />
bb0f894Claude212 </FormGroup>
213 <FormGroup>
214 <TextArea
79136bbClaude215 name="body"
216 rows={12}
217 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude218 mono
79136bbClaude219 />
bb0f894Claude220 </FormGroup>
221 <Button type="submit" variant="primary">
79136bbClaude222 Submit new issue
bb0f894Claude223 </Button>
224 </Form>
225 </Container>
79136bbClaude226 </Layout>
227 );
228 }
229);
230
231// Create issue
232issueRoutes.post(
233 "/:owner/:repo/issues/new",
234 softAuth,
235 requireAuth,
04f6b7fClaude236 requireRepoAccess("write"),
79136bbClaude237 async (c) => {
238 const { owner: ownerName, repo: repoName } = c.req.param();
239 const user = c.get("user")!;
240 const body = await c.req.parseBody();
241 const title = String(body.title || "").trim();
242 const issueBody = String(body.body || "").trim();
243
244 if (!title) {
245 return c.redirect(
246 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
247 );
248 }
249
250 const resolved = await resolveRepo(ownerName, repoName);
251 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
252
253 const [issue] = await db
254 .insert(issues)
255 .values({
256 repositoryId: resolved.repo.id,
257 authorId: user.id,
258 title,
259 body: issueBody || null,
260 })
261 .returning();
262
263 // Update issue count
264 await db
265 .update(repositories)
266 .set({ issueCount: resolved.repo.issueCount + 1 })
267 .where(eq(repositories.id, resolved.repo.id));
268
a9ada5fClaude269 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
270 // suggested labels, priority, summary, and a possible-duplicate
271 // callout. Suggestions only — nothing applied automatically.
272 triggerIssueTriage({
273 ownerName,
274 repoName,
275 repositoryId: resolved.repo.id,
276 issueId: issue.id,
277 issueNumber: issue.number,
278 authorId: user.id,
279 title,
280 body: issueBody,
281 }).catch(() => {});
282
79136bbClaude283 return c.redirect(
284 `/${ownerName}/${repoName}/issues/${issue.number}`
285 );
286 }
287);
288
289// View single issue
04f6b7fClaude290issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("read"), async (c) => {
79136bbClaude291 const { owner: ownerName, repo: repoName } = c.req.param();
292 const issueNum = parseInt(c.req.param("number"), 10);
293 const user = c.get("user");
294
295 const resolved = await resolveRepo(ownerName, repoName);
296 if (!resolved) {
297 return c.html(
298 <Layout title="Not Found" user={user}>
bb0f894Claude299 <EmptyState title="Not found" />
79136bbClaude300 </Layout>,
301 404
302 );
303 }
304
305 const [issue] = await db
306 .select()
307 .from(issues)
308 .where(
309 and(
310 eq(issues.repositoryId, resolved.repo.id),
311 eq(issues.number, issueNum)
312 )
313 )
314 .limit(1);
315
316 if (!issue) {
317 return c.html(
318 <Layout title="Not Found" user={user}>
bb0f894Claude319 <EmptyState title="Issue not found" />
79136bbClaude320 </Layout>,
321 404
322 );
323 }
324
325 const [author] = await db
326 .select()
327 .from(users)
328 .where(eq(users.id, issue.authorId))
329 .limit(1);
330
331 // Get comments
332 const comments = await db
333 .select({
334 comment: issueComments,
335 author: { username: users.username },
336 })
337 .from(issueComments)
338 .innerJoin(users, eq(issueComments.authorId, users.id))
339 .where(eq(issueComments.issueId, issue.id))
340 .orderBy(asc(issueComments.createdAt));
341
6fc53bdClaude342 // Load reactions for the issue + each comment in parallel.
343 const [issueReactions, ...commentReactions] = await Promise.all([
344 summariseReactions("issue", issue.id, user?.id),
345 ...comments.map((row) =>
346 summariseReactions("issue_comment", row.comment.id, user?.id)
347 ),
348 ]);
349
79136bbClaude350 const canManage =
351 user &&
352 (user.id === resolved.owner.id || user.id === issue.authorId);
58915a9Claude353 const info = c.req.query("info");
79136bbClaude354
355 return c.html(
356 <Layout
357 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
358 user={user}
359 >
360 <RepoHeader owner={ownerName} repo={repoName} />
361 <IssueNav owner={ownerName} repo={repoName} active="issues" />
b584e52Claude362 <div
363 id="live-comment-banner"
364 class="alert"
365 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
366 >
367 <strong class="js-live-count">0</strong> new comment(s) —{" "}
368 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
369 reload to view
370 </a>
371 </div>
372 <script
373 dangerouslySetInnerHTML={{
374 __html: liveCommentBannerScript({
375 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
376 bannerElementId: "live-comment-banner",
377 }),
378 }}
379 />
79136bbClaude380 <div class="issue-detail">
58915a9Claude381 {info && (
382 <div style="margin: 12px 0; padding: 10px 14px; border-radius: 6px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); color: var(--text); font-size: 14px">
383 {decodeURIComponent(info)}
384 </div>
385 )}
79136bbClaude386 <h2>
387 {issue.title}{" "}
bb0f894Claude388 <span style="color:var(--text-muted);font-weight:400">
79136bbClaude389 #{issue.number}
390 </span>
391 </h2>
bb0f894Claude392 <Flex align="center" gap={8} style="margin:8px 0 20px">
393 <Badge variant={issue.state === "open" ? "open" : "closed"}>
79136bbClaude394 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
bb0f894Claude395 </Badge>
396 <span style="color:var(--text-muted);font-size:14px">
397 <strong style="color:var(--text)">
79136bbClaude398 {author?.username || "unknown"}
399 </strong>{" "}
400 opened this issue {formatRelative(issue.createdAt)}
401 </span>
fbf4aefClaude402 {issue.state === "open" && user && user.id === resolved.owner.id && (
403 <a
404 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
405 class="btn btn-primary"
406 style="margin-left:auto;font-size:13px;padding:4px 10px"
407 title="Generate a draft pull request from this issue using Claude"
408 >
409 Build with AI
410 </a>
411 )}
bb0f894Claude412 </Flex>
79136bbClaude413
414 {issue.body && (
bb0f894Claude415 <CommentBox
416 author={author?.username || "unknown"}
417 date={issue.createdAt}
418 body={renderMarkdown(issue.body)}
419 />
79136bbClaude420 )}
421
422 {comments.map(({ comment, author: commentAuthor }) => (
bb0f894Claude423 <CommentBox
424 author={commentAuthor.username}
425 date={comment.createdAt}
426 body={renderMarkdown(comment.body)}
427 />
79136bbClaude428 ))}
429
430 {user && (
431 <div style="margin-top: 20px">
432 <form
cce7944Claude433 method="post"
79136bbClaude434 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
435 >
436 <div class="form-group">
437 <textarea
438 name="body"
439 rows={6}
440 required
441 placeholder="Leave a comment... (Markdown supported)"
442 style="font-family: var(--font-mono); font-size: 13px"
443 />
444 </div>
445 <div style="display: flex; gap: 8px">
446 <button type="submit" class="btn btn-primary">
447 Comment
448 </button>
449 {canManage && (
450 <button
451 type="submit"
452 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
453 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
454 >
455 {issue.state === "open"
456 ? "Close issue"
457 : "Reopen issue"}
458 </button>
459 )}
58915a9Claude460 {canManage && issue.state === "open" && (
461 <button
462 type="submit"
463 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
464 formnovalidate
465 class="btn"
466 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
467 >
468 Re-run AI triage
469 </button>
470 )}
79136bbClaude471 </div>
472 </form>
473 </div>
474 )}
475 </div>
476 </Layout>
477 );
478});
479
480// Add comment
481issueRoutes.post(
482 "/:owner/:repo/issues/:number/comment",
483 softAuth,
484 requireAuth,
04f6b7fClaude485 requireRepoAccess("write"),
79136bbClaude486 async (c) => {
487 const { owner: ownerName, repo: repoName } = c.req.param();
488 const issueNum = parseInt(c.req.param("number"), 10);
489 const user = c.get("user")!;
490 const body = await c.req.parseBody();
491 const commentBody = String(body.body || "").trim();
492
493 if (!commentBody) {
494 return c.redirect(
495 `/${ownerName}/${repoName}/issues/${issueNum}`
496 );
497 }
498
499 const resolved = await resolveRepo(ownerName, repoName);
500 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
501
502 const [issue] = await db
503 .select()
504 .from(issues)
505 .where(
506 and(
507 eq(issues.repositoryId, resolved.repo.id),
508 eq(issues.number, issueNum)
509 )
510 )
511 .limit(1);
512
513 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
514
d4ac5c3Claude515 const [inserted] = await db
516 .insert(issueComments)
517 .values({
518 issueId: issue.id,
519 authorId: user.id,
520 body: commentBody,
521 })
522 .returning();
523
524 // Live update: nudge any browser tabs subscribed to this issue. Pure
525 // fanout — never blocks the redirect, never throws into the request.
526 if (inserted) {
527 try {
528 const { publish } = await import("../lib/sse");
529 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
530 event: "issue-comment",
531 data: {
532 issueId: issue.id,
533 commentId: inserted.id,
534 authorId: user.id,
535 authorUsername: user.username,
536 },
537 });
538 } catch {
539 /* SSE is best-effort */
540 }
541 }
79136bbClaude542
543 return c.redirect(
544 `/${ownerName}/${repoName}/issues/${issueNum}`
545 );
546 }
547);
548
549// Close issue
550issueRoutes.post(
551 "/:owner/:repo/issues/:number/close",
552 softAuth,
553 requireAuth,
04f6b7fClaude554 requireRepoAccess("write"),
79136bbClaude555 async (c) => {
556 const { owner: ownerName, repo: repoName } = c.req.param();
557 const issueNum = parseInt(c.req.param("number"), 10);
558
559 const resolved = await resolveRepo(ownerName, repoName);
560 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
561
562 await db
563 .update(issues)
564 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
565 .where(
566 and(
567 eq(issues.repositoryId, resolved.repo.id),
568 eq(issues.number, issueNum)
569 )
570 );
571
572 return c.redirect(
573 `/${ownerName}/${repoName}/issues/${issueNum}`
574 );
575 }
576);
577
578// Reopen issue
579issueRoutes.post(
580 "/:owner/:repo/issues/:number/reopen",
581 softAuth,
582 requireAuth,
04f6b7fClaude583 requireRepoAccess("write"),
79136bbClaude584 async (c) => {
585 const { owner: ownerName, repo: repoName } = c.req.param();
586 const issueNum = parseInt(c.req.param("number"), 10);
587
588 const resolved = await resolveRepo(ownerName, repoName);
589 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
590
591 await db
592 .update(issues)
593 .set({ state: "open", closedAt: null, updatedAt: new Date() })
594 .where(
595 and(
596 eq(issues.repositoryId, resolved.repo.id),
597 eq(issues.number, issueNum)
598 )
599 );
600
601 return c.redirect(
602 `/${ownerName}/${repoName}/issues/${issueNum}`
603 );
604 }
605);
606
607// Shared nav component with issues tab
608const IssueNav = ({
609 owner,
610 repo,
611 active,
612}: {
613 owner: string;
614 repo: string;
615 active: "code" | "commits" | "issues";
616}) => (
bb0f894Claude617 <TabNav
618 tabs={[
619 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
620 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
621 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
622 ]}
623 />
79136bbClaude624);
625
58915a9Claude626// Re-run AI triage on demand (e.g. after the issue body has been edited).
627// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
628issueRoutes.post(
629 "/:owner/:repo/issues/:number/ai-retriage",
630 softAuth,
631 requireAuth,
632 requireRepoAccess("write"),
633 async (c) => {
634 const { owner: ownerName, repo: repoName } = c.req.param();
635 const issueNum = parseInt(c.req.param("number"), 10);
636 const user = c.get("user")!;
637 const resolved = await resolveRepo(ownerName, repoName);
638 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
639
640 const [issue] = await db
641 .select()
642 .from(issues)
643 .where(
644 and(
645 eq(issues.repositoryId, resolved.repo.id),
646 eq(issues.number, issueNum)
647 )
648 )
649 .limit(1);
650 if (!issue) {
651 return c.redirect(`/${ownerName}/${repoName}/issues`);
652 }
653
654 triggerIssueTriage(
655 {
656 ownerName,
657 repoName,
658 repositoryId: resolved.repo.id,
659 issueId: issue.id,
660 issueNumber: issue.number,
661 authorId: user.id,
662 title: issue.title,
663 body: issue.body || "",
664 },
665 { force: true }
666 ).catch(() => {});
667
668 return c.redirect(
669 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
670 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
671 )}`
672 );
673 }
674);
675
79136bbClaude676export default issueRoutes;
677export { IssueNav };