Skip to main content

torii_lib/platforms/sourcehut/
issue.rs

1//! Sourcehut — issue client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct SourcehutIssueClient {
8    token: String,
9}
10
11impl SourcehutIssueClient {
12    pub fn new() -> Result<Self> {
13        let token = crate::auth::resolve_token("sourcehut", ".")
14            .value
15            .ok_or_else(|| ToriiError::Auth {
16                provider: "sourcehut".into(),
17                message:
18                    "Sourcehut token not found. Generate one at https://meta.sr.ht/oauth and run: \
19                 torii auth set sourcehut YOUR_TOKEN"
20                        .to_string(),
21            })?;
22        Ok(Self { token })
23    }
24
25    fn client(&self) -> Client {
26        crate::http::make_client()
27    }
28    fn auth(&self) -> String {
29        format!("token {}", self.token)
30    }
31}
32
33impl IssueClient for SourcehutIssueClient {
34    fn list(&self, owner: &str, repo: &str, _state: &str) -> Result<Vec<Issue>> {
35        // todo.sr.ht doesn't support per-state filtering on the list
36        // endpoint — we fetch then filter client-side. The owner already
37        // includes the `~` prefix from the URL parser.
38        let url = format!("https://todo.sr.ht/api/trackers/{}/{}/tickets", owner, repo);
39        let req = self.client().get(&url).header("Authorization", self.auth());
40        let json = crate::http::send_json(req, &format!("Sourcehut todo (url: {})", url))?;
41        let arr = json["results"]
42            .as_array()
43            .ok_or_else(|| ToriiError::MalformedResponse {
44                provider: "sourcehut".into(),
45                message: format!("Sourcehut returned no `results` array. Body: {}", json),
46            })?;
47        Ok(arr
48            .iter()
49            .filter_map(|v| parse_sourcehut_issue(v).ok())
50            .collect())
51    }
52
53    fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
54        let url = format!("https://todo.sr.ht/api/trackers/{}/{}/tickets", owner, repo);
55        let body = serde_json::json!({
56            "title":       opts.title,
57            "description": opts.body.unwrap_or_default(),
58        });
59        let req = self
60            .client()
61            .post(&url)
62            .header("Authorization", self.auth())
63            .json(&body);
64        let json = crate::http::send_json(req, "Sourcehut create ticket")?;
65        parse_sourcehut_issue(&json)
66    }
67
68    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
69        // todo.sr.ht ticket updates go through `PUT /tickets/{id}` with
70        // a `status: "resolved"` and `resolution: "fixed"` body.
71        let url = format!(
72            "https://todo.sr.ht/api/trackers/{}/{}/tickets/{}",
73            owner, repo, number
74        );
75        let body = serde_json::json!({
76            "status":     "resolved",
77            "resolution": "fixed",
78        });
79        let req = self
80            .client()
81            .put(&url)
82            .header("Authorization", self.auth())
83            .json(&body);
84        crate::http::send_empty(req, "Sourcehut close ticket")
85    }
86
87    fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
88        let url = format!(
89            "https://todo.sr.ht/api/trackers/{}/{}/tickets/{}/events",
90            owner, repo, number
91        );
92        let payload = serde_json::json!({ "comment": body });
93        let req = self
94            .client()
95            .post(&url)
96            .header("Authorization", self.auth())
97            .json(&payload);
98        crate::http::send_empty(req, "Sourcehut comment ticket")
99    }
100}
101
102fn parse_sourcehut_issue(json: &serde_json::Value) -> Result<Issue> {
103    let number = json["id"].as_u64().unwrap_or(0);
104    let owner = json["tracker"]["owner"]["canonical_name"]
105        .as_str()
106        .unwrap_or("");
107    let tracker = json["tracker"]["name"].as_str().unwrap_or("");
108    Ok(Issue {
109        number,
110        title: json["title"].as_str().unwrap_or("").to_string(),
111        body: json["description"].as_str().map(String::from),
112        // todo.sr.ht uses "reported" (open) and "resolved" (closed).
113        state: match json["status"].as_str().unwrap_or("") {
114            "reported" => "open".to_string(),
115            "resolved" => "closed".to_string(),
116            other => other.to_string(),
117        },
118        author: json["submitter"]["canonical_name"]
119            .as_str()
120            .unwrap_or("")
121            .to_string(),
122        url: format!("https://todo.sr.ht/{}/{}/{}", owner, tracker, number),
123        labels: json["labels"]
124            .as_array()
125            .map(|a| {
126                a.iter()
127                    .filter_map(|l| l["name"].as_str().map(String::from))
128                    .collect()
129            })
130            .unwrap_or_default(),
131        assignees: json["assignees"]
132            .as_array()
133            .map(|a| {
134                a.iter()
135                    .filter_map(|u| u["canonical_name"].as_str().map(String::from))
136                    .collect()
137            })
138            .unwrap_or_default(),
139        created_at: json["created"].as_str().unwrap_or("").to_string(),
140        comments: 0, // todo.sr.ht doesn't expose a count on the list endpoint
141    })
142}
143
144// ── Radicle (peer-to-peer, via `rad` CLI) ────────────────────────────────────
145//
146// Radicle stores issues in special refs inside the project itself —
147// no central server. We drive the `rad issue` subcommand directly.
148// owner/repo args from the URL parser hold the RID and (empty) repo
149// part; `rad` resolves the project from the current working dir's
150// `.git` config, so we don't need to pass the RID per call.
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn parse_sourcehut_issue_full() {
158        let json = serde_json::json!({
159            "id": 12u64,
160            "title": "Ticket title",
161            "description": "Body text",
162            "status": "reported",
163            "submitter": { "canonical_name": "~alice" },
164            "tracker": {
165                "name": "mytracker",
166                "owner": { "canonical_name": "~alice" }
167            },
168            "labels": [ { "name": "bug" }, { "name": "ui" } ],
169            "assignees": [ { "canonical_name": "~bob" } ],
170            "created": "2026-01-01T00:00:00Z",
171        });
172        let issue = parse_sourcehut_issue(&json).unwrap();
173        assert_eq!(issue.number, 12);
174        assert_eq!(issue.title, "Ticket title");
175        assert_eq!(issue.body.as_deref(), Some("Body text"));
176        assert_eq!(issue.state, "open");
177        assert_eq!(issue.author, "~alice");
178        assert_eq!(issue.url, "https://todo.sr.ht/~alice/mytracker/12");
179        assert_eq!(issue.labels, vec!["bug".to_string(), "ui".to_string()]);
180        assert_eq!(issue.assignees, vec!["~bob".to_string()]);
181        assert_eq!(issue.created_at, "2026-01-01T00:00:00Z");
182        assert_eq!(issue.comments, 0);
183    }
184
185    #[test]
186    fn parse_sourcehut_issue_state_mapping() {
187        for (srht, ours) in [
188            ("reported", "open"),
189            ("resolved", "closed"),
190            ("confirmed", "confirmed"), // unknown statuses pass through raw
191        ] {
192            let json = serde_json::json!({ "status": srht });
193            assert_eq!(parse_sourcehut_issue(&json).unwrap().state, ours);
194        }
195    }
196
197    #[test]
198    fn parse_sourcehut_issue_minimal_defaults() {
199        let json = serde_json::json!({});
200        let issue = parse_sourcehut_issue(&json).unwrap();
201        assert_eq!(issue.number, 0);
202        assert_eq!(issue.title, "");
203        assert_eq!(issue.body, None);
204        assert_eq!(issue.author, "");
205        assert!(issue.labels.is_empty());
206        assert!(issue.assignees.is_empty());
207        assert_eq!(issue.created_at, "");
208    }
209}