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.tsxBlame584 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";
24import { html } from "hono/html";
25
26const issueRoutes = new Hono<AuthEnv>();
27
28// Helper to resolve repo from :owner/:repo params
29async function resolveRepo(ownerName: string, repoName: string) {
30 const [owner] = await db
31 .select()
32 .from(users)
33 .where(eq(users.username, ownerName))
34 .limit(1);
35 if (!owner) return null;
36
37 const [repo] = await db
38 .select()
39 .from(repositories)
40 .where(
41 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
42 )
43 .limit(1);
44 if (!repo) return null;
45
46 return { owner, repo };
47}
48
49// Issue list
50issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
51 const { owner: ownerName, repo: repoName } = c.req.param();
52 const user = c.get("user");
53 const state = c.req.query("state") || "open";
54
55 const resolved = await resolveRepo(ownerName, repoName);
56 if (!resolved) {
57 return c.html(
58 <Layout title="Not Found" user={user}>
59 <div class="empty-state">
60 <h2>Repository not found</h2>
61 </div>
62 </Layout>,
63 404
64 );
65 }
66
67 const { repo } = resolved;
68
69 const issueList = await db
70 .select({
71 issue: issues,
72 author: { username: users.username },
73 })
74 .from(issues)
75 .innerJoin(users, eq(issues.authorId, users.id))
76 .where(
77 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
78 )
79 .orderBy(desc(issues.createdAt));
80
81 // Count open/closed
82 const [counts] = await db
83 .select({
84 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
85 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
86 })
87 .from(issues)
88 .where(eq(issues.repositoryId, repo.id));
89
90 return c.html(
91 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
92 <RepoHeader owner={ownerName} repo={repoName} />
93 <IssueNav owner={ownerName} repo={repoName} active="issues" />
94 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
95 <div class="issue-tabs">
96 <a
97 href={`/${ownerName}/${repoName}/issues?state=open`}
98 class={state === "open" ? "active" : ""}
99 >
100 {counts?.open ?? 0} Open
101 </a>
102 <a
103 href={`/${ownerName}/${repoName}/issues?state=closed`}
104 class={state === "closed" ? "active" : ""}
105 >
106 {counts?.closed ?? 0} Closed
107 </a>
108 </div>
109 {user && (
110 <a
111 href={`/${ownerName}/${repoName}/issues/new`}
112 class="btn btn-primary"
113 >
114 New issue
115 </a>
116 )}
117 </div>
118 {issueList.length === 0 ? (
119 <div class="empty-state">
120 <p>
121 No {state} issues.
122 {state === "closed" && (
123 <span>
124 {" "}
125 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
126 View open issues
127 </a>
128 </span>
129 )}
130 </p>
131 </div>
132 ) : (
133 <div class="issue-list">
134 {issueList.map(({ issue, author }) => (
135 <div class="issue-item">
136 <div
137 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
138 >
139 {issue.state === "open" ? "\u25CB" : "\u2713"}
140 </div>
141 <div>
142 <div class="issue-title">
143 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
144 {issue.title}
145 </a>
146 </div>
147 <div class="issue-meta">
148 #{issue.number} opened by {author.username}{" "}
149 {formatRelative(issue.createdAt)}
150 </div>
151 </div>
152 </div>
153 ))}
154 </div>
155 )}
156 </Layout>
157 );
158});
159
160// New issue form
161issueRoutes.get(
162 "/:owner/:repo/issues/new",
163 softAuth,
164 requireAuth,
165 async (c) => {
166 const { owner: ownerName, repo: repoName } = c.req.param();
167 const user = c.get("user")!;
168 const error = c.req.query("error");
24cf2caClaude169 const template = await loadIssueTemplate(ownerName, repoName);
79136bbClaude170
171 return c.html(
172 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
173 <RepoHeader owner={ownerName} repo={repoName} />
174 <IssueNav owner={ownerName} repo={repoName} active="issues" />
175 <div style="max-width: 800px">
176 <h2 style="margin-bottom: 16px">New issue</h2>
24cf2caClaude177 {template && (
178 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
179 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
180 </div>
181 )}
79136bbClaude182 {error && (
183 <div class="auth-error">{decodeURIComponent(error)}</div>
184 )}
9837657Claude185 <form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
79136bbClaude186 <div class="form-group">
187 <input
188 type="text"
189 name="title"
190 required
191 placeholder="Title"
192 style="font-size: 16px; padding: 10px 14px"
193 />
194 </div>
195 <div class="form-group">
196 <textarea
197 name="body"
198 rows={12}
199 placeholder="Leave a comment... (Markdown supported)"
200 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude201 >
202 {template || ""}
203 </textarea>
79136bbClaude204 </div>
205 <button type="submit" class="btn btn-primary">
206 Submit new issue
207 </button>
208 </form>
209 </div>
210 </Layout>
211 );
212 }
213);
214
215// Create issue
216issueRoutes.post(
217 "/:owner/:repo/issues/new",
218 softAuth,
219 requireAuth,
220 async (c) => {
221 const { owner: ownerName, repo: repoName } = c.req.param();
222 const user = c.get("user")!;
223 const body = await c.req.parseBody();
224 const title = String(body.title || "").trim();
225 const issueBody = String(body.body || "").trim();
226
227 if (!title) {
228 return c.redirect(
229 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
230 );
231 }
232
233 const resolved = await resolveRepo(ownerName, repoName);
234 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
235
236 const [issue] = await db
237 .insert(issues)
238 .values({
239 repositoryId: resolved.repo.id,
240 authorId: user.id,
241 title,
242 body: issueBody || null,
243 })
244 .returning();
245
246 // Update issue count
247 await db
248 .update(repositories)
249 .set({ issueCount: resolved.repo.issueCount + 1 })
250 .where(eq(repositories.id, resolved.repo.id));
251
252 return c.redirect(
253 `/${ownerName}/${repoName}/issues/${issue.number}`
254 );
255 }
256);
257
258// View single issue
259issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
260 const { owner: ownerName, repo: repoName } = c.req.param();
261 const issueNum = parseInt(c.req.param("number"), 10);
262 const user = c.get("user");
263
264 const resolved = await resolveRepo(ownerName, repoName);
265 if (!resolved) {
266 return c.html(
267 <Layout title="Not Found" user={user}>
268 <div class="empty-state">
269 <h2>Not found</h2>
270 </div>
271 </Layout>,
272 404
273 );
274 }
275
276 const [issue] = await db
277 .select()
278 .from(issues)
279 .where(
280 and(
281 eq(issues.repositoryId, resolved.repo.id),
282 eq(issues.number, issueNum)
283 )
284 )
285 .limit(1);
286
287 if (!issue) {
288 return c.html(
289 <Layout title="Not Found" user={user}>
290 <div class="empty-state">
291 <h2>Issue not found</h2>
292 </div>
293 </Layout>,
294 404
295 );
296 }
297
298 const [author] = await db
299 .select()
300 .from(users)
301 .where(eq(users.id, issue.authorId))
302 .limit(1);
303
304 // Get comments
305 const comments = await db
306 .select({
307 comment: issueComments,
308 author: { username: users.username },
309 })
310 .from(issueComments)
311 .innerJoin(users, eq(issueComments.authorId, users.id))
312 .where(eq(issueComments.issueId, issue.id))
313 .orderBy(asc(issueComments.createdAt));
314
6fc53bdClaude315 // Load reactions for the issue + each comment in parallel.
316 const [issueReactions, ...commentReactions] = await Promise.all([
317 summariseReactions("issue", issue.id, user?.id),
318 ...comments.map((row) =>
319 summariseReactions("issue_comment", row.comment.id, user?.id)
320 ),
321 ]);
322
79136bbClaude323 const canManage =
324 user &&
325 (user.id === resolved.owner.id || user.id === issue.authorId);
326
327 return c.html(
328 <Layout
329 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
330 user={user}
331 >
332 <RepoHeader owner={ownerName} repo={repoName} />
333 <IssueNav owner={ownerName} repo={repoName} active="issues" />
334 <div class="issue-detail">
335 <h2>
336 {issue.title}{" "}
337 <span style="color: var(--text-muted); font-weight: 400">
338 #{issue.number}
339 </span>
340 </h2>
341 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
342 <span
343 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
344 >
345 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
346 </span>
347 <span style="color: var(--text-muted); font-size: 14px">
348 <strong style="color: var(--text)">
349 {author?.username || "unknown"}
350 </strong>{" "}
351 opened this issue {formatRelative(issue.createdAt)}
352 </span>
353 </div>
354
355 {issue.body && (
356 <div class="issue-comment-box">
357 <div class="comment-header">
358 <strong>{author?.username}</strong> commented{" "}
359 {formatRelative(issue.createdAt)}
360 </div>
361 <div class="markdown-body">
362 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
363 </div>
6fc53bdClaude364 <div style="padding: 0 16px 12px">
365 <ReactionsBar
366 targetType="issue"
367 targetId={issue.id}
368 summaries={issueReactions}
369 canReact={!!user}
370 />
371 </div>
79136bbClaude372 </div>
373 )}
374
6fc53bdClaude375 {comments.map(({ comment, author: commentAuthor }, i) => (
79136bbClaude376 <div class="issue-comment-box">
377 <div class="comment-header">
378 <strong>{commentAuthor.username}</strong> commented{" "}
379 {formatRelative(comment.createdAt)}
380 </div>
381 <div class="markdown-body">
382 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
383 </div>
6fc53bdClaude384 <div style="padding: 0 16px 12px">
385 <ReactionsBar
386 targetType="issue_comment"
387 targetId={comment.id}
388 summaries={commentReactions[i] || []}
389 canReact={!!user}
390 />
391 </div>
79136bbClaude392 </div>
393 ))}
394
395 {user && (
396 <div style="margin-top: 20px">
397 <form
9837657Claude398 method="post"
79136bbClaude399 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
400 >
401 <div class="form-group">
402 <textarea
403 name="body"
404 rows={6}
405 required
406 placeholder="Leave a comment... (Markdown supported)"
407 style="font-family: var(--font-mono); font-size: 13px"
408 />
409 </div>
410 <div style="display: flex; gap: 8px">
411 <button type="submit" class="btn btn-primary">
412 Comment
413 </button>
414 {canManage && (
415 <button
416 type="submit"
417 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
418 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
419 >
420 {issue.state === "open"
421 ? "Close issue"
422 : "Reopen issue"}
423 </button>
424 )}
425 </div>
426 </form>
427 </div>
428 )}
429 </div>
430 </Layout>
431 );
432});
433
434// Add comment
435issueRoutes.post(
436 "/:owner/:repo/issues/:number/comment",
437 softAuth,
438 requireAuth,
439 async (c) => {
440 const { owner: ownerName, repo: repoName } = c.req.param();
441 const issueNum = parseInt(c.req.param("number"), 10);
442 const user = c.get("user")!;
443 const body = await c.req.parseBody();
444 const commentBody = String(body.body || "").trim();
445
446 if (!commentBody) {
447 return c.redirect(
448 `/${ownerName}/${repoName}/issues/${issueNum}`
449 );
450 }
451
452 const resolved = await resolveRepo(ownerName, repoName);
453 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
454
455 const [issue] = await db
456 .select()
457 .from(issues)
458 .where(
459 and(
460 eq(issues.repositoryId, resolved.repo.id),
461 eq(issues.number, issueNum)
462 )
463 )
464 .limit(1);
465
466 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
467
468 await db.insert(issueComments).values({
469 issueId: issue.id,
470 authorId: user.id,
471 body: commentBody,
472 });
473
474 return c.redirect(
475 `/${ownerName}/${repoName}/issues/${issueNum}`
476 );
477 }
478);
479
480// Close issue
481issueRoutes.post(
482 "/:owner/:repo/issues/:number/close",
483 softAuth,
484 requireAuth,
485 async (c) => {
486 const { owner: ownerName, repo: repoName } = c.req.param();
487 const issueNum = parseInt(c.req.param("number"), 10);
488
489 const resolved = await resolveRepo(ownerName, repoName);
490 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
491
492 await db
493 .update(issues)
494 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
495 .where(
496 and(
497 eq(issues.repositoryId, resolved.repo.id),
498 eq(issues.number, issueNum)
499 )
500 );
501
502 return c.redirect(
503 `/${ownerName}/${repoName}/issues/${issueNum}`
504 );
505 }
506);
507
508// Reopen issue
509issueRoutes.post(
510 "/:owner/:repo/issues/:number/reopen",
511 softAuth,
512 requireAuth,
513 async (c) => {
514 const { owner: ownerName, repo: repoName } = c.req.param();
515 const issueNum = parseInt(c.req.param("number"), 10);
516
517 const resolved = await resolveRepo(ownerName, repoName);
518 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
519
520 await db
521 .update(issues)
522 .set({ state: "open", closedAt: null, updatedAt: new Date() })
523 .where(
524 and(
525 eq(issues.repositoryId, resolved.repo.id),
526 eq(issues.number, issueNum)
527 )
528 );
529
530 return c.redirect(
531 `/${ownerName}/${repoName}/issues/${issueNum}`
532 );
533 }
534);
535
536// Shared nav component with issues tab
537const IssueNav = ({
538 owner,
539 repo,
540 active,
541}: {
542 owner: string;
543 repo: string;
544 active: "code" | "commits" | "issues";
545}) => (
546 <div class="repo-nav">
547 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
548 Code
549 </a>
550 <a
551 href={`/${owner}/${repo}/issues`}
552 class={active === "issues" ? "active" : ""}
553 >
554 Issues
555 </a>
556 <a
557 href={`/${owner}/${repo}/commits`}
558 class={active === "commits" ? "active" : ""}
559 >
560 Commits
561 </a>
562 </div>
563);
564
565function formatRelative(date: Date | string): string {
566 const d = typeof date === "string" ? new Date(date) : date;
567 const now = new Date();
568 const diffMs = now.getTime() - d.getTime();
569 const diffMins = Math.floor(diffMs / 60000);
570 if (diffMins < 1) return "just now";
571 if (diffMins < 60) return `${diffMins}m ago`;
572 const diffHours = Math.floor(diffMins / 60);
573 if (diffHours < 24) return `${diffHours}h ago`;
574 const diffDays = Math.floor(diffHours / 24);
575 if (diffDays < 30) return `${diffDays}d ago`;
576 return d.toLocaleDateString("en-US", {
577 month: "short",
578 day: "numeric",
579 year: "numeric",
580 });
581}
582
583export default issueRoutes;
584export { IssueNav };