Skip to content

Commit 6fa7c82

Browse files
committed
Add PR message flags for non-interactive use
Update PR message docs in skill references Limit custom PR message to selected branch
1 parent 2bf959b commit 6fa7c82

File tree

5 files changed

+156
-33
lines changed

5 files changed

+156
-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: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub async fn create_pr(
7070
with_force: bool,
7171
run_hooks: bool,
7272
default: bool,
73+
message: Option<PrMessage>,
7374
out: &mut OutputChannel,
7475
) -> anyhow::Result<()> {
7576
let review_map = get_review_map(ctx, Some(but_forge::CacheConfig::CacheOnly))?;
@@ -117,6 +118,7 @@ pub async fn create_pr(
117118
with_force,
118119
run_hooks,
119120
default,
121+
message.as_ref(),
120122
out,
121123
maybe_branch_names,
122124
)
@@ -170,6 +172,7 @@ pub async fn handle_multiple_branches_in_workspace(
170172
with_force: bool,
171173
run_hooks: bool,
172174
default_message: bool,
175+
message: Option<&PrMessage>,
173176
out: &mut OutputChannel,
174177
selected_branches: Option<Vec<String>>,
175178
) -> anyhow::Result<()> {
@@ -209,6 +212,7 @@ pub async fn handle_multiple_branches_in_workspace(
209212
with_force,
210213
run_hooks,
211214
default_message,
215+
message,
212216
out,
213217
)
214218
.await?;
@@ -327,6 +331,7 @@ async fn publish_reviews_for_branch_and_dependents(
327331
with_force: bool,
328332
run_hooks: bool,
329333
default_message: bool,
334+
message: Option<&PrMessage>,
330335
out: &mut OutputChannel,
331336
) -> Result<PublishReviewsOutcome, anyhow::Error> {
332337
let base_branch = gitbutler_branch_actions::base::get_base_branch_data(ctx)?;
@@ -380,13 +385,15 @@ async fn publish_reviews_for_branch_and_dependents(
380385
)?;
381386
}
382387

388+
let message_for_head = if head.name == branch_name { message } else { None };
383389
let published_review = publish_review_for_branch(
384390
ctx,
385391
stack_entry.id,
386392
head.name.to_str()?,
387393
current_target_branch,
388394
review_map,
389395
default_message,
396+
message_for_head,
390397
)
391398
.await?;
392399
match published_review {
@@ -482,13 +489,39 @@ enum PublishReviewResult {
482489
AlreadyExists(Vec<but_forge::ForgeReview>),
483490
}
484491

492+
#[derive(Clone, Debug)]
493+
pub struct PrMessage {
494+
pub title: String,
495+
pub body: String,
496+
}
497+
498+
pub fn parse_pr_message(content: &str) -> anyhow::Result<PrMessage> {
499+
let mut lines = content.lines();
500+
let title = lines.next().unwrap_or("").trim().to_string();
501+
502+
if title.is_empty() {
503+
anyhow::bail!("Aborting due to empty PR title");
504+
}
505+
506+
// Skip any leading blank lines after the title, then collect the rest as description
507+
let body = lines
508+
.skip_while(|l| l.trim().is_empty())
509+
.collect::<Vec<_>>()
510+
.join("\n")
511+
.trim()
512+
.to_string();
513+
514+
Ok(PrMessage { title, body })
515+
}
516+
485517
async fn publish_review_for_branch(
486518
ctx: &mut Context,
487519
stack_id: Option<StackId>,
488520
branch_name: &str,
489521
target_branch: &str,
490522
review_map: &std::collections::HashMap<String, Vec<but_forge::ForgeReview>>,
491523
default_message: bool,
524+
message: Option<&PrMessage>,
492525
) -> anyhow::Result<PublishReviewResult> {
493526
// Check if a review already exists for the branch.
494527
// If it does, skip publishing a new review.
@@ -500,7 +533,9 @@ async fn publish_review_for_branch(
500533
}
501534

502535
let commit = default_commit(ctx, stack_id, branch_name)?;
503-
let (title, body) = if default_message {
536+
let (title, body) = if let Some(message) = message {
537+
(message.title.clone(), message.body.clone())
538+
} else if default_message {
504539
let title = extract_commit_title(commit.as_ref())
505540
.map(|t| t.to_string())
506541
.unwrap_or(branch_name.to_string());
@@ -658,24 +693,8 @@ fn get_pr_title_and_body_from_editor(
658693
template.push_str("#\n");
659694

660695
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))
696+
let message = parse_pr_message(&content)?;
697+
Ok((message.title, message.body))
679698
}
680699

681700
/// Extract the commit description (body) from the commit message, skipping the first line (title).
@@ -756,3 +775,64 @@ pub fn get_review_numbers(
756775
"".to_string().normal()
757776
}
758777
}
778+
779+
#[cfg(test)]
780+
mod tests {
781+
use super::*;
782+
783+
#[test]
784+
fn parse_pr_message_title_only() {
785+
let msg = parse_pr_message("My PR Title").unwrap();
786+
assert_eq!(msg.title, "My PR Title");
787+
assert_eq!(msg.body, "");
788+
}
789+
790+
#[test]
791+
fn parse_pr_message_title_and_body() {
792+
let msg = parse_pr_message("My PR Title\n\nThis is the body.").unwrap();
793+
assert_eq!(msg.title, "My PR Title");
794+
assert_eq!(msg.body, "This is the body.");
795+
}
796+
797+
#[test]
798+
fn parse_pr_message_multiline_body() {
799+
let msg = parse_pr_message("Title\n\nLine 1\nLine 2\nLine 3").unwrap();
800+
assert_eq!(msg.title, "Title");
801+
assert_eq!(msg.body, "Line 1\nLine 2\nLine 3");
802+
}
803+
804+
#[test]
805+
fn parse_pr_message_skips_blank_lines_between_title_and_body() {
806+
let msg = parse_pr_message("Title\n\n\n\nBody starts here").unwrap();
807+
assert_eq!(msg.title, "Title");
808+
assert_eq!(msg.body, "Body starts here");
809+
}
810+
811+
#[test]
812+
fn parse_pr_message_trims_whitespace() {
813+
let msg = parse_pr_message(" Title with spaces \n\n Body with spaces ").unwrap();
814+
assert_eq!(msg.title, "Title with spaces");
815+
assert_eq!(msg.body, "Body with spaces");
816+
}
817+
818+
#[test]
819+
fn parse_pr_message_empty_string_fails() {
820+
let result = parse_pr_message("");
821+
assert!(result.is_err());
822+
assert!(result.unwrap_err().to_string().contains("empty PR title"));
823+
}
824+
825+
#[test]
826+
fn parse_pr_message_whitespace_only_fails() {
827+
let result = parse_pr_message(" \n\n ");
828+
assert!(result.is_err());
829+
assert!(result.unwrap_err().to_string().contains("empty PR title"));
830+
}
831+
832+
#[test]
833+
fn parse_pr_message_blank_first_line_fails() {
834+
let result = parse_pr_message("\nActual title on second line");
835+
assert!(result.is_err());
836+
assert!(result.unwrap_err().to_string().contains("empty PR title"));
837+
}
838+
}

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)