Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1141 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";
831a117Claude27import {
28 applyQuery,
29 formatIssueQuery,
30 parseIssueQuery,
31 type QueryableIssue,
32} from "../lib/issue-query";
79136bbClaude33import { softAuth, requireAuth } from "../middleware/auth";
34import type { AuthEnv } from "../middleware/auth";
35import { html } from "hono/html";
36
37const issueRoutes = new Hono<AuthEnv>();
38
39// Helper to resolve repo from :owner/:repo params
40async function resolveRepo(ownerName: string, repoName: string) {
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!owner) return null;
47
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
53 )
54 .limit(1);
55 if (!repo) return null;
56
57 return { owner, repo };
58}
59
60// Issue list
61issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
62 const { owner: ownerName, repo: repoName } = c.req.param();
63 const user = c.get("user");
831a117Claude64 const stateParam = c.req.query("state") || "open";
65 const rawQuery = c.req.query("q") || "";
79136bbClaude66
67 const resolved = await resolveRepo(ownerName, repoName);
68 if (!resolved) {
69 return c.html(
70 <Layout title="Not Found" user={user}>
71 <div class="empty-state">
72 <h2>Repository not found</h2>
73 </div>
74 </Layout>,
75 404
76 );
77 }
78
79 const { repo } = resolved;
80
831a117Claude81 // J23 — the DSL may override `is:` from the query string. If `is:` is set
82 // in `q`, it wins; otherwise the tab-based state controls the filter.
83 const parsedQuery = parseIssueQuery(rawQuery);
84 const effectiveState = parsedQuery.is ?? stateParam;
85
86 // Fetch issues matching the basic state filter, joined with author.
87 const issueRows = await db
79136bbClaude88 .select({
89 issue: issues,
90 author: { username: users.username },
91 })
92 .from(issues)
93 .innerJoin(users, eq(issues.authorId, users.id))
94 .where(
831a117Claude95 and(
96 eq(issues.repositoryId, repo.id),
97 eq(issues.state, effectiveState)
98 )
79136bbClaude99 )
100 .orderBy(desc(issues.createdAt));
101
831a117Claude102 // Fetch labels for the fetched issues (single query). Used both for
103 // DSL filtering and for an inline render hint.
104 const issueIds = issueRows.map((r) => r.issue.id);
105 const labelRows =
106 issueIds.length === 0
107 ? []
108 : await db
109 .select({
110 issueId: issueLabels.issueId,
111 name: labels.name,
112 color: labels.color,
113 })
114 .from(issueLabels)
115 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
116 .where(
117 sql`${issueLabels.issueId} IN (${sql.join(
118 issueIds.map((id) => sql`${id}`),
119 sql`, `
120 )})`
121 );
122
123 const labelsByIssue = new Map<string, { name: string; color: string }[]>();
124 for (const row of labelRows) {
125 const arr = labelsByIssue.get(row.issueId) ?? [];
126 arr.push({ name: row.name, color: row.color });
127 labelsByIssue.set(row.issueId, arr);
128 }
129
130 // Build queryable shape for the DSL matcher.
131 type Row = (typeof issueRows)[number];
132 type RowWithLabels = Row & { labelNames: string[]; colorByLabel: Map<string, string> };
133 const enriched: RowWithLabels[] = issueRows.map((r) => {
134 const ls = labelsByIssue.get(r.issue.id) ?? [];
135 const colorByLabel = new Map<string, string>();
136 for (const l of ls) colorByLabel.set(l.name, l.color);
137 return { ...r, labelNames: ls.map((l) => l.name), colorByLabel };
138 });
139
140 // Apply the DSL if a query was provided. Otherwise pass-through.
141 let display: RowWithLabels[];
142 if (rawQuery.trim()) {
143 const queryable: (QueryableIssue & { __row: RowWithLabels })[] = enriched.map(
144 (r) => ({
145 title: r.issue.title,
146 body: r.issue.body,
147 state: r.issue.state,
148 authorName: r.author.username,
149 labelNames: r.labelNames,
150 milestoneTitle: null,
151 createdAt: r.issue.createdAt,
152 updatedAt: r.issue.updatedAt,
153 commentCount: 0,
154 __row: r,
155 })
156 );
157 const { matches } = applyQuery(rawQuery, queryable);
158 display = matches.map((m) => m.__row);
159 } else {
160 display = enriched;
161 }
162
163 // Count open/closed for the tab pills — always over ALL issues in the
164 // repo, independent of `q`, so the counters don't collapse when filters
165 // narrow the list.
79136bbClaude166 const [counts] = await db
167 .select({
168 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
169 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
170 })
171 .from(issues)
172 .where(eq(issues.repositoryId, repo.id));
173
831a117Claude174 const qsForTab = (s: string) => {
175 const parts: string[] = [`state=${s}`];
176 if (rawQuery) parts.push(`q=${encodeURIComponent(rawQuery)}`);
177 return parts.join("&");
178 };
179
180 return (
181 c.html(
79136bbClaude182 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
183 <RepoHeader owner={ownerName} repo={repoName} />
184 <IssueNav owner={ownerName} repo={repoName} active="issues" />
831a117Claude185
186 {/* J23 — DSL search bar. */}
187 <form
188 method="GET"
189 action={`/${ownerName}/${repoName}/issues`}
190 style="margin-bottom: 12px; display: flex; gap: 8px"
191 >
192 <input type="hidden" name="state" value={effectiveState} />
193 <input
194 type="text"
195 name="q"
196 value={rawQuery}
197 placeholder='is:open label:bug author:alice "race condition"'
198 style="flex: 1; padding: 6px 10px; font-size: 13px; font-family: var(--font-mono)"
199 />
200 <button type="submit" class="btn" style="padding: 4px 12px">
201 Search
202 </button>
203 {rawQuery && (
204 <a
205 href={`/${ownerName}/${repoName}/issues?state=${effectiveState}`}
206 class="btn"
207 style="padding: 4px 12px; font-size: 13px"
208 >
209 Clear
210 </a>
211 )}
212 </form>
213 <details style="margin-bottom: 12px; color: var(--text-muted); font-size: 12px">
214 <summary style="cursor: pointer">Search syntax</summary>
215 <div style="margin-top: 6px; line-height: 1.6">
216 <code>is:open</code> / <code>is:closed</code> •{" "}
217 <code>author:&lt;user&gt;</code> •{" "}
218 <code>label:&lt;name&gt;</code> (repeatable, AND) •{" "}
219 <code>-label:&lt;name&gt;</code> to exclude •{" "}
220 <code>no:label</code> •{" "}
221 <code>milestone:"v1.0"</code> •{" "}
222 <code>sort:</code>
223 <code>created-desc</code>|<code>created-asc</code>|
224 <code>updated-desc</code>|<code>updated-asc</code>|
225 <code>comments-desc</code> • any other text is a case-insensitive
226 substring match against title+body.
227 </div>
228 </details>
229
79136bbClaude230 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
231 <div class="issue-tabs">
232 <a
831a117Claude233 href={`/${ownerName}/${repoName}/issues?${qsForTab("open")}`}
234 class={effectiveState === "open" ? "active" : ""}
79136bbClaude235 >
236 {counts?.open ?? 0} Open
237 </a>
238 <a
831a117Claude239 href={`/${ownerName}/${repoName}/issues?${qsForTab("closed")}`}
240 class={effectiveState === "closed" ? "active" : ""}
79136bbClaude241 >
242 {counts?.closed ?? 0} Closed
243 </a>
244 </div>
d6b4e67Claude245 <div style="display: flex; gap: 8px; align-items: center">
831a117Claude246 {rawQuery && (
247 <span style="color: var(--text-muted); font-size: 12px">
248 {display.length} match{display.length === 1 ? "" : "es"}
249 </span>
250 )}
79136bbClaude251 <a
d6b4e67Claude252 href={`/${ownerName}/${repoName}/issues/stale`}
253 class="btn"
254 style="padding: 4px 10px; font-size: 12px"
79136bbClaude255 >
d6b4e67Claude256 Stale
79136bbClaude257 </a>
d6b4e67Claude258 {user && (
259 <a
260 href={`/${ownerName}/${repoName}/issues/new`}
261 class="btn btn-primary"
262 >
263 New issue
264 </a>
265 )}
266 </div>
79136bbClaude267 </div>
831a117Claude268 {display.length === 0 ? (
79136bbClaude269 <div class="empty-state">
270 <p>
831a117Claude271 {rawQuery
272 ? `No issues match ${JSON.stringify(formatIssueQuery(parsedQuery))}.`
273 : `No ${effectiveState} issues.`}
274 {!rawQuery && effectiveState === "closed" && (
79136bbClaude275 <span>
276 {" "}
277 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
278 View open issues
279 </a>
280 </span>
281 )}
282 </p>
283 </div>
284 ) : (
285 <div class="issue-list">
831a117Claude286 {display.map(({ issue, author, labelNames, colorByLabel }) => (
79136bbClaude287 <div class="issue-item">
288 <div
289 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
290 >
291 {issue.state === "open" ? "\u25CB" : "\u2713"}
292 </div>
831a117Claude293 <div style="min-width: 0; flex: 1">
79136bbClaude294 <div class="issue-title">
295 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
296 {issue.title}
297 </a>
831a117Claude298 {labelNames.length > 0 && (
299 <span style="margin-left: 8px">
300 {labelNames.map((name) => (
301 <span
302 style={`display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-right: 4px; background: ${colorByLabel.get(name) ?? "var(--bg-secondary)"}; color: #fff; border: 1px solid var(--border)`}
303 >
304 {name}
305 </span>
306 ))}
307 </span>
308 )}
79136bbClaude309 </div>
310 <div class="issue-meta">
311 #{issue.number} opened by {author.username}{" "}
312 {formatRelative(issue.createdAt)}
313 </div>
314 </div>
315 </div>
316 ))}
317 </div>
318 )}
319 </Layout>
831a117Claude320 )
79136bbClaude321 );
322});
323
324// New issue form
325issueRoutes.get(
326 "/:owner/:repo/issues/new",
327 softAuth,
328 requireAuth,
329 async (c) => {
330 const { owner: ownerName, repo: repoName } = c.req.param();
331 const user = c.get("user")!;
332 const error = c.req.query("error");
9a4eb42Claude333 const slug = c.req.query("template");
334
335 // J17 — multi-template selector. Fetch the list first; if there are 2+
336 // templates and the user has not picked one, show a chooser. If exactly
337 // one template exists, use it automatically. Fall back to the legacy
338 // single-file loader when no frontmatter templates are found.
339 const multi = await listIssueTemplates(ownerName, repoName);
340 const picked: IssueTemplate | null = findTemplateBySlug(multi, slug);
341
342 if (!picked && multi.length >= 2 && !slug) {
343 return c.html(
344 <Layout
345 title={`New issue — ${ownerName}/${repoName}`}
346 user={user}
347 >
348 <RepoHeader owner={ownerName} repo={repoName} />
349 <IssueNav owner={ownerName} repo={repoName} active="issues" />
350 <div style="max-width: 720px">
351 <h2 style="margin-bottom: 4px">New issue</h2>
352 <p style="color: var(--text-muted); margin-bottom: 24px">
353 Choose a template to get started, or{" "}
354 <a
355 href={`/${ownerName}/${repoName}/issues/new?template=__blank`}
356 >
357 open a blank issue
358 </a>
359 .
360 </p>
361 <div style="display: flex; flex-direction: column; gap: 12px">
362 {multi.map((t) => (
363 <div style="display: flex; align-items: center; gap: 16px; border: 1px solid var(--border); border-radius: 6px; padding: 16px; background: var(--bg-secondary)">
364 <div style="flex: 1; min-width: 0">
365 <div style="font-weight: 600; margin-bottom: 4px">
366 {t.name}
367 </div>
368 {t.about && (
369 <div style="font-size: 13px; color: var(--text-muted)">
370 {t.about}
371 </div>
372 )}
373 {t.labels.length > 0 && (
374 <div style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px">
375 {t.labels.map((l) => (
376 <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)">
377 {l}
378 </span>
379 ))}
380 </div>
381 )}
382 </div>
383 <a
384 href={`/${ownerName}/${repoName}/issues/new?template=${encodeURIComponent(t.slug)}`}
385 class="btn btn-primary"
386 >
387 Get started
388 </a>
389 </div>
390 ))}
391 </div>
392 </div>
393 </Layout>
394 );
395 }
396
397 // Auto-pick the single template when only one exists and no slug is set.
398 const auto = !picked && !slug && multi.length === 1 ? multi[0] : null;
399 const active = picked || auto;
400
401 // Legacy fallback for repos that ship a plain ISSUE_TEMPLATE.md.
402 const legacy =
403 !active && slug !== "__blank"
404 ? await loadIssueTemplate(ownerName, repoName)
405 : null;
406
407 const prefillTitle = active?.title ?? "";
408 const prefillBody = active ? active.body : legacy || "";
79136bbClaude409
410 return c.html(
411 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
412 <RepoHeader owner={ownerName} repo={repoName} />
413 <IssueNav owner={ownerName} repo={repoName} active="issues" />
414 <div style="max-width: 800px">
415 <h2 style="margin-bottom: 16px">New issue</h2>
9a4eb42Claude416 {active && (
417 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
418 Using template <code>{active.path}</code>
419 {multi.length >= 2 && (
420 <>
421 {" — "}
422 <a href={`/${ownerName}/${repoName}/issues/new`}>
423 choose a different template
424 </a>
425 </>
426 )}
427 .
428 </div>
429 )}
430 {!active && legacy && (
24cf2caClaude431 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
432 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
433 </div>
434 )}
9a4eb42Claude435 {active && active.labels.length > 0 && (
436 <div style="margin-bottom: 8px">
437 {active.labels.map((l) => (
438 <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">
439 {l}
440 </span>
441 ))}
442 </div>
443 )}
79136bbClaude444 {error && (
445 <div class="auth-error">{decodeURIComponent(error)}</div>
446 )}
447 <form method="POST" action={`/${ownerName}/${repoName}/issues/new`}>
448 <div class="form-group">
449 <input
450 type="text"
451 name="title"
452 required
453 placeholder="Title"
9a4eb42Claude454 value={prefillTitle}
79136bbClaude455 style="font-size: 16px; padding: 10px 14px"
456 />
457 </div>
458 <div class="form-group">
459 <textarea
460 name="body"
461 rows={12}
462 placeholder="Leave a comment... (Markdown supported)"
463 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude464 >
9a4eb42Claude465 {prefillBody}
24cf2caClaude466 </textarea>
79136bbClaude467 </div>
468 <button type="submit" class="btn btn-primary">
469 Submit new issue
470 </button>
471 </form>
472 </div>
473 </Layout>
474 );
475 }
476);
477
478// Create issue
479issueRoutes.post(
480 "/:owner/:repo/issues/new",
481 softAuth,
482 requireAuth,
483 async (c) => {
484 const { owner: ownerName, repo: repoName } = c.req.param();
485 const user = c.get("user")!;
486 const body = await c.req.parseBody();
487 const title = String(body.title || "").trim();
488 const issueBody = String(body.body || "").trim();
489
490 if (!title) {
491 return c.redirect(
492 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
493 );
494 }
495
496 const resolved = await resolveRepo(ownerName, repoName);
497 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
498
499 const [issue] = await db
500 .insert(issues)
501 .values({
502 repositoryId: resolved.repo.id,
503 authorId: user.id,
504 title,
505 body: issueBody || null,
506 })
507 .returning();
508
509 // Update issue count
510 await db
511 .update(repositories)
512 .set({ issueCount: resolved.repo.issueCount + 1 })
513 .where(eq(repositories.id, resolved.repo.id));
514
515 return c.redirect(
516 `/${ownerName}/${repoName}/issues/${issue.number}`
517 );
518 }
519);
520
521// View single issue
522issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
523 const { owner: ownerName, repo: repoName } = c.req.param();
524 const issueNum = parseInt(c.req.param("number"), 10);
525 const user = c.get("user");
526
527 const resolved = await resolveRepo(ownerName, repoName);
528 if (!resolved) {
529 return c.html(
530 <Layout title="Not Found" user={user}>
531 <div class="empty-state">
532 <h2>Not found</h2>
533 </div>
534 </Layout>,
535 404
536 );
537 }
538
539 const [issue] = await db
540 .select()
541 .from(issues)
542 .where(
543 and(
544 eq(issues.repositoryId, resolved.repo.id),
545 eq(issues.number, issueNum)
546 )
547 )
548 .limit(1);
549
550 if (!issue) {
551 return c.html(
552 <Layout title="Not Found" user={user}>
553 <div class="empty-state">
554 <h2>Issue not found</h2>
555 </div>
556 </Layout>,
557 404
558 );
559 }
560
561 const [author] = await db
562 .select()
563 .from(users)
564 .where(eq(users.id, issue.authorId))
565 .limit(1);
566
567 // Get comments
568 const comments = await db
569 .select({
570 comment: issueComments,
571 author: { username: users.username },
572 })
573 .from(issueComments)
574 .innerJoin(users, eq(issueComments.authorId, users.id))
575 .where(eq(issueComments.issueId, issue.id))
576 .orderBy(asc(issueComments.createdAt));
577
6fc53bdClaude578 // Load reactions for the issue + each comment in parallel.
579 const [issueReactions, ...commentReactions] = await Promise.all([
580 summariseReactions("issue", issue.id, user?.id),
581 ...comments.map((row) =>
582 summariseReactions("issue_comment", row.comment.id, user?.id)
583 ),
584 ]);
585
79136bbClaude586 const canManage =
587 user &&
588 (user.id === resolved.owner.id || user.id === issue.authorId);
589
bed6b57Claude590 // J14 — issue dependencies: blockers + blocked lists.
591 const [blockers, blocked] = await Promise.all([
592 (async () => {
593 try {
594 const { listBlockersOf } = await import("../lib/issue-dependencies");
595 return await listBlockersOf(issue.id);
596 } catch {
597 return [];
598 }
599 })(),
600 (async () => {
601 try {
602 const { listBlockedBy } = await import("../lib/issue-dependencies");
603 return await listBlockedBy(issue.id);
604 } catch {
605 return [];
606 }
607 })(),
608 ]);
609 const depError = c.req.query("depError");
610
79136bbClaude611 return c.html(
612 <Layout
613 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
614 user={user}
615 >
616 <RepoHeader owner={ownerName} repo={repoName} />
617 <IssueNav owner={ownerName} repo={repoName} active="issues" />
618 <div class="issue-detail">
619 <h2>
620 {issue.title}{" "}
621 <span style="color: var(--text-muted); font-weight: 400">
622 #{issue.number}
623 </span>
624 </h2>
625 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
626 <span
627 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
628 >
629 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
630 </span>
631 <span style="color: var(--text-muted); font-size: 14px">
632 <strong style="color: var(--text)">
633 {author?.username || "unknown"}
634 </strong>{" "}
635 opened this issue {formatRelative(issue.createdAt)}
636 </span>
637 </div>
638
bed6b57Claude639 <DependenciesPanel
640 owner={ownerName}
641 repo={repoName}
642 issueNumber={issue.number}
643 blockers={blockers}
644 blocked={blocked}
645 canManage={!!canManage}
646 depError={depError}
647 />
648
79136bbClaude649 {issue.body && (
650 <div class="issue-comment-box">
651 <div class="comment-header">
652 <strong>{author?.username}</strong> commented{" "}
653 {formatRelative(issue.createdAt)}
654 </div>
655 <div class="markdown-body">
656 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
657 </div>
6fc53bdClaude658 <div style="padding: 0 16px 12px">
659 <ReactionsBar
660 targetType="issue"
661 targetId={issue.id}
662 summaries={issueReactions}
663 canReact={!!user}
664 />
665 </div>
79136bbClaude666 </div>
667 )}
668
6fc53bdClaude669 {comments.map(({ comment, author: commentAuthor }, i) => (
79136bbClaude670 <div class="issue-comment-box">
671 <div class="comment-header">
672 <strong>{commentAuthor.username}</strong> commented{" "}
673 {formatRelative(comment.createdAt)}
674 </div>
675 <div class="markdown-body">
676 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
677 </div>
6fc53bdClaude678 <div style="padding: 0 16px 12px">
679 <ReactionsBar
680 targetType="issue_comment"
681 targetId={comment.id}
682 summaries={commentReactions[i] || []}
683 canReact={!!user}
684 />
685 </div>
79136bbClaude686 </div>
687 ))}
688
689 {user && (
690 <div style="margin-top: 20px">
691 <form
692 method="POST"
693 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
694 >
695 <div class="form-group">
696 <textarea
697 name="body"
698 rows={6}
699 required
700 placeholder="Leave a comment... (Markdown supported)"
701 style="font-family: var(--font-mono); font-size: 13px"
702 />
703 </div>
704 <div style="display: flex; gap: 8px">
705 <button type="submit" class="btn btn-primary">
706 Comment
707 </button>
708 {canManage && (
709 <button
710 type="submit"
711 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
712 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
713 >
714 {issue.state === "open"
715 ? "Close issue"
716 : "Reopen issue"}
717 </button>
718 )}
719 </div>
720 </form>
721 </div>
722 )}
723 </div>
724 </Layout>
725 );
726});
727
728// Add comment
729issueRoutes.post(
730 "/:owner/:repo/issues/:number/comment",
731 softAuth,
732 requireAuth,
733 async (c) => {
734 const { owner: ownerName, repo: repoName } = c.req.param();
735 const issueNum = parseInt(c.req.param("number"), 10);
736 const user = c.get("user")!;
737 const body = await c.req.parseBody();
738 const commentBody = String(body.body || "").trim();
739
740 if (!commentBody) {
741 return c.redirect(
742 `/${ownerName}/${repoName}/issues/${issueNum}`
743 );
744 }
745
746 const resolved = await resolveRepo(ownerName, repoName);
747 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
748
749 const [issue] = await db
750 .select()
751 .from(issues)
752 .where(
753 and(
754 eq(issues.repositoryId, resolved.repo.id),
755 eq(issues.number, issueNum)
756 )
757 )
758 .limit(1);
759
760 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
761
762 await db.insert(issueComments).values({
763 issueId: issue.id,
764 authorId: user.id,
765 body: commentBody,
766 });
767
768 return c.redirect(
769 `/${ownerName}/${repoName}/issues/${issueNum}`
770 );
771 }
772);
773
774// Close issue
775issueRoutes.post(
776 "/:owner/:repo/issues/:number/close",
777 softAuth,
778 requireAuth,
779 async (c) => {
780 const { owner: ownerName, repo: repoName } = c.req.param();
781 const issueNum = parseInt(c.req.param("number"), 10);
782
783 const resolved = await resolveRepo(ownerName, repoName);
784 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
785
786 await db
787 .update(issues)
788 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
789 .where(
790 and(
791 eq(issues.repositoryId, resolved.repo.id),
792 eq(issues.number, issueNum)
793 )
794 );
795
796 return c.redirect(
797 `/${ownerName}/${repoName}/issues/${issueNum}`
798 );
799 }
800);
801
802// Reopen issue
803issueRoutes.post(
804 "/:owner/:repo/issues/:number/reopen",
805 softAuth,
806 requireAuth,
807 async (c) => {
808 const { owner: ownerName, repo: repoName } = c.req.param();
809 const issueNum = parseInt(c.req.param("number"), 10);
810
811 const resolved = await resolveRepo(ownerName, repoName);
812 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
813
814 await db
815 .update(issues)
816 .set({ state: "open", closedAt: null, updatedAt: new Date() })
817 .where(
818 and(
819 eq(issues.repositoryId, resolved.repo.id),
820 eq(issues.number, issueNum)
821 )
822 );
823
824 return c.redirect(
825 `/${ownerName}/${repoName}/issues/${issueNum}`
826 );
827 }
828);
829
bed6b57Claude830// J14 — Add blocker dependency.
831issueRoutes.post(
832 "/:owner/:repo/issues/:number/dependencies",
833 softAuth,
834 requireAuth,
835 async (c) => {
836 const { owner: ownerName, repo: repoName } = c.req.param();
837 const issueNum = parseInt(c.req.param("number"), 10);
838 const user = c.get("user")!;
839 const body = await c.req.parseBody();
840 const blockerRaw = String(body.blockerNumber || "").trim().replace(/^#/, "");
841 const blockerNum = parseInt(blockerRaw, 10);
842
843 const resolved = await resolveRepo(ownerName, repoName);
844 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
845
846 if (!Number.isFinite(blockerNum) || blockerNum <= 0) {
847 return c.redirect(
848 `/${ownerName}/${repoName}/issues/${issueNum}?depError=invalid`
849 );
850 }
851
852 const [blocked] = await db
853 .select()
854 .from(issues)
855 .where(
856 and(
857 eq(issues.repositoryId, resolved.repo.id),
858 eq(issues.number, issueNum)
859 )
860 )
861 .limit(1);
862 if (!blocked) return c.redirect(`/${ownerName}/${repoName}/issues`);
863
864 const [blocker] = await db
865 .select()
866 .from(issues)
867 .where(
868 and(
869 eq(issues.repositoryId, resolved.repo.id),
870 eq(issues.number, blockerNum)
871 )
872 )
873 .limit(1);
874 if (!blocker) {
875 return c.redirect(
876 `/${ownerName}/${repoName}/issues/${issueNum}?depError=not_found`
877 );
878 }
879
880 const canManage =
881 user.id === resolved.owner.id || user.id === blocked.authorId;
882 if (!canManage) {
883 return c.redirect(
884 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
885 );
886 }
887
888 const { addDependency } = await import("../lib/issue-dependencies");
889 const result = await addDependency({
890 blockerIssueId: blocker.id,
891 blockedIssueId: blocked.id,
892 createdBy: user.id,
893 });
894 if (!result.ok) {
895 return c.redirect(
896 `/${ownerName}/${repoName}/issues/${issueNum}?depError=${result.reason}`
897 );
898 }
899 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
900 }
901);
902
903// J14 — Remove blocker dependency. :which is either "blockers" or "blocks".
904issueRoutes.post(
905 "/:owner/:repo/issues/:number/dependencies/:which/:otherId/remove",
906 softAuth,
907 requireAuth,
908 async (c) => {
909 const { owner: ownerName, repo: repoName } = c.req.param();
910 const issueNum = parseInt(c.req.param("number"), 10);
911 const which = c.req.param("which");
912 const otherId = c.req.param("otherId");
913 const user = c.get("user")!;
914
915 const resolved = await resolveRepo(ownerName, repoName);
916 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
917
918 const [thisIssue] = await db
919 .select()
920 .from(issues)
921 .where(
922 and(
923 eq(issues.repositoryId, resolved.repo.id),
924 eq(issues.number, issueNum)
925 )
926 )
927 .limit(1);
928 if (!thisIssue) return c.redirect(`/${ownerName}/${repoName}/issues`);
929
930 const canManage =
931 user.id === resolved.owner.id || user.id === thisIssue.authorId;
932 if (!canManage) {
933 return c.redirect(
934 `/${ownerName}/${repoName}/issues/${issueNum}?depError=forbidden`
935 );
936 }
937
938 const { removeDependency } = await import("../lib/issue-dependencies");
939 // which === "blockers" → otherId is the blocker; this issue is blocked.
940 // which === "blocks" → otherId is the blocked; this issue is blocker.
941 if (which === "blockers") {
942 await removeDependency(otherId, thisIssue.id);
943 } else if (which === "blocks") {
944 await removeDependency(thisIssue.id, otherId);
945 }
946 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}`);
947 }
948);
949
950// J14 — Dependencies UI panel.
951type DepRow = {
952 id: string;
953 issueId: string;
954 number: number;
955 title: string;
956 state: string;
957 authorUsername: string;
958};
959
960function depErrorMessage(reason: string | undefined): string | null {
961 if (!reason) return null;
962 switch (reason) {
963 case "self":
964 return "An issue cannot block itself.";
965 case "cross_repo":
966 return "Both issues must belong to the same repository.";
967 case "exists":
968 return "That dependency already exists.";
969 case "cycle":
970 return "That dependency would create a cycle.";
971 case "not_found":
972 return "Issue not found.";
973 case "invalid":
974 return "Invalid issue number.";
975 case "forbidden":
976 return "You don't have permission to change dependencies on this issue.";
977 default:
978 return "Could not update dependencies.";
979 }
980}
981
982const DependenciesPanel = ({
983 owner,
984 repo,
985 issueNumber,
986 blockers,
987 blocked,
988 canManage,
989 depError,
990}: {
991 owner: string;
992 repo: string;
993 issueNumber: number;
994 blockers: DepRow[];
995 blocked: DepRow[];
996 canManage: boolean;
997 depError: string | undefined;
998}) => {
999 if (blockers.length === 0 && blocked.length === 0 && !canManage) return null;
1000 const errMsg = depErrorMessage(depError);
1001 const renderRow = (row: DepRow, which: "blockers" | "blocks") => (
1002 <div
1003 style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border)"
1004 >
1005 <span
1006 class={`issue-badge ${row.state === "open" ? "badge-open" : "badge-closed"}`}
1007 style="font-size: 11px; padding: 1px 6px"
1008 >
1009 {row.state === "open" ? "\u25CB" : "\u2713"}
1010 </span>
1011 <a
1012 href={`/${owner}/${repo}/issues/${row.number}`}
1013 style="flex: 1; text-decoration: none"
1014 >
1015 <span style="color: var(--text-muted)">#{row.number}</span>{" "}
1016 <span>{row.title}</span>
1017 </a>
1018 <span style="color: var(--text-muted); font-size: 12px">
1019 by {row.authorUsername}
1020 </span>
1021 {canManage && (
1022 <form
1023 method="POST"
1024 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies/${which}/${row.issueId}/remove`}
1025 style="margin: 0"
1026 >
1027 <button
1028 type="submit"
1029 class="btn"
1030 style="padding: 2px 8px; font-size: 11px"
1031 title="Remove dependency"
1032 >
1033 {"\u2715"}
1034 </button>
1035 </form>
1036 )}
1037 </div>
1038 );
1039 return (
1040 <div
1041 style="margin: 16px 0; padding: 12px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-secondary)"
1042 >
1043 <div style="font-weight: 600; margin-bottom: 8px">Dependencies</div>
1044 {errMsg && <div class="auth-error" style="margin-bottom: 8px">{errMsg}</div>}
1045
1046 <div style="margin-bottom: 12px">
1047 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
1048 Blocked by ({blockers.length})
1049 </div>
1050 {blockers.length === 0 ? (
1051 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
1052 No blockers.
1053 </div>
1054 ) : (
1055 blockers.map((r) => renderRow(r, "blockers"))
1056 )}
1057 {canManage && (
1058 <form
1059 method="POST"
1060 action={`/${owner}/${repo}/issues/${issueNumber}/dependencies`}
1061 style="display: flex; gap: 6px; margin-top: 8px"
1062 >
1063 <input
1064 type="text"
1065 name="blockerNumber"
1066 required
1067 placeholder="#123"
1068 style="flex: 1; padding: 4px 8px; font-size: 12px"
1069 />
1070 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1071 Add blocker
1072 </button>
1073 </form>
1074 )}
1075 </div>
1076
1077 <div>
1078 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
1079 Blocks ({blocked.length})
1080 </div>
1081 {blocked.length === 0 ? (
1082 <div style="font-size: 12px; color: var(--text-muted); padding: 4px 0">
1083 This issue does not block any others.
1084 </div>
1085 ) : (
1086 blocked.map((r) => renderRow(r, "blocks"))
1087 )}
1088 </div>
1089 </div>
1090 );
1091};
1092
79136bbClaude1093// Shared nav component with issues tab
1094const IssueNav = ({
1095 owner,
1096 repo,
1097 active,
1098}: {
1099 owner: string;
1100 repo: string;
1101 active: "code" | "commits" | "issues";
1102}) => (
1103 <div class="repo-nav">
1104 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
1105 Code
1106 </a>
1107 <a
1108 href={`/${owner}/${repo}/issues`}
1109 class={active === "issues" ? "active" : ""}
1110 >
1111 Issues
1112 </a>
1113 <a
1114 href={`/${owner}/${repo}/commits`}
1115 class={active === "commits" ? "active" : ""}
1116 >
1117 Commits
1118 </a>
1119 </div>
1120);
1121
1122function formatRelative(date: Date | string): string {
1123 const d = typeof date === "string" ? new Date(date) : date;
1124 const now = new Date();
1125 const diffMs = now.getTime() - d.getTime();
1126 const diffMins = Math.floor(diffMs / 60000);
1127 if (diffMins < 1) return "just now";
1128 if (diffMins < 60) return `${diffMins}m ago`;
1129 const diffHours = Math.floor(diffMins / 60);
1130 if (diffHours < 24) return `${diffHours}h ago`;
1131 const diffDays = Math.floor(diffHours / 24);
1132 if (diffDays < 30) return `${diffDays}d ago`;
1133 return d.toLocaleDateString("en-US", {
1134 month: "short",
1135 day: "numeric",
1136 year: "numeric",
1137 });
1138}
1139
1140export default issueRoutes;
1141export { IssueNav };