diff --git a/lib/crates/arc-workflows/src/cli/pr.rs b/lib/crates/arc-workflows/src/cli/pr.rs index 31f4fcfe0..04d711855 100644 --- a/lib/crates/arc-workflows/src/cli/pr.rs +++ b/lib/crates/arc-workflows/src/cli/pr.rs @@ -89,7 +89,7 @@ async fn pr_create_from( .model .unwrap_or_else(|| arc_llm::catalog::default_model().id.to_string()); - let url = crate::pull_request::maybe_open_pull_request( + let record = crate::pull_request::maybe_open_pull_request( &creds, &origin_url, base_branch, @@ -101,10 +101,13 @@ async fn pr_create_from( .await .map_err(|e| anyhow::anyhow!("{e}"))?; - match url { - Some(url) => { - info!(pr_url = %url, "Pull request created"); - println!("{url}"); + match record { + Some(record) => { + info!(pr_url = %record.html_url, "Pull request created"); + if let Err(e) = record.save(&run_dir.join("pull_request.json")) { + tracing::warn!(error = %e, "Failed to save pull_request.json"); + } + println!("{}", record.html_url); } None => { println!("No pull request created (empty diff)."); diff --git a/lib/crates/arc-workflows/src/cli/run.rs b/lib/crates/arc-workflows/src/cli/run.rs index 5d7e1b7c5..859951443 100644 --- a/lib/crates/arc-workflows/src/cli/run.rs +++ b/lib/crates/arc-workflows/src/cli/run.rs @@ -1080,8 +1080,15 @@ pub async fn run_command( ) .await { - Ok(Some(url)) => { - eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); + Ok(Some(record)) => { + eprintln!( + "{} {}", + styles.bold.apply_to("Pull request:"), + record.html_url + ); + if let Err(e) = record.save(&logs_dir.join("pull_request.json")) { + tracing::warn!(error = %e, "Failed to save pull_request.json"); + } } Ok(None) => {} // empty diff, logged at DEBUG Err(e) => { diff --git a/lib/crates/arc-workflows/src/pull_request.rs b/lib/crates/arc-workflows/src/pull_request.rs index 5e9192284..00deab162 100644 --- a/lib/crates/arc-workflows/src/pull_request.rs +++ b/lib/crates/arc-workflows/src/pull_request.rs @@ -1,12 +1,39 @@ +use std::path::Path; + +use serde::Serialize; use tracing::{debug, info}; use arc_github::{self as github_app, ssh_url_to_https, GitHubAppCredentials}; +/// Record of a pull request created for a workflow run. +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestRecord { + pub html_url: String, + pub number: u64, + pub owner: String, + pub repo: String, + pub base_branch: String, + pub head_branch: String, + pub title: String, +} + +impl PullRequestRecord { + pub fn save(&self, path: &Path) -> Result<(), String> { + let json = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize pull_request.json: {e}"))?; + std::fs::write(path, json).map_err(|e| format!("Failed to write pull_request.json: {e}")) + } +} + /// Derive a PR title from the workflow goal. /// /// Uses the first line, truncated to 120 characters for readability. fn pr_title_from_goal(goal: &str) -> String { let first_line = goal.lines().next().unwrap_or(goal); + let first_line = first_line + .strip_prefix("## ") + .or_else(|| first_line.strip_prefix("# ")) + .unwrap_or(first_line); if first_line.chars().count() > 120 { let truncated: String = first_line.chars().take(119).collect(); format!("{truncated}…") @@ -53,8 +80,8 @@ pub async fn generate_pr_body(diff: &str, goal: &str, model: &str) -> Result Result, String> { +) -> Result, String> { if diff.is_empty() { debug!("Empty diff, skipping pull request creation"); return Ok(None); @@ -77,7 +104,7 @@ pub async fn maybe_open_pull_request( let title = pr_title_from_goal(goal); - let (url, pr_number) = github_app::create_pull_request( + let (html_url, number) = github_app::create_pull_request( creds, &owner, &repo, @@ -88,9 +115,17 @@ pub async fn maybe_open_pull_request( ) .await?; - info!(pr_url = %url, pr_number, "Pull request created"); + info!(pr_url = %html_url, number, "Pull request created"); - Ok(Some(url)) + Ok(Some(PullRequestRecord { + html_url, + number, + owner, + repo, + base_branch: base_branch.to_string(), + head_branch: head_branch.to_string(), + title, + })) } #[cfg(test)] @@ -99,8 +134,32 @@ mod tests { #[test] fn pr_title_uses_first_line() { - let goal = "# Add Draft PR Mode\n\nMore details here..."; - assert_eq!(pr_title_from_goal(goal), "# Add Draft PR Mode"); + let goal = "Add Draft PR Mode\n\nMore details here..."; + assert_eq!(pr_title_from_goal(goal), "Add Draft PR Mode"); + } + + #[test] + fn pr_title_strips_h1_prefix() { + assert_eq!( + pr_title_from_goal("# Add Draft PR Mode"), + "Add Draft PR Mode" + ); + } + + #[test] + fn pr_title_strips_h2_prefix() { + assert_eq!( + pr_title_from_goal("## Add Draft PR Mode"), + "Add Draft PR Mode" + ); + } + + #[test] + fn pr_title_does_not_strip_h3_prefix() { + assert_eq!( + pr_title_from_goal("### Add Draft PR Mode"), + "### Add Draft PR Mode" + ); } #[test] @@ -130,6 +189,32 @@ mod tests { assert_eq!(pr_title_from_goal("Fix bug"), "Fix bug"); } + #[test] + fn pull_request_record_save_writes_json() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pull_request.json"); + let record = PullRequestRecord { + html_url: "https://github.com/owner/repo/pull/42".to_string(), + number: 42, + owner: "owner".to_string(), + repo: "repo".to_string(), + base_branch: "main".to_string(), + head_branch: "arc/run/abc".to_string(), + title: "Fix the thing".to_string(), + }; + record.save(&path).unwrap(); + + let content: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(content["html_url"], "https://github.com/owner/repo/pull/42"); + assert_eq!(content["number"], 42); + assert_eq!(content["owner"], "owner"); + assert_eq!(content["repo"], "repo"); + assert_eq!(content["base_branch"], "main"); + assert_eq!(content["head_branch"], "arc/run/abc"); + assert_eq!(content["title"], "Fix the thing"); + } + #[tokio::test] async fn empty_diff_returns_none() { let creds = GitHubAppCredentials {