1use 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#[cfg(test)]
204mod tests {
205 use super::*;
206 use httpmock::prelude::*;
207
208 #[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 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}