Skip to main content

torii_lib/platforms/gitlab/
pipeline.rs

1//! GitLab — pipeline client.
2
3use 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    /// Kept for parity with the other platform clients — the factory
14    /// constructs GitLab via `new_with_base_url` (self-hosted support).
15    #[allow(dead_code)]
16    pub fn new() -> Result<Self> {
17        Self::new_with_base_url(None)
18    }
19
20    /// 0.8.0 — construct against a custom GitLab API base URL
21    /// (self-hosted instances declared in `platforms.toml`). `None`
22    /// falls back to `GITLAB_URL` env or `https://gitlab.com/api/v4`.
23    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    // ---- job ops on GitLab Pipelines ----
123
124    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        // GitLab supports `?scope[]=failed&scope[]=success` for server-side
132        // filtering, but a single client-side filter is simpler and
133        // doesn't risk an empty array because of a typo in the scope name.
134        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        // `/jobs/:id/trace` returns the raw text log directly (no JSON
159        // wrapping), so we use `.text()` instead of `.json()`.
160        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// ============================================================================
310// Factory + helpers
311// ============================================================================
312
313// ============================================================================
314// Gitea Actions (runs)
315// ============================================================================
316//
317// Gitea Actions is a GitHub-Actions-compatible runner introduced in
318// Gitea 1.19+ / Forgejo (the Codeberg fork). The public REST endpoints
319// at `/api/v1/repos/{owner}/{repo}/actions/runs` mirror GitHub's
320// shape; status enum follows the same `success/failure/in_progress`
321// convention. Older Gitea instances may 404 on these endpoints — we
322// surface the platform error rather than guessing.
323
324#[cfg(test)]
325mod tests {
326    // NOTE: `parse_gitlab_pipeline` is already covered by the tests in
327    // `src/platforms/pipeline.rs` — only the job parser and the HTTP
328    // client are tested here.
329    use super::*;
330    use httpmock::prelude::*;
331
332    // ── parser (jobs) ────────────────────────────────────────────────────
333
334    #[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    // ── client (httpmock) ────────────────────────────────────────────────
386
387    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}