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 4367d5b13..b035803e1 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 5d1f9100f..a2936931b 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 fd0c032ec..a06654171 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 006abb9ef..76fcb4c94 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 659084a66..fb3c17911 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