Skip to main content

torii_lib/platforms/gitlab/
pr.rs

1//! GitLab — pr client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabPrClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitLabPrClient {
13    pub fn new() -> Result<Self> {
14        let token = crate::auth::resolve_token("gitlab", ".")
15            .value
16            .ok_or_else(|| ToriiError::Auth {
17                provider: "gitlab".into(),
18                message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN"
19                    .to_string(),
20            })?;
21        let base_url =
22            std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
23        Ok(Self { token, base_url })
24    }
25
26    fn client(&self) -> Client {
27        crate::http::make_client()
28    }
29
30    fn project_path(owner: &str, repo: &str) -> String {
31        crate::url::encode(&format!("{}/{}", owner, repo))
32    }
33}
34
35impl PrClient for GitLabPrClient {
36    fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest> {
37        let url = format!(
38            "{}/projects/{}/merge_requests",
39            self.base_url,
40            Self::project_path(owner, repo)
41        );
42        let body = serde_json::json!({
43            "title":         opts.title,
44            "description":   opts.body.unwrap_or_default(),
45            "source_branch": opts.head,
46            "target_branch": opts.base,
47            "draft":         opts.draft,
48        });
49        let req = self
50            .client()
51            .post(&url)
52            .header("Authorization", format!("Bearer {}", self.token))
53            .json(&body);
54        let json = crate::http::send_json(req, "GitLab create MR")?;
55        parse_gitlab_mr(&json)
56    }
57
58    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>> {
59        let gl_state = match state {
60            "open" => "opened",
61            "closed" => "closed",
62            "merged" => "merged",
63            other => other,
64        };
65        let url = format!(
66            "{}/projects/{}/merge_requests?state={}&per_page=50",
67            self.base_url,
68            Self::project_path(owner, repo),
69            gl_state
70        );
71        let req = self
72            .client()
73            .get(&url)
74            .header("Authorization", format!("Bearer {}", self.token));
75        let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
76        crate::http::extract_array(&json, &url)?
77            .iter()
78            .map(parse_gitlab_mr)
79            .collect()
80    }
81
82    fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
83        let url = format!(
84            "{}/projects/{}/merge_requests/{}",
85            self.base_url,
86            Self::project_path(owner, repo),
87            number
88        );
89        let req = self
90            .client()
91            .get(&url)
92            .header("Authorization", format!("Bearer {}", self.token));
93        let json = crate::http::send_json(req, &format!("GitLab MR !{}", number))?;
94        parse_gitlab_mr(&json)
95    }
96
97    fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()> {
98        let url = format!(
99            "{}/projects/{}/merge_requests/{}/merge",
100            self.base_url,
101            Self::project_path(owner, repo),
102            number
103        );
104        let squash = matches!(method, MergeMethod::Squash);
105        let body = serde_json::json!({ "squash": squash });
106        let req = self
107            .client()
108            .put(&url)
109            .header("Authorization", format!("Bearer {}", self.token))
110            .json(&body);
111        crate::http::send_empty(req, "GitLab merge MR")
112    }
113
114    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
115        let url = format!(
116            "{}/projects/{}/merge_requests/{}",
117            self.base_url,
118            Self::project_path(owner, repo),
119            number
120        );
121        let body = serde_json::json!({ "state_event": "close" });
122        let req = self
123            .client()
124            .put(&url)
125            .header("Authorization", format!("Bearer {}", self.token))
126            .json(&body);
127        crate::http::send_empty(req, "GitLab close MR")
128    }
129
130    fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()> {
131        let url = format!(
132            "{}/projects/{}/merge_requests/{}",
133            self.base_url,
134            Self::project_path(owner, repo),
135            number
136        );
137        let mut body = serde_json::Map::new();
138        if let Some(t) = opts.title {
139            body.insert("title".into(), t.into());
140        }
141        if let Some(b) = opts.body {
142            body.insert("description".into(), b.into());
143        }
144        if let Some(b) = opts.base {
145            body.insert("target_branch".into(), b.into());
146        }
147        let req = self
148            .client()
149            .put(&url)
150            .header("Authorization", format!("Bearer {}", self.token))
151            .json(&serde_json::Value::Object(body));
152        crate::http::send_empty(req, "GitLab update MR")
153    }
154
155    fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()> {
156        let url = format!(
157            "{}/projects/{}/repository/branches/{}",
158            self.base_url,
159            Self::project_path(owner, repo),
160            crate::url::encode(branch)
161        );
162        let req = self
163            .client()
164            .delete(&url)
165            .header("Authorization", format!("Bearer {}", self.token));
166        crate::http::send_empty(req, "GitLab delete branch")
167    }
168
169    fn checkout_branch(&self, pr: &PullRequest) -> String {
170        pr.head.clone()
171    }
172}
173
174fn parse_gitlab_mr(json: &serde_json::Value) -> Result<PullRequest> {
175    Ok(PullRequest {
176        number: json["iid"].as_u64().unwrap_or(0),
177        title: json["title"].as_str().unwrap_or("").to_string(),
178        body: json["description"].as_str().map(|s| s.to_string()),
179        state: json["state"].as_str().unwrap_or("").to_string(),
180        head: json["source_branch"].as_str().unwrap_or("").to_string(),
181        base: json["target_branch"].as_str().unwrap_or("").to_string(),
182        author: json["author"]["username"]
183            .as_str()
184            .unwrap_or("")
185            .to_string(),
186        url: json["web_url"].as_str().unwrap_or("").to_string(),
187        draft: json["draft"].as_bool().unwrap_or(false),
188        mergeable: json["merge_status"].as_str().map(|s| s == "can_be_merged"),
189        created_at: json["created_at"].as_str().unwrap_or("").to_string(),
190    })
191}
192
193// ============================================================================
194// Gitea / Codeberg / Forgejo
195// ============================================================================
196//
197// Gitea's pulls API is GitHub-shaped at `/api/v1/...`. Same `number`
198// identifier, same `head`/`base`/`draft` fields, same `merge_method`
199// values for merge — auth header is `token <token>` like GitHub.
200// `mergeable` is exposed as a boolean rather than GitHub's null/true
201// while-computing dance, so we surface it directly.
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use httpmock::prelude::*;
207
208    // ── parser ───────────────────────────────────────────────────────────
209
210    #[test]
211    fn parse_gitlab_mr_full() {
212        let json = serde_json::json!({
213            "iid": 42u64,
214            "title": "Add feature",
215            "description": "Implements the thing.",
216            "state": "opened",
217            "source_branch": "feature/x",
218            "target_branch": "main",
219            "author": { "username": "paski" },
220            "web_url": "https://gitlab.com/acme/widget/-/merge_requests/42",
221            "draft": true,
222            "merge_status": "can_be_merged",
223            "created_at": "2026-06-01T12:00:00Z"
224        });
225        let pr = parse_gitlab_mr(&json).unwrap();
226        assert_eq!(pr.number, 42);
227        assert_eq!(pr.title, "Add feature");
228        assert_eq!(pr.body.as_deref(), Some("Implements the thing."));
229        assert_eq!(pr.state, "opened");
230        assert_eq!(pr.head, "feature/x");
231        assert_eq!(pr.base, "main");
232        assert_eq!(pr.author, "paski");
233        assert_eq!(pr.url, "https://gitlab.com/acme/widget/-/merge_requests/42");
234        assert!(pr.draft);
235        assert_eq!(pr.mergeable, Some(true));
236        assert_eq!(pr.created_at, "2026-06-01T12:00:00Z");
237    }
238
239    #[test]
240    fn parse_gitlab_mr_missing_optionals_defaults() {
241        let json = serde_json::json!({
242            "iid": 7u64,
243            "title": "t",
244            "state": "merged",
245            "source_branch": "b",
246            "target_branch": "main"
247        });
248        let pr = parse_gitlab_mr(&json).unwrap();
249        assert_eq!(pr.number, 7);
250        assert_eq!(pr.body, None);
251        assert_eq!(pr.author, "");
252        assert!(!pr.draft);
253        assert_eq!(pr.mergeable, None);
254        assert_eq!(pr.created_at, "");
255    }
256
257    #[test]
258    fn parse_gitlab_mr_cannot_be_merged_maps_to_false() {
259        let json = serde_json::json!({ "iid": 1u64, "merge_status": "cannot_be_merged" });
260        assert_eq!(parse_gitlab_mr(&json).unwrap().mergeable, Some(false));
261    }
262
263    // ── client (httpmock) ────────────────────────────────────────────────
264
265    fn client(server: &MockServer) -> GitLabPrClient {
266        GitLabPrClient {
267            token: "test-token".into(),
268            base_url: server.base_url(),
269        }
270    }
271
272    #[test]
273    fn list_translates_open_state_and_parses_mrs() {
274        let server = MockServer::start();
275        let m = server.mock(|when, then| {
276            when.method(GET)
277                .path("/projects/acme%2Fwidget/merge_requests")
278                .query_param("state", "opened")
279                .header("Authorization", "Bearer test-token");
280            then.status(200).json_body(serde_json::json!([{
281                "iid": 5u64, "title": "MR five", "state": "opened",
282                "source_branch": "f", "target_branch": "main",
283                "author": { "username": "paski" },
284                "web_url": "https://x", "created_at": ""
285            }]));
286        });
287        let prs = client(&server).list("acme", "widget", "open").unwrap();
288        m.assert();
289        assert_eq!(prs.len(), 1);
290        assert_eq!(prs[0].number, 5);
291        assert_eq!(prs[0].title, "MR five");
292    }
293
294    #[test]
295    fn close_sends_put_state_event_with_bearer_auth() {
296        let server = MockServer::start();
297        let m = server.mock(|when, then| {
298            when.method(PUT)
299                .path("/projects/acme%2Fwidget/merge_requests/7")
300                .header("Authorization", "Bearer test-token")
301                .json_body(serde_json::json!({ "state_event": "close" }));
302            then.status(200);
303        });
304        client(&server).close("acme", "widget", 7).unwrap();
305        m.assert();
306    }
307
308    #[test]
309    fn get_non_2xx_maps_to_platform_api_error() {
310        let server = MockServer::start();
311        server.mock(|when, then| {
312            when.method(GET)
313                .path("/projects/acme%2Fwidget/merge_requests/404");
314            then.status(404)
315                .json_body(serde_json::json!({ "message": "404 Not found" }));
316        });
317        let err = client(&server).get("acme", "widget", 404).unwrap_err();
318        assert!(
319            matches!(err, ToriiError::PlatformApi { .. }),
320            "expected PlatformApi, got: {err:?}"
321        );
322    }
323}