tangled-cli 0.1.0

CLI for interacting with Tangled, an AT Protocol-based git collaboration platform
//! Issue record operations on the PDS.

use anyhow::{anyhow, Result};
use tangled_api::lexicon::com::atproto::repo as atproto_repo;

use super::auth::PdsAuth;
use super::types::{Issue, IssueRecord, IssueState, REPO_COLLECTION};
use super::{http, uri_rkey};

const ISSUE_COLLECTION: &str = "sh.tangled.repo.issue";

async fn list_collection(
    pds_base: &str,
    repo: &str,
    collection: &str,
    auth: &PdsAuth,
) -> Result<Vec<atproto_repo::list_records::Record>> {
    let params = atproto_repo::list_records::Params {
        repo: repo.to_string(),
        collection: collection.to_string(),
        limit: Some(100),
        cursor: None,
        reverse: None,
    };
    let rb = atproto_repo::list_records(http(), pds_base, &params);
    let out: atproto_repo::list_records::Output = auth.send(rb).await?;
    Ok(out.records)
}

async fn create_record(
    pds_base: &str,
    auth: &PdsAuth,
    author_did: &str,
    collection: &str,
    record: serde_json::Value,
) -> Result<atproto_repo::create_record::Output> {
    let input = atproto_repo::create_record::Input {
        repo: author_did.to_string(),
        collection: collection.to_string(),
        rkey: None,
        validate: Some(false),
        record,
        swap_commit: None,
    };
    let rb = atproto_repo::create_record(http(), pds_base, &input);
    auth.send(rb).await
}

pub async fn list_issues(
    pds_base: &str,
    author_did: &str,
    repo_at_uri: Option<&str>,
    auth: &PdsAuth,
) -> Result<Vec<IssueRecord>> {
    let records =
        list_collection(pds_base, author_did, ISSUE_COLLECTION, auth).await?;
    let mut out = vec![];
    for it in records {
        let issue: Issue = serde_json::from_value(it.value)?;
        if let Some(filter_repo) = repo_at_uri {
            if issue.repo.as_str() != filter_repo {
                continue;
            }
        }
        out.push(IssueRecord {
            author_did: author_did.to_string(),
            rkey: uri_rkey(&it.uri).unwrap_or_default(),
            issue,
        });
    }
    Ok(out)
}

#[allow(clippy::too_many_arguments)]
pub async fn create_issue(
    pds_base: &str,
    auth: &PdsAuth,
    author_did: &str,
    repo_did: &str,
    repo_rkey: &str,
    title: &str,
    body: Option<&str>,
) -> Result<String> {
    let legacy_repo_at = if repo_did.starts_with("at://") {
        repo_did.to_string()
    } else {
        format!("at://{}/{}/{}", repo_did, REPO_COLLECTION, repo_rkey)
    };
    let now = super::now_rfc3339();
    let record = |repo_ref: &str| {
        let mut rec = serde_json::json!({
            "repo": repo_ref,
            "title": title,
            "createdAt": now,
        });
        if let Some(body) = body {
            rec["body"] = serde_json::json!(body);
        }
        rec
    };

    let res = match create_record(
        pds_base,
        auth,
        author_did,
        ISSUE_COLLECTION,
        record(repo_did),
    )
    .await
    {
        Ok(res) => res,
        Err(err) => create_record(
            pds_base,
            auth,
            author_did,
            ISSUE_COLLECTION,
            record(&legacy_repo_at),
        )
        .await
        .map_err(|legacy_err| {
            anyhow!(
                "{}; legacy repo reference also failed: {}",
                err,
                legacy_err
            )
        })?,
    };
    uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri"))
}

pub async fn comment_issue(
    pds_base: &str,
    auth: &PdsAuth,
    author_did: &str,
    issue_at: &str,
    body: &str,
) -> Result<String> {
    let record = serde_json::json!({
        "issue": issue_at,
        "body": body,
        "createdAt": super::now_rfc3339(),
    });
    let res = create_record(
        pds_base,
        auth,
        author_did,
        "sh.tangled.repo.issue.comment",
        record,
    )
    .await?;
    uri_rkey(&res.uri)
        .ok_or_else(|| anyhow!("missing rkey in issue comment uri"))
}

