xbp 10.39.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
//! Mark ledger issues done when Linear/GitHub say they are completed/closed.

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)
}