Skip to content

Commit 3e24b47

Browse files
committed
feat: show blocker counts in GitHub discovery
1 parent 82815eb commit 3e24b47

File tree

5 files changed

+242
-8
lines changed

5 files changed

+242
-8
lines changed

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
103103
61. [x] Add feedback coverage metrics: percent of findings with thumbs or explicit disposition.
104104
62. [x] Add acceptance/rejection trend lines over time for recent reviews.
105105
63. [x] Add top accepted categories/rules and top rejected categories/rules to Analytics.
106-
64. [ ] Add unresolved blocker counts per repository and per PR.
106+
64. [x] Add unresolved blocker counts per repository and per PR.
107107
65. [ ] Add review completeness and mean-time-to-resolution charts.
108108
66. [ ] Add feedback-learning effectiveness metrics: did reranked findings get higher acceptance after rollout?
109109
67. [ ] Add pattern-repository utilization analytics showing when extra context actually affected findings.
@@ -161,4 +161,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
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.
163163
- [x] Surface lifecycle-aware PR readiness summaries in the GitHub PR detail workflow.
164+
- [x] Surface unresolved blocker counts in repo and PR GitHub discovery views.
164165
- [ ] Commit and push each validated checkpoint before moving to the next epic.

src/server/api.rs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ use std::sync::Arc;
99
use uuid::Uuid;
1010

1111
use super::pr_readiness::{
12-
apply_dynamic_review_state, get_pr_readiness_snapshot, latest_review_head_by_source,
13-
load_review_inventory, PrReadinessSnapshot,
12+
apply_dynamic_review_state, build_pr_readiness_snapshot, build_repo_blocker_rollups,
13+
get_pr_readiness_snapshot, latest_review_head_by_source, load_review_inventory,
14+
PrReadinessSnapshot,
1415
};
1516
use super::state::{
1617
build_progress_callback, count_diff_files, count_reviewed_files, current_timestamp,
1718
emit_wide_event, AppState, FileMetricEvent, HotspotDetail, ReviewEventBuilder, ReviewListItem,
1819
ReviewSession, ReviewStatus, MAX_DIFF_SIZE,
1920
};
20-
use crate::core::comment::CommentSynthesizer;
21+
use crate::core::comment::{CommentSynthesizer, MergeReadiness};
2122
use crate::core::convention_learner::ConventionStore;
2223
use tracing::{info, warn};
2324

