diff --git a/lib/crates/arc-cli/src/main.rs b/lib/crates/arc-cli/src/main.rs index 1b902f0ff..a979eec30 100644 --- a/lib/crates/arc-cli/src/main.rs +++ b/lib/crates/arc-cli/src/main.rs @@ -78,6 +78,11 @@ enum Command { Setup, /// List workflow runs Ps(arc_workflows::cli::runs::RunsListArgs), + /// Pull request operations + Pr { + #[command(subcommand)] + command: PrCommand, + }, /// System maintenance commands System { #[command(subcommand)] @@ -85,6 +90,12 @@ enum Command { }, } +#[derive(Subcommand)] +enum PrCommand { + /// Create a pull request from a completed run + Create(arc_workflows::cli::pr::PrCreateArgs), +} + #[derive(Subcommand)] enum SystemCommand { /// Delete old workflow runs @@ -141,6 +152,7 @@ async fn main() -> Result<()> { Command::Doctor { .. } => "doctor", Command::Setup => "setup", Command::Ps(_) => "ps", + Command::Pr { .. } => "pr", Command::System { .. } => "system", }; @@ -331,6 +343,13 @@ async fn main() -> Result<()> { Command::Ps(args) => { arc_workflows::cli::runs::list_command(&args)?; } + Command::Pr { command } => match command { + PrCommand::Create(args) => { + let server_config = arc_api::server_config::load_server_config(None)?; + let github_app = build_github_app_credentials(&server_config); + arc_workflows::cli::pr::pr_create_command(args, github_app).await?; + } + }, Command::System { command } => match command { SystemCommand::Prune(args) => { arc_workflows::cli::runs::prune_command(&args)?; diff --git a/lib/crates/arc-github/src/lib.rs b/lib/crates/arc-github/src/lib.rs index 9f5b0f60a..b13cd81e9 100644 --- a/lib/crates/arc-github/src/lib.rs +++ b/lib/crates/arc-github/src/lib.rs @@ -313,6 +313,41 @@ pub fn ssh_url_to_https(url: &str) -> String { url.to_string() } +/// Check whether a branch exists in a GitHub repository. +/// +/// Uses a GitHub App installation token to query the branches API. +/// Returns `true` if the branch exists, `false` if it doesn't (404). +pub async fn branch_exists( + creds: &GitHubAppCredentials, + owner: &str, + repo: &str, + branch: &str, + base_url: &str, +) -> Result { + let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; + let client = reqwest::Client::new(); + + let token = create_installation_access_token(&client, &jwt, owner, repo, base_url).await?; + + let url = format!("{base_url}/repos/{owner}/{repo}/branches/{branch}"); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .send() + .await + .map_err(|e| format!("Failed to check branch existence: {e}"))?; + + match resp.status().as_u16() { + 200 => Ok(true), + 404 => Ok(false), + status => Err(format!( + "Unexpected status {status} checking branch '{branch}'" + )), + } +} + /// Resolve git clone credentials for a GitHub repository. /// /// Returns `(username, password)` for authenticated cloning. @@ -658,4 +693,102 @@ mod tests { token_mock.assert_async().await; } + + // ----------------------------------------------------------------------- + // branch_exists + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn branch_exists_returns_true_on_200() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("GET", "/repos/owner/repo/branches/my-branch") + .with_status(200) + .with_body(r#"{"name": "my-branch"}"#) + .create_async() + .await; + + let pem = test_rsa_key(); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: pem, + }; + let result = branch_exists(&creds, "owner", "repo", "my-branch", &server.url()).await; + assert_eq!(result.unwrap(), true); + } + + #[tokio::test] + async fn branch_exists_returns_false_on_404() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("GET", "/repos/owner/repo/branches/no-such-branch") + .with_status(404) + .create_async() + .await; + + let pem = test_rsa_key(); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: pem, + }; + let result = branch_exists(&creds, "owner", "repo", "no-such-branch", &server.url()).await; + assert_eq!(result.unwrap(), false); + } + + #[tokio::test] + async fn branch_exists_returns_error_on_500() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/1/access_tokens") + .with_status(201) + .with_body(r#"{"token": "ghs_test"}"#) + .create_async() + .await; + server + .mock("GET", "/repos/owner/repo/branches/broken") + .with_status(500) + .create_async() + .await; + + let pem = test_rsa_key(); + let creds = GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: pem, + }; + let result = branch_exists(&creds, "owner", "repo", "broken", &server.url()).await; + assert!(result.is_err()); + } } diff --git a/lib/crates/arc-workflows/src/cli/cp.rs b/lib/crates/arc-workflows/src/cli/cp.rs index b0f99d34f..99db75987 100644 --- a/lib/crates/arc-workflows/src/cli/cp.rs +++ b/lib/crates/arc-workflows/src/cli/cp.rs @@ -2,9 +2,9 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use clap::Args; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; -use crate::cli::runs::{default_logs_base, scan_runs}; +use crate::cli::runs::{default_logs_base, find_run_by_prefix}; use crate::sandbox_record::SandboxRecord; #[derive(Args)] @@ -73,34 +73,6 @@ fn split_run_path(s: &str) -> Option<(&str, &str)> { s.split_once(':') } -/// Find a run directory by prefix match against run IDs. -fn find_run_by_prefix(base: &Path, prefix: &str) -> Result { - let runs = scan_runs(base).context("Failed to scan runs")?; - let matches: Vec<_> = runs - .iter() - .filter(|r| r.run_id.starts_with(prefix)) - .collect(); - - match matches.len() { - 0 => { - warn!(run_id = %prefix, "No matching run found"); - bail!("No run found matching prefix '{prefix}'") - } - 1 => { - let run = &matches[0]; - debug!(run_id = %prefix, matched = %run.run_id, "Resolved run by prefix"); - Ok(run.path.clone()) - } - n => { - let ids: Vec<&str> = matches.iter().map(|r| r.run_id.as_str()).collect(); - bail!( - "Ambiguous prefix '{prefix}': {n} runs match: {}", - ids.join(", ") - ) - } - } -} - /// Reconnect to a sandbox from a saved record. /// /// Returns a sandbox that can perform file operations. @@ -396,63 +368,4 @@ mod tests { _ => panic!("Expected Upload"), } } - - #[test] - fn find_run_by_prefix_no_match() { - let dir = tempfile::tempdir().unwrap(); - let result = find_run_by_prefix(dir.path(), "nonexistent"); - assert!(result.is_err()); - } - - #[test] - fn find_run_by_prefix_single_match() { - let dir = tempfile::tempdir().unwrap(); - let run_dir = dir.path().join("20260101-ABC123"); - std::fs::create_dir_all(&run_dir).unwrap(); - std::fs::write( - run_dir.join("manifest.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "run_id": "abc123-full-id", - "workflow_name": "test", - "goal": "", - "start_time": "2026-01-01T12:00:00Z", - "node_count": 1, - "edge_count": 0 - })) - .unwrap(), - ) - .unwrap(); - - let result = find_run_by_prefix(dir.path(), "abc123").unwrap(); - assert_eq!(result, run_dir); - } - - #[test] - fn find_run_by_prefix_ambiguous() { - let dir = tempfile::tempdir().unwrap(); - for (subdir, run_id) in [("d1", "abc-111"), ("d2", "abc-222")] { - let run_dir = dir.path().join(subdir); - std::fs::create_dir_all(&run_dir).unwrap(); - std::fs::write( - run_dir.join("manifest.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "run_id": run_id, - "workflow_name": "test", - "goal": "", - "start_time": "2026-01-01T12:00:00Z", - "node_count": 1, - "edge_count": 0 - })) - .unwrap(), - ) - .unwrap(); - } - - let result = find_run_by_prefix(dir.path(), "abc"); - assert!(result.is_err()); - assert!( - result.unwrap_err().to_string().contains("Ambiguous"), - "Should mention ambiguity" - ); - } } diff --git a/lib/crates/arc-workflows/src/cli/mod.rs b/lib/crates/arc-workflows/src/cli/mod.rs index 8be9cb766..7efe228f2 100644 --- a/lib/crates/arc-workflows/src/cli/mod.rs +++ b/lib/crates/arc-workflows/src/cli/mod.rs @@ -2,6 +2,7 @@ pub mod backend; pub mod cli_backend; pub mod cp; pub mod parse; +pub mod pr; pub mod progress; pub mod run; pub mod run_config; diff --git a/lib/crates/arc-workflows/src/cli/pr.rs b/lib/crates/arc-workflows/src/cli/pr.rs new file mode 100644 index 000000000..31f4fcfe0 --- /dev/null +++ b/lib/crates/arc-workflows/src/cli/pr.rs @@ -0,0 +1,336 @@ +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use clap::Args; +use tracing::info; + +use crate::cli::runs::{default_logs_base, find_run_by_prefix}; +use crate::conclusion::Conclusion; +use crate::manifest::Manifest; +use crate::outcome::StageStatus; + +#[derive(Args)] +pub struct PrCreateArgs { + /// Run ID or prefix + pub run_id: String, + /// LLM model for generating PR description + #[arg(long)] + pub model: Option, +} + +pub async fn pr_create_command( + args: PrCreateArgs, + github_app: Option, +) -> Result<()> { + let base = default_logs_base(); + pr_create_from(&base, args, github_app).await +} + +async fn pr_create_from( + base: &Path, + args: PrCreateArgs, + github_app: Option, +) -> Result<()> { + let run_dir = find_run_by_prefix(base, &args.run_id)?; + + let manifest = + Manifest::load(&run_dir.join("manifest.json")).context("Failed to load manifest.json")?; + + let conclusion = Conclusion::load(&run_dir.join("conclusion.json")) + .context("Failed to load conclusion.json — is the run finished?")?; + + match conclusion.status { + StageStatus::Success | StageStatus::PartialSuccess => {} + status => bail!("Run status is '{status}', expected success or partial_success"), + } + + let run_branch = manifest + .run_branch + .as_deref() + .context("Run has no run_branch — was it run with git push enabled?")?; + + let diff = std::fs::read_to_string(run_dir.join("final.patch")) + .context("Failed to read final.patch — no diff available")?; + if diff.trim().is_empty() { + bail!("final.patch is empty — nothing to create a PR for"); + } + + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let (origin_url, detected_branch) = + crate::daytona_sandbox::detect_repo_info(&cwd).map_err(|e| anyhow::anyhow!("{e}"))?; + + let base_branch = manifest + .base_branch + .as_deref() + .or(detected_branch.as_deref()) + .unwrap_or("main"); + + let https_url = arc_github::ssh_url_to_https(&origin_url); + let (owner, repo) = + arc_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; + + let creds = github_app.context( + "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", + )?; + + let branch_found = + arc_github::branch_exists(&creds, &owner, &repo, run_branch, "https://api.github.com") + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if !branch_found { + bail!( + "Branch '{run_branch}' not found on GitHub. \ + Was it pushed? Try: git push origin {run_branch}" + ); + } + + let model = args + .model + .unwrap_or_else(|| arc_llm::catalog::default_model().id.to_string()); + + let url = crate::pull_request::maybe_open_pull_request( + &creds, + &origin_url, + base_branch, + run_branch, + &manifest.goal, + &diff, + &model, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + match url { + Some(url) => { + info!(pr_url = %url, "Pull request created"); + println!("{url}"); + } + None => { + println!("No pull request created (empty diff)."); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_test_run( + base: &Path, + manifest_json: serde_json::Value, + conclusion_json: Option, + diff: Option<&str>, + ) -> String { + let run_id = manifest_json["run_id"].as_str().unwrap(); + let dir_name = format!("20260101-{}", &run_id[..6].to_uppercase()); + let run_dir = base.join(&dir_name); + fs::create_dir_all(&run_dir).unwrap(); + fs::write( + run_dir.join("manifest.json"), + serde_json::to_string_pretty(&manifest_json).unwrap(), + ) + .unwrap(); + if let Some(c) = conclusion_json { + fs::write( + run_dir.join("conclusion.json"), + serde_json::to_string_pretty(&c).unwrap(), + ) + .unwrap(); + } + if let Some(d) = diff { + fs::write(run_dir.join("final.patch"), d).unwrap(); + } + run_id.to_string() + } + + #[tokio::test] + async fn pr_create_fails_missing_conclusion() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0, + "run_branch": "arc/run/abc123" + }), + None, + Some("diff content"), + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("conclusion"), "got: {err}"); + } + + #[tokio::test] + async fn pr_create_fails_on_failed_run() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0, + "run_branch": "arc/run/abc123" + }), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:01:00Z", + "status": "fail", + "duration_ms": 60000 + })), + Some("diff content"), + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("fail"), "got: {err}"); + } + + #[tokio::test] + async fn pr_create_fails_missing_run_branch() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0 + }), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:01:00Z", + "status": "success", + "duration_ms": 60000 + })), + Some("diff content"), + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("run_branch"), "got: {err}"); + } + + #[tokio::test] + async fn pr_create_fails_missing_diff() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0, + "run_branch": "arc/run/abc123" + }), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:01:00Z", + "status": "success", + "duration_ms": 60000 + })), + None, + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("final.patch"), "got: {err}"); + } + + #[tokio::test] + async fn pr_create_fails_empty_diff() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0, + "run_branch": "arc/run/abc123" + }), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:01:00Z", + "status": "success", + "duration_ms": 60000 + })), + Some(" \n "), + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("empty"), "got: {err}"); + } + + #[tokio::test] + async fn pr_create_fails_missing_github_creds() { + let tmp = tempfile::tempdir().unwrap(); + let run_id = make_test_run( + tmp.path(), + serde_json::json!({ + "run_id": "abc123-test", + "workflow_name": "test", + "goal": "fix bug", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0, + "run_branch": "arc/run/abc123" + }), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:01:00Z", + "status": "success", + "duration_ms": 60000 + })), + Some("diff content"), + ); + + let args = PrCreateArgs { + run_id, + model: None, + }; + let result = pr_create_from(tmp.path(), args, None).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("GitHub App"), "got: {err}"); + } +} diff --git a/lib/crates/arc-workflows/src/cli/runs.rs b/lib/crates/arc-workflows/src/cli/runs.rs index afde94821..7ae94d749 100644 --- a/lib/crates/arc-workflows/src/cli/runs.rs +++ b/lib/crates/arc-workflows/src/cli/runs.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{bail, Context, Result}; use clap::Args; use serde::Serialize; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; #[derive(Args)] pub struct RunFilterArgs { @@ -190,6 +190,34 @@ pub(crate) fn default_logs_base() -> PathBuf { .join("logs") } +/// Find a run directory by prefix match against run IDs. +pub fn find_run_by_prefix(base: &Path, prefix: &str) -> Result { + let runs = scan_runs(base).context("Failed to scan runs")?; + let matches: Vec<_> = runs + .iter() + .filter(|r| r.run_id.starts_with(prefix)) + .collect(); + + match matches.len() { + 0 => { + warn!(run_id = %prefix, "No matching run found"); + bail!("No run found matching prefix '{prefix}'") + } + 1 => { + let run = &matches[0]; + debug!(run_id = %prefix, matched = %run.run_id, "Resolved run by prefix"); + Ok(run.path.clone()) + } + n => { + let ids: Vec<&str> = matches.iter().map(|r| r.run_id.as_str()).collect(); + bail!( + "Ambiguous prefix '{prefix}': {n} runs match: {}", + ids.join(", ") + ) + } + } +} + pub fn list_command(args: &RunsListArgs) -> Result<()> { let base = default_logs_base(); let runs = scan_runs(&base)?; @@ -660,4 +688,64 @@ mod tests { let result = parse_label_filters(&args); assert!(result.is_empty()); } + + #[test] + fn find_run_by_prefix_no_match() { + let dir = tempfile::tempdir().unwrap(); + let result = find_run_by_prefix(dir.path(), "nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn find_run_by_prefix_single_match() { + let dir = tempfile::tempdir().unwrap(); + let run_dir = dir.path().join("20260101-ABC123"); + fs::create_dir_all(&run_dir).unwrap(); + fs::write( + run_dir.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "run_id": "abc123-full-id", + "workflow_name": "test", + "goal": "", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0 + })) + .unwrap(), + ) + .unwrap(); + + let result = find_run_by_prefix(dir.path(), "abc123").unwrap(); + assert_eq!(result, run_dir); + } + + #[test] + fn find_run_by_prefix_ambiguous() { + let dir = tempfile::tempdir().unwrap(); + let subdirs = [("d1", "abc-111"), ("d2", "abc-222")]; + for (subdir, run_id) in subdirs { + let run_dir = dir.path().join(subdir); + fs::create_dir_all(&run_dir).unwrap(); + fs::write( + run_dir.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "run_id": run_id, + "workflow_name": "test", + "goal": "", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0 + })) + .unwrap(), + ) + .unwrap(); + } + + let result = find_run_by_prefix(dir.path(), "abc"); + assert!(result.is_err()); + assert!( + result.unwrap_err().to_string().contains("Ambiguous"), + "Should mention ambiguity" + ); + } }