diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 85688e7050a..891e3c7f9f2 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -1,5 +1,5 @@ -import { workspaces, worktrees } from "@superset/local-db"; -import { and, eq, isNull } from "drizzle-orm"; +import { projects, workspaces, worktrees } from "@superset/local-db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; @@ -15,7 +15,7 @@ import { getDefaultBranch, refreshDefaultBranch, } from "../utils/git"; -import { fetchGitHubPRStatus } from "../utils/github"; +import { fetchGitHubPRStatus, fetchGitHubPRStatusBatch } from "../utils/github"; export const createGitStatusProcedures = () => { return router({ @@ -113,6 +113,56 @@ export const createGitStatusProcedures = () => { return freshStatus; }), + getGitHubStatusBatch: publicProcedure + .input(z.object({ workspaceIds: z.array(z.string()) })) + .query(async ({ input }) => { + if (input.workspaceIds.length === 0) { + return {}; + } + + // Get all workspaces with their worktrees and projects in one query + const workspaceData = localDb + .select({ + workspace: workspaces, + worktree: worktrees, + project: projects, + }) + .from(workspaces) + .innerJoin(worktrees, eq(workspaces.worktreeId, worktrees.id)) + .innerJoin(projects, eq(workspaces.projectId, projects.id)) + .where( + and( + inArray(workspaces.id, input.workspaceIds), + isNull(workspaces.deletingAt), + ), + ) + .all(); + + const worktreeInfos = workspaceData.map((row) => ({ + workspaceId: row.workspace.id, + worktreePath: row.worktree.path, + branch: row.worktree.branch, + repoPath: row.project.mainRepoPath, + })); + + const results = await fetchGitHubPRStatusBatch(worktreeInfos); + + // Update DB cache for each result + for (const row of workspaceData) { + const status = results.get(row.workspace.id); + if (status) { + localDb + .update(worktrees) + .set({ githubStatus: status }) + .where(eq(worktrees.id, row.worktree.id)) + .run(); + } + } + + // Convert Map to plain object for serialization + return Object.fromEntries(results); + }), + getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 48868425e9d..340d277e88d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -1,9 +1,11 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { CheckItem, GitHubStatus } from "@superset/local-db"; +import { z } from "zod"; import { branchExistsOnRemote } from "../git"; import { execWithShellEnv } from "../shell-env"; import { + GHCheckContextSchema, type GHPRResponse, GHPRResponseSchema, GHRepoResponseSchema, @@ -15,6 +17,167 @@ const execFileAsync = promisify(execFile); const cache = new Map(); const CACHE_TTL_MS = 10_000; +// Cache for batch PR list per repo (30 second TTL) +const prListCache = new Map< + string, + { data: Map; timestamp: number } +>(); +const PR_LIST_CACHE_TTL_MS = 30_000; + +// Schema for gh pr list response (extends GHPRResponse with headRefName) +const GHPRListItemSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + headRefName: z.string(), + mergedAt: z.string().nullable(), + additions: z.number(), + deletions: z.number(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) + .nullable(), + statusCheckRollup: z.array(GHCheckContextSchema).nullable(), +}); +const GHPRListSchema = z.array(GHPRListItemSchema); +type GHPRListItem = z.infer; + +/** + * Fetches all open PRs for a repo in a single gh call. + * Results are cached for 30 seconds per repo. + */ +async function fetchAllPRsForRepo( + repoPath: string, +): Promise> { + const cached = prListCache.get(repoPath); + if (cached && Date.now() - cached.timestamp < PR_LIST_CACHE_TTL_MS) { + return cached.data; + } + + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--author", + "@me", + "--state", + "all", + "--limit", + "50", + "--json", + "number,title,url,state,isDraft,headRefName,mergedAt,additions,deletions,reviewDecision,statusCheckRollup", + ], + { cwd: repoPath }, + ); + + const raw = JSON.parse(stdout); + const result = GHPRListSchema.safeParse(raw); + if (!result.success) { + console.error("[GitHub] PR list schema validation failed:", result.error); + return new Map(); + } + + // Index by branch name for fast lookup + const prMap = new Map(); + for (const pr of result.data) { + prMap.set(pr.headRefName, pr); + } + + prListCache.set(repoPath, { data: prMap, timestamp: Date.now() }); + return prMap; + } catch { + return new Map(); + } +} + +interface WorktreeInfo { + workspaceId: string; + worktreePath: string; + branch: string; + repoPath: string; +} + +/** + * Batch fetch GitHub PR status for multiple worktrees. + * Groups by repo and fetches all PRs per repo in one call. + */ +export async function fetchGitHubPRStatusBatch( + worktrees: WorktreeInfo[], +): Promise> { + const results = new Map(); + + // Group worktrees by repo + const byRepo = new Map(); + for (const wt of worktrees) { + const existing = byRepo.get(wt.repoPath) ?? []; + existing.push(wt); + byRepo.set(wt.repoPath, existing); + } + + // Fetch PRs for each repo in parallel + await Promise.all( + Array.from(byRepo.entries()).map(async ([repoPath, repoWorktrees]) => { + const [repoUrl, prMap] = await Promise.all([ + getRepoUrl(repoPath), + fetchAllPRsForRepo(repoPath), + ]); + + // Skip if we couldn't get repo URL + if (!repoUrl) { + for (const wt of repoWorktrees) { + results.set(wt.workspaceId, null); + } + return; + } + + // Check branch existence in parallel for all worktrees in this repo + const branchChecks = await Promise.all( + repoWorktrees.map((wt) => + branchExistsOnRemote(wt.worktreePath, wt.branch), + ), + ); + + for (let i = 0; i < repoWorktrees.length; i++) { + const wt = repoWorktrees[i]; + const pr = prMap.get(wt.branch); + const branchCheck = branchChecks[i]; + + const status: GitHubStatus = { + pr: pr ? convertPRListItemToStatus(pr) : null, + repoUrl, + branchExistsOnRemote: branchCheck.status === "exists", + lastRefreshed: Date.now(), + }; + + results.set(wt.workspaceId, status); + cache.set(wt.worktreePath, { data: status, timestamp: Date.now() }); + } + }), + ); + + return results; +} + +function convertPRListItemToStatus( + pr: GHPRListItem, +): NonNullable { + return { + number: pr.number, + title: pr.title, + url: pr.url, + state: mapPRState(pr.state, pr.isDraft), + mergedAt: pr.mergedAt ? new Date(pr.mergedAt).getTime() : undefined, + additions: pr.additions, + deletions: pr.deletions, + reviewDecision: mapReviewDecision(pr.reviewDecision), + checksStatus: computeChecksStatus(pr.statusCheckRollup), + checks: parseChecks(pr.statusCheckRollup), + }; +} + /** * Fetches GitHub PR status for a worktree using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts index 75fcdd5296d..713107491aa 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -1 +1 @@ -export { fetchGitHubPRStatus } from "./github"; +export { fetchGitHubPRStatus, fetchGitHubPRStatusBatch } from "./github"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 0c107874352..638d429d20d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -16,7 +16,7 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { LuEye, LuEyeOff, LuFolder, LuFolderGit2 } from "react-icons/lu"; @@ -79,7 +79,6 @@ export function WorkspaceListItem({ const navigate = useNavigate(); const matchRoute = useMatchRoute(); const reorderWorkspaces = useReorderWorkspaces(); - const [hasHovered, setHasHovered] = useState(false); const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); @@ -108,12 +107,12 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - // Lazy-load GitHub status on hover to avoid N+1 queries + // Reads from cache populated by usePRStatusPolling const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: id }, { - enabled: hasHovered && type === "worktree", + enabled: type === "worktree", staleTime: GITHUB_STATUS_STALE_TIME, }, ); @@ -144,12 +143,6 @@ export function WorkspaceListItem({ } }; - const handleMouseEnter = () => { - if (!hasHovered) { - setHasHovered(true); - } - }; - const handleOpenInFinder = () => { if (worktreePath) { openInFinder.mutate(worktreePath); @@ -205,7 +198,6 @@ export function WorkspaceListItem({