@@ -1580,6 +1581,10 @@ pub struct GhRepo {
15801581
pub language: Option<String>,
15811582
pub updated_at: String,
15821583
pub open_prs: usize,
1584+
#[serde(default, skip_serializing_if = "Option::is_none")]
1585+
pub open_blockers: Option<usize>,
1586+
#[serde(default, skip_serializing_if = "Option::is_none")]
1587+
pub blocking_prs: Option<usize>,
15831588
pub default_branch: String,
15841589
pub stargazers_count: u32,
15851590
pub private: bool,
@@ -1649,6 +1654,9 @@ pub async fn get_gh_repos(
16491654
.cloned()
16501655
.unwrap_or_default();
16511656

1657+
let inventory = load_review_inventory(&state).await;
1658+
let blocker_rollups = build_repo_blocker_rollups(&inventory);
1659+
16521660
let repos: Vec<GhRepo> = items
16531661
.into_iter()
16541662
.map(|item| GhRepo {
@@ -1671,6 +1679,16 @@ pub async fn get_gh_repos(
16711679
.unwrap_or("")
16721680
.to_string(),
16731681
open_prs: 0,
1682+
open_blockers: item
1683+
.get("full_name")
1684+
.and_then(|v| v.as_str())
1685+
.and_then(|repo| blocker_rollups.get(repo))
1686+
.map(|rollup| rollup.open_blockers),
1687+
blocking_prs: item
1688+
.get("full_name")
1689+
.and_then(|v| v.as_str())
1690+
.and_then(|repo| blocker_rollups.get(repo))
1691+
.map(|rollup| rollup.blocking_prs),
16741692
default_branch: item
16751693
.get("default_branch")
16761694
.and_then(|v| v.as_str())
@@ -1714,6 +1732,9 @@ pub async fn get_gh_repos(
17141732
)
17151733
})?;
17161734

1735+
let inventory = load_review_inventory(&state).await;
1736+
let blocker_rollups = build_repo_blocker_rollups(&inventory);
1737+
17171738
let repos: Vec<GhRepo> = items
17181739
.into_iter()
17191740
.map(|item| GhRepo {
@@ -1736,6 +1757,16 @@ pub async fn get_gh_repos(
17361757
.unwrap_or("")
17371758
.to_string(),
17381759
open_prs: 0,
1760+
open_blockers: item
1761+
.get("full_name")
1762+
.and_then(|v| v.as_str())
1763+
.and_then(|repo| blocker_rollups.get(repo))
1764+
.map(|rollup| rollup.open_blockers),
1765+
blocking_prs: item
1766+
.get("full_name")
1767+
.and_then(|v| v.as_str())
1768+
.and_then(|repo| blocker_rollups.get(repo))
1769+
.map(|rollup| rollup.blocking_prs),
17391770
default_branch: item
17401771
.get("default_branch")
17411772
.and_then(|v| v.as_str())
@@ -1821,6 +1852,10 @@ pub struct GhPullRequest {
18211852
pub base_branch: String,
18221853
pub labels: Vec<String>,
18231854
pub draft: bool,
1855+
#[serde(default, skip_serializing_if = "Option::is_none")]
1856+
pub open_blockers: Option<usize>,
1857+
#[serde(default, skip_serializing_if = "Option::is_none")]
1858+
pub merge_readiness: Option<MergeReadiness>,
18241859
}
18251860

18261861
/// Regex for validating repo names: owner/repo
@@ -1896,6 +1931,8 @@ pub async fn get_gh_prs(
18961931
)
18971932
})?;
18981933

1934+
let inventory = load_review_inventory(&state).await;
1935+
18991936
let prs: Vec<GhPullRequest> = items
19001937
.into_iter()
19011938
.filter(|item| {
@@ -1930,8 +1967,20 @@ pub async fn get_gh_prs(
19301967
.to_string()
19311968
};
19321969

1970+
let pr_number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
1971+
let current_head_sha = item
1972+
.get("head")
1973+
.and_then(|v| v.get("sha"))
1974+
.and_then(|v| v.as_str());
1975+
let readiness_snapshot =
1976+
build_pr_readiness_snapshot(&inventory, &params.repo, pr_number, current_head_sha);
1977+
let latest_summary = readiness_snapshot
1978+
.latest_review
1979+
.as_ref()
1980+
.and_then(|review| review.summary.as_ref());
1981+
19331982
GhPullRequest {
1934-
number: item.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
1983+
number: pr_number,
19351984
title: item
19361985
.get("title")
19371986
.and_then(|v| v.as_str())
@@ -1971,6 +2020,8 @@ pub async fn get_gh_prs(
19712020
.to_string(),
19722021
labels,
19732022
draft: item.get("draft").and_then(|v| v.as_bool()).unwrap_or(false),
2023+
open_blockers: latest_summary.map(|summary| summary.open_blockers),
2024+
merge_readiness: latest_summary.map(|summary| summary.merge_readiness),
19742025
}
19752026
})
19762027
.collect();
@@ -3092,6 +3143,8 @@ mod tests {
30923143
language: Some("Rust".to_string()),
30933144
updated_at: "2024-01-01T00:00:00Z".to_string(),
30943145
open_prs: 5,
3146+
open_blockers: Some(3),
3147+
blocking_prs: Some(2),
30953148
default_branch: "main".to_string(),
30963149
stargazers_count: 42,
30973150
private: false,
@@ -3100,6 +3153,8 @@ mod tests {
31003153
assert_eq!(json["full_name"], "owner/repo");
31013154
assert_eq!(json["language"], "Rust");
31023155
assert_eq!(json["open_prs"], 5);
3156+
assert_eq!(json["open_blockers"], 3);
3157+
assert_eq!(json["blocking_prs"], 2);
31033158
assert_eq!(json["stargazers_count"], 42);
31043159
assert_eq!(json["private"], false);
31053160
}
@@ -3120,12 +3175,16 @@ mod tests {
31203175
base_branch: "main".to_string(),
31213176
labels: vec!["bugfix".to_string()],
31223177
draft: false,
3178+
open_blockers: Some(2),
3179+
merge_readiness: Some(MergeReadiness::NeedsAttention),
31233180
};
31243181
let json = serde_json::to_value(&pr).unwrap();
31253182
assert_eq!(json["number"], 42);
31263183
assert_eq!(json["title"], "Fix bug");
31273184
assert_eq!(json["author"], "dev");
31283185
assert_eq!(json["draft"], false);
3186+
assert_eq!(json["open_blockers"], 2);
3187+
assert_eq!(json["merge_readiness"], "NeedsAttention");
31293188
assert_eq!(json["labels"].as_array().unwrap().len(), 1);
31303189
}
31313190

src/server/pr_readiness.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ pub struct PrReadinessReview {
4141
pub error: Option<String>,
4242
}
4343

44+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
45+
pub struct RepoBlockerRollup {
46+
pub open_blockers: usize,
47+
pub blocking_prs: usize,
48+
}
49+
4450
impl PrReadinessReview {
4551
fn from_session(session: &ReviewSession) -> Self {
4652
Self {
@@ -61,6 +67,12 @@ pub(crate) fn pr_diff_source(repo: &str, pr_number: u32) -> String {
6167
format!("pr:{repo}#{pr_number}")
6268
}
6369

70+
pub(crate) fn parse_pr_diff_source(diff_source: &str) -> Option<(String, u32)> {
71+
let rest = diff_source.strip_prefix("pr:")?;
72+
let (repo, pr_number) = rest.rsplit_once('#')?;
73+
Some((repo.to_string(), pr_number.parse().ok()?))
74+
}
75+
6476
pub(crate) async fn load_review_inventory(state: &Arc<AppState>) -> Vec<ReviewSession> {
6577
let mut sessions: Vec<ReviewSession> = {
6678
let reviews = state.reviews.read().await;
@@ -132,6 +144,47 @@ pub(crate) fn apply_dynamic_review_state(
132144
session
133145
}
134146

147+
fn latest_summarized_reviews_by_source(
148+
reviews: &[ReviewSession],
149+
) -> HashMap<String, ReviewSession> {
150+
let mut latest: HashMap<String, ReviewSession> = HashMap::new();
151+
for review in reviews {
152+
if review.summary.is_none() || !review.diff_source.starts_with("pr:") {
153+
continue;
154+
}
155+
match latest.get(&review.diff_source) {
156+
Some(current) if current.started_at >= review.started_at => {}
157+
_ => {
158+
latest.insert(review.diff_source.clone(), review.clone());
159+
}
160+
}
161+
}
162+
latest
163+
}
164+
165+
pub(crate) fn build_repo_blocker_rollups(
166+
reviews: &[ReviewSession],
167+
) -> HashMap<String, RepoBlockerRollup> {
168+
let mut rollups = HashMap::new();
169+
for review in latest_summarized_reviews_by_source(reviews).into_values() {
170+
let Some(summary) = review.summary.as_ref() else {
171+
continue;
172+
};
173+
let Some((repo, _)) = parse_pr_diff_source(&review.diff_source) else {
174+
continue;
175+
};
176+
177+
let rollup = rollups
178+
.entry(repo)
179+
.or_insert_with(RepoBlockerRollup::default);
180+
rollup.open_blockers += summary.open_blockers;
181+
if summary.open_blockers > 0 {
182+
rollup.blocking_prs += 1;
183+
}
184+
}
185+
rollups
186+
}
187+
135188
pub(crate) fn build_pr_readiness_snapshot(
136189
reviews: &[ReviewSession],
137190
repo: &str,
@@ -308,4 +361,87 @@ mod tests {
308361
crate::core::comment::MergeReadiness::NeedsReReview
309362
);
310363
}
364+
365+
#[test]
366+
fn repo_blocker_rollups_use_latest_review_per_pr() {
367+
let older_pr = make_pr_review_session(
368+
"r1",
369+
10,
370+
"sha-a",
371+
vec![make_comment("c1", Severity::Warning, CommentStatus::Open)],
372+
);
373+
let newer_same_pr = ReviewSession {
374+
id: "r2".to_string(),
375+
status: ReviewStatus::Complete,
376+
diff_source: "pr:owner/repo#42".to_string(),
377+
github_head_sha: Some("sha-b".to_string()),
378+
started_at: 20,
379+
completed_at: Some(21),
380+
summary: Some(CommentSynthesizer::generate_summary(&[])),
381+
files_reviewed: 1,
382+
comments: Vec::new(),
383+
error: None,
384+
pr_summary_text: None,
385+
diff_content: None,
386+
event: None,
387+
progress: None,
388+
};
389+
let other_pr = ReviewSession {
390+
id: "r3".to_string(),
391+
status: ReviewStatus::Complete,
392+
diff_source: "pr:owner/repo#43".to_string(),
393+
github_head_sha: Some("sha-c".to_string()),
394+
started_at: 15,
395+
completed_at: Some(16),
396+
summary: Some(CommentSynthesizer::generate_summary(&vec![make_comment(
397+
"c2",
398+
Severity::Error,
399+
CommentStatus::Open,
400+
)])),
401+
files_reviewed: 1,
402+
comments: vec![make_comment("c2", Severity::Error, CommentStatus::Open)],
403+
error: None,
404+
pr_summary_text: None,
405+
diff_content: None,
406+
event: None,
407+
progress: None,
408+
};
409+
let other_repo = ReviewSession {
410+
id: "r4".to_string(),
411+
status: ReviewStatus::Complete,
412+
diff_source: "pr:other/repo#7".to_string(),
413+
github_head_sha: Some("sha-d".to_string()),
414+
started_at: 12,
415+
completed_at: Some(13),
416+
summary: Some(CommentSynthesizer::generate_summary(&vec![make_comment(
417+
"c3",
418+
Severity::Warning,
419+
CommentStatus::Open,
420+
)])),
421+
files_reviewed: 1,
422+
comments: vec![make_comment("c3", Severity::Warning, CommentStatus::Open)],
423+
error: None,
424+
pr_summary_text: None,
425+
diff_content: None,
426+
event: None,
427+
progress: None,
428+
};
429+
430+
let rollups = build_repo_blocker_rollups(&[older_pr, newer_same_pr, other_pr, other_repo]);
431+
432+
assert_eq!(
433+
rollups.get("owner/repo"),
434+
Some(&RepoBlockerRollup {
435+
open_blockers: 1,
436+
blocking_prs: 1,
437+
})
438+
);
439+
assert_eq!(
440+
rollups.get("other/repo"),
441+
Some(&RepoBlockerRollup {
442+
open_blockers: 1,
443+
blocking_prs: 1,
444+
})
445+
);
446+
}
311447
}

web/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ export interface GhRepo {
356356
language: string | null
357357
updated_at: string
358358
open_prs: number
359+
open_blockers?: number
360+
blocking_prs?: number
359361
default_branch: string
360362
stargazers_count: number
361363
private: boolean
@@ -375,6 +377,8 @@ export interface GhPullRequest {
375377
base_branch: string
376378
labels: string[]
377379
draft: boolean
380+
open_blockers?: number
381+
merge_readiness?: MergeReadiness
378382
}
379383

380384
export interface PrReadinessReview {

0 commit comments

Comments
 (0)