torii_lib/platforms/gitea/
issue.rs1use crate::error::Result;
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct GiteaIssueClient {
8 token: String,
9 base_url: String,
10}
11
12impl GiteaIssueClient {
13 pub fn new() -> Result<Self> {
14 Self::new_with_host(crate::pr::gitea_base_url())
15 }
16
17 pub fn new_with_host(base_url: &str) -> Result<Self> {
18 let token = crate::pr::resolve_gitea_token()?;
19 Ok(Self {
20 token,
21 base_url: base_url.trim_end_matches('/').to_string(),
22 })
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 GiteaIssueClient {
34 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
35 let url = format!(
37 "{}/api/v1/repos/{}/{}/issues?state={}&type=issues&limit=50",
38 self.base_url, owner, repo, state
39 );
40 let req = self.client().get(&url).header("Authorization", self.auth());
41 let json = crate::http::send_json(req, &format!("Gitea (url: {})", url))?;
42 Ok(crate::http::extract_array(&json, &url)?
43 .iter()
44 .filter_map(|v| parse_gitea_issue(v).ok())
45 .collect())
46 }
47
48 fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
49 let url = format!("{}/api/v1/repos/{}/{}/issues", self.base_url, owner, repo);
50 let body = serde_json::json!({
51 "title": opts.title,
52 "body": opts.body.unwrap_or_default(),
53 });
54 let req = self
55 .client()
56 .post(&url)
57 .header("Authorization", self.auth())
58 .json(&body);
59 let json = crate::http::send_json(req, "Gitea create issue")?;
60 parse_gitea_issue(&json)
61 }
62
63 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
64 let url = format!(
65 "{}/api/v1/repos/{}/{}/issues/{}",
66 self.base_url, owner, repo, number
67 );
68 let body = serde_json::json!({ "state": "closed" });
69 let req = self
70 .client()
71 .patch(&url)
72 .header("Authorization", self.auth())
73 .json(&body);
74 crate::http::send_empty(req, "Gitea close issue")
75 }
76
77 fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
78 let url = format!(
79 "{}/api/v1/repos/{}/{}/issues/{}/comments",
80 self.base_url, owner, repo, number
81 );
82 let payload = serde_json::json!({ "body": body });
83 let req = self
84 .client()
85 .post(&url)
86 .header("Authorization", self.auth())
87 .json(&payload);
88 crate::http::send_empty(req, "Gitea comment issue")
89 }
90}
91
92fn parse_gitea_issue(json: &serde_json::Value) -> Result<Issue> {
93 Ok(Issue {
94 number: json["number"].as_u64().unwrap_or(0),
95 title: json["title"].as_str().unwrap_or("").to_string(),
96 body: json["body"].as_str().map(|s| s.to_string()),
97 state: json["state"].as_str().unwrap_or("").to_string(),
98 author: json["user"]["login"].as_str().unwrap_or("").to_string(),
99 url: json["html_url"].as_str().unwrap_or("").to_string(),
100 labels: json["labels"]
101 .as_array()
102 .map(|a| {
103 a.iter()
104 .filter_map(|l| l["name"].as_str().map(|s| s.to_string()))
105 .collect()
106 })
107 .unwrap_or_default(),
108 assignees: json["assignees"]
109 .as_array()
110 .map(|a| {
111 a.iter()
112 .filter_map(|u| u["login"].as_str().map(|s| s.to_string()))
113 .collect()
114 })
115 .unwrap_or_default(),
116 created_at: json["created_at"].as_str().unwrap_or("").to_string(),
117 comments: json["comments"].as_u64().unwrap_or(0),
118 })
119}
120
121#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::error::ToriiError;
136 use httpmock::prelude::*;
137
138 fn client(server: &MockServer) -> GiteaIssueClient {
139 GiteaIssueClient {
140 token: "test-token".into(),
141 base_url: server.base_url(),
142 }
143 }
144
145 fn issue_json(number: u64) -> serde_json::Value {
146 serde_json::json!({
147 "number": number,
148 "title": "Crash on startup",
149 "body": "stack trace attached",
150 "state": "open",
151 "user": { "login": "bob" },
152 "html_url": "https://codeberg.org/o/r/issues/12",
153 "labels": [{ "name": "bug" }, { "name": "p1" }],
154 "assignees": [{ "login": "alice" }, { "login": "carol" }],
155 "created_at": "2026-02-03T04:05:06Z",
156 "comments": 4,
157 })
158 }
159
160 #[test]
161 fn parse_gitea_issue_extracts_all_fields() {
162 let i = parse_gitea_issue(&issue_json(12)).unwrap();
163 assert_eq!(i.number, 12);
164 assert_eq!(i.title, "Crash on startup");
165 assert_eq!(i.body.as_deref(), Some("stack trace attached"));
166 assert_eq!(i.state, "open");
167 assert_eq!(i.author, "bob");
168 assert_eq!(i.url, "https://codeberg.org/o/r/issues/12");
169 assert_eq!(i.labels, vec!["bug".to_string(), "p1".to_string()]);
170 assert_eq!(i.assignees, vec!["alice".to_string(), "carol".to_string()]);
171 assert_eq!(i.created_at, "2026-02-03T04:05:06Z");
172 assert_eq!(i.comments, 4);
173 }
174
175 #[test]
176 fn parse_gitea_issue_defaults_when_optionals_missing() {
177 let i = parse_gitea_issue(&serde_json::json!({ "number": 5, "title": "t" })).unwrap();
178 assert_eq!(i.number, 5);
179 assert_eq!(i.body, None);
180 assert!(i.labels.is_empty());
181 assert!(i.assignees.is_empty());
182 assert_eq!(i.author, "");
183 assert_eq!(i.comments, 0);
184 }
185
186 #[test]
187 fn list_parses_issues_from_mocked_endpoint() {
188 let server = MockServer::start();
189 let mock = server.mock(|when, then| {
190 when.method(GET)
191 .path("/api/v1/repos/owner/repo/issues")
192 .query_param("state", "open")
193 .query_param("type", "issues")
194 .query_param("limit", "50")
195 .header("Authorization", "token test-token");
196 then.status(200)
197 .json_body(serde_json::json!([issue_json(1), issue_json(2)]));
198 });
199 let issues = client(&server).list("owner", "repo", "open").unwrap();
200 mock.assert();
201 assert_eq!(issues.len(), 2);
202 assert_eq!(issues[0].number, 1);
203 assert_eq!(issues[1].number, 2);
204 }
205
206 #[test]
207 fn comment_posts_body_with_token_auth() {
208 let server = MockServer::start();
209 let mock = server.mock(|when, then| {
210 when.method(POST)
211 .path("/api/v1/repos/owner/repo/issues/8/comments")
212 .header("Authorization", "token test-token")
213 .json_body(serde_json::json!({ "body": "looks good" }));
214 then.status(201);
215 });
216 client(&server)
217 .comment("owner", "repo", 8, "looks good")
218 .unwrap();
219 mock.assert();
220 }
221
222 #[test]
223 fn list_maps_non_2xx_to_platform_api_error() {
224 let server = MockServer::start();
225 server.mock(|when, then| {
226 when.method(GET).path("/api/v1/repos/owner/repo/issues");
227 then.status(403)
228 .json_body(serde_json::json!({ "message": "forbidden" }));
229 });
230 let err = client(&server).list("owner", "repo", "open").unwrap_err();
231 assert!(
232 matches!(err, ToriiError::PlatformApi { status: 403, .. }),
233 "expected PlatformApi, got: {err:?}"
234 );
235 }
236}