fabro/lib/crates/fabro-workflow/src/pipeline/pull_request.rs
2026-04-01 22:57:27 -07:00

1361 lines
45 KiB
Rust

use std::path::Path;
use fabro_config::run::MergeStrategy;
use fabro_store::RunStore;
use fabro_types::PullRequestRecord;
use tracing::{debug, info};
use fabro_github::{self as github_app, GitHubAppCredentials, ssh_url_to_https};
use fabro_graphviz::parser;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_util::text::strip_goal_decoration;
use super::types::{Concluded, Finalized, PullRequestOptions};
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::outcome::{StageStatus, format_cost as outcome_format_cost};
use crate::records::{Conclusion, RunRecord, RunRecordExt};
use fabro_retro::retro::Retro;
/// 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 stripped = strip_goal_decoration(goal);
if stripped.chars().count() > 120 {
let truncated: String = stripped.chars().take(119).collect();
format!("{truncated}…")
} else {
stripped.to_string()
}
}
/// Truncate a PR body to fit GitHub's 65,536 character limit.
fn truncate_pr_body(body: &str) -> String {
const MAX_BODY: usize = 65_536;
const SUFFIX: &str = "\n\n_(truncated)_";
if body.len() <= MAX_BODY {
return body.to_string();
}
let cutoff = body.floor_char_boundary(MAX_BODY - SUFFIX.len());
format!("{}{SUFFIX}", &body[..cutoff])
}
/// Format an optional cost as `$X.XX` or an en-dash when absent.
fn format_cost(cost: Option<f64>) -> String {
cost.map_or_else(|| "\u{2013}".to_string(), outcome_format_cost)
}
/// Format a duration in milliseconds as a human-readable string.
fn format_duration_ms(ms: u64) -> String {
let secs = ms / 1000;
if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
}
}
/// Format the Retro section of the PR body.
///
/// Renders stats, friction points, and open items. Omits sub-sections when empty.
fn format_retro_section(retro: &Retro) -> String {
let mut parts = Vec::new();
parts.push("### Retro".to_string());
parts.push(String::new());
// Stats summary
parts.push(format!(
"* {} stages completed, {} failed, {} retries",
retro.stats.stages_completed, retro.stats.stages_failed, retro.stats.total_retries
));
parts.push(format!(
"* {} files modified",
retro.stats.files_touched.len()
));
// Friction points
if let Some(ref fps) = retro.friction_points {
if !fps.is_empty() {
parts.push(String::new());
parts.push("**Friction points:**".to_string());
parts.push(String::new());
for fp in fps {
parts.push(format!("* {}", fp.description));
}
}
}
// Open items
if let Some(ref items) = retro.open_items {
if !items.is_empty() {
parts.push(String::new());
parts.push("**Open items:**".to_string());
parts.push(String::new());
for item in items {
parts.push(format!("* {}", item.description));
}
}
}
parts.join("\n")
}
/// Format the Fabro Details section of the PR body.
///
/// Renders a cost/duration table in a collapsible `<details>` block, and
/// optionally a workflow graph summary in another `<details>` block.
fn format_arc_details_section(
conclusion: &Conclusion,
run_record: Option<&RunRecord>,
dot_source: Option<&str>,
) -> String {
let mut parts = Vec::new();
parts.push("### Fabro Details".to_string());
parts.push(String::new());
// Cost table
let total_duration = format_duration_ms(conclusion.duration_ms);
let total_cost_str = format_cost(conclusion.total_cost);
let stage_count = conclusion.stages.len();
parts.push(format!(
"<details>\n<summary>Ran {stage_count} {} in {total_duration} for {total_cost_str}</summary>",
if stage_count == 1 { "stage" } else { "stages" }
));
parts.push(String::new());
parts.push("| Stage | Duration | Cost | Retries |".to_string());
parts.push("|---|---|---|---|".to_string());
for stage in &conclusion.stages {
let dur = format_duration_ms(stage.duration_ms);
let cost = format_cost(stage.cost);
parts.push(format!(
"| {} | {} | {} | {} |",
stage.stage_label, dur, cost, stage.retries
));
}
// Total row
let total_retries = conclusion.total_retries;
parts.push(format!(
"| **Total** | **{total_duration}** | **{total_cost_str}** | **{total_retries}** |"
));
parts.push(String::new());
parts.push("</details>".to_string());
// Workflow graph summary — prefer RunRecord's graph, fall back to DOT parsing
if let Some(record) = run_record {
let graph_name = format!("{}.fabro", record.workflow_name());
let node_count = record.node_count();
let edge_count = record.edge_count();
parts.push(String::new());
parts.push(format!(
"<details>\n<summary>Ran <code>{graph_name}</code> ({node_count} {} and {edge_count} {})</summary>",
if node_count == 1 { "node" } else { "nodes" },
if edge_count == 1 { "edge" } else { "edges" }
));
if let Some(dot) = dot_source {
parts.push(String::new());
parts.push("```dot".to_string());
parts.push(dot.to_string());
parts.push("```".to_string());
}
parts.push(String::new());
parts.push("</details>".to_string());
} else if let Some(dot) = dot_source {
parts.push(String::new());
// Extract graph name and count nodes/edges for the summary
let (graph_name, node_count, edge_count) = parse_dot_summary(dot);
parts.push(format!(
"<details>\n<summary>Ran <code>{graph_name}</code> ({node_count} {} and {edge_count} {})</summary>",
if node_count == 1 { "node" } else { "nodes" },
if edge_count == 1 { "edge" } else { "edges" }
));
parts.push(String::new());
parts.push("```dot".to_string());
parts.push(dot.to_string());
parts.push("```".to_string());
parts.push(String::new());
parts.push("</details>".to_string());
}
parts.join("\n")
}
/// Parse a DOT source string to extract graph name, node count, and edge count.
fn parse_dot_summary(dot: &str) -> (String, usize, usize) {
match parser::parse(dot) {
Ok(graph) => (
format!("{}.fabro", graph.name),
graph.nodes.len(),
graph.edges.len(),
),
Err(_) => ("workflow.fabro".to_string(), 0, 0),
}
}
#[cfg(test)]
fn read_dot_source(run_dir: &Path) -> Option<String> {
let workflow_fabro_path = run_dir.join("workflow.fabro");
if let Ok(content) = std::fs::read_to_string(&workflow_fabro_path) {
debug!(path = %workflow_fabro_path.display(), "Read workflow graph for PR body");
return Some(content);
}
let legacy_fabro_path = run_dir.join("graph.fabro");
if let Ok(content) = std::fs::read_to_string(&legacy_fabro_path) {
debug!(path = %legacy_fabro_path.display(), "Read workflow graph for PR body (legacy)");
return Some(content);
}
let dot_path = run_dir.join("graph.dot");
match std::fs::read_to_string(&dot_path) {
Ok(content) => {
debug!(path = %dot_path.display(), "Read workflow graph for PR body (dot fallback)");
Some(content)
}
Err(_) => None,
}
}
/// Read plan text from the first `nodes/plan*/response.md` found in run_dir.
///
/// Entries are sorted alphabetically so `plan` is preferred over `planning`.
fn read_plan_text(run_dir: &Path) -> Option<String> {
let nodes_dir = run_dir.join("nodes");
let mut entries: Vec<_> = std::fs::read_dir(&nodes_dir).ok()?.flatten().collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let dir_name = entry.file_name();
let dir_name_str = dir_name.to_string_lossy();
if dir_name_str.starts_with("plan") && entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let response_path = entry.path().join("response.md");
if let Ok(content) = std::fs::read_to_string(&response_path) {
debug!(node_dir = %dir_name_str, "Found plan node response for PR body");
return Some(content);
}
}
}
None
}
/// Assemble the full PR body from LLM output and programmatic sections.
fn assemble_pr_body(
llm_output: &str,
plan_text: Option<&str>,
retro_section: &str,
arc_details_section: &str,
) -> String {
let mut parts = Vec::new();
parts.push(llm_output.to_string());
if let Some(plan) = plan_text {
parts.push(String::new());
parts.push("<details>".to_string());
parts.push("<summary>Full plan</summary>".to_string());
parts.push(String::new());
parts.push("````md".to_string());
parts.push(plan.to_string());
parts.push("````".to_string());
parts.push(String::new());
parts.push("</details>".to_string());
}
if !retro_section.is_empty() {
parts.push(String::new());
parts.push(retro_section.to_string());
}
if !arc_details_section.is_empty() {
parts.push(String::new());
parts.push(arc_details_section.to_string());
}
parts.push(String::new());
parts.push("\u{2692}\u{fe0f} Generated with [Fabro](https://fabro.sh)".to_string());
parts.join("\n")
}
fn emit_run_notice(
emitter: &EventEmitter,
level: RunNoticeLevel,
code: impl Into<String>,
message: impl Into<String>,
) {
emitter.emit(&WorkflowRunEvent::RunNotice {
level,
code: code.into(),
message: message.into(),
});
}
async fn load_pull_request_diff(run_store: Option<&dyn RunStore>, run_dir: &Path) -> String {
match run_store {
Some(run_store) => run_store
.get_final_patch()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to load final patch from store for PR");
})
.ok()
.flatten()
.unwrap_or_default(),
None => {
let _ = run_dir;
String::new()
}
}
}
/// Build a complete PR body by combining LLM-generated narrative with
/// programmatic sections (plan, retro, fabro details).
pub async fn build_pr_body(
diff: &str,
goal: &str,
model: &str,
run_store: Option<&dyn RunStore>,
run_dir: &Path,
conclusion: Option<&Conclusion>,
) -> Result<String, String> {
debug!("Building PR body");
let plan_text = read_plan_text(run_dir);
let loaded_conclusion = if conclusion.is_none() {
match run_store {
Some(run_store) => run_store
.get_conclusion()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to load conclusion from store for PR body");
})
.ok()
.flatten(),
None => None,
}
} else {
None
};
let conclusion = conclusion.or(loaded_conclusion.as_ref());
let retro = match run_store {
Some(run_store) => run_store
.get_retro()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to load retro from store for PR body");
})
.ok()
.flatten(),
None => None,
};
let run_record = match run_store {
Some(run_store) => run_store
.get_run()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to load run record from store for PR body");
})
.ok()
.flatten(),
None => None,
};
let dot_source = match run_store {
Some(run_store) => run_store
.get_graph()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to load graph from store for PR body");
})
.ok()
.flatten(),
None => None,
};
// Build LLM prompt
let system = if plan_text.is_some() {
"Write a PR description with: (1) 2-3 concise paragraphs explaining the change, then (2) a '### Plan Summary' section with bullet points summarizing the plan. Do not include a title. Do not include the full plan.".to_string()
} else {
"Write a concise PR description in 2-3 paragraphs explaining the change. Do not include a title.".to_string()
};
// Truncate diff to fit context windows (~50k chars)
let max_diff_len = 50_000;
let truncated_diff = if diff.len() > max_diff_len {
&diff[..diff.floor_char_boundary(max_diff_len)]
} else {
diff
};
let prompt = if let Some(ref plan) = plan_text {
// Truncate plan for LLM context (~20k chars)
let max_plan_len = 20_000;
let truncated_plan = if plan.len() > max_plan_len {
&plan[..plan.floor_char_boundary(max_plan_len)]
} else {
plan.as_str()
};
format!(
"Goal: {goal}\n\nPlan:\n```\n{truncated_plan}\n```\n\nDiff:\n```\n{truncated_diff}\n```"
)
} else {
format!("Goal: {goal}\n\nDiff:\n```\n{truncated_diff}\n```")
};
let params = GenerateParams::new(model).system(system).prompt(prompt);
let result = generate(params)
.await
.map_err(|e| format!("LLM generation failed: {e}"))?;
let llm_output = result.response.text();
let retro_section = retro.as_ref().map(format_retro_section).unwrap_or_default();
let arc_details_section = conclusion
.as_ref()
.map(|c| format_arc_details_section(c, run_record.as_ref(), dot_source.as_deref()))
.unwrap_or_default();
let body = assemble_pr_body(
&llm_output,
plan_text.as_deref(),
&retro_section,
&arc_details_section,
);
info!("PR body generated");
Ok(body)
}
/// Auto-merge configuration for a pull request.
pub struct AutoMergeOptions {
pub merge_strategy: MergeStrategy,
}
/// Optionally open a pull request after a successful workflow run.
///
/// Returns `Ok(Some(PullRequestRecord))` if a PR was created, `Ok(None)` if
/// the diff was empty, or `Err` on failure.
#[allow(clippy::too_many_arguments)]
pub async fn maybe_open_pull_request(
creds: &GitHubAppCredentials,
origin_url: &str,
base_branch: &str,
head_branch: &str,
goal: &str,
diff: &str,
model: &str,
draft: bool,
auto_merge: Option<AutoMergeOptions>,
run_store: Option<&dyn RunStore>,
run_dir: &Path,
conclusion: Option<&Conclusion>,
) -> Result<Option<PullRequestRecord>, String> {
if diff.is_empty() {
debug!("Empty diff, skipping pull request creation");
return Ok(None);
}
let https_url = ssh_url_to_https(origin_url);
let (owner, repo) = github_app::parse_github_owner_repo(&https_url)?;
let body = build_pr_body(diff, goal, model, run_store, run_dir, conclusion).await?;
let body = truncate_pr_body(&body);
let title = pr_title_from_goal(goal);
let created = github_app::create_pull_request(
creds,
&owner,
&repo,
base_branch,
head_branch,
&title,
&body,
draft,
&github_app::github_api_base_url(),
)
.await?;
info!(pr_url = %created.html_url, created.number, "Pull request created");
if let Some(am_cfg) = auto_merge {
let merge_method = match am_cfg.merge_strategy {
MergeStrategy::Squash => github_app::AutoMergeMethod::Squash,
MergeStrategy::Merge => github_app::AutoMergeMethod::Merge,
MergeStrategy::Rebase => github_app::AutoMergeMethod::Rebase,
};
match github_app::enable_auto_merge(
creds,
&owner,
&repo,
&created.node_id,
merge_method,
&github_app::github_api_base_url(),
)
.await
{
Ok(()) => {
info!(pr_number = created.number, "Auto-merge enabled");
}
Err(e) => {
tracing::warn!(
pr_number = created.number,
error = %e,
"Failed to enable auto-merge (repo may not have auto-merge enabled in settings)"
);
}
}
}
let record = PullRequestRecord {
html_url: created.html_url,
number: created.number,
owner,
repo,
base_branch: base_branch.to_string(),
head_branch: head_branch.to_string(),
title,
};
if let Some(run_store) = run_store {
run_store
.put_pull_request(&record)
.await
.map_err(|err| format!("failed to persist pull request in run store: {err}"))?;
}
Ok(Some(record))
}
/// PULL_REQUEST phase: optionally create a pull request after finalize.
///
/// This stage is infallible: failures are emitted and logged, but the pipeline completes.
pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) -> Finalized {
let Concluded {
run_id,
outcome,
conclusion,
pushed_branch,
graph,
run_options,
emitter,
} = concluded;
let mut pr_url = None;
if let Some(pr_cfg) = &options.pr_config {
if run_options.dry_run_enabled() {
tracing::debug!("Skipping PR creation: run is in dry-run mode");
} else if let Err(ref e) = outcome {
tracing::debug!(error = %e, "Skipping PR creation: engine returned an error");
} else if let Ok(ref result) = outcome {
if matches!(
result.status,
StageStatus::Success | StageStatus::PartialSuccess
) {
let diff =
load_pull_request_diff(options.run_store.as_deref(), &options.run_dir).await;
if let (Some(base_branch), Some(run_branch), Some(creds), Some(origin)) = (
&run_options.base_branch,
pushed_branch.as_deref(),
&options.github_app,
&options.origin_url,
) {
let auto_merge = if pr_cfg.auto_merge {
Some(AutoMergeOptions {
merge_strategy: pr_cfg.merge_strategy,
})
} else {
None
};
match maybe_open_pull_request(
creds,
origin,
base_branch,
run_branch,
graph.goal(),
&diff,
&options.model,
pr_cfg.draft,
auto_merge,
options.run_store.as_deref(),
&options.run_dir,
Some(&conclusion),
)
.await
{
Ok(Some(record)) => {
emitter.emit(&WorkflowRunEvent::PullRequestCreated {
pr_url: record.html_url.clone(),
pr_number: record.number,
draft: pr_cfg.draft,
});
pr_url = Some(record.html_url.clone());
}
Ok(None) => {}
Err(e) => {
emitter.emit(&WorkflowRunEvent::PullRequestFailed { error: e.clone() });
emit_run_notice(
&emitter,
RunNoticeLevel::Warn,
"pull_request_failed",
format!("PR creation failed: {e}"),
);
}
}
}
}
}
}
Finalized {
run_id,
outcome,
conclusion,
pushed_branch,
pr_url,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Once};
use super::*;
use crate::records::StageSummary;
use chrono::Utc;
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_llm::client::Client;
use fabro_llm::error::SdkError;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::set_default_client;
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
use fabro_retro::retro::{
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
};
use fabro_store::{InMemoryStore, Store};
use fabro_types::RunRecord;
use fabro_types::fixtures;
use futures::stream;
struct MockProvider {
response_text: String,
}
impl MockProvider {
fn new(text: &str) -> Self {
Self {
response_text: text.to_string(),
}
}
}
#[async_trait::async_trait]
impl ProviderAdapter for MockProvider {
fn name(&self) -> &str {
"mock"
}
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
Ok(Response {
id: "resp_1".into(),
model: "mock-model".into(),
provider: "mock".into(),
message: Message::assistant(&self.response_text),
finish_reason: FinishReason::Stop,
usage: Usage {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
})
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
let text = self.response_text.clone();
let events = vec![
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
Response {
id: "resp_1".into(),
model: "mock-model".into(),
provider: "mock".into(),
message: Message::assistant(&text),
finish_reason: FinishReason::Stop,
usage: Usage {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
},
)),
];
Ok(Box::pin(stream::iter(events)))
}
}
fn install_mock_llm() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let mut providers: HashMap<String, Arc<dyn ProviderAdapter>> = HashMap::new();
providers.insert(
"mock".to_string(),
Arc::new(MockProvider::new("Narrative from mock.")),
);
set_default_client(Client::new(providers, Some("mock".to_string()), vec![]));
});
}
fn make_test_conclusion() -> Conclusion {
Conclusion {
timestamp: Utc::now(),
status: crate::outcome::StageStatus::Success,
duration_ms: 150_000,
failure_reason: None,
final_git_commit_sha: None,
stages: vec![
StageSummary {
stage_id: "plan".to_string(),
stage_label: "plan".to_string(),
duration_ms: 45_000,
cost: Some(0.12),
retries: 0,
},
StageSummary {
stage_id: "implement".to_string(),
stage_label: "implement".to_string(),
duration_ms: 90_000,
cost: Some(0.25),
retries: 0,
},
StageSummary {
stage_id: "simplify".to_string(),
stage_label: "simplify".to_string(),
duration_ms: 15_000,
cost: Some(0.05),
retries: 0,
},
],
total_cost: Some(0.42),
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: true,
}
}
fn make_test_retro() -> Retro {
Retro {
run_id: fixtures::RUN_1,
workflow_name: "implement".to_string(),
goal: "Fix the bug".to_string(),
timestamp: Utc::now(),
smoothness: None,
stages: vec![
StageRetro {
stage_id: "plan".to_string(),
stage_label: "plan".to_string(),
status: "success".to_string(),
duration_ms: 45_000,
retries: 0,
cost: Some(0.12),
notes: None,
failure_reason: None,
files_touched: vec![],
},
StageRetro {
stage_id: "implement".to_string(),
stage_label: "implement".to_string(),
status: "success".to_string(),
duration_ms: 90_000,
retries: 0,
cost: Some(0.25),
notes: None,
failure_reason: None,
files_touched: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
},
StageRetro {
stage_id: "simplify".to_string(),
stage_label: "simplify".to_string(),
status: "success".to_string(),
duration_ms: 15_000,
retries: 0,
cost: Some(0.05),
notes: None,
failure_reason: None,
files_touched: vec![],
},
],
stats: AggregateStats {
total_duration_ms: 150_000,
total_cost: Some(0.42),
total_retries: 0,
files_touched: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
stages_completed: 3,
stages_failed: 0,
},
intent: None,
outcome: None,
learnings: None,
friction_points: Some(vec![
FrictionPoint {
kind: FrictionKind::ToolFailure,
description: "Daytona sandbox didn't have cargo on PATH".to_string(),
stage_id: None,
},
FrictionPoint {
kind: FrictionKind::Timeout,
description: "Proxy timeouts during cold compilations".to_string(),
stage_id: None,
},
]),
open_items: Some(vec![OpenItem {
kind: OpenItemKind::TechDebt,
description: "`ToolApprovalFn` type alias still exists".to_string(),
}]),
}
}
// ── format_retro_section tests ──────────────────────────────────────
#[test]
fn format_retro_section_full() {
let retro = make_test_retro();
let section = format_retro_section(&retro);
assert!(section.contains("### Retro"));
assert!(section.contains("3 stages completed, 0 failed, 0 retries"));
assert!(section.contains("2 files modified"));
assert!(section.contains("**Friction points:**"));
assert!(section.contains("Daytona sandbox didn't have cargo on PATH"));
assert!(section.contains("Proxy timeouts during cold compilations"));
assert!(section.contains("**Open items:**"));
assert!(section.contains("`ToolApprovalFn` type alias still exists"));
}
#[test]
fn format_retro_section_no_friction_no_open() {
let mut retro = make_test_retro();
retro.friction_points = None;
retro.open_items = None;
let section = format_retro_section(&retro);
assert!(section.contains("### Retro"));
assert!(section.contains("3 stages completed"));
assert!(!section.contains("**Friction points:**"));
assert!(!section.contains("**Open items:**"));
}
#[test]
fn format_retro_section_empty_stats() {
let retro = Retro {
run_id: fixtures::RUN_2,
workflow_name: "test".to_string(),
goal: "test".to_string(),
timestamp: Utc::now(),
smoothness: None,
stages: vec![],
stats: AggregateStats {
total_duration_ms: 0,
total_cost: None,
total_retries: 0,
files_touched: vec![],
stages_completed: 0,
stages_failed: 0,
},
intent: None,
outcome: None,
learnings: None,
friction_points: None,
open_items: None,
};
let section = format_retro_section(&retro);
assert!(section.contains("0 stages completed, 0 failed, 0 retries"));
assert!(section.contains("0 files modified"));
}
// ── format_arc_details_section tests ────────────────────────────────
#[test]
fn format_arc_details_cost_table() {
let conclusion = make_test_conclusion();
let section = format_arc_details_section(&conclusion, None, None);
assert!(section.contains("### Fabro Details"));
assert!(section.contains("Ran 3 stages in 2m 30s for $0.42"));
assert!(section.contains("| plan | 45s | $0.12 | 0 |"));
assert!(section.contains("| implement | 1m 30s | $0.25 | 0 |"));
assert!(section.contains("| simplify | 15s | $0.05 | 0 |"));
assert!(section.contains("| **Total** | **2m 30s** | **$0.42** | **0** |"));
}
#[test]
fn format_arc_details_no_cost() {
let mut conclusion = make_test_conclusion();
for stage in &mut conclusion.stages {
stage.cost = None;
}
conclusion.total_cost = None;
let section = format_arc_details_section(&conclusion, None, None);
// En-dash for missing costs
assert!(section.contains("| plan | 45s | \u{2013} | 0 |"));
assert!(section.contains("for \u{2013}"));
}
#[test]
fn format_arc_details_with_dot_graph() {
let conclusion = make_test_conclusion();
let dot = "digraph implement {\n plan [type=\"agent\"]\n code [type=\"agent\"]\n plan -> code\n}\n";
let section = format_arc_details_section(&conclusion, None, Some(dot));
assert!(section.contains("<code>implement.fabro</code>"));
assert!(section.contains("2 nodes and 1 edge"));
assert!(section.contains("```dot"));
assert!(section.contains("digraph implement"));
}
// ── read_plan_text tests ────────────────────────────────────────────
#[test]
fn read_plan_text_found() {
let tmp = tempfile::tempdir().unwrap();
let plan_dir = tmp.path().join("nodes").join("plan");
std::fs::create_dir_all(&plan_dir).unwrap();
std::fs::write(plan_dir.join("response.md"), "This is the plan").unwrap();
let result = read_plan_text(tmp.path());
assert_eq!(result, Some("This is the plan".to_string()));
}
#[test]
fn read_plan_text_prefix_match() {
let tmp = tempfile::tempdir().unwrap();
let plan_dir = tmp.path().join("nodes").join("planning");
std::fs::create_dir_all(&plan_dir).unwrap();
std::fs::write(plan_dir.join("response.md"), "Planning content").unwrap();
let result = read_plan_text(tmp.path());
assert_eq!(result, Some("Planning content".to_string()));
}
#[test]
fn read_plan_text_not_found() {
let tmp = tempfile::tempdir().unwrap();
let nodes_dir = tmp.path().join("nodes").join("implement");
std::fs::create_dir_all(nodes_dir).unwrap();
let result = read_plan_text(tmp.path());
assert_eq!(result, None);
}
#[test]
fn read_plan_text_no_nodes_dir() {
let tmp = tempfile::tempdir().unwrap();
let result = read_plan_text(tmp.path());
assert_eq!(result, None);
}
// ── assemble_pr_body tests ──────────────────────────────────────────
#[test]
fn assemble_all_sections() {
let body = assemble_pr_body(
"This is the narrative.\n\n### Plan Summary\n\n* Step 1\n* Step 2",
Some("Full plan text here"),
"### Retro\n\n* 3 stages completed",
"### Fabro Details\n\n<details>...</details>",
);
assert!(body.contains("This is the narrative."));
assert!(body.contains("### Plan Summary"));
assert!(body.contains("<details>\n<summary>Full plan</summary>"));
assert!(body.contains("````md\nFull plan text here\n````"));
assert!(body.contains("### Retro"));
assert!(body.contains("### Fabro Details"));
}
#[test]
fn assemble_no_plan() {
let body = assemble_pr_body(
"Narrative only.",
None,
"### Retro\n\n* stats",
"### Fabro Details\n\n<details>...</details>",
);
assert!(body.contains("Narrative only."));
assert!(!body.contains("Full plan"));
assert!(body.contains("### Retro"));
assert!(body.contains("### Fabro Details"));
}
#[test]
fn assemble_no_retro() {
let body = assemble_pr_body("Narrative only.", Some("Plan"), "", "");
assert!(body.contains("Narrative only."));
assert!(body.contains("Full plan"));
// Empty sections should not produce extra headers
assert!(!body.contains("### Retro"));
assert!(!body.contains("### Fabro Details"));
}
#[test]
fn assemble_narrative_only() {
let body = assemble_pr_body("Just the narrative.", None, "", "");
assert_eq!(
body,
"Just the narrative.\n\n\u{2692}\u{fe0f} Generated with [Fabro](https://fabro.sh)"
);
}
#[test]
fn assemble_conclusion_without_retro() {
let conclusion = make_test_conclusion();
let arc_details = format_arc_details_section(&conclusion, None, None);
let body = assemble_pr_body("Narrative.", None, "", &arc_details);
assert!(body.contains("### Fabro Details"));
assert!(body.contains("Ran 3 stages"));
assert!(!body.contains("### Retro"));
}
#[test]
fn assemble_both_conclusion_and_retro() {
let conclusion = make_test_conclusion();
let retro = make_test_retro();
let retro_section = format_retro_section(&retro);
let arc_details = format_arc_details_section(&conclusion, None, None);
let body = assemble_pr_body("Narrative.", None, &retro_section, &arc_details);
assert!(body.contains("### Retro"));
assert!(body.contains("### Fabro Details"));
}
#[tokio::test]
async fn build_pr_body_uses_in_memory_conclusion() {
install_mock_llm();
let tmp = tempfile::tempdir().unwrap();
let conclusion = make_test_conclusion();
let body = build_pr_body(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
"mock-model",
None,
tmp.path(),
Some(&conclusion),
)
.await
.unwrap();
assert!(body.contains("Narrative from mock."));
assert!(body.contains("### Fabro Details"));
assert!(body.contains("Ran 3 stages in 2m 30s for $0.42"));
assert!(body.contains("| **Total** | **2m 30s** | **$0.42** | **0** |"));
}
#[tokio::test]
async fn build_pr_body_uses_store_records_without_legacy_files() {
install_mock_llm();
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let created_at = Utc::now();
let run_store = store
.create_run(
&fixtures::RUN_1,
created_at,
Some(&tmp.path().display().to_string()),
)
.await
.unwrap();
run_store
.put_run(&RunRecord {
run_id: fixtures::RUN_1,
created_at,
settings: FabroSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::new(),
})
.await
.unwrap();
run_store
.put_graph("digraph test { plan -> code }")
.await
.unwrap();
run_store.put_retro(&make_test_retro()).await.unwrap();
let conclusion = make_test_conclusion();
let body = build_pr_body(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
"mock-model",
Some(run_store.as_ref()),
tmp.path(),
Some(&conclusion),
)
.await
.unwrap();
assert!(body.contains("Narrative from mock."));
assert!(body.contains("### Retro"));
assert!(body.contains("### Fabro Details"));
assert!(body.contains("test.fabro"));
}
// ── parse_dot_summary tests ─────────────────────────────────────────
#[test]
fn parse_dot_summary_basic() {
let dot = r#"digraph my_workflow {
plan [type="agent"]
code [type="agent"]
plan -> code
}"#;
let (name, nodes, edges) = parse_dot_summary(dot);
assert_eq!(name, "my_workflow.fabro");
assert_eq!(nodes, 2);
assert_eq!(edges, 1);
}
#[test]
fn parse_dot_summary_empty() {
let (name, nodes, edges) = parse_dot_summary("");
assert_eq!(name, "workflow.fabro");
assert_eq!(nodes, 0);
assert_eq!(edges, 0);
}
// ── format_duration_ms tests ────────────────────────────────────────
#[test]
fn format_duration_seconds() {
assert_eq!(format_duration_ms(45_000), "45s");
}
#[test]
fn format_duration_minutes() {
assert_eq!(format_duration_ms(150_000), "2m 30s");
}
#[test]
fn format_duration_zero() {
assert_eq!(format_duration_ms(0), "0s");
}
// ── read_dot_source tests ───────────────────────────────────────────
#[test]
fn read_dot_source_found() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("workflow.fabro"), "digraph test {}").unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, Some("digraph test {}".to_string()));
}
#[test]
fn read_dot_source_legacy_fabro_fallback() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("graph.fabro"), "digraph legacy {}").unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, Some("digraph legacy {}".to_string()));
}
#[test]
fn read_dot_source_dot_fallback() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("graph.dot"), "digraph old {}").unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, Some("digraph old {}".to_string()));
}
#[test]
fn read_dot_source_not_found() {
let tmp = tempfile::tempdir().unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, None);
}
// ── Existing 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");
}
#[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_strips_plan_prefix() {
assert_eq!(
pr_title_from_goal("Plan: Add Draft PR Mode"),
"Add Draft PR Mode"
);
}
#[test]
fn pr_title_strips_heading_and_plan_prefix() {
assert_eq!(
pr_title_from_goal("## Plan: Add Draft PR Mode"),
"Add Draft PR Mode"
);
}
#[test]
fn pr_title_strips_h3_prefix() {
assert_eq!(
pr_title_from_goal("### Add Draft PR Mode"),
"Add Draft PR Mode"
);
}
#[test]
fn pr_title_truncates_long_line() {
let long = "x".repeat(300);
let title = pr_title_from_goal(&long);
assert_eq!(title.chars().count(), 120);
assert!(title.ends_with('…'));
}
#[test]
fn pr_body_truncates_long_body() {
let long = "x".repeat(70_000);
let body = truncate_pr_body(&long);
assert!(body.len() <= 65_536);
assert!(body.ends_with("\n\n_(truncated)_"));
}
#[test]
fn pr_body_short_body_unchanged() {
let short = "Some PR description";
assert_eq!(truncate_pr_body(short), short);
}
#[test]
fn pr_title_short_goal_unchanged() {
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: "fabro/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"], "fabro/run/abc");
assert_eq!(content["title"], "Fix the thing");
}
#[tokio::test]
async fn empty_diff_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let creds = GitHubAppCredentials {
app_id: "123".to_string(),
private_key_pem: "unused".to_string(),
};
let result = maybe_open_pull_request(
&creds,
"https://github.com/owner/repo.git",
"main",
"fabro/run/123",
"Fix bug",
"",
"claude-sonnet-4-20250514",
false,
None,
None,
tmp.path(),
None,
)
.await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn load_pull_request_diff_uses_store_without_disk_patch() {
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let run_store = store
.create_run(
&fixtures::RUN_1,
Utc::now(),
Some(&tmp.path().display().to_string()),
)
.await
.unwrap();
run_store
.put_final_patch("diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n")
.await
.unwrap();
let diff = load_pull_request_diff(Some(run_store.as_ref()), tmp.path()).await;
assert!(diff.contains("from_store"));
}
}