feat: display CI build status on worktree cards (#80)

Show CI check status icons (success/failure/pending) next to the branch
name on worktree cards. Return 'neutral' instead of 'pending' when no
CI checks exist to avoid showing a misleading spinner. Fix lint errors
in client.ts (curly braces, array-type).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing 2026-03-23 23:36:57 -07:00 committed by GitHub
parent 1dff41c6d7
commit 598f7c17fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 43 additions and 13 deletions

View file

@ -7,7 +7,7 @@ const execFileAsync = promisify(execFile)
// Concurrency limiter - max 4 parallel gh processes
const MAX_CONCURRENT = 4
let running = 0
const queue: Array<() => void> = []
const queue: (() => void)[] = []
function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
@ -25,7 +25,9 @@ function acquire(): Promise<void> {
function release(): void {
running--
const next = queue.shift()
if (next) next()
if (next) {
next()
}
}
/**
@ -104,13 +106,13 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
encoding: 'utf-8'
}
)
const data = JSON.parse(stdout) as Array<{
const data = JSON.parse(stdout) as {
number: number
title: string
state: string
url: string
labels: Array<{ name: string }>
}>
labels: { name: string }[]
}[]
return data.map((d) => ({
number: d.number,
title: d.title,
@ -127,19 +129,25 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
function mapPRState(state: string): PRInfo['state'] {
const s = state?.toUpperCase()
if (s === 'MERGED') return 'merged'
if (s === 'CLOSED') return 'closed'
if (s === 'MERGED') {
return 'merged'
}
if (s === 'CLOSED') {
return 'closed'
}
// gh CLI returns isDraft separately, but state field is OPEN for drafts too
return 'open'
}
function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus {
if (!rollup || !Array.isArray(rollup) || rollup.length === 0) return 'pending'
if (!rollup || !Array.isArray(rollup) || rollup.length === 0) {
return 'neutral'
}
let hasFailure = false
let hasPending = false
for (const check of rollup as Array<{ status?: string; conclusion?: string; state?: string }>) {
for (const check of rollup as { status?: string; conclusion?: string; state?: string }[]) {
const conclusion = check.conclusion?.toUpperCase()
const status = check.status?.toUpperCase()
const state = check.state?.toUpperCase()
@ -162,7 +170,11 @@ function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus {
}
}
if (hasFailure) return 'failure'
if (hasPending) return 'pending'
if (hasFailure) {
return 'failure'
}
if (hasPending) {
return 'pending'
}
return 'success'
}

View file

@ -3,7 +3,7 @@ import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Bell, LoaderCircle, CircleDot } from 'lucide-react'
import { Bell, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
@ -228,7 +228,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{worktree.displayName}
</div>
{/* Line 2: Repo badge + branch + primary badge */}
{/* Line 2: Repo badge + branch + primary badge + CI status */}
<div className="flex items-center gap-1 min-w-0">
{repo && (
<Badge
@ -249,6 +249,24 @@ const WorktreeCard = React.memo(function WorktreeCard({
main
</Badge>
)}
{pr && pr.checksStatus !== 'neutral' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto shrink-0 inline-flex items-center">
{pr.checksStatus === 'success' && (
<CircleCheck className="size-3 text-emerald-400" />
)}
{pr.checksStatus === 'failure' && <CircleX className="size-3 text-red-400" />}
{pr.checksStatus === 'pending' && (
<LoaderCircle className="size-3 text-yellow-400 animate-spin" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>CI checks {checksLabel(pr.checksStatus).toLowerCase()}</span>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Meta section: Issue, Comment, PR */}