Save pull request metadata to pull_request.json after PR creation

Return a PullRequestRecord struct from maybe_open_pull_request instead
of a bare URL, persist it as JSON in the run/logs directory, and strip
markdown heading prefixes (# / ##) from generated PR titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 15:09:07 -04:00
parent 56c91b1d66
commit d1523ea36f
3 changed files with 110 additions and 15 deletions

View file

@ -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).");

View file

@ -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) => {

View file

@ -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<Str
/// 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.
/// Returns `Ok(Some(PullRequestRecord))` 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,
@ -63,7 +90,7 @@ pub async fn maybe_open_pull_request(
goal: &str,
diff: &str,
model: &str,
) -> Result<Option<String>, String> {
) -> Result<Option<PullRequestRecord>, 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 {