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 07843a68cf6..371c7c97198 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 @@ -19,6 +19,7 @@ import { refreshDefaultBranch, } from "../utils/git"; import { + batchFetchGitHubPRStatuses, fetchGitHubPRComments, fetchGitHubPRStatus, type PullRequestCommentsTarget, @@ -206,6 +207,68 @@ export const createGitStatusProcedures = () => { return freshStatus; }), + batchGetGitHubStatuses: publicProcedure.query(async () => { + const allWorkspaceRows = localDb + .select() + .from(workspaces) + .where(isNull(workspaces.deletingAt)) + .all(); + + const allWorktreeRows = localDb.select().from(worktrees).all(); + const worktreeMap = new Map(allWorktreeRows.map((wt) => [wt.id, wt])); + const workspaceMap = new Map( + allWorkspaceRows.map((ws) => [ws.id, ws]), + ); + + const entries = allWorkspaceRows + .filter((ws) => ws.type === "worktree" && ws.worktreeId) + .map((ws) => { + // biome-ignore lint/style/noNonNullAssertion: filtered above + const wt = worktreeMap.get(ws.worktreeId!); + if (!wt) return null; + return { + workspaceId: ws.id, + worktreePath: wt.path, + branch: wt.branch, + }; + }) + .filter((e): e is NonNullable => e !== null); + + const batchResults = await batchFetchGitHubPRStatuses(entries); + + const result: Record = {}; + + for (const [workspaceId, freshStatus] of batchResults) { + result[workspaceId] = freshStatus; + + const ws = workspaceMap.get(workspaceId); + if (!ws?.worktreeId) continue; + const wt = worktreeMap.get(ws.worktreeId); + if (!wt) continue; + + if ( + hasMeaningfulGitHubStatusChange({ + current: wt.githubStatus, + next: freshStatus, + }) + ) { + localDb + .update(worktrees) + .set({ githubStatus: freshStatus }) + .where(eq(worktrees.id, wt.id)) + .run(); + } + } + + for (const ws of allWorkspaceRows) { + if (!(ws.id in result)) { + result[ws.id] = null; + } + } + + return result; + }), + getGitHubPRComments: publicProcedure .input(gitHubPRCommentsInputSchema) .query(async ({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.test.ts new file mode 100644 index 00000000000..fca74646f24 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, test } from "bun:test"; +import { + type GHGraphQLPRNode, + GHGraphQLPRNodeSchema, + GHPRResponseSchema, + normalizeGraphQLPR, +} from "./types"; + +describe("GHGraphQLPRNodeSchema", () => { + test("parses a full GraphQL PR node", () => { + const raw = { + number: 42, + title: "Add batch PR fetching", + url: "https://github.com/org/repo/pull/42", + state: "OPEN", + isDraft: false, + mergedAt: null, + additions: 150, + deletions: 20, + headRefOid: "abc123def456", + headRefName: "feat/batch-pr", + headRepository: { name: "repo" }, + headRepositoryOwner: { login: "org" }, + isCrossRepository: false, + reviewDecision: "APPROVED", + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: "CheckRun", + name: "CI", + conclusion: "SUCCESS", + detailsUrl: "https://ci.example.com/1", + status: "COMPLETED", + }, + { + __typename: "StatusContext", + context: "deploy/preview", + state: "SUCCESS", + targetUrl: "https://preview.example.com", + }, + ], + }, + }, + }, + }, + ], + }, + reviewRequests: { + nodes: [ + { + requestedReviewer: { + __typename: "User", + login: "reviewer1", + }, + }, + { + requestedReviewer: { + __typename: "Team", + slug: "core-team", + name: "Core Team", + }, + }, + ], + }, + }; + + const result = GHGraphQLPRNodeSchema.safeParse(raw); + expect(result.success).toBe(true); + }); + + test("parses a minimal GraphQL PR node", () => { + const raw = { + number: 1, + title: "Fix typo", + url: "https://github.com/org/repo/pull/1", + state: "MERGED", + isDraft: false, + mergedAt: "2026-03-01T00:00:00Z", + additions: 1, + deletions: 1, + headRefOid: "deadbeef", + headRefName: "fix/typo", + headRepository: null, + headRepositoryOwner: null, + reviewDecision: null, + commits: null, + reviewRequests: null, + }; + + const result = GHGraphQLPRNodeSchema.safeParse(raw); + expect(result.success).toBe(true); + }); + + test("rejects a CheckRun node with missing __typename", () => { + const raw = { + number: 1, + title: "Bad", + url: "https://github.com/org/repo/pull/1", + state: "OPEN", + isDraft: false, + mergedAt: null, + additions: 0, + deletions: 0, + headRefOid: "aaa", + headRefName: "fix/bad", + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { name: "CI", conclusion: "SUCCESS", status: "COMPLETED" }, + ], + }, + }, + }, + }, + ], + }, + }; + + const result = GHGraphQLPRNodeSchema.safeParse(raw); + expect(result.success).toBe(false); + }); +}); + +describe("normalizeGraphQLPR", () => { + const basePRNode: GHGraphQLPRNode = { + number: 42, + title: "Test PR", + url: "https://github.com/org/repo/pull/42", + state: "OPEN", + isDraft: false, + mergedAt: null, + additions: 100, + deletions: 50, + headRefOid: "abc123", + headRefName: "feat/test", + headRepository: { name: "repo" }, + headRepositoryOwner: { login: "org" }, + isCrossRepository: false, + reviewDecision: "APPROVED", + commits: null, + reviewRequests: null, + }; + + test("converts basic fields", () => { + const result = normalizeGraphQLPR(basePRNode); + + expect(result.number).toBe(42); + expect(result.title).toBe("Test PR"); + expect(result.url).toBe("https://github.com/org/repo/pull/42"); + expect(result.state).toBe("OPEN"); + expect(result.isDraft).toBe(false); + expect(result.additions).toBe(100); + expect(result.deletions).toBe(50); + expect(result.headRefOid).toBe("abc123"); + expect(result.headRefName).toBe("feat/test"); + expect(result.headRepository).toEqual({ name: "repo" }); + expect(result.headRepositoryOwner).toEqual({ login: "org" }); + expect(result.reviewDecision).toBe("APPROVED"); + }); + + test("normalizes statusCheckRollup from nested GraphQL to flat array", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: "CheckRun", + name: "CI Build", + conclusion: "SUCCESS", + detailsUrl: "https://ci.example.com", + status: "COMPLETED", + }, + { + __typename: "StatusContext", + context: "deploy/staging", + state: "PENDING", + targetUrl: "https://staging.example.com", + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const result = normalizeGraphQLPR(node); + expect(result.statusCheckRollup).toHaveLength(2); + + const checkRun = result.statusCheckRollup?.[0]; + expect(checkRun?.name).toBe("CI Build"); + expect(checkRun?.conclusion).toBe("SUCCESS"); + expect(checkRun?.detailsUrl).toBe("https://ci.example.com"); + + const statusCtx = result.statusCheckRollup?.[1]; + expect(statusCtx?.context).toBe("deploy/staging"); + expect(statusCtx?.state).toBe("PENDING"); + expect(statusCtx?.targetUrl).toBe("https://staging.example.com"); + }); + + test("returns null statusCheckRollup when commits is null", () => { + const result = normalizeGraphQLPR(basePRNode); + expect(result.statusCheckRollup).toBeNull(); + }); + + test("normalizes reviewRequests from nested GraphQL", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + reviewRequests: { + nodes: [ + { + requestedReviewer: { + __typename: "User", + login: "alice", + }, + }, + { + requestedReviewer: { + __typename: "Team", + slug: "backend", + name: "Backend Team", + }, + }, + null, + ], + }, + }; + + const result = normalizeGraphQLPR(node); + expect(result.reviewRequests).toHaveLength(2); + expect(result.reviewRequests?.[0]).toEqual({ + login: "alice", + type: "User", + }); + expect(result.reviewRequests?.[1]).toEqual({ + slug: "backend", + name: "Backend Team", + type: "Team", + }); + }); + + test("produces a shape compatible with GHPRResponseSchema", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + reviewDecision: "CHANGES_REQUESTED", + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: "CheckRun", + name: "lint", + conclusion: "FAILURE", + detailsUrl: "https://ci.example.com/lint", + status: "COMPLETED", + }, + ], + }, + }, + }, + }, + ], + }, + reviewRequests: { + nodes: [ + { + requestedReviewer: { + __typename: "User", + login: "bob", + }, + }, + ], + }, + }; + + const normalized = normalizeGraphQLPR(node); + const parseResult = GHPRResponseSchema.safeParse(normalized); + expect(parseResult.success).toBe(true); + }); + + test("handles null reviewDecision", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + reviewDecision: null, + }; + const result = normalizeGraphQLPR(node); + expect(result.reviewDecision).toBeNull(); + }); + + test("handles draft PR", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + isDraft: true, + state: "OPEN", + }; + const result = normalizeGraphQLPR(node); + expect(result.isDraft).toBe(true); + expect(result.state).toBe("OPEN"); + }); + + test("handles merged PR with mergedAt timestamp", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + state: "MERGED", + mergedAt: "2026-03-27T12:00:00Z", + }; + const result = normalizeGraphQLPR(node); + expect(result.state).toBe("MERGED"); + expect(result.mergedAt).toBe("2026-03-27T12:00:00Z"); + }); + + test("filters out null entries in statusCheckRollup contexts", () => { + const node: GHGraphQLPRNode = { + ...basePRNode, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + null, + { + __typename: "CheckRun", + name: "test", + conclusion: "SUCCESS", + status: "COMPLETED", + }, + null, + ], + }, + }, + }, + }, + ], + }, + }; + + const result = normalizeGraphQLPR(node); + expect(result.statusCheckRollup).toHaveLength(1); + expect(result.statusCheckRollup?.[0]?.name).toBe("test"); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.ts new file mode 100644 index 00000000000..bf8bf8dc8e0 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.ts @@ -0,0 +1,319 @@ +import type { GitHubStatus } from "@superset/local-db"; +import { execWithShellEnv } from "../shell-env"; +import { setCachedGitHubStatus } from "./cache"; +import { + formatPRData, + getPRHeadBranchCandidates, + shouldAcceptPRMatch, + sortPRCandidates, +} from "./pr-resolution"; +import { extractNwoFromUrl, getRepoContext } from "./repo-context"; +import { + GHGraphQLPRNodeSchema, + type GHPRResponse, + type RepoContext, + normalizeGraphQLPR, +} from "./types"; + +export interface BatchWorkspaceEntry { + workspaceId: string; + worktreePath: string; + branch: string; + headSha?: string; +} + +interface RepoGroup { + owner: string; + name: string; + repoContext: RepoContext; + entries: BatchWorkspaceEntry[]; +} + +interface AliasMapping { + repoAlias: string; + prAlias: string; + entry: BatchWorkspaceEntry; + group: RepoGroup; +} + +const BATCH_QUERY_TIMEOUT_MS = 30_000; + +const PR_FIELDS_FRAGMENT = ` +number +title +url +state +isDraft +mergedAt +additions +deletions +headRefOid +headRefName +headRepository { name } +headRepositoryOwner { login } +isCrossRepository +reviewDecision +commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + conclusion + detailsUrl + status + } + ... on StatusContext { + context + state + targetUrl + } + } + } + } + } + } +} +reviewRequests(first: 20) { + nodes { + requestedReviewer { + __typename + ... on User { login } + ... on Team { slug name } + } + } +}`; + +/** + * Groups workspace entries by GitHub repository using cached repo context, + * then builds and executes a single GraphQL query for all PR statuses. + * + * Returns a Map keyed by workspaceId. Entries without a matching PR still + * receive a `GitHubStatus` with `pr: null` so the caller can distinguish + * "no PR" from "not attempted". + */ +export async function batchFetchGitHubPRStatuses( + entries: BatchWorkspaceEntry[], +): Promise> { + if (entries.length === 0) { + return new Map(); + } + + const repoGroups = await groupByRepo(entries); + if (repoGroups.length === 0) { + return new Map(); + } + + const cwd = entries[0].worktreePath; + + try { + return await executeBatchQuery(cwd, repoGroups); + } catch (error) { + console.warn("[GitHub] Batch PR query failed:", error); + return new Map(); + } +} + +async function groupByRepo( + entries: BatchWorkspaceEntry[], +): Promise { + const repoMap = new Map(); + + const contextResults = await Promise.all( + entries.map(async (entry) => { + const ctx = await getRepoContext(entry.worktreePath).catch(() => null); + return { entry, ctx }; + }), + ); + + for (const { entry, ctx } of contextResults) { + if (!ctx) continue; + + const targetUrl = ctx.isFork ? ctx.upstreamUrl : ctx.repoUrl; + const nwo = extractNwoFromUrl(targetUrl); + if (!nwo) continue; + + const key = nwo.toLowerCase(); + let group = repoMap.get(key); + if (!group) { + const [owner, name] = nwo.split("/"); + if (!owner || !name) continue; + group = { owner, name, repoContext: ctx, entries: [] }; + repoMap.set(key, group); + } + group.entries.push(entry); + } + + return [...repoMap.values()]; +} + +/** + * Builds a GraphQL query with aliased `repository` and `pullRequests` fields, + * executes it via `gh api graphql`, and maps results back to workspace IDs. + */ +async function executeBatchQuery( + cwd: string, + repoGroups: RepoGroup[], +): Promise> { + const results = new Map(); + const aliasMappings: AliasMapping[] = []; + + const queryParts: string[] = []; + for (let ri = 0; ri < repoGroups.length; ri++) { + const group = repoGroups[ri]; + const repoAlias = `repo_${ri}`; + const prParts: string[] = []; + + // branch candidate → prAlias for O(1) dedup lookups within this repo + const branchToAlias = new Map(); + let prIndex = 0; + + for (const entry of group.entries) { + for (const branchCandidate of getPRHeadBranchCandidates(entry.branch)) { + const existingPrAlias = branchToAlias.get(branchCandidate); + if (existingPrAlias) { + aliasMappings.push({ + repoAlias, + prAlias: existingPrAlias, + entry, + group, + }); + continue; + } + + const prAlias = `pr_${prIndex++}`; + branchToAlias.set(branchCandidate, prAlias); + + const escapedBranch = escapeGraphQLString(branchCandidate); + prParts.push( + `${prAlias}: pullRequests(first: 3, headRefName: "${escapedBranch}", states: [OPEN, CLOSED, MERGED], orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ${PR_FIELDS_FRAGMENT} } }`, + ); + aliasMappings.push({ repoAlias, prAlias, entry, group }); + } + } + + if (prParts.length > 0) { + const escapedOwner = escapeGraphQLString(group.owner); + const escapedName = escapeGraphQLString(group.name); + queryParts.push( + `${repoAlias}: repository(owner: "${escapedOwner}", name: "${escapedName}") { ${prParts.join(" ")} }`, + ); + } + } + + if (queryParts.length === 0) { + return results; + } + + const query = `{ ${queryParts.join(" ")} }`; + + let stdout: string; + try { + const result = await execWithShellEnv( + "gh", + ["api", "graphql", "-f", `query=${query}`], + { cwd, timeout: BATCH_QUERY_TIMEOUT_MS }, + ); + stdout = result.stdout; + } catch (error) { + console.warn("[GitHub] Batch GraphQL query execution failed:", error); + return results; + } + + let parsed: { data?: Record> }; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + console.warn("[GitHub] Failed to parse batch GraphQL response"); + return results; + } + + if (!parsed.data) { + return results; + } + + // Collect all PR candidates per workspace, then pick the best match. + const workspaceCandidates = new Map< + string, + { prs: GHPRResponse[]; entry: BatchWorkspaceEntry; group: RepoGroup } + >(); + + for (const mapping of aliasMappings) { + const repoData = parsed.data[mapping.repoAlias]; + if (!repoData) continue; + + const prConnection = repoData[mapping.prAlias]; + if (!prConnection?.nodes) continue; + + for (const rawNode of prConnection.nodes) { + const parseResult = GHGraphQLPRNodeSchema.safeParse(rawNode); + if (!parseResult.success) continue; + + const normalized = normalizeGraphQLPR(parseResult.data); + + if ( + !shouldAcceptPRMatch({ + localBranch: mapping.entry.branch, + pr: normalized, + headSha: mapping.entry.headSha, + }) + ) { + continue; + } + + let candidates = workspaceCandidates.get(mapping.entry.workspaceId); + if (!candidates) { + candidates = { prs: [], entry: mapping.entry, group: mapping.group }; + workspaceCandidates.set(mapping.entry.workspaceId, candidates); + } + candidates.prs.push(normalized); + } + } + + const now = Date.now(); + + for (const [workspaceId, { prs, entry, group }] of workspaceCandidates) { + const bestPR = sortPRCandidates(prs, entry.headSha)[0]; + if (!bestPR) continue; + + const status: GitHubStatus = { + pr: formatPRData(bestPR), + repoUrl: group.repoContext.repoUrl, + upstreamUrl: group.repoContext.upstreamUrl, + isFork: group.repoContext.isFork, + branchExistsOnRemote: true, + lastRefreshed: now, + }; + + results.set(workspaceId, status); + setCachedGitHubStatus(entry.worktreePath, status); + } + + // Workspaces with no PR match still get a status so callers can + // distinguish "no PR" from "not in batch". + for (const group of repoGroups) { + for (const entry of group.entries) { + if (results.has(entry.workspaceId)) continue; + + const status: GitHubStatus = { + pr: null, + repoUrl: group.repoContext.repoUrl, + upstreamUrl: group.repoContext.upstreamUrl, + isFork: group.repoContext.isFork, + branchExistsOnRemote: true, + lastRefreshed: now, + }; + results.set(entry.workspaceId, status); + setCachedGitHubStatus(entry.worktreePath, status); + } + } + + return results; +} + +function escapeGraphQLString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} 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 2253ee3f295..a986320027c 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 @@ -11,3 +11,7 @@ export { getRepoContext, normalizeGitHubUrl, } from "./repo-context"; +export { + batchFetchGitHubPRStatuses, + type BatchWorkspaceEntry, +} from "./batch-pr-status"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts index 90f09953237..b17c86f61b5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts @@ -124,7 +124,7 @@ export function shouldAcceptPRMatch({ return true; } -function sortPRCandidates( +export function sortPRCandidates( candidates: GHPRResponse[], headSha?: string, ): GHPRResponse[] { @@ -358,7 +358,7 @@ function parsePRListResponse(stdout: string): GHPRResponse[] { return parsed; } -function formatPRData(data: GHPRResponse): NonNullable { +export function formatPRData(data: GHPRResponse): NonNullable { return { number: data.number, title: data.title, 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 index 7f379fcffc9..87224f35d7f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -171,6 +171,193 @@ export interface RepoContext { export type GHPRResponse = z.infer; +// --- GraphQL batch PR query response schemas --- +// __typename is always present in GraphQL when requested, so it's required +// here and used as the discriminator for union parsing. + +const GHGraphQLCheckRunSchema = z.object({ + __typename: z.literal("CheckRun"), + name: z.string().optional(), + conclusion: z + .enum([ + "SUCCESS", + "FAILURE", + "CANCELLED", + "SKIPPED", + "TIMED_OUT", + "ACTION_REQUIRED", + "NEUTRAL", + "", + ]) + .nullable() + .optional(), + detailsUrl: z.string().optional(), + status: z.string().optional(), +}); + +const GHGraphQLStatusContextSchema = z.object({ + __typename: z.literal("StatusContext"), + context: z.string().optional(), + state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(), + targetUrl: z.string().nullable().optional(), +}); + +const GHGraphQLCheckContextNodeSchema = z.discriminatedUnion("__typename", [ + GHGraphQLCheckRunSchema, + GHGraphQLStatusContextSchema, +]); + +const GHGraphQLReviewerUserSchema = z.object({ + __typename: z.literal("User"), + login: z.string(), +}); + +const GHGraphQLReviewerTeamSchema = z.object({ + __typename: z.literal("Team"), + slug: z.string().optional(), + name: z.string().optional(), +}); + +export const GHGraphQLPRNodeSchema = 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(), + headRefOid: z.string(), + headRefName: z.string(), + headRepository: z + .object({ name: z.string().optional() }) + .nullable() + .optional(), + headRepositoryOwner: z + .object({ login: z.string().optional() }) + .nullable() + .optional(), + isCrossRepository: z.boolean().optional(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]) + .nullable() + .optional(), + commits: z + .object({ + nodes: z + .array( + z + .object({ + commit: z + .object({ + statusCheckRollup: z + .object({ + contexts: z.object({ + nodes: z.array( + GHGraphQLCheckContextNodeSchema.nullable(), + ), + }), + }) + .nullable() + .optional(), + }) + .nullable(), + }) + .nullable(), + ) + .nullable(), + }) + .nullable() + .optional(), + reviewRequests: z + .object({ + nodes: z + .array( + z + .object({ + requestedReviewer: z + .discriminatedUnion("__typename", [ + GHGraphQLReviewerUserSchema, + GHGraphQLReviewerTeamSchema, + ]) + .nullable() + .optional(), + }) + .nullable(), + ) + .nullable(), + }) + .nullable() + .optional(), +}); + +export type GHGraphQLPRNode = z.infer; + +/** + * Converts a GraphQL PR node into the same `GHPRResponse` shape that + * `gh pr view --json` produces, so downstream formatting functions + * (`formatPRData`, `parseChecks`, etc.) work unchanged. + */ +export function normalizeGraphQLPR(node: GHGraphQLPRNode): GHPRResponse { + const checkContexts = + node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes; + + const statusCheckRollup: GHPRResponse["statusCheckRollup"] = checkContexts + ? checkContexts + .filter((ctx): ctx is NonNullable => ctx !== null) + .map((ctx) => { + if (ctx.__typename === "CheckRun") { + return { + name: ctx.name, + conclusion: ctx.conclusion ?? undefined, + detailsUrl: ctx.detailsUrl, + status: ctx.status, + }; + } + return { + context: ctx.context, + state: ctx.state, + targetUrl: ctx.targetUrl ?? undefined, + }; + }) + : null; + + const reviewRequests: GHPRResponse["reviewRequests"] = + node.reviewRequests?.nodes + ?.filter((rr): rr is NonNullable => rr !== null) + .map((rr) => { + const reviewer = rr.requestedReviewer; + if (!reviewer) return {}; + if (reviewer.__typename === "User") { + return { login: reviewer.login, type: "User" as const }; + } + return { + slug: reviewer.slug, + name: reviewer.name, + type: "Team" as const, + }; + }) ?? null; + + return { + number: node.number, + title: node.title, + url: node.url, + state: node.state, + isDraft: node.isDraft, + mergedAt: node.mergedAt, + additions: node.additions, + deletions: node.deletions, + headRefOid: node.headRefOid, + headRefName: node.headRefName, + headRepository: node.headRepository, + headRepositoryOwner: node.headRepositoryOwner, + isCrossRepository: node.isCrossRepository, + reviewDecision: node.reviewDecision ?? null, + statusCheckRollup, + reviewRequests, + }; +} + export const GHDeploymentSchema = z.object({ id: z.number(), ref: z.string(), diff --git a/apps/desktop/src/renderer/hooks/useBatchGitHubStatus.ts b/apps/desktop/src/renderer/hooks/useBatchGitHubStatus.ts new file mode 100644 index 00000000000..dc078576538 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBatchGitHubStatus.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS } from "renderer/lib/githubQueryPolicy"; + +/** + * Sidebar-level hook that fetches PR status for ALL workspaces in a single + * GraphQL call, then seeds per-workspace React Query caches so + * `WorkspaceListItem` reads them instantly without hover gating. + * + * Only seeds a cache entry when it is currently empty — never overwrites + * richer data returned by per-workspace `getGitHubStatus` fetches (which + * include `previewUrl` and accurate `branchExistsOnRemote`). + */ +export function useBatchGitHubStatus() { + const utils = electronTrpc.useUtils(); + + const { data: batchData } = + electronTrpc.workspaces.batchGetGitHubStatuses.useQuery(undefined, { + refetchInterval: BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS, + refetchOnWindowFocus: true, + staleTime: BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS, + }); + + useEffect(() => { + if (!batchData) return; + + for (const [workspaceId, status] of Object.entries(batchData)) { + if (status === null) continue; + + const existing = utils.workspaces.getGitHubStatus.getData({ + workspaceId, + }); + + if (!existing) { + utils.workspaces.getGitHubStatus.setData({ workspaceId }, status); + } + } + }, [batchData, utils]); +} diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts index dca23c1f3e0..3255ef50024 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts @@ -78,7 +78,7 @@ describe("getGitHubStatusQueryPolicy", () => { }); }); - test("keeps workspace list items cheaper than full-page PR surfaces", () => { + test("keeps workspace list items aligned with batch polling interval", () => { expect( getGitHubStatusQueryPolicy("workspace-list-item", { hasWorkspaceId: true, @@ -88,7 +88,7 @@ describe("getGitHubStatusQueryPolicy", () => { enabled: true, refetchInterval: false, refetchOnWindowFocus: false, - staleTime: 30_000, + staleTime: 15_000, }); }); diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts index 6109e168286..102230380cc 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts @@ -1,6 +1,8 @@ const ACTIVE_GITHUB_STATUS_STALE_TIME_MS = 10_000; const ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; -const WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS = 30_000; +export const BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS = 15_000; +const WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS = + BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS; const PASSIVE_GITHUB_STATUS_STALE_TIME_MS = 5 * 60 * 1000; const GITHUB_PR_COMMENTS_STALE_TIME_MS = 30_000; const GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS = 30_000; diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts index de2aafc9ae8..aff02b41d0b 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts @@ -3,6 +3,7 @@ export type { GitHubStatusQuerySurface, } from "./githubQueryPolicy"; export { + BATCH_GITHUB_STATUS_REFETCH_INTERVAL_MS, getGitHubPRCommentsQueryPolicy, getGitHubStatusQueryPolicy, } from "./githubQueryPolicy"; 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 2b56af6ee39..2e33005db9b 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 @@ -146,7 +146,7 @@ export function WorkspaceListItem({ "workspace-list-item", { hasWorkspaceId: !!id, - isActive: hasHovered && type === "worktree", + isActive: type === "worktree", }, ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 4198ed8636c..61ef2e39971 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; +import { useBatchGitHubStatus } from "renderer/hooks/useBatchGitHubStatus"; import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; import { useWorkspaceSelectionStore } from "renderer/stores/workspace-selection"; import { MultiDragPreview } from "./MultiDragPreview"; @@ -21,6 +22,7 @@ export function WorkspaceSidebar({ activeProjectName, }: WorkspaceSidebarProps) { const { groups } = useWorkspaceShortcuts(); + useBatchGitHubStatus(); const clearSelection = useWorkspaceSelectionStore((s) => s.clearSelection); const projectShortcutIndices = useMemo( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx index f9fe3ea6608..580378c8180 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -41,11 +41,8 @@ export function WorkspaceRow({ useWorkspaceDeleteHandler(); const githubStatusQueryPolicy = getGitHubStatusQueryPolicy("workspace-row", { hasWorkspaceId: !!workspace.workspaceId, - isActive: - hasHovered && workspace.type === "worktree" && !!workspace.workspaceId, + isActive: workspace.type === "worktree" && !!workspace.workspaceId, }); - - // Lazy-load GitHub status on hover to avoid N+1 queries const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: workspace.workspaceId ?? "" },