pub async fn get_issue_record(
    pds_base: &str,
    author_did: &str,
    rkey: &str,
    auth: &PdsAuth,
) -> Result<Issue> {
    let params = atproto_repo::get_record::Params {
        repo: author_did.to_string(),
        collection: ISSUE_COLLECTION.to_string(),
        rkey: rkey.to_string(),
        cid: None,
    };
    let rb = atproto_repo::get_record(http(), pds_base, &params);
    let out: atproto_repo::get_record::Output = auth.send(rb).await?;
    Ok(serde_json::from_value(out.value)?)
}

pub async fn put_issue_record(
    pds_base: &str,
    author_did: &str,
    rkey: &str,
    record: &Issue,
    auth: &PdsAuth,
) -> Result<()> {
    let input = atproto_repo::put_record::Input {
        repo: author_did.to_string(),
        collection: ISSUE_COLLECTION.to_string(),
        rkey: rkey.to_string(),
        validate: Some(false),
        record: serde_json::to_value(record)?,
        swap_commit: None,
        swap_record: None,
    };
    let rb = atproto_repo::put_record(http(), pds_base, &input);
    let _: serde_json::Value = auth.send(rb).await?;
    Ok(())
}

pub async fn set_issue_state(
    pds_base: &str,
    auth: &PdsAuth,
    author_did: &str,
    issue_at: &str,
    state_nsid: &str,
) -> Result<String> {
    let record = serde_json::json!({
        "issue": issue_at,
        "state": state_nsid,
        "createdAt": super::now_rfc3339(),
    });
    let res = create_record(
        pds_base,
        auth,
        author_did,
        "sh.tangled.repo.issue.state",
        record,
    )
    .await?;
    uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri"))
}

pub async fn list_issue_states(
    pds_base: &str,
    author_did: &str,
    auth: &PdsAuth,
) -> Result<Vec<IssueState>> {
    let records = list_collection(
        pds_base,
        author_did,
        "sh.tangled.repo.issue.state",
        auth,
    )
    .await?;
    let mut out = vec![];
    for it in records {
        out.push(serde_json::from_value(it.value)?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use mockito::{Matcher, Server};
    use serde_json::json;

    use super::*;

    #[tokio::test]
    async fn create_issue_writes_repo_did_shape() {
        let mut pds = Server::new_async().await;
        let _create_issue = pds
            .mock("POST", "/xrpc/com.atproto.repo.createRecord")
            .match_body(Matcher::PartialJson(json!({
                "repo": "did:plc:author",
                "collection": "sh.tangled.repo.issue",
                "record": {
                    "repo": "did:plc:repo",
                    "title": "Bug"
                }
            })))
            .with_status(200)
            .with_body(
                json!({
                    "uri": "at://did:plc:author/sh.tangled.repo.issue/3abc",
                    "cid": "bafycid"
                })
                .to_string(),
            )
            .create_async()
            .await;

        let auth = PdsAuth::Bearer("access".to_string());
        let rkey = create_issue(
            &pds.url(),
            &auth,
            "did:plc:author",
            "did:plc:repo",
            "demo",
            "Bug",
            Some("body"),
        )
        .await
        .expect("issue creation should write current repo DID references");

        assert_eq!(rkey, "3abc");
    }

    #[tokio::test]
    async fn create_issue_falls_back_to_legacy_repo_at_uri_when_current_shape_is_rejected(
    ) {
        let mut pds = Server::new_async().await;
        let _current_shape_rejected = pds
            .mock("POST", "/xrpc/com.atproto.repo.createRecord")
            .match_body(Matcher::PartialJson(json!({
                "record": {
                    "repo": "did:plc:owner"
                }
            })))
            .with_status(400)
            .with_body(json!({"error": "InvalidRecord"}).to_string())
            .create_async()
            .await;

        let _legacy_shape_accepted = pds
            .mock("POST", "/xrpc/com.atproto.repo.createRecord")
            .match_body(Matcher::PartialJson(json!({
                "record": {
                    "repo": "at://did:plc:owner/sh.tangled.repo/demo"
                }
            })))
            .with_status(200)
            .with_body(
                json!({
                    "uri": "at://did:plc:author/sh.tangled.repo.issue/3legacy",
                    "cid": "bafycid"
                })
                .to_string(),
            )
            .create_async()
            .await;

        let auth = PdsAuth::Bearer("access".to_string());
        let rkey = create_issue(
            &pds.url(),
            &auth,
            "did:plc:author",
            "did:plc:owner",
            "demo",
            "Bug",
            None,
        )
        .await
        .expect("legacy fallback should preserve compatibility with older deployments");

        assert_eq!(rkey, "3legacy");
    }
}