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