feat: implement --state filter for issue list command

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 <crush@charm.land>

dunkirk.sh fc7640a0 53768759

verified
Changed files
+76 -3
crates
tangled-api
tangled-cli
src
commands
+35
crates/tangled-api/src/client.rs
···
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<Vec<IssueState>> {
+
#[derive(Deserialize)]
+
struct Item {
+
#[allow(dead_code)]
+
uri: String,
+
#[allow(dead_code)]
+
cid: Option<String>,
+
value: IssueState,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
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", &params, bearer)
+
.await?;
+
Ok(res.records.into_iter().map(|it| it.value).collect())
+
}
+
pub async fn get_pull_record(
&self,
author_did: &str,
···
pub author_did: String,
pub rkey: String,
pub issue: Issue,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct IssueState {
+
pub issue: String,
+
pub state: String,
// Pull record value (subset)
+2 -2
crates/tangled-api/src/lib.rs
···
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,
};
+39 -1
crates/tangled-cli/src/commands/issue.rs
···
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 {