tangled-cli 0.1.0

CLI for interacting with Tangled, an AT Protocol-based git collaboration platform
use std::io::IsTerminal;

use anyhow::{anyhow, Result};
use inquire::Text;

use crate::cli::{
    Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs,
    IssueListArgs, IssueShowArgs, OutputFormat,
};
use crate::ops::types::Issue;

pub async fn run(cli: &Cli, cmd: IssueCommand) -> Result<()> {
    match cmd {
        IssueCommand::List(args) => list(cli, args).await,
        IssueCommand::Create(args) => create(args).await,
        IssueCommand::Show(args) => show(cli, args).await,
        IssueCommand::Edit(args) => edit(args).await,
        IssueCommand::Comment(args) => comment(args).await,
    }
}

async fn list(cli: &Cli, args: IssueListArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let inferred;
    let repo_arg = if args.repo.is_none() {
        inferred = Some(crate::util::current_git_repo_context()?);
        inferred.as_ref().map(|context| context.repo_spec())
    } else {
        inferred = None;
        args.repo.clone()
    };

    let repo_filter_refs = if let Some(repo) = &repo_arg {
        let (owner, name) = parse_repo_ref(repo, &session.handle);
        let info =
            crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
        if let Some(context) = inferred.as_ref() {
            validate_remote_matches_repo(context, &info)?;
        }
        Some(vec![info.issue_repo_ref(), info.repo_at_uri()])
    } else {
        None
    };

    let mut items =
        crate::ops::issue::list_issues(&pds, &session.did, None, &auth).await?;

    if let Some(repo_refs) = repo_filter_refs.as_ref() {
        items.retain(|it| {
            repo_refs.iter().any(|repo_ref| repo_ref == &it.issue.repo)
        });
    }

    // Filter by state if requested
    if let Some(state_filter) = &args.state {
        let state_nsid = match state_filter.as_str() {
            "open" => "sh.tangled.repo.issue.state.open",
            "closed" => "sh.tangled.repo.issue.state.closed",
            other => {
                return Err(anyhow!(format!(
                    "unknown state '{}', expected 'open' or 'closed'",
                    other
                )))
            }
        };

        // Fetch issue states
        let states =
            crate::ops::issue::list_issue_states(&pds, &session.did, &auth)
                .await?;

        // Build map of issue AT-URI to current state
        let mut issue_states = std::collections::HashMap::new();
        for state in states {
            issue_states.insert(state.issue, state.state);
        }

        // Filter issues by state
        items.retain(|it| {
            let issue_at = format!(
                "at://{}/sh.tangled.repo.issue/{}",
                it.author_did, it.rkey
            );
            match issue_states.get(&issue_at) {
                Some(state) => state == state_nsid,
                None => state_nsid == "sh.tangled.repo.issue.state.open", // default to open
            }
        });
    }

    if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
        return crate::util::print_serialized(cli.format, &items);
    }

    if items.is_empty() {
        println!("No issues found (showing only issues you created)");
    } else {
        // Avoid repeating repository lookups when several issues belong to
        // the same repository.
        let mut repo_cache: std::collections::HashMap<String, String> =
            std::collections::HashMap::new();
        let mut rows = Vec::with_capacity(items.len());

        for item in items {
            let repo_ref = item.issue.repo.clone();
            let repo_display = if let Some(cached) = repo_cache.get(&repo_ref) {
                cached.clone()
            } else {
                let display =
                    crate::ops::repo::repo_display_name(&pds, &repo_ref, &auth)
                        .await
                        .unwrap_or_else(|_| repo_ref.clone());
                repo_cache.insert(repo_ref, display.clone());
                display
            };
            rows.push([item.rkey, item.issue.title, repo_display]);
        }

        crate::util::print_table(["RKEY", "TITLE", "REPOSITORY"], rows);
    }
    Ok(())
}

async fn create(args: IssueCreateArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let inferred = if args.repo.is_none() {
        Some(crate::util::current_git_repo_context()?)
    } else {
        None
    };
    let repo_spec;
    let repo = if let Some(repo) = args.repo.as_ref() {
        repo.as_str()
    } else if let Some(context) = inferred.as_ref() {
        repo_spec = context.repo_spec();
        repo_spec.as_str()
    } else {
        return Err(anyhow!(
            "--repo is required for issue create outside a Tangled checkout"
        ));
    };
    let (owner, name) = parse_repo_ref(repo, &session.handle);
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
    if let Some(context) = inferred.as_ref() {
        validate_remote_matches_repo(context, &info)?;
    }
    let title = resolve_issue_title(args.title.as_deref())?;
    let body = resolve_issue_body(args.body.as_deref())?;
    let rkey = crate::ops::issue::create_issue(
        &pds,
        &auth,
        &session.did,
        &info.issue_repo_ref(),
        &info.rkey,
        title.as_str(),
        body.as_deref(),
    )
    .await?;
    println!("Created issue rkey={} in {}/{}", rkey, owner, name);
    Ok(())
}

fn resolve_issue_title(provided: Option<&str>) -> Result<String> {
    if let Some(title) = provided {
        return Ok(title.to_string());
    }
    if std::io::stdin().is_terminal() {
        return Text::new("Title").prompt().map_err(Into::into);
    }
    Err(anyhow!(
        "--title is required for non-interactive issue create"
    ))
}

