From 38a6db8ea6566eff940382f1d30fc77ed027b51c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:56:01 -0400 Subject: [PATCH] Add `--force` option for `fabro pr create` (#155) Co-authored-by: Fabro Co-authored-by: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/args.rs | 3 + .../fabro-cli/src/commands/pr/create.rs | 3 + .../fabro-cli/tests/it/cmd/pr_create.rs | 35 ++++++++++- lib/crates/fabro-cli/tests/it/cmd/support.rs | 60 +++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index e2b348c23..228e50921 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -672,6 +672,9 @@ pub(crate) struct PrCreateArgs { /// LLM model for generating PR description #[arg(long)] pub(crate) model: Option, + /// Create PR even if the run status is not success/partial_success + #[arg(short, long)] + pub(crate) force: bool, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index cb8ddcf2f..52cae606c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -42,6 +42,9 @@ pub(super) async fn create_command( match conclusion.status { StageStatus::Success | StageStatus::PartialSuccess => {} + status if args.force => { + tracing::warn!("Run status is '{status}', proceeding because --force was specified"); + } status => bail!("Run status is '{status}', expected success or partial_success"), } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index eea4e179a..f80603aa7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -1,6 +1,6 @@ use fabro_test::{fabro_snapshot, test_context}; -use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run}; +use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run, setup_failed_run}; #[test] fn help() { @@ -23,6 +23,7 @@ fn help() { --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --model LLM model for generating PR description + -f, --force Create PR even if the run status is not success/partial_success --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] @@ -79,3 +80,35 @@ fn pr_create_uses_store_run_record_without_run_json() { error: Run has no run_branch — was it run with git push enabled? "); } + +#[test] +fn pr_create_failed_run_rejects_without_force() { + let context = test_context!(); + let run = setup_failed_run(&context); + let mut cmd = context.command(); + cmd.args(["pr", "create", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Run status is 'fail', expected success or partial_success + "); +} + +#[test] +fn pr_create_failed_run_proceeds_with_force() { + let context = test_context!(); + let run = setup_failed_run(&context); + let mut cmd = context.command(); + cmd.args(["pr", "create", "--force", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Run has no run_branch — was it run with git push enabled? + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index a33c489b6..e9b023e83 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -378,6 +378,66 @@ worktree_mode = "never" WorkspaceRunSetup { run, workspace_dir } } +pub(crate) fn setup_failed_run(context: &TestContext) -> RunSetup { + let workspace_dir = context.temp_dir.join("failed-run"); + std::fs::create_dir_all(&workspace_dir) + .unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display())); + + write_text_file( + &workspace_dir.join("fail.fabro"), + r#"digraph Fail { + graph [goal="Always fail", default_max_retries=0] + start [shape=Mdiamond] + exit [shape=Msquare] + boom [shape=parallelogram, script="exit 1", goal_gate=true] + start -> boom -> exit +} +"#, + ); + write_text_file( + &workspace_dir.join("run.toml"), + r#"_version = 1 + +[workflow] +graph = "fail.fabro" + +[run] +goal = "Always fail" + +[run.sandbox] +provider = "local" + +[run.sandbox.local] +worktree_mode = "never" +"#, + ); + + // The workflow is expected to fail (script exits 1), but the CLI may still + // exit 0. The `pr create` tests verify that the conclusion has a fail status. + let run_id = unique_run_id(); + let mut cmd = context.run_cmd(); + cmd.current_dir(&workspace_dir); + cmd.timeout(COMMAND_TIMEOUT); + cmd.env("OPENAI_API_KEY", "test"); + cmd.args([ + "--run-id", + run_id.as_str(), + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + "--provider", + "openai", + "run.toml", + ]); + let _output = cmd.output().expect("command should execute"); + + RunSetup { + run_dir: context.find_run_dir(&run_id), + run_id, + } +} + fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup { let run_id = unique_run_id(); let mut cmd = context.run_cmd();