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.tsxBlame991 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";
9a4eb42Claude21import {
22 listIssueTemplates,
23 findTemplateBySlug,
24 type IssueTemplate,
25} from "../lib/issue-templates";
79136bbClaude26import { renderMarkdown } from "../lib/markdown";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { html } from "hono/html";
30
31const issueRoutes = new Hono<AuthEnv>();
32
33// Helper to resolve repo from :owner/:repo params
34async function resolveRepo(ownerName: string, repoName: string) {
35 const [owner] = await db
36 .select()
37 .from(users)
38 .where(eq(users.username, ownerName))
39 .limit(1);
40 if (!owner) return null;
41
42 const [repo] = await db
43 .select()
44 .from(repositories)
45 .where(
46 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
47 )
48 .limit(1);
49 if (!repo) return null;
50
51 return { owner, repo };
52}
53
54// Issue list
55issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
56 const { owner: ownerName, repo: repoName } = c.req.param();
57 const user = c.get("user");
58 const state = c.req.query("state") || "open";
59
60 const resolved = await resolveRepo(ownerName, repoName);
61 if (!resolved) {
62 return c.html(
63 <Layout title="Not Found" user={user}>
64 <div class="empty-state">
65 <h2>Repository not found</h2>
66 </div>
67 </Layout>,
68 404
69 );
70 }
71
72 const { repo } = resolved;
73
74 const issueList = await db
75 .select({
76 issue: issues,
77 author: { username: users.username },
78 })
79 .from(issues)
80 .innerJoin(users, eq(issues.authorId, users.id))
81 .where(
82 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
83 )
84 .orderBy(desc(issues.createdAt));
85
86 // Count open/closed
87 const [counts] = await db
88 .select({
89 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
90 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
91 })
92 .from(issues)
93 .where(eq(issues.repositoryId, repo.id));
94
95 return c.html(
96 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
97 <RepoHeader owner={ownerName} repo={repoName} />
98 <IssueNav owner={ownerName} repo={repoName} active="issues" />
99 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
100 <div class="issue-tabs">
101 <a
102 href={`/${ownerName}/${repoName}/issues?state=open`}
103 class={state === "open" ? "active" : ""}
104 >
105 {counts?.open ?? 0} Open
106 </a>
107 <a
108 href={`/${ownerName}/${repoName}/issues?state=closed`}
109 class={state === "closed" ? "active" : ""}
110 >
111 {counts?.closed ?? 0} Closed
112 </a>
113 </div>
d6b4e67Claude114 <div style="display: flex; gap: 8px; align-items: center">
79136bbClaude115 <a
d6b4e67Claude116 href={`/${ownerName}/${repoName}/issues/stale`}
117 class="btn"
118 style="padding: 4px 10px; font-size: 12px"
79136bbClaude119 >
d6b4e67Claude120 Stale
79136bbClaude121 </a>
d6b4e67Claude122 {user && (
123 <a
124 href={`/${ownerName}/${repoName}/issues/new`}
125 class="btn btn-primary"
126 >
127 New issue
128 </a>
129 )}
130 </div>
79136bbClaude131 </div>
132 {issueList.length === 0 ? (
133 <div class="empty-state">
134 <p>
135 No {state} issues.
136 {state === "closed" && (
137 <span>
138 {" "}
139 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
140 View open issues
141 </a>
142 </span>
143 )}
144 </p>
145 </div>
146 ) : (
147 <div class="issue-list">
148 {issueList.map(({ issue, author }) => (
149 <div class="issue-item">
150 <div
151 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
152 >
153 {issue.state === "open" ? "\u25CB" : "\u2713"}
154 </div>
155 <div>
156 <div class="issue-title">
157 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
158 {issue.title}
159 </a>
160 </div>
161 <div class="issue-meta">
162 #{issue.number} opened by {author.username}{" "}
163 {formatRelative(issue.createdAt)}
164 </div>
165 </div>
166 </div>
167 ))}
168 </div>
169 )}
170 </Layout>
171 );
172});
173
174// New issue form
175issueRoutes.get(
176 "/:owner/:repo/issues/new",
177 softAuth,
178 requireAuth,
179 async (c) => {
180 const { owner: ownerName, repo: repoName } = c.req.param();
181 const user = c.get("user")!;
182 const error = c.req.query("error");
9a4eb42Claude183 const slug = c.req.query("template");
184
185 // J17 — multi-template selector. Fetch the list first; if there are 2+
186 // templates and the user has not picked one, show a chooser. If exactly
187 // one template exists, use it automatically. Fall back to the legacy
188 // single-file loader when no frontmatter templates are found.
189 const multi = await listIssueTemplates(ownerName, repoName);
190 const picked: IssueTemplate | null = findTemplateBySlug(multi, slug);
191
192 if (!picked && multi.length >= 2 && !slug) {
193 return c.html(
194 <Layout
195 title={`New issue — ${ownerName}/${repoName}`}
196 user={user}
197 >
198 <RepoHeader owner={ownerName} repo={repoName} />
199 <IssueNav owner={ownerName} repo={repoName} active="issues" />
200 <div style="max-width: 720px">
201 <h2 style="margin-bottom: 4px">New issue</h2>
202 <p style="color: var(--text-muted); margin-bottom: 24px">
203 Choose a template to get started, or{" "}
204 <a
205 href={`/${ownerName}/${repoName}/issues/new?template=__blank`}
206 >
207 open a blank issue
208 </a>
209 .
210 </p>
211 <div style="display: flex; flex-direction: column; gap: 12px">
212 {multi.map((t) => (
213 <div style="display: flex; align-items: center; gap: 16px; border: 1px solid var(--border); border-radius: 6px; padding: 16px; background: var(--bg-secondary)">
214 <div style="flex: 1; min-width: 0">
215 <div style="font-weight: 600; margin-bottom: 4px">
216 {t.name}
217 </div>
218 {t.about && (
219 <div style="font-size: 13px; color: var(--text-muted)">
220 {t.about}
221 </div>
222 )}
223 {t.labels.length > 0 && (
224 <div style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px">
225 {t.labels.map((l) => (
226 <span style="display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg); border: 1px solid var(--border); color: var(--text-muted)">
227 {l}
228 </span>
229 ))}
230 </div>
231 )}
232 </div>
233 <a
234 href={`/${ownerName}/${repoName}/issues/new?template=${encodeURIComponent(t.slug)}`}
235 class="btn btn-primary"
236 >
237 Get started
238 </a>
239 </div>
240 ))}
241 </div>
242 </div>
243 </Layout>
244 );
245 }
246
247 // Auto-pick the single template when only one exists and no slug is set.
248 const auto = !picked && !slug && multi.length === 1 ? multi[0] : null;
249 const active = picked || auto;
250
251 // Legacy fallback for repos that ship a plain ISSUE_TEMPLATE.md.
252 const legacy =
253 !active && slug !== "__blank"
254 ? await loadIssueTemplate(ownerName, repoName)
255 : null;
256
257 const prefillTitle = active?.title ?? "";
258 const prefillBody = active ? active.body : legacy || "";
79136bbClaude259
260 return c.html(
261 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
262 <RepoHeader owner={ownerName} repo={repoName} />
263 <IssueNav owner={ownerName} repo={repoName} active="issues" />
264 <div style="max-width: 800px">
265 <h2 style="margin-bottom: 16px">New issue</h2>
9a4eb42Claude266 {active && (
267 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
268 Using template <code>{active.path}</code>
269 {multi.length >= 2 && (
270 <>
271 {" — "}
272 <a href={`/${ownerName}/${repoName}/issues/new`}>
273 choose a different template
274 </a>
275 </>
276 )}
277 .
278 </div>
279 )}
280 {!active && legacy && (
24cf2caClaude281 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
282 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
283 </div>
284 )}
9a4eb42Claude285 {active && active.labels.length > 0 && (
286 <div style="margin-bottom: 8px">
287 {active.labels.map((l) => (
288 <span style="display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text-muted); margin-right: 6px">
289 {l}
290 </span>
291 ))}
292 </div>
293 )}
79136bbClaude294 {error && (
295 <div class="auth-error">{decodeURIComponent(error)}</div>
296 )}
297 <form method="POST" action={`/${ownerName}/${repoName}/issues/new`}>
298 <div class="form-group">
299 <input
300 type="text"
301 name="title"
302 required
303 placeholder="Title"
9a4eb42Claude304 value={prefillTitle}
79136bbClaude305 style="font-size: 16px; padding: 10px 14px"
306 />
307 </div>
308 <div class="form-group">
309 <textarea
310 name="body"
311 rows={12}
312 placeholder="Leave a comment... (Markdown supported)"
313 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude314 >
9a4eb42Claude315 {prefillBody}
24cf2caClaude316 </textarea>
79136bbClaude317 </div>
318 <button type="submit" class="btn btn-primary">
319 Submit new issue
320 </button>
321 </form>
322 </div>
323 </Layout>
324 );
325 }
326);
327
328// Create issue
329issueRoutes.post(
330 "/:owner/:repo/issues/new",
331 softAuth,
332 requireAuth,
333 async (c) => {
334 const { owner: ownerName, repo: repoName } = c.req.param();
335 const user = c.get("user")!;
336 const body = await c.req.parseBody();
337 const title = String(body.title || "").trim();
338 const issueBody = String(body.body || "").trim();
339
340 if (!title) {
341 return c.redirect(
342 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
343 );
344 }
345
346 const resolved = await resolveRepo(ownerName, repoName);
347 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
348
349 const [issue] = await db
350 .insert(issues)
351 .values({
352 repositoryId: resolved.repo.id,
353 authorId: user.id,
354 title,
355 body: issueBody || null,
356 })
357 .returning();
358
359 // Update issue count
360 await db
361 .update(repositories)
362 .set({ issueCount: resolved.repo.issueCount + 1 })
363 .where(eq(repositories.id, resolved.repo.id));
364
365 return c.redirect(
366 `/${ownerName}/${repoName}/issues/${issue.number}`
367 );
368 }
369);
370
371// View single issue
372issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
373 const { owner: ownerName, repo: repoName } = c.req.param();
374 const issueNum = parseInt(c.req.param("number"), 10);
375 const user = c.get("user");
376
377 const resolved = await resolveRepo(ownerName, repoName);
378 if (!resolved) {
379 return c.html(
380 <Layout title="Not Found" user={user}>
381 <div class="empty-state">
382 <h2>Not found</h2>
383 </div>
384 </Layout>,
385 404
386 );
387 }
388
389 const [issue] = await db
390 .select()
391 .from(issues)
392 .where(
393 and(
394 eq(issues.repositoryId, resolved.repo.id),
395 eq(issues.number, issueNum)
396 )
397 )
398 .limit(1);
399
400 if (!issue) {
401 return c.html(
402 <Layout title="Not Found" user={user}>
403 <div class="empty-state">
404 <h2>Issue not found</h2>
405 </div>
406 </Layout>,
407 404
408 );
409 }
410
411 const [author] = await db
412 .select()
413 .from(users)
414 .where(eq(users.id, issue.authorId))
415 .limit(1);
416
417 // Get comments
418 const comments = await db
419 .select({
420 comment: issueComments,
421 author: { username: users.username },
422 })
423 .from(issueComments)
424 .innerJoin(users, eq(issueComments.authorId, users.id))
425 .where(eq(issueComments.issueId, issue.id))
426 .orderBy(asc(issueComments.createdAt));
427
6fc53bdClaude428 // Load reactions for the issue + each comment in parallel.
429 const [issueReactions, ...commentReactions] = await Promise.all([
430 summariseReactions("issue", issue.id, user?.id),
431 ...comments.map((row) =>
432 summariseReactions("issue_comment", row.comment.id, user?.id)
433 ),
434 ]);
435
79136bbClaude436 const canManage =
437 user &&
438 (user.id === resolved.owner.id || user.id === issue.authorId);
439
bed6b57Claude440 // J14 — issue dependencies: blockers + blocked lists.
441 const [blockers, blocked] = await Promise.all([
442 (async () => {
443 try {
444 const { listBlockersOf } = await import("../lib/issue-dependencies");
445 return await listBlockersOf(issue.id);
446 } catch {
447 return [];
448 }
449 })(),
450 (async () => {
451 try {
452 const { listBlockedBy } = await import("../lib/issue-dependencies");
453 return await listBlockedBy(issue.id);
454 } catch {
455 return [];
456 }
457 })(),
458 ]);
459 const depError = c.req.query("depError");
460
79136bbClaude461 return c.html(
462 <Layout
463 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
464 user={user}
465 >
466 <RepoHeader owner={ownerName} repo={repoName} />
467 <IssueNav owner={ownerName} repo={repoName} active="issues" />
468 <div class="issue-detail">
469 <h2>
470 {issue.title}{" "}
471 <span style="color: var(--text-muted); font-weight: 400">
472 #{issue.number}
473 </span>
474 </h2>
475 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
476 <span
477 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
478 >
479 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
480 </span>
481 <span style="color: var(--text-muted); font-size: 14px">
482 <strong style="color: var(--text)">
483 {author?.username || "unknown"}
484 </strong>{" "}
485 opened this issue {formatRelative(issue.createdAt)}
486 </span>
487 </div>
488
bed6b57Claude489 <DependenciesPanel
490 owner={ownerName}
491 repo={repoName}
492 issueNumber={issue.number}
493 blockers={blockers}
494 blocked={blocked}
495 canManage={!!canManage}
496 depError={depError}
497 />
498
79136bbClaude499 {issue.body && (
500 <div class="issue-comment-box">
501 <div class="comment-header">
502 <strong>{author?.username}</strong> commented{" "}
503 {formatRelative(issue.createdAt)}
504 </div>
505 <div class="markdown-body">
506 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
507 </div>
6fc53bdClaude508 <div style="padding: 0 16px 12px">
509 <ReactionsBar
510 targetType="issue"
511 targetId={issue.id}
512 summaries={issueReactions}
513 canReact={!!user}
514 />
515 </div>
79136bbClaude516 </div>
517 )}
518
6fc53bdClaude519 {comments.map(({ comment, author: commentAuthor }, i) => (
79136bbClaude520 <div class="issue-comment-box">
521 <div class="comment-header">
522 <strong>{commentAuthor.username}</strong> commented{" "}
523 {formatRelative(comment.createdAt)}
524 </div>
525 <div class="markdown-body">
526 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
527 </div>
6fc53bdClaude528 <div style="padding: 0 16px 12px">
529 <ReactionsBar
530 targetType="issue_comment"
531 targetId={comment.id}
532 summaries={commentReactions[i] || []}
533 canReact={!!user}
534 />
535 </div>
79136bbClaude536 </div>
537 ))}
538
539 {user && (
540 <div style="margin-top: 20px">
541 <form
542 method="POST"
543 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
544 >
545 <div class="form-group">
546 <textarea
547 name="body"
548 rows={6}
549 required
550 placeholder="Leave a comment... (Markdown supported)"
551 style="font-family: var(--font-mono); font-size: 13px"
552 />
553 </div>
554 <div style="display: flex; gap: 8px">
555 <button type="submit" class="btn btn-primary">
556 Comment
557 </button>
558 {canManage && (
559 <button
560 type="submit"
561 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
562 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
563 >
564 {issue.state === "open"
565 ? "Close issue"
566 : "Reopen issue"}
567 </button>
568 )}
569 </div>
570 </form>
571 </div>
572 )}
573 </div>
574 </Layout>
575 );
576});
577
578// Add comment
579issueRoutes.post(
580 "/:owner/:repo/issues/:number/comment",
581 softAuth,
582 requireAuth,
583 async (c) => {
584 const { owner: ownerName, repo: repoName } = c.req.param();
585 const issueNum = parseInt(c.req.param("number"), 10);
586 const user = c.get("user")!;
587 const body = await c.req.parseBody();
588 const commentBody = String(body.body || "").trim();
589
590 if (!commentBody) {
591 return c.redirect(
592 `/${ownerName}/${repoName}/issues/${issueNum}`
593 );
594 }
595
596 const resolved = await resolveRepo(ownerName, repoName);
597 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
598
599 const [issue] = await db
600 .select()
601 .from(issues)
602 .where(
603 and(
604 eq(issues.repositoryId, resolved.repo.id),
605 eq(issues.number, issueNum)
606 )
607 )
608 .limit(1);
609
610 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
611
612 await db.insert(issueComments).values({
613 issueId: issue.id,
614 authorId: user.id,
615 body: commentBody,
616 });
617
618 return c.redirect(
619 `/${ownerName}/${repoName}/issues/${issueNum}`
620 );
621 }
622);
623
624// Close issue
625issueRoutes.post(
626 "/:owner/:repo/issues/:number/close",
627 softAuth,
628 requireAuth,
629 async (c) => {
630 const { owner: ownerName, repo: repoName } = c.req.param();
631 const issueNum = parseInt(c.req.param("number"), 10);
632
633 const resolved = await resolveRepo(ownerName, repoName);
634 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
635
636 await db
637 .update(issues)
638 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
639 .where(
640 and(
641 eq(issues.repositoryId, resolved.repo.id),
642 eq(issues.number, issueNum)
643 )
644 );
645
646 return c.redirect(
647 `/${ownerName}/${repoName}/issues/${issueNum}`
648 );
649 }
650);
651
652// Reopen issue
653issueRoutes.post(
654 "/:owner/:repo/issues/:number/reopen",
655 softAuth,
656 requireAuth,
657 async (c) => {
658 const { owner: ownerName, repo: repoName } = c.req.param();
659 const issueNum = parseInt(c.req.param("number"), 10);
660
661 const resolved = await resolveRepo(ownerName, repoName);
662 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
663
664 await db
665 .update(issues)
666 .set({ state: "open", closedAt: null, updatedAt: new Date() })
667 .where(
668 and(
669 eq(issues.repositoryId, resolved.repo.id),
670 eq(issues.number, issueNum)
671 )
672 );
673
674 return c.redirect(
675 `/${ownerName}/${repoName}/issues/${issueNum}`
676 );
677 }
678);
679
bed6b57Claude680// J14 — Add blocker dependency.
681issueRoutes.post(
682 "/:owner/:repo/issues/:number/dependencies",
683 softAuth,
684 requireAuth,
685 async (c) => {
686 const { owner: ownerName, repo: repoName } = c.req.param();
687 const issueNum = parseInt(c.req.param("number"), 10);
688 const user = c.get("user")!;
689 const body = await c.req.parseBody();
690 const blockerRaw = String(body.blockerNumber || "").trim().replace(/^#/, "");
691 const blockerNum = parseInt(blockerRaw, 10);
692
693 const resolved = await resolveRepo(ownerName, repoName);
694 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
695
696 if (!Number.isFinite(blockerNum) || blockerNum <= 0) {
697 return c.redirect(
698 `/${ownerName}/${repoName}/issues/${issueNum}?depError=invalid`
699 );
700 }
701
702 const [blocked] = await db
703 .select()
704 .from(issues)
705 .where(
706 and(
707 eq(issues.repositoryId, resolved.repo.id),
708 eq(issues.number, issueNum)
709 )
710 )
711 .limit(1);
712 if (!blocked) return c.redirect(`/${ownerName}/${repoName}/issues`);
713
714 const [blocker] = await db
715 .select()
716 .from(issues)
717 .where(
718 and(
719 eq(issues.repositoryId, resolved.repo.id),
720 eq(issues.number, blockerNum)
721 )
722 )
723 .limit(1);
724 if (!blocker) {
725 return c.redirect(
726 `/${ownerName}/${repoName}/issues/${issueNum}?depError=not_found`
727 );
728 }
729
730 const canManage =
731 user.id === resolved.owner.id || user.id === blocked.authorId;
732 if (!canManage) {
733 return c.redirect(
734 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
735 );
736 }
737
738 const { addDependency } = await import("../lib/issue-dependencies");
739 const result = await addDependency({
740 blockerIssueId: blocker.id,
741 blockedIssueId: blocked.id,
742 createdBy: user.id,
743 });
744 if (!result.ok) {
745 return c.redirect(
746 `/${ownerName}/${repoName}/issues/${issueNum}?depError=${result.reason}`
747 );
748 }
749 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
750 }
751);
752
753// J14 — Remove blocker dependency. :which is either "blockers" or "blocks".
754issueRoutes.post(
755 "/:owner/:repo/issues/:number/dependencies/:which/:otherId/remove",
756 softAuth,
757 requireAuth,
758 async (c) => {
759 const { owner: ownerName, repo: repoName } = c.req.param();
760 const issueNum = parseInt(c.req.param("number"), 10);
761 const which = c.req.param("which");
762 const otherId = c.req.param("otherId");
763 const user = c.get("user")!;
764
765 const resolved = await resolveRepo(ownerName, repoName);
766 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
767
768 const [thisIssue] = await db
769 .select()
770 .from(issues)
771 .where(
772 and(
773 eq(issues.repositoryId, resolved.repo.id),
774 eq(issues.number, issueNum)
775 )
776 )
777 .limit(1);
778 if (!thisIssue) return c.redirect(`/${ownerName}/${repoName}/issues`);
779
780 const canManage =
781 user.id === resolved.owner.id || user.id === thisIssue.authorId;
782 if (!canManage) {
783 return c.redirect(
784 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
785 );
786 }
787
788 const { removeDependency } = await import("../lib/issue-dependencies");
789 // which === "blockers" → otherId is the blocker; this issue is blocked.
790 // which === "blocks" → otherId is the blocked; this issue is blocker.
791 if (which === "blockers") {
792 await removeDependency(otherId, thisIssue.id);
793 } else if (which === "blocks") {
794 await removeDependency(thisIssue.id, otherId);
795 }
796 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
797 }
798);
799
800// J14 — Dependencies UI panel.
801type DepRow = {
802 id: string;
803 issueId: string;
804 number: number;
805 title: string;
806 state: string;
807 authorUsername: string;
808};
809
810function depErrorMessage(reason: string | undefined): string | null {
811 if (!reason) return null;
812 switch (reason) {
813 case "self":
814 return "An issue cannot block itself.";
815 case "cross_repo":
816 return "Both issues must belong to the same repository.";
817 case "exists":
818 return "That dependency already exists.";
819 case "cycle":
820 return "That dependency would create a cycle.";
821 case "not_found":
822 return "Issue not found.";
823 case "invalid":
824 return "Invalid issue number.";
825 case "forbidden":
826 return "You don't have permission to change dependencies on this issue.";
827 default:
828 return "Could not update dependencies.";
829 }
830}
831
832const DependenciesPanel = ({
833 owner,
834 repo,
835 issueNumber,
836 blockers,
837 blocked,
838 canManage,
839 depError,
840}: {
841 owner: string;
842 repo: string;
843 issueNumber: number;
844 blockers: DepRow[];
845 blocked: DepRow[];
846 canManage: boolean;
847 depError: string | undefined;
848}) => {
849 if (blockers.length === 0 && blocked.length === 0 && !canManage) return null;
850 const errMsg = depErrorMessage(depError);
851 const renderRow = (row: DepRow, which: "blockers" | "blocks") => (
852 <div
853 style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border)"
854 >
855 <span
856 class={`issue-badge ${row.state === "open" ? "badge-open" : "badge-closed"}`}
857 style="font-size: 11px; padding: 1px 6px"
858 >
859 {row.state === "open" ? "\u25CB" : "\u2713"}
860 </span>
861 <a
862 href={`/${owner}/${repo}/issues/${row.number}`}
863 style="flex: 1; text-decoration: none"
864 >
865 <span style="color: var(--text-muted)">#{row.number}</span>{" "}
866 <span>{row.title}</span>
867 </a>
868 <span style="color: var(--text-muted); font-size: 12px">
869 by {row.authorUsername}
870 </span>
871 {canManage && (
872 <form
873 method="POST"
874 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies/${which}/${row.issueId}/remove`}
875 style="margin: 0"
876 >
877 <button
878 type="submit"
879 class="btn"
880 style="padding: 2px 8px; font-size: 11px"
881 title="Remove dependency"
882 >
883 {"\u2715"}
884 </button>
885 </form>
886 )}
887 </div>
888 );
889 return (
890 <div
891 style="margin: 16px 0; padding: 12px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-secondary)"
892 >
893 <div style="font-weight: 600; margin-bottom: 8px">Dependencies</div>
894 {errMsg && <div class="auth-error" style="margin-bottom: 8px">{errMsg}</div>}
895
896 <div style="margin-bottom: 12px">
897 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
898 Blocked by ({blockers.length})
899 </div>
900 {blockers.length === 0 ? (
901 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
902 No blockers.
903 </div>
904 ) : (
905 blockers.map((r) => renderRow(r, "blockers"))
906 )}
907 {canManage && (
908 <form
909 method="POST"
910 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies`}
911 style="display: flex; gap: 6px; margin-top: 8px"
912 >
913 <input
914 type="text"
915 name="blockerNumber"
916 required
917 placeholder="#123"
918 style="flex: 1; padding: 4px 8px; font-size: 12px"
919 />
920 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
921 Add blocker
922 </button>
923 </form>
924 )}
925 </div>
926
927 <div>
928 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
929 Blocks ({blocked.length})
930 </div>
931 {blocked.length === 0 ? (
932 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
933 This issue does not block any others.
934 </div>
935 ) : (
936 blocked.map((r) => renderRow(r, "blocks"))
937 )}
938 </div>
939 </div>
940 );
941};
942
79136bbClaude943// Shared nav component with issues tab
944const IssueNav = ({
945 owner,
946 repo,
947 active,
948}: {
949 owner: string;
950 repo: string;
951 active: "code" | "commits" | "issues";
952}) => (
953 <div class="repo-nav">
954 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
955 Code
956 </a>
957 <a
958 href={`/${owner}/${repo}/issues`}
959 class={active === "issues" ? "active" : ""}
960 >
961 Issues
962 </a>
963 <a
964 href={`/${owner}/${repo}/commits`}
965 class={active === "commits" ? "active" : ""}
966 >
967 Commits
968 </a>
969 </div>
970);
971
972function formatRelative(date: Date | string): string {
973 const d = typeof date === "string" ? new Date(date) : date;
974 const now = new Date();
975 const diffMs = now.getTime() - d.getTime();
976 const diffMins = Math.floor(diffMs / 60000);
977 if (diffMins < 1) return "just now";
978 if (diffMins < 60) return `${diffMins}m ago`;
979 const diffHours = Math.floor(diffMins / 60);
980 if (diffHours < 24) return `${diffHours}h ago`;
981 const diffDays = Math.floor(diffHours / 24);
982 if (diffDays < 30) return `${diffDays}d ago`;
983 return d.toLocaleDateString("en-US", {
984 month: "short",
985 day: "numeric",
986 year: "numeric",
987 });
988}
989
990export default issueRoutes;
991export { IssueNav };