fn resolve_issue_body(provided: Option<&str>) -> Result<Option<String>> {
    if let Some(body) = provided {
        return Ok(Some(body.to_string()));
    }
    if std::io::stdin().is_terminal() {
        return crate::commands::prompt_body_editor_or_skip(None);
    }
    Ok(None)
}

fn validate_remote_matches_repo(
    context: &crate::util::GitRepoContext,
    info: &crate::ops::types::RepoRecord,
) -> Result<()> {
    let host = context.host.as_str();
    let repo_knot = info.knot.as_str();
    let compatible = host == "tangled.org" && repo_knot == "knot1.tangled.sh"
        || host == repo_knot;
    if compatible {
        Ok(())
    } else {
        Err(anyhow!(
            "inferred remote {} points at {}, but PDS record for {} uses {}; pass --repo explicitly",
            context.remote_name,
            context.host,
            context.repo_spec(),
            info.knot
        ))
    }
}

async fn show(cli: &Cli, args: IssueShowArgs) -> Result<()> {
    // For now, show only accepts at-uri or did:rkey or rkey (for your DID)
    let format = if args.json {
        OutputFormat::Json
    } else {
        cli.format
    };
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let id = args.id;
    let (did, rkey) = parse_record_id(&id, &session.did)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    // Fetch all issues by this DID and find rkey
    let items = crate::ops::issue::list_issues(&pds, &did, None, &auth).await?;
    if let Some(item) = items.into_iter().find(|i| i.rkey == rkey) {
        if matches!(format, OutputFormat::Json | OutputFormat::Yaml) {
            return crate::util::print_serialized(format, &item);
        }

        println!("TITLE: {}", item.issue.title);
        if !item.issue.body.is_empty() {
            println!("BODY:\n{}", item.issue.body);
        }
        println!("REPO: {}", item.issue.repo);
        println!("AUTHOR: {}", item.author_did);
        println!("RKEY: {}", rkey);
    } else if matches!(format, OutputFormat::Json | OutputFormat::Yaml) {
        crate::util::print_serialized(
            format,
            &Option::<crate::ops::types::IssueRecord>::None,
        )?;
    } else {
        println!("Issue not found for did={} rkey={}", did, rkey);
    }
    Ok(())
}

async fn edit(args: IssueEditArgs) -> Result<()> {
    // Simple edit: fetch existing record and putRecord with new title/body
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let (did, rkey) = parse_record_id(&args.id, &session.did)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    // Get existing
    let mut rec: Issue =
        crate::ops::issue::get_issue_record(&pds, &did, &rkey, &auth).await?;
    if let Some(t) = args.title.as_deref() {
        rec.title = t.to_string();
    }
    if let Some(b) = args.body.as_deref() {
        rec.body = b.to_string();
    }
    // Put record back
    crate::ops::issue::put_issue_record(&pds, &did, &rkey, &rec, &auth).await?;

    // Optional state change
    if let Some(state) = args.state.as_deref() {
        let state_nsid = match state {
            "open" => "sh.tangled.repo.issue.state.open",
            "closed" => "sh.tangled.repo.issue.state.closed",
            other => {
                return Err(anyhow!(format!(
                    "unknown state '{}', expected 'open' or 'closed'",
                    other
                )))
            }
        };
        let issue_at = format!("at://{}/sh.tangled.repo.issue/{}", did, rkey);
        crate::ops::issue::set_issue_state(
            &pds,
            &auth,
            &session.did,
            &issue_at,
            state_nsid,
        )
        .await?;
    }
    println!("Updated issue {}:{}", did, rkey);
    Ok(())
}

async fn comment(args: IssueCommentArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let (did, rkey) = parse_record_id(&args.id, &session.did)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    // Resolve issue AT-URI
    let _issue =
        crate::ops::issue::get_issue_record(&pds, &did, &rkey, &auth).await?;
    let issue_at = format!("at://{}/sh.tangled.repo.issue/{}", did, rkey);
    if let Some(body) = args.body.as_deref() {
        crate::ops::issue::comment_issue(
            &pds,
            &auth,
            &session.did,
            &issue_at,
            body,
        )
        .await?;
        println!("Comment posted");
    }
    if args.close {
        crate::ops::issue::set_issue_state(
            &pds,
            &auth,
            &session.did,
            &issue_at,
            "sh.tangled.repo.issue.state.closed",
        )
        .await?;
        println!("Issue closed");
    }
    Ok(())
}

fn parse_repo_ref<'a>(
    spec: &'a str,
    default_owner: &'a str,
) -> (&'a str, &'a str) {
    if let Some((owner, name)) = spec.split_once('/') {
        (owner, name)
    } else {
        (default_owner, spec)
    }
}

fn parse_record_id<'a>(
    id: &'a str,
    default_did: &'a str,
) -> Result<(String, String)> {
    if let Some(rest) = id.strip_prefix("at://") {
        let parts: Vec<&str> = rest.split('/').collect();
        if parts.len() >= 4 {
            return Ok((parts[0].to_string(), parts[3].to_string()));
        }
    }
    if let Some((did, rkey)) = id.split_once(':') {
        return Ok((did.to_string(), rkey.to_string()));
    }
    Ok((default_did.to_string(), id.to_string()))
}