Display PR checks and title updates (#101)

- Add PR checks panel to right sidebar
- Stream checks from GitHub API
- Update PR title real-time with loading state
- Add error logging and dependency fixes
- Fix linting issues (curly braces, exhaustive deps)
This commit is contained in:
Jinjing 2026-03-25 09:29:39 -07:00 committed by GitHub
parent 0b9624ec70
commit ddc8a78744
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 534 additions and 5 deletions

View File

@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRInfo, IssueInfo, CheckStatus } from '../../shared/types'
import type { PRInfo, IssueInfo, CheckStatus, PRCheckDetail } from '../../shared/types'
const execFileAsync = promisify(execFile)
@ -127,6 +127,95 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
}
}
/**
* Get detailed check statuses for a PR by number.
*/
export async function getPRChecks(repoPath: string, prNumber: number): Promise<PRCheckDetail[]> {
await acquire()
try {
const { stdout } = await execFileAsync(
'gh',
['pr', 'checks', String(prNumber), '--json', 'name,state,link'],
{
cwd: repoPath,
encoding: 'utf-8'
}
)
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
return data.map((d) => ({
name: d.name,
status: mapCheckStatus(d.state),
conclusion: mapCheckConclusion(d.state),
url: d.link || null
}))
} catch (err) {
console.warn('getPRChecks failed:', err)
return []
} finally {
release()
}
}
/**
* Update a PR's title.
*/
export async function updatePRTitle(
repoPath: string,
prNumber: number,
title: string
): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['pr', 'edit', String(prNumber), '--title', title], {
cwd: repoPath,
encoding: 'utf-8'
})
return true
} catch (err) {
console.warn('updatePRTitle failed:', err)
return false
} finally {
release()
}
}
function mapCheckStatus(state: string): PRCheckDetail['status'] {
const s = state?.toUpperCase()
if (s === 'PENDING' || s === 'QUEUED') {
return 'queued'
}
if (s === 'IN_PROGRESS') {
return 'in_progress'
}
return 'completed'
}
function mapCheckConclusion(state: string): PRCheckDetail['conclusion'] {
const s = state?.toUpperCase()
if (s === 'SUCCESS' || s === 'PASS') {
return 'success'
}
if (s === 'FAILURE' || s === 'FAIL') {
return 'failure'
}
if (s === 'CANCELLED') {
return 'cancelled'
}
if (s === 'TIMED_OUT') {
return 'timed_out'
}
if (s === 'SKIPPED') {
return 'skipped'
}
if (s === 'PENDING' || s === 'QUEUED' || s === 'IN_PROGRESS') {
return 'pending'
}
if (s === 'NEUTRAL') {
return 'neutral'
}
return null
}
export function mapPRState(state: string): PRInfo['state'] {
const s = state?.toUpperCase()
if (s === 'MERGED') {

View File

@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import { getPRForBranch, getIssue, listIssues } from '../github/client'
import { getPRForBranch, getIssue, listIssues, getPRChecks, updatePRTitle } from '../github/client'
export function registerGitHubHandlers(): void {
ipcMain.handle('gh:prForBranch', (_event, args: { repoPath: string; branch: string }) => {
@ -13,4 +13,15 @@ export function registerGitHubHandlers(): void {
ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => {
return listIssues(args.repoPath, args.limit)
})
ipcMain.handle('gh:prChecks', (_event, args: { repoPath: string; prNumber: number }) => {
return getPRChecks(args.repoPath, args.prNumber)
})
ipcMain.handle(
'gh:updatePRTitle',
(_event, args: { repoPath: string; prNumber: number; title: string }) => {
return updatePRTitle(args.repoPath, args.prNumber, args.title)
}
)
}

View File

@ -4,6 +4,7 @@ import type {
Worktree,
WorktreeMeta,
PRInfo,
PRCheckDetail,
IssueInfo,
GlobalSettings,
OrcaHooks,
@ -54,6 +55,8 @@ type GhApi = {
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
prChecks: (args: { repoPath: string; prNumber: number }) => Promise<PRCheckDetail[]>
updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise<boolean>
}
type SettingsApi = {

View File

@ -140,7 +140,16 @@ const api = {
ipcRenderer.invoke('gh:issue', args),
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:listIssues', args)
ipcRenderer.invoke('gh:listIssues', args),
prChecks: (args: { repoPath: string; prNumber: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:prChecks', args),
updatePRTitle: (args: {
repoPath: string
prNumber: number
title: string
}): Promise<boolean> => ipcRenderer.invoke('gh:updatePRTitle', args)
},
settings: {

View File

@ -0,0 +1,394 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
CircleCheck,
CircleX,
LoaderCircle,
CircleDashed,
CircleMinus,
ExternalLink,
RefreshCw,
Check,
X,
Pencil
} from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
function PullRequestIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden fill="currentColor" className={className}>
<path
fillRule="evenodd"
d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.25 2.25 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1.5 1.5 0 011.5 1.5v5.628a2.25 2.25 0 101.5 0V5.5A3 3 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"
/>
</svg>
)
}
const CHECK_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
success: CircleCheck,
failure: CircleX,
pending: LoaderCircle,
neutral: CircleDashed,
skipped: CircleMinus,
cancelled: CircleX,
timed_out: CircleX
}
const CHECK_COLOR: Record<string, string> = {
success: 'text-emerald-500',
failure: 'text-rose-500',
pending: 'text-amber-500',
neutral: 'text-muted-foreground',
skipped: 'text-muted-foreground/60',
cancelled: 'text-muted-foreground/60',
timed_out: 'text-rose-500'
}
function prStateColor(state: PRInfo['state']): string {
switch (state) {
case 'merged':
return 'bg-purple-500/15 text-purple-500 border-purple-500/20'
case 'open':
return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20'
case 'closed':
return 'bg-muted text-muted-foreground border-border'
case 'draft':
return 'bg-muted text-muted-foreground/70 border-border'
}
}
export default function ChecksPanel(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const [checks, setChecks] = useState<PRCheckDetail[]>([])
const [checksLoading, setChecksLoading] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [titleSaving, setTitleSaving] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Find active worktree and repo
const { worktree, repo } = useMemo(() => {
if (!activeWorktreeId) {
return { worktree: null, repo: null }
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
const r = repos.find((rp) => rp.id === wt.repoId)
return { worktree: wt, repo: r ?? null }
}
}
return { worktree: null, repo: null }
}, [activeWorktreeId, worktreesByRepo, repos])
const branch = worktree ? worktree.branch.replace(/^refs\/heads\//, '') : ''
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
// Fetch checks
const fetchChecks = useCallback(async () => {
if (!repo || !pr) {
return
}
setChecksLoading(true)
try {
const result = (await window.api.gh.prChecks({
repoPath: repo.path,
prNumber: pr.number
})) as PRCheckDetail[]
setChecks(result)
} catch (err) {
console.warn('Failed to fetch PR checks:', err)
setChecks([])
} finally {
setChecksLoading(false)
}
}, [repo, pr])
// Fetch checks on mount + poll
useEffect(() => {
if (!pr) {
setChecks([])
return
}
void fetchChecks()
pollRef.current = setInterval(() => void fetchChecks(), 30_000)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
}
}
}, [fetchChecks, pr])
const handleRefresh = useCallback(async () => {
if (!repo || !branch) {
return
}
// Refresh PR data + checks
await fetchPRForBranch(repo.path, branch)
await fetchChecks()
}, [repo, branch, fetchPRForBranch, fetchChecks])
const handleStartEdit = useCallback(() => {
if (!pr) {
return
}
setTitleDraft(pr.title)
setEditingTitle(true)
setTimeout(() => titleInputRef.current?.focus(), 0)
}, [pr])
const handleCancelEdit = useCallback(() => {
setEditingTitle(false)
setTitleDraft('')
}, [])
const handleSaveTitle = useCallback(async () => {
if (!repo || !pr || !titleDraft.trim() || titleDraft === pr.title) {
setEditingTitle(false)
return
}
setTitleSaving(true)
try {
const ok = await window.api.gh.updatePRTitle({
repoPath: repo.path,
prNumber: pr.number,
title: titleDraft.trim()
})
if (ok) {
// Re-fetch PR to get updated title
await fetchPRForBranch(repo.path, branch)
}
} finally {
setTitleSaving(false)
setEditingTitle(false)
}
}, [repo, pr, titleDraft, branch, fetchPRForBranch])
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSaveTitle()
} else if (e.key === 'Escape') {
handleCancelEdit()
}
},
[handleSaveTitle, handleCancelEdit]
)
// Open PR in browser
const handleOpenPR = useCallback(() => {
if (pr?.url) {
window.api.shell.openUrl(pr.url)
}
}, [pr])
// ── Empty state ──
if (!worktree) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground px-4 text-center">
Select a worktree to view PR checks
</div>
)
}
if (!pr) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 px-4 text-center">
<PullRequestIcon className="size-8 text-muted-foreground/30" />
<div className="text-[11px] text-muted-foreground">
No pull request found for this branch
</div>
<div className="text-[10px] text-muted-foreground/60">
Push your branch and open a PR to see checks here
</div>
</div>
)
}
// ── Sorted checks: failures first, then pending, then success ──
const sortedChecks = [...checks].sort((a, b) => {
const order = {
failure: 0,
timed_out: 0,
cancelled: 1,
pending: 2,
neutral: 3,
skipped: 4,
success: 5
}
const aOrder = order[a.conclusion ?? 'pending'] ?? 3
const bOrder = order[b.conclusion ?? 'pending'] ?? 3
return aOrder - bOrder
})
const passingCount = checks.filter((c) => c.conclusion === 'success').length
const failingCount = checks.filter(
(c) => c.conclusion === 'failure' || c.conclusion === 'timed_out'
).length
const pendingCount = checks.filter(
(c) => c.conclusion === 'pending' || c.conclusion === null
).length
return (
<div className="flex-1 overflow-auto scrollbar-sleek">
{/* PR Header */}
<div className="px-3 py-3 border-b border-border space-y-2.5">
{/* PR number + state badge + refresh + open link */}
<div className="flex items-center gap-2">
<PullRequestIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-[12px] font-semibold text-foreground">#{pr.number}</span>
<span
className={cn(
'text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border',
prStateColor(pr.state)
)}
>
{pr.state}
</span>
<div className="flex-1" />
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Refresh"
onClick={() => void handleRefresh()}
>
<RefreshCw className={cn('size-3.5', checksLoading && 'animate-spin')} />
</button>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Open on GitHub"
onClick={handleOpenPR}
>
<ExternalLink className="size-3.5" />
</button>
</div>
{/* PR title (editable) */}
{editingTitle ? (
<div className="flex items-center gap-1">
<input
ref={titleInputRef}
className="flex-1 text-[12px] bg-background border border-border rounded px-2 py-1 text-foreground outline-none focus:ring-1 focus:ring-ring"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={handleTitleKeyDown}
disabled={titleSaving}
/>
<button
className="p-1 rounded hover:bg-accent text-emerald-500 hover:text-emerald-400 transition-colors"
title="Save"
onClick={() => void handleSaveTitle()}
disabled={titleSaving}
>
{titleSaving ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</button>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Cancel"
onClick={handleCancelEdit}
disabled={titleSaving}
>
<X className="size-3.5" />
</button>
</div>
) : (
<div
className="group/title flex items-start gap-1.5 cursor-pointer -mx-1 px-1 py-0.5 rounded hover:bg-accent/40 transition-colors"
onClick={handleStartEdit}
>
<span className="text-[12px] text-foreground leading-snug flex-1">{pr.title}</span>
<Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
)}
{/* Updated at */}
{pr.updatedAt && (
<div className="text-[10px] text-muted-foreground/60">
Updated {new Date(pr.updatedAt).toLocaleString()}
</div>
)}
</div>
{/* Checks Summary */}
{checks.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 border-b border-border text-[10px] text-muted-foreground">
{passingCount > 0 && (
<span className="flex items-center gap-1">
<CircleCheck className="size-3 text-emerald-500" />
{passingCount} passing
</span>
)}
{failingCount > 0 && (
<span className="flex items-center gap-1">
<CircleX className="size-3 text-rose-500" />
{failingCount} failing
</span>
)}
{pendingCount > 0 && (
<span className="flex items-center gap-1">
<LoaderCircle className="size-3 text-amber-500" />
{pendingCount} pending
</span>
)}
</div>
)}
{/* Checks List */}
{checksLoading && checks.length === 0 ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : checks.length === 0 ? (
<div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground">
No checks configured
</div>
) : (
<div className="py-1">
{sortedChecks.map((check) => {
const conclusion = check.conclusion ?? 'pending'
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
return (
<div
key={check.name}
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/40 transition-colors',
check.url && 'cursor-pointer'
)}
onClick={() => {
if (check.url) {
window.api.shell.openUrl(check.url)
}
}}
>
<Icon
className={cn(
'size-3.5 shrink-0',
color,
conclusion === 'pending' && 'animate-spin'
)}
/>
<span className="flex-1 truncate text-[12px] text-foreground">{check.name}</span>
{check.url && <ExternalLink className="size-3 text-muted-foreground/40 shrink-0" />}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { Files, Search, GitBranch } from 'lucide-react'
import { Files, Search, GitBranch, ListChecks } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor'
@ -15,6 +15,7 @@ import {
import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@ -44,6 +45,12 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
title: 'Source Control',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}G`
},
{
id: 'checks',
icon: ListChecks,
title: 'Checks',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`
},
{ id: 'search', icon: Search, title: 'Search', shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}F` }
]
@ -110,6 +117,7 @@ export default function RightSidebar(): React.JSX.Element {
{rightSidebarTab === 'explorer' && <FileExplorer key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'search' && <SearchPanel key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'source-control' && <SourceControl key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'checks' && <ChecksPanel key={activeWorktreeId ?? 'none'} />}
</div>
)

View File

@ -14,7 +14,7 @@ export type OpenFile = {
diffStaged?: boolean
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control'
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
export type ActivityBarPosition = 'top' | 'side'
export type EditorSlice = {

View File

@ -100,6 +100,21 @@ export type PRInfo = {
updatedAt: string
}
export type PRCheckDetail = {
name: string
status: 'queued' | 'in_progress' | 'completed'
conclusion:
| 'success'
| 'failure'
| 'cancelled'
| 'timed_out'
| 'neutral'
| 'skipped'
| 'pending'
| null
url: string | null
}
export type IssueInfo = {
number: number
title: string