Skip to content

Commit 4c67b02

Browse files
committed
feat: add PR comment lifecycle search API
1 parent 719cecb commit 4c67b02

File tree

4 files changed

+253
-9
lines changed

4 files changed

+253
-9
lines changed

TODO.md

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

114114
## 8. APIs, Automation, and MCP-Like Surfaces
115115

116-
71. [ ] Expose unresolved/resolved comment search through the HTTP API.
116+
71. [x] Expose unresolved/resolved comment search through the HTTP API.
117117
72. [x] Expose PR readiness through the HTTP API for CI and agent integrations.
118118
73. [ ] Add API endpoints to fetch learned rules, attention gaps, and top rejected patterns.
119119
74. [ ] Add machine-friendly APIs to fetch findings grouped by severity, file, and lifecycle state.
@@ -167,4 +167,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
167167
- [x] Add visible feedback badges on comments so accepted and rejected states are not icon-only.
168168
- [x] Add a train-the-reviewer callout on review detail when thumbs coverage is low.
169169
- [x] Add structured custom context and per-path instruction editors to the Settings review context workflow.
170+
- [x] Expose latest-review PR comment search with unresolved, resolved, and dismissed lifecycle filters through the API.
170171
- [ ] Commit and push each validated checkpoint before moving to the next epic.

src/server/api.rs

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ use uuid::Uuid;
1010

1111
use super::pr_readiness::{
1212
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,
13+
get_pr_readiness_snapshot, latest_pr_review_session, latest_review_head_by_source,
14+
load_review_inventory, pr_diff_source, PrReadinessSnapshot,
1515
};
1616
use super::state::{
1717
build_progress_callback, count_diff_files, count_reviewed_files, current_timestamp,
1818
emit_wide_event, AppState, FileMetricEvent, HotspotDetail, ReviewEventBuilder, ReviewListItem,
1919
ReviewSession, ReviewStatus, MAX_DIFF_SIZE,
2020
};
21-
use crate::core::comment::{CommentSynthesizer, MergeReadiness};
21+
use crate::core::comment::{CommentStatus, CommentSynthesizer, MergeReadiness};
2222
use crate::core::convention_learner::ConventionStore;
2323
use tracing::{info, warn};
2424

@@ -1837,6 +1837,79 @@ pub struct PrReadinessParams {
18371837
pub pr_number: u32,
18381838
}
18391839

1840+
#[derive(Deserialize)]
1841+
pub struct PrCommentSearchParams {
1842+
pub repo: String,
1843+
pub pr_number: u32,
1844+
pub status: Option<String>,
1845+
}
1846+
1847+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1848+
enum CommentSearchFilter {
1849+
All,
1850+
Unresolved,
1851+
Resolved,
1852+
Dismissed,
1853+
}
1854+
1855+
impl CommentSearchFilter {
1856+
fn from_api_value(value: Option<&str>) -> Option<Self> {
1857+
match value.map(|value| value.trim().to_ascii_lowercase()) {
1858+
None => Some(Self::All),
1859+
Some(value) if value.is_empty() || value == "all" => Some(Self::All),
1860+
Some(value) if value == "open" || value == "unresolved" => Some(Self::Unresolved),
1861+
Some(value) if value == "resolved" => Some(Self::Resolved),
1862+
Some(value) if value == "dismissed" => Some(Self::Dismissed),
1863+
_ => None,
1864+
}
1865+
}
1866+
1867+
fn as_api_str(self) -> &'static str {
1868+
match self {
1869+
Self::All => "all",
1870+
Self::Unresolved => "unresolved",
1871+
Self::Resolved => "resolved",
1872+
Self::Dismissed => "dismissed",
1873+
}
1874+
}
1875+
1876+
fn matches(self, status: CommentStatus) -> bool {
1877+
match self {
1878+
Self::All => true,
1879+
Self::Unresolved => status == CommentStatus::Open,
1880+
Self::Resolved => status == CommentStatus::Resolved,
1881+
Self::Dismissed => status == CommentStatus::Dismissed,
1882+
}
1883+
}
1884+
}
1885+
1886+
#[derive(Serialize)]
1887+
pub struct PrCommentSearchResponse {
1888+
pub repo: String,
1889+
pub pr_number: u32,
1890+
pub diff_source: String,
1891+
pub status: String,
1892+
#[serde(default, skip_serializing_if = "Option::is_none")]
1893+
pub latest_review_id: Option<String>,
1894+
#[serde(default, skip_serializing_if = "Option::is_none")]
1895+
pub latest_review_status: Option<ReviewStatus>,
1896+
#[serde(default)]
1897+
pub total_comments: usize,
1898+
#[serde(default)]
1899+
pub comments: Vec<crate::core::Comment>,
1900+
}
1901+
1902+
fn filter_comments_by_search_filter(
1903+
comments: &[crate::core::Comment],
1904+
filter: CommentSearchFilter,
1905+
) -> Vec<crate::core::Comment> {
1906+
comments
1907+
.iter()
1908+
.filter(|comment| filter.matches(comment.status))
1909+
.cloned()
1910+
.collect()
1911+
}
1912+
18401913
#[derive(Serialize)]
18411914
pub struct GhPullRequest {
18421915
pub number: u32,
@@ -2081,6 +2154,49 @@ pub async fn get_gh_pr_readiness(
20812154
))
20822155
}
20832156

