1use crate::error::{Result, ToriiError};
4use crate::platforms::pipeline::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabPipelineClient {
8 token: String,
9 base_url: String,
10}
11
12impl GitLabPipelineClient {
13 #[allow(dead_code)]
16 pub fn new() -> Result<Self> {
17 Self::new_with_base_url(None)
18 }
19
20 pub fn new_with_base_url(base_url: Option<&str>) -> Result<Self> {
24 let token = crate::auth::resolve_token("gitlab", ".")
25 .value
26 .ok_or_else(|| ToriiError::Auth {
27 provider: "gitlab".into(),
28 message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN"
29 .to_string(),
30 })?;
31 let resolved = base_url
32 .map(|s| s.trim_end_matches('/').to_string())
33 .or_else(|| std::env::var("GITLAB_URL").ok())
34 .unwrap_or_else(|| "https://gitlab.com/api/v4".to_string());
35 Ok(Self {
36 token,
37 base_url: resolved,
38 })
39 }
40
41 fn client(&self) -> Client {
42 crate::http::make_client()
43 }
44
45 fn project_path(owner: &str, repo: &str) -> String {
46 crate::url::encode(&format!("{}/{}", owner, repo))
47 }
48}
49
50impl PipelineClient for GitLabPipelineClient {
51 fn list(&self, owner: &str, repo: &str, filters: &ListFilters) -> Result<Vec<Pipeline>> {
52 let mut url = format!(
53 "{}/projects/{}/pipelines?per_page={}",
54 self.base_url,
55 Self::project_path(owner, repo),
56 filters.per_page.clamp(1, 100)
57 );
58 if let Some(ref s) = filters.status {
59 let gl = match s.as_str() {
60 "success" => "success",
61 "failed" => "failed",
62 "running" => "running",
63 "canceled" => "canceled",
64 "pending" => "pending",
65 other => other,
66 };
67 url.push_str(&format!("&status={}", gl));
68 }
69 let req = self
70 .client()
71 .get(&url)
72 .header("Authorization", format!("Bearer {}", self.token));
73 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
74 crate::http::extract_array(&json, &url)?
75 .iter()
76 .map(parse_gitlab_pipeline)
77 .collect()
78 }
79
80 fn cancel(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
81 let url = format!(
82 "{}/projects/{}/pipelines/{}/cancel",
83 self.base_url,
84 Self::project_path(owner, repo),
85 id
86 );
87 let req = self
88 .client()
89 .post(&url)
90 .header("Authorization", format!("Bearer {}", self.token));
91 crate::http::send_empty(req, "GitLab cancel pipeline")
92 }
93
94 fn retry(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
95 let url = format!(
96 "{}/projects/{}/pipelines/{}/retry",
97 self.base_url,
98 Self::project_path(owner, repo),
99 id
100 );
101 let req = self
102 .client()
103 .post(&url)
104 .header("Authorization", format!("Bearer {}", self.token));
105 crate::http::send_empty(req, "GitLab retry pipeline")
106 }
107
108 fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
109 let url = format!(
110 "{}/projects/{}/pipelines/{}",
111 self.base_url,
112 Self::project_path(owner, repo),
113 id
114 );
115 let req = self
116 .client()
117 .delete(&url)
118 .header("Authorization", format!("Bearer {}", self.token));
119 crate::http::send_empty(req, "GitLab delete pipeline")
120 }
121
122 fn list_jobs(
125 &self,
126 owner: &str,
127 repo: &str,
128 pipeline_id: &str,
129 status_filter: Option<&str>,
130 ) -> Result<Vec<Job>> {
131 let url = format!(
135 "{}/projects/{}/pipelines/{}/jobs?per_page=100",
136 self.base_url,
137 Self::project_path(owner, repo),
138 pipeline_id
139 );
140 let req = self
141 .client()
142 .get(&url)
143 .header("Authorization", format!("Bearer {}", self.token));
144 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
145 let arr = crate::http::extract_array(&json, &url)?;
146 let jobs: Vec<Job> = arr
147 .iter()
148 .filter_map(|v| parse_gitlab_job(v, pipeline_id).ok())
149 .collect();
150 if let Some(s) = status_filter {
151 Ok(jobs.into_iter().filter(|j| j.status == s).collect())
152 } else {
153 Ok(jobs)
154 }
155 }
156
157 fn job_log(&self, owner: &str, repo: &str, job_id: &str) -> Result<String> {
158 let url = format!(
161 "{}/projects/{}/jobs/{}/trace",
162 self.base_url,
163 Self::project_path(owner, repo),
164 job_id
165 );
166 let req = self
167 .client()
168 .get(&url)
169 .header("Authorization", format!("Bearer {}", self.token));
170 crate::http::send_text(req, "GitLab job trace")
171 }
172
173 fn job_retry(&self, owner: &str, repo: &str, job_id: &str) -> Result<()> {
174 let url = format!(
175 "{}/projects/{}/jobs/{}/retry",
176 self.base_url,
177 Self::project_path(owner, repo),
178 job_id
179 );
180 let req = self
181 .client()
182 .post(&url)
183 .header("Authorization", format!("Bearer {}", self.token));
184 crate::http::send_empty(req, "GitLab job retry")
185 }
186
187 fn job_cancel(&self, owner: &str, repo: &str, job_id: &str) -> Result<()> {
188 let url = format!(
189 "{}/projects/{}/jobs/{}/cancel",
190 self.base_url,
191 Self::project_path(owner, repo),
192 job_id
193 );
194 let req = self
195 .client()
196 .post(&url)
197 .header("Authorization", format!("Bearer {}", self.token));
198 crate::http::send_empty(req, "GitLab job cancel")
199 }
200
201 fn job_artifacts_download(
202 &self,
203 owner: &str,
204 repo: &str,
205 job_id: &str,
206 output_path: &std::path::Path,
207 ) -> Result<()> {
208 let url = format!(
209 "{}/projects/{}/jobs/{}/artifacts",
210 self.base_url,
211 Self::project_path(owner, repo),
212 job_id
213 );
214 let req = self
215 .client()
216 .get(&url)
217 .header("Authorization", format!("Bearer {}", self.token));
218 let bytes = crate::http::send_bytes(req, "GitLab artifacts")?;
219 std::fs::write(output_path, &bytes).map_err(|e| {
220 ToriiError::Fs(format!(
221 "Failed to write artifacts to {}: {}",
222 output_path.display(),
223 e
224 ))
225 })?;
226 Ok(())
227 }
228
229 fn job_erase(&self, owner: &str, repo: &str, job_id: &str) -> Result<()> {
230 let url = format!(
231 "{}/projects/{}/jobs/{}/erase",
232 self.base_url,
233 Self::project_path(owner, repo),
234 job_id
235 );
236 let req = self
237 .client()
238 .post(&url)
239 .header("Authorization", format!("Bearer {}", self.token));
240 crate::http::send_empty(req, "GitLab job erase")
241 }
242}
243
244fn parse_gitlab_job(v: &serde_json::Value, pipeline_id: &str) -> Result<Job> {
245 let id = v["id"]
246 .as_u64()
247 .map(|n| n.to_string())
248 .or_else(|| v["id"].as_str().map(String::from))
249 .ok_or_else(|| ToriiError::MalformedResponse {
250 provider: "gitlab".into(),
251 message: "GitLab job missing id".into(),
252 })?;
253 let raw_status = v["status"].as_str().unwrap_or("").to_string();
254 let status = match raw_status.as_str() {
255 "success" => "success",
256 "failed" => "failed",
257 "running" | "preparing" | "waiting_for_resource" => "running",
258 "canceled" | "cancelled" => "canceled",
259 "pending" | "scheduled" | "created" | "manual" => "pending",
260 "skipped" => "canceled",
261 _ => "other",
262 }
263 .to_string();
264 Ok(Job {
265 id,
266 pipeline_id: pipeline_id.to_string(),
267 name: v["name"].as_str().unwrap_or("").to_string(),
268 status,
269 raw_status,
270 stage: v["stage"].as_str().unwrap_or("").to_string(),
271 web_url: v["web_url"].as_str().unwrap_or("").to_string(),
272 created_at: v["created_at"].as_str().unwrap_or("").to_string(),
273 finished_at: v["finished_at"].as_str().map(String::from),
274 duration_seconds: v["duration"].as_f64(),
275 })
276}
277
278pub(crate) fn parse_gitlab_pipeline(v: &serde_json::Value) -> Result<Pipeline> {
279 let id = v["id"]
280 .as_u64()
281 .map(|n| n.to_string())
282 .or_else(|| v["id"].as_str().map(String::from))
283 .ok_or_else(|| ToriiError::MalformedResponse {
284 provider: "gitlab".into(),
285 message: "GitLab pipeline missing id".into(),
286 })?;
287 let raw_status = v["status"].as_str().unwrap_or("").to_string();
288 let status = match raw_status.as_str() {
289 "success" => "success",
290 "failed" => "failed",
291 "running" | "preparing" | "waiting_for_resource" => "running",
292 "canceled" | "cancelled" => "canceled",
293 "pending" | "scheduled" | "created" | "manual" => "pending",
294 _ => "other",
295 }
296 .to_string();
297 Ok(Pipeline {
298 id,
299 status,
300 raw_status,
301 branch: v["ref"].as_str().unwrap_or("").to_string(),
302 sha: v["sha"].as_str().unwrap_or("").to_string(),
303 web_url: v["web_url"].as_str().unwrap_or("").to_string(),
304 created_at: v["created_at"].as_str().unwrap_or("").to_string(),
305 updated_at: v["updated_at"].as_str().unwrap_or("").to_string(),
306 })
307}
308
309#[cfg(test)]
325mod tests {
326 use super::*;
330 use httpmock::prelude::*;
331
332 #[test]
335 fn parse_gitlab_job_full() {
336 let json = serde_json::json!({
337 "id": 555u64,
338 "status": "failed",
339 "name": "build-linux",
340 "stage": "build",
341 "web_url": "https://gitlab.com/acme/widget/-/jobs/555",
342 "created_at": "2026-06-01T10:00:00Z",
343 "finished_at": "2026-06-01T10:05:00Z",
344 "duration": 300.5
345 });
346 let j = parse_gitlab_job(&json, "99").unwrap();
347 assert_eq!(j.id, "555");
348 assert_eq!(j.pipeline_id, "99");
349 assert_eq!(j.name, "build-linux");
350 assert_eq!(j.status, "failed");
351 assert_eq!(j.raw_status, "failed");
352 assert_eq!(j.stage, "build");
353 assert_eq!(j.web_url, "https://gitlab.com/acme/widget/-/jobs/555");
354 assert_eq!(j.finished_at.as_deref(), Some("2026-06-01T10:05:00Z"));
355 assert_eq!(j.duration_seconds, Some(300.5));
356 }
357
358 #[test]
359 fn parse_gitlab_job_normalizes_statuses() {
360 for (raw, want) in [
361 ("waiting_for_resource", "running"),
362 ("manual", "pending"),
363 ("skipped", "canceled"),
364 ("cancelled", "canceled"),
365 ("something_new", "other"),
366 ] {
367 let json = serde_json::json!({ "id": 1u64, "status": raw });
368 let j = parse_gitlab_job(&json, "1").unwrap();
369 assert_eq!(j.status, want, "raw status `{raw}`");
370 assert_eq!(j.raw_status, raw);
371 assert_eq!(j.finished_at, None);
372 assert_eq!(j.duration_seconds, None);
373 }
374 }
375
376 #[test]
377 fn parse_gitlab_job_missing_id_is_malformed_response() {
378 let err = parse_gitlab_job(&serde_json::json!({ "status": "failed" }), "1").unwrap_err();
379 assert!(
380 matches!(err, ToriiError::MalformedResponse { .. }),
381 "expected MalformedResponse, got: {err:?}"
382 );
383 }
384
385 fn client(server: &MockServer) -> GitLabPipelineClient {
388 GitLabPipelineClient {
389 token: "test-token".into(),
390 base_url: server.base_url(),
391 }
392 }
393
394 #[test]
395 fn list_passes_status_and_per_page_filters() {
396 let server = MockServer::start();
397 let m = server.mock(|when, then| {
398 when.method(GET)
399 .path("/projects/acme%2Fwidget/pipelines")
400 .query_param("per_page", "5")
401 .query_param("status", "failed")
402 .header("Authorization", "Bearer test-token");
403 then.status(200).json_body(serde_json::json!([{
404 "id": 42u64, "status": "failed", "ref": "main", "sha": "abc",
405 "web_url": "https://x", "created_at": "", "updated_at": ""
406 }]));
407 });
408 let filters = ListFilters {
409 status: Some("failed".into()),
410 per_page: 5,
411 };
412 let pipelines = client(&server).list("acme", "widget", &filters).unwrap();
413 m.assert();
414 assert_eq!(pipelines.len(), 1);
415 assert_eq!(pipelines[0].id, "42");
416 assert_eq!(pipelines[0].status, "failed");
417 }
418
419 #[test]
420 fn list_jobs_filters_by_normalized_status_client_side() {
421 let server = MockServer::start();
422 server.mock(|when, then| {
423 when.method(GET)
424 .path("/projects/acme%2Fwidget/pipelines/99/jobs")
425 .header("Authorization", "Bearer test-token");
426 then.status(200).json_body(serde_json::json!([
427 { "id": 1u64, "status": "failed", "name": "a" },
428 { "id": 2u64, "status": "success", "name": "b" }
429 ]));
430 });
431 let c = client(&server);
432 let failed = c.list_jobs("acme", "widget", "99", Some("failed")).unwrap();
433 assert_eq!(failed.len(), 1);
434 assert_eq!(failed[0].id, "1");
435 let all = c.list_jobs("acme", "widget", "99", None).unwrap();
436 assert_eq!(all.len(), 2);
437 }
438
439 #[test]
440 fn cancel_posts_with_bearer_auth() {
441 let server = MockServer::start();
442 let m = server.mock(|when, then| {
443 when.method(POST)
444 .path("/projects/acme%2Fwidget/pipelines/9/cancel")
445 .header("Authorization", "Bearer test-token");
446 then.status(200);
447 });
448 client(&server).cancel("acme", "widget", "9").unwrap();
449 m.assert();
450 }
451
452 #[test]
453 fn job_log_returns_raw_trace_text() {
454 let server = MockServer::start();
455 server.mock(|when, then| {
456 when.method(GET)
457 .path("/projects/acme%2Fwidget/jobs/555/trace");
458 then.status(200).body("line one\nline two");
459 });
460 let log = client(&server).job_log("acme", "widget", "555").unwrap();
461 assert_eq!(log, "line one\nline two");
462 }
463
464 #[test]
465 fn retry_non_2xx_maps_to_platform_api_error() {
466 let server = MockServer::start();
467 server.mock(|when, then| {
468 when.method(POST)
469 .path("/projects/acme%2Fwidget/pipelines/9/retry");
470 then.status(403)
471 .json_body(serde_json::json!({ "message": "403 Forbidden" }));
472 });
473 let err = client(&server).retry("acme", "widget", "9").unwrap_err();
474 assert!(
475 matches!(err, ToriiError::PlatformApi { .. }),
476 "expected PlatformApi, got: {err:?}"
477 );
478 }
479}