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