torii_lib/platforms/radicle/
pr.rs1use 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 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#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn parse_radicle_patch_full() {
154 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); 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 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"); 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}