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:
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user