Skip to main content

torii_lib/platforms/gitea/
issue.rs

1//! Gitea / Codeberg / Forgejo — issue client.
2
3use 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        // Gitea uses `type=issues` to exclude PRs from the listing.
36        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// ── Sourcehut (todo.sr.ht) ───────────────────────────────────────────────────
122//
123// Sourcehut's bug tracker lives on a separate subdomain from the git
124// host. The convention is `~user/<tracker-name>` where tracker name is
125// usually the same as the repo (but not enforced) — projects sometimes
126// have multiple trackers (e.g. `-bugs`, `-features`). We assume
127// `tracker_name == repo_name`; if the user uses a different naming
128// scheme they can pass `--remote` to a remote whose URL points at the
129// correct tracker (in 0.8.0 with platforms.toml this will be
130// configurable per-host).
131
132#[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}