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