Skip to content

Commit 2af2dee

Browse files
committed
feat: add blocker-only review mode
1 parent 3e24b47 commit 2af2dee

File tree

3 files changed

+293
-24
lines changed

3 files changed

+293
-24
lines changed

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
8989

9090
51. [ ] Add visible accepted/rejected/dismissed badges to comments throughout the UI, not just icon state.
9191
52. [ ] Add comment grouping by unresolved, fixed, stale, and informational sections in `ReviewView`.
92-
53. [ ] Add a "show only blockers" mode for large reviews.
92+
53. [x] 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.
9595
56. [x] Add lifecycle-aware PR summaries that explain what still blocks merge.
@@ -162,4 +162,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
162162
- [x] Add PR readiness query surfaces in the CLI and HTTP API for non-UI workflows.
163163
- [x] Surface lifecycle-aware PR readiness summaries in the GitHub PR detail workflow.
164164
- [x] Surface unresolved blocker counts in repo and PR GitHub discovery views.
165+
- [x] Add a blocker-only review mode that narrows large reviews to open Error and Warning findings.
165166
- [ ] Commit and push each validated checkpoint before moving to the next epic.

web/src/pages/ReviewView.tsx

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,52 @@ import { ScoreGauge } from '../components/ScoreGauge'
88
import { SeverityBadge } from '../components/SeverityBadge'
99
import { CommentCard } from '../components/CommentCard'
1010
import { parseDiff } from '../lib/parseDiff'
11-
import type { CommentLifecycleStatus, MergeReadiness, Severity, ReviewEvent } from '../api/types'
11+
import type { Comment, CommentLifecycleStatus, MergeReadiness, Severity, ReviewEvent } from '../api/types'
1212

1313
type ViewMode = 'diff' | 'list'
1414

