/** * Issue Queries - Drizzle ORM (REFACTORED) * * All issue and triage-related database operations. * REFACTOR: SQL-first pagination using LIMIT/OFFSET, JOINs for triage data. * This eliminates N+1 queries and in-memory deduplication. */ import { db } from "@/db"; import { issues, triageData, repositories } from "@/db/schema"; import { eq, and, desc, asc, count, or, like, sql, inArray } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; // Define columns to select (excludes bodySummary which doesn't exist in DB yet) const issueColumns = { id: issues.id, githubIssueId: issues.githubIssueId, number: issues.number, title: issues.title, body: issues.body, authorName: issues.authorName, repoId: issues.repoId, repoName: issues.repoName, owner: issues.owner, repo: issues.repo, htmlUrl: issues.htmlUrl, state: issues.state, isPR: issues.isPR, authorAssociation: issues.authorAssociation, headSha: issues.headSha, updatedAt: issues.updatedAt, createdAt: issues.createdAt, }; // ============================================================================= // Issue CRUD // ============================================================================= export async function getIssueById(id: string) { const result = await db.select(issueColumns).from(issues).where(eq(issues.id, id)).limit(1); return result[0] || null; } export async function getIssueByGithubId(githubIssueId: number) { const result = await db.select(issueColumns).from(issues).where(eq(issues.githubIssueId, githubIssueId)).limit(1); return result[0] || null; } export async function createIssue(data: { githubIssueId: number; number: number; title: string; body?: string; bodySummary?: string; authorName: string; repoId: string; repoName: string; owner?: string; repo?: string; htmlUrl?: string; state?: string; isPR?: boolean; headSha?: string; // For PRs updatedAt?: string; // From GitHub's updated_at }) { const id = uuidv4(); const now = new Date().toISOString(); // Auto-generate bodySummary if not provided const summary = data.bodySummary || (data.body ? data.body.substring(0, 200) : null); await db.insert(issues).values({ id, githubIssueId: data.githubIssueId, number: data.number, title: data.title, body: data.body || null, bodySummary: summary, authorName: data.authorName, repoId: data.repoId, repoName: data.repoName, owner: data.owner || null, repo: data.repo || null, htmlUrl: data.htmlUrl || null, state: data.state || "open", isPR: data.isPR || false, headSha: data.headSha || null, updatedAt: data.updatedAt || now, createdAt: now, }).onConflictDoNothing(); return { id, ...data, bodySummary: summary, createdAt: now }; } export async function updateIssueState(id: string, state: string) { await db.update(issues).set({ state, updatedAt: new Date().toISOString() }).where(eq(issues.id, id)); } export async function getIssueByNumberAndRepo(number: number, repoId: string) { const result = await db.select(issueColumns).from(issues) .where(and(eq(issues.number, number), eq(issues.repoId, repoId))) .limit(1); return result[0] || null; } /** * Delete duplicate issue/PR rows, keeping only the oldest entry per number+repoId. * Returns the count of deleted duplicates. */ export async function cleanupDuplicateIssues() { const allIssues = await db.select(issueColumns).from(issues).orderBy(asc(issues.createdAt)); const kept = new Map(); // key -> id to keep const toDelete: string[] = []; for (const issue of allIssues) { const key = `${issue.repoId}:${issue.number}`; if (kept.has(key)) { toDelete.push(issue.id); } else { kept.set(key, issue.id); } } for (const id of toDelete) { await db.delete(issues).where(eq(issues.id, id)); } return toDelete.length; } // ============================================================================= // Issue Listing with SQL Pagination (REFACTORED) // ============================================================================= export interface IssueFilters { repoId?: string; repoName?: string; // Filter by repo name (owner/repo format) userId?: string; authorName?: string; state?: string; isPR?: boolean; search?: string; } /** * Pagination using SQL LIMIT/OFFSET. * ✅ Never fetches all rows into memory * ✅ Uses database-level LIMIT/OFFSET, not JS slice() */ export async function getIssues(filters: IssueFilters, page = 1, limit = 10) { // Validate page and limit const offset = Math.max(0, (page - 1) * limit); const safePage = Math.max(1, page); // Build conditions const conditions = []; if (filters.repoId) conditions.push(eq(issues.repoId, filters.repoId)); if (filters.repoName) conditions.push(eq(issues.repoName, filters.repoName)); if (filters.authorName) conditions.push(eq(issues.authorName, filters.authorName)); if (filters.state) conditions.push(eq(issues.state, filters.state)); if (filters.isPR !== undefined) conditions.push(eq(issues.isPR, filters.isPR)); if (filters.search) { conditions.push(or( like(issues.title, `%${filters.search}%`), like(issues.body, `%${filters.search}%`) )); } // If userId provided, filter by user's repos that were explicitly added if (filters.userId) { const userRepos = await db.select({ id: repositories.id }) .from(repositories) .where(and( eq(repositories.userId, filters.userId), eq(repositories.addedByUser, true) )); const repoIds = userRepos.map(r => r.id); if (repoIds.length > 0) { conditions.push(inArray(issues.repoId, repoIds)); } else { return { issues: [], total: 0, page: safePage, limit, totalPages: 0 }; } } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; // Get total count const countResult = await db.select({ count: count() }) .from(issues) .where(whereClause); const total = countResult[0]?.count || 0; const totalPages = Math.ceil(total / limit); // ✅ SQL-level LIMIT/OFFSET — never materializes full result const results = await db.select({ id: issues.id, githubIssueId: issues.githubIssueId, number: issues.number, title: issues.title, body: issues.body, authorName: issues.authorName, repoId: issues.repoId, repoName: issues.repoName, owner: issues.owner, repo: issues.repo, htmlUrl: issues.htmlUrl, state: issues.state, isPR: issues.isPR, authorAssociation: issues.authorAssociation, headSha: issues.headSha, updatedAt: issues.updatedAt, createdAt: issues.createdAt, }) .from(issues) .where(whereClause) .orderBy(desc(issues.createdAt)) .limit(limit) .offset(offset) return { issues: results, total, page: safePage, limit, totalPages, }; } // ============================================================================= // Issues with Triage Data (REFACTORED - Single Query with JOIN) // ============================================================================= /** * ✅ REFACTORED: Single SQL JOIN instead of Promise.all(). * Eliminates N+1 query problem. Database joins and returns in one round-trip. */ export async function getIssuesWithTriage(filters: IssueFilters, page = 1, limit = 10) { // Validate pagination const offset = Math.max(0, (page - 1) * limit); const safePage = Math.max(1, page); console.log("[getIssuesWithTriage] Starting query with filters:", filters, "page:", page, "limit:", limit); try { // Build conditions (identical to getIssues) const conditions = []; if (filters.repoId) conditions.push(eq(issues.repoId, filters.repoId)); if (filters.repoName) conditions.push(eq(issues.repoName, filters.repoName)); if (filters.authorName) conditions.push(eq(issues.authorName, filters.authorName)); if (filters.state) conditions.push(eq(issues.state, filters.state)); if (filters.isPR !== undefined) conditions.push(eq(issues.isPR, filters.isPR)); if (filters.search) { conditions.push(or( like(issues.title, `%${filters.search}%`), like(issues.body, `%${filters.search}%`) )); } if (filters.userId) { const userRepos = await db.select({ id: repositories.id }) .from(repositories) .where(and( eq(repositories.userId, filters.userId), eq(repositories.addedByUser, true) )); const repoIds = userRepos.map(r => r.id); if (repoIds.length > 0) { conditions.push(inArray(issues.repoId, repoIds)); } else { return { issues: [], total: 0, page: safePage, limit, totalPages: 0 }; } } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; console.log("[getIssuesWithTriage] Built where clause, fetching count..."); // Get total count const countResult = await db.select({ count: count() }) .from(issues) .where(whereClause); const total = countResult[0]?.count || 0; const totalPages = Math.ceil(total / limit); console.log("[getIssuesWithTriage] Total count:", total, "totalPages:", totalPages); // Fetch issues using simple query console.log("[getIssuesWithTriage] Executing issues query..."); const issueResults = await db.select(issueColumns) .from(issues) .where(whereClause) .orderBy(desc(issues.createdAt)) .limit(limit) .offset(offset); console.log("[getIssuesWithTriage] Query returned %d results", issueResults.length); // Fetch triage data for these issues if (issueResults.length === 0) { return { issues: [], total, page: safePage, limit, totalPages, }; } console.log("[getIssuesWithTriage] Fetching triage data for %d issues", issueResults.length); const issueIds = issueResults.map(i => i.id); const triageDataResults = await db.select() .from(triageData) .where(inArray(triageData.issueId, issueIds)); console.log("[getIssuesWithTriage] Found %d triage records", triageDataResults.length); // Create a map of triage data by issueId for quick lookup const triageMap = new Map(triageDataResults.map(t => [t.issueId, t])); // Merge issues with their triage data const issuesWithTriage = issueResults.map(issue => ({ ...issue, triage: triageMap.get(issue.id) || null, })); return { issues: issuesWithTriage, total, page: safePage, limit, totalPages, }; } catch (error: any) { console.error("[getIssuesWithTriage] Query failed:", error); console.error("[getIssuesWithTriage] Error message:", error?.message); console.error("[getIssuesWithTriage] Error stack:", error?.stack); throw error; } } // ============================================================================= // Triage Operations // ============================================================================= export async function getTriageData(issueId: string) { const result = await db.select().from(triageData).where(eq(triageData.issueId, issueId)).limit(1); return result[0] || null; } export async function createOrUpdateTriageData(data: { issueId: string; classification: string; summary: string; suggestedLabel: string; sentiment: string; }) { const id = uuidv4(); const now = new Date().toISOString(); await db.insert(triageData).values({ id, issueId: data.issueId, classification: data.classification, summary: data.summary, suggestedLabel: data.suggestedLabel, sentiment: data.sentiment, analyzedAt: now, }).onConflictDoUpdate({ target: triageData.issueId, set: { classification: data.classification, summary: data.summary, suggestedLabel: data.suggestedLabel, sentiment: data.sentiment, analyzedAt: now, } }); return { id, ...data, analyzedAt: now }; } // ============================================================================= // Dashboard Stats // ============================================================================= export async function getDashboardStats(userId: string) { // Only count issues/PRs from repos explicitly added by the user const userRepos = await db.select({ id: repositories.id }) .from(repositories) .where(and( eq(repositories.userId, userId), eq(repositories.addedByUser, true) )); if (userRepos.length === 0) { return { openIssues: 0, openPRs: 0, triaged: 0, untriaged: 0 }; } const repoIds = userRepos.map(r => r.id); const openIssues = await db.select({ count: count() }) .from(issues) .where(and( inArray(issues.repoId, repoIds), eq(issues.state, "open"), eq(issues.isPR, false) )); const openPRs = await db.select({ count: count() }) .from(issues) .where(and( inArray(issues.repoId, repoIds), eq(issues.state, "open"), eq(issues.isPR, true) )); // Count triaged issues const allOpenIssueIds = await db.select({ id: issues.id }) .from(issues) .where(and( inArray(issues.repoId, repoIds), eq(issues.state, "open") )); let triaged = 0; for (const issue of allOpenIssueIds) { const hasTriagedata = await db.select({ id: triageData.id }) .from(triageData) .where(eq(triageData.issueId, issue.id)) .limit(1); if (hasTriagedata.length > 0) triaged++; } return { openIssues: openIssues[0]?.count || 0, openPRs: openPRs[0]?.count || 0, triaged, untriaged: (openIssues[0]?.count || 0) - triaged, }; }