2157+
pub async fn get_gh_pr_comments(
2158+
State(state): State<Arc<AppState>>,
2159+
Query(params): Query<PrCommentSearchParams>,
2160+
) -> Result<Json<PrCommentSearchResponse>, (StatusCode, String)> {
2161+
if !is_valid_repo_name(&params.repo) {
2162+
return Err((
2163+
StatusCode::BAD_REQUEST,
2164+
"Invalid repo format. Expected 'owner/repo'.".to_string(),
2165+
));
2166+
}
2167+
2168+
if params.pr_number == 0 || params.pr_number > 999_999_999 {
2169+
return Err((StatusCode::BAD_REQUEST, "Invalid PR number.".to_string()));
2170+
}
2171+
2172+
let filter =
2173+
CommentSearchFilter::from_api_value(params.status.as_deref()).ok_or_else(|| {
2174+
(
2175+
StatusCode::BAD_REQUEST,
2176+
"Invalid status. Must be all, unresolved, open, resolved, or dismissed."
2177+
.to_string(),
2178+
)
2179+
})?;
2180+
2181+
let inventory = load_review_inventory(&state).await;
2182+
let latest_review = latest_pr_review_session(&inventory, &params.repo, params.pr_number);
2183+
let comments = latest_review
2184+
.as_ref()
2185+
.map(|review| filter_comments_by_search_filter(&review.comments, filter))
2186+
.unwrap_or_default();
2187+
2188+
Ok(Json(PrCommentSearchResponse {
2189+
repo: params.repo.clone(),
2190+
pr_number: params.pr_number,
2191+
diff_source: pr_diff_source(&params.repo, params.pr_number),
2192+
status: filter.as_api_str().to_string(),
2193+
latest_review_id: latest_review.as_ref().map(|review| review.id.clone()),
2194+
latest_review_status: latest_review.as_ref().map(|review| review.status.clone()),
2195+
total_comments: comments.len(),
2196+
comments,
2197+
}))
2198+
}
2199+
20842200
// === GitHub PR Review ===
20852201

20862202
#[derive(Deserialize)]
@@ -3211,4 +3327,86 @@ mod tests {
32113327
assert_eq!(req.pr_number, 42);
32123328
assert!(req.post_results);
32133329
}
3330+
3331+
fn make_search_comment(id: &str, status: CommentStatus) -> crate::core::Comment {
3332+
crate::core::Comment {
3333+
id: id.to_string(),
3334+
file_path: std::path::PathBuf::from("src/lib.rs"),
3335+
line_number: 10,
3336+
content: format!("comment {id}"),
3337+
rule_id: None,
3338+
severity: crate::core::comment::Severity::Warning,
3339+
category: crate::core::comment::Category::Bug,
3340+
suggestion: None,
3341+
confidence: 0.8,
3342+
code_suggestion: None,
3343+
tags: Vec::new(),
3344+
fix_effort: crate::core::comment::FixEffort::Low,
3345+
feedback: None,
3346+
status,
3347+
}
3348+
}
3349+
3350+
#[test]
3351+
fn test_comment_search_filter_parses_aliases() {
3352+
assert_eq!(
3353+
CommentSearchFilter::from_api_value(None),
3354+
Some(CommentSearchFilter::All)
3355+
);
3356+
assert_eq!(
3357+
CommentSearchFilter::from_api_value(Some("open")),
3358+
Some(CommentSearchFilter::Unresolved)
3359+
);
3360+
assert_eq!(
3361+
CommentSearchFilter::from_api_value(Some("unresolved")),
3362+
Some(CommentSearchFilter::Unresolved)
3363+
);
3364+
assert_eq!(
3365+
CommentSearchFilter::from_api_value(Some("resolved")),
3366+
Some(CommentSearchFilter::Resolved)
3367+
);
3368+
assert_eq!(
3369+
CommentSearchFilter::from_api_value(Some("dismissed")),
3370+
Some(CommentSearchFilter::Dismissed)
3371+
);
3372+
assert_eq!(CommentSearchFilter::from_api_value(Some("wat")), None);
3373+
}
3374+
3375+
#[test]
3376+
fn test_filter_comments_by_search_filter_matches_status() {
3377+
let comments = vec![
3378+
make_search_comment("open", CommentStatus::Open),
3379+
make_search_comment("resolved", CommentStatus::Resolved),
3380+
make_search_comment("dismissed", CommentStatus::Dismissed),
3381+
];
3382+
3383+
assert_eq!(
3384+
filter_comments_by_search_filter(&comments, CommentSearchFilter::All)
3385+
.into_iter()
3386+
.map(|comment| comment.id)
3387+
.collect::<Vec<_>>(),
3388+
vec!["open", "resolved", "dismissed"]
3389+
);
3390+
assert_eq!(
3391+
filter_comments_by_search_filter(&comments, CommentSearchFilter::Unresolved)
3392+
.into_iter()
3393+
.map(|comment| comment.id)
3394+
.collect::<Vec<_>>(),
3395+
vec!["open"]
3396+
);
3397+
assert_eq!(
3398+
filter_comments_by_search_filter(&comments, CommentSearchFilter::Resolved)
3399+
.into_iter()
3400+
.map(|comment| comment.id)
3401+
.collect::<Vec<_>>(),
3402+
vec!["resolved"]
3403+
);
3404+
assert_eq!(
3405+
filter_comments_by_search_filter(&comments, CommentSearchFilter::Dismissed)
3406+
.into_iter()
3407+
.map(|comment| comment.id)
3408+
.collect::<Vec<_>>(),
3409+
vec!["dismissed"]
3410+
);
3411+
}
32143412
}

