@@ -1087,98 +1087,167 @@ pub async fn create_forge_review(
10871087#[ derive( Debug , Clone , Serialize , Deserialize ) ]
10881088#[ cfg_attr( feature = "export-schema" , derive( schemars:: JsonSchema ) ) ]
10891089#[ serde( rename_all = "camelCase" ) ]
1090- pub struct ForgeReviewDescriptionUpdate {
1090+ pub struct ForgeReviewUpdate {
10911091 /// The unique identifier number for this review within its repository. This can be a PR or MR number.
10921092 pub number : i64 ,
10931093 /// The current body/description of the review, which may be None if no description is set.
10941094 pub body : Option < String > ,
10951095 /// The platform-specific symbol for this review type (e.g., "#" for GitHub pull requests and "!" for MRs).
10961096 pub unit_symbol : String ,
1097+ /// If set, update the base/target branch of this review to the given value.
1098+ pub target_branch : Option < String > ,
10971099}
10981100
10991101#[ cfg( feature = "export-schema" ) ]
1100- but_schemars:: register_sdk_type!( ForgeReviewDescriptionUpdate ) ;
1102+ but_schemars:: register_sdk_type!( ForgeReviewUpdate ) ;
11011103
1102- impl From < ForgeReview > for ForgeReviewDescriptionUpdate {
1104+ impl From < ForgeReview > for ForgeReviewUpdate {
11031105 fn from ( review : ForgeReview ) -> Self {
1104- ForgeReviewDescriptionUpdate {
1106+ ForgeReviewUpdate {
11051107 number : review. number ,
11061108 body : review. body ,
11071109 unit_symbol : review. unit_symbol ,
1110+ target_branch : Some ( review. target_branch ) ,
11081111 }
11091112 }
11101113}
11111114
1112- /// Update the review description tables for a set of reviews
1113- pub async fn update_review_description_tables (
1115+ impl From < ForgeReviewTargetUpdate > for ForgeReviewUpdate {
1116+ fn from ( update : ForgeReviewTargetUpdate ) -> Self {
1117+ ForgeReviewUpdate {
1118+ number : update. number ,
1119+ body : None ,
1120+ unit_symbol : String :: new ( ) ,
1121+ target_branch : Some ( update. target_branch ) ,
1122+ }
1123+ }
1124+ }
1125+
1126+ /// Update reviews: description footers and, optionally, target/base branches.
1127+ ///
1128+ /// Per-review failures are collected rather than aborting the batch.
1129+ pub async fn sync_reviews (
11141130 preferred_forge_user : & Option < crate :: ForgeUser > ,
11151131 forge_repo_info : & crate :: forge:: ForgeRepoInfo ,
1116- reviews : & [ ForgeReviewDescriptionUpdate ] ,
1132+ reviews : & [ ForgeReviewUpdate ] ,
11171133 storage : & but_forge_storage:: Controller ,
11181134) -> Result < ( ) > {
11191135 let crate :: forge:: ForgeRepoInfo {
11201136 forge, owner, repo, ..
11211137 } = forge_repo_info;
11221138
1139+ let mut errors: Vec < String > = Vec :: new ( ) ;
1140+
1141+ // Skip body updates when no review has footer content (e.g. push-only target updates).
1142+ let has_footer_content = reviews. iter ( ) . any ( |r| !r. unit_symbol . is_empty ( ) ) ;
1143+
11231144 match forge {
11241145 ForgeName :: GitHub => {
11251146 let preferred_account = preferred_forge_user. as_ref ( ) . and_then ( |user| user. github ( ) ) ;
11261147 let pr_numbers: Vec < i64 > = reviews. iter ( ) . map ( |r| r. number ) . collect ( ) ;
11271148
11281149 for review in reviews {
1129- let updated_body = update_body (
1130- review. body . as_deref ( ) ,
1131- review. number ,
1132- & pr_numbers,
1133- & review. unit_symbol ,
1134- ) ;
1150+ let updated_body = if has_footer_content {
1151+ Some ( update_body (
1152+ review. body . as_deref ( ) ,
1153+ review. number ,
1154+ & pr_numbers,
1155+ & review. unit_symbol ,
1156+ ) )
1157+ } else {
1158+ None
1159+ } ;
11351160
11361161 let params = but_github:: UpdatePullRequestParams {
11371162 owner,
11381163 repo,
11391164 pr_number : review. number ,
11401165 title : None ,
1141- body : Some ( & updated_body) ,
1142- base : None ,
1166+ body : updated_body. as_deref ( ) ,
1167+ base : review . target_branch . as_deref ( ) ,
11431168 state : None ,
11441169 } ;
11451170
1146- but_github:: pr:: update ( preferred_account, params, storage) . await ?;
1171+ if let Err ( err) = but_github:: pr:: update ( preferred_account, params, storage) . await {
1172+ errors. push ( format ! ( "PR #{}: {err}" , review. number) ) ;
1173+ }
11471174 }
1148-
1149- Ok ( ( ) )
11501175 }
11511176 ForgeName :: GitLab => {
11521177 let project_id = GitLabProjectId :: new ( owner, repo) ;
11531178 let preferred_account = preferred_forge_user. as_ref ( ) . and_then ( |user| user. gitlab ( ) ) ;
11541179 let mr_iids: Vec < i64 > = reviews. iter ( ) . map ( |r| r. number ) . collect ( ) ;
11551180
11561181 for review in reviews {
1157- let updated_body = update_body (
1158- review. body . as_deref ( ) ,
1159- review. number ,
1160- & mr_iids,
1161- & review. unit_symbol ,
1162- ) ;
1182+ let updated_body = if has_footer_content {
1183+ Some ( update_body (
1184+ review. body . as_deref ( ) ,
1185+ review. number ,
1186+ & mr_iids,
1187+ & review. unit_symbol ,
1188+ ) )
1189+ } else {
1190+ None
1191+ } ;
11631192
11641193 let params = but_gitlab:: UpdateMergeRequestParams {
11651194 project_id : project_id. clone ( ) ,
11661195 mr_iid : review. number ,
11671196 title : None ,
1168- description : Some ( & updated_body) ,
1169- target_branch : None ,
1197+ description : updated_body. as_deref ( ) ,
1198+ target_branch : review . target_branch . as_deref ( ) ,
11701199 state_event : None ,
11711200 } ;
11721201
1173- but_gitlab:: mr:: update ( preferred_account, params, storage) . await ?;
1202+ if let Err ( err) = but_gitlab:: mr:: update ( preferred_account, params, storage) . await {
1203+ errors. push ( format ! ( "MR !{}: {err}" , review. number) ) ;
1204+ }
11741205 }
1206+ }
1207+ _ => {
1208+ return Err ( Error :: msg ( format ! (
1209+ "Updating reviews for forge {forge:?} is not implemented yet." ,
1210+ ) ) ) ;
1211+ }
1212+ }
11751213
1176- Ok ( ( ) )
1214+ if errors. is_empty ( ) {
1215+ Ok ( ( ) )
1216+ } else {
1217+ Err ( Error :: msg ( format ! (
1218+ "Some reviews failed to update:\n {}" ,
1219+ errors. join( "\n " )
1220+ ) ) )
1221+ }
1222+ }
1223+
1224+ /// A target branch update for a single review (PR/MR).
1225+ #[ derive( Debug , Clone ) ]
1226+ pub struct ForgeReviewTargetUpdate {
1227+ pub number : i64 ,
1228+ pub target_branch : String ,
1229+ }
1230+
1231+ /// Compute the expected target branch for each review in a stack.
1232+ /// Walks branches bottom-to-top; each review targets the preceding branch
1233+ /// (or `base_branch` for the bottom-most).
1234+ pub fn compute_review_target_updates (
1235+ heads : & [ ( String , Option < i64 > ) ] ,
1236+ base_branch : & str ,
1237+ ) -> Vec < ForgeReviewTargetUpdate > {
1238+ let mut updates = Vec :: new ( ) ;
1239+ let mut current_target = base_branch;
1240+ // heads are expected bottom-to-top (base branch first).
1241+ for ( branch_name, review_number) in heads {
1242+ if let Some ( number) = review_number {
1243+ updates. push ( ForgeReviewTargetUpdate {
1244+ number : * number,
1245+ target_branch : current_target. to_string ( ) ,
1246+ } ) ;
11771247 }
1178- _ => Err ( Error :: msg ( format ! (
1179- "Updating review descriptions for forge {forge:?} is not implemented yet." ,
1180- ) ) ) ,
1248+ current_target = branch_name;
11811249 }
1250+ updates
11821251}
11831252
11841253/// Replaces or inserts a new footer into an existing body of text.
@@ -1709,4 +1778,79 @@ mod tests {
17091778 assert_eq ! ( result, "Head content\n \n Tail content" ) ;
17101779 assert ! ( !result. contains( STACKING_FOOTER_BOUNDARY_TOP ) ) ;
17111780 }
1781+
1782+ // --- compute_review_target_updates tests ---
1783+
1784+ fn heads ( specs : & [ ( & str , Option < i64 > ) ] ) -> Vec < ( String , Option < i64 > ) > {
1785+ specs
1786+ . iter ( )
1787+ . map ( |( name, id) | ( name. to_string ( ) , * id) )
1788+ . collect ( )
1789+ }
1790+
1791+ #[ test]
1792+ fn target_updates_empty_stack ( ) {
1793+ let result = compute_review_target_updates ( & [ ] , "main" ) ;
1794+ assert ! ( result. is_empty( ) ) ;
1795+ }
1796+
1797+ #[ test]
1798+ fn target_updates_no_reviews ( ) {
1799+ let h = heads ( & [ ( "branch-a" , None ) , ( "branch-b" , None ) ] ) ;
1800+ let result = compute_review_target_updates ( & h, "main" ) ;
1801+ assert ! ( result. is_empty( ) ) ;
1802+ }
1803+
1804+ #[ test]
1805+ fn target_updates_single_branch_with_review ( ) {
1806+ let h = heads ( & [ ( "branch-a" , Some ( 1 ) ) ] ) ;
1807+ let result = compute_review_target_updates ( & h, "main" ) ;
1808+ assert_eq ! ( result. len( ) , 1 ) ;
1809+ assert_eq ! ( result[ 0 ] . number, 1 ) ;
1810+ assert_eq ! ( result[ 0 ] . target_branch, "main" ) ;
1811+ }
1812+
1813+ #[ test]
1814+ fn target_updates_stacked_reviews_point_to_parent ( ) {
1815+ // bottom-to-top: branch-a -> branch-b -> branch-c
1816+ let h = heads ( & [
1817+ ( "branch-a" , Some ( 1 ) ) ,
1818+ ( "branch-b" , Some ( 2 ) ) ,
1819+ ( "branch-c" , Some ( 3 ) ) ,
1820+ ] ) ;
1821+ let result = compute_review_target_updates ( & h, "main" ) ;
1822+ assert_eq ! ( result. len( ) , 3 ) ;
1823+ assert_eq ! ( result[ 0 ] . number, 1 ) ;
1824+ assert_eq ! ( result[ 0 ] . target_branch, "main" ) ;
1825+ assert_eq ! ( result[ 1 ] . number, 2 ) ;
1826+ assert_eq ! ( result[ 1 ] . target_branch, "branch-a" ) ;
1827+ assert_eq ! ( result[ 2 ] . number, 3 ) ;
1828+ assert_eq ! ( result[ 2 ] . target_branch, "branch-b" ) ;
1829+ }
1830+
1831+ #[ test]
1832+ fn target_updates_skips_branches_without_reviews ( ) {
1833+ // branch-a has no review, branch-b does — b should target a (not main)
1834+ let h = heads ( & [ ( "branch-a" , None ) , ( "branch-b" , Some ( 5 ) ) ] ) ;
1835+ let result = compute_review_target_updates ( & h, "main" ) ;
1836+ assert_eq ! ( result. len( ) , 1 ) ;
1837+ assert_eq ! ( result[ 0 ] . number, 5 ) ;
1838+ assert_eq ! ( result[ 0 ] . target_branch, "branch-a" ) ;
1839+ }
1840+
1841+ #[ test]
1842+ fn target_updates_gap_in_middle ( ) {
1843+ // a(PR) -> b(no PR) -> c(PR): c should target b, a should target main
1844+ let h = heads ( & [
1845+ ( "branch-a" , Some ( 1 ) ) ,
1846+ ( "branch-b" , None ) ,
1847+ ( "branch-c" , Some ( 3 ) ) ,
1848+ ] ) ;
1849+ let result = compute_review_target_updates ( & h, "main" ) ;
1850+ assert_eq ! ( result. len( ) , 2 ) ;
1851+ assert_eq ! ( result[ 0 ] . number, 1 ) ;
1852+ assert_eq ! ( result[ 0 ] . target_branch, "main" ) ;
1853+ assert_eq ! ( result[ 1 ] . number, 3 ) ;
1854+ assert_eq ! ( result[ 1 ] . target_branch, "branch-b" ) ;
1855+ }
17121856}
0 commit comments