From 80fbeb5fcdd5d2a9847bdead997fa7a633dcb0cd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:32:55 -0400 Subject: [PATCH] Auto-PR on Successful Workflow Run (#3) * arc(01KK6WHJ904CXGN0MCQFGWKMKS): implement (success) Arc-Run: 01KK6WHJ904CXGN0MCQFGWKMKS Arc-Completed: 2 Arc-Checkpoint: 4c3a9115be9074c7e23792c0ef9b1f0525ffc10d * arc(01KK6WHJ904CXGN0MCQFGWKMKS): simplify (success) Arc-Run: 01KK6WHJ904CXGN0MCQFGWKMKS Arc-Completed: 3 Arc-Checkpoint: 7d8e8413e70eb198e14fcb676903dcc91bd21b76 * Fix merge conflicts: add pull_request to OpenAPI spec, fix clippy warning, cargo fmt Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: arc Co-authored-by: Claude Opus 4.6 --- crates/arc-api/src/demo/mod.rs | 6 + crates/arc-api/src/server.rs | 2 + crates/arc-api/tests/openapi_conformance.rs | 1 + crates/arc-workflows/src/cli/run.rs | 95 +++++++- crates/arc-workflows/src/cli/run_config.rs | 87 +++++++ crates/arc-workflows/src/engine.rs | 82 +++++++ crates/arc-workflows/src/github_app.rs | 165 ++++++++++++- .../arc-workflows/src/handler/manager_loop.rs | 2 + crates/arc-workflows/src/lib.rs | 1 + crates/arc-workflows/src/pull_request.rs | 92 +++++++ .../tests/daytona_integration.rs | 12 + crates/arc-workflows/tests/integration.rs | 224 ++++++++++++++++++ docs/api-reference/arc-api.yaml | 10 + 13 files changed, 769 insertions(+), 10 deletions(-) create mode 100644 crates/arc-workflows/src/pull_request.rs diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 44853df75..98e63fb1e 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -1292,6 +1292,7 @@ mod runs { ])), hooks: vec![], checkpoint: Default::default(), + pull_request: None, }) .unwrap() } @@ -1441,6 +1442,7 @@ mod workflows { ])), hooks: vec![], checkpoint: Default::default(), + pull_request: None, }), graph: r#"digraph fix_build { graph [ @@ -1507,6 +1509,7 @@ mod workflows { ])), hooks: vec![], checkpoint: Default::default(), + pull_request: None, }), graph: r#"digraph implement { graph [ @@ -1585,6 +1588,7 @@ mod workflows { ])), hooks: vec![], checkpoint: Default::default(), + pull_request: None, }), graph: r#"digraph sync { graph [ @@ -1652,6 +1656,7 @@ mod workflows { ])), hooks: vec![], checkpoint: Default::default(), + pull_request: None, }), graph: r#"digraph expand { graph [ @@ -3091,6 +3096,7 @@ mod settings { }), vars: None, checkpoint: Default::default(), + pull_request: None, }, hook_config: arc_workflows::hook::HookConfig { hooks: vec![] }, }) diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 42860b39f..39ee37cd2 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -604,6 +604,8 @@ async fn execute_run(state: Arc, run_id: String) { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: state.git_author.clone(), + base_branch: None, + pull_request_enabled: false, }; let result = tokio::select! { diff --git a/crates/arc-api/tests/openapi_conformance.rs b/crates/arc-api/tests/openapi_conformance.rs index d8018c391..a5a8f0a67 100644 --- a/crates/arc-api/tests/openapi_conformance.rs +++ b/crates/arc-api/tests/openapi_conformance.rs @@ -305,6 +305,7 @@ fn fully_populated_server_config() -> ServerConfig { checkpoint: CheckpointConfig { exclude_globs: vec![], }, + pull_request: Some(PullRequestConfig { enabled: true }), }, hook_config: HookConfig { // One hook per HookType variant so the key union covers all fields. diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index e5a0f6bda..7d3f89c71 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -298,6 +298,10 @@ pub async fn run_command( let preserve_sandbox = resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults); let original_cwd = std::env::current_dir()?; + let (origin_url, detected_base_branch) = + crate::daytona_sandbox::detect_repo_info(&original_cwd) + .map(|(url, branch)| (Some(url), branch)) + .unwrap_or((None, None)); let git_clean = match sandbox_provider { SandboxProvider::Local | SandboxProvider::Docker => { crate::git::ensure_clean(&original_cwd).is_ok() @@ -541,20 +545,20 @@ pub async fn run_command( }); // Set up git inside Daytona sandbox (if applicable) - let (daytona_run_id, daytona_base_sha, daytona_branch) = + let (daytona_run_id, daytona_base_sha, daytona_branch, daytona_base_branch) = if sandbox_provider == SandboxProvider::Daytona { match setup_daytona_git(&*sandbox).await { - Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)), + Ok((rid, base, branch, base_br)) => (Some(rid), Some(base), Some(branch), base_br), Err(e) => { eprintln!( "{} Daytona git setup failed ({e}), running without git checkpoints.", styles.yellow.apply_to("Warning:"), ); - (None, None, None) + (None, None, None, None) } } } else { - (None, None, None) + (None, None, None, None) }; // Create SSH access if requested @@ -751,6 +755,11 @@ pub async fn run_command( checkpoint_exclude_globs, github_app: github_app.clone(), git_author, + base_branch: detected_base_branch.or(daytona_base_branch), + pull_request_enabled: run_cfg + .as_ref() + .and_then(|c| c.pull_request.as_ref()) + .is_some_and(|p| p.enabled), }; let run_start = Instant::now(); @@ -815,6 +824,55 @@ pub async fn run_command( .await; } + // Auto-create PR on successful completion + if config.pull_request_enabled { + if let Ok(ref outcome) = engine_result { + if matches!( + outcome.status, + StageStatus::Success | StageStatus::PartialSuccess + ) { + let diff = tokio::fs::read_to_string(logs_dir.join("final.patch")) + .await + .unwrap_or_default(); + if let ( + Some(ref base_branch), + Some(ref run_branch), + Some(ref creds), + Some(ref origin), + ) = ( + &config.base_branch, + &config.run_branch, + &github_app, + &origin_url, + ) { + match crate::pull_request::maybe_open_pull_request( + creds, + origin, + base_branch, + run_branch, + graph.goal(), + &diff, + &model, + ) + .await + { + Ok(Some(url)) => { + eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); + } + Ok(None) => {} // empty diff, logged at DEBUG + Err(e) => { + tracing::warn!(error = %e, "Pull request creation failed"); + eprintln!( + "{} PR creation failed: {e}", + styles.yellow.apply_to("Warning:") + ); + } + } + } + } + } + } + let outcome = engine_result?; // 8. Print result @@ -937,10 +995,26 @@ fn setup_worktree( } /// Set up git inside a Daytona sandbox for checkpoint commits. -/// Returns (run_id, base_sha, branch_name) on success. +/// Returns (run_id, base_sha, branch_name, base_branch) on success. async fn setup_daytona_git( sandbox: &dyn arc_agent::Sandbox, -) -> anyhow::Result<(String, String, String)> { +) -> anyhow::Result<(String, String, String, Option)> { + // Get current branch name before creating the run branch + let branch_result = sandbox + .exec_command("git rev-parse --abbrev-ref HEAD", 10_000, None, None, None) + .await + .map_err(|e| anyhow::anyhow!("git rev-parse --abbrev-ref HEAD failed: {e}"))?; + let base_branch = if branch_result.exit_code == 0 { + let name = branch_result.stdout.trim().to_string(); + if name.is_empty() || name == "HEAD" { + None + } else { + Some(name) + } + } else { + None + }; + // Get current HEAD as base SHA let sha_result = sandbox .exec_command("git rev-parse HEAD", 10_000, None, None, None) @@ -972,7 +1046,7 @@ async fn setup_daytona_git( ); } - Ok((run_id, base_sha, branch_name)) + Ok((run_id, base_sha, branch_name, base_branch)) } /// Resume a workflow run from a git run branch. @@ -1122,6 +1196,8 @@ async fn run_from_branch( checkpoint_exclude_globs: Vec::new(), github_app: None, git_author, + base_branch: None, + pull_request_enabled: false, }; let run_start = Instant::now(); @@ -1656,6 +1732,7 @@ mod tests { vars: None, hooks: Vec::new(), checkpoint: Default::default(), + pull_request: None, }; let (model, provider) = resolve_model_provider( Some("gpt-5.2"), @@ -1697,6 +1774,7 @@ mod tests { vars: None, hooks: Vec::new(), checkpoint: Default::default(), + pull_request: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -1773,6 +1851,7 @@ mod tests { vars: None, hooks: Vec::new(), checkpoint: Default::default(), + pull_request: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -1798,6 +1877,7 @@ mod tests { vars: None, hooks: Vec::new(), checkpoint: Default::default(), + pull_request: None, }; let defaults = RunDefaults::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); @@ -1822,6 +1902,7 @@ mod tests { vars: None, hooks: Vec::new(), checkpoint: Default::default(), + pull_request: None, }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index 72924c570..6a61b8ef6 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -16,6 +16,12 @@ pub struct CheckpointConfig { pub exclude_globs: Vec, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PullRequestConfig { + #[serde(default)] + pub enabled: bool, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WorkflowRunConfig { @@ -31,6 +37,7 @@ pub struct WorkflowRunConfig { pub hooks: Vec, #[serde(default)] pub checkpoint: CheckpointConfig, + pub pull_request: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -68,6 +75,7 @@ pub struct RunDefaults { pub vars: Option>, #[serde(default)] pub checkpoint: CheckpointConfig, + pub pull_request: Option, } impl WorkflowRunConfig { @@ -164,6 +172,10 @@ impl WorkflowRunConfig { merged.dedup(); self.checkpoint.exclude_globs = merged; } + + if self.pull_request.is_none() { + self.pull_request = defaults.pull_request.clone(); + } } } @@ -1703,4 +1715,79 @@ MISSING = "${env.ARC_TEST_DEFINITELY_NOT_SET_67890}" "unexpected error: {err}" ); } + + #[test] + fn parse_toml_with_pull_request() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.dot" + +[pull_request] +enabled = true +"#; + let config = parse_run_config(toml).unwrap(); + let pr = config.pull_request.unwrap(); + assert!(pr.enabled); + } + + #[test] + fn parse_toml_without_pull_request_defaults_none() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.dot" +"#; + let config = parse_run_config(toml).unwrap(); + assert!(config.pull_request.is_none()); + } + + #[test] + fn apply_defaults_pull_request_task_wins() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[pull_request] +enabled = true +"#, + ) + .unwrap(); + let defaults = RunDefaults { + pull_request: Some(PullRequestConfig { enabled: false }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + assert!(cfg.pull_request.unwrap().enabled); + } + + #[test] + fn apply_defaults_pull_request_inherited() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + pull_request: Some(PullRequestConfig { enabled: true }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + assert!(cfg.pull_request.unwrap().enabled); + } + + #[test] + fn parse_run_defaults_with_pull_request() { + let toml = r#" +[pull_request] +enabled = true +"#; + let defaults: RunDefaults = toml::from_str(toml).unwrap(); + assert!(defaults.pull_request.unwrap().enabled); + } } diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index 062b03ec7..82525b3ce 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -842,6 +842,10 @@ pub struct RunConfig { pub github_app: Option, /// Git author identity for checkpoint commits. pub git_author: crate::git::GitAuthor, + /// Name of the branch the run was started from (for PR base). + pub base_branch: Option, + /// Whether to auto-create a PR on successful completion. + pub pull_request_enabled: bool, } /// The workflow run execution engine. @@ -2841,6 +2845,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -2865,6 +2871,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); let checkpoint_path = dir.path().join("checkpoint.json"); @@ -2897,6 +2905,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -2925,6 +2935,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -2949,6 +2961,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -2986,6 +3000,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3047,6 +3063,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3135,6 +3153,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3166,6 +3186,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3192,6 +3214,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3218,6 +3242,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3248,6 +3274,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3406,6 +3434,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3447,6 +3477,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); @@ -3506,6 +3538,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); @@ -3568,6 +3602,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; @@ -3634,6 +3670,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_ok()); @@ -3689,6 +3727,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3745,6 +3785,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); @@ -3776,6 +3818,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3803,6 +3847,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -3829,6 +3875,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -3868,6 +3916,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // Set cancel after a short delay (while the slow handler is running) @@ -3944,6 +3994,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -3973,6 +4025,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4004,6 +4058,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4040,6 +4096,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4074,6 +4132,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4105,6 +4165,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4197,6 +4259,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // The engine returns Err because the Fail outcome has no outgoing fail edge, @@ -4404,6 +4468,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4438,6 +4504,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4479,6 +4547,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4560,6 +4630,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4652,6 +4724,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&g, &config).await; assert!(result.is_err()); @@ -4721,6 +4795,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -4777,6 +4853,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -4834,6 +4912,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let _outcome = engine.run(&g, &config).await.unwrap(); @@ -4918,6 +4998,8 @@ mod tests { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: crate::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&g, &config).await.unwrap(); diff --git a/crates/arc-workflows/src/github_app.rs b/crates/arc-workflows/src/github_app.rs index 2329f8570..7016ca38a 100644 --- a/crates/arc-workflows/src/github_app.rs +++ b/crates/arc-workflows/src/github_app.rs @@ -68,13 +68,14 @@ pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result Result { #[derive(Deserialize)] struct Installation { @@ -135,7 +136,7 @@ pub async fn create_installation_access_token( ); let body = serde_json::json!({ "repositories": [repo], - "permissions": { "contents": "write" } + "permissions": permissions, }); let token_resp = client @@ -177,6 +178,122 @@ pub async fn create_installation_access_token( Ok(access_token.token) } +/// Request a scoped Installation Access Token with `contents: write`. +pub async fn create_installation_access_token( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + create_installation_access_token_with_permissions( + client, + jwt, + owner, + repo, + base_url, + serde_json::json!({ "contents": "write" }), + ) + .await +} + +/// Request a scoped Installation Access Token with `contents: write` +/// and `pull_requests: write`. Used for creating pull requests. +pub async fn create_installation_access_token_for_pr( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + create_installation_access_token_with_permissions( + client, + jwt, + owner, + repo, + base_url, + serde_json::json!({ "contents": "write", "pull_requests": "write" }), + ) + .await +} + +/// Create a pull request on GitHub. +/// +/// Signs a JWT, obtains a PR-scoped installation token, and POSTs to the +/// GitHub pulls API. Returns `(html_url, pr_number)` on success. +pub async fn create_pull_request( + creds: &GitHubAppCredentials, + owner: &str, + repo: &str, + base: &str, + head: &str, + title: &str, + body: &str, +) -> Result<(String, u64), String> { + let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; + let client = reqwest::Client::new(); + + let token = + create_installation_access_token_for_pr(&client, &jwt, owner, repo, GITHUB_API_BASE_URL) + .await?; + + tracing::debug!(title = %title, head = %head, base = %base, "Creating pull request"); + + let pr_body = serde_json::json!({ + "title": title, + "head": head, + "base": base, + "body": body, + }); + + let url = format!("{GITHUB_API_BASE_URL}/repos/{owner}/{repo}/pulls"); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .json(&pr_body) + .send() + .await + .map_err(|e| format!("Failed to create pull request: {e}"))?; + + let status = resp.status(); + match status.as_u16() { + 201 => {} + 422 => { + let body_text = resp.text().await.unwrap_or_default(); + return Err(format!( + "Pull request could not be created (422): {body_text}" + )); + } + 401 | 403 => { + return Err(format!( + "Authentication failed creating pull request ({})", + status + )); + } + _ => { + let body_text = resp.text().await.unwrap_or_default(); + return Err(format!( + "Unexpected status {status} creating pull request: {body_text}" + )); + } + } + + #[derive(Deserialize)] + struct PullRequestResponse { + html_url: String, + number: u64, + } + + let pr: PullRequestResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse pull request response: {e}"))?; + + Ok((pr.html_url, pr.number)) +} + /// Convert a Git SSH URL to HTTPS format for token-based authentication. /// /// SSH URLs like `git@github.com:owner/repo.git` become @@ -474,4 +591,46 @@ mod tests { .unwrap_err(); assert!(err.contains("authentication failed"), "got: {err}"); } + + // ----------------------------------------------------------------------- + // create_installation_access_token_for_pr + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn create_iat_for_pr_requests_pr_permissions() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_body(r#"{"id": 456}"#) + .create_async() + .await; + + let token_mock = server + .mock("POST", "/app/installations/456/access_tokens") + .match_header("Authorization", "Bearer test-jwt") + .match_body(mockito::Matcher::JsonString( + r#"{"repositories":["repo"],"permissions":{"contents":"write","pull_requests":"write"}}"#.to_string(), + )) + .with_status(201) + .with_body(r#"{"token": "ghs_pr_token"}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let token = create_installation_access_token_for_pr( + &client, + "test-jwt", + "owner", + "repo", + &server.url(), + ) + .await + .unwrap(); + assert_eq!(token, "ghs_pr_token"); + + token_mock.assert_async().await; + } } diff --git a/crates/arc-workflows/src/handler/manager_loop.rs b/crates/arc-workflows/src/handler/manager_loop.rs index 970005540..9f2427184 100644 --- a/crates/arc-workflows/src/handler/manager_loop.rs +++ b/crates/arc-workflows/src/handler/manager_loop.rs @@ -151,6 +151,8 @@ impl Handler for SubWorkflowHandler { .as_ref() .map(|gs| gs.git_author.clone()) .unwrap_or_default(), + base_branch: None, + pull_request_enabled: false, }; // Clone parent context for child; inject parent preamble diff --git a/crates/arc-workflows/src/lib.rs b/crates/arc-workflows/src/lib.rs index 2e78b5444..7151697cd 100644 --- a/crates/arc-workflows/src/lib.rs +++ b/crates/arc-workflows/src/lib.rs @@ -46,6 +46,7 @@ pub mod manifest; pub mod outcome; pub mod parser; pub mod preamble; +pub mod pull_request; pub mod retro; pub mod retro_agent; pub mod stylesheet; diff --git a/crates/arc-workflows/src/pull_request.rs b/crates/arc-workflows/src/pull_request.rs new file mode 100644 index 000000000..f0d22420e --- /dev/null +++ b/crates/arc-workflows/src/pull_request.rs @@ -0,0 +1,92 @@ +use tracing::{debug, info}; + +use crate::github_app::{self, ssh_url_to_https, GitHubAppCredentials}; + +/// Generate a PR body from the diff and goal using an LLM. +pub async fn generate_pr_body(diff: &str, goal: &str, model: &str) -> Result { + let system = "Write a concise PR description summarizing the changes.".to_string(); + + // Truncate diff to fit context windows (~50k chars) + let max_diff_len = 50_000; + let truncated_diff = if diff.len() > max_diff_len { + &diff[..max_diff_len] + } else { + diff + }; + + let prompt = format!("Goal: {goal}\n\nDiff:\n```\n{truncated_diff}\n```"); + + let params = arc_llm::generate::GenerateParams::new(model) + .system(system) + .prompt(prompt); + + let result = arc_llm::generate::generate(params) + .await + .map_err(|e| format!("LLM generation failed: {e}"))?; + + Ok(result.response.text()) +} + +/// Optionally open a pull request after a successful workflow run. +/// +/// Returns `Ok(Some(html_url))` if a PR was created, `Ok(None)` if the diff +/// was empty, or `Err` on failure. +pub async fn maybe_open_pull_request( + creds: &GitHubAppCredentials, + origin_url: &str, + base_branch: &str, + head_branch: &str, + goal: &str, + diff: &str, + model: &str, +) -> Result, String> { + if diff.is_empty() { + debug!("Empty diff, skipping pull request creation"); + return Ok(None); + } + + let https_url = ssh_url_to_https(origin_url); + let (owner, repo) = github_app::parse_github_owner_repo(&https_url)?; + + let body = generate_pr_body(diff, goal, model).await?; + + let (url, pr_number) = github_app::create_pull_request( + creds, + &owner, + &repo, + base_branch, + head_branch, + goal, + &body, + ) + .await?; + + info!(pr_url = %url, pr_number, "Pull request created"); + + Ok(Some(url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_diff_returns_none() { + let creds = GitHubAppCredentials { + app_id: "123".to_string(), + private_key_pem: "unused".to_string(), + }; + let result = maybe_open_pull_request( + &creds, + "https://github.com/owner/repo.git", + "main", + "arc/run/123", + "Fix bug", + "", + "claude-sonnet-4-20250514", + ) + .await; + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } +} diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index 661c8dabb..0488ec7b0 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -396,6 +396,8 @@ async fn daytona_pipeline_artifact_offload_and_sync() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -590,6 +592,8 @@ async fn daytona_git_checkpoint_remote_emits_events() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -773,6 +777,8 @@ async fn daytona_parallel_git_branching_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1101,6 +1107,8 @@ async fn daytona_git_checkpoint_with_shadow_branch() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1241,6 +1249,8 @@ async fn daytona_asset_collection() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1473,6 +1483,8 @@ async fn daytona_git_push_run_branch_to_origin() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 469324ffe..1444dd62e 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -202,6 +202,8 @@ async fn end_to_end_linear_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -342,6 +344,8 @@ async fn end_to_end_branching_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -464,6 +468,8 @@ async fn end_to_end_human_gate_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -576,6 +582,8 @@ async fn goal_gate_routes_to_retry_target_on_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -698,6 +706,8 @@ async fn goal_gate_routes_to_retry_target_when_present() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1007,6 +1017,8 @@ async fn retry_on_failure_then_succeed() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1083,6 +1095,8 @@ async fn pipeline_with_many_nodes() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1407,6 +1421,8 @@ async fn smoke_test_with_mock_codergen_backend() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1509,6 +1525,8 @@ async fn end_to_end_parallel_fan_out_fan_in() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1622,6 +1640,8 @@ async fn resume_from_checkpoint_completes_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -1721,6 +1741,8 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // This should succeed because goal gate for gated_work is satisfied @@ -1765,6 +1787,8 @@ async fn graph_goal_in_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -1801,6 +1825,8 @@ async fn event_streaming_lifecycle() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -1881,6 +1907,8 @@ async fn context_flow_between_stages() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -1934,6 +1962,8 @@ async fn tool_handler_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2004,6 +2034,8 @@ async fn auto_approve_interviewer_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2041,6 +2073,8 @@ async fn codergen_without_backend_simulated() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -2146,6 +2180,8 @@ async fn branching_loop_back_on_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2231,6 +2267,8 @@ async fn human_gate_loops_back() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2289,6 +2327,8 @@ async fn scenario_ship_a_feature() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2375,6 +2415,8 @@ async fn scenario_parallel_expert_review() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2455,6 +2497,8 @@ async fn scenario_node_retries_on_retry_status() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2517,6 +2561,8 @@ async fn scenario_loop_restart_resets_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2585,6 +2631,8 @@ async fn scenario_bug_triage_router() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2643,6 +2691,8 @@ async fn scenario_crash_recovery() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -2752,6 +2802,8 @@ async fn manager_loop_stop_condition_satisfied_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -2829,6 +2881,8 @@ async fn manager_loop_max_cycles_exceeded_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -2965,6 +3019,8 @@ async fn conditional_branching_success_fail_paths() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3018,6 +3074,8 @@ async fn edge_selection_condition_match_wins_over_weight() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3065,6 +3123,8 @@ async fn edge_selection_weight_breaks_ties() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3104,6 +3164,8 @@ async fn edge_selection_lexical_tiebreak() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3162,6 +3224,8 @@ async fn context_updates_visible_across_nodes() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3206,6 +3270,8 @@ async fn stylesheet_applies_model_override() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3262,6 +3328,8 @@ async fn custom_handler_registration_and_execution() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3333,6 +3401,8 @@ async fn integration_smoke_plan_implement_review_done() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3437,6 +3507,8 @@ async fn manager_loop_runs_child_engine_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -3572,6 +3644,8 @@ async fn manager_loop_context_flows_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -3646,6 +3720,8 @@ async fn manager_loop_child_dotfile_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); @@ -3760,6 +3836,8 @@ async fn graph_merge_e2e_through_engine() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -3911,6 +3989,8 @@ async fn fidelity_default_is_compact() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -3968,6 +4048,8 @@ async fn fidelity_graph_default_applied() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4021,6 +4103,8 @@ async fn fidelity_node_overrides_graph_default() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4080,6 +4164,8 @@ async fn fidelity_edge_overrides_node_and_graph() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4129,6 +4215,8 @@ async fn fidelity_full_produces_empty_preamble() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4188,6 +4276,8 @@ async fn fidelity_truncate_preamble_minimal() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4260,6 +4350,8 @@ async fn fidelity_summary_low_mode() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4327,6 +4419,8 @@ async fn fidelity_summary_medium_mode() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4394,6 +4488,8 @@ async fn fidelity_summary_high_mode() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4454,6 +4550,8 @@ async fn fidelity_full_sets_thread_id_in_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4525,6 +4623,8 @@ async fn fidelity_full_nodes_share_thread_id() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4605,6 +4705,8 @@ async fn fidelity_resume_degrades_full_to_summary_high() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4701,6 +4803,8 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4784,6 +4888,8 @@ async fn fidelity_resume_no_degrade_when_not_full() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -4826,6 +4932,8 @@ async fn fidelity_stored_in_checkpoint_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4912,6 +5020,8 @@ async fn fidelity_precedence_multi_node_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -4980,6 +5090,8 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5056,6 +5168,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine_low .run(&graph_low, &config_low) @@ -5124,6 +5238,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine_med .run(&graph_med, &config_med) @@ -5195,6 +5311,8 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5249,6 +5367,8 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5306,6 +5426,8 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5364,6 +5486,8 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5432,6 +5556,8 @@ async fn fidelity_from_parsed_dot_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5480,6 +5606,8 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5550,6 +5678,8 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine.run(&graph, &config).await.expect("run"); @@ -5636,6 +5766,8 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine .run_from_checkpoint(&graph, &config, &checkpoint) @@ -5830,6 +5962,8 @@ mod real_llm { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = tokio::time::timeout( @@ -5945,6 +6079,8 @@ mod real_llm { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = tokio::time::timeout( @@ -6085,6 +6221,8 @@ mod real_llm { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = tokio::time::timeout( @@ -6193,6 +6331,8 @@ mod real_llm { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = tokio::time::timeout( @@ -6290,6 +6430,8 @@ async fn human_gate_freeform_only_routes_text() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -6422,6 +6564,8 @@ async fn human_gate_freeform_with_fixed_choice_match() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -6538,6 +6682,8 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -6668,6 +6814,8 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -6778,6 +6926,8 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -7039,6 +7189,8 @@ fn make_run_config(dir: &std::path::Path) -> RunConfig { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, } } @@ -8176,6 +8328,8 @@ async fn arc_e2e_with_real_llm() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -8305,6 +8459,8 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; engine @@ -8505,6 +8661,8 @@ async fn large_context_values_are_offloaded_to_artifact_store() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -8718,6 +8876,8 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -8849,6 +9009,8 @@ async fn node_dir_uses_visit_count_on_revisit() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -9768,6 +9930,8 @@ async fn full_pipeline_with_cli_backend_node() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -9898,6 +10062,8 @@ async fn stylesheet_backend_property_routes_to_cli() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -10180,6 +10346,8 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // 5. Run pipeline @@ -10370,6 +10538,8 @@ async fn git_checkpoint_host_writes_shadow_branch() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // 5. Run pipeline @@ -10565,6 +10735,8 @@ async fn parallel_git_branching_host_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; // 5. Run pipeline @@ -10828,6 +11000,8 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -11211,6 +11385,8 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11259,6 +11435,8 @@ async fn e2e_circuit_breaker_custom_limit() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11300,6 +11478,8 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11348,6 +11528,8 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11389,6 +11571,8 @@ async fn e2e_circuit_breaker_loop_restart() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11452,6 +11636,8 @@ async fn e2e_failure_signature_persisted_in_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -11517,6 +11703,8 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let _outcome = engine.run(&graph, &config).await.unwrap(); @@ -11574,6 +11762,8 @@ async fn e2e_signature_maps_persist_in_checkpoint() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -11701,6 +11891,8 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11769,6 +11961,8 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.unwrap(); @@ -11866,6 +12060,8 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -11963,6 +12159,8 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12004,6 +12202,8 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12045,6 +12245,8 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12086,6 +12288,8 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12124,6 +12328,8 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12166,6 +12372,8 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12271,6 +12479,8 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let result = engine.run(&graph, &config).await; @@ -12328,6 +12538,8 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -12375,6 +12587,8 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -12441,6 +12655,8 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let start = std::time::Instant::now(); @@ -12572,6 +12788,8 @@ async fn asset_collection_local_sandbox_success() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -12681,6 +12899,8 @@ async fn asset_collection_local_sandbox_on_failure() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -12773,6 +12993,8 @@ async fn asset_collection_docker_sandbox() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine @@ -12843,6 +13065,8 @@ async fn wait_timer_e2e() { checkpoint_exclude_globs: Vec::new(), github_app: None, git_author: arc_workflows::git::GitAuthor::default(), + base_branch: None, + pull_request_enabled: false, }; let outcome = engine.run(&graph, &config).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); diff --git a/docs/api-reference/arc-api.yaml b/docs/api-reference/arc-api.yaml index 5c0a721ef..e9e780f79 100644 --- a/docs/api-reference/arc-api.yaml +++ b/docs/api-reference/arc-api.yaml @@ -3957,6 +3957,8 @@ components: description: Default variable map. checkpoint: $ref: "#/components/schemas/CheckpointConfiguration" + pull_request: + $ref: "#/components/schemas/PullRequestConfiguration" hooks: type: array items: @@ -3980,6 +3982,14 @@ components: type: string description: Glob patterns to exclude from checkpoints. + PullRequestConfiguration: + description: Pull request creation configuration. + type: object + properties: + enabled: + type: boolean + description: Whether to create a pull request after a successful run. + WebConfiguration: description: Web UI configuration. type: object