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

milestones.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.

milestones.tsxBlame1274 lines · 1 contributor
85c4e13Claude1/**
2 * Milestones — group issues and PRs toward a shared goal with due dates
3 * and progress tracking.
4 *
5 * Routes:
6 * GET /:owner/:repo/milestones — list
7 * GET /:owner/:repo/milestones/new — create form
8 * POST /:owner/:repo/milestones — create
9 * GET /:owner/:repo/milestones/:id — detail (issues + PRs in milestone)
10 * GET /:owner/:repo/milestones/:id/edit — edit form
11 * POST /:owner/:repo/milestones/:id — update
12 * POST /:owner/:repo/milestones/:id/close — close
13 * POST /:owner/:repo/milestones/:id/delete — delete (owner only)
14 */
15
16import { Hono } from "hono";
17import { eq, and, desc, asc, sql } from "drizzle-orm";
18import { db } from "../db";
19import { milestones, issues, pullRequests, repositories, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader } from "../views/components";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { requireRepoAccess } from "../middleware/repo-access";
25
26const milestonesRoutes = new Hono<AuthEnv>();
27
28// ---------------------------------------------------------------------------
29// CSS scoped to .ms-* namespace
30// ---------------------------------------------------------------------------
31const milestonesStyles = `
32 .ms-hero {
33 position: relative;
34 margin: 4px 0 24px;
35 padding: 28px 32px;
36 background: var(--bg-elevated);
37 border: 1px solid var(--border);
38 border-radius: 16px;
39 overflow: hidden;
40 }
41 .ms-hero::before {
42 content: '';
43 position: absolute;
44 top: 0; left: 0; right: 0;
45 height: 2px;
6fd5915Claude46 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
85c4e13Claude47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .ms-hero-inner {
51 position: relative;
52 z-index: 1;
53 display: flex;
54 justify-content: space-between;
55 align-items: flex-end;
56 gap: 24px;
57 flex-wrap: wrap;
58 }
59 .ms-hero-text { flex: 1; min-width: 280px; }
60 .ms-hero-eyebrow {
61 font-size: 12.5px;
62 color: var(--text-muted);
63 margin-bottom: 8px;
64 letter-spacing: 0.04em;
65 text-transform: uppercase;
66 font-weight: 600;
67 }
68 .ms-hero-title {
69 font-family: var(--font-display);
70 font-size: clamp(26px, 3.5vw, 36px);
71 font-weight: 800;
72 letter-spacing: -0.026em;
73 line-height: 1.08;
74 margin: 0 0 10px;
75 color: var(--text-strong);
76 }
77 .ms-hero-sub {
78 font-size: 15px;
79 color: var(--text-muted);
80 margin: 0;
81 line-height: 1.5;
82 max-width: 560px;
83 }
84 .ms-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
85
86 /* Filter tabs */
87 .ms-filter-row {
88 display: flex;
89 align-items: center;
90 gap: 8px;
91 margin-bottom: 16px;
92 flex-wrap: wrap;
93 }
94 .ms-filter-tabs {
95 display: inline-flex;
96 background: var(--bg-elevated);
97 border: 1px solid var(--border);
98 border-radius: 9999px;
99 padding: 4px;
100 gap: 2px;
101 }
102 .ms-filter-tab {
103 display: inline-flex;
104 align-items: center;
105 gap: 6px;
106 padding: 6px 14px;
107 border-radius: 9999px;
108 font-size: 13px;
109 font-weight: 500;
110 color: var(--text-muted);
111 text-decoration: none;
112 transition: color 120ms, background 120ms;
113 }
114 .ms-filter-tab:hover { color: var(--text-strong); text-decoration: none; }
115 .ms-filter-tab.is-active {
6fd5915Claude116 background: rgba(91,110,232,0.14);
85c4e13Claude117 color: var(--text-strong);
118 }
119 .ms-filter-tab-count {
120 font-size: 11.5px;
121 color: var(--text-muted);
122 background: rgba(255,255,255,0.04);
123 padding: 1px 7px;
124 border-radius: 9999px;
125 }
126
127 /* Milestone card */
128 .ms-list {
129 display: flex;
130 flex-direction: column;
131 gap: 12px;
132 margin: 0;
133 padding: 0;
134 list-style: none;
135 }
136 .ms-card {
137 background: var(--bg-elevated);
138 border: 1px solid var(--border);
139 border-radius: 12px;
140 padding: 18px 22px;
141 transition: border-color 120ms, box-shadow 120ms;
142 }
143 .ms-card:hover {
6fd5915Claude144 border-color: rgba(91,110,232,0.4);
145 box-shadow: 0 4px 16px -8px rgba(91,110,232,0.2);
85c4e13Claude146 }
147 .ms-card-header {
148 display: flex;
149 align-items: flex-start;
150 justify-content: space-between;
151 gap: 12px;
152 margin-bottom: 10px;
153 flex-wrap: wrap;
154 }
155 .ms-card-title {
156 font-family: var(--font-display);
157 font-size: 16px;
158 font-weight: 700;
159 letter-spacing: -0.012em;
160 color: var(--text-strong);
161 text-decoration: none;
162 margin: 0;
163 }
164 .ms-card-title a {
165 color: inherit;
166 text-decoration: none;
167 }
168 .ms-card-title a:hover { color: var(--accent); }
169 .ms-card-meta {
170 font-size: 12.5px;
171 color: var(--text-muted);
172 display: flex;
173 align-items: center;
174 gap: 10px;
175 flex-wrap: wrap;
176 margin-bottom: 10px;
177 }
178 .ms-card-due-ok { color: var(--text-muted); }
179 .ms-card-due-warn { color: #f59e0b; font-weight: 600; }
180 .ms-card-due-over { color: #ef4444; font-weight: 600; }
181 .ms-card-state {
182 display: inline-flex;
183 align-items: center;
184 gap: 5px;
185 padding: 2px 9px;
186 border-radius: 9999px;
187 font-size: 11.5px;
188 font-weight: 600;
189 }
190 .ms-card-state.is-open { background: rgba(52,211,153,0.10); color: #34d399; border: 1px solid rgba(52,211,153,0.3); }
6fd5915Claude191 .ms-card-state.is-closed { background: rgba(182,157,255,0.10); color: #5b6ee8; border: 1px solid rgba(182,157,255,0.3); }
85c4e13Claude192
193 /* Progress bar */
194 .ms-progress-wrap {
195 margin-top: 8px;
196 }
197 .ms-progress-bar-bg {
198 height: 6px;
199 background: var(--bg-secondary);
200 border-radius: 9999px;
201 overflow: hidden;
202 margin-bottom: 5px;
203 }
204 .ms-progress-bar-fill {
205 height: 100%;
6fd5915Claude206 background: linear-gradient(90deg, #5b6ee8, #5f8fa0);
85c4e13Claude207 border-radius: 9999px;
208 transition: width 400ms ease;
209 }
210 .ms-progress-label {
211 font-size: 12px;
212 color: var(--text-muted);
213 }
214
215 /* Card actions */
216 .ms-card-actions {
217 margin-top: 12px;
218 display: flex;
219 gap: 8px;
220 flex-wrap: wrap;
221 }
222 .ms-card-action-link {
223 font-size: 12.5px;
224 color: var(--text-muted);
225 text-decoration: none;
226 padding: 3px 0;
227 border-bottom: 1px dashed transparent;
228 transition: color 120ms, border-color 120ms;
229 }
230 .ms-card-action-link:hover { color: var(--accent); border-bottom-color: var(--accent); }
231 .ms-card-action-sep { color: var(--text-muted); font-size: 12.5px; }
232
233 /* Empty state */
234 .ms-empty {
235 margin: 0;
236 padding: 56px 32px;
237 background: var(--bg-elevated);
238 border: 1px solid var(--border);
239 border-radius: 16px;
240 text-align: center;
241 }
242 .ms-empty-title {
243 font-family: var(--font-display);
244 font-size: 20px;
245 font-weight: 700;
246 letter-spacing: -0.015em;
247 color: var(--text-strong);
248 margin: 0 0 8px;
249 }
250 .ms-empty-sub {
251 font-size: 14px;
252 color: var(--text-muted);
253 margin: 0 auto 20px;
254 max-width: 440px;
255 line-height: 1.55;
256 }
257
258 /* Form */
259 .ms-form-section {
260 max-width: 700px;
261 margin: 0 auto;
262 }
263 .ms-form-card {
264 background: var(--bg-elevated);
265 border: 1px solid var(--border);
266 border-radius: 14px;
267 padding: 28px 32px;
268 }
269 .ms-form-title {
270 font-family: var(--font-display);
271 font-size: 20px;
272 font-weight: 700;
273 letter-spacing: -0.016em;
274 margin: 0 0 22px;
275 color: var(--text-strong);
276 }
277 .ms-form-group { margin-bottom: 18px; }
278 .ms-form-label {
279 display: block;
280 font-size: 13px;
281 font-weight: 600;
282 color: var(--text);
283 margin-bottom: 6px;
284 }
285 .ms-form-label .ms-form-optional {
286 font-weight: 400;
287 color: var(--text-muted);
288 margin-left: 4px;
289 }
290 .ms-form-input {
291 width: 100%;
292 background: var(--bg-surface);
293 border: 1px solid var(--border);
294 border-radius: 8px;
295 color: var(--text);
296 font-family: var(--font-sans, inherit);
297 font-size: 14px;
298 padding: 9px 13px;
299 outline: none;
300 transition: border-color 120ms, box-shadow 120ms;
301 box-sizing: border-box;
302 }
303 .ms-form-input:focus {
6fd5915Claude304 border-color: rgba(91,110,232,0.6);
305 box-shadow: 0 0 0 3px rgba(91,110,232,0.15);
85c4e13Claude306 }
307 .ms-form-textarea {
308 min-height: 100px;
309 resize: vertical;
310 font-family: var(--font-mono);
311 font-size: 13.5px;
312 line-height: 1.5;
313 }
314 .ms-form-hint {
315 font-size: 12px;
316 color: var(--text-muted);
317 margin-top: 5px;
318 line-height: 1.45;
319 }
320 .ms-form-actions {
321 display: flex;
322 gap: 10px;
323 margin-top: 24px;
324 flex-wrap: wrap;
325 }
326
327 /* Detail page */
328 .ms-detail-header {
329 position: relative;
330 margin: 4px 0 20px;
331 padding: 22px 26px;
332 background: var(--bg-elevated);
333 border: 1px solid var(--border);
334 border-radius: 16px;
335 overflow: hidden;
336 }
337 .ms-detail-header::before {
338 content: '';
339 position: absolute;
340 top: 0; left: 0; right: 0;
341 height: 2px;
6fd5915Claude342 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
85c4e13Claude343 opacity: 0.7;
344 pointer-events: none;
345 }
346 .ms-detail-title {
347 font-family: var(--font-display);
348 font-size: clamp(20px, 2.8vw, 28px);
349 font-weight: 700;
350 letter-spacing: -0.02em;
351 color: var(--text-strong);
352 margin: 0 0 10px;
353 }
354 .ms-detail-meta {
355 display: flex;
356 align-items: center;
357 gap: 12px;
358 flex-wrap: wrap;
359 font-size: 13.5px;
360 color: var(--text-muted);
361 margin-bottom: 14px;
362 }
363 .ms-detail-desc {
364 font-size: 14.5px;
365 color: var(--text);
366 line-height: 1.55;
367 margin-bottom: 14px;
368 white-space: pre-wrap;
369 }
370 .ms-detail-actions {
371 display: flex;
372 gap: 8px;
373 flex-wrap: wrap;
374 margin-top: 14px;
375 }
376
377 /* Items list (issues/PRs inside a milestone) */
378 .ms-items-section { margin-top: 24px; }
379 .ms-items-header {
380 font-family: var(--font-display);
381 font-size: 15px;
382 font-weight: 700;
383 color: var(--text-strong);
384 margin: 0 0 10px;
385 letter-spacing: -0.01em;
386 }
387 .ms-items-list {
388 list-style: none;
389 margin: 0;
390 padding: 0;
391 border: 1px solid var(--border);
392 border-radius: 12px;
393 overflow: hidden;
394 background: var(--bg-elevated);
395 }
396 .ms-item-row {
397 display: flex;
398 align-items: flex-start;
399 gap: 12px;
400 padding: 12px 18px;
401 border-bottom: 1px solid var(--border);
402 transition: background 120ms;
403 }
404 .ms-item-row:last-child { border-bottom: none; }
6fd5915Claude405 .ms-item-row:hover { background: rgba(91,110,232,0.04); }
85c4e13Claude406 .ms-item-icon {
407 width: 16px;
408 height: 16px;
409 flex-shrink: 0;
410 margin-top: 3px;
411 font-size: 14px;
412 }
413 .ms-item-icon.is-open { color: #34d399; }
6fd5915Claude414 .ms-item-icon.is-closed { color: #5b6ee8; }
85c4e13Claude415 .ms-item-icon.is-merged { color: #a78bfa; }
416 .ms-item-main { flex: 1; min-width: 0; }
417 .ms-item-title {
418 font-size: 14.5px;
419 font-weight: 600;
420 color: var(--text-strong);
421 text-decoration: none;
422 line-height: 1.3;
423 }
424 .ms-item-title:hover { color: var(--accent); }
425 .ms-item-meta {
426 font-size: 12px;
427 color: var(--text-muted);
428 margin-top: 3px;
429 }
430 .ms-item-kind {
431 font-size: 11px;
432 font-weight: 600;
433 padding: 1px 6px;
434 border-radius: 4px;
435 background: rgba(255,255,255,0.05);
436 color: var(--text-muted);
437 }
438
439 /* Alert strip */
440 .ms-alert {
441 margin-bottom: 14px;
442 padding: 10px 14px;
443 border-radius: 8px;
444 font-size: 13.5px;
445 }
446 .ms-alert-error {
447 background: rgba(239,68,68,0.08);
448 border: 1px solid rgba(239,68,68,0.3);
449 color: #ef4444;
450 }
451 .ms-alert-success {
452 background: rgba(52,211,153,0.08);
453 border: 1px solid rgba(52,211,153,0.3);
454 color: #34d399;
455 }
456
457 @media (max-width: 720px) {
458 .ms-hero { padding: 20px 16px; }
459 .ms-hero-inner { flex-direction: column; align-items: flex-start; }
460 .ms-form-card { padding: 20px 16px; }
461 }
462`;
463
464const MilestonesStyle = () => (
465 <style dangerouslySetInnerHTML={{ __html: milestonesStyles }} />
466);
467
468// ---------------------------------------------------------------------------
469// Helpers
470// ---------------------------------------------------------------------------
471
472async function resolveRepo(ownerName: string, repoName: string) {
473 const [owner] = await db
474 .select()
475 .from(users)
476 .where(eq(users.username, ownerName))
477 .limit(1);
478 if (!owner) return null;
479
480 const [repo] = await db
481 .select()
482 .from(repositories)
483 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
484 .limit(1);
485 if (!repo) return null;
486
487 return { owner, repo };
488}
489
490/** Format a date as "MMM D, YYYY" */
491function fmtDate(d: Date | string | null | undefined): string {
492 if (!d) return "";
493 const date = typeof d === "string" ? new Date(d) : d;
494 return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
495}
496
497/** Days until due date (negative = overdue) */
498function daysUntil(d: Date | string | null | undefined): number | null {
499 if (!d) return null;
500 const date = typeof d === "string" ? new Date(d) : d;
501 const now = new Date();
502 const diff = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
503 return diff;
504}
505
506function dueDateClass(days: number | null): string {
507 if (days === null) return "";
508 if (days < 0) return "ms-card-due-over";
509 if (days <= 7) return "ms-card-due-warn";
510 return "ms-card-due-ok";
511}
512
513function dueDateLabel(days: number | null, dateStr: string): string {
514 if (days === null) return "";
515 if (days < 0) return `Overdue by ${Math.abs(days)} day${Math.abs(days) !== 1 ? "s" : ""} (${dateStr})`;
516 if (days === 0) return `Due today (${dateStr})`;
517 if (days === 1) return `Due tomorrow (${dateStr})`;
518 return `Due ${dateStr}`;
519}
520
521// Progress calculation
522function calcProgress(total: number, closed: number) {
523 if (total === 0) return 0;
524 return Math.round((closed / total) * 100);
525}
526
527// ---------------------------------------------------------------------------
528// GET /:owner/:repo/milestones — list
529// ---------------------------------------------------------------------------
530milestonesRoutes.get(
531 "/:owner/:repo/milestones",
532 softAuth,
533 requireRepoAccess("read"),
534 async (c) => {
535 const { owner: ownerName, repo: repoName } = c.req.param();
536 const user = c.get("user");
537 const stateFilter = (c.req.query("state") || "open") as "open" | "closed";
538
539 const resolved = await resolveRepo(ownerName, repoName);
540 if (!resolved) {
541 return c.html(
542 <Layout title="Not Found" user={user}>
543 <p style="padding:40px;color:var(--text-muted)">Repository not found.</p>
544 </Layout>,
545 404
546 );
547 }
548 const { repo } = resolved;
549
550 const msList = await db
551 .select()
552 .from(milestones)
553 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.state, stateFilter)))
554 .orderBy(asc(milestones.dueDate), asc(milestones.createdAt));
555
556 // For each milestone, count open and closed issues
557 const progressMap: Record<string, { total: number; closed: number }> = {};
558 if (msList.length > 0) {
559 const msIds = msList.map((m) => m.id);
560 for (const msId of msIds) {
561 const [row] = await db
562 .select({
563 total: sql<number>`count(*)::int`,
564 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')::int`,
565 })
566 .from(issues)
567 .where(eq(issues.milestoneId, msId));
568 progressMap[msId] = { total: Number(row?.total ?? 0), closed: Number(row?.closed ?? 0) };
569 }
570 }
571
572 // Count open/closed milestones for tabs
573 const [msCount] = await db
574 .select({
575 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
576 closed: sql<number>`count(*) filter (where ${milestones.state} = 'closed')::int`,
577 })
578 .from(milestones)
579 .where(eq(milestones.repositoryId, repo.id));
580
581 const isOwner = !!(user && user.id === resolved.owner.id);
582
583 return c.html(
584 <Layout title={`Milestones — ${ownerName}/${repoName}`} user={user}>
585 <MilestonesStyle />
586 <RepoHeader owner={ownerName} repo={repoName} />
587
588 <section class="ms-hero">
589 <div class="ms-hero-inner">
590 <div class="ms-hero-text">
591 <div class="ms-hero-eyebrow">Milestones &middot; {ownerName}/{repoName}</div>
592 <h1 class="ms-hero-title">
593 Ship toward <span class="gradient-text">clear goals</span>.
594 </h1>
595 <p class="ms-hero-sub">
596 Group issues and pull requests into milestones with due dates and progress tracking.
597 </p>
598 </div>
599 {isOwner && (
600 <div class="ms-hero-actions">
601 <a href={`/${ownerName}/${repoName}/milestones/new`} class="btn btn-primary">
602 + New milestone
603 </a>
604 <a href={`/${ownerName}/${repoName}/issues`} class="btn">
605 Issues
606 </a>
607 </div>
608 )}
609 </div>
610 </section>
611
612 <div class="ms-filter-row">
613 <div class="ms-filter-tabs">
614 <a
615 class={`ms-filter-tab${stateFilter === "open" ? " is-active" : ""}`}
616 href={`/${ownerName}/${repoName}/milestones?state=open`}
617 >
618 Open
619 <span class="ms-filter-tab-count">{Number(msCount?.open ?? 0)}</span>
620 </a>
621 <a
622 class={`ms-filter-tab${stateFilter === "closed" ? " is-active" : ""}`}
623 href={`/${ownerName}/${repoName}/milestones?state=closed`}
624 >
625 Closed
626 <span class="ms-filter-tab-count">{Number(msCount?.closed ?? 0)}</span>
627 </a>
628 </div>
629 </div>
630
631 {msList.length === 0 ? (
632 <div class="ms-empty">
633 <h2 class="ms-empty-title">
634 {stateFilter === "closed" ? "No closed milestones" : "No milestones yet"}
635 </h2>
636 <p class="ms-empty-sub">
637 {stateFilter === "closed"
638 ? "Closed milestones will appear here once you finish a release cycle."
639 : "Create a milestone to group related issues and pull requests toward a shared goal."}
640 </p>
641 {isOwner && stateFilter === "open" && (
642 <a href={`/${ownerName}/${repoName}/milestones/new`} class="btn btn-primary">
643 Create your first milestone
644 </a>
645 )}
646 </div>
647 ) : (
648 <ul class="ms-list">
649 {msList.map((ms) => {
650 const { total, closed } = progressMap[ms.id] ?? { total: 0, closed: 0 };
651 const pct = calcProgress(total, closed);
652 const days = daysUntil(ms.dueDate);
653 const dateStr = fmtDate(ms.dueDate);
654 return (
655 <li class="ms-card">
656 <div class="ms-card-header">
657 <h2 class="ms-card-title">
658 <a href={`/${ownerName}/${repoName}/milestones/${ms.id}`}>
659 {ms.title}
660 </a>
661 </h2>
662 <span class={`ms-card-state ${ms.state === "open" ? "is-open" : "is-closed"}`}>
663 {ms.state === "open" ? "Open" : "Closed"}
664 </span>
665 </div>
666 {ms.dueDate && (
667 <div class="ms-card-meta">
668 <span class={dueDateClass(days)}>
669 {dueDateLabel(days, dateStr)}
670 </span>
671 </div>
672 )}
673 {ms.description && (
674 <div class="ms-card-meta" style="margin-top:-2px;margin-bottom:6px">
675 {ms.description}
676 </div>
677 )}
678 <div class="ms-progress-wrap">
679 <div class="ms-progress-bar-bg">
680 <div
681 class="ms-progress-bar-fill"
682 style={`width:${pct}%`}
683 role="progressbar"
684 aria-valuenow={pct}
685 aria-valuemin={0}
686 aria-valuemax={100}
687 />
688 </div>
689 <span class="ms-progress-label">
690 {pct}% complete &middot; {total - closed} open &middot; {closed} closed
691 </span>
692 </div>
693 {isOwner && (
694 <div class="ms-card-actions">
695 <a class="ms-card-action-link" href={`/${ownerName}/${repoName}/milestones/${ms.id}/edit`}>
696 Edit
697 </a>
698 <span class="ms-card-action-sep">&middot;</span>
699 {ms.state === "open" ? (
700 <form method="post" action={`/${ownerName}/${repoName}/milestones/${ms.id}/close`} style="display:inline">
701 <button type="submit" class="ms-card-action-link" style="background:none;border:none;padding:3px 0;cursor:pointer;font-family:inherit">
702 Close milestone
703 </button>
704 </form>
705 ) : (
706 <form method="post" action={`/${ownerName}/${repoName}/milestones/${ms.id}`} style="display:inline">
707 <input type="hidden" name="_action" value="reopen" />
708 <button type="submit" class="ms-card-action-link" style="background:none;border:none;padding:3px 0;cursor:pointer;font-family:inherit">
709 Reopen milestone
710 </button>
711 </form>
712 )}
713 <span class="ms-card-action-sep">&middot;</span>
714 <form
715 method="post"
716 action={`/${ownerName}/${repoName}/milestones/${ms.id}/delete`}
717 style="display:inline"
718 onsubmit="return confirm('Delete this milestone? Issues and PRs will lose their milestone assignment.')"
719 >
720 <button type="submit" class="ms-card-action-link" style="background:none;border:none;padding:3px 0;cursor:pointer;font-family:inherit;color:#ef4444">
721 Delete
722 </button>
723 </form>
724 </div>
725 )}
726 </li>
727 );
728 })}
729 </ul>
730 )}
731 </Layout>
732 );
733 }
734);
735
736// ---------------------------------------------------------------------------
737// GET /:owner/:repo/milestones/new — create form
738// ---------------------------------------------------------------------------
739milestonesRoutes.get(
740 "/:owner/:repo/milestones/new",
741 softAuth,
742 requireAuth,
743 requireRepoAccess("write"),
744 async (c) => {
745 const { owner: ownerName, repo: repoName } = c.req.param();
746 const user = c.get("user")!;
747 const error = c.req.query("error");
748
749 return c.html(
750 <Layout title={`New milestone — ${ownerName}/${repoName}`} user={user}>
751 <MilestonesStyle />
752 <RepoHeader owner={ownerName} repo={repoName} />
753 <div class="ms-form-section" style="margin-top:24px">
754 <div class="ms-form-card">
755 <h1 class="ms-form-title">New milestone</h1>
756 {error && (
757 <div class="ms-alert ms-alert-error">{decodeURIComponent(error)}</div>
758 )}
759 <form method="post" action={`/${ownerName}/${repoName}/milestones`}>
760 <div class="ms-form-group">
761 <label class="ms-form-label" for="ms-title">Title</label>
762 <input
763 id="ms-title"
764 type="text"
765 name="title"
766 required
767 class="ms-form-input"
768 placeholder="e.g. v1.0 Release"
769 maxlength={255}
770 />
771 </div>
772 <div class="ms-form-group">
773 <label class="ms-form-label" for="ms-due">
774 Due date <span class="ms-form-optional">(optional)</span>
775 </label>
776 <input
777 id="ms-due"
778 type="date"
779 name="due_date"
780 class="ms-form-input"
781 style="max-width:220px"
782 />
783 </div>
784 <div class="ms-form-group">
785 <label class="ms-form-label" for="ms-desc">
786 Description <span class="ms-form-optional">(optional)</span>
787 </label>
788 <textarea
789 id="ms-desc"
790 name="description"
791 class="ms-form-input ms-form-textarea"
792 placeholder="A short description of what this milestone represents"
793 maxlength={2000}
794 />
795 <p class="ms-form-hint">Plain text only.</p>
796 </div>
797 <div class="ms-form-actions">
798 <button type="submit" class="btn btn-primary">Create milestone</button>
799 <a href={`/${ownerName}/${repoName}/milestones`} class="btn">Cancel</a>
800 </div>
801 </form>
802 </div>
803 </div>
804 </Layout>
805 );
806 }
807);
808
809// ---------------------------------------------------------------------------
810// POST /:owner/:repo/milestones — create
811// ---------------------------------------------------------------------------
812milestonesRoutes.post(
813 "/:owner/:repo/milestones",
814 softAuth,
815 requireAuth,
816 requireRepoAccess("write"),
817 async (c) => {
818 const { owner: ownerName, repo: repoName } = c.req.param();
819 const user = c.get("user")!;
820 const body = await c.req.parseBody();
821 const title = String(body.title || "").trim();
822 const description = String(body.description || "").trim() || null;
823 const dueDateRaw = String(body.due_date || "").trim();
824 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
825
826 // Handle reopen action routed through POST /:id
827 const action = String(body._action || "");
828
829 if (!title) {
830 return c.redirect(
831 `/${ownerName}/${repoName}/milestones/new?error=${encodeURIComponent("Title is required")}`
832 );
833 }
834
835 const resolved = await resolveRepo(ownerName, repoName);
836 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
837
838 await db.insert(milestones).values({
839 repositoryId: resolved.repo.id,
840 title,
841 description,
842 dueDate: dueDate ?? undefined,
843 state: "open",
844 });
845
846 return c.redirect(`/${ownerName}/${repoName}/milestones`);
847 }
848);
849
850// ---------------------------------------------------------------------------
851// GET /:owner/:repo/milestones/:milestoneId — detail
852// ---------------------------------------------------------------------------
853milestonesRoutes.get(
854 "/:owner/:repo/milestones/:milestoneId",
855 softAuth,
856 requireRepoAccess("read"),
857 async (c) => {
858 const { owner: ownerName, repo: repoName, milestoneId } = c.req.param();
859 const user = c.get("user");
860
861 const resolved = await resolveRepo(ownerName, repoName);
862 if (!resolved) {
863 return c.html(
864 <Layout title="Not Found" user={user}>
865 <p style="padding:40px;color:var(--text-muted)">Repository not found.</p>
866 </Layout>,
867 404
868 );
869 }
870 const { repo } = resolved;
871
872 const [ms] = await db
873 .select()
874 .from(milestones)
875 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, repo.id)))
876 .limit(1);
877
878 if (!ms) {
879 return c.html(
880 <Layout title="Not Found" user={user}>
881 <p style="padding:40px;color:var(--text-muted)">Milestone not found.</p>
882 </Layout>,
883 404
884 );
885 }
886
887 // Load issues in this milestone
888 const msIssues = await db
889 .select({ issue: issues, author: { username: users.username } })
890 .from(issues)
891 .innerJoin(users, eq(issues.authorId, users.id))
892 .where(eq(issues.milestoneId, milestoneId))
893 .orderBy(desc(issues.createdAt));
894
895 // Load PRs in this milestone
896 const msPRs = await db
897 .select({ pr: pullRequests, author: { username: users.username } })
898 .from(pullRequests)
899 .innerJoin(users, eq(pullRequests.authorId, users.id))
900 .where(eq(pullRequests.milestoneId, milestoneId))
901 .orderBy(desc(pullRequests.createdAt));
902
903 const totalIssues = msIssues.length;
904 const closedIssues = msIssues.filter((r) => r.issue.state === "closed").length;
905 const totalPRs = msPRs.length;
906 const closedPRs = msPRs.filter((r) => r.pr.state === "merged" || r.pr.state === "closed").length;
907 const total = totalIssues + totalPRs;
908 const closed = closedIssues + closedPRs;
909 const pct = calcProgress(total, closed);
910
911 const days = daysUntil(ms.dueDate);
912 const dateStr = fmtDate(ms.dueDate);
913 const isOwner = !!(user && user.id === resolved.owner.id);
914 const success = c.req.query("success");
915
916 return c.html(
917 <Layout title={`${ms.title} — ${ownerName}/${repoName}`} user={user}>
918 <MilestonesStyle />
919 <RepoHeader owner={ownerName} repo={repoName} />
920
921 {success && (
922 <div class="ms-alert ms-alert-success" style="margin:12px 0">
923 {decodeURIComponent(success)}
924 </div>
925 )}
926
927 <div class="ms-detail-header">
928 <h1 class="ms-detail-title">{ms.title}</h1>
929 <div class="ms-detail-meta">
930 <span class={`ms-card-state ${ms.state === "open" ? "is-open" : "is-closed"}`}>
931 {ms.state === "open" ? "Open" : "Closed"}
932 </span>
933 {ms.dueDate && (
934 <span class={dueDateClass(days)}>
935 {dueDateLabel(days, dateStr)}
936 </span>
937 )}
938 <a href={`/${ownerName}/${repoName}/milestones`} style="color:var(--text-muted);text-decoration:none;font-size:13px">
939 &larr; All milestones
940 </a>
941 </div>
942 {ms.description && (
943 <div class="ms-detail-desc">{ms.description}</div>
944 )}
945
946 <div class="ms-progress-wrap">
947 <div class="ms-progress-bar-bg">
948 <div
949 class="ms-progress-bar-fill"
950 style={`width:${pct}%`}
951 role="progressbar"
952 aria-valuenow={pct}
953 aria-valuemin={0}
954 aria-valuemax={100}
955 />
956 </div>
957 <span class="ms-progress-label">
958 {pct}% complete &middot; {total - closed} open &middot; {closed} closed
959 </span>
960 </div>
961
962 {isOwner && (
963 <div class="ms-detail-actions">
964 <a href={`/${ownerName}/${repoName}/milestones/${ms.id}/edit`} class="btn">
965 Edit
966 </a>
967 {ms.state === "open" ? (
968 <form method="post" action={`/${ownerName}/${repoName}/milestones/${ms.id}/close`} style="display:inline">
969 <button type="submit" class="btn">Close milestone</button>
970 </form>
971 ) : (
972 <form method="post" action={`/${ownerName}/${repoName}/milestones/${ms.id}`} style="display:inline">
973 <input type="hidden" name="_action" value="reopen" />
974 <button type="submit" class="btn">Reopen milestone</button>
975 </form>
976 )}
977 <form
978 method="post"
979 action={`/${ownerName}/${repoName}/milestones/${ms.id}/delete`}
980 style="display:inline"
981 onsubmit="return confirm('Delete this milestone?')"
982 >
983 <button type="submit" class="btn btn-danger">Delete</button>
984 </form>
985 </div>
986 )}
987 </div>
988
989 {/* Issues */}
990 {msIssues.length > 0 && (
991 <div class="ms-items-section">
992 <h2 class="ms-items-header">Issues ({msIssues.length})</h2>
993 <ul class="ms-items-list">
994 {msIssues.map(({ issue, author }) => (
995 <li class="ms-item-row">
996 <span class={`ms-item-icon ${issue.state === "open" ? "is-open" : "is-closed"}`} aria-hidden="true">
997 {issue.state === "open" ? "○" : "✓"}
998 </span>
999 <div class="ms-item-main">
1000 <a
1001 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
1002 class="ms-item-title"
1003 >
1004 {issue.title}
1005 </a>
1006 <div class="ms-item-meta">
1007 #{issue.number} opened by {author.username}
1008 </div>
1009 </div>
1010 <span class="ms-item-kind">Issue</span>
1011 </li>
1012 ))}
1013 </ul>
1014 </div>
1015 )}
1016
1017 {/* Pull Requests */}
1018 {msPRs.length > 0 && (
1019 <div class="ms-items-section">
1020 <h2 class="ms-items-header">Pull Requests ({msPRs.length})</h2>
1021 <ul class="ms-items-list">
1022 {msPRs.map(({ pr, author }) => {
1023 const prState = pr.state === "merged" ? "merged" : pr.isDraft ? "draft" : pr.state;
1024 return (
1025 <li class="ms-item-row">
1026 <span class={`ms-item-icon is-${prState}`} aria-hidden="true">
1027 {prState === "merged" ? "✔" : prState === "closed" ? "×" : "○"}
1028 </span>
1029 <div class="ms-item-main">
1030 <a
1031 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1032 class="ms-item-title"
1033 >
1034 {pr.title}
1035 </a>
1036 <div class="ms-item-meta">
1037 #{pr.number} by {author.username}
1038 {pr.isDraft ? " · Draft" : ""}
1039 </div>
1040 </div>
1041 <span class="ms-item-kind">PR</span>
1042 </li>
1043 );
1044 })}
1045 </ul>
1046 </div>
1047 )}
1048
1049 {total === 0 && (
1050 <div class="ms-empty" style="margin-top:24px">
1051 <h2 class="ms-empty-title">No issues or pull requests yet</h2>
1052 <p class="ms-empty-sub">
1053 Assign issues or pull requests to this milestone from their respective pages.
1054 </p>
1055 <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
1056 <a href={`/${ownerName}/${repoName}/issues`} class="btn">View issues</a>
1057 <a href={`/${ownerName}/${repoName}/pulls`} class="btn">View pull requests</a>
1058 </div>
1059 </div>
1060 )}
1061 </Layout>
1062 );
1063 }
1064);
1065
1066// ---------------------------------------------------------------------------
1067// GET /:owner/:repo/milestones/:milestoneId/edit — edit form
1068// ---------------------------------------------------------------------------
1069milestonesRoutes.get(
1070 "/:owner/:repo/milestones/:milestoneId/edit",
1071 softAuth,
1072 requireAuth,
1073 requireRepoAccess("write"),
1074 async (c) => {
1075 const { owner: ownerName, repo: repoName, milestoneId } = c.req.param();
1076 const user = c.get("user")!;
1077 const error = c.req.query("error");
1078
1079 const resolved = await resolveRepo(ownerName, repoName);
1080 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1081
1082 const [ms] = await db
1083 .select()
1084 .from(milestones)
1085 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)))
1086 .limit(1);
1087
1088 if (!ms) {
1089 return c.redirect(`/${ownerName}/${repoName}/milestones`);
1090 }
1091
1092 // Format date for the input[type=date] value (YYYY-MM-DD)
1093 const dueDateValue = ms.dueDate
1094 ? new Date(ms.dueDate).toISOString().split("T")[0]
1095 : "";
1096
1097 return c.html(
1098 <Layout title={`Edit milestone — ${ownerName}/${repoName}`} user={user}>
1099 <MilestonesStyle />
1100 <RepoHeader owner={ownerName} repo={repoName} />
1101 <div class="ms-form-section" style="margin-top:24px">
1102 <div class="ms-form-card">
1103 <h1 class="ms-form-title">Edit milestone</h1>
1104 {error && (
1105 <div class="ms-alert ms-alert-error">{decodeURIComponent(error)}</div>
1106 )}
1107 <form method="post" action={`/${ownerName}/${repoName}/milestones/${ms.id}`}>
1108 <input type="hidden" name="_action" value="update" />
1109 <div class="ms-form-group">
1110 <label class="ms-form-label" for="ms-title">Title</label>
1111 <input
1112 id="ms-title"
1113 type="text"
1114 name="title"
1115 required
1116 value={ms.title}
1117 class="ms-form-input"
1118 maxlength={255}
1119 />
1120 </div>
1121 <div class="ms-form-group">
1122 <label class="ms-form-label" for="ms-due">
1123 Due date <span class="ms-form-optional">(optional)</span>
1124 </label>
1125 <input
1126 id="ms-due"
1127 type="date"
1128 name="due_date"
1129 value={dueDateValue}
1130 class="ms-form-input"
1131 style="max-width:220px"
1132 />
1133 </div>
1134 <div class="ms-form-group">
1135 <label class="ms-form-label" for="ms-desc">
1136 Description <span class="ms-form-optional">(optional)</span>
1137 </label>
1138 <textarea
1139 id="ms-desc"
1140 name="description"
1141 class="ms-form-input ms-form-textarea"
1142 maxlength={2000}
1143 >
1144 {ms.description || ""}
1145 </textarea>
1146 </div>
1147 <div class="ms-form-actions">
1148 <button type="submit" class="btn btn-primary">Save changes</button>
1149 <a href={`/${ownerName}/${repoName}/milestones/${ms.id}`} class="btn">Cancel</a>
1150 </div>
1151 </form>
1152 </div>
1153 </div>
1154 </Layout>
1155 );
1156 }
1157);
1158
1159// ---------------------------------------------------------------------------
1160// POST /:owner/:repo/milestones/:milestoneId — update or reopen
1161// ---------------------------------------------------------------------------
1162milestonesRoutes.post(
1163 "/:owner/:repo/milestones/:milestoneId",
1164 softAuth,
1165 requireAuth,
1166 requireRepoAccess("write"),
1167 async (c) => {
1168 const { owner: ownerName, repo: repoName, milestoneId } = c.req.param();
1169 const user = c.get("user")!;
1170 const body = await c.req.parseBody();
1171 const action = String(body._action || "update");
1172
1173 const resolved = await resolveRepo(ownerName, repoName);
1174 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1175
1176 const [ms] = await db
1177 .select()
1178 .from(milestones)
1179 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)))
1180 .limit(1);
1181
1182 if (!ms) {
1183 return c.redirect(`/${ownerName}/${repoName}/milestones`);
1184 }
1185
1186 if (action === "reopen") {
1187 await db
1188 .update(milestones)
1189 .set({ state: "open", closedAt: null })
1190 .where(eq(milestones.id, ms.id));
1191 return c.redirect(
1192 `/${ownerName}/${repoName}/milestones?success=${encodeURIComponent("Milestone reopened")}`
1193 );
1194 }
1195
1196 // Default: update fields
1197 const title = String(body.title || "").trim();
1198 const description = String(body.description || "").trim() || null;
1199 const dueDateRaw = String(body.due_date || "").trim();
1200 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
1201
1202 if (!title) {
1203 return c.redirect(
1204 `/${ownerName}/${repoName}/milestones/${milestoneId}/edit?error=${encodeURIComponent("Title is required")}`
1205 );
1206 }
1207
1208 await db
1209 .update(milestones)
1210 .set({ title, description, dueDate: dueDate ?? undefined })
1211 .where(eq(milestones.id, ms.id));
1212
1213 return c.redirect(
1214 `/${ownerName}/${repoName}/milestones/${ms.id}?success=${encodeURIComponent("Milestone updated")}`
1215 );
1216 }
1217);
1218
1219// ---------------------------------------------------------------------------
1220// POST /:owner/:repo/milestones/:milestoneId/close
1221// ---------------------------------------------------------------------------
1222milestonesRoutes.post(
1223 "/:owner/:repo/milestones/:milestoneId/close",
1224 softAuth,
1225 requireAuth,
1226 requireRepoAccess("write"),
1227 async (c) => {
1228 const { owner: ownerName, repo: repoName, milestoneId } = c.req.param();
1229
1230 const resolved = await resolveRepo(ownerName, repoName);
1231 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1232
1233 await db
1234 .update(milestones)
1235 .set({ state: "closed", closedAt: new Date() })
1236 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)));
1237
1238 return c.redirect(
1239 `/${ownerName}/${repoName}/milestones?success=${encodeURIComponent("Milestone closed")}`
1240 );
1241 }
1242);
1243
1244// ---------------------------------------------------------------------------
1245// POST /:owner/:repo/milestones/:milestoneId/delete — owner only
1246// ---------------------------------------------------------------------------
1247milestonesRoutes.post(
1248 "/:owner/:repo/milestones/:milestoneId/delete",
1249 softAuth,
1250 requireAuth,
1251 requireRepoAccess("write"),
1252 async (c) => {
1253 const { owner: ownerName, repo: repoName, milestoneId } = c.req.param();
1254 const user = c.get("user")!;
1255
1256 const resolved = await resolveRepo(ownerName, repoName);
1257 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1258
1259 // Only repo owner can delete milestones
1260 if (user.id !== resolved.owner.id && !user.isAdmin) {
1261 return c.redirect(`/${ownerName}/${repoName}/milestones`);
1262 }
1263
1264 await db
1265 .delete(milestones)
1266 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)));
1267
1268 return c.redirect(
1269 `/${ownerName}/${repoName}/milestones?success=${encodeURIComponent("Milestone deleted")}`
1270 );
1271 }
1272);
1273
1274export default milestonesRoutes;