1use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5use reqwest::blocking::Client;
6
7pub struct GiteaPrClient {
8 token: String,
9 base_url: String,
10}
11
12impl GiteaPrClient {
13 pub fn new() -> Result<Self> {
14 Self::new_with_host(gitea_base_url())
15 }
16
17 pub fn new_with_host(base_url: &str) -> Result<Self> {
18 let token = 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 PrClient for GiteaPrClient {
34 fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest> {
35 let url = format!("{}/api/v1/repos/{}/{}/pulls", self.base_url, owner, repo);
36 let mut title = opts.title.clone();
37 if opts.draft && !title.to_lowercase().starts_with("wip:") {
39 title = format!("WIP: {}", title);
40 }
41 let body = serde_json::json!({
42 "title": title,
43 "body": opts.body.unwrap_or_default(),
44 "head": opts.head,
45 "base": opts.base,
46 });
47 let req = self
48 .client()
49 .post(&url)
50 .header("Authorization", self.auth())
51 .json(&body);
52 let json = crate::http::send_json(req, "Gitea create PR")?;
53 parse_gitea_pr(&json)
54 }
55
56 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>> {
57 let url = format!(
58 "{}/api/v1/repos/{}/{}/pulls?state={}&limit=50",
59 self.base_url, owner, repo, state
60 );
61 let req = self.client().get(&url).header("Authorization", self.auth());
62 let json = crate::http::send_json(req, &format!("Gitea (url: {})", url))?;
63 crate::http::extract_array(&json, &url)?
64 .iter()
65 .map(parse_gitea_pr)
66 .collect()
67 }
68
69 fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
70 let url = format!(
71 "{}/api/v1/repos/{}/{}/pulls/{}",
72 self.base_url, owner, repo, number
73 );
74 let req = self.client().get(&url).header("Authorization", self.auth());
75 let json = crate::http::send_json(req, &format!("Gitea PR #{}", number))?;
76 parse_gitea_pr(&json)
77 }
78
79 fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()> {
80 let url = format!(
81 "{}/api/v1/repos/{}/{}/pulls/{}/merge",
82 self.base_url, owner, repo, number
83 );
84 let do_param = match method {
85 MergeMethod::Merge => "merge",
86 MergeMethod::Squash => "squash",
87 MergeMethod::Rebase => "rebase",
88 };
89 let body = serde_json::json!({ "Do": do_param });
90 let req = self
91 .client()
92 .post(&url)
93 .header("Authorization", self.auth())
94 .json(&body);
95 crate::http::send_empty(req, "Gitea merge PR")
96 }
97
98 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
99 let url = format!(
100 "{}/api/v1/repos/{}/{}/pulls/{}",
101 self.base_url, owner, repo, number
102 );
103 let body = serde_json::json!({ "state": "closed" });
104 let req = self
105 .client()
106 .patch(&url)
107 .header("Authorization", self.auth())
108 .json(&body);
109 crate::http::send_empty(req, "Gitea close PR")
110 }
111
112 fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()> {
113 let url = format!(
114 "{}/api/v1/repos/{}/{}/pulls/{}",
115 self.base_url, owner, repo, number
116 );
117 let mut body = serde_json::Map::new();
118 if let Some(t) = opts.title {
119 body.insert("title".into(), serde_json::Value::String(t));
120 }
121 if let Some(b) = opts.body {
122 body.insert("body".into(), serde_json::Value::String(b));
123 }
124 if let Some(base) = opts.base {
125 body.insert("base".into(), serde_json::Value::String(base));
126 }
127 if body.is_empty() {
128 return Ok(());
129 }
130 let req = self
131 .client()
132 .patch(&url)
133 .header("Authorization", self.auth())
134 .json(&serde_json::Value::Object(body));
135 crate::http::send_empty(req, "Gitea update PR")
136 }
137
138 fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()> {
139 let url = format!(
140 "{}/api/v1/repos/{}/{}/branches/{}",
141 self.base_url, owner, repo, branch
142 );
143 let req = self
144 .client()
145 .delete(&url)
146 .header("Authorization", self.auth());
147 crate::http::send_empty(req, "Gitea delete branch")
148 }
149
150 fn checkout_branch(&self, pr: &PullRequest) -> String {
151 pr.head.clone()
152 }
153}
154
155fn parse_gitea_pr(json: &serde_json::Value) -> Result<PullRequest> {
156 Ok(PullRequest {
157 number: json["number"].as_u64().unwrap_or(0),
158 title: json["title"].as_str().unwrap_or("").to_string(),
159 body: json["body"].as_str().map(|s| s.to_string()),
160 state: json["state"].as_str().unwrap_or("").to_string(),
161 head: json["head"]["ref"].as_str().unwrap_or("").to_string(),
162 base: json["base"]["ref"].as_str().unwrap_or("").to_string(),
163 author: json["user"]["login"].as_str().unwrap_or("").to_string(),
164 url: json["html_url"].as_str().unwrap_or("").to_string(),
165 draft: json["title"]
167 .as_str()
168 .map(|s| {
169 let l = s.to_lowercase();
170 l.starts_with("wip:") || l.starts_with("[wip]") || l.starts_with("draft:")
171 })
172 .unwrap_or(false),
173 mergeable: json["mergeable"].as_bool(),
174 created_at: json["created_at"].as_str().unwrap_or("").to_string(),
175 })
176}
177
178pub fn gitea_base_url() -> &'static str {
200 "https://codeberg.org"
201}
202
203pub fn resolve_gitea_token() -> Result<String> {
212 for provider in ["gitea", "codeberg", "forgejo"] {
213 if let Some(t) = crate::auth::resolve_token(provider, ".").value {
214 return Ok(t);
215 }
216 }
217 Err(ToriiError::Auth {
218 provider: "gitea".into(),
219 message:
220 "Gitea / Codeberg / Forgejo token not found. Run: torii auth set codeberg YOUR_TOKEN"
221 .to_string(),
222 })
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use httpmock::prelude::*;
229
230 fn client(server: &MockServer) -> GiteaPrClient {
231 GiteaPrClient {
232 token: "test-token".into(),
233 base_url: server.base_url(),
234 }
235 }
236
237 fn pr_json(number: u64, title: &str) -> serde_json::Value {
238 serde_json::json!({
239 "number": number,
240 "title": title,
241 "body": "the body",
242 "state": "open",
243 "head": { "ref": "feature" },
244 "base": { "ref": "main" },
245 "user": { "login": "alice" },
246 "html_url": "https://codeberg.org/o/r/pulls/1",
247 "mergeable": true,
248 "created_at": "2026-01-02T03:04:05Z",
249 })
250 }
251
252 #[test]
253 fn parse_gitea_pr_extracts_all_fields() {
254 let pr = parse_gitea_pr(&pr_json(7, "Add feature")).unwrap();
255 assert_eq!(pr.number, 7);
256 assert_eq!(pr.title, "Add feature");
257 assert_eq!(pr.body.as_deref(), Some("the body"));
258 assert_eq!(pr.state, "open");
259 assert_eq!(pr.head, "feature");
260 assert_eq!(pr.base, "main");
261 assert_eq!(pr.author, "alice");
262 assert_eq!(pr.url, "https://codeberg.org/o/r/pulls/1");
263 assert!(!pr.draft);
264 assert_eq!(pr.mergeable, Some(true));
265 assert_eq!(pr.created_at, "2026-01-02T03:04:05Z");
266 }
267
268 #[test]
269 fn parse_gitea_pr_detects_drafts_from_title_conventions() {
270 for t in ["WIP: thing", "wip: thing", "[WIP] thing", "Draft: thing"] {
271 let pr = parse_gitea_pr(&pr_json(1, t)).unwrap();
272 assert!(pr.draft, "title {t:?} should be detected as draft");
273 }
274 assert!(
276 !parse_gitea_pr(&pr_json(1, "ship the WIP tracker"))
277 .unwrap()
278 .draft
279 );
280 }
281
282 #[test]
283 fn parse_gitea_pr_defaults_when_optionals_missing() {
284 let pr = parse_gitea_pr(&serde_json::json!({ "number": 3 })).unwrap();
285 assert_eq!(pr.number, 3);
286 assert_eq!(pr.body, None);
287 assert_eq!(pr.mergeable, None);
288 assert_eq!(pr.title, "");
289 assert_eq!(pr.head, "");
290 assert_eq!(pr.base, "");
291 assert_eq!(pr.author, "");
292 assert!(!pr.draft);
293 }
294
295 #[test]
296 fn list_parses_prs_from_mocked_endpoint() {
297 let server = MockServer::start();
298 let mock = server.mock(|when, then| {
299 when.method(GET)
300 .path("/api/v1/repos/owner/repo/pulls")
301 .query_param("state", "open")
302 .query_param("limit", "50")
303 .header("Authorization", "token test-token");
304 then.status(200).json_body(serde_json::json!([
305 pr_json(1, "First"),
306 pr_json(2, "WIP: Second")
307 ]));
308 });
309 let prs = client(&server).list("owner", "repo", "open").unwrap();
310 mock.assert();
311 assert_eq!(prs.len(), 2);
312 assert_eq!(prs[0].number, 1);
313 assert_eq!(prs[0].title, "First");
314 assert!(prs[1].draft);
315 }
316
317 #[test]
318 fn create_prefixes_wip_for_draft_and_sends_token_auth() {
319 let server = MockServer::start();
320 let mock = server.mock(|when, then| {
321 when.method(POST)
322 .path("/api/v1/repos/owner/repo/pulls")
323 .header("Authorization", "token test-token")
324 .json_body(serde_json::json!({
325 "title": "WIP: Feature",
326 "body": "",
327 "head": "feature",
328 "base": "main",
329 }));
330 then.status(201).json_body(pr_json(9, "WIP: Feature"));
331 });
332 let pr = client(&server)
333 .create(
334 "owner",
335 "repo",
336 CreatePrOptions {
337 title: "Feature".into(),
338 body: None,
339 head: "feature".into(),
340 base: "main".into(),
341 draft: true,
342 },
343 )
344 .unwrap();
345 mock.assert();
346 assert_eq!(pr.number, 9);
347 assert!(pr.draft);
348 }
349
350 #[test]
351 fn update_with_no_fields_is_a_noop_without_network() {
352 let c = GiteaPrClient {
355 token: "test-token".into(),
356 base_url: "http://127.0.0.1:1".into(),
357 };
358 let opts = UpdatePrOptions {
359 title: None,
360 body: None,
361 base: None,
362 };
363 assert!(c.update("owner", "repo", 1, opts).is_ok());
364 }
365
366 #[test]
367 fn get_maps_non_2xx_to_platform_api_error() {
368 let server = MockServer::start();
369 server.mock(|when, then| {
370 when.method(GET).path("/api/v1/repos/owner/repo/pulls/4");
371 then.status(500)
372 .json_body(serde_json::json!({ "message": "boom" }));
373 });
374 let err = client(&server).get("owner", "repo", 4).unwrap_err();
375 assert!(
376 matches!(err, ToriiError::PlatformApi { status: 500, .. }),
377 "expected PlatformApi, got: {err:?}"
378 );
379 }
380}