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