@@ -12,7 +12,7 @@ use uuid::Uuid;
1212use 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} ;
1717use 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 ) ]
26382639pub 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
27372800async 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 ( ) ;
0 commit comments