Skip to main content

torii_lib/platforms/gitea/
pr.rs

1//! Gitea / Codeberg / Forgejo — pr client.
2
3use 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        // Gitea has no draft flag — convention is `WIP:` prefix.
38        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        // Gitea convention: WIP: prefix marks drafts (no native flag pre-1.19).
166        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
178// ============================================================================
179// Sourcehut (paradigm mismatch — patches go through mailing lists)
180// ============================================================================
181//
182// Sourcehut's contribution model is **email-based patches sent to
183// `~user/repo-devel@lists.sr.ht`**, not a server-side merge-request
184// object. There is no REST endpoint to "create a PR" the way GitHub
185// or GitLab expose one — the patch lives on the mailing list, the
186// maintainer applies it locally with `torii patch apply`, then pushes.
187//
188// We expose a stub client that returns a clear error explaining the
189// workflow, so the four CLI commands (`torii pr list/create/view/...`)
190// fail with guidance instead of silently working on a wrong endpoint.
191// `torii patch export <range>` + mailing the resulting `.patch` files
192// is the supported flow.
193
194/// Map a "gitea" platform value to its base URL. Today this is always
195/// `https://codeberg.org`; in 0.8.0 with `platforms.toml` support, the
196/// caller will be able to resolve self-hosted instances per-remote.
197///
198/// Centralised here so adding a per-host map later only touches one site.
199pub fn gitea_base_url() -> &'static str {
200    "https://codeberg.org"
201}
202
203/// Resolve the Gitea / Codeberg / Forgejo token. The auth subsystem
204/// accepts all three names as distinct providers (because users like
205/// to call them by their brand), but the API surface is the same — so
206/// we try all three in order and return the first one set.
207///
208/// Used by every Gitea* client (release / issue / pr / pipeline) so
209/// `torii auth set codeberg YOUR_TOKEN` works without forcing the user
210/// to learn that "the canonical provider is gitea".
211pub 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        // "WIP" elsewhere in the title is not a draft marker.
275        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        // Port 1 has no listener — if update() sent a request this
353        // would fail with a Network error instead of Ok(()).
354        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}