Commit85c4e13unknown_key
feat: milestones — group issues and PRs with due dates and progress tracking
feat: milestones — group issues and PRs with due dates and progress tracking Adds a full milestones feature (migration 0077): - New `milestones.tsx` route with list, create, detail, edit, close, and delete pages. Progress bar shows % closed issues + PRs per milestone. Due date countdown highlights overdue/soon-due milestones in orange/red. - `issues.milestoneId` column added to schema and migration 0077 SQL. - New issue form gains a milestone dropdown (open milestones for the repo). - Issue list gains a "Milestones N" link in the hero actions row. - Milestone filter: `?milestone=:id` on the issues list page. - Registered in app.tsx after issueRoutes. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
5 files changed+1371−185c4e1322c8458522b7f2794adf8be28c5e4ace4
5 changed files+1371−1
Addeddrizzle/0090_milestones.sql+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1-- Migration 0077: Add milestone_id to issues table
2-- The milestones table and pull_requests.milestone_id already exist (migration 0001).
3-- This migration adds milestone_id to the issues table.
4
5ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "milestone_id" uuid REFERENCES "milestones"("id") ON DELETE SET NULL;
6
7--> statement-breakpoint
8CREATE INDEX IF NOT EXISTS "issues_milestone" ON "issues" ("milestone_id");
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -29,6 +29,7 @@ import integrationsChatRoutes from "./routes/integrations-chat";
2929import agentsRoutes from "./routes/agents";
3030import agentPipelinesRoutes from "./routes/agent-pipelines";
3131import issueRoutes from "./routes/issues";
32import milestonesRoutes from "./routes/milestones";
3233import commentModerationRoutes from "./routes/comment-moderation";
3334import repoSettings from "./routes/repo-settings";
3435import collaboratorRoutes from "./routes/collaborators";
@@ -499,6 +500,10 @@ app.route("/", compareRoutes);
499500// Issue tracker
500501app.route("/", issueRoutes);
501502
503// Milestones — group issues + PRs toward a shared goal with due dates + progress tracking.
504// Mounted after issueRoutes so /:owner/:repo/issues/* paths win before the milestone patterns.
505app.route("/", milestonesRoutes);
506
502507// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
503508// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
504509// the `/:owner/:repo/comments/*` paths resolve before the broader PR
Modifiedsrc/db/schema.ts+6−0View fileUnifiedSplit
@@ -618,6 +618,11 @@ export const issues = pgTable(
618618 title: text("title").notNull(),
619619 body: text("body"),
620620 state: text("state").notNull().default("open"), // open, closed
621 // Migration 0077 — milestone grouping. Optional; ON DELETE SET NULL so
622 // deleting a milestone never removes the issue.
623 milestoneId: uuid("milestone_id").references(() => milestones.id, {
624 onDelete: "set null",
625 }),
621626 createdAt: timestamp("created_at").defaultNow().notNull(),
622627 updatedAt: timestamp("updated_at").defaultNow().notNull(),
623628 closedAt: timestamp("closed_at"),
@@ -625,6 +630,7 @@ export const issues = pgTable(
625630 (table) => [
626631 index("issues_repo_state").on(table.repositoryId, table.state),
627632 index("issues_repo_number").on(table.repositoryId, table.number),
633 index("issues_milestone").on(table.milestoneId),
628634 ]
629635);
630636
Modifiedsrc/routes/issues.tsx+78−1View fileUnifiedSplit
@@ -13,6 +13,7 @@ import {
1313 labels,
1414 issueLabels,
1515 pullRequests,
16 milestones,
1617} from "../db/schema";
1718import { Layout } from "../views/layout";
1819import { RepoHeader, RepoNav } from "../views/components";
@@ -817,6 +818,7 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
817818 const state = c.req.query("state") || "open";
818819 const searchQ = (c.req.query("q") || "").trim();
819820 const labelFilter = (c.req.query("label") || "").trim();
821 const milestoneFilter = (c.req.query("milestone") || "").trim();
820822 const sort = (c.req.query("sort") || "newest").trim();
821823 // Bounded pagination — unbounded selects ran a full table scan + O(n)
822824 // sort on every page load; with 10k+ issues the request would hang.
@@ -892,6 +894,17 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
892894 }
893895 }
894896
897 // If milestone filter is set, resolve the milestone ID
898 let milestoneFilterId: string | null = null;
899 if (milestoneFilter) {
900 const [matchedMs] = await db
901 .select({ id: milestones.id })
902 .from(milestones)
903 .where(and(eq(milestones.repositoryId, repo.id), eq(milestones.id, milestoneFilter)))
904 .limit(1);
905 milestoneFilterId = matchedMs?.id ?? null;
906 }
907
895908 const baseWhere = and(
896909 eq(issues.repositoryId, repo.id),
897910 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
@@ -900,7 +913,8 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
900913 ? inArray(issues.id, labelFilteredIds)
901914 : labelFilteredIds !== null && labelFilteredIds.length === 0
902915 ? sql`false`
903 : undefined
916 : undefined,
917 milestoneFilterId ? eq(issues.milestoneId, milestoneFilterId) : undefined
904918 );
905919
906920 const issueList = await db
@@ -928,6 +942,14 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
928942 .from(issues)
929943 .where(eq(issues.repositoryId, repo.id));
930944
945 // Count open milestones for the "N Milestones" link
946 const [milestoneCounts] = await db
947 .select({
948 open: sql<number>`count(*) filter (where ${milestones.state} = 'open')::int`,
949 })
950 .from(milestones)
951 .where(eq(milestones.repositoryId, repo.id));
952
931953 const viewerIsOwnerOnList = !!(user && user.id === resolved.owner.id);
932954 const pendingCountList = viewerIsOwnerOnList
933955 ? await countPendingForRepo(repo.id)
@@ -973,6 +995,18 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
973995 + New issue
974996 </a>
975997 )}
998 <a
999 href={`/${ownerName}/${repoName}/milestones`}
1000 class="btn"
1001 title="View milestones for this repository"
1002 >
1003 Milestones
1004 {Number(milestoneCounts?.open ?? 0) > 0 && (
1005 <span style="margin-left:5px;font-size:11.5px;background:rgba(140,109,255,0.18);color:#a78bfa;padding:1px 7px;border-radius:9999px">
1006 {Number(milestoneCounts?.open ?? 0)}
1007 </span>
1008 )}
1009 </a>
9761010 <a href={`/${ownerName}/${repoName}`} class="btn">
9771011 Back to code
9781012 </a>
@@ -1149,6 +1183,16 @@ issueRoutes.get(
11491183 const error = c.req.query("error");
11501184 const template = await loadIssueTemplate(ownerName, repoName);
11511185
1186 // Load open milestones for the dropdown
1187 const resolved = await resolveRepo(ownerName, repoName);
1188 const openMilestones = resolved
1189 ? await db
1190 .select({ id: milestones.id, title: milestones.title })
1191 .from(milestones)
1192 .where(and(eq(milestones.repositoryId, resolved.repo.id), eq(milestones.state, "open")))
1193 .orderBy(milestones.title)
1194 : [];
1195
11521196 return c.html(
11531197 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
11541198 <IssuesStyle />
@@ -1200,6 +1244,26 @@ issueRoutes.get(
12001244 mono
12011245 />
12021246 </FormGroup>
1247 {openMilestones.length > 0 && (
1248 <FormGroup>
1249 <label
1250 for="milestone_id"
1251 style="display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px"
1252 >
1253 Milestone <span style="font-weight:400;color:var(--text-muted)">(optional)</span>
1254 </label>
1255 <select
1256 id="milestone_id"
1257 name="milestone_id"
1258 style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;padding:8px 12px;outline:none;width:100%;max-width:340px"
1259 >
1260 <option value="">No milestone</option>
1261 {openMilestones.map((ms) => (
1262 <option value={ms.id}>{ms.title}</option>
1263 ))}
1264 </select>
1265 </FormGroup>
1266 )}
12031267 <Button type="submit" variant="primary">
12041268 Submit new issue
12051269 </Button>
@@ -1222,6 +1286,7 @@ issueRoutes.post(
12221286 const body = await c.req.parseBody();
12231287 const title = String(body.title || "").trim();
12241288 const issueBody = String(body.body || "").trim();
1289 const milestoneIdRaw = String(body.milestone_id || "").trim() || null;
12251290
12261291 if (!title) {
12271292 return c.redirect(
@@ -1232,6 +1297,17 @@ issueRoutes.post(
12321297 const resolved = await resolveRepo(ownerName, repoName);
12331298 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
12341299
1300 // Validate milestone belongs to this repo if provided
1301 let validatedMilestoneId: string | null = null;
1302 if (milestoneIdRaw) {
1303 const [msRow] = await db
1304 .select({ id: milestones.id })
1305 .from(milestones)
1306 .where(and(eq(milestones.id, milestoneIdRaw), eq(milestones.repositoryId, resolved.repo.id)))
1307 .limit(1);
1308 validatedMilestoneId = msRow?.id ?? null;
1309 }
1310
12351311 const [issue] = await db
12361312 .insert(issues)
12371313 .values({
@@ -1239,6 +1315,7 @@ issueRoutes.post(
12391315 authorId: user.id,
12401316 title,
12411317 body: issueBody || null,
1318 milestoneId: validatedMilestoneId,
12421319 })
12431320 .returning();
12441321
Addedsrc/routes/milestones.tsx+1274−0View fileUnifiedSplit
Large file (1,275 lines). Load full file