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