From 1c29b5b1bb8c540d4557511f9e9b184f3662f756 Mon Sep 17 00:00:00 2001 From: Aditya Vaish Date: Sat, 28 Mar 2026 01:27:22 +0530 Subject: [PATCH 1/2] feat(desktop): add batch fetching for GitHub PR statuses - Introduced `batchGetGitHubStatuses` procedure to efficiently retrieve statuses for multiple workspaces. - Enhanced GitHub utilities with `batchFetchGitHubPRStatuses` for improved performance. - Updated workspace-related components to utilize the new batch fetching mechanism. - Adjusted query policies to align with the new batch polling interval for workspace list items. - Refactored related types and functions to support the new batch processing capabilities. --- .../workspaces/procedures/git-status.ts | 63 +++ .../utils/github/batch-pr-status.test.ts | 361 ++++++++++++++++++ .../utils/github/batch-pr-status.ts | 319 ++++++++++++++++ .../routers/workspaces/utils/github/index.ts | 4 + .../workspaces/utils/github/pr-resolution.ts | 4 +- .../routers/workspaces/utils/github/types.ts | 187 +++++++++ .../renderer/hooks/useBatchGitHubStatus.ts | 39 ++ .../githubQueryPolicy.test.ts | 4 +- .../githubQueryPolicy/githubQueryPolicy.ts | 4 +- .../renderer/lib/githubQueryPolicy/index.ts | 1 + .../WorkspaceListItem/WorkspaceListItem.tsx | 2 +- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 2 + .../WorkspaceRow/WorkspaceRow.tsx | 5 +- 13 files changed, 985 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.test.ts create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/github/batch-pr-status.ts create mode 100644 apps/desktop/src/renderer/hooks/useBatchGitHubStatus.ts 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 ?? "" }, From a3192322858e0ce108c9b825828fa8c202c07dc5 Mon Sep 17 00:00:00 2001 From: Aditya Vaish Date: Thu, 7 May 2026 01:41:05 +0530 Subject: [PATCH 2/2] fix(api): wrap Slack JSON.parse calls in try-catch for graceful error handling Unhandled JSON.parse exceptions in the Slack events and interactions endpoints caused 500 responses on malformed payloads, triggering Slack's retry loop. Now returns 400 consistently, matching the existing pattern in link/route.ts. --- apps/api/src/app/api/integrations/slack/events/route.ts | 7 ++++++- .../src/app/api/integrations/slack/interactions/route.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts index bad9569f6c6..75df71fd6f5 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -25,7 +25,12 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid signature" }, { status: 401 }); } - const payload = JSON.parse(body); + let payload: Record; + try { + payload = JSON.parse(body); + } catch { + return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); + } // Slack sends this once when configuring the Events URL if (payload.type === "url_verification") { diff --git a/apps/api/src/app/api/integrations/slack/interactions/route.ts b/apps/api/src/app/api/integrations/slack/interactions/route.ts index 457522d765e..80aed79a859 100644 --- a/apps/api/src/app/api/integrations/slack/interactions/route.ts +++ b/apps/api/src/app/api/integrations/slack/interactions/route.ts @@ -29,7 +29,12 @@ export async function POST(request: Request) { return new Response("ok", { status: 200 }); } - const payload = JSON.parse(payloadRaw); + let payload: Record; + try { + payload = JSON.parse(payloadRaw); + } catch { + return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); + } if (payload.type === "block_actions") { const teamId: string = payload.team?.id;