@@ -63,13 +63,15 @@ pub fn set_review_template(
6363/// Create a new PR for a branch.
6464/// If no branch is specified, prompts the user to select one.
6565/// If there is only one branch without a PR, asks for confirmation.
66+ #[ allow( clippy:: too_many_arguments) ]
6667pub async fn create_pr (
6768 ctx : & mut Context ,
6869 branch : Option < String > ,
6970 skip_force_push_protection : bool ,
7071 with_force : bool ,
7172 run_hooks : bool ,
7273 default : bool ,
74+ message : Option < PrMessage > ,
7375 out : & mut OutputChannel ,
7476) -> anyhow:: Result < ( ) > {
7577 let review_map = get_review_map ( ctx, Some ( but_forge:: CacheConfig :: CacheOnly ) ) ?;
@@ -117,6 +119,7 @@ pub async fn create_pr(
117119 with_force,
118120 run_hooks,
119121 default,
122+ message. as_ref ( ) ,
120123 out,
121124 maybe_branch_names,
122125 )
@@ -170,6 +173,7 @@ pub async fn handle_multiple_branches_in_workspace(
170173 with_force : bool ,
171174 run_hooks : bool ,
172175 default_message : bool ,
176+ message : Option < & PrMessage > ,
173177 out : & mut OutputChannel ,
174178 selected_branches : Option < Vec < String > > ,
175179) -> anyhow:: Result < ( ) > {
@@ -209,6 +213,7 @@ pub async fn handle_multiple_branches_in_workspace(
209213 with_force,
210214 run_hooks,
211215 default_message,
216+ message,
212217 out,
213218 )
214219 . await ?;
@@ -327,6 +332,7 @@ async fn publish_reviews_for_branch_and_dependents(
327332 with_force : bool ,
328333 run_hooks : bool ,
329334 default_message : bool ,
335+ message : Option < & PrMessage > ,
330336 out : & mut OutputChannel ,
331337) -> Result < PublishReviewsOutcome , anyhow:: Error > {
332338 let base_branch = gitbutler_branch_actions:: base:: get_base_branch_data ( ctx) ?;
@@ -380,13 +386,15 @@ async fn publish_reviews_for_branch_and_dependents(
380386 ) ?;
381387 }
382388
389+ let message_for_head = if head. name == branch_name { message } else { None } ;
383390 let published_review = publish_review_for_branch (
384391 ctx,
385392 stack_entry. id ,
386393 head. name . to_str ( ) ?,
387394 current_target_branch,
388395 review_map,
389396 default_message,
397+ message_for_head,
390398 )
391399 . await ?;
392400 match published_review {
@@ -482,13 +490,39 @@ enum PublishReviewResult {
482490 AlreadyExists ( Vec < but_forge:: ForgeReview > ) ,
483491}
484492
493+ #[ derive( Clone , Debug ) ]
494+ pub struct PrMessage {
495+ pub title : String ,
496+ pub body : String ,
497+ }
498+
499+ pub fn parse_pr_message ( content : & str ) -> anyhow:: Result < PrMessage > {
500+ let mut lines = content. lines ( ) ;
501+ let title = lines. next ( ) . unwrap_or ( "" ) . trim ( ) . to_string ( ) ;
502+
503+ if title. is_empty ( ) {
504+ anyhow:: bail!( "Aborting due to empty PR title" ) ;
505+ }
506+
507+ // Skip any leading blank lines after the title, then collect the rest as description
508+ let body = lines
509+ . skip_while ( |l| l. trim ( ) . is_empty ( ) )
510+ . collect :: < Vec < _ > > ( )
511+ . join ( "\n " )
512+ . trim ( )
513+ . to_string ( ) ;
514+
515+ Ok ( PrMessage { title, body } )
516+ }
517+
485518async fn publish_review_for_branch (
486519 ctx : & mut Context ,
487520 stack_id : Option < StackId > ,
488521 branch_name : & str ,
489522 target_branch : & str ,
490523 review_map : & std:: collections:: HashMap < String , Vec < but_forge:: ForgeReview > > ,
491524 default_message : bool ,
525+ message : Option < & PrMessage > ,
492526) -> anyhow:: Result < PublishReviewResult > {
493527 // Check if a review already exists for the branch.
494528 // If it does, skip publishing a new review.
@@ -500,7 +534,9 @@ async fn publish_review_for_branch(
500534 }
501535
502536 let commit = default_commit ( ctx, stack_id, branch_name) ?;
503- let ( title, body) = if default_message {
537+ let ( title, body) = if let Some ( message) = message {
538+ ( message. title . clone ( ) , message. body . clone ( ) )
539+ } else if default_message {
504540 let title = extract_commit_title ( commit. as_ref ( ) )
505541 . map ( |t| t. to_string ( ) )
506542 . unwrap_or ( branch_name. to_string ( ) ) ;
@@ -658,24 +694,8 @@ fn get_pr_title_and_body_from_editor(
658694 template. push_str ( "#\n " ) ;
659695
660696 let content = get_text:: from_editor_no_comments ( "pr_message" , & template) ?. to_string ( ) ;
661-
662- // Split into title (first line) and body (rest)
663- let mut lines = content. lines ( ) ;
664- let title = lines. next ( ) . unwrap_or ( "" ) . trim ( ) . to_string ( ) ;
665-
666- if title. is_empty ( ) {
667- anyhow:: bail!( "Aborting due to empty PR title" ) ;
668- }
669-
670- // Skip any leading blank lines after the title, then collect the rest as description
671- let body: String = lines
672- . skip_while ( |l| l. trim ( ) . is_empty ( ) )
673- . collect :: < Vec < _ > > ( )
674- . join ( "\n " )
675- . trim ( )
676- . to_string ( ) ;
677-
678- Ok ( ( title, body) )
697+ let message = parse_pr_message ( & content) ?;
698+ Ok ( ( message. title , message. body ) )
679699}
680700
681701/// Extract the commit description (body) from the commit message, skipping the first line (title).
@@ -756,3 +776,64 @@ pub fn get_review_numbers(
756776 "" . to_string ( ) . normal ( )
757777 }
758778}
779+
780+ #[ cfg( test) ]
781+ mod tests {
782+ use super :: * ;
783+
784+ #[ test]
785+ fn parse_pr_message_title_only ( ) {
786+ let msg = parse_pr_message ( "My PR Title" ) . unwrap ( ) ;
787+ assert_eq ! ( msg. title, "My PR Title" ) ;
788+ assert_eq ! ( msg. body, "" ) ;
789+ }
790+
791+ #[ test]
792+ fn parse_pr_message_title_and_body ( ) {
793+ let msg = parse_pr_message ( "My PR Title\n \n This is the body." ) . unwrap ( ) ;
794+ assert_eq ! ( msg. title, "My PR Title" ) ;
795+ assert_eq ! ( msg. body, "This is the body." ) ;
796+ }
797+
798+ #[ test]
799+ fn parse_pr_message_multiline_body ( ) {
800+ let msg = parse_pr_message ( "Title\n \n Line 1\n Line 2\n Line 3" ) . unwrap ( ) ;
801+ assert_eq ! ( msg. title, "Title" ) ;
802+ assert_eq ! ( msg. body, "Line 1\n Line 2\n Line 3" ) ;
803+ }
804+
805+ #[ test]
806+ fn parse_pr_message_skips_blank_lines_between_title_and_body ( ) {
807+ let msg = parse_pr_message ( "Title\n \n \n \n Body starts here" ) . unwrap ( ) ;
808+ assert_eq ! ( msg. title, "Title" ) ;
809+ assert_eq ! ( msg. body, "Body starts here" ) ;
810+ }
811+
812+ #[ test]
813+ fn parse_pr_message_trims_whitespace ( ) {
814+ let msg = parse_pr_message ( " Title with spaces \n \n Body with spaces " ) . unwrap ( ) ;
815+ assert_eq ! ( msg. title, "Title with spaces" ) ;
816+ assert_eq ! ( msg. body, "Body with spaces" ) ;
817+ }
818+
819+ #[ test]
820+ fn parse_pr_message_empty_string_fails ( ) {
821+ let result = parse_pr_message ( "" ) ;
822+ assert ! ( result. is_err( ) ) ;
823+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "empty PR title" ) ) ;
824+ }
825+
826+ #[ test]
827+ fn parse_pr_message_whitespace_only_fails ( ) {
828+ let result = parse_pr_message ( " \n \n " ) ;
829+ assert ! ( result. is_err( ) ) ;
830+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "empty PR title" ) ) ;
831+ }
832+
833+ #[ test]
834+ fn parse_pr_message_blank_first_line_fails ( ) {
835+ let result = parse_pr_message ( "\n Actual title on second line" ) ;
836+ assert ! ( result. is_err( ) ) ;
837+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "empty PR title" ) ) ;
838+ }
839+ }
0 commit comments