torii_lib/platforms/radicle/
issue.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use serde_json::Value;
6
7pub struct RadicleIssueClient;
8
9impl RadicleIssueClient {
10 pub fn new() -> Result<Self> {
11 Ok(Self)
12 }
13}
14
15impl IssueClient for RadicleIssueClient {
16 fn list(&self, _o: &str, _r: &str, state: &str) -> Result<Vec<Issue>> {
17 let st = match state {
21 "open" => "open",
22 "closed" => "closed",
23 _ => "all",
24 };
25 let json = crate::radicle::run_rad_json(&["issue", "list", "--state", st])?;
26 let arr = json
27 .as_array()
28 .ok_or_else(|| ToriiError::MalformedResponse {
29 provider: "radicle".into(),
30 message: "rad issue list: expected array".into(),
31 })?;
32 Ok(arr
33 .iter()
34 .filter_map(|v| parse_radicle_issue(v).ok())
35 .collect())
36 }
37
38 fn create(&self, _o: &str, _r: &str, opts: CreateIssueOptions) -> Result<Issue> {
39 let body = opts.body.unwrap_or_default();
40 let stdout = crate::radicle::run_rad(&[
41 "issue",
42 "open",
43 "--title",
44 &opts.title,
45 "--description",
46 &body,
47 ])?;
48 let id = stdout
50 .trim()
51 .lines()
52 .last()
53 .unwrap_or("")
54 .trim()
55 .to_string();
56 Ok(Issue {
57 number: 0, title: opts.title,
59 body: Some(body),
60 state: "open".to_string(),
61 author: String::new(),
62 url: format!("rad:{}", id),
63 labels: vec![],
64 assignees: vec![],
65 created_at: String::new(),
66 comments: 0,
67 })
68 }
69
70 fn close(&self, _o: &str, _r: &str, number: u64) -> Result<()> {
71 Err(ToriiError::Unsupported(format!(
76 "Radicle issues are identified by hash, not number. `torii issue close {}` \
77 cannot be mapped 1:1 — use `rad issue state <id> --closed` directly until \
78 torii's IssueClient trait grows a string-id variant.",
79 number
80 )))
81 }
82
83 fn comment(&self, _o: &str, _r: &str, number: u64, _body: &str) -> Result<()> {
84 Err(ToriiError::Unsupported(format!(
85 "Radicle issues are identified by hash, not number. `torii issue comment {}` \
86 can't address a hash-id issue — use `rad issue comment <id>` directly.",
87 number
88 )))
89 }
90}
91
92fn parse_radicle_issue(v: &Value) -> Result<Issue> {
93 let id = v["id"].as_str().unwrap_or("");
94 Ok(Issue {
95 number: 0,
96 title: v["title"].as_str().unwrap_or("").to_string(),
97 body: v["description"].as_str().map(String::from),
98 state: v["state"]["status"].as_str().unwrap_or("open").to_string(),
99 author: v["author"]["alias"]
100 .as_str()
101 .or_else(|| v["author"]["id"].as_str())
102 .unwrap_or("")
103 .to_string(),
104 url: format!("rad:{}", id),
105 labels: v["labels"]
106 .as_array()
107 .map(|a| {
108 a.iter()
109 .filter_map(|l| l.as_str().map(String::from))
110 .collect()
111 })
112 .unwrap_or_default(),
113 assignees: v["assignees"]
114 .as_array()
115 .map(|a| {
116 a.iter()
117 .filter_map(|u| u.as_str().map(String::from))
118 .collect()
119 })
120 .unwrap_or_default(),
121 created_at: v["timestamp"].as_str().unwrap_or("").to_string(),
122 comments: v["comments"].as_u64().unwrap_or(0),
123 })
124}
125
126#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn parse_radicle_issue_full() {
139 let v = serde_json::json!({
141 "id": "deadbeefcafe",
142 "title": "Tracker bug",
143 "description": "Body text",
144 "state": { "status": "closed" },
145 "author": { "alias": "alice", "id": "did:key:z6MkAlice" },
146 "labels": ["bug", "p1"],
147 "assignees": ["did:key:z6MkBob"],
148 "timestamp": "2026-01-01T00:00:00Z",
149 "comments": 3u64,
150 });
151 let issue = parse_radicle_issue(&v).unwrap();
152 assert_eq!(issue.number, 0); assert_eq!(issue.title, "Tracker bug");
154 assert_eq!(issue.body.as_deref(), Some("Body text"));
155 assert_eq!(issue.state, "closed");
156 assert_eq!(issue.author, "alice");
157 assert_eq!(issue.url, "rad:deadbeefcafe");
158 assert_eq!(issue.labels, vec!["bug".to_string(), "p1".to_string()]);
159 assert_eq!(issue.assignees, vec!["did:key:z6MkBob".to_string()]);
160 assert_eq!(issue.created_at, "2026-01-01T00:00:00Z");
161 assert_eq!(issue.comments, 3);
162 }
163
164 #[test]
165 fn parse_radicle_issue_author_falls_back_to_did() {
166 let v = serde_json::json!({ "author": { "id": "did:key:z6MkExample" } });
167 assert_eq!(
168 parse_radicle_issue(&v).unwrap().author,
169 "did:key:z6MkExample"
170 );
171 }
172
173 #[test]
174 fn parse_radicle_issue_minimal_defaults() {
175 let v = serde_json::json!({});
176 let issue = parse_radicle_issue(&v).unwrap();
177 assert_eq!(issue.number, 0);
178 assert_eq!(issue.title, "");
179 assert_eq!(issue.body, None);
180 assert_eq!(issue.state, "open"); assert_eq!(issue.author, "");
182 assert_eq!(issue.url, "rad:");
183 assert!(issue.labels.is_empty());
184 assert!(issue.assignees.is_empty());
185 assert_eq!(issue.created_at, "");
186 assert_eq!(issue.comments, 0);
187 }
188}