Rust CLI for tangled

feat: improve how the issue command handles issue #6

open opened by dunkirk.sh targeting main

This patch fixes tangled-cli issue list to properly deserialize issue records by making optional fields (createdAt, $type, owner, issueId) optional in the Issue struct, adds a --state filter to show only open or closed issues by querying issue state records and filtering client-side, and formats repository display as readable "handle/name" (e.g., "dunkirk.sh/thistle") instead of AT-URIs while using a cache to avoid repeated API calls for the same repository.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:krxbvxvis5skq7jj6eot23ul/sh.tangled.repo.pull/3m6d7rghmzp22
+189 -14
Diff #0
+100 -11
crates/tangled-api/src/client.rs
··· 412 412 413 413 414 414 415 + Err(anyhow!("repo not found for owner/name")) 416 + } 415 417 416 - 417 - 418 - 419 - 420 - 418 + pub async fn get_repo_by_rkey( 419 + &self, 420 + did: &str, 421 + rkey: &str, 422 + bearer: Option<&str>, 423 + ) -> Result<Repository> { 424 + #[derive(Deserialize)] 425 + struct GetRes { 426 + value: Repository, 427 + } 428 + let params = [ 429 + ("repo", did.to_string()), 430 + ("collection", "sh.tangled.repo".to_string()), 431 + ("rkey", rkey.to_string()), 432 + ]; 433 + let res: GetRes = self 434 + .get_json("com.atproto.repo.getRecord", &params, bearer) 435 + .await?; 436 + Ok(res.value) 437 + } 438 + 439 + pub async fn resolve_did_to_handle( 440 + &self, 441 + did: &str, 442 + bearer: Option<&str>, 443 + ) -> Result<String> { 444 + #[derive(Deserialize)] 445 + struct Res { 446 + handle: String, 447 + } 448 + let params = [("repo", did.to_string())]; 449 + let res: Res = self 450 + .get_json("com.atproto.repo.describeRepo", &params, bearer) 451 + .await?; 452 + Ok(res.handle) 453 + } 454 + 455 + pub async fn delete_repo( 456 + &self, 457 + did: &str, 421 458 422 459 423 460 ··· 705 742 #[derive(Deserialize)] 706 743 struct Item { 707 744 uri: String, 745 + #[allow(dead_code)] 746 + cid: Option<String>, 708 747 value: Issue, 709 748 } 710 749 #[derive(Deserialize)] ··· 921 960 922 961 923 962 963 + Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) 964 + } 924 965 966 + pub async fn list_issue_states( 967 + &self, 968 + author_did: &str, 969 + bearer: Option<&str>, 970 + ) -> Result<Vec<IssueState>> { 971 + #[derive(Deserialize)] 972 + struct Item { 973 + #[allow(dead_code)] 974 + uri: String, 975 + #[allow(dead_code)] 976 + cid: Option<String>, 977 + value: IssueState, 978 + } 979 + #[derive(Deserialize)] 980 + struct ListRes { 981 + #[serde(default)] 982 + records: Vec<Item>, 983 + } 984 + let params = vec![ 985 + ("repo", author_did.to_string()), 986 + ("collection", "sh.tangled.repo.issue.state".to_string()), 987 + ("limit", "100".to_string()), 988 + ]; 989 + let res: ListRes = self 990 + .get_json("com.atproto.repo.listRecords", &params, bearer) 991 + .await?; 992 + Ok(res.records.into_iter().map(|it| it.value).collect()) 993 + } 994 + 995 + pub async fn get_pull_record( 996 + &self, 997 + author_did: &str, 925 998 926 999 927 1000 ··· 1338 1411 1339 1412 1340 1413 1414 + pub title: String, 1415 + #[serde(default)] 1416 + pub body: String, 1417 + #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] 1418 + pub created_at: Option<String>, 1419 + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] 1420 + pub record_type: Option<String>, 1421 + #[serde(skip_serializing_if = "Option::is_none")] 1422 + pub owner: Option<String>, 1423 + #[serde(rename = "issueId", skip_serializing_if = "Option::is_none")] 1424 + pub issue_id: Option<i64>, 1425 + } 1341 1426 1427 + #[derive(Debug, Clone)] 1342 1428 1343 1429 1344 1430 1431 + pub issue: Issue, 1432 + } 1345 1433 1346 - pub title: String, 1347 - #[serde(default)] 1348 - pub body: String, 1349 - #[serde(rename = "createdAt")] 1350 - pub created_at: String, 1434 + #[derive(Debug, Clone, Serialize, Deserialize)] 1435 + pub struct IssueState { 1436 + pub issue: String, 1437 + pub state: String, 1351 1438 } 1352 1439 1353 - #[derive(Debug, Clone)] 1440 + // Pull record value (subset) 1441 + #[derive(Debug, Clone, Serialize, Deserialize)] 1442 + pub struct PullTarget {
+2 -2
crates/tangled-api/src/lib.rs
··· 2 2 3 3 pub use client::TangledClient; 4 4 pub use client::{ 5 - CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, 6 - RepoRecord, Repository, Secret, 5 + CreateRepoOptions, DefaultBranch, Issue, IssueRecord, IssueState, Language, Languages, Pull, 6 + PullRecord, RepoRecord, Repository, Secret, 7 7 };
+87 -1
crates/tangled-cli/src/commands/issue.rs
··· 34 34 None 35 35 }; 36 36 37 - let items = client 37 + let mut items = client 38 38 .list_issues( 39 39 &session.did, 40 40 repo_filter_at.as_deref(), 41 41 Some(session.access_jwt.as_str()), 42 42 ) 43 43 .await?; 44 + 45 + // Filter by state if requested 46 + if let Some(state_filter) = &args.state { 47 + let state_nsid = match state_filter.as_str() { 48 + "open" => "sh.tangled.repo.issue.state.open", 49 + "closed" => "sh.tangled.repo.issue.state.closed", 50 + other => { 51 + return Err(anyhow!(format!( 52 + "unknown state '{}', expected 'open' or 'closed'", 53 + other 54 + ))) 55 + } 56 + }; 57 + 58 + // Fetch issue states 59 + let states = client 60 + .list_issue_states(&session.did, Some(session.access_jwt.as_str())) 61 + .await?; 62 + 63 + // Build map of issue AT-URI to current state 64 + let mut issue_states = std::collections::HashMap::new(); 65 + for state in states { 66 + issue_states.insert(state.issue, state.state); 67 + } 68 + 69 + // Filter issues by state 70 + items.retain(|it| { 71 + let issue_at = format!( 72 + "at://{}/sh.tangled.repo.issue/{}", 73 + it.author_did, it.rkey 74 + ); 75 + match issue_states.get(&issue_at) { 76 + Some(state) => state == state_nsid, 77 + None => state_nsid == "sh.tangled.repo.issue.state.open", // default to open 78 + } 79 + }); 80 + } 81 + 44 82 if items.is_empty() { 45 83 println!("No issues found (showing only issues you created)"); 46 84 } else { 85 + println!("RKEY\tTITLE\tREPO"); 86 + 87 + // Build cache of repo AT-URIs to formatted names 88 + let mut repo_cache: std::collections::HashMap<String, String> = std::collections::HashMap::new(); 89 + 90 + for it in items { 91 + let repo_display = if let Some(cached) = repo_cache.get(&it.issue.repo) { 92 + cached.clone() 93 + } else if let Some((repo_did, repo_rkey)) = parse_repo_at_uri(&it.issue.repo) { 94 + // Fetch and format repo info 95 + let formatted = match client 96 + .get_repo_by_rkey(&repo_did, &repo_rkey, Some(session.access_jwt.as_str())) 97 + .await 98 + { 99 + Ok(repo) => { 100 + let handle = client 101 + .resolve_did_to_handle(&repo_did, Some(session.access_jwt.as_str())) 102 + .await 103 + .unwrap_or(repo_did.clone()); 104 + format!("{}/{}", handle, repo.name) 105 + } 106 + Err(_) => it.issue.repo.clone(), 107 + }; 108 + repo_cache.insert(it.issue.repo.clone(), formatted.clone()); 109 + formatted 110 + } else { 111 + it.issue.repo.clone() 112 + }; 113 + println!("{}\t{}\t{}", it.rkey, it.issue.title, repo_display); 114 + } 115 + } 116 + Ok(()) 117 + } 118 + 119 + fn parse_repo_at_uri(at_uri: &str) -> Option<(String, String)> { 120 + // Parse at://did/sh.tangled.repo/rkey 121 + let without_prefix = at_uri.strip_prefix("at://")?; 122 + let parts: Vec<&str> = without_prefix.split('/').collect(); 123 + if parts.len() >= 3 && parts[1] == "sh.tangled.repo" { 124 + Some((parts[0].to_string(), parts[2].to_string())) 125 + } else { 126 + None 127 + } 128 + } 129 + 130 + async fn create(args: IssueCreateArgs) -> Result<()> { 131 + let session = crate::util::load_session_with_refresh().await?; 132 + let pds = session

Submissions

sign up or login to add to the discussion
dunkirk.sh submitted #0
3 commits
expand
53768759
fix: add missing optional fields to Issue struct for deserialization
fc7640a0
feat: implement --state filter for issue list command
4f0df76b
feat: format issue repo as handle/name with caching
merge conflicts detected
expand
  • crates/tangled-api/src/lib.rs:2