src/server/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ pub async fn start_server(config: Config, host: &str, port: u16) -> anyhow::Resu
117117
.route("/gh/repos", get(api::get_gh_repos))
118118
.route("/gh/prs", get(api::get_gh_prs))
119119
.route("/gh/pr-readiness", get(api::get_gh_pr_readiness))
120+
.route("/gh/pr-comments", get(api::get_gh_pr_comments))
120121
.route("/gh/review", post(api::start_pr_review))
121122
.route("/agent/tools", get(api::get_agent_tools))
122123
.route("/gh/auth/device", post(github::start_device_flow))

src/server/pr_readiness.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ fn latest_summarized_reviews_by_source(
162162
latest
163163
}
164164

165+
pub(crate) fn latest_pr_review_session(
166+
reviews: &[ReviewSession],
167+
repo: &str,
168+
pr_number: u32,
169+
) -> Option<ReviewSession> {
170+
let diff_source = pr_diff_source(repo, pr_number);
171+
172+
reviews
173+
.iter()
174+
.filter(|session| session.diff_source == diff_source && session.summary.is_some())
175+
.max_by_key(|session| (session.started_at, session.completed_at.unwrap_or_default()))
176+
.cloned()
177+
}
178+
165179
pub(crate) fn build_repo_blocker_rollups(
166180
reviews: &[ReviewSession],
167181
) -> HashMap<String, RepoBlockerRollup> {
@@ -193,11 +207,7 @@ pub(crate) fn build_pr_readiness_snapshot(
193207
) -> PrReadinessSnapshot {
194208
let diff_source = pr_diff_source(repo, pr_number);
195209
let latest_by_source = latest_review_head_by_source(reviews);
196-
let latest_review = reviews
197-
.iter()
198-
.filter(|session| session.diff_source == diff_source && session.summary.is_some())
199-
.max_by_key(|session| (session.started_at, session.completed_at.unwrap_or_default()))
200-
.cloned()
210+
let latest_review = latest_pr_review_session(reviews, repo, pr_number)
201211
.map(|session| apply_dynamic_review_state(session, &latest_by_source, current_head_sha))
202212
.map(|session| PrReadinessReview::from_session(&session));
203213

@@ -362,6 +372,40 @@ mod tests {
362372
);
363373
}
364374

375+
#[test]
376+
fn latest_pr_review_session_ignores_newer_failed_reviews() {
377+
let older_complete = make_pr_review_session(
378+
"r1",
379+
10,
380+
"sha-a",
381+
vec![make_comment("c1", Severity::Warning, CommentStatus::Open)],
382+
);
383+
let newer_failed = ReviewSession {
384+
id: "r2".to_string(),
385+
status: ReviewStatus::Failed,
386+
diff_source: "pr:owner/repo#42".to_string(),
387+
github_head_sha: Some("sha-b".to_string()),
388+
started_at: 20,
389+
completed_at: Some(21),
390+
summary: None,
391+
files_reviewed: 0,
392+
comments: Vec::new(),
393+
error: Some("boom".to_string()),
394+
pr_summary_text: None,
395+
diff_content: None,
396+
event: None,
397+
progress: None,
398+
};
399+
400+
let latest_review =
401+
latest_pr_review_session(&[older_complete, newer_failed], "owner/repo", 42)
402+
.expect("latest completed review");
403+
404+
assert_eq!(latest_review.id, "r1");
405+
assert_eq!(latest_review.status, ReviewStatus::Complete);
406+
assert_eq!(latest_review.comments.len(), 1);
407+
}
408+
365409
#[test]
366410
fn repo_blocker_rollups_use_latest_review_per_pr() {
367411
let older_pr = make_pr_review_session(

0 commit comments

Comments
 (0)