From e5360c358c2c8d044cacf98bff40ebec32f1c35a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 17:00:24 -0800 Subject: [PATCH 1/7] Add workspace hover cards with GitHub PR status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hover card to workspace tabs showing worktree info and GitHub PR status - Fetch PR data via `gh` CLI with graceful fallback if unavailable - Display worktree name, age, rebase warning, PR number/status/title - Show diff stats (+additions/-deletions) with GitHub-style colors - Add expandable checks list showing individual CI job statuses with links - Show review status (approved/changes requested/pending) - Include "View on GitHub" button with GitHub logo πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../trpc/routers/workspaces/utils/github.ts | 208 ++++++++++++ .../lib/trpc/routers/workspaces/workspaces.ts | 62 ++++ apps/desktop/src/main/lib/db/schemas.ts | 11 + .../WorkspaceHoverCard/WorkspaceHoverCard.tsx | 302 ++++++++++++++++++ .../WorkspaceTabs/WorkspaceHoverCard/index.ts | 1 + .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 1 + .../WorkspaceItemContextMenu.tsx | 35 +- 7 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts new file mode 100644 index 00000000000..056ee6fc79e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts @@ -0,0 +1,208 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; + +const execAsync = promisify(exec); + +interface GHCheckContext { + __typename: string; + name?: string; + context?: string; // StatusContext uses 'context' instead of 'name' + state?: "SUCCESS" | "FAILURE" | "PENDING" | "ERROR"; + conclusion?: + | "SUCCESS" + | "FAILURE" + | "CANCELLED" + | "SKIPPED" + | "TIMED_OUT" + | "ACTION_REQUIRED" + | "NEUTRAL" + | null; + detailsUrl?: string; + targetUrl?: string; // StatusContext uses 'targetUrl' instead of 'detailsUrl' +} + +interface GHPRResponse { + number: number; + title: string; + url: string; + state: "OPEN" | "CLOSED" | "MERGED"; + isDraft: boolean; + mergedAt: string | null; + additions: number; + deletions: number; + reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; + statusCheckRollup: { + contexts: GHCheckContext[]; + } | null; +} + +interface GHRepoResponse { + url: string; +} + +/** + * Fetches GitHub PR status for a worktree using the `gh` CLI. + * Returns null if `gh` is not installed, not authenticated, or on error. + */ +export async function fetchGitHubPRStatus( + worktreePath: string, +): Promise { + try { + // First, get the repo URL + const repoUrl = await getRepoUrl(worktreePath); + if (!repoUrl) { + return null; + } + + // Try to get PR info for current branch + const prInfo = await getPRForCurrentBranch(worktreePath); + + return { + pr: prInfo, + repoUrl, + lastRefreshed: Date.now(), + }; + } catch { + // Any error (gh not installed, not auth'd, etc.) - return null + return null; + } +} + +async function getRepoUrl(worktreePath: string): Promise { + try { + const { stdout } = await execAsync("gh repo view --json url", { + cwd: worktreePath, + }); + const data = JSON.parse(stdout) as GHRepoResponse; + return data.url; + } catch { + return null; + } +} + +async function getPRForCurrentBranch( + worktreePath: string, +): Promise { + try { + // Get the current branch name explicitly (worktrees don't work well with gh's auto-detection) + const { stdout: branchName } = await execAsync( + "git rev-parse --abbrev-ref HEAD", + { cwd: worktreePath }, + ); + const branch = branchName.trim(); + + const { stdout } = await execAsync( + `gh pr view ${branch} --json number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup`, + { cwd: worktreePath }, + ); + const data = JSON.parse(stdout) as GHPRResponse; + + const checks = parseChecks(data.statusCheckRollup); + + return { + number: data.number, + title: data.title, + url: data.url, + state: mapPRState(data.state, data.isDraft), + mergedAt: data.mergedAt ? new Date(data.mergedAt).getTime() : undefined, + additions: data.additions, + deletions: data.deletions, + reviewDecision: mapReviewDecision(data.reviewDecision), + checksStatus: computeChecksStatus(data.statusCheckRollup), + checks, + }; + } catch (error) { + // "no pull requests found" is not an error - just no PR + if ( + error instanceof Error && + error.message.includes("no pull requests found") + ) { + return null; + } + // Re-throw other errors to be caught by parent + throw error; + } +} + +function mapPRState( + state: GHPRResponse["state"], + isDraft: boolean, +): NonNullable["state"] { + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (isDraft) return "draft"; + return "open"; +} + +function mapReviewDecision( + decision: GHPRResponse["reviewDecision"], +): NonNullable["reviewDecision"] { + if (decision === "APPROVED") return "approved"; + if (decision === "CHANGES_REQUESTED") return "changes_requested"; + return "pending"; +} + +function parseChecks(rollup: GHPRResponse["statusCheckRollup"]): CheckItem[] { + if (!rollup || !rollup.contexts || rollup.contexts.length === 0) { + return []; + } + + return rollup.contexts.map((ctx) => { + // CheckRun uses 'name', StatusContext uses 'context' + const name = ctx.name || ctx.context || "Unknown check"; + // CheckRun uses 'detailsUrl', StatusContext uses 'targetUrl' + const url = ctx.detailsUrl || ctx.targetUrl; + // StatusContext uses 'state', CheckRun uses 'conclusion' + const rawStatus = ctx.state || ctx.conclusion; + + let status: CheckItem["status"]; + if (rawStatus === "SUCCESS") { + status = "success"; + } else if ( + rawStatus === "FAILURE" || + rawStatus === "ERROR" || + rawStatus === "TIMED_OUT" + ) { + status = "failure"; + } else if (rawStatus === "SKIPPED" || rawStatus === "NEUTRAL") { + status = "skipped"; + } else if (rawStatus === "CANCELLED") { + status = "cancelled"; + } else { + status = "pending"; + } + + return { name, status, url }; + }); +} + +function computeChecksStatus( + rollup: GHPRResponse["statusCheckRollup"], +): NonNullable["checksStatus"] { + if (!rollup || !rollup.contexts || rollup.contexts.length === 0) { + return "none"; + } + + let hasFailure = false; + let hasPending = false; + + for (const ctx of rollup.contexts) { + // StatusContext uses 'state', CheckRun uses 'conclusion' + const status = ctx.state || ctx.conclusion; + + if (status === "FAILURE" || status === "ERROR" || status === "TIMED_OUT") { + hasFailure = true; + } else if ( + status === "PENDING" || + status === null || + status === undefined + ) { + hasPending = true; + } + } + + if (hasFailure) return "failure"; + if (hasPending) return "pending"; + return "success"; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index e526dbfa2c8..0c542780bbb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -15,6 +15,7 @@ import { removeWorktree, worktreeExists, } from "./utils/git"; +import { fetchGitHubPRStatus } from "./utils/github"; import { loadSetupConfig } from "./utils/setup"; import { runTeardown } from "./utils/teardown"; import { getWorktreePath } from "./utils/worktree"; @@ -558,6 +559,67 @@ export const createWorkspacesRouter = () => { return { gitStatus }; }), + + getGitHubStatus: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace) { + return null; + } + + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + if (!worktree) { + return null; + } + + // Always fetch fresh data on hover + const freshStatus = await fetchGitHubPRStatus(worktree.path); + + // Update cache if we got data + if (freshStatus) { + await db.update((data) => { + const wt = data.worktrees.find((w) => w.id === worktree.id); + if (wt) { + wt.githubStatus = freshStatus; + } + }); + } + + return freshStatus; + }), + + getWorktreeInfo: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace) { + return null; + } + + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + if (!worktree) { + return null; + } + + // Extract worktree name from path (last segment) + const worktreeName = worktree.path.split("/").pop() ?? worktree.branch; + + return { + worktreeName, + createdAt: worktree.createdAt, + gitStatus: worktree.gitStatus ?? null, + githubStatus: worktree.githubStatus ?? null, + }; + }), }); }; diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index a24e1bb3d6b..e319ee7fff3 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -16,6 +16,12 @@ export interface GitStatus { lastRefreshed: number; } +export interface CheckItem { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url?: string; +} + export interface GitHubStatus { pr: { number: number; @@ -23,6 +29,11 @@ export interface GitHubStatus { url: string; state: "open" | "draft" | "merged" | "closed"; mergedAt?: number; + additions: number; + deletions: number; + reviewDecision: "approved" | "changes_requested" | "pending"; + checksStatus: "success" | "failure" | "pending" | "none"; + checks: CheckItem[]; } | null; repoUrl: string; lastRefreshed: number; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx new file mode 100644 index 00000000000..cdced695dfc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -0,0 +1,302 @@ +import { Button } from "@superset/ui/button"; +import { + Check, + ChevronDown, + ChevronRight, + LoaderCircle, + Minus, + TriangleAlert, + X, +} from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; +import { useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { trpc } from "renderer/lib/trpc"; + +interface WorkspaceHoverCardContentProps { + workspaceId: string; +} + +export function WorkspaceHoverCardContent({ + workspaceId, +}: WorkspaceHoverCardContentProps) { + const { data: worktreeInfo } = trpc.workspaces.getWorktreeInfo.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + + const { data: githubStatus, isLoading: isLoadingGithub } = + trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + + const pr = githubStatus?.pr; + const needsRebase = worktreeInfo?.gitStatus?.needsRebase; + + return ( +
+ {/* Header: Worktree name + age */} + {worktreeInfo?.worktreeName && ( +
+ + {worktreeInfo.worktreeName} + + {worktreeInfo?.createdAt && ( + + {formatRelativeTime(worktreeInfo.createdAt)} + + )} +
+ )} + + {/* Needs Rebase Warning */} + {needsRebase && ( +
+ + Behind main, needs rebase +
+ )} + + {/* PR Section */} + {isLoadingGithub ? ( +
+ + Loading PR... +
+ ) : pr ? ( +
+ {/* PR Header: Number + Status + Diff Stats */} +
+
+ + #{pr.number} + + +
+
+ +{pr.additions} + + -{pr.deletions} + +
+
+ + {/* PR Title */} +

{pr.title}

+ + {/* Checks & Review - only for open PRs */} + {pr.state === "open" && ( +
+
+ + Β· + +
+ {pr.checks.length > 0 && } +
+ )} + + {/* View on GitHub button */} + +
+ ) : githubStatus ? ( +
+ No PR for this branch +
+ ) : null} +
+ ); +} + +function PRStatusBadge({ + state, +}: { + state: "open" | "draft" | "merged" | "closed"; +}) { + const styles = { + open: "bg-emerald-500/15 text-emerald-500", + draft: "bg-muted text-muted-foreground", + merged: "bg-violet-500/15 text-violet-500", + closed: "bg-destructive/15 text-destructive-foreground", + }; + + const labels = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", + }; + + return ( + + {labels[state]} + + ); +} + +function ChecksSummary({ + checks, + status, +}: { + checks: CheckItem[]; + status: "success" | "failure" | "pending" | "none"; +}) { + if (status === "none") return null; + + const passing = checks.filter((c) => c.status === "success").length; + const total = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ).length; + + const config = { + success: { + icon: Check, + className: "text-emerald-500", + }, + failure: { + icon: X, + className: "text-destructive-foreground", + }, + pending: { + icon: LoaderCircle, + className: "text-amber-500", + }, + }; + + const { icon: Icon, className } = config[status]; + const label = total > 0 ? `${passing}/${total} checks` : "Checks"; + + return ( + + + {label} + + ); +} + +function ChecksList({ checks }: { checks: CheckItem[] }) { + const [expanded, setExpanded] = useState(false); + + // Filter out skipped/cancelled for display count, but show all when expanded + const relevantChecks = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ); + + if (relevantChecks.length === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {relevantChecks.map((check) => ( + + ))} +
+ )} +
+ ); +} + +function CheckItemRow({ check }: { check: CheckItem }) { + const statusConfig = { + success: { icon: Check, className: "text-emerald-500" }, + failure: { icon: X, className: "text-destructive-foreground" }, + pending: { icon: LoaderCircle, className: "text-amber-500" }, + skipped: { icon: Minus, className: "text-muted-foreground" }, + cancelled: { icon: Minus, className: "text-muted-foreground" }, + }; + + const { icon: Icon, className } = statusConfig[check.status]; + + const content = ( + + + {check.name} + + ); + + if (check.url) { + return ( + + {content} + + ); + } + + return
{content}
; +} + +function ReviewStatus({ + status, +}: { + status: "approved" | "changes_requested" | "pending"; +}) { + const config = { + approved: { label: "Approved", className: "text-emerald-500" }, + changes_requested: { + label: "Changes requested", + className: "text-destructive-foreground", + }, + pending: { label: "Review pending", className: "text-muted-foreground" }, + }; + + const { label, className } = config[status]; + + return {label}; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days === 1 ? "" : "s"} ago`; + } + if (hours > 0) { + return `${hours} hour${hours === 1 ? "" : "s"} ago`; + } + if (minutes > 0) { + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + } + return "Just now"; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts new file mode 100644 index 00000000000..907ac164558 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts @@ -0,0 +1 @@ +export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 9c442580a80..bc5908df427 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -105,6 +105,7 @@ export function WorkspaceItem({ return ( <> diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index c55863d005e..aa913f9453d 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -5,17 +5,25 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; import type { ReactNode } from "react"; import { trpc } from "renderer/lib/trpc"; +import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; interface WorkspaceItemContextMenuProps { children: ReactNode; + workspaceId: string; worktreePath: string; onRename: () => void; } export function WorkspaceItemContextMenu({ children, + workspaceId, worktreePath, onRename, }: WorkspaceItemContextMenuProps) { @@ -28,15 +36,22 @@ export function WorkspaceItemContextMenu({ }; return ( - - {children} - - Rename - - - Open in Finder - - - + + + + {children} + + + Rename + + + Open in Finder + + + + + + + ); } From 1865e9a3b2fe6d93d1666f323ffc1987e05d5ce7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 17:07:54 -0800 Subject: [PATCH 2/7] Refactor WorkspaceHoverCard into subcomponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break down the monolithic WorkspaceHoverCard.tsx into separate components following the repo's co-location pattern: - PRStatusBadge: Badge showing PR state (open/draft/merged/closed) - ReviewStatus: Shows review decision status - ChecksSummary: Summary of CI checks (X/Y passing) - ChecksList: Expandable list of individual checks with CheckItemRow - utils.ts: Shared formatRelativeTime utility πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../WorkspaceHoverCard/WorkspaceHoverCard.tsx | 199 +----------------- .../components/ChecksList/CheckItemRow.tsx | 42 ++++ .../components/ChecksList/ChecksList.tsx | 44 ++++ .../components/ChecksList/index.ts | 1 + .../ChecksSummary/ChecksSummary.tsx | 43 ++++ .../components/ChecksSummary/index.ts | 1 + .../PRStatusBadge/PRStatusBadge.tsx | 27 +++ .../components/PRStatusBadge/index.ts | 1 + .../components/ReviewStatus/ReviewStatus.tsx | 18 ++ .../components/ReviewStatus/index.ts | 1 + .../WorkspaceTabs/WorkspaceHoverCard/utils.ts | 20 ++ 11 files changed, 204 insertions(+), 193 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx index cdced695dfc..e7de46f9f47 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -1,17 +1,12 @@ import { Button } from "@superset/ui/button"; -import { - Check, - ChevronDown, - ChevronRight, - LoaderCircle, - Minus, - TriangleAlert, - X, -} from "lucide-react"; -import type { CheckItem } from "main/lib/db/schemas"; -import { useState } from "react"; +import { LoaderCircle, TriangleAlert } from "lucide-react"; import { FaGithub } from "react-icons/fa"; import { trpc } from "renderer/lib/trpc"; +import { ChecksList } from "./components/ChecksList"; +import { ChecksSummary } from "./components/ChecksSummary"; +import { PRStatusBadge } from "./components/PRStatusBadge"; +import { ReviewStatus } from "./components/ReviewStatus"; +import { formatRelativeTime } from "./utils"; interface WorkspaceHoverCardContentProps { workspaceId: string; @@ -118,185 +113,3 @@ export function WorkspaceHoverCardContent({ ); } - -function PRStatusBadge({ - state, -}: { - state: "open" | "draft" | "merged" | "closed"; -}) { - const styles = { - open: "bg-emerald-500/15 text-emerald-500", - draft: "bg-muted text-muted-foreground", - merged: "bg-violet-500/15 text-violet-500", - closed: "bg-destructive/15 text-destructive-foreground", - }; - - const labels = { - open: "Open", - draft: "Draft", - merged: "Merged", - closed: "Closed", - }; - - return ( - - {labels[state]} - - ); -} - -function ChecksSummary({ - checks, - status, -}: { - checks: CheckItem[]; - status: "success" | "failure" | "pending" | "none"; -}) { - if (status === "none") return null; - - const passing = checks.filter((c) => c.status === "success").length; - const total = checks.filter( - (c) => c.status !== "skipped" && c.status !== "cancelled", - ).length; - - const config = { - success: { - icon: Check, - className: "text-emerald-500", - }, - failure: { - icon: X, - className: "text-destructive-foreground", - }, - pending: { - icon: LoaderCircle, - className: "text-amber-500", - }, - }; - - const { icon: Icon, className } = config[status]; - const label = total > 0 ? `${passing}/${total} checks` : "Checks"; - - return ( - - - {label} - - ); -} - -function ChecksList({ checks }: { checks: CheckItem[] }) { - const [expanded, setExpanded] = useState(false); - - // Filter out skipped/cancelled for display count, but show all when expanded - const relevantChecks = checks.filter( - (c) => c.status !== "skipped" && c.status !== "cancelled", - ); - - if (relevantChecks.length === 0) return null; - - return ( -
- - - {expanded && ( -
- {relevantChecks.map((check) => ( - - ))} -
- )} -
- ); -} - -function CheckItemRow({ check }: { check: CheckItem }) { - const statusConfig = { - success: { icon: Check, className: "text-emerald-500" }, - failure: { icon: X, className: "text-destructive-foreground" }, - pending: { icon: LoaderCircle, className: "text-amber-500" }, - skipped: { icon: Minus, className: "text-muted-foreground" }, - cancelled: { icon: Minus, className: "text-muted-foreground" }, - }; - - const { icon: Icon, className } = statusConfig[check.status]; - - const content = ( - - - {check.name} - - ); - - if (check.url) { - return ( - - {content} - - ); - } - - return
{content}
; -} - -function ReviewStatus({ - status, -}: { - status: "approved" | "changes_requested" | "pending"; -}) { - const config = { - approved: { label: "Approved", className: "text-emerald-500" }, - changes_requested: { - label: "Changes requested", - className: "text-destructive-foreground", - }, - pending: { label: "Review pending", className: "text-muted-foreground" }, - }; - - const { label, className } = config[status]; - - return {label}; -} - -function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - return `${days} day${days === 1 ? "" : "s"} ago`; - } - if (hours > 0) { - return `${hours} hour${hours === 1 ? "" : "s"} ago`; - } - if (minutes > 0) { - return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; - } - return "Just now"; -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx new file mode 100644 index 00000000000..6962dae4a2d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx @@ -0,0 +1,42 @@ +import { Check, LoaderCircle, Minus, X } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; + +interface CheckItemRowProps { + check: CheckItem; +} + +export function CheckItemRow({ check }: CheckItemRowProps) { + const statusConfig = { + success: { icon: Check, className: "text-emerald-500" }, + failure: { icon: X, className: "text-destructive-foreground" }, + pending: { icon: LoaderCircle, className: "text-amber-500" }, + skipped: { icon: Minus, className: "text-muted-foreground" }, + cancelled: { icon: Minus, className: "text-muted-foreground" }, + }; + + const { icon: Icon, className } = statusConfig[check.status]; + + const content = ( + + + {check.name} + + ); + + if (check.url) { + return ( + + {content} + + ); + } + + return
{content}
; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx new file mode 100644 index 00000000000..16f10e757a1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx @@ -0,0 +1,44 @@ +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; +import { useState } from "react"; +import { CheckItemRow } from "./CheckItemRow"; + +interface ChecksListProps { + checks: CheckItem[]; +} + +export function ChecksList({ checks }: ChecksListProps) { + const [expanded, setExpanded] = useState(false); + + // Filter out skipped/cancelled for display count, but show all when expanded + const relevantChecks = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ); + + if (relevantChecks.length === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {relevantChecks.map((check) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts new file mode 100644 index 00000000000..7fcb36d6b4e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts @@ -0,0 +1 @@ +export { ChecksList } from "./ChecksList"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx new file mode 100644 index 00000000000..aa1b0447300 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx @@ -0,0 +1,43 @@ +import { Check, LoaderCircle, X } from "lucide-react"; +import type { CheckItem } from "main/lib/db/schemas"; + +interface ChecksSummaryProps { + checks: CheckItem[]; + status: "success" | "failure" | "pending" | "none"; +} + +export function ChecksSummary({ checks, status }: ChecksSummaryProps) { + if (status === "none") return null; + + const passing = checks.filter((c) => c.status === "success").length; + const total = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ).length; + + const config = { + success: { + icon: Check, + className: "text-emerald-500", + }, + failure: { + icon: X, + className: "text-destructive-foreground", + }, + pending: { + icon: LoaderCircle, + className: "text-amber-500", + }, + }; + + const { icon: Icon, className } = config[status]; + const label = total > 0 ? `${passing}/${total} checks` : "Checks"; + + return ( + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts new file mode 100644 index 00000000000..25d7c97e309 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts @@ -0,0 +1 @@ +export { ChecksSummary } from "./ChecksSummary"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx new file mode 100644 index 00000000000..13d4cfcc3c4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx @@ -0,0 +1,27 @@ +interface PRStatusBadgeProps { + state: "open" | "draft" | "merged" | "closed"; +} + +export function PRStatusBadge({ state }: PRStatusBadgeProps) { + const styles = { + open: "bg-emerald-500/15 text-emerald-500", + draft: "bg-muted text-muted-foreground", + merged: "bg-violet-500/15 text-violet-500", + closed: "bg-destructive/15 text-destructive-foreground", + }; + + const labels = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", + }; + + return ( + + {labels[state]} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts new file mode 100644 index 00000000000..5f1e1bb361c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts @@ -0,0 +1 @@ +export { PRStatusBadge } from "./PRStatusBadge"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx new file mode 100644 index 00000000000..1895ebb0b65 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx @@ -0,0 +1,18 @@ +interface ReviewStatusProps { + status: "approved" | "changes_requested" | "pending"; +} + +export function ReviewStatus({ status }: ReviewStatusProps) { + const config = { + approved: { label: "Approved", className: "text-emerald-500" }, + changes_requested: { + label: "Changes requested", + className: "text-destructive-foreground", + }, + pending: { label: "Review pending", className: "text-muted-foreground" }, + }; + + const { label, className } = config[status]; + + return {label}; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts new file mode 100644 index 00000000000..ee4628b0bf1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts @@ -0,0 +1 @@ +export { ReviewStatus } from "./ReviewStatus"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts new file mode 100644 index 00000000000..460da7f74dc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts @@ -0,0 +1,20 @@ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days === 1 ? "" : "s"} ago`; + } + if (hours > 0) { + return `${hours} hour${hours === 1 ? "" : "s"} ago`; + } + if (minutes > 0) { + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + } + return "Just now"; +} From 3c5b3a02684e6a7b2328c5423588738cd81b4722 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 18:29:01 -0800 Subject: [PATCH 3/7] Fix project structure and use date-fns for relative time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update AGENTS.md with expanded project structure guidelines - Add shadcn/ui exception section for kebab-case files - Move CheckItemRow to proper nested folder structure - Replace custom formatRelativeTime with date-fns formatDistanceToNow - Delete unnecessary utils.ts wrapper file πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- AGENTS.md | 35 +++++++++++++++---- apps/desktop/package.json | 1 + .../WorkspaceHoverCard/WorkspaceHoverCard.tsx | 4 +-- .../components/ChecksList/ChecksList.tsx | 2 +- .../CheckItemRow}/CheckItemRow.tsx | 0 .../components/CheckItemRow/index.ts | 1 + .../WorkspaceTabs/WorkspaceHoverCard/utils.ts | 20 ----------- bun.lock | 3 ++ 8 files changed, 36 insertions(+), 30 deletions(-) rename apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/{ => components/CheckItemRow}/CheckItemRow.tsx (100%) create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts diff --git a/AGENTS.md b/AGENTS.md index d37d2efe5d6..a610d44b9e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,13 +77,30 @@ app/ β”œβ”€β”€ page.tsx β”œβ”€β”€ dashboard/ β”‚ β”œβ”€β”€ page.tsx -β”‚ └── components/ -β”‚ └── MetricsChart/ -β”‚ β”œβ”€β”€ MetricsChart.tsx -β”‚ β”œβ”€β”€ MetricsChart.test.tsx # Tests co-located -β”‚ β”œβ”€β”€ index.ts -β”‚ β”œβ”€β”€ useMetricsData.ts # Hook used only here -β”‚ └── constants.ts +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ └── MetricsChart/ +β”‚ β”‚ β”œβ”€β”€ MetricsChart.tsx +β”‚ β”‚ β”œβ”€β”€ MetricsChart.test.tsx # Tests co-located +β”‚ β”‚ β”œβ”€β”€ index.ts +β”‚ β”‚ └── constants.ts +β”‚ β”œβ”€β”€ hooks/ # Hooks used only in dashboard +β”‚ β”‚ └── useMetrics/ +β”‚ β”‚ β”œβ”€β”€ useMetrics.ts +β”‚ β”‚ β”œβ”€β”€ useMetrics.test.ts +β”‚ β”‚ └── index.ts +β”‚ β”œβ”€β”€ utils/ # Utils used only in dashboard +β”‚ β”‚ └── formatData/ +β”‚ β”‚ β”œβ”€β”€ formatData.ts +β”‚ β”‚ β”œβ”€β”€ formatData.test.ts +β”‚ β”‚ └── index.ts +β”‚ β”œβ”€β”€ stores/ # Stores used only in dashboard +β”‚ β”‚ └── dashboardStore/ +β”‚ β”‚ β”œβ”€β”€ dashboardStore.ts +β”‚ β”‚ └── index.ts +β”‚ └── providers/ # Providers for dashboard context +β”‚ └── DashboardProvider/ +β”‚ β”œβ”€β”€ DashboardProvider.tsx +β”‚ └── index.ts └── components/ β”œβ”€β”€ Sidebar/ β”‚ β”œβ”€β”€ Sidebar.tsx @@ -121,6 +138,10 @@ components/ # Used in 2+ pages (last resort) 3. **One component per file**: No multi-component files 4. **Co-locate dependencies**: Utils, hooks, constants, config, tests, stories live next to the file using them +### Exception: shadcn/ui Components + +The `src/components/ui/`, `src/components/ai-elements`, and `src/components/react-flow/` directories contain shadcn/ui components. These use **kebab-case single files** (e.g., `button.tsx`, `base-node.tsx`) instead of the folder structure above. This is intentionalβ€”shadcn CLI expects this format for updates via `bunx shadcn@latest add`. + ## Database Rules - Schema in `packages/db/src/` diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3237a6683be..eb5d944cf7a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -51,6 +51,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx index e7de46f9f47..11772b29e53 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -1,4 +1,5 @@ import { Button } from "@superset/ui/button"; +import { formatDistanceToNow } from "date-fns"; import { LoaderCircle, TriangleAlert } from "lucide-react"; import { FaGithub } from "react-icons/fa"; import { trpc } from "renderer/lib/trpc"; @@ -6,7 +7,6 @@ import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; import { PRStatusBadge } from "./components/PRStatusBadge"; import { ReviewStatus } from "./components/ReviewStatus"; -import { formatRelativeTime } from "./utils"; interface WorkspaceHoverCardContentProps { workspaceId: string; @@ -39,7 +39,7 @@ export function WorkspaceHoverCardContent({ {worktreeInfo?.createdAt && ( - {formatRelativeTime(worktreeInfo.createdAt)} + {formatDistanceToNow(worktreeInfo.createdAt, { addSuffix: true })} )} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx index 16f10e757a1..fcf726cb48d 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx @@ -1,7 +1,7 @@ import { ChevronDown, ChevronRight } from "lucide-react"; import type { CheckItem } from "main/lib/db/schemas"; import { useState } from "react"; -import { CheckItemRow } from "./CheckItemRow"; +import { CheckItemRow } from "./components/CheckItemRow"; interface ChecksListProps { checks: CheckItem[]; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/CheckItemRow.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts new file mode 100644 index 00000000000..dbc5ff57432 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts @@ -0,0 +1 @@ +export { CheckItemRow } from "./CheckItemRow"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts deleted file mode 100644 index 460da7f74dc..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - return `${days} day${days === 1 ? "" : "s"} ago`; - } - if (hours > 0) { - return `${hours} hour${hours === 1 ? "" : "s"} ago`; - } - if (minutes > 0) { - return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; - } - return "Just now"; -} diff --git a/bun.lock b/bun.lock index 1840e210699..8367b2a3267 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", @@ -1414,6 +1415,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], From bf6ab6ad210288f8b473958d74d42ebae49fa0d3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 6 Dec 2025 10:24:21 -0800 Subject: [PATCH 4/7] Add Zod validation and 30s caching for GitHub status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate gh CLI output with Zod schemas instead of type assertions - Derive TypeScript types from Zod schemas using z.infer - Add 30-second TTL cache to reduce GitHub API calls πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../trpc/routers/workspaces/utils/github.ts | 105 +++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts index 056ee6fc79e..c0833c4c81f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts @@ -1,53 +1,75 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; +import { z } from "zod"; const execAsync = promisify(exec); -interface GHCheckContext { - __typename: string; - name?: string; - context?: string; // StatusContext uses 'context' instead of 'name' - state?: "SUCCESS" | "FAILURE" | "PENDING" | "ERROR"; - conclusion?: - | "SUCCESS" - | "FAILURE" - | "CANCELLED" - | "SKIPPED" - | "TIMED_OUT" - | "ACTION_REQUIRED" - | "NEUTRAL" - | null; - detailsUrl?: string; - targetUrl?: string; // StatusContext uses 'targetUrl' instead of 'detailsUrl' -} - -interface GHPRResponse { - number: number; - title: string; - url: string; - state: "OPEN" | "CLOSED" | "MERGED"; - isDraft: boolean; - mergedAt: string | null; - additions: number; - deletions: number; - reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; - statusCheckRollup: { - contexts: GHCheckContext[]; - } | null; -} - -interface GHRepoResponse { - url: string; -} +// Zod schemas for gh CLI output validation +const GHCheckContextSchema = z.object({ + __typename: z.string(), + name: z.string().optional(), + context: z.string().optional(), // StatusContext uses 'context' instead of 'name' + state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), + conclusion: z + .enum([ + "SUCCESS", + "FAILURE", + "CANCELLED", + "SKIPPED", + "TIMED_OUT", + "ACTION_REQUIRED", + "NEUTRAL", + ]) + .nullable() + .optional(), + detailsUrl: z.string().optional(), + targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl' +}); + +const GHPRResponseSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + mergedAt: z.string().nullable(), + additions: z.number(), + deletions: z.number(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) + .nullable(), + statusCheckRollup: z + .object({ + contexts: z.array(GHCheckContextSchema), + }) + .nullable(), +}); + +const GHRepoResponseSchema = z.object({ + url: z.string(), +}); + +type GHPRResponse = z.infer; + +// Cache for GitHub status (30 second TTL) +const cache = new Map(); +const CACHE_TTL_MS = 30_000; /** * Fetches GitHub PR status for a worktree using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. + * Results are cached for 30 seconds. */ export async function fetchGitHubPRStatus( worktreePath: string, ): Promise { + // Check cache first + const cached = cache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + try { // First, get the repo URL const repoUrl = await getRepoUrl(worktreePath); @@ -58,11 +80,16 @@ export async function fetchGitHubPRStatus( // Try to get PR info for current branch const prInfo = await getPRForCurrentBranch(worktreePath); - return { + const result: GitHubStatus = { pr: prInfo, repoUrl, lastRefreshed: Date.now(), }; + + // Cache the result + cache.set(worktreePath, { data: result, timestamp: Date.now() }); + + return result; } catch { // Any error (gh not installed, not auth'd, etc.) - return null return null; @@ -74,7 +101,7 @@ async function getRepoUrl(worktreePath: string): Promise { const { stdout } = await execAsync("gh repo view --json url", { cwd: worktreePath, }); - const data = JSON.parse(stdout) as GHRepoResponse; + const data = GHRepoResponseSchema.parse(JSON.parse(stdout)); return data.url; } catch { return null; @@ -96,7 +123,7 @@ async function getPRForCurrentBranch( `gh pr view ${branch} --json number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup`, { cwd: worktreePath }, ); - const data = JSON.parse(stdout) as GHPRResponse; + const data = GHPRResponseSchema.parse(JSON.parse(stdout)); const checks = parseChecks(data.statusCheckRollup); From 932eb2528aeef1e703b3174fd81271e0982ebcc6 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 6 Dec 2025 10:32:28 -0800 Subject: [PATCH 5/7] Fix Zod schema to match actual gh CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reviewDecision can be empty string (not just null) - statusCheckRollup is a flat array, not { contexts: [...] } - Add missing fields: status, startedAt, completedAt, workflowName - conclusion can be empty string when check is in progress - Treat empty string as pending in checksStatus computation πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../trpc/routers/workspaces/utils/github.ts | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts index c0833c4c81f..cdbf6beca7f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts @@ -11,6 +11,7 @@ const GHCheckContextSchema = z.object({ name: z.string().optional(), context: z.string().optional(), // StatusContext uses 'context' instead of 'name' state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), + status: z.string().optional(), // CheckRun status: COMPLETED, IN_PROGRESS, etc. conclusion: z .enum([ "SUCCESS", @@ -20,11 +21,14 @@ const GHCheckContextSchema = z.object({ "TIMED_OUT", "ACTION_REQUIRED", "NEUTRAL", + "", // Can be empty string when in progress ]) - .nullable() .optional(), detailsUrl: z.string().optional(), targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl' + startedAt: z.string().optional(), + completedAt: z.string().optional(), + workflowName: z.string().optional(), }); const GHPRResponseSchema = z.object({ @@ -37,13 +41,10 @@ const GHPRResponseSchema = z.object({ additions: z.number(), deletions: z.number(), reviewDecision: z - .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) - .nullable(), - statusCheckRollup: z - .object({ - contexts: z.array(GHCheckContextSchema), - }) + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) .nullable(), + // statusCheckRollup is an array directly, not { contexts: [...] } + statusCheckRollup: z.array(GHCheckContextSchema).nullable(), }); const GHRepoResponseSchema = z.object({ @@ -101,8 +102,14 @@ async function getRepoUrl(worktreePath: string): Promise { const { stdout } = await execAsync("gh repo view --json url", { cwd: worktreePath, }); - const data = GHRepoResponseSchema.parse(JSON.parse(stdout)); - return data.url; + const raw = JSON.parse(stdout); + const result = GHRepoResponseSchema.safeParse(raw); + if (!result.success) { + console.error("[GitHub] Repo schema validation failed:", result.error); + console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2)); + return null; + } + return result.data.url; } catch { return null; } @@ -123,7 +130,14 @@ async function getPRForCurrentBranch( `gh pr view ${branch} --json number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup`, { cwd: worktreePath }, ); - const data = GHPRResponseSchema.parse(JSON.parse(stdout)); + const raw = JSON.parse(stdout); + const result = GHPRResponseSchema.safeParse(raw); + if (!result.success) { + console.error("[GitHub] PR schema validation failed:", result.error); + console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2)); + throw new Error("PR schema validation failed"); + } + const data = result.data; const checks = parseChecks(data.statusCheckRollup); @@ -171,11 +185,11 @@ function mapReviewDecision( } function parseChecks(rollup: GHPRResponse["statusCheckRollup"]): CheckItem[] { - if (!rollup || !rollup.contexts || rollup.contexts.length === 0) { + if (!rollup || rollup.length === 0) { return []; } - return rollup.contexts.map((ctx) => { + return rollup.map((ctx) => { // CheckRun uses 'name', StatusContext uses 'context' const name = ctx.name || ctx.context || "Unknown check"; // CheckRun uses 'detailsUrl', StatusContext uses 'targetUrl' @@ -207,14 +221,14 @@ function parseChecks(rollup: GHPRResponse["statusCheckRollup"]): CheckItem[] { function computeChecksStatus( rollup: GHPRResponse["statusCheckRollup"], ): NonNullable["checksStatus"] { - if (!rollup || !rollup.contexts || rollup.contexts.length === 0) { + if (!rollup || rollup.length === 0) { return "none"; } let hasFailure = false; let hasPending = false; - for (const ctx of rollup.contexts) { + for (const ctx of rollup) { // StatusContext uses 'state', CheckRun uses 'conclusion' const status = ctx.state || ctx.conclusion; @@ -222,6 +236,7 @@ function computeChecksStatus( hasFailure = true; } else if ( status === "PENDING" || + status === "" || status === null || status === undefined ) { From 930cf8b653d940be1d701a50933fcc3170022342 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 6 Dec 2025 10:36:22 -0800 Subject: [PATCH 6/7] Fix command injection and restructure github utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fix: - Replace exec() with execFile() to prevent shell injection via branch names - Pass gh CLI arguments as array instead of interpolated string Structure: - Move github.ts to github/github.ts with index.ts barrel export - Extract Zod schemas to co-located types.ts πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../workspaces/utils/{ => github}/github.ts | 87 ++++++------------- .../routers/workspaces/utils/github/index.ts | 1 + .../routers/workspaces/utils/github/types.ts | 48 ++++++++++ 3 files changed, 77 insertions(+), 59 deletions(-) rename apps/desktop/src/lib/trpc/routers/workspaces/utils/{ => github}/github.ts (73%) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts similarity index 73% rename from apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts rename to apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index cdbf6beca7f..15ef63a62c4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -1,57 +1,13 @@ -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; -import { z } from "zod"; - -const execAsync = promisify(exec); - -// Zod schemas for gh CLI output validation -const GHCheckContextSchema = z.object({ - __typename: z.string(), - name: z.string().optional(), - context: z.string().optional(), // StatusContext uses 'context' instead of 'name' - state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), - status: z.string().optional(), // CheckRun status: COMPLETED, IN_PROGRESS, etc. - conclusion: z - .enum([ - "SUCCESS", - "FAILURE", - "CANCELLED", - "SKIPPED", - "TIMED_OUT", - "ACTION_REQUIRED", - "NEUTRAL", - "", // Can be empty string when in progress - ]) - .optional(), - detailsUrl: z.string().optional(), - targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl' - startedAt: z.string().optional(), - completedAt: z.string().optional(), - workflowName: z.string().optional(), -}); - -const GHPRResponseSchema = z.object({ - number: z.number(), - title: z.string(), - url: z.string(), - state: z.enum(["OPEN", "CLOSED", "MERGED"]), - isDraft: z.boolean(), - mergedAt: z.string().nullable(), - additions: z.number(), - deletions: z.number(), - reviewDecision: z - .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) - .nullable(), - // statusCheckRollup is an array directly, not { contexts: [...] } - statusCheckRollup: z.array(GHCheckContextSchema).nullable(), -}); - -const GHRepoResponseSchema = z.object({ - url: z.string(), -}); - -type GHPRResponse = z.infer; +import { + type GHPRResponse, + GHPRResponseSchema, + GHRepoResponseSchema, +} from "./types"; + +const execFileAsync = promisify(execFile); // Cache for GitHub status (30 second TTL) const cache = new Map(); @@ -99,9 +55,13 @@ export async function fetchGitHubPRStatus( async function getRepoUrl(worktreePath: string): Promise { try { - const { stdout } = await execAsync("gh repo view --json url", { - cwd: worktreePath, - }); + const { stdout } = await execFileAsync( + "gh", + ["repo", "view", "--json", "url"], + { + cwd: worktreePath, + }, + ); const raw = JSON.parse(stdout); const result = GHRepoResponseSchema.safeParse(raw); if (!result.success) { @@ -120,14 +80,23 @@ async function getPRForCurrentBranch( ): Promise { try { // Get the current branch name explicitly (worktrees don't work well with gh's auto-detection) - const { stdout: branchName } = await execAsync( - "git rev-parse --abbrev-ref HEAD", + const { stdout: branchName } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath }, ); const branch = branchName.trim(); - const { stdout } = await execAsync( - `gh pr view ${branch} --json number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup`, + // Use execFile with args array to prevent command injection + const { stdout } = await execFileAsync( + "gh", + [ + "pr", + "view", + branch, + "--json", + "number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup", + ], { cwd: worktreePath }, ); const raw = JSON.parse(stdout); 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 new file mode 100644 index 00000000000..75fcdd5296d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -0,0 +1 @@ +export { fetchGitHubPRStatus } from "./github"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts new file mode 100644 index 00000000000..1f11b544e8d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +// Zod schemas for gh CLI output validation +export const GHCheckContextSchema = z.object({ + name: z.string().optional(), + context: z.string().optional(), // StatusContext uses 'context' instead of 'name' + state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), + status: z.string().optional(), // CheckRun status: COMPLETED, IN_PROGRESS, etc. + conclusion: z + .enum([ + "SUCCESS", + "FAILURE", + "CANCELLED", + "SKIPPED", + "TIMED_OUT", + "ACTION_REQUIRED", + "NEUTRAL", + "", // Can be empty string when in progress + ]) + .optional(), + detailsUrl: z.string().optional(), + targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl' + startedAt: z.string().optional(), + completedAt: z.string().optional(), + workflowName: z.string().optional(), +}); + +export const GHPRResponseSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + mergedAt: z.string().nullable(), + additions: z.number(), + deletions: z.number(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) + .nullable(), + // statusCheckRollup is an array directly, not { contexts: [...] } + statusCheckRollup: z.array(GHCheckContextSchema).nullable(), +}); + +export const GHRepoResponseSchema = z.object({ + url: z.string(), +}); + +export type GHPRResponse = z.infer; From 763aff7694ae829f97252b8094c83daf1a7a4227 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 6 Dec 2025 10:38:31 -0800 Subject: [PATCH 7/7] Reduce GitHub status cache TTL to 10 seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/lib/trpc/routers/workspaces/utils/github/github.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 15ef63a62c4..42841adb026 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 @@ -9,9 +9,9 @@ import { const execFileAsync = promisify(execFile); -// Cache for GitHub status (30 second TTL) +// Cache for GitHub status (10 second TTL) const cache = new Map(); -const CACHE_TTL_MS = 30_000; +const CACHE_TTL_MS = 10_000; /** * Fetches GitHub PR status for a worktree using the `gh` CLI.