mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
4155d4e5c5
commit
e2c25cbfcd
6 changed files with 581 additions and 91 deletions
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
336
lib/crates/arc-workflows/src/cli/pr.rs
Normal file
336
lib/crates/arc-workflows/src/cli/pr.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue