Skip to content

Commit c7e51f3

Browse files
committed
feat: add PR re-review API
1 parent cc1c69d commit c7e51f3

File tree

9 files changed

+157
-12
lines changed

9 files changed

+157
-12
lines changed

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
117117
72. [x] Expose PR readiness through the HTTP API for CI and agent integrations.
118118
73. [x] Add API endpoints to fetch learned rules, attention gaps, and top rejected patterns.
119119
74. [x] Add machine-friendly APIs to fetch findings grouped by severity, file, and lifecycle state.
120-
75. [ ] Add a "trigger re-review" API that reuses existing PR metadata and loop policy.
120+
75. [x] Add a "trigger re-review" API that reuses existing PR metadata and loop policy.
121121
76. [x] Add APIs for comment resolution and lifecycle updates, not just thumbs.
122122
77. [ ] Add an MCP server for DiffScope with review, analytics, and rule-management tools.
123123
78. [ ] Add reusable agent skills/workflows for checking PR readiness and running fix loops.
@@ -173,4 +173,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
173173
- [x] Add grouped PR findings API responses for severity, file, and lifecycle automation workflows.
174174
- [x] Add Analytics JSON/CSV exports covering review quality, lifecycle, and reinforcement metrics.
175175
- [x] Add learned-rules, attention-gap, and rejected-pattern analytics API endpoints for automation consumers.
176+
- [x] Add a PR re-review API that reuses stored review metadata and posting policy.
176177
- [ ] Commit and push each validated checkpoint before moving to the next epic.

