Skip to content

Commit 82815eb

Browse files
committed
feat: surface PR readiness in repo detail
1 parent 80e2ec4 commit 82815eb

File tree

7 files changed

+285
-2
lines changed

7 files changed

+285
-2
lines changed

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
9292
53. [ ] Add a "show only blockers" mode for large reviews.
9393
54. [ ] Add keyboard actions for thumbs, resolve, and jump-to-next-finding workflows.
9494
55. [ ] Add file-level readiness summaries in the diff sidebar.
95-
56. [ ] Add lifecycle-aware PR summaries that explain what still blocks merge.
95+
56. [x] Add lifecycle-aware PR summaries that explain what still blocks merge.
9696
57. [ ] Add a "train the reviewer" callout when thumbs coverage on a review is low.
9797
58. [ ] Add review-change comparisons so users can diff one review run against the next on the same PR.
9898
59. [ ] Add better surfacing for incremental PR reviews so users know when only the delta was reviewed.
@@ -160,4 +160,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
160160
- [x] Make stale-review detection compare PR head SHAs so same-head reruns do not look stale.
161161
- [x] Split open findings into blocking vs informational buckets and surface critical blocker cards in review detail.
162162
- [x] Add PR readiness query surfaces in the CLI and HTTP API for non-UI workflows.
163+
- [x] Surface lifecycle-aware PR readiness summaries in the GitHub PR detail workflow.
163164
- [ ] Commit and push each validated checkpoint before moving to the next epic.

web/src/api/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export const api = {
9191
return request<import('./types').GhPullRequest[]>(`/gh/prs?${qs}`)
9292
},
9393

94+
getGhPrReadiness: (repo: string, prNumber: number) => {
95+
const qs = new URLSearchParams({ repo, pr_number: String(prNumber) })
96+
return request<import('./types').PrReadinessSnapshot>(`/gh/pr-readiness?${qs}`)
97+
},
98+
9499
startPrReview: (body: import('./types').StartPrReviewRequest) =>
95100
request<{ id: string; status: string }>('/gh/review', {
96101
method: 'POST',

web/src/api/hooks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ export function useGhPrs(repo: string | undefined, state?: string) {
118118
})
119119
}
120120

