Skip to main content

torii_lib/platforms/radicle/
pr.rs

1//! Radicle — pr client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5
6pub struct RadiclePrClient;
7
8impl RadiclePrClient {
9    pub fn new() -> Result<Self> {
10        Ok(Self)
11    }
12}
13
14impl PrClient for RadiclePrClient {
15    fn create(&self, _o: &str, _r: &str, opts: CreatePrOptions) -> Result<PullRequest> {
16        // `rad patch open` creates a patch from the current branch
17        // against the project's default branch. We pass title +
18        // description; head/base are picked up from the current
19        // checkout.
20        let body = opts.body.unwrap_or_default();
21        let stdout = crate::radicle::run_rad(&[
22            "patch",
23            "open",
24            "--message",
25            &opts.title,
26            "--message",
27            &body,
28        ])?;
29        let id = stdout
30            .trim()
31            .lines()
32            .last()
33            .unwrap_or("")
34            .trim()
35            .to_string();
36        Ok(PullRequest {
37            number: 0,
38            title: opts.title,
39            body: Some(body),
40            state: "open".to_string(),
41            head: opts.head,
42            base: opts.base,
43            author: String::new(),
44            url: format!("rad:{}", id),
45            draft: opts.draft,
46            mergeable: None,
47            created_at: String::new(),
48        })
49    }
50
51    fn list(&self, _o: &str, _r: &str, state: &str) -> Result<Vec<PullRequest>> {
52        let st = match state {
53            "open" => "open",
54            "closed" => "archived",
55            "merged" => "merged",
56            _ => "all",
57        };
58        let json = crate::radicle::run_rad_json(&["patch", "list", "--state", st])?;
59        let arr = json
60            .as_array()
61            .ok_or_else(|| ToriiError::MalformedResponse {
62                provider: "radicle".into(),
63                message: "rad patch list: expected array".into(),
64            })?;
65        Ok(arr
66            .iter()
67            .filter_map(|v| parse_radicle_patch(v).ok())
68            .collect())
69    }
70
71    fn get(&self, _o: &str, _r: &str, _number: u64) -> Result<PullRequest> {
72        Err(ToriiError::Unsupported(
73            "Radicle patches are identified by hash, not number. Use \
74             `rad patch show <id>` directly until torii's PrClient trait \
75             grows a string-id variant."
76                .to_string(),
77        ))
78    }
79
80    fn merge(&self, _o: &str, _r: &str, _number: u64, _method: MergeMethod) -> Result<()> {
81        Err(ToriiError::Unsupported(
82            "Radicle patches merge through `rad patch merge <id>` directly. \
83             The CLI's numeric merge surface doesn't apply."
84                .to_string(),
85        ))
86    }
87
88    fn close(&self, _o: &str, _r: &str, _number: u64) -> Result<()> {
89        Err(ToriiError::Unsupported(
90            "Radicle uses `rad patch archive <id>` (by hash) to close a patch.".to_string(),
91        ))
92    }
93
94    fn update(&self, _o: &str, _r: &str, _number: u64, _opts: UpdatePrOptions) -> Result<()> {
95        Err(ToriiError::Unsupported(
96            "Radicle patches are updated by pushing a new revision \
97             (`git push rad HEAD:refs/patches/<id>`). Use the CLI directly."
98                .to_string(),
99        ))
100    }
101
102    fn delete_branch(&self, _o: &str, _r: &str, _b: &str) -> Result<()> {
103        Err(ToriiError::Unsupported(
104            "Radicle patches don't have branches in the github sense; revisions live in COB refs."
105                .to_string(),
106        ))
107    }
108
109    fn checkout_branch(&self, pr: &PullRequest) -> String {
110        pr.head.clone()
111    }
112}
113
114fn parse_radicle_patch(v: &serde_json::Value) -> Result<PullRequest> {
115    let id = v["id"].as_str().unwrap_or("");
116    Ok(PullRequest {
117        number: 0,
118        title: v["title"].as_str().unwrap_or("").to_string(),
119        body: v["description"].as_str().map(String::from),
120        state: v["state"]["status"].as_str().unwrap_or("open").to_string(),
121        head: v["head"].as_str().unwrap_or("").to_string(),
122        base: v["base"].as_str().unwrap_or("").to_string(),
123        author: v["author"]["alias"]
124            .as_str()
125            .or_else(|| v["author"]["id"].as_str())
126            .unwrap_or("")
127            .to_string(),
128        url: format!("rad:{}", id),
129        draft: v["draft"].as_bool().unwrap_or(false),
130        mergeable: None,
131        created_at: v["timestamp"].as_str().unwrap_or("").to_string(),
132    })
133}
134
135// ============================================================================
136// Bitbucket Cloud
137// ============================================================================
138//
139// Bitbucket Cloud's REST v2 at `api.bitbucket.org/2.0`. Auth is Basic
140// with `user:app_password`; if the user stores the token without the
141// `:` we treat it as a Bearer (OAuth) token instead. The terminology:
142//   workspace ≈ owner   (the org / user slug)
143//   repo_slug ≈ repo    (the project slug)
144//   state strings are UPPERCASE: OPEN / MERGED / DECLINED / SUPERSEDED
145// Pages come wrapped in `{ values: [...], pagelen, next }` — we read
146// just the first page (50 entries) like the other clients do.
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn parse_radicle_patch_full() {
154        // Shape of one element from `rad patch list` JSON output.
155        let v = serde_json::json!({
156            "id": "abc123def456",
157            "title": "Fix sync",
158            "description": "Patch body",
159            "state": { "status": "merged" },
160            "head": "deadbeef",
161            "base": "cafebabe",
162            "author": { "alias": "alice", "id": "did:key:z6MkAlice" },
163            "draft": true,
164            "timestamp": "2026-01-01T00:00:00Z",
165        });
166        let pr = parse_radicle_patch(&v).unwrap();
167        assert_eq!(pr.number, 0); // radicle patches are hash-id'd, not numbered
168        assert_eq!(pr.title, "Fix sync");
169        assert_eq!(pr.body.as_deref(), Some("Patch body"));
170        assert_eq!(pr.state, "merged");
171        assert_eq!(pr.head, "deadbeef");
172        assert_eq!(pr.base, "cafebabe");
173        assert_eq!(pr.author, "alice");
174        assert_eq!(pr.url, "rad:abc123def456");
175        assert!(pr.draft);
176        assert_eq!(pr.mergeable, None);
177        assert_eq!(pr.created_at, "2026-01-01T00:00:00Z");
178    }
179
180    #[test]
181    fn parse_radicle_patch_author_falls_back_to_did() {
182        // Peers without an alias only expose their DID.
183        let v = serde_json::json!({ "author": { "id": "did:key:z6MkExample" } });
184        assert_eq!(
185            parse_radicle_patch(&v).unwrap().author,
186            "did:key:z6MkExample"
187        );
188    }
189
190    #[test]
191    fn parse_radicle_patch_minimal_defaults() {
192        let v = serde_json::json!({});
193        let pr = parse_radicle_patch(&v).unwrap();
194        assert_eq!(pr.title, "");
195        assert_eq!(pr.body, None);
196        assert_eq!(pr.state, "open"); // missing state defaults to open
197        assert_eq!(pr.head, "");
198        assert_eq!(pr.base, "");
199        assert_eq!(pr.author, "");
200        assert_eq!(pr.url, "rad:");
201        assert!(!pr.draft);
202        assert_eq!(pr.created_at, "");
203    }
204}