use anyhow::Result;
use rusqlite::Connection;
use crate::analytics;
use crate::cli::{DeliveryReportArgs, GroupBy, ReportArgs};
use crate::commands::report_layout::{
MetricStatus, ScorecardRow, append_legend, classify_lower_better, classify_ratio_higher_better,
fmt_ratio, fmt_ratio_percent, group_label, render_scorecard, truncate,
};
use crate::commands::report_scope;
use crate::db;
pub fn run(args: DeliveryReportArgs) -> Result<()> {
let db = db::open()?;
analytics::create_reporting_views(&db)?;
let resolved = report_scope::resolve_main_report_args(&args.report, args.overall);
let options = analytics::ReportQueryOptions {
implicit_model_default: resolved.implicit_model_default,
};
let rows = analytics::query_change_report_with_options(&db, &resolved.report, options)?;
let show_branch_hint =
rows.is_empty() && task_report_hidden_branch_rows_exist(&db, &resolved.report, options)?;
print!(
"{}",
render_delivery_report(&rows, &resolved.report, show_branch_hint)
);
Ok(())
}
fn render_delivery_report(
rows: &[analytics::ChangeReportRow],
report: &ReportArgs,
show_branch_hint: bool,
) -> String {
let mut out = String::new();
out.push_str("Delivery Metrics\n");
if rows.is_empty() {
if show_branch_hint {
out.push_str(
"No ticket-style task rows matched. Try `paceflow delivery --group-by branch` or `--overall`.\n",
);
} else {
out.push_str("No delivery rows found. Run `paceflow ingest` first.\n");
}
return out;
}
if !report.weekly && report.group_by.is_none() {
let row = &rows[0];
out.push_str(&format!("Commits analyzed: {}\n", row.commit_count));
out.push_str(&format!(
"Heavy commits: {} ({})\n",
row.heavy_commit_count,
fmt_share(row.heavy_commit_count, row.commit_count)
));
if row.github_pr_heavy_eligible > 0 {
out.push_str(&format!(
"GitHub PR lookup coverage: {} / {} heavy GitHub commits (completed lookup)\n\n",
row.github_pr_heavy_ready, row.github_pr_heavy_eligible
));
} else {
out.push('\n');
}
let scorecard = [
ScorecardRow {
label: "Mainline Reach",
value: fmt_ratio(&row.merge_rate, 2),
status: classify_ratio_higher_better(&row.merge_rate, 75.0, 50.0),
},
ScorecardRow {
label: "PR reach",
value: fmt_optional_ratio(&row.pr_reach_rate, row.github_pr_metrics_available, 2),
status: classify_optional_ratio_higher_better(
&row.pr_reach_rate,
row.github_pr_metrics_available,
70.0,
40.0,
),
},
ScorecardRow {
label: "PR merge",
value: fmt_optional_ratio(&row.pr_merge_rate, row.github_pr_metrics_available, 2),
status: classify_optional_ratio_higher_better(
&row.pr_merge_rate,
row.github_pr_metrics_available,
75.0,
50.0,
),
},
ScorecardRow {
label: "Mainline lead",
value: fmt_hours(row.avg_commit_to_mainline_hours),
status: classify_lower_better(row.avg_commit_to_mainline_hours, 24.0, 72.0),
},
];
out.push_str(&render_scorecard(&scorecard));
append_legend(
&mut out,
&[
"Heavy commits: commits where matched AI-attributed lines are at least half of changed lines.",
"GitHub PR lookup coverage: heavy commits on github.com with completed lookup (resolved or no PR) vs all heavy commits on github.com.",
"PR reach: among completed lookups, share where a pull request existed.",
"Mainline reach: share of heavy AI commits that later reached mainline.",
"Mainline lead: average hours from commit time to mainline reach for heavy merged commits (prefer later mainline_reached_at, else later PR merged_at).",
"PR merge: among PR-linked commits with completed lookup, share whose PR merged.",
"PR reach / merge show N/A when no GitHub-heavy commits or no completed lookups yet.",
"Status: higher is better for all delivery signals.",
],
);
return out;
}
let show_week = report.weekly;
let show_group = report.group_by.is_some();
let show_branch = matches!(report.group_by, Some(GroupBy::Task));
let mut headers = vec![];
out.push('\n');
if show_week {
headers.push(format!("{:<10}", "Week"));
}
if show_group {
headers.push(format!("{:<28}", group_label(report.group_by)));
}
if show_branch {
headers.push(format!("{:<26}", "Branch"));
}
headers.push(format!("{:>8}", "Commits"));
headers.push(format!("{:>8}", "Heavy"));
headers.push(format!("{:>9}", "PR sync"));
headers.push(format!("{:>10}", "PR Reach"));
headers.push(format!("{:>15}", "Mainline Reach"));
headers.push(format!("{:>13}", "Mainline Lead"));
headers.push(format!("{:>12}", "PR Merge"));
if show_branch {
headers.push(format!("{:>12}", "± LOC commits"));
}
out.push_str(&format!("{}\n", headers.join(" ")));
for row in rows {
let mut cols = vec![];
if show_week {
cols.push(format!("{:<10}", row.week_start.as_deref().unwrap_or("-")));
}
if show_group {
cols.push(format!(
"{:<28}",
truncate(row.group_value.as_deref().unwrap_or("(all)"), 28)
));
}
if show_branch {
cols.push(format!(
"{:<26}",
truncate(row.branch_name.as_deref().unwrap_or("-"), 26)
));
}
cols.push(format!("{:>8}", row.commit_count));
cols.push(format!("{:>8}", row.heavy_commit_count));
cols.push(format!("{:>9}", fmt_github_pr_lookup_coverage(row)));
cols.push(format!(
"{:>10}",
fmt_optional_ratio_percent(&row.pr_reach_rate, row.github_pr_metrics_available, 1)
));
cols.push(format!("{:>15}", fmt_ratio_percent(&row.merge_rate, 1)));
cols.push(format!(
"{:>13}",
fmt_hours(row.avg_commit_to_mainline_hours)
));
cols.push(format!(
"{:>14}",
fmt_optional_ratio_percent(&row.pr_merge_rate, row.github_pr_metrics_available, 1)
));
if show_branch {
cols.push(format!(
"{:>12}",
fmt_task_branch_diff_stat(
row.task_branch_lines_added,
row.task_branch_lines_removed
)
));
}
out.push_str(&format!("{}\n", cols.join(" ")));
}
let mut legend: Vec<&'static str> = vec![
"PR sync = completed GitHub PR lookups / heavy commits on github.com (same scope as PR reach).",
"PR Reach and PR Merge use completed lookups only; sync the rest with `PACEFLOW_GITHUB_TOKEN` and ingest.",
"PR Reach, Mainline Reach, and PR Merge = percentage rates (PR merge N/A when no PR-linked commits in the completed set).",
"Mainline Lead = average commit-to-mainline hours in the group; use mainline_reached_at when it is later than commit time, otherwise use PR merged_at when later.",
];
if matches!(report.group_by, Some(GroupBy::Task)) {
legend.push(
"± LOC commits: sum of lines added/removed from ingested git stats (`fact_commit`) for commits attributed to this task branch (not git diff vs staging).",
);
}
append_legend(&mut out, &legend);
out
}
fn fmt_github_pr_lookup_coverage(row: &analytics::ChangeReportRow) -> String {
if row.github_pr_heavy_eligible == 0 {
return "—".to_string();
}
format!(
"{}/{}",
row.github_pr_heavy_ready, row.github_pr_heavy_eligible
)
}
fn fmt_optional_ratio(
metric: &analytics::RatioMetric,
available: bool,
precision: usize,
) -> String {
if !available {
return "N/A".to_string();
}
fmt_ratio(metric, precision)
}
fn fmt_optional_ratio_percent(
metric: &analytics::RatioMetric,
available: bool,
precision: usize,
) -> String {
if !available {
return "N/A".to_string();
}
fmt_ratio_percent(metric, precision)
}
fn classify_optional_ratio_higher_better(
metric: &analytics::RatioMetric,
available: bool,
good_at_least: f64,
watch_at_least: f64,
) -> MetricStatus {
if !available {
return MetricStatus::Unavailable;
}
classify_ratio_higher_better(metric, good_at_least, watch_at_least)
}
fn fmt_share(numerator: i64, denominator: i64) -> String {
if denominator > 0 {
format!(
"{:.2}% of commits",
numerator as f64 / denominator as f64 * 100.0
)
} else {
"N/A".to_string()
}
}
fn fmt_task_branch_diff_stat(added: i64, removed: i64) -> String {
format!("+{}/-{}", added, removed)
}
fn fmt_hours(value: Option<f64>) -> String {
fn trunc_to(value: f64, decimals: u32) -> f64 {
let factor = 10_f64.powi(decimals as i32);
(value * factor).trunc() / factor
}
match value {
Some(hours) if hours < 1.0 => format!("{}m", (hours * 60.0).trunc() as i64),
Some(hours) if hours < 24.0 => format!("{:.1}h", trunc_to(hours, 1)),
Some(hours) => format!("{:.1}d", trunc_to(hours / 24.0, 1)),
None => "N/A".to_string(),
}
}
fn task_report_hidden_branch_rows_exist(
db: &Connection,
report: &ReportArgs,
options: analytics::ReportQueryOptions,
) -> Result<bool> {
if !matches!(report.group_by, Some(GroupBy::Task)) || report.task.is_some() {
return Ok(false);
}
let mut branch_report = report.clone();
branch_report.group_by = Some(GroupBy::Branch);
branch_report.task = None;
branch_report.limit = 1;
Ok(!analytics::query_change_report_with_options(db, &branch_report, options)?.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analytics::{ChangeReportRow, RatioMetric};
use crate::cli::ReportArgs;
#[test]
fn render_delivery_report_uses_scorecard_summary_with_footer() {
let rows = vec![ChangeReportRow {
week_start: None,
group_value: None,
branch_name: None,
repo_root: None,
commit_count: 4,
heavy_commit_count: 2,
github_pr_heavy_eligible: 2,
github_pr_heavy_ready: 2,
pr_reach_rate: RatioMetric {
numerator: 1,
denominator: 2,
},
merge_rate: RatioMetric {
numerator: 2,
denominator: 2,
},
pr_merge_rate: RatioMetric {
numerator: 1,
denominator: 1,
},
github_pr_metrics_available: true,
avg_commit_to_mainline_hours: Some(2.5),
task_branch_lines_added: 0,
task_branch_lines_removed: 0,
}];
let report = ReportArgs {
weekly: false,
group_by: None,
from: None,
to: None,
repo: None,
all_projects: false,
provider: None,
task: None,
branch: None,
model: None,
limit: 50,
};
let rendered = render_delivery_report(&rows, &report, false);
assert!(rendered.contains("Commits analyzed: 4"));
assert!(rendered.contains("Heavy commits: 2 (50.00% of commits)"));
assert!(rendered.contains("GitHub PR lookup coverage: 2 / 2 heavy GitHub commits"));
assert!(rendered.contains("│ Signal"));
assert!(rendered.contains("│ Mainline Reach"));
assert!(rendered.contains("50.00% (1/2)"));
assert!(rendered.contains("100.00% (2/2)"));
assert!(rendered.contains("good"));
assert!(rendered.contains("watch"));
assert!(rendered.contains("Legend:"));
assert!(!rendered.contains("Heavy commits ="));
}
#[test]
fn render_delivery_report_uses_na_for_github_metrics_without_complete_sync() {
let rows = vec![ChangeReportRow {
week_start: None,
group_value: Some("codex".to_string()),
branch_name: None,
repo_root: None,
commit_count: 4,
heavy_commit_count: 2,
github_pr_heavy_eligible: 2,
github_pr_heavy_ready: 0,
pr_reach_rate: RatioMetric {
numerator: 0,
denominator: 2,
},
merge_rate: RatioMetric {
numerator: 1,
denominator: 2,
},
pr_merge_rate: RatioMetric {
numerator: 0,
denominator: 0,
},
github_pr_metrics_available: false,
avg_commit_to_mainline_hours: None,
task_branch_lines_added: 0,
task_branch_lines_removed: 0,
}];
let report = ReportArgs {
weekly: false,
group_by: Some(GroupBy::Provider),
from: None,
to: None,
repo: None,
all_projects: false,
provider: None,
task: None,
branch: None,
model: None,
limit: 50,
};
let rendered = render_delivery_report(&rows, &report, false);
assert!(rendered.contains("PR Reach"));
assert!(rendered.contains("Mainline Reach"));
assert!(rendered.contains("PR Merge"));
assert!(!rendered.contains("Merge Rate"));
assert!(!rendered.contains("C1(PR)"));
assert!(rendered.contains("N/A"));
}
#[test]
fn render_delivery_report_marks_unavailable_pr_metrics_in_summary() {
let rows = vec![ChangeReportRow {
week_start: None,
group_value: None,
branch_name: None,
repo_root: None,
commit_count: 4,
heavy_commit_count: 2,
github_pr_heavy_eligible: 2,
github_pr_heavy_ready: 0,
pr_reach_rate: RatioMetric {
numerator: 0,
denominator: 2,
},
merge_rate: RatioMetric {
numerator: 1,
denominator: 2,
},
pr_merge_rate: RatioMetric {
numerator: 0,
denominator: 0,
},
github_pr_metrics_available: false,
avg_commit_to_mainline_hours: None,
task_branch_lines_added: 0,
task_branch_lines_removed: 0,
}];
let report = ReportArgs {
weekly: false,
group_by: None,
from: None,
to: None,
repo: None,
all_projects: false,
provider: None,
task: None,
branch: None,
model: None,
limit: 50,
};
let rendered = render_delivery_report(&rows, &report, false);
assert!(rendered.contains("unavailable"));
assert!(rendered.contains("GitHub PR lookup coverage: 0 / 2 heavy GitHub commits"));
assert!(rendered.contains(
"PR reach / merge show N/A when no GitHub-heavy commits or no completed lookups yet.",
));
}
#[test]
fn render_delivery_report_suggests_branch_view_for_hidden_task_rows() {
let report = ReportArgs {
weekly: false,
group_by: Some(GroupBy::Task),
from: None,
to: None,
repo: None,
all_projects: false,
provider: Some("claude".to_string()),
task: None,
branch: None,
model: None,
limit: 50,
};
let rendered = render_delivery_report(&[], &report, true);
assert!(rendered.contains("No ticket-style task rows matched."));
assert!(rendered.contains("`paceflow delivery --group-by branch`"));
assert!(!rendered.contains("Run `paceflow ingest` first."));
}
}