use super::ledger::{
load_ledger, save_ledger, TodoIssueStatus, TodoLedger,
};
use super::resolve_scan_root;
use chrono::Utc;
use colored::Colorize;
use std::path::PathBuf;
pub struct ReconcileOptions {
pub path: Option<PathBuf>,
pub dry_run: bool,
}
pub async fn run_reconcile(opts: ReconcileOptions) -> Result<(), String> {
let root = resolve_scan_root(opts.path.as_deref())?;
let mut ledger = load_ledger(&root)?;
let (closed, checked, errors) = reconcile_ledger(&mut ledger).await?;
if !opts.dry_run {
save_ledger(&root, &ledger)?;
}
println!();
println!("{}", "TODO issue reconcile".bright_cyan().bold());
println!(
" checked={} newly_done={} errors={}{}",
checked,
closed,
errors,
if opts.dry_run {
" (dry-run)"
} else {
""
}
);
if closed > 0 && !opts.dry_run {
println!(
"{}",
"Closed issues no longer accumulate worktree-watch coding time.".dimmed()
);
}
Ok(())
}
pub async fn reconcile_ledger(ledger: &mut TodoLedger) -> Result<(usize, usize, usize), String> {
let mut closed = 0usize;
let mut checked = 0usize;
let mut errors = 0usize;
let now = Utc::now().to_rfc3339();
#[cfg(feature = "linear")]
let linear_key = crate::commands::linear_cmd::ensure_linear_api_key().await.ok();
#[cfg(not(feature = "linear"))]
let linear_key: Option<String> = None;
let gh_token = crate::commands::github_cmd::resolve_github_token().ok();
let fingerprints: Vec<String> = ledger.entries.keys().cloned().collect();
for fp in fingerprints {
let entry = match ledger.entries.get(&fp) {
Some(e) if e.is_open() && (e.linear.is_some() || e.github.is_some()) => e.clone(),
_ => continue,
};
checked += 1;
let mut is_done = false;
let mut closed_at = now.clone();
#[cfg(feature = "linear")]
if let (Some(key), Some(lin)) = (linear_key.as_deref(), entry.linear.as_ref()) {
match crate::commands::linear_cmd::get_issue(key, &lin.identifier).await {
Ok(issue) => {
let ty = issue.state_type.as_deref().unwrap_or("").to_ascii_lowercase();
if ty == "completed" || ty == "canceled" || ty == "cancelled" {
is_done = true;
if let Some(updated) = issue.updated_at {
closed_at = updated;
}
}
}
Err(err) => {
errors += 1;
eprintln!(
"{} Linear {}: {err}",
"warn".yellow(),
lin.identifier
);
}
}
}
if !is_done {
if let (Some(token), Some(gh)) = (gh_token.as_deref(), entry.github.as_ref()) {
let owner = entry
.repo_owner
.clone()
.or_else(|| owner_from_url(gh.url.as_deref()));
let repo = entry
.repo_name
.clone()
.or_else(|| repo_from_url(gh.url.as_deref()));
if let (Some(owner), Some(repo)) = (owner, repo) {
match crate::commands::github_cmd::get_issue(token, &owner, &repo, gh.number)
.await
{
Ok(issue) => {
if issue.state.eq_ignore_ascii_case("closed") {
is_done = true;
}
}
Err(err) => {
errors += 1;
eprintln!("{} GitHub #{}: {err}", "warn".yellow(), gh.number);
}
}
}
}
}
if is_done {
if let Some(entry) = ledger.entries.get_mut(&fp) {
entry.status = TodoIssueStatus::Done;
entry.closed_at = Some(closed_at);
entry.updated_at = Some(now.clone());
closed += 1;
let label = entry
.linear
.as_ref()
.map(|l| l.identifier.clone())
.or_else(|| entry.github.as_ref().map(|g| format!("#{}", g.number)))
.unwrap_or_else(|| fp.chars().take(8).collect());
println!(" {} {}", "done".bright_green(), label);
}
}
}
Ok((closed, checked, errors))
}
fn owner_from_url(url: Option<&str>) -> Option<String> {
let url = url?;
let rest = url
.trim()
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))?;
rest.split('/').next().map(str::to_string)
}
fn repo_from_url(url: Option<&str>) -> Option<String> {
let url = url?;
let rest = url
.trim()
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))?;
let mut parts = rest.split('/');
let _owner = parts.next()?;
parts.next().map(str::to_string)
}