Skip to content

Commit 02f56e7

Browse files
committed
add-pr-reviews (squashed)
1 parent e68940c commit 02f56e7

5 files changed

Lines changed: 155 additions & 9 deletions

File tree

src/main/github.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export interface CheckStatus {
1515
detailsUrl?: string
1616
}
1717

18+
export interface PRReview {
19+
user: string
20+
avatarUrl: string
21+
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'
22+
body: string
23+
submittedAt: string
24+
htmlUrl: string
25+
}
26+
1827
export interface PRStatus {
1928
number: number
2029
title: string
@@ -25,6 +34,9 @@ export interface PRStatus {
2534
checksOverall: 'success' | 'failure' | 'pending' | 'none'
2635
/** true = has conflicts with base, false = mergeable, null = still computing */
2736
hasConflict: boolean | null
37+
reviews: PRReview[]
38+
/** Overall review decision: approved, changes requested, or pending */
39+
reviewDecision: 'approved' | 'changes_requested' | 'review_required' | 'none'
2840
}
2941

3042
function getToken(): string | null {
@@ -129,6 +141,14 @@ interface ApiCombinedStatus {
129141
statuses: ApiStatus[]
130142
}
131143

144+
interface ApiReview {
145+
user: { login: string; avatar_url: string }
146+
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'
147+
body: string
148+
submitted_at: string
149+
html_url: string
150+
}
151+
132152
function normalizeCheckState(
133153
status: ApiCheckRun['status'],
134154
conclusion: ApiCheckRun['conclusion']
@@ -202,10 +222,11 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
202222
// Fetch check runs, status contexts, and PR detail (for mergeable) in parallel.
203223
// The /pulls/{n} endpoint triggers GitHub's background mergeability computation
204224
// and returns the result if it's ready — otherwise mergeable is null.
205-
const [checkRunsRes, combinedRes, prDetail] = await Promise.all([
225+
const [checkRunsRes, combinedRes, prDetail, reviewsRes] = await Promise.all([
206226
githubFetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`) as Promise<ApiCheckRunsResponse>,
207227
githubFetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status`) as Promise<ApiCombinedStatus>,
208-
githubFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}`) as Promise<ApiPRDetail>
228+
githubFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}`) as Promise<ApiPRDetail>,
229+
githubFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`) as Promise<ApiReview[]>
209230
])
210231

211232
// mergeable_state 'dirty' is the definitive conflict signal. mergeable===false
@@ -236,6 +257,29 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
236257
})
237258
}
238259

260+
// Process reviews — keep all reviews, dedupe to latest per user for decision
261+
const reviews: PRReview[] = (Array.isArray(reviewsRes) ? reviewsRes : [])
262+
.filter((r) => r.user && r.state !== 'PENDING')
263+
.map((r) => ({
264+
user: r.user.login,
265+
avatarUrl: r.user.avatar_url,
266+
state: r.state,
267+
body: r.body || '',
268+
submittedAt: r.submitted_at,
269+
htmlUrl: r.html_url
270+
}))
271+
272+
// Compute overall review decision from the latest review per user
273+
const latestByUser = new Map<string, PRReview['state']>()
274+
for (const r of reviews) {
275+
latestByUser.set(r.user, r.state)
276+
}
277+
const latestStates = [...latestByUser.values()]
278+
let reviewDecision: PRStatus['reviewDecision'] = 'none'
279+
if (latestStates.some((s) => s === 'CHANGES_REQUESTED')) reviewDecision = 'changes_requested'
280+
else if (latestStates.some((s) => s === 'APPROVED')) reviewDecision = 'approved'
281+
else if (latestStates.length > 0) reviewDecision = 'review_required'
282+
239283
// Determine PR state
240284
let state: PRStatus['state']
241285
if (pr.merged_at) state = 'merged'
@@ -251,7 +295,9 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
251295
branch: branchName,
252296
checks,
253297
checksOverall: computeOverall(checks),
254-
hasConflict
298+
hasConflict,
299+
reviews,
300+
reviewDecision
255301
}
256302
} catch (err) {
257303
log('github', `getPRStatus failed for ${branchName}`, err instanceof Error ? err.message : err)

src/renderer/components/PRStatusPanel.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
22
import { ExternalLink, GitMerge, ChevronDown, Check, GitPullRequest, RefreshCw } from 'lucide-react'
33
import type {
44
PRStatus,
5+
PRReview,
56
CheckStatus,
67
Worktree,
78
MergeStrategy,
@@ -376,6 +377,81 @@ const OVERALL_COLORS: Record<string, string> = {
376377
none: 'text-dim'
377378
}
378379

380+
const REVIEW_DECISION_LABELS: Record<PRStatus['reviewDecision'], { text: string; color: string }> = {
381+
approved: { text: 'Approved', color: 'text-success' },
382+
changes_requested: { text: 'Changes requested', color: 'text-warning' },
383+
review_required: { text: 'Review pending', color: 'text-faint' },
384+
none: { text: '', color: '' }
385+
}
386+
387+
const REVIEW_STATE_ICONS: Record<PRReview['state'], { symbol: string; color: string }> = {
388+
APPROVED: { symbol: '\u2713', color: 'text-success' },
389+
CHANGES_REQUESTED: { symbol: '\u25CF', color: 'text-warning' },
390+
COMMENTED: { symbol: '\u25CB', color: 'text-faint' },
391+
DISMISSED: { symbol: '-', color: 'text-dim' },
392+
PENDING: { symbol: '\u25CB', color: 'text-dim' }
393+
}
394+
395+
function ReviewSummary({
396+
reviews,
397+
decision
398+
}: {
399+
reviews: PRReview[]
400+
decision: PRStatus['reviewDecision']
401+
}): JSX.Element {
402+
const [expanded, setExpanded] = useState(false)
403+
const label = REVIEW_DECISION_LABELS[decision]
404+
405+
// Dedupe to latest review per user for the summary row
406+
const latestByUser = new Map<string, PRReview>()
407+
for (const r of reviews) {
408+
latestByUser.set(r.user, r)
409+
}
410+
const uniqueReviewers = [...latestByUser.values()]
411+
412+
return (
413+
<div className="mb-1">
414+
<div
415+
className="flex items-center gap-1.5 cursor-pointer"
416+
onClick={() => setExpanded(!expanded)}
417+
>
418+
<span className={`text-xs ${label.color}`}>{label.text}</span>
419+
<span className="text-xs text-faint">
420+
({uniqueReviewers.length} {uniqueReviewers.length === 1 ? 'reviewer' : 'reviewers'})
421+
{expanded ? '\u25B4' : '\u25BE'}
422+
</span>
423+
</div>
424+
{expanded && (
425+
<div className="space-y-0.5 mt-1">
426+
{uniqueReviewers.map((review) => {
427+
const icon = REVIEW_STATE_ICONS[review.state]
428+
return (
429+
<div
430+
key={review.user}
431+
className="flex items-center gap-1.5 text-xs py-0.5 cursor-pointer hover:bg-panel-raised px-1 -mx-1 rounded group"
432+
onClick={() => window.api.openExternal(review.htmlUrl)}
433+
title={`${review.user}: ${review.state.toLowerCase().replace('_', ' ')}`}
434+
>
435+
<img
436+
src={review.avatarUrl}
437+
alt={review.user}
438+
className="w-4 h-4 rounded-full shrink-0"
439+
/>
440+
<span className="text-muted truncate">{review.user}</span>
441+
<span className={`shrink-0 ${icon.color}`}>{icon.symbol}</span>
442+
<ExternalLink
443+
size={10}
444+
className="shrink-0 text-faint opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
445+
/>
446+
</div>
447+
)
448+
})}
449+
</div>
450+
)}
451+
</div>
452+
)
453+
}
454+
379455
interface PRStatusPanelProps {
380456
pr: PRStatus | null | undefined
381457
hasGithubToken?: boolean | null
@@ -495,6 +571,11 @@ export function PRStatusPanel({
495571
</div>
496572
)}
497573

574+
{/* Reviews summary */}
575+
{pr.reviews.length > 0 && (
576+
<ReviewSummary reviews={pr.reviews} decision={pr.reviewDecision} />
577+
)}
578+
498579
{/* Checks summary */}
499580
<div
500581
className={`flex items-center gap-1.5 cursor-pointer ${expanded ? 'mb-1.5' : ''}`}

src/renderer/components/WorktreeTab.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,19 @@ export function WorktreeTab({ worktree, isActive, status, prStatus, isMerged, re
8181
title={STATUS_LABELS[displayStatus]}
8282
/>
8383
{prStatus && (
84-
<GitPullRequest
85-
size={13}
86-
className={`shrink-0 ${iconColor}`}
87-
title={`PR #${prStatus.number}${prStatus.checksOverall !== 'none' ? ` \u2014 checks ${prStatus.checksOverall}` : ''}${iconTitleSuffix}`}
88-
/>
84+
<span className="relative shrink-0">
85+
<GitPullRequest
86+
size={13}
87+
className={iconColor}
88+
title={`PR #${prStatus.number}${prStatus.checksOverall !== 'none' ? ` \u2014 checks ${prStatus.checksOverall}` : ''}${iconTitleSuffix}${prStatus.reviewDecision === 'approved' ? ' \u2014 approved' : prStatus.reviewDecision === 'changes_requested' ? ' \u2014 changes requested' : ''}`}
89+
/>
90+
{prStatus.reviewDecision === 'approved' && (
91+
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-success ring-1 ring-panel" />
92+
)}
93+
{prStatus.reviewDecision === 'changes_requested' && (
94+
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-warning ring-1 ring-panel" />
95+
)}
96+
</span>
8997
)}
9098
<div className="min-w-0 flex-1">
9199
<div className="text-sm font-medium truncate">{worktree.branch}</div>

src/renderer/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ export interface MergeLocalResult {
141141
mainPath: string
142142
}
143143

144+
export interface PRReview {
145+
user: string
146+
avatarUrl: string
147+
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'
148+
body: string
149+
submittedAt: string
150+
htmlUrl: string
151+
}
152+
144153
export interface PRStatus {
145154
number: number
146155
title: string
@@ -151,6 +160,8 @@ export interface PRStatus {
151160
checksOverall: 'success' | 'failure' | 'pending' | 'none'
152161
/** true = has conflicts with base, false = mergeable, null = still computing */
153162
hasConflict: boolean | null
163+
reviews: PRReview[]
164+
reviewDecision: 'approved' | 'changes_requested' | 'review_required' | 'none'
154165
}
155166

156167
export interface ElectronAPI {

src/renderer/worktree-sort.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function getGroupKey(
1616
if (locallyMerged) return 'merged'
1717
if (!pr) return 'no-pr'
1818
if (pr.state === 'merged' || pr.state === 'closed') return 'merged'
19-
if (pr.checksOverall === 'failure' || pr.hasConflict === true) return 'needs-attention'
19+
if (pr.checksOverall === 'failure' || pr.hasConflict === true || pr.reviewDecision === 'changes_requested') return 'needs-attention'
2020
return 'active'
2121
}
2222

0 commit comments

Comments
 (0)