Skip to content

Commit 0b47aed

Browse files
authored
Merge pull request #12184 from gitbutlerapp/pr-message-noninteractive
Add PR message flags for non-interactive use
2 parents 2bf959b + 7811643 commit 0b47aed

File tree

5 files changed

+157
-33
lines changed

5 files changed

+157
-33
lines changed

crates/but/skill/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ For detailed command syntax and all available options, see [references/reference
116116
- `but pull` - Update with upstream
117117
- `but push [branch]` - Push to remote
118118
- `but pr new <branch>` - Push and create pull request (auto-pushes, no need to push first)
119+
- `but pr new <branch> -m "Title..."` - Inline PR message (first line is title, rest is description)
120+
- `but pr new <branch> -F pr_message.txt` - PR message from file (first line is title, rest is description)
121+
- For stacked branches, the custom message (`-m` or `-F`) only applies to the selected branch; dependent branches use defaults
119122

120123
## Key Concepts
121124

crates/but/skill/references/reference.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,18 @@ Create and manage pull requests.
330330

331331
```bash
332332
but pr new <branch-id> # Push branch and create PR (recommended)
333-
but pr new <branch-id> -m "Title" -m "Body" # With title and description
333+
but pr new <branch-id> -F pr_message.txt # Use file: first line is title, rest is description
334+
but pr new <branch-id> -m "Title..." # Inline message: first line is title, rest is description
334335
but pr # Create PR (prompts for branch)
335336
but pr template # Configure PR description template
336337
```
337338

338339
**Key behavior:** `but pr new` automatically pushes the branch to remote before creating the PR. No need to run `but push` first.
339340

341+
In non-interactive environments, use `--message (-m)`, `--file (-F)`, or `--default (-t)` to avoid editor prompts.
342+
343+
**Note:** For stacked branches, the custom message (`-m` or `-F`) only applies to the selected branch. Dependent branches in the stack will use default messages (commit title/description).
344+
340345
Requires forge integration to be configured via `but config forge auth`.
341346

342347
### `but merge <branch>`

crates/but/src/args/forge.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ pub mod pr {
1313
/// The branch to create a PR for.
1414
#[clap(value_name = "BRANCH")]
1515
branch: Option<String>,
16+
/// PR title and description. The first line is the title, the rest is the description.
17+
#[clap(short = 'm', long = "message", conflicts_with_all = &["file", "default"])]
18+
message: Option<String>,
19+
/// Read PR title and description from file. The first line is the title, the rest is the description.
20+
#[clap(short = 'F', long = "file", value_name = "FILE", conflicts_with_all = &["message", "default"])]
21+
file: Option<std::path::PathBuf>,
1622
/// Force push even if it's not fast-forward (defaults to true).
1723
#[clap(long, short = 'f', default_value_t = true)]
1824
with_force: bool,

crates/but/src/command/legacy/forge/review.rs

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
6667
pub 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+
485518
async 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\nThis 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\nLine 1\nLine 2\nLine 3").unwrap();
801+
assert_eq!(msg.title, "Title");
802+
assert_eq!(msg.body, "Line 1\nLine 2\nLine 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\nBody 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("\nActual title on second line");
836+
assert!(result.is_err());
837+
assert!(result.unwrap_err().to_string().contains("empty PR title"));
838+
}
839+
}

crates/but/src/lib.rs

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -733,30 +733,59 @@ async fn match_subcommand(
733733
match cmd {
734734
Some(forge::pr::Subcommands::New {
735735
branch,
736+
message,
737+
file,
736738
skip_force_push_protection,
737739
with_force,
738740
run_hooks,
739741
default,
740-
}) => command::legacy::forge::review::create_pr(
741-
&mut ctx,
742-
branch,
743-
skip_force_push_protection,
744-
with_force,
745-
run_hooks,
746-
default,
747-
out,
748-
)
749-
.await
750-
.context("Failed to create PR for branch.")
751-
.emit_metrics(metrics_ctx),
742+
}) => {
743+
// Read message content from file or inline
744+
let message_content = match &file {
745+
Some(path) => Some(
746+
std::fs::read_to_string(path)
747+
.with_context(|| format!("Failed to read PR message from file: {}", path.display()))?,
748+
),
749+
None => message.clone(),
750+
};
751+
// Parse early to fail fast on invalid content
752+
let pr_message = match message_content {
753+
Some(content) => Some(command::legacy::forge::review::parse_pr_message(&content)?),
754+
None => None,
755+
};
756+
// Check for non-interactive environment
757+
if !out.can_prompt() {
758+
if branch.is_none() {
759+
anyhow::bail!("Non-interactive environment detected. Please specify a branch.");
760+
}
761+
if pr_message.is_none() && !default {
762+
anyhow::bail!(
763+
"Non-interactive environment detected. Provide one of: --message (-m), --file (-F), or --default (-t)."
764+
);
765+
}
766+
}
767+
command::legacy::forge::review::create_pr(
768+
&mut ctx,
769+
branch,
770+
skip_force_push_protection,
771+
with_force,
772+
run_hooks,
773+
default,
774+
pr_message,
775+
out,
776+
)
777+
.await
778+
.context("Failed to create PR for branch.")
779+
.emit_metrics(metrics_ctx)
780+
}
752781
Some(forge::pr::Subcommands::Template { template_path }) => {
753782
command::legacy::forge::review::set_review_template(&mut ctx, template_path, out)
754783
.context("Failed to set PR template.")
755784
.emit_metrics(metrics_ctx)
756785
}
757786
None => {
758787
// Default to `pr new` when no subcommand is provided
759-
command::legacy::forge::review::create_pr(&mut ctx, None, false, true, true, false, out)
788+
command::legacy::forge::review::create_pr(&mut ctx, None, false, true, true, false, None, out)
760789
.await
761790
.context("Failed to create PR for branch.")
762791
.emit_metrics(metrics_ctx)

0 commit comments

Comments
 (0)