From 5376875995fdce60ad9071df7103e8f334d3e12a Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sun, 23 Nov 2025 15:47:51 -0500 Subject: [PATCH 1/3] fix: add missing optional fields to Issue struct for deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some issue records in the wild include optional fields that weren't in the Issue struct, causing deserialization failures: - createdAt: not present in all records - $type: AT Protocol record type field - owner: legacy field on some issues - issueId: numeric ID on some issues - cid: content identifier in listRecords response This makes all these fields optional so the CLI can deserialize any issue record regardless of which optional fields are present. 💖 Generated with Crush Assisted-by: Claude Sonnet 4.5 via Crush --- crates/tangled-api/src/client.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index 0867ed8..e291e99 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -705,6 +705,8 @@ impl TangledClient { #[derive(Deserialize)] struct Item { uri: String, + #[allow(dead_code)] + cid: Option, value: Issue, } #[derive(Deserialize)] @@ -1346,8 +1348,14 @@ pub struct Issue { pub title: String, #[serde(default)] pub body: String, - #[serde(rename = "createdAt")] - pub created_at: String, + #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(rename = "$type", skip_serializing_if = "Option::is_none")] + pub record_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(rename = "issueId", skip_serializing_if = "Option::is_none")] + pub issue_id: Option, } #[derive(Debug, Clone)] -- 2.50.1 (Apple Git-155) From fc7640a08233b5cae6ff37bbeaaf256cec3ee5d0 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sun, 23 Nov 2025 15:51:30 -0500 Subject: [PATCH 2/3] feat: implement --state filter for issue list command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for filtering issues by state (open/closed) using the --state flag on `tangled issue list`. Implementation: - Add list_issue_states() API method to fetch state records - Add IssueState struct for sh.tangled.repo.issue.state records - Filter issues client-side by fetching state records and matching - Default to "open" state for issues without explicit state records 💖 Generated with Crush Assisted-by: Claude Sonnet 4.5 via Crush --- crates/tangled-api/src/client.rs | 35 +++++++++++++++++++++ crates/tangled-api/src/lib.rs | 4 +-- crates/tangled-cli/src/commands/issue.rs | 40 +++++++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index e291e99..82ec51f 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -926,6 +926,35 @@ impl TangledClient { Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri")) } + pub async fn list_issue_states( + &self, + author_did: &str, + bearer: Option<&str>, + ) -> Result> { + #[derive(Deserialize)] + struct Item { + #[allow(dead_code)] + uri: String, + #[allow(dead_code)] + cid: Option, + value: IssueState, + } + #[derive(Deserialize)] + struct ListRes { + #[serde(default)] + records: Vec, + } + let params = vec![ + ("repo", author_did.to_string()), + ("collection", "sh.tangled.repo.issue.state".to_string()), + ("limit", "100".to_string()), + ]; + let res: ListRes = self + .get_json("com.atproto.repo.listRecords", ¶ms, bearer) + .await?; + Ok(res.records.into_iter().map(|it| it.value).collect()) + } + pub async fn get_pull_record( &self, author_did: &str, @@ -1365,6 +1394,12 @@ pub struct IssueRecord { pub issue: Issue, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IssueState { + pub issue: String, + pub state: String, +} + // Pull record value (subset) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullTarget { diff --git a/crates/tangled-api/src/lib.rs b/crates/tangled-api/src/lib.rs index 661c21a..4c85466 100644 --- a/crates/tangled-api/src/lib.rs +++ b/crates/tangled-api/src/lib.rs @@ -2,6 +2,6 @@ pub mod client; pub use client::TangledClient; pub use client::{ - CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, - RepoRecord, Repository, Secret, + CreateRepoOptions, DefaultBranch, Issue, IssueRecord, IssueState, Language, Languages, Pull, + PullRecord, RepoRecord, Repository, Secret, }; diff --git a/crates/tangled-cli/src/commands/issue.rs b/crates/tangled-cli/src/commands/issue.rs index 6f2a377..28cc54c 100644 --- a/crates/tangled-cli/src/commands/issue.rs +++ b/crates/tangled-cli/src/commands/issue.rs @@ -34,13 +34,51 @@ async fn list(args: IssueListArgs) -> Result<()> { None }; - let items = client + let mut items = client .list_issues( &session.did, repo_filter_at.as_deref(), Some(session.access_jwt.as_str()), ) .await?; + + // 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 = client + .list_issue_states(&session.did, Some(session.access_jwt.as_str())) + .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 items.is_empty() { println!("No issues found (showing only issues you created)"); } else { -- 2.50.1 (Apple Git-155) From 4f0df76ba65e26766ee0c6adad83b96b0a2c0b1c Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sun, 23 Nov 2025 16:02:45 -0500 Subject: [PATCH 3/3] feat: format issue repo as handle/name with caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/tangled-api/src/client.rs | 37 ++++++++++++++++++++++ crates/tangled-cli/src/commands/issue.rs | 39 +++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/tangled-api/src/client.rs b/crates/tangled-api/src/client.rs index 82ec51f..1a55d4d 100644 --- a/crates/tangled-api/src/client.rs +++ b/crates/tangled-api/src/client.rs @@ -415,6 +415,43 @@ impl TangledClient { Err(anyhow!("repo not found for owner/name")) } + pub async fn get_repo_by_rkey( + &self, + did: &str, + rkey: &str, + bearer: Option<&str>, + ) -> Result { + #[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", ¶ms, bearer) + .await?; + Ok(res.value) + } + + pub async fn resolve_did_to_handle( + &self, + did: &str, + bearer: Option<&str>, + ) -> Result { + #[derive(Deserialize)] + struct Res { + handle: String, + } + let params = [("repo", did.to_string())]; + let res: Res = self + .get_json("com.atproto.repo.describeRepo", ¶ms, bearer) + .await?; + Ok(res.handle) + } + pub async fn delete_repo( &self, did: &str, diff --git a/crates/tangled-cli/src/commands/issue.rs b/crates/tangled-cli/src/commands/issue.rs index 28cc54c..b861c1e 100644 --- a/crates/tangled-cli/src/commands/issue.rs +++ b/crates/tangled-cli/src/commands/issue.rs @@ -83,13 +83,50 @@ async fn list(args: IssueListArgs) -> Result<()> { 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 = 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<()> { let session = crate::util::load_session_with_refresh().await?; let pds = session -- 2.50.1 (Apple Git-155)