Skip to main content

torii_lib/platforms/radicle/
issue.rs

1//! Radicle — issue client.
2
3use 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        // `rad issue list --state open|closed|all`. We translate the
18        // platform-agnostic "open" / "closed" / "all" into rad's
19        // matching state names.
20        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        // `rad issue open` prints the new issue id on the last line.
49        let id = stdout
50            .trim()
51            .lines()
52            .last()
53            .unwrap_or("")
54            .trim()
55            .to_string();
56        Ok(Issue {
57            number: 0, // Radicle issues are identified by hash, not number
58            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        // Radicle uses string ids, not numbers — torii's IssueClient
72        // signature takes u64 so we can't address a real radicle issue
73        // through this method. Surface a clear error pointing at the
74        // CLI direct path.
75        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// ── Bitbucket Cloud (issues — deprecated but still works if enabled) ────────
127//
128// Bitbucket Cloud's issue tracker is technically deprecated in favour
129// of third-party trackers (Jira), but the REST endpoint still works
130// for repos that have issues enabled. On repos without issues
131// enabled, the API returns 404 — we surface that with a hint.
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn parse_radicle_issue_full() {
139        // Shape of one element from `rad issue list` JSON output.
140        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); // radicle issues are hash-id'd, not numbered
153        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"); // missing state defaults to open
181        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}