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.tsxBlame878 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 )}
185 <form method="POST" action={`/${ownerName}/${repoName}/issues/new`}>
186 <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
bed6b57Claude327 // J14 — issue dependencies: blockers + blocked lists.
328 const [blockers, blocked] = await Promise.all([
329 (async () => {
330 try {
331 const { listBlockersOf } = await import("../lib/issue-dependencies");
332 return await listBlockersOf(issue.id);
333 } catch {
334 return [];
335 }
336 })(),
337 (async () => {
338 try {
339 const { listBlockedBy } = await import("../lib/issue-dependencies");
340 return await listBlockedBy(issue.id);
341 } catch {
342 return [];
343 }
344 })(),
345 ]);
346 const depError = c.req.query("depError");
347
79136bbClaude348 return c.html(
349 <Layout
350 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
351 user={user}
352 >
353 <RepoHeader owner={ownerName} repo={repoName} />
354 <IssueNav owner={ownerName} repo={repoName} active="issues" />
355 <div class="issue-detail">
356 <h2>
357 {issue.title}{" "}
358 <span style="color: var(--text-muted); font-weight: 400">
359 #{issue.number}
360 </span>
361 </h2>
362 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
363 <span
364 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
365 >
366 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
367 </span>
368 <span style="color: var(--text-muted); font-size: 14px">
369 <strong style="color: var(--text)">
370 {author?.username || "unknown"}
371 </strong>{" "}
372 opened this issue {formatRelative(issue.createdAt)}
373 </span>
374 </div>
375
bed6b57Claude376 <DependenciesPanel
377 owner={ownerName}
378 repo={repoName}
379 issueNumber={issue.number}
380 blockers={blockers}
381 blocked={blocked}
382 canManage={!!canManage}
383 depError={depError}
384 />
385
79136bbClaude386 {issue.body && (
387 <div class="issue-comment-box">
388 <div class="comment-header">
389 <strong>{author?.username}</strong> commented{" "}
390 {formatRelative(issue.createdAt)}
391 </div>
392 <div class="markdown-body">
393 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
394 </div>
6fc53bdClaude395 <div style="padding: 0 16px 12px">
396 <ReactionsBar
397 targetType="issue"
398 targetId={issue.id}
399 summaries={issueReactions}
400 canReact={!!user}
401 />
402 </div>
79136bbClaude403 </div>
404 )}
405
6fc53bdClaude406 {comments.map(({ comment, author: commentAuthor }, i) => (
79136bbClaude407 <div class="issue-comment-box">
408 <div class="comment-header">
409 <strong>{commentAuthor.username}</strong> commented{" "}
410 {formatRelative(comment.createdAt)}
411 </div>
412 <div class="markdown-body">
413 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
414 </div>
6fc53bdClaude415 <div style="padding: 0 16px 12px">
416 <ReactionsBar
417 targetType="issue_comment"
418 targetId={comment.id}
419 summaries={commentReactions[i] || []}
420 canReact={!!user}
421 />
422 </div>
79136bbClaude423 </div>
424 ))}
425
426 {user && (
427 <div style="margin-top: 20px">
428 <form
429 method="POST"
430 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
431 >
432 <div class="form-group">
433 <textarea
434 name="body"
435 rows={6}
436 required
437 placeholder="Leave a comment... (Markdown supported)"
438 style="font-family: var(--font-mono); font-size: 13px"
439 />
440 </div>
441 <div style="display: flex; gap: 8px">
442 <button type="submit" class="btn btn-primary">
443 Comment
444 </button>
445 {canManage && (
446 <button
447 type="submit"
448 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
449 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
450 >
451 {issue.state === "open"
452 ? "Close issue"
453 : "Reopen issue"}
454 </button>
455 )}
456 </div>
457 </form>
458 </div>
459 )}
460 </div>
461 </Layout>
462 );
463});
464
465// Add comment
466issueRoutes.post(
467 "/:owner/:repo/issues/:number/comment",
468 softAuth,
469 requireAuth,
470 async (c) => {
471 const { owner: ownerName, repo: repoName } = c.req.param();
472 const issueNum = parseInt(c.req.param("number"), 10);
473 const user = c.get("user")!;
474 const body = await c.req.parseBody();
475 const commentBody = String(body.body || "").trim();
476
477 if (!commentBody) {
478 return c.redirect(
479 `/${ownerName}/${repoName}/issues/${issueNum}`
480 );
481 }
482
483 const resolved = await resolveRepo(ownerName, repoName);
484 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
485
486 const [issue] = await db
487 .select()
488 .from(issues)
489 .where(
490 and(
491 eq(issues.repositoryId, resolved.repo.id),
492 eq(issues.number, issueNum)
493 )
494 )
495 .limit(1);
496
497 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
498
499 await db.insert(issueComments).values({
500 issueId: issue.id,
501 authorId: user.id,
502 body: commentBody,
503 });
504
505 return c.redirect(
506 `/${ownerName}/${repoName}/issues/${issueNum}`
507 );
508 }
509);
510
511// Close issue
512issueRoutes.post(
513 "/:owner/:repo/issues/:number/close",
514 softAuth,
515 requireAuth,
516 async (c) => {
517 const { owner: ownerName, repo: repoName } = c.req.param();
518 const issueNum = parseInt(c.req.param("number"), 10);
519
520 const resolved = await resolveRepo(ownerName, repoName);
521 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
522
523 await db
524 .update(issues)
525 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
526 .where(
527 and(
528 eq(issues.repositoryId, resolved.repo.id),
529 eq(issues.number, issueNum)
530 )
531 );
532
533 return c.redirect(
534 `/${ownerName}/${repoName}/issues/${issueNum}`
535 );
536 }
537);
538
539// Reopen issue
540issueRoutes.post(
541 "/:owner/:repo/issues/:number/reopen",
542 softAuth,
543 requireAuth,
544 async (c) => {
545 const { owner: ownerName, repo: repoName } = c.req.param();
546 const issueNum = parseInt(c.req.param("number"), 10);
547
548 const resolved = await resolveRepo(ownerName, repoName);
549 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
550
551 await db
552 .update(issues)
553 .set({ state: "open", closedAt: null, updatedAt: new Date() })
554 .where(
555 and(
556 eq(issues.repositoryId, resolved.repo.id),
557 eq(issues.number, issueNum)
558 )
559 );
560
561 return c.redirect(
562 `/${ownerName}/${repoName}/issues/${issueNum}`
563 );
564 }
565);
566
bed6b57Claude567// J14 — Add blocker dependency.
568issueRoutes.post(
569 "/:owner/:repo/issues/:number/dependencies",
570 softAuth,
571 requireAuth,
572 async (c) => {
573 const { owner: ownerName, repo: repoName } = c.req.param();
574 const issueNum = parseInt(c.req.param("number"), 10);
575 const user = c.get("user")!;
576 const body = await c.req.parseBody();
577 const blockerRaw = String(body.blockerNumber || "").trim().replace(/^#/, "");
578 const blockerNum = parseInt(blockerRaw, 10);
579
580 const resolved = await resolveRepo(ownerName, repoName);
581 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
582
583 if (!Number.isFinite(blockerNum) || blockerNum <= 0) {
584 return c.redirect(
585 `/${ownerName}/${repoName}/issues/${issueNum}?depError=invalid`
586 );
587 }
588
589 const [blocked] = await db
590 .select()
591 .from(issues)
592 .where(
593 and(
594 eq(issues.repositoryId, resolved.repo.id),
595 eq(issues.number, issueNum)
596 )
597 )
598 .limit(1);
599 if (!blocked) return c.redirect(`/${ownerName}/${repoName}/issues`);
600
601 const [blocker] = await db
602 .select()
603 .from(issues)
604 .where(
605 and(
606 eq(issues.repositoryId, resolved.repo.id),
607 eq(issues.number, blockerNum)
608 )
609 )
610 .limit(1);
611 if (!blocker) {
612 return c.redirect(
613 `/${ownerName}/${repoName}/issues/${issueNum}?depError=not_found`
614 );
615 }
616
617 const canManage =
618 user.id === resolved.owner.id || user.id === blocked.authorId;
619 if (!canManage) {
620 return c.redirect(
621 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
622 );
623 }
624
625 const { addDependency } = await import("../lib/issue-dependencies");
626 const result = await addDependency({
627 blockerIssueId: blocker.id,
628 blockedIssueId: blocked.id,
629 createdBy: user.id,
630 });
631 if (!result.ok) {
632 return c.redirect(
633 `/${ownerName}/${repoName}/issues/${issueNum}?depError=${result.reason}`
634 );
635 }
636 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
637 }
638);
639
640// J14 — Remove blocker dependency. :which is either "blockers" or "blocks".
641issueRoutes.post(
642 "/:owner/:repo/issues/:number/dependencies/:which/:otherId/remove",
643 softAuth,
644 requireAuth,
645 async (c) => {
646 const { owner: ownerName, repo: repoName } = c.req.param();
647 const issueNum = parseInt(c.req.param("number"), 10);
648 const which = c.req.param("which");
649 const otherId = c.req.param("otherId");
650 const user = c.get("user")!;
651
652 const resolved = await resolveRepo(ownerName, repoName);
653 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
654
655 const [thisIssue] = await db
656 .select()
657 .from(issues)
658 .where(
659 and(
660 eq(issues.repositoryId, resolved.repo.id),
661 eq(issues.number, issueNum)
662 )
663 )
664 .limit(1);
665 if (!thisIssue) return c.redirect(`/${ownerName}/${repoName}/issues`);
666
667 const canManage =
668 user.id === resolved.owner.id || user.id === thisIssue.authorId;
669 if (!canManage) {
670 return c.redirect(
671 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
672 );
673 }
674
675 const { removeDependency } = await import("../lib/issue-dependencies");
676 // which === "blockers" → otherId is the blocker; this issue is blocked.
677 // which === "blocks" → otherId is the blocked; this issue is blocker.
678 if (which === "blockers") {
679 await removeDependency(otherId, thisIssue.id);
680 } else if (which === "blocks") {
681 await removeDependency(thisIssue.id, otherId);
682 }
683 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
684 }
685);
686
687// J14 — Dependencies UI panel.
688type DepRow = {
689 id: string;
690 issueId: string;
691 number: number;
692 title: string;
693 state: string;
694 authorUsername: string;
695};
696
697function depErrorMessage(reason: string | undefined): string | null {
698 if (!reason) return null;
699 switch (reason) {
700 case "self":
701 return "An issue cannot block itself.";
702 case "cross_repo":
703 return "Both issues must belong to the same repository.";
704 case "exists":
705 return "That dependency already exists.";
706 case "cycle":
707 return "That dependency would create a cycle.";
708 case "not_found":
709 return "Issue not found.";
710 case "invalid":
711 return "Invalid issue number.";
712 case "forbidden":
713 return "You don't have permission to change dependencies on this issue.";
714 default:
715 return "Could not update dependencies.";
716 }
717}
718
719const DependenciesPanel = ({
720 owner,
721 repo,
722 issueNumber,
723 blockers,
724 blocked,
725 canManage,
726 depError,
727}: {
728 owner: string;
729 repo: string;
730 issueNumber: number;
731 blockers: DepRow[];
732 blocked: DepRow[];
733 canManage: boolean;
734 depError: string | undefined;
735}) => {
736 if (blockers.length === 0 && blocked.length === 0 && !canManage) return null;
737 const errMsg = depErrorMessage(depError);
738 const renderRow = (row: DepRow, which: "blockers" | "blocks") => (
739 <div
740 style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border)"
741 >
742 <span
743 class={`issue-badge ${row.state === "open" ? "badge-open" : "badge-closed"}`}
744 style="font-size: 11px; padding: 1px 6px"
745 >
746 {row.state === "open" ? "\u25CB" : "\u2713"}
747 </span>
748 <a
749 href={`/${owner}/${repo}/issues/${row.number}`}
750 style="flex: 1; text-decoration: none"
751 >
752 <span style="color: var(--text-muted)">#{row.number}</span>{" "}
753 <span>{row.title}</span>
754 </a>
755 <span style="color: var(--text-muted); font-size: 12px">
756 by {row.authorUsername}
757 </span>
758 {canManage && (
759 <form
760 method="POST"
761 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies/${which}/${row.issueId}/remove`}
762 style="margin: 0"
763 >
764 <button
765 type="submit"
766 class="btn"
767 style="padding: 2px 8px; font-size: 11px"
768 title="Remove dependency"
769 >
770 {"\u2715"}
771 </button>
772 </form>
773 )}
774 </div>
775 );
776 return (
777 <div
778 style="margin: 16px 0; padding: 12px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-secondary)"
779 >
780 <div style="font-weight: 600; margin-bottom: 8px">Dependencies</div>
781 {errMsg && <div class="auth-error" style="margin-bottom: 8px">{errMsg}</div>}
782
783 <div style="margin-bottom: 12px">
784 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
785 Blocked by ({blockers.length})
786 </div>
787 {blockers.length === 0 ? (
788 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
789 No blockers.
790 </div>
791 ) : (
792 blockers.map((r) => renderRow(r, "blockers"))
793 )}
794 {canManage && (
795 <form
796 method="POST"
797 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies`}
798 style="display: flex; gap: 6px; margin-top: 8px"
799 >
800 <input
801 type="text"
802 name="blockerNumber"
803 required
804 placeholder="#123"
805 style="flex: 1; padding: 4px 8px; font-size: 12px"
806 />
807 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
808 Add blocker
809 </button>
810 </form>
811 )}
812 </div>
813
814 <div>
815 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
816 Blocks ({blocked.length})
817 </div>
818 {blocked.length === 0 ? (
819 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
820 This issue does not block any others.
821 </div>
822 ) : (
823 blocked.map((r) => renderRow(r, "blocks"))
824 )}
825 </div>
826 </div>
827 );
828};
829
79136bbClaude830// Shared nav component with issues tab
831const IssueNav = ({
832 owner,
833 repo,
834 active,
835}: {
836 owner: string;
837 repo: string;
838 active: "code" | "commits" | "issues";
839}) => (
840 <div class="repo-nav">
841 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
842 Code
843 </a>
844 <a
845 href={`/${owner}/${repo}/issues`}
846 class={active === "issues" ? "active" : ""}
847 >
848 Issues
849 </a>
850 <a
851 href={`/${owner}/${repo}/commits`}
852 class={active === "commits" ? "active" : ""}
853 >
854 Commits
855 </a>
856 </div>
857);
858
859function formatRelative(date: Date | string): string {
860 const d = typeof date === "string" ? new Date(date) : date;
861 const now = new Date();
862 const diffMs = now.getTime() - d.getTime();
863 const diffMins = Math.floor(diffMs / 60000);
864 if (diffMins < 1) return "just now";
865 if (diffMins < 60) return `${diffMins}m ago`;
866 const diffHours = Math.floor(diffMins / 60);
867 if (diffHours < 24) return `${diffHours}h ago`;
868 const diffDays = Math.floor(diffHours / 24);
869 if (diffDays < 30) return `${diffDays}d ago`;
870 return d.toLocaleDateString("en-US", {
871 month: "short",
872 day: "numeric",
873 year: "numeric",
874 });
875}
876
877export default issueRoutes;
878export { IssueNav };