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.
This commit is contained in:
Mohamed Boudra
2026-04-06 10:54:56 +07:00
parent eabd561fde
commit f75e28a77f
5 changed files with 282 additions and 1 deletions
@@ -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 <Check size={11} color={theme.colors.palette.green[500]} strokeWidth={3} />;
}
if (checksStatus === "failure") {
return <X size={11} color={theme.colors.palette.red[500]} strokeWidth={3} />;
}
// pending
return (
<View
style={{
width: 7,
height: 7,
borderRadius: 3.5,
backgroundColor: theme.colors.palette.amber[500],
}}
/>
);
}
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -1042,6 +1067,7 @@ function WorkspaceRowInner({
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
<ChecksStatusIcon checksStatus={prHint.checksStatus} />
</View>
) : null}
</Pressable>
@@ -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: {
@@ -337,6 +337,46 @@ function WorkspaceHoverCardContent({
</Text>
</Pressable>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<View style={styles.checksList}>
{prHint.checks.map((check) => (
<Pressable
key={check.name}
style={({ hovered }) => [
styles.serviceRow,
hovered && check.url && styles.serviceRowHovered,
]}
onPress={check.url ? () => void openExternalUrl(check.url!) : undefined}
disabled={!check.url}
>
<View
style={[
styles.statusDot,
{
backgroundColor:
check.status === "success"
? theme.colors.palette.green[500]
: check.status === "failure" || check.status === "cancelled"
? theme.colors.palette.red[500]
: check.status === "pending"
? theme.colors.palette.amber[500]
: theme.colors.foregroundMuted,
},
]}
/>
<Text
style={styles.checkName}
numberOfLines={1}
>
{check.name}
</Text>
{check.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
))}
</View>
) : null}
<View style={styles.separator} />
<View style={styles.serviceList} testID="hover-card-service-list">
{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,
},
}));
@@ -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"],
};
}
+11
View File
@@ -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({
+187 -1
View File
@@ -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<string | null> {
try {
const { stdout } = await execAsync("git config --get remote.origin.url", {
@@ -2001,7 +2182,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt",
"url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision",
],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
);
@@ -2019,6 +2200,8 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
: typeof pr.state === "string" && pr.state.trim().length > 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<PullRequestSta
baseRefName: pr.baseRefName ?? "",
headRefName: pr.headRefName ?? head,
isMerged: mergedAt !== null,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision,
},
githubFeaturesEnabled: true,
};