use super::ledger::{
load_ledger, save_ledger, TodoEffortStats, TodoIssueStatus, TodoLedger, TodoLedgerEntry,
};
use super::resolve_scan_root;
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::commands::worktree_watch::{
load_repo_commit_activity, load_repo_mutation_activity, CommitActivityEvent,
MutationActivityEvent,
};
use chrono::{DateTime, Utc};
use colored::Colorize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const DEFAULT_SESSION_GAP_MINUTES: u64 = 15;
#[derive(Debug, Clone)]
pub struct EffortRecomputeOptions {
pub path: Option<PathBuf>,
pub session_gap_minutes: u64,
pub json: bool,
pub persist: bool,
}
pub async fn run_effort(opts: EffortRecomputeOptions) -> Result<(), String> {
let root = resolve_scan_root(opts.path.as_deref())?;
let report = recompute_effort(&root, opts.session_gap_minutes, opts.persist)?;
if opts.json {
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
);
return Ok(());
}
print_effort_report(&report);
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EffortReport {
pub project_root: String,
pub session_gap_minutes: u64,
pub computed_at: String,
pub open_issues: usize,
pub done_issues: usize,
pub total_estimated_coding_seconds: u64,
pub issues: Vec<IssueEffortRow>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueEffortRow {
pub fingerprint: String,
pub kind: String,
pub text: String,
pub paths: Vec<String>,
pub status: String,
pub linear: Option<String>,
pub github: Option<u64>,
pub opened_at: Option<String>,
pub closed_at: Option<String>,
pub estimated_coding_seconds: u64,
pub event_count: u64,
pub commit_count: u64,
pub added_lines: u64,
pub removed_lines: u64,
pub first_activity_at: Option<String>,
pub last_activity_at: Option<String>,
}
pub fn recompute_effort(
project_root: &Path,
session_gap_minutes: u64,
persist: bool,
) -> Result<EffortReport, String> {
let mut ledger = load_ledger(project_root)?;
let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
let now = Utc::now().to_rfc3339();
let mut by_repo: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
for (fp, entry) in &ledger.entries {
if entry.linear.is_none() && entry.github.is_none() {
continue;
}
let owner = entry
.repo_owner
.clone()
.or_else(|| infer_owner_from_github_url(entry))
.unwrap_or_default();
let name = entry
.repo_name
.clone()
.or_else(|| infer_name_from_github_url(entry))
.unwrap_or_default();
if owner.is_empty() || name.is_empty() {
continue;
}
by_repo.entry((owner, name)).or_default().push(fp.clone());
}
for entry in ledger.entries.values_mut() {
if entry.linear.is_none() && entry.github.is_none() {
continue;
}
entry.effort = TodoEffortStats {
computed_at: Some(now.clone()),
..Default::default()
};
}
for ((owner, name), fingerprints) in &by_repo {
let mutations = load_repo_mutation_activity(owner, name).unwrap_or_default();
let commits = load_repo_commit_activity(owner, name).unwrap_or_default();
let mut path_to_fps: HashMap<String, Vec<String>> = HashMap::new();
for fp in fingerprints {
let Some(entry) = ledger.entries.get(fp) else {
continue;
};
for path in entry.all_tracked_paths() {
path_to_fps.entry(normalize_path(&path)).or_default().push(fp.clone());
}
}
attribute_mutations(&mut ledger, &mutations, &path_to_fps, gap_seconds);
attribute_commits(&mut ledger, &commits, &path_to_fps, project_root);
}
if persist {
save_ledger(project_root, &ledger)?;
write_effort_snapshot(project_root, &ledger, session_gap_minutes)?;
}
Ok(build_report(project_root, &ledger, session_gap_minutes, &now))
}
fn attribute_mutations(
ledger: &mut TodoLedger,
mutations: &[MutationActivityEvent],
path_to_fps: &HashMap<String, Vec<String>>,
gap_seconds: u64,
) {
let mut per_issue_events: BTreeMap<String, Vec<&MutationActivityEvent>> = BTreeMap::new();
for event in mutations {
let mut hit_fps: BTreeSet<String> = BTreeSet::new();
for path in event_paths(event) {
if let Some(fps) = path_to_fps.get(&path) {
for fp in fps {
hit_fps.insert(fp.clone());
}
}
}
for fp in hit_fps {
if let Some(entry) = ledger.entries.get(&fp) {
if event_in_window(event.occurred_at, entry) {
per_issue_events.entry(fp).or_default().push(event);
}
}
}
}
for (fp, mut events) in per_issue_events {
events.sort_by_key(|e| e.occurred_at);
let Some(entry) = ledger.entries.get_mut(&fp) else {
continue;
};
let mut previous_at: Option<DateTime<Utc>> = None;
for event in events {
let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
previous_at = Some(event.occurred_at);
entry.effort.estimated_coding_seconds += seconds;
entry.effort.event_count += 1;
entry.effort.added_lines += event.added_lines.unwrap_or(0);
entry.effort.removed_lines += event.removed_lines.unwrap_or(0);
let at = event.occurred_at.to_rfc3339();
if entry.effort.first_activity_at.is_none() {
entry.effort.first_activity_at = Some(at.clone());
}
entry.effort.last_activity_at = Some(at);
}
}
}
fn attribute_commits(
ledger: &mut TodoLedger,
commits: &[CommitActivityEvent],
path_to_fps: &HashMap<String, Vec<String>>,
project_root: &Path,
) {
let mut sha_files: HashMap<String, Vec<String>> = HashMap::new();
for commit in commits {
let files = sha_files
.entry(commit.head_sha.clone())
.or_insert_with(|| {
commit_paths_for_sha(
Path::new(&commit.repo_root),
project_root,
&commit.head_sha,
)
});
if files.is_empty() {
continue;
}
let mut hit_fps: BTreeSet<String> = BTreeSet::new();
for path in files {
if let Some(fps) = path_to_fps.get(&normalize_path(path)) {
for fp in fps {
hit_fps.insert(fp.clone());
}
}
}
for fp in hit_fps {
let Some(entry) = ledger.entries.get_mut(&fp) else {
continue;
};
if !event_in_window(commit.occurred_at, entry) {
continue;
}
entry.effort.commit_count += 1;
let at = commit.occurred_at.to_rfc3339();
if entry.effort.first_activity_at.is_none() {
entry.effort.first_activity_at = Some(at.clone());
}
if entry
.effort
.last_activity_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.map(|prev| commit.occurred_at > prev)
.unwrap_or(true)
{
entry.effort.last_activity_at = Some(at);
}
}
}
}
fn commit_paths_for_sha(repo_root_hint: &Path, project_root: &Path, sha: &str) -> Vec<String> {
let roots = [repo_root_hint, project_root];
for root in roots {
if !root.exists() {
continue;
}
let output = Command::new("git")
.current_dir(root)
.args(["show", "--name-only", "--pretty=format:", sha])
.output();
let Ok(output) = output else {
continue;
};
if !output.status.success() {
continue;
}
let text = String::from_utf8_lossy(&output.stdout);
let paths: Vec<String> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| normalize_path(l))
.collect();
if !paths.is_empty() {
return paths;
}
}
Vec::new()
}
fn event_paths(event: &MutationActivityEvent) -> Vec<String> {
let mut paths: BTreeSet<String> = event.paths.iter().map(|p| normalize_path(p)).collect();
if let Some(p) = &event.primary_path {
paths.insert(normalize_path(p));
}
paths.into_iter().collect()
}
fn event_in_window(occurred_at: DateTime<Utc>, entry: &TodoLedgerEntry) -> bool {
if let Some(opened) = entry
.opened_at
.as_deref()
.or(entry.created_at.as_deref())
.and_then(parse_rfc3339)
{
if occurred_at < opened {
return false;
}
}
if !entry.is_open() {
if let Some(closed) = entry.closed_at.as_deref().and_then(parse_rfc3339) {
if occurred_at > closed {
return false;
}
} else {
return false;
}
}
true
}
fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn coding_seconds_for_event(
previous_at: Option<DateTime<Utc>>,
occurred_at: DateTime<Utc>,
gap_seconds: u64,
) -> u64 {
let Some(previous_at) = previous_at else {
return 60;
};
let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
if delta <= 0 {
0
} else {
(delta as u64).min(gap_seconds)
}
}
fn normalize_path(path: &str) -> String {
path.replace('\\', "/").trim().trim_start_matches("./").to_string()
}
fn infer_owner_from_github_url(entry: &TodoLedgerEntry) -> Option<String> {
let url = entry.github.as_ref()?.url.as_deref()?;
parse_owner_repo_from_html(url).map(|(o, _)| o)
}
fn infer_name_from_github_url(entry: &TodoLedgerEntry) -> Option<String> {
let url = entry.github.as_ref()?.url.as_deref()?;
parse_owner_repo_from_html(url).map(|(_, n)| n)
}
fn parse_owner_repo_from_html(url: &str) -> Option<(String, String)> {
let url = url.trim().trim_end_matches('/');
let rest = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))?;
let mut parts = rest.split('/');
let owner = parts.next()?.to_string();
let name = parts.next()?.to_string();
if owner.is_empty() || name.is_empty() {
None
} else {
Some((owner, name))
}
}
fn write_effort_snapshot(
project_root: &Path,
ledger: &TodoLedger,
session_gap_minutes: u64,
) -> Result<(), String> {
let report = build_report(
project_root,
ledger,
session_gap_minutes,
&Utc::now().to_rfc3339(),
);
let path = project_root.join(".xbp").join("todo-effort.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
let content = serde_json::to_string_pretty(&report)
.map_err(|e| format!("Failed to serialize effort snapshot: {e}"))?;
fs::write(&path, content + "\n")
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(())
}
fn build_report(
project_root: &Path,
ledger: &TodoLedger,
session_gap_minutes: u64,
computed_at: &str,
) -> EffortReport {
let mut issues = Vec::new();
let mut open_issues = 0usize;
let mut done_issues = 0usize;
let mut total = 0u64;
for (fp, entry) in &ledger.entries {
if entry.linear.is_none() && entry.github.is_none() {
continue;
}
if entry.is_open() {
open_issues += 1;
} else {
done_issues += 1;
}
total += entry.effort.estimated_coding_seconds;
issues.push(IssueEffortRow {
fingerprint: fp.clone(),
kind: entry.kind.clone(),
text: entry.text.clone(),
paths: entry.all_tracked_paths().into_iter().collect(),
status: match entry.status {
TodoIssueStatus::Open if entry.closed_at.is_none() => "open".into(),
_ => "done".into(),
},
linear: entry.linear.as_ref().map(|l| l.identifier.clone()),
github: entry.github.as_ref().map(|g| g.number),
opened_at: entry.opened_at.clone().or_else(|| entry.created_at.clone()),
closed_at: entry.closed_at.clone(),
estimated_coding_seconds: entry.effort.estimated_coding_seconds,
event_count: entry.effort.event_count,
commit_count: entry.effort.commit_count,
added_lines: entry.effort.added_lines,
removed_lines: entry.effort.removed_lines,
first_activity_at: entry.effort.first_activity_at.clone(),
last_activity_at: entry.effort.last_activity_at.clone(),
});
}
issues.sort_by(|a, b| {
b.estimated_coding_seconds
.cmp(&a.estimated_coding_seconds)
.then_with(|| b.event_count.cmp(&a.event_count))
});
EffortReport {
project_root: project_root.display().to_string(),
session_gap_minutes,
computed_at: computed_at.to_string(),
open_issues,
done_issues,
total_estimated_coding_seconds: total,
issues,
}
}
fn print_effort_report(report: &EffortReport) {
println!();
println!("{}", "TODO → issue effort".bright_cyan().bold());
println!(
" open={} done={} total_coding={}",
report.open_issues,
report.done_issues,
format_duration(report.total_estimated_coding_seconds)
);
if report.issues.is_empty() {
println!("{}", "No linked TODO issues in the ledger.".dimmed());
return;
}
let rows: Vec<Vec<String>> = report
.issues
.iter()
.map(|i| {
let id = i
.linear
.clone()
.or_else(|| i.github.map(|n| format!("#{n}")))
.unwrap_or_else(|| i.fingerprint.chars().take(8).collect());
vec![
id,
i.status.clone(),
format_duration(i.estimated_coding_seconds),
i.event_count.to_string(),
i.commit_count.to_string(),
i.paths.first().cloned().unwrap_or_else(|| "-".into()),
truncate(&i.text, 40),
]
})
.collect();
print!(
"{}",
render_table(
&["Issue", "Status", "Coding", "Edits", "Commits", "Path", "Text"],
&rows,
TableStyle::Pipe,
"",
)
);
println!(
"{}",
"Coding time uses worktree-watch session gaps on tracked paths, only while the issue is open."
.dimmed()
);
}
fn format_duration(seconds: u64) -> String {
format_duration_public(seconds)
}
pub(crate) fn format_duration_public(seconds: u64) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if hours > 0 {
format!("{hours}h{minutes:02}m")
} else if minutes > 0 {
format!("{minutes}m{secs:02}s")
} else {
format!("{secs}s")
}
}
fn truncate(s: &str, max: usize) -> String {
let mut out: String = s.chars().take(max).collect();
if s.chars().count() > max {
out.push('…');
}
out
}
pub fn default_session_gap_minutes() -> u64 {
DEFAULT_SESSION_GAP_MINUTES
}