121+
export function useGhPrReadiness(repo: string | undefined, prNumber: number | undefined) {
122+
return useQuery({
123+
queryKey: ['gh-pr-readiness', repo, prNumber],
124+
queryFn: () => api.getGhPrReadiness(repo!, prNumber!),
125+
enabled: !!repo && !!prNumber,
126+
})
127+
}
128+
121129
export function useEvents(params?: {
122130
source?: string; model?: string; status?: string;
123131
time_from?: string; time_to?: string;

web/src/api/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,26 @@ export interface GhPullRequest {
377377
draft: boolean
378378
}
379379

380+
export interface PrReadinessReview {
381+
id: string
382+
status: ReviewStatus
383+
started_at: string | number
384+
completed_at?: string | number
385+
reviewed_head_sha?: string
386+
summary?: ReviewSummary
387+
files_reviewed: number
388+
comment_count: number
389+
error?: string
390+
}
391+
392+
export interface PrReadinessSnapshot {
393+
repo: string
394+
pr_number: number
395+
diff_source: string
396+
current_head_sha?: string
397+
latest_review?: PrReadinessReview
398+
}
399+
380400
export interface StartPrReviewRequest {
381401
repo: string
382402
pr_number: number
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ExternalLink, Loader2 } from 'lucide-react'
2+
3+
import type { MergeReadiness, PrReadinessSnapshot, ReviewSummary } from '../api/types'
4+
5+
type Props = {
6+
readiness?: PrReadinessSnapshot
7+
isLoading?: boolean
8+
error?: unknown
9+
onOpenReview?: (reviewId: string) => void
10+
}
11+
12+
const READINESS_STYLES: Record<MergeReadiness, string> = {
13+
Ready: 'bg-sev-suggestion/10 text-sev-suggestion border border-sev-suggestion/20',
14+
NeedsAttention: 'bg-sev-warning/10 text-sev-warning border border-sev-warning/20',
15+
NeedsReReview: 'bg-accent/10 text-accent border border-accent/20',
16+
}
17+
18+
const READINESS_LABELS: Record<MergeReadiness, string> = {
19+
Ready: 'Merge Ready',
20+
NeedsAttention: 'Needs Attention',
21+
NeedsReReview: 'Needs Re-review',
22+
}
23+
24+
export function PrReadinessSummary({ readiness, isLoading = false, error, onOpenReview }: Props) {
25+
const latestReview = readiness?.latest_review
26+
const summary = latestReview?.summary
27+
28+
return (
29+
<div className="mb-4 rounded-lg border border-border-subtle bg-surface p-3">
30+
<div className="flex items-start justify-between gap-3 mb-3">
31+
<div>
32+
<div className="text-[13px] text-text-primary">Latest DiffScope readiness</div>
33+
<div className="text-[11px] text-text-muted mt-0.5">
34+
Merge guidance from the latest stored DiffScope review for this PR.
35+
</div>
36+
</div>
37+
{latestReview?.id && onOpenReview && (
38+
<button
39+
type="button"
40+
onClick={() => onOpenReview(latestReview.id)}
41+
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium bg-surface-2 border border-border text-text-secondary hover:text-text-primary hover:border-text-muted transition-colors"
42+
>
43+
Open latest review <ExternalLink size={12} />
44+
</button>
45+
)}
46+
</div>
47+
48+
{isLoading ? (
49+
<div className="flex items-center gap-2 text-[12px] text-text-muted">
50+
<Loader2 size={14} className="animate-spin" />
51+
Loading readiness…
52+
</div>
53+
) : error ? (
54+
<div className="text-[12px] text-sev-error">
55+
{error instanceof Error ? error.message : 'Failed to load readiness'}
56+
</div>
57+
) : !latestReview ? (
58+
<div className="text-[12px] text-text-secondary">
59+
No DiffScope review has been recorded for this PR yet. Start a review below to populate merge guidance.
60+
</div>
61+
) : !summary ? (
62+
<div className="text-[12px] text-text-secondary">
63+
The latest DiffScope review does not have a readiness summary yet.
64+
</div>
65+
) : (
66+
<>
67+
<div className="flex flex-wrap items-center gap-2 mb-3">
68+
<span className={`text-[10px] px-2 py-0.5 rounded font-code ${READINESS_STYLES[summary.merge_readiness]}`}>
69+
{READINESS_LABELS[summary.merge_readiness]}
70+
</span>
71+
<span className="text-[10px] text-text-muted font-code">
72+
Review {latestReview.id.slice(0, 8)}
73+
</span>
74+
<span className="text-[10px] text-text-muted font-code">{latestReview.status}</span>
75+
</div>
76+
77+
<div className="grid grid-cols-2 gap-3 text-[11px] mb-3">
78+
<Metric label="Open blockers" value={String(summary.open_blockers)} tone={summary.open_blockers > 0 ? 'warning' : 'success'} />
79+
<Metric label="Verification" value={summary.verification.state} />
80+
<Metric label="Lifecycle" value={`${summary.open_comments} open`} hint={`${summary.resolved_comments} resolved · ${summary.dismissed_comments} dismissed`} />
81+
<Metric label="Findings" value={String(summary.total_comments)} hint={`${latestReview.files_reviewed} file${latestReview.files_reviewed === 1 ? '' : 's'} reviewed`} />
82+
</div>
83+
84+
{(readiness?.current_head_sha || latestReview.reviewed_head_sha) && (
85+
<div className="grid grid-cols-2 gap-3 text-[10px] font-code text-text-muted mb-3">
86+
{readiness?.current_head_sha && (
87+
<Metric label="Current head" value={shortSha(readiness.current_head_sha)} />
88+
)}
89+
{latestReview.reviewed_head_sha && (
90+
<Metric label="Reviewed head" value={shortSha(latestReview.reviewed_head_sha)} />
91+
)}
92+
</div>
93+
)}
94+
95+
<div className="rounded border border-border-subtle bg-surface-1 p-3">
96+
<div className="text-[11px] font-medium text-text-primary mb-2">What still blocks merge</div>
97+
<ul className="space-y-1 text-[11px] text-text-secondary list-disc pl-4">
98+
{buildReadinessReasons(summary).map(reason => (
99+
<li key={reason}>{reason}</li>
100+
))}
101+
</ul>
102+
</div>
103+
</>
104+
)}
105+
</div>
106+
)
107+
}
108+
109+
function buildReadinessReasons(summary: ReviewSummary): string[] {
110+
const reasons: string[] = []
111+
if (summary.open_blockers > 0) {
112+
reasons.push(`${summary.open_blockers} blocking finding${summary.open_blockers === 1 ? '' : 's'} ${summary.open_blockers === 1 ? 'remains' : 'remain'} open.`)
113+
}
114+
reasons.push(...summary.readiness_reasons)
115+
116+
if (reasons.length === 0) {
117+
reasons.push('No open blockers remain in the latest DiffScope review.')
118+
}
119+
120+
return reasons
121+
}
122+
123+
function shortSha(sha: string): string {
124+
return sha.slice(0, 12)
125+
}
126+
127+
function Metric({
128+
label,
129+
value,
130+
hint,
131+
tone = 'default',
132+
}: {
133+
label: string
134+
value: string
135+
hint?: string
136+
tone?: 'default' | 'warning' | 'success'
137+
}) {
138+
const toneClass = tone === 'warning'
139+
? 'text-sev-warning'
140+
: tone === 'success'
141+
? 'text-sev-suggestion'
142+
: 'text-text-primary'
143+
144+
return (
145+
<div>
146+
<div className="text-text-muted">{label}</div>
147+
<div className={`mt-0.5 ${toneClass}`}>{value}</div>
148+
{hint && <div className="text-text-muted mt-0.5">{hint}</div>}
149+
</div>
150+
)
151+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import { PrReadinessSummary } from '../PrReadinessSummary'
6+
import type { PrReadinessSnapshot, ReviewSummary } from '../../api/types'
7+
8+
function makeSummary(overrides: Partial<ReviewSummary> = {}): ReviewSummary {
9+
return {
10+
total_comments: 4,
11+
by_severity: { Warning: 2, Info: 2 },
12+
by_category: { Bug: 2, Style: 2 },
13+
critical_issues: 0,
14+
files_reviewed: 2,
15+
overall_score: 8.4,
16+
recommendations: [],
17+
open_comments: 2,
18+
open_by_severity: { Warning: 2 },
19+
open_blocking_comments: 2,
20+
open_informational_comments: 0,
21+
resolved_comments: 1,
22+
dismissed_comments: 1,
23+
open_blockers: 2,
24+
merge_readiness: 'NeedsAttention',
25+
verification: {
26+
state: 'Verified',
27+
judge_count: 2,
28+
required_votes: 2,
29+
warning_count: 0,
30+
filtered_comments: 0,
31+
abstained_comments: 0,
32+
},
33+
readiness_reasons: [],
34+
...overrides,
35+
}
36+
}
37+
38+
function makeSnapshot(overrides: Partial<PrReadinessSnapshot> = {}): PrReadinessSnapshot {
39+
return {
40+
repo: 'owner/repo',
41+
pr_number: 42,
42+
diff_source: 'pr:owner/repo#42',
43+
current_head_sha: '0123456789abcdef',
44+
latest_review: {
45+
id: 'review-12345678',
46+
status: 'Complete',
47+
started_at: 1,
48+
completed_at: 2,
49+
reviewed_head_sha: 'fedcba9876543210',
50+
summary: makeSummary(),
51+
files_reviewed: 2,
52+
comment_count: 4,
53+
},
54+
...overrides,
55+
}
56+
}
57+
58+
describe('PrReadinessSummary', () => {
59+
it('renders a no-review state when readiness is empty', () => {
60+
render(<PrReadinessSummary readiness={{ repo: 'owner/repo', pr_number: 42, diff_source: 'pr:owner/repo#42' }} />)
61+
62+
expect(screen.getByText('Latest DiffScope readiness')).toBeInTheDocument()
63+
expect(screen.getByText(/No DiffScope review has been recorded/i)).toBeInTheDocument()
64+
})
65+
66+
it('renders lifecycle-aware merge blockers for the latest review', () => {
67+
render(<PrReadinessSummary readiness={makeSnapshot()} />)
68+
69+
expect(screen.getByText('Needs Attention')).toBeInTheDocument()
70+
expect(screen.getByText('Open blockers')).toBeInTheDocument()
71+
expect(screen.getByText('2')).toBeInTheDocument()
72+
expect(screen.getByText('2 blocking findings remain open.')).toBeInTheDocument()
73+
expect(screen.getByText('0123456789ab')).toBeInTheDocument()
74+
expect(screen.getByText('fedcba987654')).toBeInTheDocument()
75+
})
76+
77+
it('opens the latest review when requested', async () => {
78+
const user = userEvent.setup()
79+
const onOpenReview = vi.fn()
80+
render(<PrReadinessSummary readiness={makeSnapshot()} onOpenReview={onOpenReview} />)
81+
82+
await user.click(screen.getByRole('button', { name: /Open latest review/i }))
83+
expect(onOpenReview).toHaveBeenCalledWith('review-12345678')
84+
})
85+
})

web/src/pages/Repos.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState, useEffect, useRef } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { ArrowLeft, Search, Lock, Star, GitPullRequest, Loader2, ChevronRight, RefreshCw, X, ExternalLink, Copy, Check, Webhook, Eye, EyeOff } from 'lucide-react'
4-
import { useGhStatus, useGhRepos, useGhPrs, useStartPrReview, useUpdateConfig, useConfig } from '../api/hooks'
4+
import { useGhStatus, useGhRepos, useGhPrReadiness, useGhPrs, useStartPrReview, useUpdateConfig, useConfig } from '../api/hooks'
55
import { api } from '../api/client'
66
import type { GhRepo, GhPullRequest, DeviceFlowResponse } from '../api/types'
7+
import { PrReadinessSummary } from '../components/PrReadinessSummary'
78

89
const LANG_COLORS: Record<string, string> = {
910
TypeScript: '#3178c6',
@@ -84,6 +85,11 @@ export function Repos() {
8485
selectedRepo?.full_name,
8586
prFilter,
8687
)
88+
const {
89+
data: prReadiness,
90+
isLoading: prReadinessLoading,
91+
error: prReadinessError,
92+
} = useGhPrReadiness(selectedRepo?.full_name, selectedPr?.number)
8793
const startPrReview = useStartPrReview()
8894

8995
// Debounce search
@@ -374,6 +380,13 @@ export function Repos() {
374380
)}
375381

376382
<div className="border-t border-border-subtle pt-4">
383+
<PrReadinessSummary
384+
readiness={prReadiness}
385+
isLoading={prReadinessLoading}
386+
error={prReadinessError}
387+
onOpenReview={(reviewId) => navigate(`/review/${reviewId}`)}
388+
/>
389+
377390
<div className="flex items-center justify-between mb-4">
378391
<div>
379392
<div className="text-[13px] text-text-primary">Post results to GitHub</div>

0 commit comments

Comments
 (0)