feat: format issue repo as handle/name with caching

Issue list now displays repo as "dunkirk.sh/thistle" instead of
"at://did:plc:.../sh.tangled.repo/rkey" for better readability.

Uses a cache to avoid repeated API calls for the same repo, making
the command fast even with many issues from the same repository.

Implementation:
- Add get_repo_by_rkey() to fetch repo record by DID and rkey
- Add resolve_did_to_handle() to convert DID to handle
- Cache repo AT-URI to formatted name mappings
- Parse AT-URI to extract DID and rkey, then resolve to handle/name

💖 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

dunkirk.sh 4f0df76b fc7640a0

verified
Changed files
+75 -1
crates
tangled-api
src
tangled-cli
src
commands
+37
crates/tangled-api/src/client.rs
···
Err(anyhow!("repo not found for owner/name"))
}
+
pub async fn get_repo_by_rkey(
+
&self,
+
did: &str,
+
rkey: &str,
+
bearer: Option<&str>,
+
) -> Result<Repository> {
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Repository,
+
}
+
let params = [
+
("repo", did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let res: GetRes = self
+
.get_json("com.atproto.repo.getRecord", &params, bearer)
+
.await?;
+
Ok(res.value)
+
}
+
+
pub async fn resolve_did_to_handle(
+
&self,
+
did: &str,
+
bearer: Option<&str>,
+
) -> Result<String> {
+
#[derive(Deserialize)]
+
struct Res {
+
handle: String,
+
}
+
let params = [("repo", did.to_string())];
+
let res: Res = self
+
.get_json("com.atproto.repo.describeRepo", &params, bearer)
+
.await?;
+
Ok(res.handle)
+
}
+
pub async fn delete_repo(
&self,
did: &str,
+38 -1
crates/tangled-cli/src/commands/issue.rs
···
println!("No issues found (showing only issues you created)");
} else {
println!("RKEY\tTITLE\tREPO");
+
+
// Build cache of repo AT-URIs to formatted names
+
let mut repo_cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
+
for it in items {
-
println!("{}\t{}\t{}", it.rkey, it.issue.title, it.issue.repo);
+
let repo_display = if let Some(cached) = repo_cache.get(&it.issue.repo) {
+
cached.clone()
+
} else if let Some((repo_did, repo_rkey)) = parse_repo_at_uri(&it.issue.repo) {
+
// Fetch and format repo info
+
let formatted = match client
+
.get_repo_by_rkey(&repo_did, &repo_rkey, Some(session.access_jwt.as_str()))
+
.await
+
{
+
Ok(repo) => {
+
let handle = client
+
.resolve_did_to_handle(&repo_did, Some(session.access_jwt.as_str()))
+
.await
+
.unwrap_or(repo_did.clone());
+
format!("{}/{}", handle, repo.name)
+
}
+
Err(_) => it.issue.repo.clone(),
+
};
+
repo_cache.insert(it.issue.repo.clone(), formatted.clone());
+
formatted
+
} else {
+
it.issue.repo.clone()
+
};
+
println!("{}\t{}\t{}", it.rkey, it.issue.title, repo_display);
}
}
Ok(())
+
}
+
+
fn parse_repo_at_uri(at_uri: &str) -> Option<(String, String)> {
+
// Parse at://did/sh.tangled.repo/rkey
+
let without_prefix = at_uri.strip_prefix("at://")?;
+
let parts: Vec<&str> = without_prefix.split('/').collect();
+
if parts.len() >= 3 && parts[1] == "sh.tangled.repo" {
+
Some((parts[0].to_string(), parts[2].to_string()))
+
} else {
+
None
+
}
}
async fn create(args: IssueCreateArgs) -> Result<()> {