src/commands/feedback_eval/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod tests {
4848
status: ReviewStatus::Complete,
4949
diff_source: "raw".to_string(),
5050
github_head_sha: None,
51+
github_post_results_requested: None,
5152
started_at: 1,
5253
completed_at: Some(2),
5354
comments,

src/server/api.rs

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use uuid::Uuid;
1212
use super::pr_readiness::{
1313
apply_dynamic_review_state, build_pr_readiness_snapshot, build_repo_blocker_rollups,
1414
get_pr_readiness_snapshot, latest_pr_review_session, latest_review_head_by_source,
15-
load_review_inventory, pr_diff_source, PrReadinessSnapshot,
15+
load_review_inventory, parse_pr_diff_source, pr_diff_source, PrReadinessSnapshot,
1616
};
1717
use super::state::{
1818
build_progress_callback, count_diff_files, count_reviewed_files, current_timestamp,
@@ -668,6 +668,7 @@ pub async fn start_review(
668668
status: ReviewStatus::Pending,
669669
diff_source: display_source,
670670
github_head_sha: None,
671+
github_post_results_requested: None,
671672
started_at: current_timestamp(),
672673
completed_at: None,
673674
comments: Vec::new(),
@@ -2634,18 +2635,51 @@ pub async fn get_gh_pr_findings(
26342635

26352636
// === GitHub PR Review ===
26362637

2637-
#[derive(Deserialize)]
2638+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
26382639
pub struct StartPrReviewRequest {
26392640
pub repo: String,
26402641
pub pr_number: u32,
26412642
pub post_results: bool,
26422643
}
26432644

2644-
#[tracing::instrument(name = "api.start_pr_review", skip(state, request), fields(repo = %request.repo, pr_number = request.pr_number))]
2645-
pub async fn start_pr_review(
2646-
State(state): State<Arc<AppState>>,
2647-
Json(request): Json<StartPrReviewRequest>,
2648-
) -> Result<Json<StartReviewResponse>, (StatusCode, String)> {
2645+
#[derive(Deserialize)]
2646+
pub struct RerunPrReviewRequest {
2647+
pub review_id: String,
2648+
pub post_results: Option<bool>,
2649+
}
2650+
2651+
fn resolve_rerun_post_results(
2652+
session: &ReviewSession,
2653+
post_results_override: Option<bool>,
2654+
) -> bool {
2655+
post_results_override
2656+
.or(session.github_post_results_requested)
2657+
.or_else(|| session.event.as_ref().map(|event| event.github_posted))
2658+
.unwrap_or(false)
2659+
}
2660+
2661+
fn build_rerun_pr_review_request(
2662+
session: &ReviewSession,
2663+
post_results_override: Option<bool>,
2664+
) -> Result<StartPrReviewRequest, (StatusCode, String)> {
2665+
let Some((repo, pr_number)) = parse_pr_diff_source(&session.diff_source) else {
2666+
return Err((
2667+
StatusCode::BAD_REQUEST,
2668+
"Review is not tied to a GitHub PR.".to_string(),
2669+
));
2670+
};
2671+
2672+
Ok(StartPrReviewRequest {
2673+
repo,
2674+
pr_number,
2675+
post_results: resolve_rerun_post_results(session, post_results_override),
2676+
})
2677+
}
2678+
2679+
async fn dispatch_pr_review(
2680+
state: &Arc<AppState>,
2681+
request: StartPrReviewRequest,
2682+
) -> Result<StartReviewResponse, (StatusCode, String)> {
26492683
info!(repo = %request.repo, pr = request.pr_number, post_results = request.post_results, "Starting PR review");
26502684

26512685
if !is_valid_repo_name(&request.repo) {
@@ -2673,7 +2707,6 @@ pub async fn start_pr_review(
26732707
.to_string();
26742708
drop(config);
26752709

2676-
// Fetch the diff via GitHub API
26772710
let diff_url = format!(
26782711
"https://api.github.com/repos/{}/pulls/{}",
26792712
request.repo, request.pr_number,
@@ -2687,13 +2720,14 @@ pub async fn start_pr_review(
26872720
.map_err(|e| (StatusCode::BAD_GATEWAY, e))?;
26882721

26892722
let id = Uuid::new_v4().to_string();
2690-
let diff_source = format!("pr:{}#{}", request.repo, request.pr_number);
2723+
let diff_source = pr_diff_source(&request.repo, request.pr_number);
26912724

26922725
let session = ReviewSession {
26932726
id: id.clone(),
26942727
status: ReviewStatus::Pending,
26952728
diff_source: diff_source.clone(),
26962729
github_head_sha: Some(head_sha.clone()),
2730+
github_post_results_requested: Some(request.post_results),
26972731
started_at: current_timestamp(),
26982732
completed_at: None,
26992733
comments: Vec::new(),
@@ -2728,10 +2762,39 @@ pub async fn start_pr_review(
27282762
.await;
27292763
});
27302764

2731-
Ok(Json(StartReviewResponse {
2765+
Ok(StartReviewResponse {
27322766
id,
27332767
status: ReviewStatus::Pending,
2734-
}))
2768+
})
2769+
}
2770+
2771+
#[tracing::instrument(name = "api.start_pr_review", skip(state, request), fields(repo = %request.repo, pr_number = request.pr_number))]
2772+
pub async fn start_pr_review(
2773+
State(state): State<Arc<AppState>>,
2774+
Json(request): Json<StartPrReviewRequest>,
2775+
) -> Result<Json<StartReviewResponse>, (StatusCode, String)> {
2776+
Ok(Json(dispatch_pr_review(&state, request).await?))
2777+
}
2778+
2779+
#[tracing::instrument(name = "api.rerun_pr_review", skip(state, request), fields(review_id = %request.review_id))]
2780+
pub async fn rerun_pr_review(
2781+
State(state): State<Arc<AppState>>,
2782+
Json(request): Json<RerunPrReviewRequest>,
2783+
) -> Result<Json<StartReviewResponse>, (StatusCode, String)> {
2784+
let review_id = request.review_id.trim();
2785+
let session = load_review_session_for_update(&state, review_id)
2786+
.await
2787+
.map_err(|status| match status {
2788+
StatusCode::NOT_FOUND => (
2789+
StatusCode::NOT_FOUND,
2790+
format!("Review '{}' not found.", review_id),
2791+
),
2792+
_ => (status, "Failed to load review session.".to_string()),
2793+
})?;
2794+
2795+
let start_request = build_rerun_pr_review_request(&session, request.post_results)?;
2796+
2797+
Ok(Json(dispatch_pr_review(&state, start_request).await?))
27352798
}
27362799

27372800
async fn run_pr_review_task(
@@ -3996,6 +4059,73 @@ mod tests {
39964059
);
39974060
}
39984061

4062+
fn make_pr_review_session(
4063+
diff_source: &str,
4064+
requested_post_results: Option<bool>,
4065+
github_posted: bool,
4066+
) -> ReviewSession {
4067+
ReviewSession {
4068+
id: "review-123".to_string(),
4069+
status: ReviewStatus::Complete,
4070+
diff_source: diff_source.to_string(),
4071+
github_head_sha: Some("abc123".to_string()),
4072+
github_post_results_requested: requested_post_results,
4073+
started_at: 10,
4074+
completed_at: Some(20),
4075+
comments: Vec::new(),
4076+
summary: None,
4077+
files_reviewed: 0,
4078+
error: None,
4079+
pr_summary_text: None,
4080+
diff_content: None,
4081+
event: Some(
4082+
ReviewEventBuilder::new("review-123", "review.completed", diff_source, "gpt-4")
4083+
.github_posted(github_posted)
4084+
.build(),
4085+
),
4086+
progress: None,
4087+
}
4088+
}
4089+
4090+
#[test]
4091+
fn test_build_rerun_pr_review_request_reuses_saved_policy() {
4092+
let session = make_pr_review_session("pr:owner/repo#42", Some(true), false);
4093+
4094+
let request = build_rerun_pr_review_request(&session, None).expect("rerun request");
4095+
4096+
assert_eq!(request.repo, "owner/repo");
4097+
assert_eq!(request.pr_number, 42);
4098+
assert!(request.post_results);
4099+
}
4100+
4101+
#[test]
4102+
fn test_build_rerun_pr_review_request_prefers_override() {
4103+
let session = make_pr_review_session("pr:owner/repo#42", Some(true), false);
4104+
4105+
let request = build_rerun_pr_review_request(&session, Some(false)).expect("rerun request");
4106+
4107+
assert!(!request.post_results);
4108+
}
4109+
4110+
#[test]
4111+
fn test_build_rerun_pr_review_request_falls_back_to_legacy_event_signal() {
4112+
let session = make_pr_review_session("pr:owner/repo#42", None, true);
4113+
4114+
let request = build_rerun_pr_review_request(&session, None).expect("rerun request");
4115+
4116+
assert!(request.post_results);
4117+
}
4118+
4119+
#[test]
4120+
fn test_build_rerun_pr_review_request_rejects_non_pr_reviews() {
4121+
let session = make_pr_review_session("head", Some(true), false);
4122+
4123+
let err = build_rerun_pr_review_request(&session, None).expect_err("non-pr review");
4124+
4125+
assert_eq!(err.0, StatusCode::BAD_REQUEST);
4126+
assert_eq!(err.1, "Review is not tied to a GitHub PR.");
4127+
}
4128+
39994129
#[test]
40004130
fn test_summarize_learned_rule_patterns_orders_by_total_observations() {
40014131
let mut store = ConventionStore::new();

src/server/github.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ pub async fn handle_webhook(
420420
status: ReviewStatus::Pending,
421421
diff_source: diff_source.clone(),
422422
github_head_sha: Some(head_sha.clone()),
423+
github_post_results_requested: None,
423424
started_at: current_timestamp(),
424425
completed_at: None,
425426
comments: Vec::new(),

src/server/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ pub async fn start_server(config: Config, host: &str, port: u16) -> anyhow::Resu
132132
.route("/gh/pr-comments", get(api::get_gh_pr_comments))
133133
.route("/gh/pr-findings", get(api::get_gh_pr_findings))
134134
.route("/gh/review", post(api::start_pr_review))
135+
.route("/gh/review/rerun", post(api::rerun_pr_review))
135136
.route("/agent/tools", get(api::get_agent_tools))
136137
.route("/gh/auth/device", post(github::start_device_flow))
137138
.route("/gh/auth/poll", post(github::poll_device_flow))

src/server/pr_readiness.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ mod tests {
267267
status: ReviewStatus::Complete,
268268
diff_source: "pr:owner/repo#42".to_string(),
269269
github_head_sha: Some(head_sha.to_string()),
270+
github_post_results_requested: None,
270271
started_at,
271272
completed_at: Some(started_at + 1),
272273
summary: Some(CommentSynthesizer::generate_summary(&comments)),
@@ -343,6 +344,7 @@ mod tests {
343344
status: ReviewStatus::Failed,
344345
diff_source: "pr:owner/repo#42".to_string(),
345346
github_head_sha: Some("sha-b".to_string()),
347+
github_post_results_requested: None,
346348
started_at: 20,
347349
completed_at: Some(21),
348350
summary: None,
@@ -385,6 +387,7 @@ mod tests {
385387
status: ReviewStatus::Failed,
386388
diff_source: "pr:owner/repo#42".to_string(),
387389
github_head_sha: Some("sha-b".to_string()),
390+
github_post_results_requested: None,
388391
started_at: 20,
389392
completed_at: Some(21),
390393
summary: None,
@@ -419,6 +422,7 @@ mod tests {
419422
status: ReviewStatus::Complete,
420423
diff_source: "pr:owner/repo#42".to_string(),
421424
github_head_sha: Some("sha-b".to_string()),
425+
github_post_results_requested: None,
422426
started_at: 20,
423427
completed_at: Some(21),
424428
summary: Some(CommentSynthesizer::generate_summary(&[])),
@@ -435,6 +439,7 @@ mod tests {
435439
status: ReviewStatus::Complete,
436440
diff_source: "pr:owner/repo#43".to_string(),
437441
github_head_sha: Some("sha-c".to_string()),
442+
github_post_results_requested: None,
438443
started_at: 15,
439444
completed_at: Some(16),
440445
summary: Some(CommentSynthesizer::generate_summary(&vec![make_comment(
@@ -455,6 +460,7 @@ mod tests {
455460
status: ReviewStatus::Complete,
456461
diff_source: "pr:other/repo#7".to_string(),
457462
github_head_sha: Some("sha-d".to_string()),
463+
github_post_results_requested: None,
458464
started_at: 12,
459465
completed_at: Some(13),
460466
summary: Some(CommentSynthesizer::generate_summary(&vec![make_comment(

src/server/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ pub struct ReviewSession {
129129
pub diff_source: String,
130130
#[serde(default)]
131131
pub github_head_sha: Option<String>,
132+
#[serde(default, skip_serializing_if = "Option::is_none")]
133+
pub github_post_results_requested: Option<bool>,
132134
pub started_at: i64,
133135
pub completed_at: Option<i64>,
134136
pub comments: Vec<Comment>,

src/server/storage_json.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ mod tests {
425425
status,
426426
diff_source: "head".to_string(),
427427
github_head_sha: None,
428+
github_post_results_requested: None,
428429
started_at,
429430
completed_at: None,
430431
comments: vec![],

src/server/storage_pg.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ impl StorageBackend for PgStorageBackend {
201201
status: parse_status(&row.1),
202202
diff_source: row.2,
203203
github_head_sha: row.3,
204+
github_post_results_requested: None,
204205
started_at: row.4.timestamp(),
205206
completed_at: row.5.map(|t| t.timestamp()),
206207
comments,
@@ -250,6 +251,7 @@ impl StorageBackend for PgStorageBackend {
250251
status: parse_status(&row.1),
251252
diff_source: row.2,
252253
github_head_sha: row.3,
254+
github_post_results_requested: None,
253255
started_at: row.4.timestamp(),
254256
completed_at: row.5.map(|t| t.timestamp()),
255257
comments,

0 commit comments

Comments
 (0)