From f75e28a77f4698941bad018ef9a7e67ef790c072 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 10:54:56 +0700 Subject: [PATCH] feat: add CI check status to workspace sidebar and hover card Extend the existing gh pr view call with statusCheckRollup and reviewDecision fields so check data arrives in a single request. The workspace row now shows an aggregate check icon (green checkmark, red X, or amber dot) next to the PR badge, and the hover card lists each individual check with its status and a link to details. --- .../src/components/sidebar-workspace-list.tsx | 29 +++ .../src/components/workspace-hover-card.tsx | 49 +++++ .../src/hooks/use-checkout-pr-status-query.ts | 6 + packages/server/src/shared/messages.ts | 11 + packages/server/src/utils/checkout-git.ts | 188 +++++++++++++++++- 5 files changed, 282 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4367d5b135..b035803e11 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { Archive, + Check, CircleAlert, ChevronDown, ChevronRight, @@ -40,6 +41,7 @@ import { MoreVertical, Plus, Trash2, + X, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -224,6 +226,29 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) { ); } +function ChecksStatusIcon({ checksStatus }: { checksStatus?: PrHint["checksStatus"] }) { + const { theme } = useUnistyles(); + if (!checksStatus || checksStatus === "none") return null; + + if (checksStatus === "success") { + return ; + } + if (checksStatus === "failure") { + return ; + } + // pending + return ( + + ); +} + function WorkspaceStatusIndicator({ bucket, workspaceKind, @@ -1042,6 +1067,7 @@ function WorkspaceRowInner({ {prHint ? ( + ) : null} @@ -2410,6 +2436,9 @@ const styles = StyleSheet.create((theme) => ({ opacity: 1, }, workspacePrBadgeRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, workspacePrBadge: { diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 5d1f9100ff..a2936931b3 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -337,6 +337,46 @@ function WorkspaceHoverCardContent({ ) : null} + {prHint?.checks && prHint.checks.length > 0 ? ( + + {prHint.checks.map((check) => ( + [ + styles.serviceRow, + hovered && check.url && styles.serviceRowHovered, + ]} + onPress={check.url ? () => void openExternalUrl(check.url!) : undefined} + disabled={!check.url} + > + + + {check.name} + + {check.url ? ( + + ) : null} + + ))} + + ) : null} {workspace.services.map((service) => ( @@ -502,4 +542,13 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, + checksList: { + paddingBottom: theme.spacing[1], + }, + checkName: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flex: 1, + minWidth: 0, + }, })); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index fd0c032ec4..a06654171d 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -20,6 +20,9 @@ export interface PrHint { url: string; number: number; state: "open" | "merged" | "closed"; + checks?: Array<{ name: string; status: string; url: string | null }>; + checksStatus?: "none" | "pending" | "success" | "failure"; + reviewDecision?: "approved" | "changes_requested" | "pending" | null; } function parsePullRequestNumber(url: string): number | null { @@ -56,6 +59,9 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null : status.state === "open" ? "open" : "closed", + checks: status.checks, + checksStatus: status.checksStatus as PrHint["checksStatus"], + reviewDecision: status.reviewDecision as PrHint["reviewDecision"], }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 006abb9efb..76fcb4c94c 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2151,6 +2151,17 @@ const CheckoutPrStatusSchema = z.object({ baseRefName: z.string(), headRefName: z.string(), isMerged: z.boolean(), + checks: z + .array( + z.object({ + name: z.string(), + status: z.string(), + url: z.string().nullable(), + }), + ) + .optional(), + checksStatus: z.string().optional(), + reviewDecision: z.string().nullable().optional(), }); export const CheckoutPrStatusResponseSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 659084a663..fb3c179115 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1831,6 +1831,9 @@ export interface PullRequestStatus { baseRefName: string; headRefName: string; isMerged: boolean; + checks?: PullRequestCheck[]; + checksStatus?: ChecksStatus; + reviewDecision?: ReviewDecision; } export interface PullRequestStatusResult { @@ -1838,6 +1841,35 @@ export interface PullRequestStatusResult { githubFeaturesEnabled: boolean; } +export type PullRequestCheck = { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url: string | null; +}; + +export type ChecksStatus = "none" | "pending" | "success" | "failure"; + +export type ReviewDecision = "approved" | "changes_requested" | "pending" | null; + +type StatusCheckRollupContext = { + __typename?: unknown; + name?: unknown; + conclusion?: unknown; + status?: unknown; + detailsUrl?: unknown; + startedAt?: unknown; + completedAt?: unknown; + checkSuite?: { + workflowRun?: { + databaseId?: unknown; + } | null; + } | null; + context?: unknown; + state?: unknown; + targetUrl?: unknown; + createdAt?: unknown; +}; + function resolveGhPath(): string { if (cachedGhPath === undefined) { cachedGhPath = findExecutable("gh"); @@ -1869,6 +1901,155 @@ function isGhAuthError(error: unknown): boolean { ); } +function mapCheckRunStatus( + status: unknown, + conclusion: unknown, +): PullRequestCheck["status"] { + if (status !== "COMPLETED") { + return "pending"; + } + + switch (conclusion) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "TIMED_OUT": + case "ACTION_REQUIRED": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + case "NEUTRAL": + return "skipped"; + default: + return "pending"; + } +} + +function mapStatusContextState(state: unknown): PullRequestCheck["status"] { + switch (state) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + return "failure"; + case "EXPECTED": + case "PENDING": + return "pending"; + default: + return "pending"; + } +} + +function getCheckRunRecency(context: StatusCheckRollupContext): number { + const workflowRunId = context.checkSuite?.workflowRun?.databaseId; + if (typeof workflowRunId === "number") { + return workflowRunId; + } + + const timestamp = + typeof context.completedAt === "string" + ? context.completedAt + : typeof context.startedAt === "string" + ? context.startedAt + : null; + if (!timestamp) { + return 0; + } + + const time = Date.parse(timestamp); + return Number.isNaN(time) ? 0 : time; +} + +function getStatusContextRecency(context: StatusCheckRollupContext): number { + if (typeof context.createdAt !== "string" || context.createdAt.length === 0) { + return 0; + } + + const time = Date.parse(context.createdAt); + return Number.isNaN(time) ? 0 : time; +} + +function parseStatusCheckRollup(value: unknown): PullRequestCheck[] { + if (!value || typeof value !== "object") { + return []; + } + + const contexts = (value as { contexts?: unknown }).contexts; + if (!Array.isArray(contexts)) { + return []; + } + + const dedupedChecks = new Map< + string, + PullRequestCheck & { + recency: number; + } + >(); + + for (const entry of contexts) { + if (!entry || typeof entry !== "object") { + continue; + } + + const context = entry as StatusCheckRollupContext; + let check: (PullRequestCheck & { recency: number }) | null = null; + + if (context.__typename === "CheckRun" && typeof context.name === "string") { + check = { + name: context.name, + status: mapCheckRunStatus(context.status, context.conclusion), + url: typeof context.detailsUrl === "string" ? context.detailsUrl : null, + recency: getCheckRunRecency(context), + }; + } else if (context.__typename === "StatusContext" && typeof context.context === "string") { + check = { + name: context.context, + status: mapStatusContextState(context.state), + url: typeof context.targetUrl === "string" ? context.targetUrl : null, + recency: getStatusContextRecency(context), + }; + } + + if (!check) { + continue; + } + + const existing = dedupedChecks.get(check.name); + if (!existing || check.recency > existing.recency) { + dedupedChecks.set(check.name, check); + } + } + + return Array.from(dedupedChecks.values(), ({ recency: _recency, ...check }) => check); +} + +function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus { + if (checks.length === 0) { + return "none"; + } + if (checks.some((check) => check.status === "failure")) { + return "failure"; + } + if (checks.some((check) => check.status === "pending")) { + return "pending"; + } + return "success"; +} + +function mapReviewDecision(value: unknown): ReviewDecision { + if (value === "APPROVED") { + return "approved"; + } + if (value === "CHANGES_REQUESTED") { + return "changes_requested"; + } + if (value === "REVIEW_REQUIRED") { + return "pending"; + } + return null; +} + async function resolveGitHubRepo(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { @@ -2001,7 +2182,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise 0 ? pr.state.toLowerCase() : ""; + const checks = parseStatusCheckRollup(pr.statusCheckRollup); + const reviewDecision = mapReviewDecision(pr.reviewDecision); return { status: { url: pr.url, @@ -2027,6 +2210,9 @@ async function getPullRequestStatusUncached(cwd: string): Promise