Add arc pr create command to create PRs from completed runs

Allows creating a pull request after the fact for any completed workflow
run, using the persisted manifest, conclusion, and diff from the run's
log directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 14:55:58 -04:00
parent 4155d4e5c5
commit e2c25cbfcd
6 changed files with 581 additions and 91 deletions

View file

@ -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)?;

View file

@ -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<bool, String> {
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());
}
}

View file

@ -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<PathBuf> {
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"
);
}
}

View file

@ -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;

View file

@ -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<String>,
}
pub async fn pr_create_command(
args: PrCreateArgs,
github_app: Option<arc_github::GitHubAppCredentials>,
) -> 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<arc_github::GitHubAppCredentials>,
) -> 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<serde_json::Value>,
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}");
}
}

View file

@ -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<PathBuf> {
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"
);
}
}