15+
const BLOCKING_SEVERITIES = new Set<Severity>(['Error', 'Warning'])
16+
17+
type ReviewCommentFilters = {
18+
severityFilter: Set<Severity>
19+
selectedFile?: string | null
20+
categoryFilter: string | null
21+
lifecycleFilter: CommentLifecycleStatus | 'All'
22+
blockerOnly?: boolean
23+
}
24+
25+
function normalizeCommentFilePath(filePath: string): string {
26+
return filePath.replace(/^\.\//, '')
27+
}
28+
29+
function isBlockingComment(comment: Pick<Comment, 'severity' | 'status'>): boolean {
30+
return (comment.status ?? 'Open') === 'Open' && BLOCKING_SEVERITIES.has(comment.severity)
31+
}
32+
33+
function filterReviewComments(
34+
comments: Comment[],
35+
{
36+
severityFilter,
37+
selectedFile = null,
38+
categoryFilter,
39+
lifecycleFilter,
40+
blockerOnly = false,
41+
}: ReviewCommentFilters,
42+
): Comment[] {
43+
return comments.filter((comment) => {
44+
if (selectedFile && normalizeCommentFilePath(comment.file_path) !== selectedFile) return false
45+
if (categoryFilter && comment.category !== categoryFilter) return false
46+
47+
if (blockerOnly) {
48+
return isBlockingComment(comment)
49+
}
50+
51+
if (!severityFilter.has(comment.severity)) return false
52+
if (lifecycleFilter !== 'All' && (comment.status ?? 'Open') !== lifecycleFilter) return false
53+
return true
54+
})
55+
}
56+
1557
export function ReviewView() {
1658
const { id } = useParams<{ id: string }>()
1759
const { data: review, isLoading } = useReview(id)
@@ -22,14 +64,45 @@ export function ReviewView() {
2264
const [severityFilter, setSeverityFilter] = useState<Set<Severity>>(new Set(['Error', 'Warning', 'Info', 'Suggestion']))
2365
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
2466
const [lifecycleFilter, setLifecycleFilter] = useState<CommentLifecycleStatus | 'All'>('All')
67+
const [showOnlyBlockers, setShowOnlyBlockers] = useState(false)
2568
const [showEvent, setShowEvent] = useState(false)
2669
const diffContent = review?.diff_content
70+
const comments = useMemo(() => review?.comments ?? [], [review?.comments])
2771

2872
const diffFiles = useMemo(() => {
2973
if (!diffContent) return []
3074
return parseDiff(diffContent)
3175
}, [diffContent])
3276

77+
const blockerCount = useMemo(() => (
78+
review?.summary?.open_blocking_comments ?? comments.filter(isBlockingComment).length
79+
), [comments, review?.summary?.open_blocking_comments])
80+
81+
const blockerFilteredComments = useMemo(() => filterReviewComments(comments, {
82+
severityFilter,
83+
categoryFilter,
84+
lifecycleFilter,
85+
blockerOnly: showOnlyBlockers,
86+
}), [comments, severityFilter, categoryFilter, lifecycleFilter, showOnlyBlockers])
87+
88+
const filteredDiffFiles = useMemo(() => {
89+
if (!showOnlyBlockers) return diffFiles
90+
const blockerFiles = new Set(blockerFilteredComments.map((comment) => normalizeCommentFilePath(comment.file_path)))
91+
return diffFiles.filter((file) => blockerFiles.has(file.path))
92+
}, [diffFiles, blockerFilteredComments, showOnlyBlockers])
93+
94+
const activeSelectedFile = selectedFile && filteredDiffFiles.some((file) => file.path === selectedFile)
95+
? selectedFile
96+
: null
97+
98+
const filteredComments = useMemo(() => filterReviewComments(comments, {
99+
severityFilter,
100+
selectedFile: activeSelectedFile,
101+
categoryFilter,
102+
lifecycleFilter,
103+
blockerOnly: showOnlyBlockers,
104+
}), [comments, severityFilter, activeSelectedFile, categoryFilter, lifecycleFilter, showOnlyBlockers])
105+
33106
// All hooks MUST be above this line — no hooks after early returns
34107

35108
if (isLoading || !review) {
@@ -103,15 +176,7 @@ export function ReviewView() {
103176
setSeverityFilter(next)
104177
}
105178

106-
const filteredComments = review.comments.filter((c) => {
107-
if (!severityFilter.has(c.severity)) return false
108-
if (selectedFile && c.file_path.replace(/^\.\//, '') !== selectedFile) return false
109-
if (categoryFilter && c.category !== categoryFilter) return false
110-
if (lifecycleFilter !== 'All' && (c.status ?? 'Open') !== lifecycleFilter) return false
111-
return true
112-
})
113-
114-
const categories = [...new Set(review.comments.map(c => c.category))]
179+
const categories = [...new Set(comments.map(c => c.category))]
115180

116181
const handleFeedback = (commentId: string, action: 'accept' | 'reject') => {
117182
feedback.mutate({ commentId, action })
@@ -129,9 +194,9 @@ export function ReviewView() {
129194
groupedByFile.get(key)!.push(c)
130195
}
131196

132-
const visibleDiffFiles = selectedFile
133-
? diffFiles.filter(f => f.path === selectedFile)
134-
: diffFiles
197+
const visibleDiffFiles = activeSelectedFile
198+
? filteredDiffFiles.filter(f => f.path === activeSelectedFile)
199+
: filteredDiffFiles
135200

136201
const readinessStyles: Record<MergeReadiness, string> = {
137202
Ready: 'bg-sev-suggestion/10 text-sev-suggestion border border-sev-suggestion/20',
@@ -151,11 +216,11 @@ export function ReviewView() {
151216
return (
152217
<div className="flex h-full">
153218
{/* File sidebar */}
154-
{diffFiles.length > 0 && (
219+
{filteredDiffFiles.length > 0 && (
155220
<FileSidebar
156-
files={diffFiles}
157-
comments={review.comments}
158-
selectedFile={selectedFile}
221+
files={filteredDiffFiles}
222+
comments={showOnlyBlockers ? blockerFilteredComments : review.comments}
223+
selectedFile={activeSelectedFile}
159224
onSelectFile={setSelectedFile}
160225
/>
161226
)}
@@ -316,8 +381,11 @@ export function ReviewView() {
316381
<button
317382
key={sev}
318383
onClick={() => toggleSeverity(sev)}
384+
disabled={showOnlyBlockers}
319385
className={`text-[11px] px-2 py-0.5 rounded transition-colors ${
320-
severityFilter.has(sev)
386+
showOnlyBlockers
387+
? 'text-text-muted/40 cursor-not-allowed'
388+
: severityFilter.has(sev)
321389
? 'bg-surface-3 text-text-primary'
322390
: 'text-text-muted/40 hover:text-text-muted'
323391
}`}
@@ -347,7 +415,8 @@ export function ReviewView() {
347415
<select
348416
value={lifecycleFilter}
349417
onChange={e => setLifecycleFilter(e.target.value as CommentLifecycleStatus | 'All')}
350-
className="text-[11px] bg-surface-2 border border-border rounded px-2 py-1 text-text-secondary appearance-none pr-6 cursor-pointer"
418+
disabled={showOnlyBlockers}
419+
className={`text-[11px] bg-surface-2 border border-border rounded px-2 py-1 text-text-secondary appearance-none pr-6 ${showOnlyBlockers ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
351420
>
352421
<option value="All">All statuses</option>
353422
<option value="Open">Open</option>
@@ -357,14 +426,31 @@ export function ReviewView() {
357426
<ChevronDown size={10} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
358427
</div>
359428

429+
<button
430+
onClick={() => setShowOnlyBlockers(value => !value)}
431+
className={`text-[11px] px-2 py-1 rounded border transition-colors ${
432+
showOnlyBlockers
433+
? 'bg-sev-warning/10 text-sev-warning border-sev-warning/20'
434+
: 'bg-surface-2 text-text-muted border-border hover:text-text-primary'
435+
}`}
436+
title="Show only open Error and Warning findings"
437+
>
438+
Blockers only
439+
<span className="ml-1 font-code">{blockerCount}</span>
440+
</button>
441+
442+
{showOnlyBlockers && (
443+
<span className="text-[10px] text-sev-warning font-code">Open Error + Warning</span>
444+
)}
445+
360446
<span className="ml-auto text-[11px] text-text-muted">
361447
{filteredComments.length}/{review.comments.length}
362448
</span>
363449
</div>
364450

365451
{/* Main content */}
366452
<div className="flex-1 overflow-auto p-4">
367-
{viewMode === 'diff' && diffFiles.length > 0 ? (
453+
{viewMode === 'diff' && visibleDiffFiles.length > 0 ? (
368454
<DiffViewer
369455
files={visibleDiffFiles}
370456
comments={filteredComments}
@@ -398,9 +484,13 @@ export function ReviewView() {
398484

399485
{filteredComments.length === 0 && (
400486
<div className="text-center py-16 text-text-muted">
401-
{review.comments.length === 0
402-
? 'No findings. Code looks good!'
403-
: 'No findings match the current filters.'}
487+
{showOnlyBlockers
488+
? blockerCount === 0
489+
? 'No open blockers remain in this review.'
490+
: 'No blockers match the current filters.'
491+
: review.comments.length === 0
492+
? 'No findings. Code looks good!'
493+
: 'No findings match the current filters.'}
404494
</div>
405495
)}
406496
</div>

0 commit comments

Comments
 (0)