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, ¶ms);
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, ¶ms);
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");
}
}