torii_lib/platforms/sourcehut/
issue.rs1use 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 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 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 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, })
142}
143
144#[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"), ] {
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}