/** * Epics and Sub-tasks Library * Manage hierarchical issue structures */ import { getDatabase, schema } from "@/db"; import { and, eq, inArray } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { logger } from "./logger"; export interface EpicInfo { epic: typeof schema.issues.$inferSelect; children: (typeof schema.issues.$inferSelect)[]; progress: { total: number; closed: number; percentage: number; }; } /** * Create an epic */ export async function createEpic(options: { repositoryId: string; title: string; body?: string; authorId: string; }): Promise { const db = getDatabase(); // Get next issue number const lastIssue = await db.query.issues.findFirst({ where: eq(schema.issues.repositoryId, options.repositoryId), orderBy: (issues, { desc }) => [desc(issues.number)], }); const number = (lastIssue?.number || 0) + 1; const epic = { id: crypto.randomUUID(), repositoryId: options.repositoryId, number, title: options.title, body: options.body || null, type: "epic", state: "open", authorId: options.authorId, createdAt: new Date(), updatedAt: new Date(), }; // @ts-expect-error - Drizzle multi-db union type issue await db.insert(schema.issues).values(epic); logger.info({ epicId: epic.id, number }, "Epic created"); return epic as typeof schema.issues.$inferSelect; } /** * Create a sub-task under an epic or issue */ export async function createSubTask(options: { parentId: string; title: string; body?: string; authorId: string; }): Promise { const db = getDatabase(); const parent = await db.query.issues.findFirst({ where: eq(schema.issues.id, options.parentId), }); if (!parent) { throw new Error("Parent issue not found"); } // Get next issue number for this repo const lastIssue = await db.query.issues.findFirst({ where: eq(schema.issues.repositoryId, parent.repositoryId), orderBy: (issues, { desc }) => [desc(issues.number)], }); const number = (lastIssue?.number || 0) + 1; const task = { id: crypto.randomUUID(), repositoryId: parent.repositoryId, number, title: options.title, body: options.body || null, type: "task", parentId: options.parentId, state: "open", authorId: options.authorId, createdAt: new Date(), updatedAt: new Date(), }; // @ts-expect-error - Drizzle multi-db union type issue await db.insert(schema.issues).values(task); logger.info( { taskId: task.id, parentId: options.parentId }, "Sub-task created", ); return task as typeof schema.issues.$inferSelect; } /** * Get epic with all children and progress */ export async function getEpicInfo(epicId: string): Promise { const db = getDatabase(); const epic = await db.query.issues.findFirst({ where: and(eq(schema.issues.id, epicId), eq(schema.issues.type, "epic")), }); if (!epic) return null; const children = await db.query.issues.findMany({ where: eq(schema.issues.parentId, epicId), }); const closed = children.filter((c) => c.state === "closed").length; const total = children.length; return { epic, children, progress: { total, closed, percentage: total > 0 ? Math.round((closed / total) * 100) : 0, }, }; } /** * Get all epics for a repository */ export async function getRepositoryEpics( repositoryId: string, ): Promise { const db = getDatabase(); const epics = await db.query.issues.findMany({ where: and( eq(schema.issues.repositoryId, repositoryId), eq(schema.issues.type, "epic"), ), }); if (epics.length === 0) return []; // Batch fetch all children for all epics in one query const epicIds = epics.map((e) => e.id); const allChildren = await db.query.issues.findMany({ where: inArray(schema.issues.parentId, epicIds), }); // Group children by parent in memory const childrenByEpic = new Map(); for (const child of allChildren) { if (!child.parentId) continue; const list = childrenByEpic.get(child.parentId) || []; list.push(child); childrenByEpic.set(child.parentId, list); } return epics.map((epic) => { const children = childrenByEpic.get(epic.id) || []; const closed = children.filter((c) => c.state === "closed").length; const total = children.length; return { epic, children, progress: { total, closed, percentage: total > 0 ? Math.round((closed / total) * 100) : 0, }, }; }); } /** * Move issue to an epic */ export async function addToEpic( issueId: string, epicId: string, ): Promise { const db = getDatabase(); const epic = await db.query.issues.findFirst({ where: and(eq(schema.issues.id, epicId), eq(schema.issues.type, "epic")), }); if (!epic) return false; await (db as NodePgDatabase) .update(schema.issues) .set({ parentId: epicId, updatedAt: new Date() }) .where(eq(schema.issues.id, issueId)); return true; } /** * Remove issue from epic */ export async function removeFromEpic(issueId: string): Promise { const db = getDatabase(); await (db as NodePgDatabase) .update(schema.issues) .set({ parentId: null, updatedAt: new Date() }) .where(eq(schema.issues.id, issueId)); return true; } /** * Get issue hierarchy (breadcrumb) */ export async function getIssueHierarchy( issueId: string, ): Promise<(typeof schema.issues.$inferSelect)[]> { const db = getDatabase(); const hierarchy: (typeof schema.issues.$inferSelect)[] = []; let currentId: string | null = issueId; while (currentId) { const issue = (await db.query.issues.findFirst({ where: eq(schema.issues.id, currentId), })) as typeof schema.issues.$inferSelect | undefined; if (!issue) break; hierarchy.unshift(issue); currentId = issue.parentId; } return hierarchy; } /** * Convert issue to epic */ export async function convertToEpic(issueId: string): Promise { const db = getDatabase(); await (db as NodePgDatabase) .update(schema.issues) .set({ type: "epic", parentId: null, updatedAt: new Date() }) .where(eq(schema.issues.id, issueId)); return true; } /** * Convert issue to task */ export async function convertToTask( issueId: string, parentId?: string, ): Promise { const db = getDatabase(); await (db as NodePgDatabase) .update(schema.issues) .set({ type: "task", parentId: parentId || null, updatedAt: new Date(), }) .where(eq(schema.issues.id, issueId)); return true; }