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