Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 "../../..";
Expand All @@ -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({
Expand Down Expand Up @@ -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 }) => {
Expand Down
163 changes: 163 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,6 +17,167 @@ const execFileAsync = promisify(execFile);
const cache = new Map<string, { data: GitHubStatus; timestamp: number }>();
const CACHE_TTL_MS = 10_000;

// Cache for batch PR list per repo (30 second TTL)
const prListCache = new Map<
string,
{ data: Map<string, GHPRListItem>; 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<typeof GHPRListItemSchema>;

/**
* 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<Map<string, GHPRListItem>> {
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<string, GHPRListItem>();
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<Map<string, GitHubStatus | null>> {
const results = new Map<string, GitHubStatus | null>();

// Group worktrees by repo
const byRepo = new Map<string, WorktreeInfo[]>();
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<GitHubStatus["pr"]> {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { fetchGitHubPRStatus } from "./github";
export { fetchGitHubPRStatus, fetchGitHubPRStatusBatch } from "./github";
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
);
Expand Down Expand Up @@ -144,12 +143,6 @@ export function WorkspaceListItem({
}
};

const handleMouseEnter = () => {
if (!hasHovered) {
setHasHovered(true);
}
};

const handleOpenInFinder = () => {
if (worktreePath) {
openInFinder.mutate(worktreePath);
Expand Down Expand Up @@ -205,7 +198,6 @@ export function WorkspaceListItem({
<button
type="button"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cn(
"relative flex items-center justify-center size-8 rounded-md",
"hover:bg-muted/50 transition-colors",
Expand Down Expand Up @@ -308,7 +300,6 @@ export function WorkspaceListItem({
handleClick();
}
}}
onMouseEnter={handleMouseEnter}
onDoubleClick={isBranchWorkspace ? undefined : rename.startRename}
className={cn(
"flex items-center w-full pl-3 pr-2 text-sm",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts";
import { usePRStatusPolling } from "renderer/screens/main/hooks/usePRStatus";
import { PortsList } from "./PortsList";
import { ProjectSection } from "./ProjectSection";
import { WorkspaceSidebarFooter } from "./WorkspaceSidebarFooter";
Expand All @@ -13,6 +14,7 @@ export function WorkspaceSidebar({
isCollapsed = false,
}: WorkspaceSidebarProps) {
const { groups } = useWorkspaceShortcuts();
usePRStatusPolling();

// Calculate shortcut base indices for each project group using cumulative offsets
const projectShortcutIndices = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,11 @@ export function ChangesView({
},
);

// Reads from cache populated by usePRStatusPolling
const { data: githubStatus, refetch: refetchGithubStatus } =
electronTrpc.workspaces.getGitHubStatus.useQuery(
{ workspaceId: workspaceId ?? "" },
{
enabled: !!workspaceId,
refetchInterval: 10000,
},
{ enabled: !!workspaceId },
);

const handleRefresh = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { usePRStatus } from "./usePRStatus";
export { usePRStatusPolling } from "./usePRStatusPolling";
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";

const PR_POLLING_INTERVAL_MS = 30_000;
const STALE_TIME_MS = 25_000;

/**
* Polls GitHub PR status for all open worktree workspaces using a single batch call.
* Must be non-blocking to avoid degrading sidebar responsiveness.
*/
export function usePRStatusPolling() {
const { data: groups = [] } =
electronTrpc.workspaces.getAllGrouped.useQuery();

// Branch workspaces don't have PRs
const worktreeWorkspaceIds = useMemo(
() =>
groups
.flatMap((group) => group.workspaces)
.filter((workspace) => workspace.type === "worktree")
.map((workspace) => workspace.id),
[groups],
);

// Single batch call instead of N individual calls
electronTrpc.workspaces.getGitHubStatusBatch.useQuery(
{ workspaceIds: worktreeWorkspaceIds },
{
enabled: worktreeWorkspaceIds.length > 0,
refetchInterval: PR_POLLING_INTERVAL_MS,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: STALE_TIME_MS,
retry: false,
},
);
}
Loading